From 994fee79d6d310f5d779034cc2f7270ef5e9f9ef Mon Sep 17 00:00:00 2001 From: Peter Willemsen Date: Wed, 18 Mar 2026 00:31:17 +0100 Subject: [PATCH 1/2] Improve DOCX layout for floating images, drop caps, and footnotes Add richer paragraph reflow so anchored images render on the correct page, wrap text across nearby paragraphs, and use visible PNG bounds for tighter square wrapping; also add drop-cap/footnote-aware pagination and supporting OOXML parsing updates. --- Cargo.lock | 687 ++++++++++++++++++++++++++- crates/rdocx-layout/Cargo.toml | 1 + crates/rdocx-layout/src/block.rs | 71 ++- crates/rdocx-layout/src/engine.rs | 307 +++++++++--- crates/rdocx-layout/src/line.rs | 96 ++-- crates/rdocx-layout/src/paginator.rs | 458 +++++++++++++++++- crates/rdocx-oxml/src/drawing.rs | 119 ++++- crates/rdocx-oxml/src/properties.rs | 75 ++- crates/rdocx-oxml/src/shared.rs | 16 +- crates/rdocx/src/document.rs | 151 +++++- 10 files changed, 1862 insertions(+), 119 deletions(-) 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..afbd1de 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()) { @@ -739,6 +685,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 +817,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 +906,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 +926,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 +947,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 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..e277faf 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,9 @@ 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.num_ilvl.is_some() { self.num_ilvl = other.num_ilvl; } @@ -632,7 +689,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 +1076,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 +1101,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..9885886 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,75 @@ 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); + } +} + +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 +3230,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) + ); + } } From 1d04d767f597b146d88623b005384b5e0e371c69 Mon Sep 17 00:00:00 2001 From: Peter Willemsen Date: Thu, 19 Mar 2026 11:07:55 +0100 Subject: [PATCH 2/2] Fixed headers --- crates/rdocx-layout/src/engine.rs | 13 ++++++++++++- crates/rdocx-oxml/src/properties.rs | 7 +++++++ crates/rdocx/src/document.rs | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/rdocx-layout/src/engine.rs b/crates/rdocx-layout/src/engine.rs index afbd1de..ce6845d 100644 --- a/crates/rdocx-layout/src/engine.rs +++ b/crates/rdocx-layout/src/engine.rs @@ -617,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); } @@ -1098,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-oxml/src/properties.rs b/crates/rdocx-oxml/src/properties.rs index e277faf..a2a311a 100644 --- a/crates/rdocx-oxml/src/properties.rs +++ b/crates/rdocx-oxml/src/properties.rs @@ -528,6 +528,13 @@ impl CT_PPr { 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; } diff --git a/crates/rdocx/src/document.rs b/crates/rdocx/src/document.rs index 9885886..106e151 100644 --- a/crates/rdocx/src/document.rs +++ b/crates/rdocx/src/document.rs @@ -2653,6 +2653,7 @@ fn normalize_paragraph_frames(content: &mut Vec) { transfer_drop_cap_paragraph(next_paragraph, &drop_cap_paragraph); content.remove(index); + index += 1; } }