diff --git a/Cargo.lock b/Cargo.lock index 74d178e..099e152 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "anstream" version = "0.6.21" @@ -84,6 +102,23 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -96,12 +131,70 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -114,6 +207,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -123,6 +225,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.20.2" @@ -149,6 +257,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bzip2" version = "0.6.1" @@ -226,6 +340,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -238,6 +358,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -265,6 +394,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -301,6 +461,32 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -316,6 +502,41 @@ dependencies = [ "num-traits", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -417,6 +638,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -453,6 +695,46 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "2.13.0" @@ -474,12 +756,32 @@ dependencies = [ "generic-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -523,6 +825,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -535,6 +843,16 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -547,6 +865,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lzma-rust2" version = "0.16.2" @@ -556,6 +883,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.0" @@ -581,12 +918,84 @@ 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 = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -608,6 +1017,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -649,6 +1070,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -661,6 +1095,15 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -680,6 +1123,46 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -704,6 +1187,105 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rdocx" version = "0.1.2" @@ -739,6 +1321,7 @@ name = "rdocx-layout" version = "0.1.2" dependencies = [ "fontdb", + "image", "rdocx-oxml", "rustybuzz", "thiserror", @@ -816,6 +1399,12 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "roxmltree" version = "0.20.0" @@ -940,6 +1529,15 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "skrifa" version = "0.37.0" @@ -965,6 +1563,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strict-num" version = "0.1.1" @@ -1026,6 +1630,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -1057,7 +1675,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png", + "png 0.17.16", "tiny-skia-path", ] @@ -1156,6 +1774,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1259,6 +1888,12 @@ dependencies = [ "semver", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "windows-link" version = "0.2.1" @@ -1375,6 +2010,32 @@ dependencies = [ "read-fonts", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zeroize" version = "1.8.2" @@ -1473,3 +2134,27 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +dependencies = [ + "zune-core", +] diff --git a/crates/rdocx-layout/Cargo.toml b/crates/rdocx-layout/Cargo.toml index 5eee7f7..001d235 100644 --- a/crates/rdocx-layout/Cargo.toml +++ b/crates/rdocx-layout/Cargo.toml @@ -21,3 +21,4 @@ fontdb = { workspace = true } rustybuzz = { workspace = true } ttf-parser = { workspace = true } unicode-linebreak = { workspace = true } +image = { workspace = true } diff --git a/crates/rdocx-layout/src/block.rs b/crates/rdocx-layout/src/block.rs index 25e1748..feed2ae 100644 --- a/crates/rdocx-layout/src/block.rs +++ b/crates/rdocx-layout/src/block.rs @@ -1,9 +1,12 @@ //! Block-level layout: paragraphs and tables as positioned blocks. use rdocx_oxml::borders::CT_PBdr; +use rdocx_oxml::drawing::{ + AnchorAlignH, AnchorAlignV, ST_RelativeFromH, ST_RelativeFromV, WrapType, +}; use rdocx_oxml::shared::ST_Jc; -use crate::line::LayoutLine; +use crate::line::{InlineItem, LayoutLine, LineBreakParams, TextSegment}; use crate::output::Color; use crate::table::TableBlock; @@ -74,6 +77,41 @@ impl LayoutBlock { } } +/// A dropped capital rendered alongside the opening lines of a paragraph. +#[derive(Debug, Clone)] +pub struct DropCap { + /// The shaped initial glyph run. + pub segment: TextSegment, + /// Number of text lines wrapped around the drop cap. + pub line_count: usize, + /// Extra gap between the drop cap and wrapped text. + pub padding_right: f64, +} + +/// A floating image anchored to a paragraph. +#[derive(Debug, Clone)] +pub struct AnchoredImage { + pub behind_doc: bool, + pub width: f64, + pub height: f64, + /// Horizontal extent from the image's left edge to the right-most visible pixel. + pub wrap_left_extent: f64, + /// Horizontal extent from the image's right edge to the left-most visible pixel. + pub wrap_right_extent: f64, + pub embed_id: String, + pub wrap: WrapType, + pub dist_top: f64, + pub dist_bottom: f64, + pub dist_left: f64, + pub dist_right: f64, + pub pos_h_offset: f64, + pub pos_h_relative_from: ST_RelativeFromH, + pub pos_h_align: Option, + pub pos_v_offset: f64, + pub pos_v_relative_from: ST_RelativeFromV, + pub pos_v_align: Option, +} + /// A laid-out paragraph with its lines and spacing. #[derive(Debug, Clone)] pub struct ParagraphBlock { @@ -105,12 +143,24 @@ pub struct ParagraphBlock { pub heading_level: Option, /// Heading text for outline generation. pub heading_text: Option, + /// Optional dropped capital rendered alongside the first lines. + pub drop_cap: Option, + /// Vertical offset applied before the first text line. + pub content_offset_top: f64, + /// Footnotes referenced by this paragraph and their reserved heights. + pub footnote_reserves: Vec<(i32, f64)>, + /// Floating images attached to this paragraph. + pub anchored_images: Vec, + /// Source inline items used to reflow anchored-image paragraphs at pagination time. + pub inline_items: Vec, + /// Base line-break parameters before page-position-dependent anchor wrapping is applied. + pub line_break_params: LineBreakParams, } impl ParagraphBlock { /// Total height of the paragraph lines (not including before/after spacing). pub fn content_height(&self) -> f64 { - self.lines.iter().map(|l| l.height).sum() + self.content_offset_top + self.lines.iter().map(|l| l.height).sum::() } /// Total height including spacing. @@ -138,6 +188,11 @@ pub fn build_paragraph_block( keep_lines: bool, page_break_before: bool, widow_control: bool, + drop_cap: Option, + footnote_reserves: Vec<(i32, f64)>, + anchored_images: Vec, + inline_items: Vec, + line_break_params: LineBreakParams, ) -> ParagraphBlock { ParagraphBlock { lines, @@ -154,6 +209,12 @@ pub fn build_paragraph_block( widow_control, heading_level: None, heading_text: None, + drop_cap, + content_offset_top: 0.0, + footnote_reserves, + anchored_images, + inline_items, + line_break_params, } } @@ -199,6 +260,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: LineBreakParams::default(), }; assert!((block.content_height() - 26.0).abs() < 0.01); assert!((block.total_height() - 40.0).abs() < 0.01); diff --git a/crates/rdocx-layout/src/engine.rs b/crates/rdocx-layout/src/engine.rs index c91535f..ce6845d 100644 --- a/crates/rdocx-layout/src/engine.rs +++ b/crates/rdocx-layout/src/engine.rs @@ -1,5 +1,6 @@ //! Layout engine orchestrator: ties all phases together. +use image::ImageReader; use rdocx_oxml::document::{BodyContent, CT_SectPr}; use rdocx_oxml::header_footer::HdrFtrType; use rdocx_oxml::properties::CT_PPr; @@ -7,7 +8,7 @@ use rdocx_oxml::shared::ST_HighlightColor; use rdocx_oxml::styles::CT_Styles; use rdocx_oxml::text::{BreakType, CT_P, FieldType, RunContent}; -use crate::block::{self, LayoutBlock, ParagraphBlock}; +use crate::block::{self, AnchoredImage, DropCap, LayoutBlock, ParagraphBlock}; use crate::error::Result; use crate::font::FontManager; use crate::input::LayoutInput; @@ -164,9 +165,6 @@ impl Engine { // Post-pagination pass: apply page background color apply_page_background(&mut pages, input); - // Post-pagination pass: resolve anchor (background) images - resolve_anchor_images(&mut pages, input); - // Post-pagination pass: resolve inline image data resolve_inline_images(&mut pages, input); @@ -252,74 +250,6 @@ fn extract_background_color(xml: &str) -> Option { None } -/// Resolve anchor (floating) images from the document and inject them into page frames. -/// -/// For `behind_doc=true` images: inserts at the START of page elements (renders underneath). -/// For `behind_doc=false` images: inserts at the END (renders on top). -fn resolve_anchor_images(pages: &mut [PageFrame], input: &LayoutInput) { - use crate::output::Rect; - use rdocx_oxml::text::RunContent; - - // Collect all anchor drawings from body content - let mut anchor_images: Vec<(bool, f64, f64, f64, f64, String)> = Vec::new(); - - for content in &input.document.body.content { - if let BodyContent::Paragraph(p) = content { - for run in &p.runs { - for rc in &run.content { - if let RunContent::Drawing(drawing) = rc - && let Some(ref anchor) = drawing.anchor - { - let behind = anchor.behind_doc; - // Convert EMU positions and extents to points - let x = anchor.pos_h_offset.to_pt(); - let y = anchor.pos_v_offset.to_pt(); - let w = anchor.extent_cx.to_pt(); - let h = anchor.extent_cy.to_pt(); - anchor_images.push((behind, x, y, w, h, anchor.embed_id.clone())); - } - } - } - } - } - - if anchor_images.is_empty() { - return; - } - - // For each anchor image, resolve image data and add to pages - for (behind, x, y, w, h, embed_id) in &anchor_images { - let (data, content_type) = if let Some(img) = input.images.get(embed_id) { - (img.data.clone(), img.content_type.clone()) - } else { - continue; - }; - - let element = PositionedElement::Image { - rect: Rect { - x: *x, - y: *y, - width: *w, - height: *h, - }, - data, - content_type, - embed_id: None, // Already resolved - }; - - if *behind { - // Behind-doc images go on the first page only - // (proper page association would require paragraph-to-page mapping) - if let Some(page) = pages.first_mut() { - page.elements.insert(0, element); - } - } else if let Some(page) = pages.first_mut() { - // Foreground anchor images go on the first page only - page.elements.push(element); - } - } -} - /// Resolve inline image data from input.images by embed_id. /// /// During pagination, inline images are created with empty data and an embed_id. @@ -494,11 +424,12 @@ fn render_page_footnotes( let indent = 12.0; // Indent after marker for line in &pb.lines { let line_baseline = cursor_y + line.ascent; + let mut x = geometry.margin_left + indent; for item in &line.items { if let LineItem::Text(seg) | LineItem::Marker(seg) = item { page.elements.push(PositionedElement::Text(GlyphRun { origin: Point { - x: geometry.margin_left + indent, + x, y: line_baseline - seg.baseline_offset, }, font_id: seg.font_id, @@ -512,6 +443,7 @@ fn render_page_footnotes( field_kind: None, footnote_id: None, })); + x += seg.width; } } cursor_y += line.height; @@ -594,6 +526,20 @@ pub fn layout_paragraph( // Convert runs to inline items let mut inline_items = Vec::new(); + let mut drop_cap = None; + let mut anchored_images = Vec::new(); + let drop_cap_line_count = effective_ppr + .frame_pr + .as_ref() + .and_then(|frame_pr| frame_pr.drop_cap.as_deref()) + .filter(|mode| matches!(*mode, "drop" | "margin")) + .map(|_| { + effective_ppr + .frame_pr + .as_ref() + .and_then(|frame_pr| frame_pr.lines) + .unwrap_or(2) as usize + }); // Handle numbering marker if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) { @@ -671,8 +617,12 @@ pub fn layout_paragraph( let resolved_rpr = style_resolver::resolve_run_properties(para_style_id, run_style_id, styles); - // Merge direct run properties + // Paragraph-level run properties apply to all runs in the paragraph + // unless the run overrides them explicitly. let mut effective_rpr = resolved_rpr; + if let Some(ref para_rpr) = effective_ppr.rpr { + effective_rpr.merge_from(para_rpr); + } if let Some(ref direct_rpr) = run.properties { effective_rpr.merge_from(direct_rpr); } @@ -739,6 +689,88 @@ pub fn layout_paragraph( continue; } + if drop_cap.is_none() + && let Some(line_count) = drop_cap_line_count + { + let mut chars = text.chars(); + if let Some(initial) = chars.next() { + let initial = initial.to_string(); + let mut shaped = fm.shape_text(font_id, &initial, font_size)?; + + if let Some(spacing) = effective_rpr.spacing { + let extra = spacing.to_pt(); + for advance in &mut shaped.advances { + *advance += extra; + } + shaped.width += extra * shaped.advances.len() as f64; + } + + drop_cap = Some(DropCap { + segment: TextSegment { + text: initial, + font_id, + font_size, + glyph_ids: shaped.glyph_ids, + advances: shaped.advances, + width: shaped.width, + ascent: metrics.ascent, + descent: metrics.descent, + color, + bold, + italic, + underline, + strike, + dstrike, + highlight, + baseline_offset, + hyperlink_url: None, + field_kind: None, + footnote_id: None, + }, + line_count, + padding_right: 4.0, + }); + + let remainder = chars.collect::(); + if remainder.is_empty() { + continue; + } + + let mut shaped = fm.shape_text(font_id, &remainder, font_size)?; + + if let Some(spacing) = effective_rpr.spacing { + let extra = spacing.to_pt(); + for advance in &mut shaped.advances { + *advance += extra; + } + shaped.width += extra * shaped.advances.len() as f64; + } + + inline_items.push(InlineItem::Text(TextSegment { + text: remainder, + font_id, + font_size, + glyph_ids: shaped.glyph_ids, + advances: shaped.advances, + width: shaped.width, + ascent: metrics.ascent, + descent: metrics.descent, + color, + bold, + italic, + underline, + strike, + dstrike, + highlight, + baseline_offset, + hyperlink_url: current_hyperlink_url.clone(), + field_kind: None, + footnote_id: None, + })); + continue; + } + } + let mut shaped = fm.shape_text(font_id, &text, font_size)?; // Apply character spacing from run properties (in twips) @@ -789,6 +821,28 @@ pub fn layout_paragraph( height, embed_id: inline.embed_id.clone(), }); + } else if let Some(ref anchor) = drawing.anchor { + let (wrap_left_extent, wrap_right_extent) = + anchored_image_wrap_extents(anchor.embed_id.as_str(), anchor.extent_cx.to_pt(), input); + anchored_images.push(AnchoredImage { + behind_doc: anchor.behind_doc, + width: anchor.extent_cx.to_pt(), + height: anchor.extent_cy.to_pt(), + wrap_left_extent, + wrap_right_extent, + embed_id: anchor.embed_id.clone(), + wrap: anchor.wrap, + dist_top: anchor.dist_t.to_pt(), + dist_bottom: anchor.dist_b.to_pt(), + dist_left: anchor.dist_l.to_pt(), + dist_right: anchor.dist_r.to_pt(), + pos_h_offset: anchor.pos_h_offset.to_pt(), + pos_h_relative_from: anchor.pos_h_relative_from, + pos_h_align: anchor.pos_h_align, + pos_v_offset: anchor.pos_v_offset.to_pt(), + pos_v_relative_from: anchor.pos_v_relative_from, + pos_v_align: anchor.pos_v_align, + }); } } RunContent::Field { field_type } => { @@ -856,6 +910,16 @@ pub fn layout_paragraph( } // Line breaking + let mut line_prefix_widths = Vec::new(); + if let Some(drop_cap) = &drop_cap { + if line_prefix_widths.len() < drop_cap.line_count { + line_prefix_widths.resize(drop_cap.line_count, 0.0); + } + for width in line_prefix_widths.iter_mut().take(drop_cap.line_count) { + *width += drop_cap.segment.width + drop_cap.padding_right; + } + } + let line_params = LineBreakParams { available_width, ind_left, @@ -866,9 +930,13 @@ pub fn layout_paragraph( line_spacing: effective_ppr.line_spacing, line_rule: effective_ppr.line_rule, jc, + line_prefix_widths, + line_suffix_widths: Vec::new(), }; let lines = line::break_into_lines(&inline_items, &line_params, fm)?; + let footnote_reserves = + collect_footnote_reserves(&inline_items, available_width, styles, input, fm, num_state); Ok(block::build_paragraph_block( lines, @@ -883,9 +951,106 @@ pub fn layout_paragraph( keep_lines, page_break_before, widow_control, + drop_cap, + footnote_reserves, + anchored_images, + inline_items, + line_params, )) } +fn anchored_image_wrap_extents(embed_id: &str, width: f64, input: &LayoutInput) -> (f64, f64) { + const WRAP_ALPHA_THRESHOLD: u8 = 32; + + let Some(image) = input.images.get(embed_id) else { + return (width, width); + }; + if image.content_type != "image/png" { + return (width, width); + } + let Ok(reader) = ImageReader::new(std::io::Cursor::new(&image.data)).with_guessed_format() else { + return (width, width); + }; + let Ok(decoded) = reader.decode() else { + return (width, width); + }; + let rgba = decoded.to_rgba8(); + let (img_width, img_height) = rgba.dimensions(); + if img_width == 0 || img_height == 0 { + return (width, width); + } + + let mut min_x = img_width; + let mut max_x = 0; + let mut found = false; + for y in 0..img_height { + for x in 0..img_width { + if rgba.get_pixel(x, y)[3] >= WRAP_ALPHA_THRESHOLD { + min_x = min_x.min(x); + max_x = max_x.max(x + 1); + found = true; + } + } + } + if !found { + return (width, width); + } + + let scale = width / img_width as f64; + (max_x as f64 * scale, (img_width - min_x) as f64 * scale) +} + +fn collect_footnote_reserves( + inline_items: &[InlineItem], + available_width: f64, + styles: &CT_Styles, + input: &LayoutInput, + fm: &mut FontManager, + num_state: &mut NumberingState, +) -> Vec<(i32, f64)> { + let mut footnote_ids = Vec::new(); + for item in inline_items { + if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item + && let Some(footnote_id) = seg.footnote_id + && !footnote_ids.contains(&footnote_id) + { + footnote_ids.push(footnote_id); + } + } + + let mut reserves = Vec::new(); + for footnote_id in footnote_ids { + let Some(footnote) = input + .footnotes + .as_ref() + .and_then(|footnotes| footnotes.get_by_id(footnote_id)) + .or_else(|| { + input + .endnotes + .as_ref() + .and_then(|endnotes| endnotes.get_by_id(footnote_id)) + }) + else { + continue; + }; + + let mut total_height = 0.0; + for paragraph in &footnote.paragraphs { + if let Ok(block) = + layout_paragraph(paragraph, available_width, styles, input, fm, num_state) + { + total_height += block.content_height(); + } + } + + if total_height > 0.0 { + reserves.push((footnote_id, total_height)); + } + } + + reserves +} + /// Merge direct paragraph properties (only fields explicitly set in the XML). fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) { // Don't merge style_id — that was already used for resolution @@ -937,6 +1102,13 @@ fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) { if direct.shading.is_some() { effective.shading = direct.shading.clone(); } + if direct.rpr.is_some() { + match (&mut effective.rpr, &direct.rpr) { + (Some(current), Some(incoming)) => current.merge_from(incoming), + (None, Some(incoming)) => effective.rpr = Some(incoming.clone()), + _ => {} + } + } if direct.num_id.is_some() { effective.num_id = direct.num_id; } diff --git a/crates/rdocx-layout/src/line.rs b/crates/rdocx-layout/src/line.rs index f13fd70..59ff91c 100644 --- a/crates/rdocx-layout/src/line.rs +++ b/crates/rdocx-layout/src/line.rs @@ -114,6 +114,7 @@ pub struct LayoutLine { } /// Parameters for line breaking. +#[derive(Debug, Clone)] pub struct LineBreakParams { /// Total available width (page width minus margins). pub available_width: f64, @@ -133,6 +134,10 @@ pub struct LineBreakParams { pub line_rule: Option, /// Paragraph justification. pub jc: Option, + /// Additional width excluded from the start of specific lines. + pub line_prefix_widths: Vec, + /// Additional width excluded from the end of specific lines. + pub line_suffix_widths: Vec, } impl Default for LineBreakParams { @@ -147,6 +152,8 @@ impl Default for LineBreakParams { line_spacing: None, line_rule: None, jc: None, + line_prefix_widths: Vec::new(), + line_suffix_widths: Vec::new(), } } } @@ -165,8 +172,8 @@ pub fn break_into_lines( ascent: 0.0, descent: 0.0, height: compute_line_height(0.0, 0.0, params), - indent_left: params.ind_left + params.ind_first_line, - available_width: params.available_width, + indent_left: line_indent(params, 0, true), + available_width: compute_line_width(params, 0, true), is_last: true, }]); } @@ -176,12 +183,8 @@ pub fn break_into_lines( let mut current_width: f64 = 0.0; let mut current_ascent: f64 = 0.0; let mut current_descent: f64 = 0.0; - let mut is_first_line = true; - - let first_line_width = compute_first_line_width(params); - let subsequent_line_width = compute_subsequent_line_width(params); - - let mut line_avail = first_line_width; + let mut line_index = 0usize; + let mut line_avail = compute_line_width(params, line_index, true); // Track the most recent font context for shaping tab leaders let mut font_ctx: Option<(FontId, f64)> = None; @@ -203,26 +206,21 @@ pub fn break_into_lines( if !current_items.is_empty() && current_width + seg_width > line_avail + 0.01 { // Finish current line - let indent = if is_first_line { - first_line_indent(params) - } else { - subsequent_line_indent(params) - }; lines.push(LayoutLine { items: std::mem::take(&mut current_items), width: current_width, ascent: current_ascent, descent: current_descent, height: compute_line_height(current_ascent, current_descent, params), - indent_left: indent, + indent_left: line_indent(params, line_index, line_index == 0), available_width: line_avail, is_last: false, }); current_width = 0.0; current_ascent = 0.0; current_descent = 0.0; - is_first_line = false; - line_avail = subsequent_line_width; + line_index += 1; + line_avail = compute_line_width(params, line_index, false); } // Add segment items to current line @@ -249,43 +247,33 @@ pub fn break_into_lines( } } BreakableSegment::ForcedBreak(break_type) => { - let indent = if is_first_line { - first_line_indent(params) - } else { - subsequent_line_indent(params) - }; lines.push(LayoutLine { items: std::mem::take(&mut current_items), width: current_width, ascent: current_ascent, descent: current_descent, height: compute_line_height(current_ascent, current_descent, params), - indent_left: indent, + indent_left: line_indent(params, line_index, line_index == 0), available_width: line_avail, is_last: matches!(break_type, ForcedBreakType::Page | ForcedBreakType::Column), }); current_width = 0.0; current_ascent = 0.0; current_descent = 0.0; - is_first_line = false; - line_avail = subsequent_line_width; + line_index += 1; + line_avail = compute_line_width(params, line_index, false); } } } // Flush remaining items as the last line - let indent = if is_first_line { - first_line_indent(params) - } else { - subsequent_line_indent(params) - }; lines.push(LayoutLine { items: current_items, width: current_width, ascent: current_ascent, descent: current_descent, height: compute_line_height(current_ascent, current_descent, params), - indent_left: indent, + indent_left: line_indent(params, line_index, line_index == 0), available_width: line_avail, is_last: true, }); @@ -669,6 +657,32 @@ fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 { params.available_width - params.ind_left - params.ind_right } +fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 { + params + .line_prefix_widths + .get(line_index) + .copied() + .unwrap_or(0.0) +} + +fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 { + params + .line_suffix_widths + .get(line_index) + .copied() + .unwrap_or(0.0) +} + +fn compute_line_width(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 { + let base_width = if is_first_line { + compute_first_line_width(params) + } else { + compute_subsequent_line_width(params) + }; + (base_width - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)) + .max(0.0) +} + fn first_line_indent(params: &LineBreakParams) -> f64 { if params.ind_hanging > 0.0 { params.ind_left - params.ind_hanging @@ -681,6 +695,15 @@ fn subsequent_line_indent(params: &LineBreakParams) -> f64 { params.ind_left } +fn line_indent(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 { + let base_indent = if is_first_line { + first_line_indent(params) + } else { + subsequent_line_indent(params) + }; + base_indent + line_prefix_width(params, line_index) +} + /// Compute line height based on spacing rules. fn compute_line_height(ascent: f64, descent: f64, params: &LineBreakParams) -> f64 { let natural = ascent + descent; @@ -754,6 +777,19 @@ mod tests { assert!(lines[0].is_last); } + #[test] + fn line_prefix_width_reduces_available_width_and_increases_indent() { + let fm = FontManager::new(); + let mut params = LineBreakParams::default(); + params.available_width = 100.0; + params.line_prefix_widths = vec![20.0]; + + let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))]; + let lines = break_into_lines(&items, ¶ms, &fm).unwrap(); + assert_eq!(lines[0].available_width, 80.0); + assert_eq!(lines[0].indent_left, 20.0); + } + #[test] fn words_wrap_to_multiple_lines() { let fm = FontManager::new(); diff --git a/crates/rdocx-layout/src/paginator.rs b/crates/rdocx-layout/src/paginator.rs index 1a83f58..7dbaff6 100644 --- a/crates/rdocx-layout/src/paginator.rs +++ b/crates/rdocx-layout/src/paginator.rs @@ -3,15 +3,17 @@ //! Handles page breaks, widow/orphan control, keep-with-next, //! keep-lines-together, and header/footer placement. -use crate::block::{LayoutBlock, ParagraphBlock}; +use crate::block::{AnchoredImage, LayoutBlock, ParagraphBlock}; use crate::font::FontManager; -use crate::line::{LayoutLine, LineItem}; +use crate::line::{self, LayoutLine, LineItem}; use crate::output::{Color, GlyphRun, OutlineEntry, PageFrame, Point, PositionedElement, Rect}; +use rdocx_oxml::drawing::{AnchorAlignH, AnchorAlignV, ST_RelativeFromH, ST_RelativeFromV}; use rdocx_oxml::shared::{ST_Border, ST_Jc, ST_Underline}; /// A resolved border edge: (thickness in pt, color, optional dash pattern as (dash, gap)). type BorderEdge = (f64, Color, Option<(f64, f64)>); +const FOOTNOTE_SEPARATOR_OFFSET: f64 = 6.0; /// Page geometry derived from section properties. #[derive(Debug, Clone, Copy)] @@ -144,9 +146,9 @@ pub fn paginate( geometry: PageGeometry, header_footer: Option<&HeaderFooterContent>, title_pg: bool, - _fm: &FontManager, + fm: &FontManager, ) -> (Vec, Vec) { - let mut pager = Pager::new(geometry, header_footer, title_pg); + let mut pager = Pager::new(geometry, header_footer, title_pg, fm); for (block_idx, block) in blocks.iter().enumerate() { // Check for page break before @@ -228,6 +230,10 @@ struct Pager<'a> { is_first_page: bool, /// Whether this section uses different first page header/footer. title_pg: bool, + reserved_footnote_ids: Vec, + reserved_footnote_height: f64, + page_anchor_images: Vec, + fm: &'a FontManager, } impl<'a> Pager<'a> { @@ -235,6 +241,7 @@ impl<'a> Pager<'a> { geometry: PageGeometry, header_footer: Option<&'a HeaderFooterContent>, title_pg: bool, + fm: &'a FontManager, ) -> Self { Pager { pages: Vec::new(), @@ -248,6 +255,10 @@ impl<'a> Pager<'a> { outlines: Vec::new(), is_first_page: true, title_pg, + reserved_footnote_ids: Vec::new(), + reserved_footnote_height: 0.0, + page_anchor_images: Vec::new(), + fm, } } @@ -302,6 +313,40 @@ impl<'a> Pager<'a> { self.cursor_y = 0.0; self.has_content_flag = false; self.is_first_page = false; + self.reserved_footnote_ids.clear(); + self.reserved_footnote_height = 0.0; + self.page_anchor_images.clear(); + } + + fn effective_content_height(&self) -> f64 { + self.content_height - self.reserved_footnote_height + } + + fn additional_footnote_height(&self, para: &ParagraphBlock) -> f64 { + let mut additional = 0.0; + let mut adds_new_footnote = false; + for (footnote_id, height) in ¶.footnote_reserves { + if !self.reserved_footnote_ids.contains(footnote_id) { + additional += *height; + adds_new_footnote = true; + } + } + if adds_new_footnote && self.reserved_footnote_height == 0.0 { + additional += FOOTNOTE_SEPARATOR_OFFSET; + } + additional + } + + fn reserve_footnotes(&mut self, para: &ParagraphBlock) { + let additional = self.additional_footnote_height(para); + if additional > 0.0 { + self.reserved_footnote_height += additional; + for (footnote_id, _) in ¶.footnote_reserves { + if !self.reserved_footnote_ids.contains(footnote_id) { + self.reserved_footnote_ids.push(*footnote_id); + } + } + } } fn flush(mut self) -> (Vec, Vec) { @@ -325,10 +370,20 @@ fn paginate_paragraph( } else { para.space_before }; + let wrap_images = collect_page_anchor_images(para, block_idx, blocks, pager); + let paragraph = relayout_paragraph_for_page( + para, + &wrap_images, + pager.cursor_y + space_before, + &pager.geometry, + pager.fm, + ); + let para = ¶graph; + let additional_footnote_height = pager.additional_footnote_height(para); // Check if paragraph fits on current page let total_needed = space_before + para.content_height(); - let remaining = pager.content_height - pager.cursor_y; + let remaining = pager.effective_content_height() - additional_footnote_height - pager.cursor_y; if total_needed > remaining && pager.has_content() { // Paragraph doesn't fit. Decide: move whole or split. @@ -339,7 +394,7 @@ fn paginate_paragraph( return; } - let available_for_lines = remaining - space_before; + let available_for_lines = remaining - space_before - para.content_offset_top; let lines_that_fit = count_lines_that_fit(¶.lines, available_for_lines); if para.widow_control && lines_that_fit < 2 { @@ -353,11 +408,13 @@ fn paginate_paragraph( if para.widow_control && lines_remaining < 2 && lines_that_fit >= 3 { // Would leave orphan — move one line to next page let split_at = lines_that_fit - 1; + pager.reserve_footnotes(para); render_para_split(para, split_at, space_before, pager); return; } if lines_that_fit > 0 { + pager.reserve_footnotes(para); render_para_split(para, lines_that_fit, space_before, pager); return; } @@ -372,8 +429,12 @@ fn paginate_paragraph( // If it doesn't fit and we're at the top, we must split line by line if total_needed > pager.content_height && pager.cursor_y == 0.0 { // Paragraph is taller than a page; split line by line - let lines_that_fit = count_lines_that_fit(¶.lines, pager.content_height); + let lines_that_fit = count_lines_that_fit( + ¶.lines, + pager.content_height - additional_footnote_height - para.content_offset_top, + ); if lines_that_fit > 0 && lines_that_fit < para.lines.len() { + pager.reserve_footnotes(para); render_para_split(para, lines_that_fit, 0.0, pager); return; } @@ -385,13 +446,16 @@ fn paginate_paragraph( LayoutBlock::Paragraph(p) => p.lines.first().map(|l| l.height).unwrap_or(0.0), LayoutBlock::Table(t) => t.rows.first().map(|r| r.height).unwrap_or(0.0), }; - if pager.cursor_y + space_before + para.content_height() + next_first > pager.content_height + if pager.cursor_y + space_before + para.content_height() + next_first + > pager.effective_content_height() && pager.has_content() { pager.finish_page(); } } + pager.reserve_footnotes(para); + // Render the paragraph let space = if pager.cursor_y == 0.0 { 0.0 @@ -435,6 +499,7 @@ fn paginate_paragraph( pager.cursor_y, &mut pager.elements, ); + pager.page_anchor_images.extend(para.anchored_images.iter().cloned()); pager.cursor_y += para.content_height(); pager.cursor_y += para.space_after; pager.mark_content(); @@ -479,6 +544,12 @@ fn render_para_split(para: &ParagraphBlock, split_at: usize, space_before: f64, widow_control: para.widow_control, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; render_para_split(&temp_para, lines_that_fit, 0.0, pager); return; @@ -509,6 +580,147 @@ fn count_lines_that_fit(lines: &[LayoutLine], available: f64) -> usize { lines.len() } +fn relayout_paragraph_for_page( + para: &ParagraphBlock, + wrap_images: &[AnchoredImage], + start_y: f64, + geometry: &PageGeometry, + fm: &FontManager, +) -> ParagraphBlock { + if wrap_images.is_empty() || para.inline_items.is_empty() { + return para.clone(); + } + + let mut adjusted = para.clone(); + let mut lines = adjusted.lines.clone(); + let mut content_offset_top = para.content_offset_top; + + for _ in 0..2 { + let paragraph_height = content_offset_top + lines.iter().map(|line| line.height).sum::(); + let (top_offset, line_prefix_widths, line_suffix_widths) = + compute_anchor_line_adjustments(wrap_images, &lines, geometry, start_y, paragraph_height); + + let mut line_break_params = adjusted.line_break_params.clone(); + merge_line_widths(&mut line_break_params.line_prefix_widths, &line_prefix_widths); + line_break_params.line_suffix_widths = line_suffix_widths; + + let Ok(reflowed_lines) = line::break_into_lines(&adjusted.inline_items, &line_break_params, fm) else { + break; + }; + + lines = reflowed_lines; + content_offset_top = top_offset; + adjusted.line_break_params = line_break_params; + } + + adjusted.lines = lines; + adjusted.content_offset_top = content_offset_top; + adjusted +} + +fn collect_page_anchor_images( + para: &ParagraphBlock, + block_idx: usize, + blocks: &[LayoutBlock], + pager: &Pager<'_>, +) -> Vec { + let mut images = pager.page_anchor_images.clone(); + images.extend(para.anchored_images.iter().cloned()); + + let mut cursor_y = pager.cursor_y; + let current_space_before = if cursor_y == 0.0 { 0.0 } else { para.space_before }; + cursor_y += current_space_before + para.content_height() + para.space_after; + + for block in blocks.iter().skip(block_idx + 1) { + if block.page_break_before() { + break; + } + + let block_space_before = if cursor_y == 0.0 { 0.0 } else { block.space_before() }; + let block_total = block_space_before + block.content_height() + block.space_after(); + if cursor_y + block_total > pager.effective_content_height() { + break; + } + + if let LayoutBlock::Paragraph(next_para) = block { + images.extend(next_para.anchored_images.iter().cloned()); + } + + cursor_y += block_total; + } + + images +} + +fn merge_line_widths(base: &mut Vec, extra: &[f64]) { + if base.len() < extra.len() { + base.resize(extra.len(), 0.0); + } + for (idx, width) in extra.iter().enumerate() { + base[idx] += *width; + } +} + +fn compute_anchor_line_adjustments( + images: &[AnchoredImage], + lines: &[LayoutLine], + geometry: &PageGeometry, + start_y: f64, + paragraph_height: f64, +) -> (f64, Vec, Vec) { + let paragraph_top = geometry.margin_top + start_y; + let mut top_offset = 0.0; + let mut prefix = Vec::new(); + let mut suffix = Vec::new(); + + for image in images { + let image_top = resolve_anchor_y(image, geometry, paragraph_top, paragraph_height) - paragraph_top; + let image_bottom = image_top + image.height; + + if image.wrap == rdocx_oxml::drawing::WrapType::TopAndBottom { + if image_top <= top_offset + 1.0 { + top_offset = top_offset.max(image_bottom + image.dist_bottom); + } + continue; + } + + if image.wrap != rdocx_oxml::drawing::WrapType::Square { + continue; + } + + let reserve = match image.pos_h_align { + Some(AnchorAlignH::Left | AnchorAlignH::Inside) => image.wrap_left_extent, + Some(AnchorAlignH::Right | AnchorAlignH::Outside) => image.wrap_right_extent, + _ => continue, + }; + + let mut line_top = top_offset; + for (line_idx, line) in lines.iter().enumerate() { + let line_bottom = line_top + line.height; + if line_bottom > image_top - image.dist_top && line_top < image_bottom + image.dist_bottom { + match image.pos_h_align { + Some(AnchorAlignH::Left | AnchorAlignH::Inside) => { + if prefix.len() <= line_idx { + prefix.resize(line_idx + 1, 0.0); + } + prefix[line_idx] += reserve; + } + Some(AnchorAlignH::Right | AnchorAlignH::Outside) => { + if suffix.len() <= line_idx { + suffix.resize(line_idx + 1, 0.0); + } + suffix[line_idx] += reserve; + } + _ => {} + } + } + line_top = line_bottom; + } + } + + (top_offset, prefix, suffix) +} + /// Render paragraph lines as positioned elements. fn render_paragraph_lines( lines: &[LayoutLine], @@ -517,7 +729,36 @@ fn render_paragraph_lines( start_y: f64, elements: &mut Vec, ) { - let mut y = start_y; + render_anchor_images( + ¶.anchored_images, + para, + geometry, + start_y, + elements, + true, + ); + + if let Some(drop_cap) = ¶.drop_cap { + elements.push(PositionedElement::Text(GlyphRun { + origin: Point { + x: geometry.margin_left + para.indent_left, + y: geometry.margin_top + start_y + drop_cap.segment.ascent + - drop_cap.segment.baseline_offset, + }, + font_id: drop_cap.segment.font_id, + font_size: drop_cap.segment.font_size, + glyph_ids: drop_cap.segment.glyph_ids.clone(), + advances: drop_cap.segment.advances.clone(), + text: drop_cap.segment.text.clone(), + color: drop_cap.segment.color, + bold: drop_cap.segment.bold, + italic: drop_cap.segment.italic, + field_kind: drop_cap.segment.field_kind, + footnote_id: drop_cap.segment.footnote_id, + })); + } + + let mut y = start_y + para.content_offset_top; for line in lines { let baseline_y = geometry.margin_top + y + line.ascent; @@ -751,6 +992,99 @@ fn render_paragraph_lines( y += line.height; } + + render_anchor_images( + ¶.anchored_images, + para, + geometry, + start_y, + elements, + false, + ); +} + +fn render_anchor_images( + images: &[AnchoredImage], + para: &ParagraphBlock, + geometry: &PageGeometry, + start_y: f64, + elements: &mut Vec, + behind_doc: bool, +) { + let paragraph_top = geometry.margin_top + start_y; + let paragraph_height = para.content_height(); + + for image in images.iter().filter(|image| image.behind_doc == behind_doc) { + let element = PositionedElement::Image { + rect: Rect { + x: resolve_anchor_x(image, geometry), + y: resolve_anchor_y(image, geometry, paragraph_top, paragraph_height), + width: image.width, + height: image.height, + }, + data: Vec::new(), + content_type: String::new(), + embed_id: Some(image.embed_id.clone()), + }; + + if behind_doc { + elements.insert(0, element); + } else { + elements.push(element); + } + } +} + +fn resolve_anchor_x(image: &AnchoredImage, geometry: &PageGeometry) -> f64 { + let (base_left, base_width) = match image.pos_h_relative_from { + ST_RelativeFromH::Page => (0.0, geometry.page_width), + ST_RelativeFromH::Margin + | ST_RelativeFromH::Column + | ST_RelativeFromH::Character + | ST_RelativeFromH::LeftMargin + | ST_RelativeFromH::RightMargin + | ST_RelativeFromH::InsideMargin + | ST_RelativeFromH::OutsideMargin => ( + geometry.margin_left, + geometry.page_width - geometry.margin_left - geometry.margin_right, + ), + }; + + match image.pos_h_align { + Some(AnchorAlignH::Left | AnchorAlignH::Inside) => base_left, + Some(AnchorAlignH::Center) => base_left + (base_width - image.width) / 2.0, + Some(AnchorAlignH::Right | AnchorAlignH::Outside) => base_left + base_width - image.width, + None => base_left + image.pos_h_offset, + } +} + +fn resolve_anchor_y( + image: &AnchoredImage, + geometry: &PageGeometry, + paragraph_top: f64, + paragraph_height: f64, +) -> f64 { + let (base_top, base_height) = match image.pos_v_relative_from { + ST_RelativeFromV::Page => (0.0, geometry.page_height), + ST_RelativeFromV::Margin + | ST_RelativeFromV::TopMargin + | ST_RelativeFromV::BottomMargin + | ST_RelativeFromV::InsideMargin + | ST_RelativeFromV::OutsideMargin => ( + geometry.margin_top, + geometry.page_height - geometry.margin_top - geometry.margin_bottom, + ), + ST_RelativeFromV::Paragraph | ST_RelativeFromV::Line => { + (paragraph_top, paragraph_height.max(image.height)) + } + }; + + match image.pos_v_align { + Some(AnchorAlignV::Top | AnchorAlignV::Inside) => base_top, + Some(AnchorAlignV::Center) => base_top + (base_height - image.height) / 2.0, + Some(AnchorAlignV::Bottom | AnchorAlignV::Outside) => base_top + base_height - image.height, + None => base_top + image.pos_v_offset, + } } /// Render header/footer blocks. @@ -1178,6 +1512,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), } } @@ -1278,6 +1618,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; let (pages, _outlines) = paginate(&blocks, PageGeometry::default(), None, false, &fm); @@ -1308,6 +1654,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; let (pages, _outlines) = paginate(&blocks, PageGeometry::default(), None, false, &fm); @@ -1374,6 +1726,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; let (pages, _outlines) = paginate(&blocks, PageGeometry::default(), None, false, &fm); @@ -1418,6 +1776,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; let (pages, _outlines) = paginate(&blocks, PageGeometry::default(), None, false, &fm); @@ -1452,6 +1816,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; let (pages, _outlines) = paginate(&blocks, PageGeometry::default(), None, false, &fm); @@ -1481,6 +1851,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; let (pages, _outlines) = paginate(&blocks, PageGeometry::default(), None, false, &fm); @@ -1577,6 +1953,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; let (pages, _outlines) = paginate(&blocks, PageGeometry::default(), None, false, &fm); @@ -1613,6 +1995,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; @@ -1657,6 +2045,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; @@ -1706,6 +2100,12 @@ mod tests { widow_control: true, heading_level: None, heading_text: None, + drop_cap: None, + content_offset_top: 0.0, + footnote_reserves: Vec::new(), + anchored_images: Vec::new(), + inline_items: Vec::new(), + line_break_params: line::LineBreakParams::default(), }; let blocks = vec![LayoutBlock::Paragraph(para)]; @@ -1727,4 +2127,44 @@ mod tests { "single word should not be stretched: {total_advance}" ); } + + #[test] + fn square_wrap_reserve_uses_image_extent() { + let geometry = PageGeometry::default(); + let image = AnchoredImage { + behind_doc: false, + width: 76.8, + height: 76.8, + wrap_left_extent: 69.6, + wrap_right_extent: 69.6, + embed_id: "arrow".to_string(), + wrap: rdocx_oxml::drawing::WrapType::Square, + dist_top: 0.0, + dist_bottom: 0.0, + dist_left: 9.0, + dist_right: 9.0, + pos_h_offset: 0.0, + pos_h_relative_from: ST_RelativeFromH::Margin, + pos_h_align: Some(AnchorAlignH::Left), + pos_v_offset: 0.0, + pos_v_relative_from: ST_RelativeFromV::Margin, + pos_v_align: Some(AnchorAlignV::Top), + }; + let lines = vec![LayoutLine { + items: vec![], + width: 0.0, + ascent: 10.0, + descent: 3.0, + height: 13.0, + indent_left: 0.0, + available_width: 468.0, + is_last: true, + }]; + + let (_, prefix, suffix) = + compute_anchor_line_adjustments(&[image], &lines, &geometry, 0.0, 13.0); + + assert_eq!(prefix, vec![69.6]); + assert!(suffix.is_empty()); + } } diff --git a/crates/rdocx-oxml/src/drawing.rs b/crates/rdocx-oxml/src/drawing.rs index 30066e8..fceb4d9 100644 --- a/crates/rdocx-oxml/src/drawing.rs +++ b/crates/rdocx-oxml/src/drawing.rs @@ -104,6 +104,54 @@ impl ST_RelativeFromV { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WrapType { None, + Square, + TopAndBottom, +} + +/// Horizontal alignment for anchored drawings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnchorAlignH { + Left, + Center, + Right, + Inside, + Outside, +} + +impl AnchorAlignH { + pub fn from_str(s: &str) -> Option { + match s { + "left" => Some(Self::Left), + "center" => Some(Self::Center), + "right" => Some(Self::Right), + "inside" => Some(Self::Inside), + "outside" => Some(Self::Outside), + _ => None, + } + } +} + +/// Vertical alignment for anchored drawings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnchorAlignV { + Top, + Center, + Bottom, + Inside, + Outside, +} + +impl AnchorAlignV { + pub fn from_str(s: &str) -> Option { + match s { + "top" => Some(Self::Top), + "center" => Some(Self::Center), + "bottom" => Some(Self::Bottom), + "inside" => Some(Self::Inside), + "outside" => Some(Self::Outside), + _ => None, + } + } } /// `CT_Anchor` — An anchored (floating) drawing element. @@ -115,14 +163,26 @@ pub struct CT_Anchor { pub pos_h_offset: Emu, /// Horizontal relative-from. pub pos_h_relative_from: ST_RelativeFromH, + /// Optional horizontal alignment. + pub pos_h_align: Option, /// Vertical position offset in EMUs. pub pos_v_offset: Emu, /// Vertical relative-from. pub pos_v_relative_from: ST_RelativeFromV, + /// Optional vertical alignment. + pub pos_v_align: Option, /// Width in EMUs. pub extent_cx: Emu, /// Height in EMUs. pub extent_cy: Emu, + /// Extra distance between surrounding text and the top edge. + pub dist_t: Emu, + /// Extra distance between surrounding text and the bottom edge. + pub dist_b: Emu, + /// Extra distance between surrounding text and the left edge. + pub dist_l: Emu, + /// Extra distance between surrounding text and the right edge. + pub dist_r: Emu, /// Wrapping type. pub wrap: WrapType, /// Relationship ID referencing the image part. @@ -145,10 +205,16 @@ impl CT_Anchor { behind_doc: true, pos_h_offset: Emu(0), pos_h_relative_from: ST_RelativeFromH::Page, + pos_h_align: None, pos_v_offset: Emu(0), pos_v_relative_from: ST_RelativeFromV::Page, + pos_v_align: None, extent_cx: Emu(page_width_emu), extent_cy: Emu(page_height_emu), + dist_t: Emu(0), + dist_b: Emu(0), + dist_l: Emu(0), + dist_r: Emu(0), wrap: WrapType::None, embed_id: embed_id.to_string(), relative_height: 0, @@ -161,6 +227,10 @@ impl CT_Anchor { pub fn from_xml(reader: &mut Reader<&[u8]>, start: &BytesStart) -> Result { let mut behind_doc = false; let mut relative_height = 0u32; + let mut dist_t = Emu(0); + let mut dist_b = Emu(0); + let mut dist_l = Emu(0); + let mut dist_r = Emu(0); // Parse attributes from the start tag for attr in start.attributes() { @@ -171,18 +241,29 @@ impl CT_Anchor { behind_doc = val == "1" || val == "true"; } else if key == b"relativeHeight" { relative_height = val.parse().unwrap_or(0); + } else if key == b"distT" { + dist_t = Emu(val.parse().unwrap_or(0)); + } else if key == b"distB" { + dist_b = Emu(val.parse().unwrap_or(0)); + } else if key == b"distL" { + dist_l = Emu(val.parse().unwrap_or(0)); + } else if key == b"distR" { + dist_r = Emu(val.parse().unwrap_or(0)); } } let mut pos_h_offset = Emu(0); let mut pos_h_relative_from = ST_RelativeFromH::Page; + let mut pos_h_align = None; let mut pos_v_offset = Emu(0); let mut pos_v_relative_from = ST_RelativeFromV::Page; + let mut pos_v_align = None; let mut extent_cx = Emu(0); let mut extent_cy = Emu(0); let mut embed_id = String::new(); let mut description = None; let mut name = None; + let mut wrap = WrapType::None; let mut buf = Vec::new(); loop { @@ -224,6 +305,10 @@ impl CT_Anchor { // Ignore simplePos } else if matches_local_name(ename.as_ref(), b"wrapNone") { // Already default + } else if matches_local_name(ename.as_ref(), b"wrapSquare") { + wrap = WrapType::Square; + } else if matches_local_name(ename.as_ref(), b"wrapTopAndBottom") { + wrap = WrapType::TopAndBottom; } } Ok(Event::Start(ref e)) => { @@ -246,6 +331,12 @@ impl CT_Anchor { let text = reader.read_text(ie.name()).unwrap_or_default(); pos_h_offset = Emu(text.trim().parse().unwrap_or(0)); } + Ok(Event::Start(ref ie)) + if matches_local_name(ie.name().as_ref(), b"align") => + { + let text = reader.read_text(ie.name()).unwrap_or_default(); + pos_h_align = AnchorAlignH::from_str(text.trim()); + } Ok(Event::End(ref ie)) if matches_local_name(ie.name().as_ref(), b"positionH") => { @@ -274,6 +365,12 @@ impl CT_Anchor { let text = reader.read_text(ie.name()).unwrap_or_default(); pos_v_offset = Emu(text.trim().parse().unwrap_or(0)); } + Ok(Event::Start(ref ie)) + if matches_local_name(ie.name().as_ref(), b"align") => + { + let text = reader.read_text(ie.name()).unwrap_or_default(); + pos_v_align = AnchorAlignV::from_str(text.trim()); + } Ok(Event::End(ref ie)) if matches_local_name(ie.name().as_ref(), b"positionV") => { @@ -307,6 +404,12 @@ impl CT_Anchor { } } reader.read_to_end_into(ename, &mut Vec::new())?; + } else if matches_local_name(ename.as_ref(), b"wrapSquare") { + wrap = WrapType::Square; + reader.read_to_end_into(ename, &mut Vec::new())?; + } else if matches_local_name(ename.as_ref(), b"wrapTopAndBottom") { + wrap = WrapType::TopAndBottom; + reader.read_to_end_into(ename, &mut Vec::new())?; } else { // Continue into nested elements (graphic, graphicData, pic, etc.) } @@ -325,11 +428,17 @@ impl CT_Anchor { behind_doc, pos_h_offset, pos_h_relative_from, + pos_h_align, pos_v_offset, pos_v_relative_from, + pos_v_align, extent_cx, extent_cy, - wrap: WrapType::None, + dist_t, + dist_b, + dist_l, + dist_r, + wrap, embed_id, relative_height, description, @@ -350,6 +459,14 @@ impl CT_Anchor { anchor.push_attribute(("behindDoc", if self.behind_doc { "1" } else { "0" })); anchor.push_attribute(("simplePos", "0")); anchor.push_attribute(("relativeHeight", buf.format(self.relative_height))); + let dist_t = self.dist_t.0.to_string(); + let dist_b = self.dist_b.0.to_string(); + let dist_l = self.dist_l.0.to_string(); + let dist_r = self.dist_r.0.to_string(); + anchor.push_attribute(("distT", dist_t.as_str())); + anchor.push_attribute(("distB", dist_b.as_str())); + anchor.push_attribute(("distL", dist_l.as_str())); + anchor.push_attribute(("distR", dist_r.as_str())); anchor.push_attribute(("locked", "0")); anchor.push_attribute(("layoutInCell", "1")); anchor.push_attribute(("allowOverlap", "1")); diff --git a/crates/rdocx-oxml/src/properties.rs b/crates/rdocx-oxml/src/properties.rs index 632103d..a2a311a 100644 --- a/crates/rdocx-oxml/src/properties.rs +++ b/crates/rdocx-oxml/src/properties.rs @@ -57,6 +57,48 @@ impl CT_Shd { } } +/// `CT_FramePr` — Paragraph frame properties. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CT_FramePr { + /// Drop cap mode, typically "drop" or "margin". + pub drop_cap: Option, + /// Number of lines occupied by the drop cap. + pub lines: Option, +} + +impl CT_FramePr { + pub fn from_xml_attrs(e: &BytesStart) -> Result { + let mut frame_pr = CT_FramePr::default(); + + for attr in e.attributes() { + let attr = attr?; + let key = attr.key.as_ref(); + let val_str = std::str::from_utf8(&attr.value)?; + + if matches_local_name(key, b"dropCap") { + frame_pr.drop_cap = Some(val_str.to_string()); + } else if matches_local_name(key, b"lines") { + frame_pr.lines = Some(val_str.parse()?); + } + } + + Ok(frame_pr) + } + + pub fn write_xml(&self, writer: &mut Writer) -> Result<()> { + let mut e = BytesStart::new("w:framePr"); + if let Some(ref drop_cap) = self.drop_cap { + e.push_attribute(("w:dropCap", drop_cap.as_str())); + } + if let Some(lines) = self.lines { + let mut buf = itoa::Buffer::new(); + e.push_attribute(("w:lines", buf.format(lines))); + } + writer.write_event(Event::Empty(e))?; + Ok(()) + } +} + /// `CT_PPr` — Paragraph properties. #[derive(Debug, Clone, Default, PartialEq)] pub struct CT_PPr { @@ -102,6 +144,8 @@ pub struct CT_PPr { pub tabs: Option, /// Paragraph shading (shd) pub shading: Option, + /// Paragraph frame properties (framePr), used for features like drop caps. + pub frame_pr: Option, /// Run properties for the paragraph mark (rPr) pub rpr: Option, /// Numbering level (numPr/ilvl) @@ -132,6 +176,9 @@ impl CT_PPr { ppr.tabs = Some(CT_Tabs::from_xml(reader)?); } else if matches_local_name(name.as_ref(), b"sectPr") { ppr.sect_pr = Some(CT_SectPr::from_xml(reader)?); + } else if matches_local_name(name.as_ref(), b"framePr") { + ppr.frame_pr = Some(CT_FramePr::from_xml_attrs(e)?); + reader.read_to_end_into(name, &mut Vec::new())?; } else { reader.read_to_end_into(name, &mut Vec::new())?; } @@ -197,6 +244,8 @@ impl CT_PPr { } } else if matches_local_name(name.as_ref(), b"shd") { ppr.shading = Some(CT_Shd::from_xml_attrs(e)?); + } else if matches_local_name(name.as_ref(), b"framePr") { + ppr.frame_pr = Some(CT_FramePr::from_xml_attrs(e)?); } } Ok(Event::End(ref e)) if matches_local_name(e.name().as_ref(), b"pPr") => { @@ -370,6 +419,10 @@ impl CT_PPr { rpr.to_xml(writer)?; } + if let Some(ref frame_pr) = self.frame_pr { + frame_pr.write_xml(writer)?; + } + if let Some(ref sect) = self.sect_pr { sect.to_xml(writer)?; } @@ -399,6 +452,7 @@ impl CT_PPr { && self.borders.is_none() && self.tabs.is_none() && self.shading.is_none() + && self.frame_pr.is_none() && self.rpr.is_none() && self.num_id.is_none() && self.num_ilvl.is_none() @@ -471,6 +525,16 @@ impl CT_PPr { if other.shading.is_some() { self.shading = other.shading.clone(); } + if other.frame_pr.is_some() { + self.frame_pr = other.frame_pr.clone(); + } + if other.rpr.is_some() { + match (&mut self.rpr, &other.rpr) { + (Some(current), Some(incoming)) => current.merge_from(incoming), + (None, Some(incoming)) => self.rpr = Some(incoming.clone()), + _ => {} + } + } if other.num_ilvl.is_some() { self.num_ilvl = other.num_ilvl; } @@ -632,7 +696,9 @@ impl CT_RPr { } } else if matches_local_name(name.as_ref(), b"shd") { rpr.shading = Some(CT_Shd::from_xml_attrs(e)?); - } else if matches_local_name(name.as_ref(), b"vanish") { + } else if matches_local_name(name.as_ref(), b"vanish") + || matches_local_name(name.as_ref(), b"webHidden") + { rpr.vanish = Some(parse_toggle(e)?); } } @@ -1017,6 +1083,14 @@ mod tests { assert_eq!(shd.fill, Some("FFFF00".to_string())); } + #[test] + fn parse_frame_pr_drop_cap() { + let ppr = parse_ppr(r#""#); + let frame_pr = ppr.frame_pr.unwrap(); + assert_eq!(frame_pr.drop_cap, Some("drop".to_string())); + assert_eq!(frame_pr.lines, Some(3)); + } + #[test] fn parse_basic_rpr() { let rpr = parse_rpr(r#""#); @@ -1034,6 +1108,12 @@ mod tests { assert_eq!(rpr.position, Some(-4)); } + #[test] + fn parse_web_hidden_as_vanish() { + let rpr = parse_rpr(r#""#); + assert_eq!(rpr.vanish, Some(true)); + } + #[test] fn round_trip_rpr() { let original = CT_RPr { diff --git a/crates/rdocx-oxml/src/shared.rs b/crates/rdocx-oxml/src/shared.rs index 2b6cbd1..c6d23a4 100644 --- a/crates/rdocx-oxml/src/shared.rs +++ b/crates/rdocx-oxml/src/shared.rs @@ -20,7 +20,9 @@ impl ST_Jc { "start" | "left" => Ok(ST_Jc::Left), "end" | "right" => Ok(ST_Jc::Right), "center" => Ok(ST_Jc::Center), - "both" | "justify" => Ok(ST_Jc::Both), + "both" | "justify" | "lowKashida" | "mediumKashida" | "highKashida" => { + Ok(ST_Jc::Both) + } "distribute" => Ok(ST_Jc::Distribute), _ => Err(OxmlError::InvalidValue(format!("invalid ST_Jc: {s}"))), } @@ -37,6 +39,18 @@ impl ST_Jc { } } +#[cfg(test)] +mod tests { + use super::ST_Jc; + + #[test] + fn kashida_justification_maps_to_both() { + assert_eq!(ST_Jc::from_str("lowKashida").unwrap(), ST_Jc::Both); + assert_eq!(ST_Jc::from_str("mediumKashida").unwrap(), ST_Jc::Both); + assert_eq!(ST_Jc::from_str("highKashida").unwrap(), ST_Jc::Both); + } +} + /// `ST_OnOff` — Boolean toggle, can be represented as "true"/"false", "1"/"0", or attribute absence. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ST_OnOff { diff --git a/crates/rdocx/src/document.rs b/crates/rdocx/src/document.rs index 7118bf9..106e151 100644 --- a/crates/rdocx/src/document.rs +++ b/crates/rdocx/src/document.rs @@ -80,7 +80,8 @@ impl Document { let doc_xml = package .get_part(&doc_part_name) .ok_or(Error::NoDocumentPart)?; - let document = CT_Document::from_xml(doc_xml)?; + let mut document = CT_Document::from_xml(doc_xml)?; + normalize_paragraph_frames(&mut document.body.content); // Try to load styles let styles = if let Some(rels) = package.get_part_rels(&doc_part_name) { @@ -2627,10 +2628,76 @@ fn deobfuscate_odttf(data: &[u8], file_name: &str) -> Option> { Some(result) } +fn normalize_paragraph_frames(content: &mut Vec) { + let mut index = 0; + + while index + 1 < content.len() { + let drop_cap_paragraph = match &content[index] { + BodyContent::Paragraph(paragraph) if is_drop_cap_paragraph(paragraph) => { + if paragraph.text().is_empty() { + index += 1; + continue; + } + paragraph.clone() + } + _ => { + index += 1; + continue; + } + }; + + let Some(BodyContent::Paragraph(next_paragraph)) = content.get_mut(index + 1) else { + index += 1; + continue; + }; + + transfer_drop_cap_paragraph(next_paragraph, &drop_cap_paragraph); + content.remove(index); + index += 1; + } +} + +fn is_drop_cap_paragraph(paragraph: &CT_P) -> bool { + paragraph + .properties + .as_ref() + .and_then(|ppr| ppr.frame_pr.as_ref()) + .and_then(|frame_pr| frame_pr.drop_cap.as_deref()) + .is_some_and(|drop_cap| matches!(drop_cap, "drop" | "margin")) +} + +fn transfer_drop_cap_paragraph(paragraph: &mut CT_P, drop_cap: &CT_P) { + let inserted_runs = drop_cap.runs.len(); + if inserted_runs == 0 { + return; + } + + let paragraph_ppr = paragraph.properties.get_or_insert_with(CT_PPr::default); + if paragraph_ppr.frame_pr.is_none() { + paragraph_ppr.frame_pr = drop_cap + .properties + .as_ref() + .and_then(|properties| properties.frame_pr.clone()); + } + + let mut merged_runs = drop_cap.runs.clone(); + merged_runs.extend(std::mem::take(&mut paragraph.runs)); + paragraph.runs = merged_runs; + + for hyperlink in &mut paragraph.hyperlinks { + hyperlink.run_start += inserted_runs; + hyperlink.run_end += inserted_runs; + } + for (run_index, _) in &mut paragraph.extra_xml { + *run_index += inserted_runs; + } +} + #[cfg(test)] mod tests { use super::*; use crate::paragraph::Alignment; + use rdocx_oxml::properties::{CT_FramePr, CT_PPr}; use rdocx_oxml::units::{HalfPoint, Twips}; #[test] @@ -3164,4 +3231,87 @@ mod tests { doc.add_paragraph("Just text."); assert!(doc.images().is_empty()); } + + #[test] + fn normalizes_drop_cap_paragraphs() { + let mut content = vec![ + BodyContent::Paragraph(CT_P { + properties: Some(CT_PPr { + frame_pr: Some(CT_FramePr { + drop_cap: Some("drop".to_string()), + lines: Some(3), + }), + ..Default::default() + }), + runs: vec![CT_R::new("D")], + hyperlinks: Vec::new(), + extra_xml: Vec::new(), + }), + BodyContent::Paragraph(CT_P { + properties: None, + runs: vec![CT_R::new("rop caps are used")], + hyperlinks: Vec::new(), + extra_xml: Vec::new(), + }), + ]; + + normalize_paragraph_frames(&mut content); + + assert_eq!(content.len(), 1); + let BodyContent::Paragraph(paragraph) = &content[0] else { + panic!("expected paragraph"); + }; + assert_eq!(paragraph.text(), "Drop caps are used"); + assert_eq!( + paragraph + .properties + .as_ref() + .and_then(|properties| properties.frame_pr.as_ref()) + .and_then(|frame_pr| frame_pr.drop_cap.as_deref()), + Some("drop") + ); + } + + #[test] + fn transfers_drop_cap_runs_into_empty_paragraph() { + let mut paragraph = CT_P { + properties: None, + runs: Vec::new(), + hyperlinks: vec![rdocx_oxml::text::HyperlinkSpan { + rel_id: Some("rId1".to_string()), + anchor: None, + run_start: 0, + run_end: 0, + }], + extra_xml: vec![(0, b"".to_vec())], + }; + + let drop_cap = CT_P { + properties: Some(CT_PPr { + frame_pr: Some(CT_FramePr { + drop_cap: Some("drop".to_string()), + lines: Some(3), + }), + ..Default::default() + }), + runs: vec![CT_R::new("D")], + hyperlinks: Vec::new(), + extra_xml: Vec::new(), + }; + + transfer_drop_cap_paragraph(&mut paragraph, &drop_cap); + + assert_eq!(paragraph.text(), "D"); + assert_eq!(paragraph.hyperlinks[0].run_start, 1); + assert_eq!(paragraph.hyperlinks[0].run_end, 1); + assert_eq!(paragraph.extra_xml[0].0, 1); + assert_eq!( + paragraph + .properties + .as_ref() + .and_then(|properties| properties.frame_pr.as_ref()) + .and_then(|frame_pr| frame_pr.lines), + Some(3) + ); + } }