Improve authored tables and SVG intrinsic sizing
This commit is contained in:
1 parent
b4d11b4c9e
commit
0562fb4921
6 files changed
+970
-210
No files matched your search
@@ -127,7 +127,7 @@ fn network_resource_url(url: &str) -> String {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RememberedContentImageIntrinsic {
|
||||
resolved_url: String,
|
||||
dimensions: (f32, f32),
|
||||
intrinsic: crate::ReplacedIntrinsic,
|
||||
}
|
||||
|
||||
/// Page-scoped raw resource bytes shared by layout preparation and repeated
|
||||
@@ -488,7 +488,7 @@ impl RenderResourceCache {
|
||||
fn seed_content_image_intrinsics(
|
||||
&mut self,
|
||||
tree: &DomTree,
|
||||
intrinsic: &mut HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||
intrinsic: &mut HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
|
||||
selected: &mut HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||
) -> HashSet<obscura_dom::tree::NodeId> {
|
||||
let remembered = self
|
||||
@@ -506,7 +506,7 @@ impl RenderResourceCache {
|
||||
self.forget_content_image_intrinsic(nid);
|
||||
continue;
|
||||
}
|
||||
intrinsic.insert(nid, value.dimensions);
|
||||
intrinsic.insert(nid, value.intrinsic);
|
||||
selected.insert(
|
||||
nid,
|
||||
SelectedImage {
|
||||
@@ -524,7 +524,7 @@ impl RenderResourceCache {
|
||||
&mut self,
|
||||
nid: obscura_dom::tree::NodeId,
|
||||
resolved_url: String,
|
||||
dimensions: (f32, f32),
|
||||
intrinsic: crate::ReplacedIntrinsic,
|
||||
) {
|
||||
if self.max_content_image_intrinsics == 0 {
|
||||
return;
|
||||
@@ -544,7 +544,7 @@ impl RenderResourceCache {
|
||||
nid,
|
||||
RememberedContentImageIntrinsic {
|
||||
resolved_url,
|
||||
dimensions,
|
||||
intrinsic,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -6920,13 +6920,20 @@ fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
|
||||
fn image_metadata_from_bytes(bytes: &[u8]) -> Option<(f32, f32)> {
|
||||
let (width, height) = image_dimensions(bytes)
|
||||
.map(|(width, height)| (width as f32, height as f32))
|
||||
.or_else(|| svg_intrinsic(bytes))?;
|
||||
image_intrinsic_metadata(bytes)?.natural_size()
|
||||
}
|
||||
|
||||
fn image_intrinsic_metadata(bytes: &[u8]) -> Option<crate::ReplacedIntrinsic> {
|
||||
let metadata = image_dimensions(bytes)
|
||||
.map(|(width, height)| {
|
||||
crate::ReplacedIntrinsic::from_dimensions(width as f32, height as f32)
|
||||
})
|
||||
.or_else(|| svg_image_intrinsic_metadata(bytes))?;
|
||||
let (width, height) = metadata.natural_size()?;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((width, height))
|
||||
Some(metadata)
|
||||
}
|
||||
|
||||
fn background_image_rect(
|
||||
@@ -7546,7 +7553,7 @@ fn collect_image_intrinsics(
|
||||
base_url: Option<&str>,
|
||||
cache: &mut RenderResourceCache,
|
||||
) -> (
|
||||
HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||
HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
|
||||
HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||
) {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
@@ -7578,16 +7585,12 @@ fn collect_image_intrinsics(
|
||||
let Some(bytes) = fetch_profiled_image_bytes(&resolved_url, None, cache, profile) else {
|
||||
continue;
|
||||
};
|
||||
let dimensions = image_dimensions(&bytes)
|
||||
.map(|(width, height)| (width as f32, height as f32))
|
||||
.or_else(|| svg_intrinsic(&bytes));
|
||||
if let Some((w, h)) = dimensions {
|
||||
if w > 0.0 && h > 0.0 {
|
||||
// A 2x (or w-descriptor) candidate's raw pixels are density
|
||||
// times its CSS size; divide so layout sees CSS px, or every
|
||||
// responsive image occupies twice its design size.
|
||||
out.insert(nid, (w / density, h / density));
|
||||
}
|
||||
if let Some(mut intrinsic) = image_intrinsic_metadata(&bytes) {
|
||||
// A 2x (or w-descriptor) candidate's raw pixels are density times
|
||||
// its CSS size. A ratio is dimensionless and remains unchanged.
|
||||
intrinsic.width = intrinsic.width.map(|width| width / density);
|
||||
intrinsic.height = intrinsic.height.map(|height| height / density);
|
||||
out.insert(nid, intrinsic);
|
||||
}
|
||||
}
|
||||
(out, selected)
|
||||
@@ -7601,9 +7604,9 @@ fn collect_content_image_intrinsics(
|
||||
styles: &std::collections::HashMap<obscura_dom::tree::NodeId, crate::LayoutStyle>,
|
||||
base_url: Option<&str>,
|
||||
cache: &mut RenderResourceCache,
|
||||
out: &mut std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||
out: &mut std::collections::HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
|
||||
selected: &mut HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||
source_intrinsic: &HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||
source_intrinsic: &HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
|
||||
source_selected: &HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||
seeded: &HashSet<obscura_dom::tree::NodeId>,
|
||||
) -> bool {
|
||||
@@ -7645,23 +7648,14 @@ fn collect_content_image_intrinsics(
|
||||
cache.forget_content_image_intrinsic(nid);
|
||||
continue;
|
||||
};
|
||||
let dimensions = image_dimensions(&bytes)
|
||||
.map(|(width, height)| (width as f32, height as f32))
|
||||
.or_else(|| svg_intrinsic(&bytes));
|
||||
let Some((width, height)) = dimensions else {
|
||||
let Some(intrinsic) = image_intrinsic_metadata(&bytes) else {
|
||||
changed |= previous_dimensions.is_some() || remembered_url_changed;
|
||||
cache.forget_content_image_intrinsic(nid);
|
||||
continue;
|
||||
};
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
changed |= previous_dimensions.is_some() || remembered_url_changed;
|
||||
cache.forget_content_image_intrinsic(nid);
|
||||
continue;
|
||||
}
|
||||
let dimensions = (width, height);
|
||||
changed |= previous_dimensions != Some(dimensions) || remembered_url_changed;
|
||||
out.insert(nid, dimensions);
|
||||
cache.remember_content_image_intrinsic(nid, resolved_url, dimensions);
|
||||
changed |= previous_dimensions != Some(intrinsic) || remembered_url_changed;
|
||||
out.insert(nid, intrinsic);
|
||||
cache.remember_content_image_intrinsic(nid, resolved_url, intrinsic);
|
||||
}
|
||||
|
||||
// A remembered selection whose computed content disappeared must stop
|
||||
@@ -8125,6 +8119,173 @@ fn svg_intrinsic(bytes: &[u8]) -> Option<(f32, f32)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read intrinsic SVG dimensions without treating `viewBox` user-space
|
||||
/// coordinates as CSS-pixel dimensions. `usvg::Tree::size()` necessarily
|
||||
/// resolves percentage/missing root dimensions to its rendering viewport, so
|
||||
/// it cannot preserve the distinction required by CSS replaced sizing.
|
||||
fn svg_image_intrinsic_metadata(bytes: &[u8]) -> Option<crate::ReplacedIntrinsic> {
|
||||
// The lightweight attribute pass below preserves missing/percentage axes,
|
||||
// which `Tree::size()` necessarily resolves. It must not, however, turn a
|
||||
// malformed XML prefix that merely resembles an SVG root into successful
|
||||
// image metadata. Use the same parser as paint as the validity gate first.
|
||||
let _validated = usvg::Tree::from_data(bytes, &usvg::Options::default()).ok()?;
|
||||
let source = std::str::from_utf8(bytes).ok()?;
|
||||
let mut remaining = source.trim_start_matches('\u{feff}').trim_start();
|
||||
let tail = loop {
|
||||
if let Some(after) = remaining.strip_prefix("<!--") {
|
||||
let end = after.find("-->")?;
|
||||
remaining = after[end + 3..].trim_start();
|
||||
continue;
|
||||
}
|
||||
if let Some(after) = remaining.strip_prefix("<?") {
|
||||
let end = after.find("?>")?;
|
||||
remaining = after[end + 2..].trim_start();
|
||||
continue;
|
||||
}
|
||||
if remaining.starts_with("<!") {
|
||||
// Skip a validated DOCTYPE/declaration, including an internal
|
||||
// subset whose quoted declarations may themselves contain `>`.
|
||||
let mut quote = None;
|
||||
let mut subset_depth = 0usize;
|
||||
let end = remaining.char_indices().find_map(|(index, ch)| match quote {
|
||||
Some(open) if ch == open => {
|
||||
quote = None;
|
||||
None
|
||||
}
|
||||
Some(_) => None,
|
||||
None if ch == '\'' || ch == '"' => {
|
||||
quote = Some(ch);
|
||||
None
|
||||
}
|
||||
None if ch == '[' => {
|
||||
subset_depth += 1;
|
||||
None
|
||||
}
|
||||
None if ch == ']' => {
|
||||
subset_depth = subset_depth.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
None if ch == '>' && subset_depth == 0 => Some(index),
|
||||
None => None,
|
||||
})?;
|
||||
remaining = remaining[end + 1..].trim_start();
|
||||
continue;
|
||||
}
|
||||
let after_open = remaining.strip_prefix('<')?;
|
||||
let name_end = after_open
|
||||
.find(|ch: char| ch.is_ascii_whitespace() || matches!(ch, '/' | '>'))
|
||||
.unwrap_or(after_open.len());
|
||||
let root_name = &after_open[..name_end];
|
||||
if root_name.rsplit(':').next()? != "svg" {
|
||||
return None;
|
||||
}
|
||||
break &after_open[name_end..];
|
||||
};
|
||||
let mut quote = None;
|
||||
let end = tail.char_indices().find_map(|(index, ch)| match quote {
|
||||
Some(open) if ch == open => {
|
||||
quote = None;
|
||||
None
|
||||
}
|
||||
Some(_) => None,
|
||||
None if ch == '\'' || ch == '"' => {
|
||||
quote = Some(ch);
|
||||
None
|
||||
}
|
||||
None if ch == '>' => Some(index),
|
||||
None => None,
|
||||
})?;
|
||||
let attributes = &tail[..end];
|
||||
let attribute = |name: &str| -> Option<&str> {
|
||||
let bytes = attributes.as_bytes();
|
||||
let mut cursor = 0;
|
||||
while cursor < bytes.len() {
|
||||
while cursor < bytes.len()
|
||||
&& (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b'/')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
let name_start = cursor;
|
||||
while cursor < bytes.len()
|
||||
&& !bytes[cursor].is_ascii_whitespace()
|
||||
&& bytes[cursor] != b'='
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
let found = &attributes[name_start..cursor];
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() || bytes[cursor] != b'=' {
|
||||
continue;
|
||||
}
|
||||
cursor += 1;
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() || !matches!(bytes[cursor], b'\'' | b'"') {
|
||||
continue;
|
||||
}
|
||||
let quote = bytes[cursor];
|
||||
cursor += 1;
|
||||
let value_start = cursor;
|
||||
while cursor < bytes.len() && bytes[cursor] != quote {
|
||||
cursor += 1;
|
||||
}
|
||||
let value = &attributes[value_start..cursor];
|
||||
cursor = cursor.saturating_add(1);
|
||||
if found == name {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
let length = |value: &str| -> Option<f32> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.ends_with('%') || value.eq_ignore_ascii_case("auto") {
|
||||
return None;
|
||||
}
|
||||
let split = value
|
||||
.find(|ch: char| !(ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E')))
|
||||
.unwrap_or(value.len());
|
||||
let number = value[..split].parse::<f32>().ok()?;
|
||||
let factor = match value[split..].trim().to_ascii_lowercase().as_str() {
|
||||
"" | "px" => 1.0,
|
||||
"in" => 96.0,
|
||||
"cm" => 96.0 / 2.54,
|
||||
"mm" => 96.0 / 25.4,
|
||||
"q" => 96.0 / 101.6,
|
||||
"pt" => 96.0 / 72.0,
|
||||
"pc" => 16.0,
|
||||
_ => return None,
|
||||
};
|
||||
let result = number * factor;
|
||||
(result.is_finite() && result > 0.0).then_some(result)
|
||||
};
|
||||
let width = attribute("width").and_then(length);
|
||||
let height = attribute("height").and_then(length);
|
||||
let view_box_ratio = attribute("viewBox").and_then(|value| {
|
||||
let values = value
|
||||
.split(|ch: char| ch.is_ascii_whitespace() || ch == ',')
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::parse::<f32>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.ok()?;
|
||||
(values.len() == 4 && values[2].is_finite() && values[3].is_finite()
|
||||
&& values[2] > 0.0 && values[3] > 0.0)
|
||||
.then_some(values[2] / values[3])
|
||||
});
|
||||
let ratio = match (width, height) {
|
||||
(Some(width), Some(height)) => Some(width / height),
|
||||
_ => view_box_ratio,
|
||||
};
|
||||
Some(crate::ReplacedIntrinsic {
|
||||
width,
|
||||
height,
|
||||
ratio,
|
||||
})
|
||||
}
|
||||
|
||||
/// A full-pixmap clip mask admitting only the pixels inside `rect`, used to
|
||||
/// crop an `object-fit: cover|none` image to its element box.
|
||||
fn box_clip_mask(pw: u32, ph: u32, rect: &crate::Rect) -> Option<tiny_skia::Mask> {
|
||||
@@ -9152,6 +9313,72 @@ mod tests {
|
||||
use crate::dom::layout_dom_with_web_fonts;
|
||||
use obscura_dom::tree_sink::parse_html;
|
||||
|
||||
#[test]
|
||||
fn svg_image_metadata_keeps_view_box_as_ratio_only() {
|
||||
let ratio_only = svg_image_intrinsic_metadata(
|
||||
br#"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 576'/>"#,
|
||||
)
|
||||
.expect("ratio-only SVG metadata");
|
||||
assert_eq!(ratio_only.width, None);
|
||||
assert_eq!(ratio_only.height, None);
|
||||
assert_eq!(ratio_only.ratio, Some(1.0));
|
||||
assert_eq!(ratio_only.natural_size(), Some((150.0, 150.0)));
|
||||
|
||||
let explicit = svg_image_intrinsic_metadata(
|
||||
br#"<svg xmlns='http://www.w3.org/2000/svg' width='120' height='80' viewBox='0 0 200 100'/>"#,
|
||||
)
|
||||
.expect("explicit SVG metadata");
|
||||
assert_eq!(explicit.width, Some(120.0));
|
||||
assert_eq!(explicit.height, Some(80.0));
|
||||
assert_eq!(explicit.ratio, Some(1.5));
|
||||
assert_eq!(explicit.natural_size(), Some((120.0, 80.0)));
|
||||
|
||||
let commented = svg_image_intrinsic_metadata(
|
||||
br#"<!-- <svg width='999' height='999'/> --><svg xmlns='http://www.w3.org/2000/svg' width='12' height='8'/>"#,
|
||||
)
|
||||
.expect("the real root after a comment");
|
||||
assert_eq!(commented.natural_size(), Some((12.0, 8.0)));
|
||||
|
||||
assert!(svg_image_intrinsic_metadata(
|
||||
br#"<svg xmlns='http://www.w3.org/2000/svg' width='10' height='20'><g>"#,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_box_only_svg_transfers_definite_css_width_without_using_view_box_units() {
|
||||
let square = br#"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'/>"#;
|
||||
assert_eq!(
|
||||
image_metadata_from_bytes(square),
|
||||
Some((150.0, 150.0)),
|
||||
"natural dimensions contain the ratio in the 300x150 default object"
|
||||
);
|
||||
let tree = parse_html(
|
||||
r#"<html><head><style>
|
||||
html,body { margin:0 }
|
||||
#host { width:360px }
|
||||
img { display:block }
|
||||
#ratio { width:100%; height:auto }
|
||||
</style></head><body><div id="host">
|
||||
<img id="auto" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'/%3E">
|
||||
<img id="ratio" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20200%20100'/%3E">
|
||||
<img id="explicit" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='120'%20height='80'%20viewBox='0%200%20200%20100'/%3E">
|
||||
</div></body></html>"#,
|
||||
);
|
||||
let mut resources = RenderResourceCache::default();
|
||||
let prepared = prepare_dom(&tree, (500.0, 400.0), None, &mut resources).expect("layout");
|
||||
let rect = |selector| {
|
||||
let id = tree.query_selector(selector).unwrap().unwrap();
|
||||
prepared.layout.rects[&id]
|
||||
};
|
||||
let auto = rect("#auto");
|
||||
assert_eq!((auto.width, auto.height), (360.0, 360.0));
|
||||
let ratio = rect("#ratio");
|
||||
assert_eq!((ratio.width, ratio.height), (360.0, 180.0));
|
||||
let explicit = rect("#explicit");
|
||||
assert_eq!((explicit.width, explicit.height), (120.0, 80.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_placeholders_honor_default_author_color_opacity_and_value_state() {
|
||||
let tree = parse_html(
|
||||
@@ -10883,11 +11110,27 @@ mod tests {
|
||||
.expect("selector")
|
||||
.expect("image")
|
||||
});
|
||||
resources.remember_content_image_intrinsic(ids[0], "a".into(), (1.0, 1.0));
|
||||
resources.remember_content_image_intrinsic(ids[1], "b".into(), (2.0, 2.0));
|
||||
resources.remember_content_image_intrinsic(
|
||||
ids[0],
|
||||
"a".into(),
|
||||
crate::ReplacedIntrinsic::from_dimensions(1.0, 1.0),
|
||||
);
|
||||
resources.remember_content_image_intrinsic(
|
||||
ids[1],
|
||||
"b".into(),
|
||||
crate::ReplacedIntrinsic::from_dimensions(2.0, 2.0),
|
||||
);
|
||||
// Refreshing id 1 makes id 2 the oldest entry.
|
||||
resources.remember_content_image_intrinsic(ids[0], "a2".into(), (3.0, 3.0));
|
||||
resources.remember_content_image_intrinsic(ids[2], "c".into(), (4.0, 4.0));
|
||||
resources.remember_content_image_intrinsic(
|
||||
ids[0],
|
||||
"a2".into(),
|
||||
crate::ReplacedIntrinsic::from_dimensions(3.0, 3.0),
|
||||
);
|
||||
resources.remember_content_image_intrinsic(
|
||||
ids[2],
|
||||
"c".into(),
|
||||
crate::ReplacedIntrinsic::from_dimensions(4.0, 4.0),
|
||||
);
|
||||
|
||||
assert_eq!(resources.content_image_intrinsics.len(), 2);
|
||||
assert!(resources.content_image_intrinsics.contains_key(&ids[0]));
|
||||
|
||||
Reference in new issue
Block a user