From 0f608f4e51222e4b93a1749e31067126c6e396ff Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Wed, 4 Feb 2026 00:24:37 +0100 Subject: [PATCH] feat: Options to add dimension HTML attributes from CSS properties Signed-off-by: Dmitry Dygalo --- CHANGELOG.md | 4 + README.md | 2 + bindings/c/CHANGELOG.md | 4 + bindings/c/README.md | 2 + bindings/c/src/lib.rs | 8 + bindings/java/CHANGELOG.md | 4 + bindings/java/README.md | 2 + .../java/org/cssinline/CssInlineConfig.java | 41 ++- bindings/java/src/main/rust/lib.rs | 6 +- bindings/javascript/CHANGELOG.md | 4 + bindings/javascript/README.md | 2 + bindings/javascript/index.d.ts | 4 + bindings/javascript/src/options.rs | 12 + bindings/javascript/wasm/index.d.ts | 2 + bindings/javascript/wasm/index.html | 40 +++ bindings/php/CHANGELOG.md | 4 + bindings/php/README.md | 2 + bindings/php/src/lib.rs | 6 + bindings/python/CHANGELOG.md | 4 + bindings/python/README.md | 2 + bindings/python/src/lib.rs | 54 ++- bindings/ruby/CHANGELOG.md | 4 + bindings/ruby/README.md | 2 + bindings/ruby/ext/css_inline/Cargo.toml | 2 +- bindings/ruby/ext/css_inline/src/lib.rs | 10 + css-inline/src/html/document.rs | 8 +- css-inline/src/html/serializer.rs | 171 +++++++++- css-inline/src/lib.rs | 36 ++ css-inline/src/main.rs | 16 + css-inline/tests/test_inlining.rs | 315 +++++++++++++++++- 30 files changed, 750 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb88c5e..408e4331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `InlineOptions::apply_width_attributes` and `InlineOptions::apply_height_attributes` options to add dimension HTML attributes from CSS properties on supported elements (`table`, `td`, `th`, `img`). [#652](https://github.com/Stranger6667/css-inline/issues/652) + ### Performance - Skip selectors that reference non-existent classes, IDs, or tags. diff --git a/README.md b/README.md index a5e9c0c8..7cd96ec1 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ fn main() -> css_inline::Result<()> { - `extra_css`. Extra CSS to be inlined. Default: `None` - `preallocate_node_capacity`. **Advanced**. Preallocates capacity for HTML nodes during parsing. This can improve performance when you have an estimate of the number of nodes in your HTML document. Default: `32` - `remove_inlined_selectors`. Specifies whether to remove selectors that were successfully inlined from `"); @@ -653,6 +812,8 @@ mod tests { false, None, InliningMode::Document, + false, + false, ) .expect("Should not fail"); assert_eq!(buffer, b""); @@ -674,6 +835,8 @@ mod tests { false, None, InliningMode::Document, + false, + false, ) .expect("Should not fail"); assert_eq!(buffer, b"& < >  "); @@ -695,6 +858,8 @@ mod tests { false, None, InliningMode::Document, + false, + false, ) .expect("Should not fail"); assert_eq!( @@ -719,6 +884,8 @@ mod tests { false, None, InliningMode::Document, + false, + false, ) .expect("Should not fail"); assert_eq!(buffer, b""); @@ -742,6 +909,8 @@ mod tests { "@media (max-width: 600px) { h1 { font-size: 18px; } }", )), InliningMode::Document, + false, + false, ) .expect("Should not fail"); assert_eq!(buffer, b""); diff --git a/css-inline/src/lib.rs b/css-inline/src/lib.rs index 69d5a613..ca8506ed 100644 --- a/css-inline/src/lib.rs +++ b/css-inline/src/lib.rs @@ -83,6 +83,16 @@ pub struct InlineOptions<'a> { pub resolver: Arc, /// Remove selectors that were successfully inlined from inline `"#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="100""#)); + assert!(result.contains(r#"style="width: 100px;""#)); +} + +#[test] +fn apply_height_attribute_img() { + let inliner = CSSInliner::options().apply_height_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"height="50""#)); + assert!(result.contains(r#"style="height: 50px;""#)); +} + +#[test] +fn apply_both_dimension_attributes_img() { + let inliner = CSSInliner::options() + .apply_width_attributes(true) + .apply_height_attributes(true) + .build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="200""#)); + assert!(result.contains(r#"height="100""#)); +} + +#[test] +fn apply_width_percent_table() { + // Tables support percentages + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#"
"#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="100%""#)); +} + +#[test] +fn apply_width_percent_td() { + // TD supports percentages + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#"
"#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="50%""#)); +} + +#[test] +fn apply_width_percent_th() { + // TH supports percentages + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#"
"#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="25%""#)); +} + +#[test] +fn apply_width_percent_img_ignored() { + // IMG does NOT support percentages - should not add attribute + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); + // But the style should still be inlined + assert!(result.contains(r#"style="width: 100%;""#)); +} + +#[test] +fn skip_existing_width_attribute() { + // Don't override existing HTML attributes + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="200""#)); + assert!(!result.contains(r#"width="100""#)); +} + +#[test] +fn skip_existing_height_attribute() { + // Don't override existing HTML attributes + let inliner = CSSInliner::options().apply_height_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"height="200""#)); + assert!(!result.contains(r#"height="100""#)); +} + +#[test] +fn ignore_complex_css_values() { + // calc(), em, rem, etc. are not converted + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + + // calc() - not supported + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); + + // em - not supported + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); + + // rem - not supported + let html = + r#""#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); + + // vh - not supported + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); + + // vw - not supported + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); +} + +#[test] +fn apply_auto_width() { + // auto should be passed through + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="auto""#)); +} + +#[test] +fn apply_unitless_width() { + // Unitless values should work (treated as pixels) + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="100""#)); +} + +#[test] +fn dimension_attributes_disabled_by_default() { + let html = r#""#; + let result = inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); + assert!(!result.contains(r#" height=""#)); + // But styles should still be inlined + assert!(result.contains(r#"style="width: 100px;height: 50px;""#)); +} + +#[test] +fn dimension_attributes_on_unsupported_elements() { + // Only table, td, th, img support dimension attributes + let inliner = CSSInliner::options() + .apply_width_attributes(true) + .apply_height_attributes(true) + .build(); + let html = r#"
"#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); + assert!(!result.contains(r#" height=""#)); +} + +#[test] +fn dimension_attributes_decimal_values() { + // Decimal values should work + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = + r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="100.5""#)); +} + +#[test] +fn dimension_attributes_negative_values() { + // Negative values are passed through (let the browser handle validity) + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = + r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="-10""#)); +} + +#[test] +fn outlook_compatibility_example() { + // Test simulating Outlook-compatible email HTML + let inliner = CSSInliner::options() + .apply_width_attributes(true) + .apply_height_attributes(true) + .build(); + let html = r#" +
+ "#; + let result = inliner.inline(html).unwrap(); + // Verify: width="600" height="400" on img + assert!(result.contains(r#"width="600""#)); + assert!(result.contains(r#"height="400""#)); + // Verify: width="100%" on table + assert!(result.contains(r#"width="100%""#)); + // Verify: width="50%" height="100" on td + assert!(result.contains(r#"width="50%""#)); + assert!(result.contains(r#"height="100""#)); +} + +#[test] +fn apply_dimensions_all_supported_elements() { + // Verify all supported elements (table, td, th, img) work together + let inliner = CSSInliner::options() + .apply_width_attributes(true) + .apply_height_attributes(true) + .build(); + let html = r#" +
+ "#; + let result = inliner.inline(html).unwrap(); + // table + assert!(result.contains(r#"width="600""#)); + assert!(result.contains(r#"height="400""#)); + // td + assert!(result.contains(r#"width="200""#)); + assert!(result.contains(r#"height="100""#)); + // th + assert!(result.contains(r#"width="150""#)); + assert!(result.contains(r#"height="50""#)); + // img + assert!(result.contains(r#"width="100""#)); + assert!(result.contains(r#"height="75""#)); +} + +#[test] +fn apply_zero_width_height() { + // Zero values should be applied + let inliner = CSSInliner::options() + .apply_width_attributes(true) + .apply_height_attributes(true) + .build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + assert!(result.contains(r#"width="0""#)); + assert!(result.contains(r#"height="0""#)); +} + +#[test] +fn apply_width_with_important() { + // !important declarations are converted to attributes (with !important stripped from the attribute value) + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + // Width attribute is added with the numeric value (no !important) + assert!(result.contains(r#"width="100""#)); + // The style attribute still contains the full value with !important + assert!(result.contains(r#"style="width: 100px !important;""#)); +} + +#[test] +fn apply_width_percent_with_important() { + // !important percentage values for table elements + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#"
"#; + let result = inliner.inline(html).unwrap(); + // Width attribute is added with the percentage value (no !important) + assert!(result.contains(r#"width="50%""#)); + // The style attribute still contains the full value with !important + assert!(result.contains(r#"style="width: 50% !important;""#)); +} + +#[test] +fn apply_width_specificity() { + // More specific selector should win + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + // Should use the more specific rule + assert!(result.contains(r#"width="200""#)); + assert!(!result.contains(r#"width="50""#)); +} + +#[test] +fn apply_width_from_inline_style() { + // Dimension attributes are extracted from stylesheet rules, not pre-existing inline styles. + // The final style attribute will contain the merged/overridden value (inline wins), + // but the dimension attribute is set from the stylesheet rule. + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = r#""#; + let result = inliner.inline(html).unwrap(); + // Stylesheet value is used for the attribute + assert!(result.contains(r#"width="50""#)); + // Inline style wins in the style attribute (100px overrides 50px) + assert!(result.contains(r#"style="width: 100px""#)); +} + +#[test] +fn ignore_shorthand_dimensions() { + // Ensure we don't accidentally process shorthands like `border-width` + let inliner = CSSInliner::options().apply_width_attributes(true).build(); + let html = + r#""#; + let result = inliner.inline(html).unwrap(); + assert!(!result.contains(r#" width=""#)); +}