diff --git a/src/lib.rs b/src/lib.rs
index f4177194..deccf95c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -32470,6 +32470,76 @@ mod tests {
#[test]
fn test_quoting_unquoting_urls() {
+ // Single quotes avoid escaping the double quotes in SVG attributes.
+ minify_test(
+ r#"
+ .foo {
+ background-image: url('data:image/svg+xml,');
+ }
+ "#,
+ r#".foo{background-image:url('data:image/svg+xml,')}"#,
+ );
+
+ // Keep double quotes when they are shorter or tied with single quotes.
+ minify_test(
+ r#"
+ .foo {
+ background-image: url("a 'b' c"), url('a "b" \'c\''), url('a "b" \'c');
+ }
+ "#,
+ r#".foo{background-image:url("a 'b' c"),url("a \"b\" 'c'"),url('a "b" \'c')}"#,
+ );
+
+ // Unquoted output still wins ties, including an empty URL.
+ minify_test(
+ r#"
+ .foo {
+ background-image: url('"'), url('""'), url('"""'), url('');
+ }
+ "#,
+ r#".foo{background-image:url(\"),url(\"\"),url('"""'),url()}"#,
+ );
+
+ // Escaped quotes and backslashes are parsed before choosing the quote style.
+ minify_test(
+ r#"
+ .foo {
+ background-image: url("a \\\"b\\\" \\c"), url('a "b" \\\'c');
+ }
+ "#,
+ r#".foo{background-image:url('a \\"b\\" \\c'),url('a "b" \\\'c')}"#,
+ );
+
+ // Preserve control characters and hex escape terminators before hex digits and spaces.
+ minify_test(
+ r#"
+ .foo {
+ background-image: url('"a"\a f\d \c a\1 b\7f c\9 "é😀"');
+ }
+ "#,
+ r#".foo{background-image:url('"a"\a f\d \c a\1 b\7f c\9 "é😀"')}"#,
+ );
+
+ minify_test(
+ r#"
+ .foo {
+ background-image: url('"a" \
+b\0 c');
+ }
+ "#,
+ r#".foo{background-image:url('"a" b�c')}"#,
+ );
+
+ // Keep the existing double-quoted output when not minifying.
+ test(
+ r#"
+ .foo {
+ background-image: url('a "b" c');
+ }
+ "#,
+ ".foo {\n background-image: url(\"a \\\"b\\\" c\");\n}\n",
+ );
+
// Quotes remain double quotes when not minifying
test(
r#".foo {
@@ -32509,6 +32579,99 @@ mod tests {
);
}
+ #[test]
+ fn test_url_quote_roundtrip() {
+ use crate::values::url::Url;
+
+ // Exercise escape boundaries on both sides of quotes, including a literal
+ // backslash followed by a hex digit and non-ASCII text.
+ let parts = [
+ "\"", "'", "\\", "\n", "\r", "\x0c", "\x01", "\x7f", "\t", " ", "f", "é😀", "(", ")",
+ ];
+ for a in parts {
+ for b in parts {
+ for c in parts {
+ let value = format!("\"\"{a}{b}{c}\"\"");
+ let url = Url {
+ url: value.as_str().into(),
+ loc: crate::dependencies::Location { line: 1, column: 1 },
+ };
+ let serialized = url
+ .to_css_string(PrinterOptions {
+ minify: true,
+ ..Default::default()
+ })
+ .unwrap();
+ assert_eq!(Url::parse_string(&serialized).unwrap(), url);
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_url_quoting_dependencies() {
+ let stylesheet = StyleSheet::parse(
+ r#"
+ .foo {
+ background-image: url('a "b" c');
+ }
+ "#,
+ ParserOptions::default(),
+ )
+ .unwrap();
+ let result = stylesheet
+ .to_css(
+ PrinterOptions {
+ minify: true,
+ analyze_dependencies: Some(Default::default()),
+ ..Default::default()
+ },
+ None::<&mut ()>,
+ )
+ .unwrap();
+ let dependencies = result.dependencies.unwrap();
+ assert_eq!(dependencies.len(), 1);
+ let Dependency::Url(dep) = &dependencies[0] else {
+ panic!("expected URL dependency")
+ };
+ assert_eq!(dep.url, r#"a "b" c"#);
+ assert_eq!(
+ result.code,
+ format!(".foo{{background-image:url(\"{}\")}}", dep.placeholder)
+ );
+ }
+
+ #[test]
+ #[cfg(feature = "sourcemap")]
+ fn test_url_quoting_source_map() {
+ let stylesheet = StyleSheet::parse(
+ r#"
+ .foo {
+ background-image: url('"a" \a b');
+ }
+ .bar {
+ color: red;
+ }
+ "#,
+ ParserOptions::default(),
+ )
+ .unwrap();
+ let mut sm = parcel_sourcemap::SourceMap::new("/");
+ let result = stylesheet
+ .to_css(
+ PrinterOptions {
+ minify: true,
+ ..Default::default()
+ },
+ Some(&mut sm),
+ )
+ .unwrap();
+ let column = result.code.find(".bar").unwrap() as u32;
+ let mapping = sm.find_closest_mapping(0, column).unwrap();
+ assert_eq!(mapping.generated_column, column);
+ assert_eq!(mapping.original.unwrap().original_line, 4);
+ }
+
#[test]
fn test_zindex() {
minify_test(".foo { z-index: 2 }", ".foo{z-index:2}");
diff --git a/src/values/url.rs b/src/values/url.rs
index ac88356d..b93d8779 100644
--- a/src/values/url.rs
+++ b/src/values/url.rs
@@ -7,6 +7,7 @@ use crate::values::string::CowArcStr;
#[cfg(feature = "visitor")]
use crate::visitor::Visit;
use cssparser::*;
+use std::fmt::{self, Write};
/// A CSS [url()](https://www.w3.org/TR/css-values-4/#urls) value and its source location.
#[derive(Debug, Clone)]
@@ -64,11 +65,25 @@ impl<'i> ToCss for Url<'i> {
let mut buf = String::new();
Token::UnquotedUrl(CowRcStr::from(self.url.as_ref())).to_css(&mut buf)?;
- // If the unquoted url is longer than it would be quoted (e.g. `url("...")`)
- // then serialize as a string and choose the shorter version.
+ // If quoting could be shorter, choose the quote that needs fewer escapes,
+ // then compare with the unquoted url. Keep the existing choice on ties.
if buf.len() > self.url.len() + 7 {
+ let mut single_quotes = 0;
+ let mut double_quotes = 0;
+ for b in self.url.bytes() {
+ match b {
+ b'\'' => single_quotes += 1,
+ b'"' => double_quotes += 1,
+ _ => {}
+ }
+ }
+
let mut buf2 = String::new();
- serialize_string(&self.url, &mut buf2)?;
+ if double_quotes > single_quotes {
+ serialize_single_quoted_string(&self.url, &mut buf2)?;
+ } else {
+ serialize_string(&self.url, &mut buf2)?;
+ }
if buf2.len() + 5 < buf.len() {
dest.write_str("url(")?;
dest.write_str(&buf2)?;
@@ -88,6 +103,24 @@ impl<'i> ToCss for Url<'i> {
}
}
+fn serialize_single_quoted_string(value: &str, dest: &mut W) -> fmt::Result {
+ dest.write_char('\'')?;
+ let mut start = 0;
+ for (i, b) in value.bytes().enumerate() {
+ if matches!(b, b'\'' | b'"') {
+ // Delegate all other escapes, including hex escape terminators, to cssparser.
+ CssStringWriter::new(dest).write_str(&value[start..i])?;
+ if b == b'\'' {
+ dest.write_char('\\')?;
+ }
+ dest.write_char(b as char)?;
+ start = i + 1;
+ }
+ }
+ CssStringWriter::new(dest).write_str(&value[start..])?;
+ dest.write_char('\'')
+}
+
impl<'i> Url<'i> {
/// Returns whether the URL is absolute, and not relative.
pub fn is_absolute(&self) -> bool {