diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 262c71a..4508727 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -2247,8 +2247,19 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option { if width == 0 || height == 0 { return None; } - let opts = usvg::Options::default(); - let tree = usvg::Tree::from_data(bytes, &opts).ok()?; + let mut opts = usvg::Options::default(); + // The outer replaced element supplies the SVG document viewport. Force + // that used CSS size onto the root before usvg resolves `viewBox`: + // a missing height is represented as 100%, which usvg otherwise resolves + // against the viewBox height itself. `` would therefore become a 32x236 viewport; its artwork is fitted + // into a thin centered strip and then the whole strip is scaled to 32x34. + // Author `preserveAspectRatio` still controls fitting inside this viewport. + // usvg resolves root dimensions before an injected stylesheet can + // override them, so provide the used viewport as actual root attributes. + let viewport_svg = svg_with_root_viewport(bytes, width, height)?; + opts.default_size = usvg::Size::from_wh(width as f32, height as f32)?; + let tree = usvg::Tree::from_data(&viewport_svg, &opts).ok()?; let size = tree.size(); if size.width() <= 0.0 || size.height() <= 0.0 { return None; @@ -2259,6 +2270,108 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option { Some(svg_pixmap) } +/// Return SVG XML whose root `width`/`height` are the resolved CSS viewport. +/// +/// This is deliberately a narrow XML start-tag rewrite rather than a DOM +/// reserialization: all namespaces, styles, definitions, and source order +/// remain byte-for-byte intact. Existing attribute values are replaced; +/// missing ones are appended. Quoted `>` characters are respected while +/// finding the end of the root tag. +fn svg_with_root_viewport(bytes: &[u8], width: u32, height: u32) -> Option> { + let source = std::str::from_utf8(bytes).ok()?; + let start = source.find(" quote = None, + (None, '"' | '\'') => quote = Some(ch), + (None, '>') => { + tag_end = Some(start + offset); + break; + } + _ => {} + } + } + let tag_end = tag_end?; + let mut root = source[start..=tag_end].to_string(); + for (name, value) in [("width", width), ("height", height)] { + if let Some((value_start, value_end)) = svg_root_attr_value_range(&root, name) { + root.replace_range(value_start..value_end, &value.to_string()); + } else { + root.insert_str(root.len() - 1, &format!(" {name}=\"{value}\"")); + } + } + + let mut output = String::with_capacity(source.len() + 32); + output.push_str(&source[..start]); + output.push_str(&root); + output.push_str(&source[tag_end + 1..]); + Some(output.into_bytes()) +} + +/// Value byte range for one attribute in an `` start tag. +fn svg_root_attr_value_range(tag: &str, wanted: &str) -> Option<(usize, usize)> { + let bytes = tag.as_bytes(); + let mut index = "= bytes.len() || bytes[index] == b'>' || bytes[index] == b'/' { + return None; + } + let name_start = index; + while index < bytes.len() + && !bytes[index].is_ascii_whitespace() + && bytes[index] != b'=' + && bytes[index] != b'>' + && bytes[index] != b'/' + { + index += 1; + } + let name_end = index; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + if index >= bytes.len() || bytes[index] != b'=' { + continue; + } + index += 1; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + let (value_start, value_end) = if index < bytes.len() + && matches!(bytes[index], b'"' | b'\'') + { + let delimiter = bytes[index]; + index += 1; + let start = index; + while index < bytes.len() && bytes[index] != delimiter { + index += 1; + } + let end = index; + index = (index + 1).min(bytes.len()); + (start, end) + } else { + let start = index; + while index < bytes.len() + && !bytes[index].is_ascii_whitespace() + && bytes[index] != b'>' + && bytes[index] != b'/' + { + index += 1; + } + (start, index) + }; + if &tag[name_start..name_end] == wanted { + return Some((value_start, value_end)); + } + } + None +} + /// Serialize an inline `` subtree (rooted at `root`) back to a standalone /// SVG document string. Emits `children` for the element /// and every descendant, preserving the root's `viewBox`/`width`/`height` and @@ -3198,6 +3311,30 @@ mod tests { assert!(found_red, "expected inline svg to paint red"); } + #[test] + fn svg_missing_root_height_uses_the_final_css_viewport() { + let svg = br##" + + "##; + let pixmap = render_svg(svg, 32, 34).expect("svg raster"); + let mut min_y = 34u32; + let mut max_y = 0u32; + for y in 0..34 { + for x in 0..32 { + let pixel = pixmap.pixel(x, y).unwrap(); + if pixel.alpha() > 0 { + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + } + assert!( + max_y.saturating_sub(min_y) >= 30, + "viewBox artwork should fill the resolved 32x34 viewport, got rows {min_y}..{max_y}" + ); + } + #[test] fn paints_inline_svg_current_color_from_computed_style() { let tree = parse_html(