diff --git a/Cargo.lock b/Cargo.lock index f1b1f7e..586f6bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -99,6 +114,7 @@ dependencies = [ "insta", "log", "pdf-inspector", + "png", "quick-xml", "sha2 0.11.0", "zip", @@ -134,6 +150,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atoi_simd" version = "0.18.1" @@ -189,18 +211,65 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "calamine" version = "0.36.1" @@ -297,6 +366,15 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -566,6 +644,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fast-float2" version = "0.2.3" @@ -578,6 +665,21 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -601,6 +703,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "futures" version = "0.3.33" @@ -713,11 +830,130 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa", + "smallvec", + "vello_common 0.0.9", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags 2.13.1", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf", + "rustc-hash", + "siphasher", + "skrifa", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash", + "smallvec", + "zune-jpeg", +] [[package]] name = "heck" @@ -758,6 +994,19 @@ dependencies = [ "cc", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -885,6 +1134,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "libc" version = "0.2.189" @@ -901,6 +1162,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -970,6 +1237,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "napi" version = "3.12.0" @@ -1071,11 +1348,12 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "pdf-inspector" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd2f755e49ad38eafbc82ba2bec1c59d57b5bf829b13a8f35e4263bc8913df3a" +version = "1.14.1" +source = "git+https://github.com/qin-ctx/pdf-inspector?rev=0d3ab538de89125109b501c9a7a6c320fcf9e338#0d3ab538de89125109b501c9a7a6c320fcf9e338" dependencies = [ + "bytemuck", "env_logger", + "hayro", "include_dir", "log", "lopdf", @@ -1087,12 +1365,99 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pic-scale" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c80d88d5c31215ceec2862abb2504860c715b8e2545a5ab15b62a3d097f30d" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -1123,6 +1488,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "pyo3" version = "0.29.1" @@ -1248,6 +1619,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "regex" version = "1.13.1" @@ -1405,12 +1786,40 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "stringprep" version = "0.1.5" @@ -1444,6 +1853,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1595,6 +2015,53 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown", + "png", + "vello_common 0.0.8", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1730,6 +2197,50 @@ dependencies = [ "windows-link", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zip" version = "8.6.0" @@ -1761,3 +2272,18 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 97d57d0..5a4f960 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,10 +28,15 @@ csv = "1.4.0" flate2 = "1" encoding_rs = "0.8.35" log = "0.4" -pdf-inspector = "0.1.8" +pdf-inspector = { git = "https://github.com/qin-ctx/pdf-inspector", rev = "0d3ab538de89125109b501c9a7a6c320fcf9e338" } +png = { version = "0.18", optional = true } quick-xml = "0.41.0" zip = { version = "8.6.0", default-features = false, features = ["deflate"] } +[features] +default = [] +pdf-images = ["pdf-inspector/render", "dep:png"] + [profile.release] lto = "thin" strip = "symbols" diff --git a/README.md b/README.md index ed4cb9e..e371449 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,33 @@ let markdown = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Csv)?; let document = anydoc::to_document(&bytes, None)?; ``` +### PDF images + +The native bindings can return each positioned PDF image as PNG bytes. The +Markdown target is already the image's file name, so consumers only need to +save the returned bytes under that name: + +```python +from pathlib import Path +import anydoc + +result = anydoc.pdf_to_markdown_with_images(Path("report.pdf").read_bytes()) +for image in result.images: + Path(image.filename).write_bytes(image.data) + +Path("report.md").write_text(result.markdown) +# Markdown contains, for example: ![Image: Im0](p1_i1.png) +``` + +The result also reports one-based OCR pages and reasons, table/column pages, +layout complexity, and font-encoding issues from the same PDF pass. + +The same API is `pdfToMarkdownWithImages` in Node.js and WebAssembly. Rust +callers enable the `pdf-images` feature and use +`anydoc::pdf_to_markdown_with_images`. The feature currently requires Rust +1.92 because of the optional renderer; the default AnyDoc build remains on +its Rust 1.88 minimum. + ## Features - **One output for every format.** Each format parses into a shared document model and renders through a single Markdown serializer, so escaping, tables, heading anchors, and footnotes behave identically whether the input was a `.doc` from 2003 or a `.pptx` from yesterday. @@ -133,7 +160,7 @@ let document = anydoc::to_document(&bytes, None)?; - **Content-based format detection.** The format is read from the bytes themselves (PDF header, RTF open group, OLE stream names, ZIP package mimetype), so mislabeled files still convert correctly. - **Fast.** Pure Rust, no ML models, no external services. Median conversion time is under 5ms per document. - **Bindings that stay out of the way.** Node.js conversion runs on the libuv thread pool and never blocks the event loop; Python releases the GIL so other threads keep running. TypeScript types and Python stubs ship with the packages. -- **PDF support built in.** Text-based PDFs convert locally through [pdf-inspector](https://github.com/firecrawl/pdf-inspector), no OCR service required. +- **PDF support built in.** Text-based PDFs convert locally through [pdf-inspector](https://github.com/firecrawl/pdf-inspector), with an opt-in API for positioned PNG images and no OCR service required. - **Agent ready.** Ships as an [Agent Skill](#agent-skill): one `npx skills add firecrawl/anydoc` and any agent can read office documents. ## Supported formats diff --git a/node/Cargo.toml b/node/Cargo.toml index 406ef42..c107f7c 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -11,7 +11,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -anydoc = { path = ".." } +anydoc = { path = "..", features = ["pdf-images"] } napi = { version = "3", default-features = false, features = ["napi4"] } napi-derive = "3" diff --git a/node/README.md b/node/README.md index 5263150..7d512d4 100644 --- a/node/README.md +++ b/node/README.md @@ -39,7 +39,12 @@ Markdown goes to stdout, errors to stderr, and `anydoc --help` covers the rest. ## Usage ```js -import { toDocument, toMarkdown, toMarkdownBytes } from '@firecrawl/anydoc'; +import { + pdfToMarkdownWithImages, + toDocument, + toMarkdown, + toMarkdownBytes, +} from '@firecrawl/anydoc'; // From a file path: const markdown = await toMarkdown('report.docx'); @@ -52,6 +57,10 @@ const fromCsv = await toMarkdownBytes(bytes, 'csv'); // Or stop at the document model, which also carries embedded assets: const document = await toDocument(bytes); + +// PDF Markdown plus positioned PNG files. Each Markdown image target is the +// matching image.filename. +const pdf = await pdfToMarkdownWithImages(pdfBytes); ``` ## Errors @@ -94,7 +103,7 @@ formatFromPath('report.odt'); // 'odt' ## Images and embedded objects -Markdown cannot embed bytes, so an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. Images that carry an external URL render as ordinary Markdown images. +For office documents, an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. PDF images are available through `pdfToMarkdownWithImages` as rendered PNG bytes whose `filename` matches the Markdown target. Images that carry an external URL render as ordinary Markdown images. Full behavior notes and benchmarks live in the [repository README](https://github.com/firecrawl/anydoc#readme). diff --git a/node/index.d.ts b/node/index.d.ts index 27ceba0..372ded6 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -235,6 +235,43 @@ export declare const enum NoteKind { endnote = 'endnote' } +export interface PdfConversion { + markdown: string + images: Array + pageCount: number + pagesNeedingOcr: Array + ocrReasonsByPage: Array + pagesWithTables: Array + pagesWithColumns: Array + isComplexLayout: boolean + hasEncodingIssues: boolean +} + +export interface PdfImage { + filename: string + /** One-based source page number. */ + page: number + /** Source placement as [x, y, width, height] in PDF points. */ + bbox: Array + width: number + height: number + /** Complete PNG file bytes. */ + data: Buffer + warnings: Array +} + +export interface PdfOcrReasons { + /** One-based source page number. */ + page: number + reasons: Array +} + +/** + * Convert PDF bytes to Markdown and return the PNG data for each positioned + * image. An image's `filename` is the exact target used in `markdown`. + */ +export declare function pdfToMarkdownWithImages(bytes: Uint8Array): Promise + /** Fully resolved character style. */ export interface Style { bold: boolean diff --git a/node/index.js b/node/index.js index ca22b8b..8550834 100644 --- a/node/index.js +++ b/node/index.js @@ -711,6 +711,7 @@ module.exports.InlineKind = nativeBinding.InlineKind module.exports.LinkTargetKind = nativeBinding.LinkTargetKind module.exports.MarkerKind = nativeBinding.MarkerKind module.exports.NoteKind = nativeBinding.NoteKind +module.exports.pdfToMarkdownWithImages = nativeBinding.pdfToMarkdownWithImages module.exports.TableKind = nativeBinding.TableKind module.exports.toDocument = nativeBinding.toDocument module.exports.toMarkdown = nativeBinding.toMarkdown diff --git a/node/src/lib.rs b/node/src/lib.rs index 01df83a..183b410 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -118,6 +118,79 @@ pub fn to_markdown_bytes( }) } +#[napi(object)] +pub struct PdfImage { + pub filename: String, + /// One-based source page number. + pub page: u32, + /// Source placement as [x, y, width, height] in PDF points. + pub bbox: Vec, + pub width: u32, + pub height: u32, + /// Complete PNG file bytes. + pub data: Buffer, + pub warnings: Vec, +} + +#[napi(object)] +pub struct PdfConversion { + pub markdown: String, + pub images: Vec, + pub page_count: u32, + pub pages_needing_ocr: Vec, + pub ocr_reasons_by_page: Vec, + pub pages_with_tables: Vec, + pub pages_with_columns: Vec, + pub is_complex_layout: bool, + pub has_encoding_issues: bool, +} + +#[napi(object)] +pub struct PdfOcrReasons { + /// One-based source page number. + pub page: u32, + pub reasons: Vec, +} + +impl From for PdfConversion { + fn from(conversion: anydoc::PdfConversion) -> Self { + Self { + markdown: conversion.markdown, + images: conversion + .images + .into_iter() + .map(|image| PdfImage { + filename: image.filename, + page: image.page, + bbox: image.bbox.into_iter().map(f64::from).collect(), + width: image.width, + height: image.height, + data: image.data.into(), + warnings: image.warnings, + }) + .collect(), + page_count: conversion.page_count, + pages_needing_ocr: conversion.pages_needing_ocr, + ocr_reasons_by_page: conversion + .ocr_reasons_by_page + .into_iter() + .map(|reason| PdfOcrReasons { page: reason.page, reasons: reason.reasons }) + .collect(), + pages_with_tables: conversion.pages_with_tables, + pages_with_columns: conversion.pages_with_columns, + is_complex_layout: conversion.is_complex_layout, + has_encoding_issues: conversion.has_encoding_issues, + } + } +} + +/// Convert PDF bytes to Markdown and return the PNG data for each positioned +/// image. An image's `filename` is the exact target used in `markdown`. +#[napi(ts_return_type = "Promise")] +pub fn pdf_to_markdown_with_images(bytes: Uint8Array) -> AsyncTask { + AsyncTask::new(PdfConversionTask { bytes: bytes.to_vec(), failure: Failure::default() }) +} + /// Parse an in-memory document into the document model, which also carries /// the embedded assets. Without a format, it is detected from the content. /// @@ -188,6 +261,28 @@ pub struct MarkdownBytesTask { failure: Failure, } +pub struct PdfConversionTask { + bytes: Vec, + failure: Failure, +} + +impl Task for PdfConversionTask { + type Output = anydoc::PdfConversion; + type JsValue = PdfConversion; + + fn compute(&mut self) -> Result { + anydoc::pdf_to_markdown_with_images(&self.bytes).map_err(|e| self.failure.capture(e)) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output.into()) + } + + fn reject(&mut self, env: Env, error: Error) -> Result { + Err(self.failure.reject(env, error)) + } +} + impl Task for MarkdownBytesTask { type Output = String; type JsValue = String; diff --git a/python/Cargo.toml b/python/Cargo.toml index 446925e..4600868 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -13,7 +13,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -anydoc = { path = ".." } +anydoc = { path = "..", features = ["pdf-images"] } # abi3-py310 pins the stable ABI: one wheel per platform covers every # CPython from 3.10 up. pyo3 = { version = "0.29", features = ["abi3-py310", "extension-module"] } diff --git a/python/README.md b/python/README.md index fa97971..cd2a403 100644 --- a/python/README.md +++ b/python/README.md @@ -44,6 +44,22 @@ markdown = anydoc.to_markdown_bytes(data, "csv") document = anydoc.to_document(data) ``` +For a PDF, `pdf_to_markdown_with_images` returns positioned PNG files as well +as Markdown. Every Markdown image target equals one returned `filename`: + +```python +from pathlib import Path + +result = anydoc.pdf_to_markdown_with_images(Path("report.pdf").read_bytes()) +for image in result.images: + Path(image.filename).write_bytes(image.data) +Path("report.md").write_text(result.markdown) +``` + +`result.pages_needing_ocr` and `result.ocr_reasons_by_page` identify pages +whose extracted text is incomplete; table, column, layout, and encoding +diagnostics are returned alongside them. + ## Errors A conversion raises only when no meaningful Markdown could come out of the file. The exception type names what went wrong: @@ -80,7 +96,7 @@ anydoc.format_from_path("report.odt") # 'odt' ## Images and embedded objects -Markdown cannot embed bytes, so an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. Images that carry an external URL render as ordinary Markdown images. +For office documents, an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. PDF images are available through `pdf_to_markdown_with_images` as rendered PNG bytes whose `filename` matches the Markdown target. Images that carry an external URL render as ordinary Markdown images. Full behavior notes and benchmarks live in the [repository README](https://github.com/firecrawl/anydoc#readme). diff --git a/python/anydoc/__init__.py b/python/anydoc/__init__.py index f7c01d7..9526bc9 100644 --- a/python/anydoc/__init__.py +++ b/python/anydoc/__init__.py @@ -18,6 +18,9 @@ MalformedError, MissingPartError, Note, + PdfConversion, + PdfImage, + PdfOcrReasons, ResourceLimitError, Style, Table, @@ -25,6 +28,7 @@ format_from_bytes, format_from_extension, format_from_path, + pdf_to_markdown_with_images, to_document, to_markdown, to_markdown_bytes, @@ -54,6 +58,9 @@ "MalformedError", "MissingPartError", "Note", + "PdfConversion", + "PdfImage", + "PdfOcrReasons", "ResourceLimitError", "Style", "Table", @@ -61,6 +68,7 @@ "format_from_bytes", "format_from_extension", "format_from_path", + "pdf_to_markdown_with_images", "to_document", "to_markdown", "to_markdown_bytes", diff --git a/python/anydoc/_anydoc.pyi b/python/anydoc/_anydoc.pyi index 19dadba..6858a4e 100644 --- a/python/anydoc/_anydoc.pyi +++ b/python/anydoc/_anydoc.pyi @@ -63,6 +63,43 @@ def to_markdown_bytes(data: bytes | bytearray, format: Format | None = None) -> detected from the content, which signature-less formats (CSV) have to name explicitly.""" +def pdf_to_markdown_with_images(data: bytes | bytearray) -> PdfConversion: + """Convert PDF bytes to Markdown and return the PNG data for each + positioned image. A `PdfImage.filename` is the exact target used in + `PdfConversion.markdown`.""" + +@final +class PdfConversion: + markdown: str + images: list[PdfImage] + page_count: int + pages_needing_ocr: list[int] + ocr_reasons_by_page: list[PdfOcrReasons] + pages_with_tables: list[int] + pages_with_columns: list[int] + is_complex_layout: bool + has_encoding_issues: bool + +@final +class PdfOcrReasons: + page: int + """One-based source page number.""" + reasons: list[str] + +@final +class PdfImage: + filename: str + """File name used by the corresponding Markdown image.""" + page: int + """One-based source page number.""" + bbox: tuple[float, float, float, float] + """Source placement as `(x, y, width, height)` in PDF points.""" + width: int + height: int + data: bytes + """Complete PNG file bytes.""" + warnings: list[str] + def to_document(data: bytes | bytearray, format: Format | None = None) -> Document: """Parse an in-memory document into the document model, which also carries the embedded assets. Without a format, it is detected from the diff --git a/python/src/lib.rs b/python/src/lib.rs index abcbbd6..5c07fef 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use pyo3::create_exception; use pyo3::exceptions::{PyException, PyValueError}; use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyList}; mod document; @@ -164,6 +165,87 @@ fn to_markdown_bytes(py: Python<'_>, data: Vec, format: Option<&str>) -> PyR py.detach(|| anydoc::to_markdown_bytes(&data, format)).map_err(|e| convert_error(py, e)) } +/// One positioned PDF image encoded as PNG. +#[pyclass(frozen, get_all, module = "anydoc")] +struct PdfImage { + filename: String, + /// One-based source page number. + page: u32, + /// Source placement as (x, y, width, height) in PDF points. + bbox: (f32, f32, f32, f32), + width: u32, + height: u32, + data: Py, + warnings: Vec, +} + +/// PDF Markdown and the images referenced by it. +#[pyclass(frozen, get_all, module = "anydoc")] +struct PdfConversion { + markdown: String, + /// list[PdfImage] + images: Py, + page_count: u32, + pages_needing_ocr: Vec, + /// list[PdfOcrReasons] + ocr_reasons_by_page: Py, + pages_with_tables: Vec, + pages_with_columns: Vec, + is_complex_layout: bool, + has_encoding_issues: bool, +} + +/// OCR diagnostics for one PDF page. +#[pyclass(frozen, get_all, module = "anydoc")] +struct PdfOcrReasons { + page: u32, + reasons: Vec, +} + +/// Convert PDF bytes to Markdown and return the PNG data for each positioned +/// image. A `PdfImage.filename` is the exact target used in `markdown`. +#[pyfunction] +fn pdf_to_markdown_with_images(py: Python<'_>, data: Vec) -> PyResult { + let conversion = py + .detach(|| anydoc::pdf_to_markdown_with_images(&data)) + .map_err(|e| convert_error(py, e))?; + let images = conversion + .images + .into_iter() + .map(|image| { + let [x, y, width, height] = image.bbox; + Py::new( + py, + PdfImage { + filename: image.filename, + page: image.page, + bbox: (x, y, width, height), + width: image.width, + height: image.height, + data: PyBytes::new(py, &image.data).unbind(), + warnings: image.warnings, + }, + ) + }) + .collect::>>()?; + let ocr_reasons = conversion + .ocr_reasons_by_page + .into_iter() + .map(|reason| Py::new(py, PdfOcrReasons { page: reason.page, reasons: reason.reasons })) + .collect::>>()?; + Ok(PdfConversion { + markdown: conversion.markdown, + images: PyList::new(py, images)?.unbind(), + page_count: conversion.page_count, + pages_needing_ocr: conversion.pages_needing_ocr, + ocr_reasons_by_page: PyList::new(py, ocr_reasons)?.unbind(), + pages_with_tables: conversion.pages_with_tables, + pages_with_columns: conversion.pages_with_columns, + is_complex_layout: conversion.is_complex_layout, + has_encoding_issues: conversion.has_encoding_issues, + }) +} + /// Parse an in-memory document into the document model, which also carries /// the embedded assets. Without a format, it is detected from the content. /// @@ -190,6 +272,7 @@ fn _anydoc(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(format_from_path, m)?)?; m.add_function(wrap_pyfunction!(to_markdown, m)?)?; m.add_function(wrap_pyfunction!(to_markdown_bytes, m)?)?; + m.add_function(wrap_pyfunction!(pdf_to_markdown_with_images, m)?)?; m.add_function(wrap_pyfunction!(to_document, m)?)?; m.add_class::()?; m.add_class::()?; @@ -204,6 +287,9 @@ fn _anydoc(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add("ConvertError", m.py().get_type::())?; m.add("EncryptedError", m.py().get_type::())?; m.add("MalformedError", m.py().get_type::())?; diff --git a/src/formats/pdf.rs b/src/formats/pdf.rs index 74a36e1..1a1d8ce 100644 --- a/src/formats/pdf.rs +++ b/src/formats/pdf.rs @@ -5,14 +5,84 @@ //! image-only PDFs need OCR, which is out of scope here; they error as //! unsupported. Pages flagged for OCR in an otherwise text-based document //! degrade with a log, consistent with the crate-wide recovery policy. +//! The optional `pdf-images` feature can return positioned, rendered image +//! bytes together with matching file names in the Markdown image targets. //! //! [pdf-inspector]: https://github.com/firecrawl/pdf-inspector use crate::error::ConvertError; -use pdf_inspector::PdfError; +use pdf_inspector::{PdfError, PdfOptions, PdfProcessResult}; pub fn to_markdown(bytes: &[u8]) -> Result { - let result = pdf_inspector::process_pdf_mem(bytes).map_err(map_error)?; + let mut result = + pdf_inspector::process_pdf_mem_with_options(bytes, PdfOptions::new()).map_err(map_error)?; + take_markdown(&mut result) +} + +#[cfg(feature = "pdf-images")] +pub fn to_markdown_with_images(bytes: &[u8]) -> Result { + use pdf_inspector::{MarkdownOptions, RenderOptions}; + + let markdown_options = MarkdownOptions { include_images: true, ..MarkdownOptions::default() }; + let mut result = pdf_inspector::process_pdf_mem_with_options( + bytes, + PdfOptions::new().markdown(markdown_options), + ) + .map_err(map_error)?; + let mut markdown = take_markdown(&mut result)?; + let mut total_bytes = 0_usize; + let extracted = + pdf_inspector::extract_images_mem(bytes, RenderOptions::new()).map_err(map_render_error)?; + let mut images = Vec::with_capacity(extracted.len()); + for image in extracted { + let markdown_target = format!("]({})", image.reference); + if !markdown.contains(&markdown_target) { + continue; + } + + let filename = format!("p{}_i{}.png", image.page + 1, image.occurrence); + markdown = markdown.replacen(&markdown_target, &format!("]({filename})"), 1); + let data = encode_png(image.width, image.height, &image.pixels)?; + total_bytes = + total_bytes.checked_add(data.len()).ok_or_else(|| ConvertError::ResourceLimit { + limit: "max_asset_total_bytes", + detail: "PDF image assets exceed the retained-bytes cap".into(), + })?; + if total_bytes > crate::package::limits::MAX_ASSET_TOTAL_BYTES { + return Err(ConvertError::ResourceLimit { + limit: "max_asset_total_bytes", + detail: "PDF image assets exceed the retained-bytes cap".into(), + }); + } + images.push(crate::PdfImage { + filename, + page: image.page + 1, + bbox: image.bbox, + width: image.width, + height: image.height, + data, + warnings: image.warnings.into_iter().map(|warning| warning.code().to_owned()).collect(), + }); + } + + Ok(crate::PdfConversion { + markdown, + images, + page_count: result.page_count, + pages_needing_ocr: result.pages_needing_ocr, + ocr_reasons_by_page: result + .ocr_reasons_by_page + .into_iter() + .map(|reason| crate::PdfOcrReasons { page: reason.page, reasons: reason.reasons }) + .collect(), + pages_with_tables: result.layout.pages_with_tables, + pages_with_columns: result.layout.pages_with_columns, + is_complex_layout: result.layout.is_complex, + has_encoding_issues: result.has_encoding_issues, + }) +} + +fn take_markdown(result: &mut PdfProcessResult) -> Result { if !result.pages_needing_ocr.is_empty() { log::warn!( "{} of {} pages need OCR and were not extracted", @@ -23,7 +93,7 @@ pub fn to_markdown(bytes: &[u8]) -> Result { if result.has_encoding_issues { log::warn!("broken font encodings detected; extracted text may be garbled"); } - match result.markdown { + match result.markdown.take() { Some(mut markdown) if !markdown.trim().is_empty() => { if !markdown.ends_with('\n') { markdown.push('\n'); @@ -37,6 +107,40 @@ pub fn to_markdown(bytes: &[u8]) -> Result { } } +#[cfg(feature = "pdf-images")] +fn encode_png(width: u32, height: u32, pixels: &[u8]) -> Result, ConvertError> { + let mut data = Vec::new(); + let mut encoder = png::Encoder::new(&mut data, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder + .write_header() + .map_err(|error| ConvertError::malformed(format!("cannot encode PDF image: {error}")))?; + writer + .write_image_data(pixels) + .map_err(|error| ConvertError::malformed(format!("cannot encode PDF image: {error}")))?; + writer + .finish() + .map_err(|error| ConvertError::malformed(format!("cannot encode PDF image: {error}")))?; + Ok(data) +} + +#[cfg(feature = "pdf-images")] +fn map_render_error(error: pdf_inspector::RenderError) -> ConvertError { + use pdf_inspector::RenderError; + + match error { + RenderError::Encrypted => ConvertError::Encrypted, + RenderError::PageDimensionsTooLarge { .. } + | RenderError::PagePixelsTooLarge { .. } + | RenderError::OutputTooLarge { .. } + | RenderError::TooManyPages { .. } => { + ConvertError::ResourceLimit { limit: "max_pdf_image_output", detail: error.to_string() } + } + _ => ConvertError::malformed(error.to_string()), + } +} + fn map_error(e: PdfError) -> ConvertError { match e { PdfError::Encrypted => ConvertError::Encrypted, diff --git a/src/lib.rs b/src/lib.rs index efba6ff..424dbcc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,64 @@ use render::markdown::document_to_markdown; use std::path::Path; +/// A PDF converted to Markdown together with every image referenced by that +/// Markdown. +#[cfg(feature = "pdf-images")] +#[derive(Debug)] +pub struct PdfConversion { + /// GitHub-Flavored Markdown. Each embedded image target is the + /// [`PdfImage::filename`] of one entry in [`images`](Self::images). + pub markdown: String, + /// Positioned images in PDF content-stream order. + pub images: Vec, + /// Number of pages in the source PDF. + pub page_count: u32, + /// One-based page numbers whose text requires OCR. + pub pages_needing_ocr: Vec, + /// Machine-readable OCR reasons grouped by one-based page number. + pub ocr_reasons_by_page: Vec, + /// One-based pages where tables were detected. + pub pages_with_tables: Vec, + /// One-based pages where multiple text columns were detected. + pub pages_with_columns: Vec, + /// Whether the PDF contains tables or multi-column text. + pub is_complex_layout: bool, + /// Whether broken font encodings were detected. + pub has_encoding_issues: bool, +} + +/// OCR diagnostics for one PDF page. +#[cfg(feature = "pdf-images")] +#[derive(Debug)] +pub struct PdfOcrReasons { + /// One-based source page number. + pub page: u32, + /// Machine-readable reason identifiers. + pub reasons: Vec, +} + +/// One positioned PDF image encoded as PNG. +#[cfg(feature = "pdf-images")] +#[derive(Debug)] +pub struct PdfImage { + /// File name used by the corresponding Markdown image, for example + /// `p1_i1.png`. + pub filename: String, + /// One-based source page number. + pub page: u32, + /// Source placement as `[x, y, width, height]` in PDF points, with the + /// origin at the bottom left. + pub bbox: [f32; 4], + /// PNG width in pixels. + pub width: u32, + /// PNG height in pixels. + pub height: u32, + /// Complete `image/png` file bytes. + pub data: Vec, + /// Non-fatal renderer warnings for the containing page. + pub warnings: Vec, +} + /// Input format. Selects the parser; container variants that share a parser /// (docm, xlsm, ...) map onto these via [`Format::from_bytes`] or /// [`Format::from_extension`]. @@ -125,6 +183,17 @@ pub fn to_markdown_bytes( Ok(document_to_markdown(&to_document(bytes, format)?)) } +/// Convert PDF bytes to Markdown and return the rendered image bytes referred +/// to by that Markdown. +/// +/// This API is available with the `pdf-images` feature. It remains separate +/// from [`to_document`] because PDF conversion is produced directly by +/// pdf-inspector and has no AnyDoc document-model form. +#[cfg(feature = "pdf-images")] +pub fn pdf_to_markdown_with_images(bytes: &[u8]) -> Result { + formats::pdf::to_markdown_with_images(bytes) +} + /// Parse an in-memory document into the document model. Pass a [`Format`] to /// select the parser, or `None` to detect it from the content. /// diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 477a1ad..c45d9c8 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -48,6 +48,13 @@ fn corpus() { } let name = rel.to_string_lossy().replace(['\\', '/'], "__"); let output = convert(&path); + #[cfg(feature = "pdf-images")] + if rel == Path::new("pdf/text.pdf") { + let pdf = anydoc::pdf_to_markdown_with_images(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(pdf.images.len(), 1); + assert!(pdf.markdown.contains(&pdf.images[0].filename)); + assert_eq!(&pdf.images[0].data[..8], b"\x89PNG\r\n\x1a\n"); + } // Once the unified recovery policy exists (P1), annotated malformed // fixtures must match their single expected outcome. if let Some(outcome) = expected_outcome(&path) { diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 9ea8841..ca5cc4c 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -20,7 +20,7 @@ crate-type = ["cdylib", "rlib"] wasm-opt = ["-O", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] [dependencies] -anydoc = { path = ".." } +anydoc = { path = "..", features = ["pdf-images"] } js-sys = "0.3" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" diff --git a/wasm/README.md b/wasm/README.md index a2e0dfd..4602f96 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -11,6 +11,7 @@ npm install @firecrawl/anydoc-wasm ```js import init, { formatFromBytes, + pdfToMarkdownWithImages, toMarkdownBytes, toDocument, } from '@firecrawl/anydoc-wasm'; @@ -26,6 +27,10 @@ const fromCsv = toMarkdownBytes(bytes, 'csv'); // Or stop at the document model, which also carries embedded assets: const document = toDocument(bytes); +// PDF Markdown plus positioned PNG files. Each Markdown image target is the +// matching image.filename. +const pdf = pdfToMarkdownWithImages(pdfBytes); + // Format detection on its own: formatFromBytes(bytes); // 'docx', or undefined when nothing matches ``` diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 5a710a5..39d23bf 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -103,6 +103,83 @@ pub fn to_markdown_bytes(bytes: &[u8], format: Option) -> Result Result { + let conversion = anydoc::pdf_to_markdown_with_images(bytes).map_err(convert_error)?; + serde_wasm_bindgen::to_value(&PdfConversion::from(conversion)) + .map_err(|error| js_sys::Error::new(&error.to_string()).into()) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PdfConversion { + markdown: String, + images: Vec, + page_count: u32, + pages_needing_ocr: Vec, + ocr_reasons_by_page: Vec, + pages_with_tables: Vec, + pages_with_columns: Vec, + is_complex_layout: bool, + has_encoding_issues: bool, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PdfImage { + filename: String, + page: u32, + bbox: [f32; 4], + width: u32, + height: u32, + #[serde(with = "serde_bytes")] + data: Vec, + warnings: Vec, +} + +#[derive(serde::Serialize)] +struct PdfOcrReasons { + page: u32, + reasons: Vec, +} + +impl From for PdfConversion { + fn from(conversion: anydoc::PdfConversion) -> Self { + Self { + markdown: conversion.markdown, + images: conversion + .images + .into_iter() + .map(|image| PdfImage { + filename: image.filename, + page: image.page, + bbox: image.bbox, + width: image.width, + height: image.height, + data: image.data, + warnings: image.warnings, + }) + .collect(), + page_count: conversion.page_count, + pages_needing_ocr: conversion.pages_needing_ocr, + ocr_reasons_by_page: conversion + .ocr_reasons_by_page + .into_iter() + .map(|reason| PdfOcrReasons { page: reason.page, reasons: reason.reasons }) + .collect(), + pages_with_tables: conversion.pages_with_tables, + pages_with_columns: conversion.pages_with_columns, + is_complex_layout: conversion.is_complex_layout, + has_encoding_issues: conversion.has_encoding_issues, + } + } +} + /// Parse an in-memory document into the document model, which also carries /// the embedded assets. Without a format, it is detected from the content. /// diff --git a/wasm/src/typescript.rs b/wasm/src/typescript.rs index 9a5cba6..afd7496 100644 --- a/wasm/src/typescript.rs +++ b/wasm/src/typescript.rs @@ -37,6 +37,38 @@ export interface Document { assets: Array } +export interface PdfConversion { + markdown: string + images: Array + pageCount: number + pagesNeedingOcr: Array + ocrReasonsByPage: Array + pagesWithTables: Array + pagesWithColumns: Array + isComplexLayout: boolean + hasEncodingIssues: boolean +} + +export interface PdfOcrReasons { + /** One-based source page number. */ + page: number + reasons: Array +} + +export interface PdfImage { + /** File name used by the corresponding Markdown image. */ + filename: string + /** One-based source page number. */ + page: number + /** Source placement as [x, y, width, height] in PDF points. */ + bbox: [number, number, number, number] + width: number + height: number + /** Complete PNG file bytes. */ + data: Uint8Array + warnings: Array +} + export type BlockKind = | 'heading' | 'paragraph'