Prepare reusable resource-aware renders
This commit is contained in:
1 parent
a008a484d3
commit
4eecbe870e
2 files changed
+547
-122
No files matched your search
@@ -27,7 +27,11 @@ pub use dom::{
|
|||||||
#[cfg(feature = "paint")]
|
#[cfg(feature = "paint")]
|
||||||
mod paint;
|
mod paint;
|
||||||
#[cfg(feature = "paint")]
|
#[cfg(feature = "paint")]
|
||||||
pub use paint::{paint_dom, paint_dom_scrolled, screenshot_png, screenshot_png_scrolled};
|
pub use paint::{
|
||||||
|
paint_dom, paint_dom_scrolled, paint_prepared, prepare_dom, screenshot_png,
|
||||||
|
screenshot_png_scrolled, screenshot_prepared, PreparedRender, RenderResourceCache,
|
||||||
|
RenderResourceLoader, SelectedImage,
|
||||||
|
};
|
||||||
|
|
||||||
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
||||||
// layout-only build keeps the lighter word-split geometry. The stub lets
|
// layout-only build keeps the lighter word-split geometry. The stub lets
|
||||||
|
|||||||
+542
-121
@@ -8,6 +8,8 @@
|
|||||||
use obscura_dom::tree::DomTree;
|
use obscura_dom::tree::DomTree;
|
||||||
use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, RadialGradient, Rect, SpreadMode, Transform};
|
use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, RadialGradient, Rect, SpreadMode, Transform};
|
||||||
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
|
static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
|
||||||
static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf");
|
static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf");
|
||||||
@@ -18,6 +20,269 @@ static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-san
|
|||||||
|
|
||||||
use crate::dom::layout_dom_with_web_fonts;
|
use crate::dom::layout_dom_with_web_fonts;
|
||||||
|
|
||||||
|
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
|
||||||
|
const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Synchronous byte loader used by [`RenderResourceCache`]. The default
|
||||||
|
/// implementation uses Obscura's pooled image agent; tests and embedding
|
||||||
|
/// callers can provide a local loader without changing preparation or paint.
|
||||||
|
pub trait RenderResourceLoader {
|
||||||
|
fn load(&mut self, url: &str) -> Option<Vec<u8>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F> RenderResourceLoader for F
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<Vec<u8>>,
|
||||||
|
{
|
||||||
|
fn load(&mut self, url: &str) -> Option<Vec<u8>> {
|
||||||
|
self(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpResourceLoader;
|
||||||
|
|
||||||
|
impl RenderResourceLoader for HttpResourceLoader {
|
||||||
|
fn load(&mut self, url: &str) -> Option<Vec<u8>> {
|
||||||
|
http_get_bytes(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CachedResource {
|
||||||
|
Bytes(Arc<[u8]>),
|
||||||
|
Missing(std::time::Instant),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page-scoped raw resource bytes shared by layout preparation and repeated
|
||||||
|
/// paints. Entries are FIFO-bounded by both count and retained byte size.
|
||||||
|
/// Successful bytes use `Arc` so consumers never clone an image/font body.
|
||||||
|
pub struct RenderResourceCache {
|
||||||
|
entries: HashMap<String, CachedResource>,
|
||||||
|
order: VecDeque<String>,
|
||||||
|
retained_bytes: usize,
|
||||||
|
max_entries: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
loader: Box<dyn RenderResourceLoader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RenderResourceCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::with_loader_and_limits(
|
||||||
|
HttpResourceLoader,
|
||||||
|
DEFAULT_RESOURCE_CACHE_ENTRIES,
|
||||||
|
DEFAULT_RESOURCE_CACHE_BYTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderResourceCache {
|
||||||
|
pub fn with_loader(loader: impl RenderResourceLoader + 'static) -> Self {
|
||||||
|
Self::with_loader_and_limits(
|
||||||
|
loader,
|
||||||
|
DEFAULT_RESOURCE_CACHE_ENTRIES,
|
||||||
|
DEFAULT_RESOURCE_CACHE_BYTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_loader_and_limits(
|
||||||
|
loader: impl RenderResourceLoader + 'static,
|
||||||
|
max_entries: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
order: VecDeque::new(),
|
||||||
|
retained_bytes: 0,
|
||||||
|
max_entries,
|
||||||
|
max_bytes,
|
||||||
|
loader: Box::new(loader),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retained_entry_count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retained_byte_len(&self) -> usize {
|
||||||
|
self.retained_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
|
||||||
|
const MISSING_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
if let Some(entry) = self.entries.get(url) {
|
||||||
|
match entry {
|
||||||
|
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
|
||||||
|
CachedResource::Missing(at) if at.elapsed() < MISSING_RETRY_AFTER => return None,
|
||||||
|
CachedResource::Missing(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.remove(url);
|
||||||
|
|
||||||
|
let loaded = self.loader.load(url).map(Arc::<[u8]>::from);
|
||||||
|
match loaded {
|
||||||
|
Some(bytes) => {
|
||||||
|
self.insert_bytes(url.to_string(), Arc::clone(&bytes));
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.insert_missing(url.to_string());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_bytes(&mut self, url: String, bytes: Arc<[u8]>) {
|
||||||
|
if self.max_entries == 0 || bytes.len() > self.max_bytes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while self.entries.len() >= self.max_entries
|
||||||
|
|| self.retained_bytes.saturating_add(bytes.len()) > self.max_bytes
|
||||||
|
{
|
||||||
|
let Some(oldest) = self.order.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.remove_entry(&oldest);
|
||||||
|
}
|
||||||
|
self.retained_bytes = self.retained_bytes.saturating_add(bytes.len());
|
||||||
|
self.order.push_back(url.clone());
|
||||||
|
self.entries.insert(url, CachedResource::Bytes(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_missing(&mut self, url: String) {
|
||||||
|
if self.max_entries == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while self.entries.len() >= self.max_entries {
|
||||||
|
let Some(oldest) = self.order.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.remove_entry(&oldest);
|
||||||
|
}
|
||||||
|
self.order.push_back(url.clone());
|
||||||
|
self.entries
|
||||||
|
.insert(url, CachedResource::Missing(std::time::Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&mut self, url: &str) {
|
||||||
|
if self.entries.contains_key(url) {
|
||||||
|
self.order.retain(|key| key != url);
|
||||||
|
self.remove_entry(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_entry(&mut self, url: &str) {
|
||||||
|
if let Some(CachedResource::Bytes(bytes)) = self.entries.remove(url) {
|
||||||
|
self.retained_bytes = self.retained_bytes.saturating_sub(bytes.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact responsive image candidate chosen during preparation.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct SelectedImage {
|
||||||
|
pub resolved_url: String,
|
||||||
|
pub density: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A final image/font-aware document layout retained across viewport paints.
|
||||||
|
/// The DOM must not be mutated while this value is reused.
|
||||||
|
pub struct PreparedRender {
|
||||||
|
viewport: (f32, f32),
|
||||||
|
base_url: Option<String>,
|
||||||
|
content_size: (f32, f32),
|
||||||
|
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
|
sticky: crate::StickyLayout,
|
||||||
|
selected_images: HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||||
|
svg_fonts: Arc<usvg::fontdb::Database>,
|
||||||
|
layout: crate::DomLayout,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreparedRender {
|
||||||
|
pub fn viewport(&self) -> (f32, f32) {
|
||||||
|
self.viewport
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> Option<&str> {
|
||||||
|
self.base_url.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout(&self) -> &crate::DomLayout {
|
||||||
|
&self.layout
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_size(&self) -> (f32, f32) {
|
||||||
|
self.content_size
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn viewport_fixed_nodes(
|
||||||
|
&self,
|
||||||
|
) -> &std::collections::HashSet<obscura_dom::tree::NodeId> {
|
||||||
|
&self.viewport_fixed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sticky_layout(&self) -> &crate::StickyLayout {
|
||||||
|
&self.sticky
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) {
|
||||||
|
let clamp_axis = |requested: f32, content: f32, viewport: f32| {
|
||||||
|
if requested.is_finite() {
|
||||||
|
requested.clamp(0.0, (content - viewport).max(0.0))
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(
|
||||||
|
clamp_axis(requested.0, self.content_size.0, self.viewport.0),
|
||||||
|
clamp_axis(requested.1, self.content_size.1, self.viewport.1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border box in immutable document space, including authored translate
|
||||||
|
/// transforms but excluding root-scroll and sticky movement.
|
||||||
|
pub fn document_rect(
|
||||||
|
&self,
|
||||||
|
id: obscura_dom::tree::NodeId,
|
||||||
|
) -> Option<crate::Rect> {
|
||||||
|
let rect = *self.layout.rects.get(&id)?;
|
||||||
|
let offset = self
|
||||||
|
.layout
|
||||||
|
.translates
|
||||||
|
.get(&id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or((0.0, 0.0));
|
||||||
|
Some(crate::Rect {
|
||||||
|
x: rect.x + offset.0,
|
||||||
|
y: rect.y + offset.1,
|
||||||
|
..rect
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border box in the current root viewport. This is the read-only geometry
|
||||||
|
/// path used by a later CSSOM integration and shares paint's clamped scroll,
|
||||||
|
/// fixed-subtree, and sticky-positioning derivatives.
|
||||||
|
pub fn viewport_rect(
|
||||||
|
&self,
|
||||||
|
id: obscura_dom::tree::NodeId,
|
||||||
|
requested_scroll: (f32, f32),
|
||||||
|
) -> Option<crate::Rect> {
|
||||||
|
let mut rect = self.document_rect(id)?;
|
||||||
|
let scroll = self.clamp_scroll(requested_scroll);
|
||||||
|
if !self.viewport_fixed.contains(&id) {
|
||||||
|
let sticky = self.sticky.translation_for(id, self.viewport, scroll);
|
||||||
|
rect.x += sticky.0 - scroll.0;
|
||||||
|
rect.y += sticky.1 - scroll.1;
|
||||||
|
}
|
||||||
|
Some(rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_image(
|
||||||
|
&self,
|
||||||
|
id: obscura_dom::tree::NodeId,
|
||||||
|
) -> Option<&SelectedImage> {
|
||||||
|
self.selected_images.get(&id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
|
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
|
||||||
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
|
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
|
||||||
/// relative image URLs (`<img src="logo.svg">`) that make up the overwhelming
|
/// relative image URLs (`<img src="logo.svg">`) that make up the overwhelming
|
||||||
@@ -36,21 +301,33 @@ pub fn paint_dom_scrolled(
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
scroll: (f32, f32),
|
scroll: (f32, f32),
|
||||||
) -> Option<Pixmap> {
|
) -> Option<Pixmap> {
|
||||||
let (w, h) = (viewport.0 as u32, viewport.1 as u32);
|
let mut resources = RenderResourceCache::default();
|
||||||
let mut pixmap = Pixmap::new(w, h)?;
|
let mut prepared = prepare_dom(tree, viewport, base_url, &mut resources)?;
|
||||||
pixmap.fill(Color::WHITE);
|
paint_prepared(tree, &mut prepared, &mut resources, scroll)
|
||||||
|
}
|
||||||
|
|
||||||
// The same URL (an icon sprite, a repeated background image) commonly
|
/// Resolve image candidates and web fonts, then create the single final layout
|
||||||
// backs many elements on one page; fetch each distinct URL at most once
|
/// shared by CSS geometry consumers and repeated paint.
|
||||||
// per screenshot. `None` caches a failed fetch too, so a broken image
|
pub fn prepare_dom(
|
||||||
// reference does not retry on every element that references it.
|
tree: &DomTree,
|
||||||
let mut image_cache: std::collections::HashMap<String, Option<Vec<u8>>> = std::collections::HashMap::new();
|
viewport: (f32, f32),
|
||||||
|
base_url: Option<&str>,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
) -> Option<PreparedRender> {
|
||||||
|
if !viewport.0.is_finite()
|
||||||
|
|| !viewport.1.is_finite()
|
||||||
|
|| viewport.0 <= 0.0
|
||||||
|
|| viewport.1 <= 0.0
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
// Fetch <img> bytes up front to learn intrinsic sizes for layout (a
|
// Fetch <img> bytes up front to learn intrinsic sizes for layout (a
|
||||||
// CSS-sized image with no width/height attribute would otherwise be 0x0
|
// CSS-sized image with no width/height attribute would otherwise be 0x0
|
||||||
// and never paint). This seeds the same cache the paint pass reads, so
|
// and never paint). This seeds the same cache the paint pass reads, so
|
||||||
// each URL is still fetched at most once.
|
// each URL is still fetched at most once.
|
||||||
let mut intrinsic = collect_image_intrinsics(tree, viewport, base_url, &mut image_cache);
|
let (mut intrinsic, mut selected_images) =
|
||||||
let fonts = collect_web_fonts(tree, base_url);
|
collect_image_intrinsics(tree, viewport, base_url, resources);
|
||||||
|
let fonts = collect_web_fonts(tree, base_url, resources);
|
||||||
// Most framework pages use web fonts and many decorative SVG icons, but
|
// Most framework pages use web fonts and many decorative SVG icons, but
|
||||||
// only SVG text needs the page font faces. Avoid cloning/loading the page
|
// only SVG text needs the page font faces. Avoid cloning/loading the page
|
||||||
// font database for ordinary icons and HTML-only text.
|
// font database for ordinary icons and HTML-only text.
|
||||||
@@ -68,20 +345,51 @@ pub fn paint_dom_scrolled(
|
|||||||
tree,
|
tree,
|
||||||
&laid.styles,
|
&laid.styles,
|
||||||
base_url,
|
base_url,
|
||||||
&mut image_cache,
|
resources,
|
||||||
&mut intrinsic,
|
&mut intrinsic,
|
||||||
|
&mut selected_images,
|
||||||
) {
|
) {
|
||||||
laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts);
|
laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts);
|
||||||
}
|
}
|
||||||
|
let content_size = laid.scrolling_content_size(tree, viewport);
|
||||||
|
let viewport_fixed = laid.viewport_fixed_nodes(tree);
|
||||||
|
let sticky = laid.root_sticky_layout(tree, viewport);
|
||||||
|
Some(PreparedRender {
|
||||||
|
viewport,
|
||||||
|
base_url: base_url.map(str::to_string),
|
||||||
|
content_size,
|
||||||
|
viewport_fixed,
|
||||||
|
sticky,
|
||||||
|
selected_images,
|
||||||
|
svg_fonts,
|
||||||
|
layout: laid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint one root-scroll position from an already prepared resource-aware
|
||||||
|
/// layout. Resource bytes and glyph caches are reused across calls.
|
||||||
|
pub fn paint_prepared(
|
||||||
|
tree: &DomTree,
|
||||||
|
prepared: &mut PreparedRender,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
scroll: (f32, f32),
|
||||||
|
) -> Option<Pixmap> {
|
||||||
|
let (w, h) = (prepared.viewport.0 as u32, prepared.viewport.1 as u32);
|
||||||
|
let mut pixmap = Pixmap::new(w, h)?;
|
||||||
|
pixmap.fill(Color::WHITE);
|
||||||
paint_laid_dom_scrolled(
|
paint_laid_dom_scrolled(
|
||||||
tree,
|
tree,
|
||||||
viewport,
|
prepared.viewport,
|
||||||
base_url,
|
prepared.base_url.as_deref(),
|
||||||
scroll,
|
scroll,
|
||||||
pixmap,
|
pixmap,
|
||||||
image_cache,
|
resources,
|
||||||
svg_fonts,
|
&prepared.selected_images,
|
||||||
&mut laid,
|
&prepared.svg_fonts,
|
||||||
|
prepared.content_size,
|
||||||
|
&prepared.viewport_fixed,
|
||||||
|
&prepared.sticky,
|
||||||
|
&mut prepared.layout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +402,16 @@ fn paint_laid_dom_scrolled(
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
scroll: (f32, f32),
|
scroll: (f32, f32),
|
||||||
mut pixmap: Pixmap,
|
mut pixmap: Pixmap,
|
||||||
mut image_cache: std::collections::HashMap<String, Option<Vec<u8>>>,
|
image_cache: &mut RenderResourceCache,
|
||||||
svg_fonts: std::sync::Arc<usvg::fontdb::Database>,
|
selected_images: &HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||||
|
svg_fonts: &Arc<usvg::fontdb::Database>,
|
||||||
|
content_size: (f32, f32),
|
||||||
|
viewport_fixed: &std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
|
sticky: &crate::StickyLayout,
|
||||||
laid: &mut crate::DomLayout,
|
laid: &mut crate::DomLayout,
|
||||||
) -> Option<Pixmap> {
|
) -> Option<Pixmap> {
|
||||||
let scroll_state = ScrollPaintState::new(tree, laid, viewport, scroll);
|
let scroll_state =
|
||||||
|
ScrollPaintState::new(viewport, scroll, content_size, viewport_fixed, sticky);
|
||||||
// Only raster images inside an actually rotated/scaled subtree receive an
|
// Only raster images inside an actually rotated/scaled subtree receive an
|
||||||
// affine entry. Ordinary pages pay one fast style scan and allocate no
|
// affine entry. Ordinary pages pay one fast style scan and allocate no
|
||||||
// matrix map; transformed pages do matrix work only along those subtrees.
|
// matrix map; transformed pages do matrix work only along those subtrees.
|
||||||
@@ -242,7 +555,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport,
|
viewport,
|
||||||
root_font_size,
|
root_font_size,
|
||||||
base_url,
|
base_url,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -365,7 +678,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
style.mask_size,
|
style.mask_size,
|
||||||
style.mask_repeat,
|
style.mask_repeat,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
);
|
);
|
||||||
} else if let Some(bg_url) = &style.background_image {
|
} else if let Some(bg_url) = &style.background_image {
|
||||||
if let Some(img_rect) = background_image_rect(
|
if let Some(img_rect) = background_image_rect(
|
||||||
@@ -379,7 +692,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
style.font_size.unwrap_or(16.0),
|
style.font_size.unwrap_or(16.0),
|
||||||
root_font_size,
|
root_font_size,
|
||||||
viewport,
|
viewport,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
) {
|
) {
|
||||||
// A background layer is always clipped to its owner's border
|
// A background layer is always clipped to its owner's border
|
||||||
// box and then to inherited overflow. Keep its full destination
|
// box and then to inherited overflow. Keep its full destination
|
||||||
@@ -397,7 +710,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
&visible,
|
&visible,
|
||||||
crate::ObjectFit::Fill,
|
crate::ObjectFit::Fill,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -416,7 +729,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
root_font_size,
|
root_font_size,
|
||||||
clip,
|
clip,
|
||||||
base_url,
|
base_url,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,25 +783,20 @@ fn paint_laid_dom_scrolled(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if name.local.as_ref() == "img" {
|
if name.local.as_ref() == "img" {
|
||||||
let source = style
|
if let Some(source) = selected_images.get(&nid) {
|
||||||
.content_image
|
|
||||||
.as_ref()
|
|
||||||
.map(|url| (url.clone(), 1.0))
|
|
||||||
.or_else(|| resolve_img_url(tree, nid, viewport));
|
|
||||||
if let Some((src, _density)) = source {
|
|
||||||
// `visible_rect` is the border box already intersected with the
|
// `visible_rect` is the border box already intersected with the
|
||||||
// ancestor overflow clip: the raster must not paint past it (a
|
// ancestor overflow clip: the raster must not paint past it (a
|
||||||
// half-scrolled carousel slide's image otherwise bleeds over
|
// half-scrolled carousel slide's image otherwise bleeds over
|
||||||
// the viewport edge).
|
// the viewport edge).
|
||||||
let painted =
|
let painted =
|
||||||
paint_image(
|
paint_image(
|
||||||
&src,
|
&source.resolved_url,
|
||||||
base_url,
|
None,
|
||||||
&rect,
|
&rect,
|
||||||
&visible_rect,
|
&visible_rect,
|
||||||
style.object_fit,
|
style.object_fit,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
projected_image,
|
projected_image,
|
||||||
);
|
);
|
||||||
// Fall back when the image itself did not paint, following
|
// Fall back when the image itself did not paint, following
|
||||||
@@ -540,7 +848,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
// the standalone document. A document-level/external symbol may
|
// the standalone document. A document-level/external symbol may
|
||||||
// itself contain `currentColor`, and therefore has to be present
|
// itself contain `currentColor`, and therefore has to be present
|
||||||
// when the root color is established.
|
// when the root color is established.
|
||||||
inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache);
|
inject_external_sprites(tree, nid, base_url, &mut markup, image_cache, &mut sprite_cache);
|
||||||
// resvg parses the serialized subtree as a standalone SVG
|
// resvg parses the serialized subtree as a standalone SVG
|
||||||
// document, outside the page's author stylesheet. Preserve the
|
// document, outside the page's author stylesheet. Preserve the
|
||||||
// host element's computed `color` so paths using `currentColor`
|
// host element's computed `color` so paths using `currentColor`
|
||||||
@@ -586,7 +894,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport,
|
viewport,
|
||||||
root_font_size,
|
root_font_size,
|
||||||
base_url,
|
base_url,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -720,7 +1028,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport,
|
viewport,
|
||||||
root_font_size,
|
root_font_size,
|
||||||
base_url,
|
base_url,
|
||||||
&mut image_cache,
|
image_cache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,22 +1073,22 @@ fn paint_laid_dom_scrolled(
|
|||||||
/// accumulating movement when the same prepared document paints more than one
|
/// accumulating movement when the same prepared document paints more than one
|
||||||
/// frame.
|
/// frame.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ScrollPaintState {
|
struct ScrollPaintState<'a> {
|
||||||
scroll: (f32, f32),
|
scroll: (f32, f32),
|
||||||
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
sticky: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
sticky: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
active: bool,
|
active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScrollPaintState {
|
impl<'a> ScrollPaintState<'a> {
|
||||||
fn new(
|
fn new(
|
||||||
tree: &DomTree,
|
|
||||||
laid: &crate::DomLayout,
|
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
requested: (f32, f32),
|
requested: (f32, f32),
|
||||||
|
content: (f32, f32),
|
||||||
|
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
|
sticky_layout: &crate::StickyLayout,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let content = laid.scrolling_content_size(tree, viewport);
|
|
||||||
let scroll_x = if requested.0.is_finite() {
|
let scroll_x = if requested.0.is_finite() {
|
||||||
requested
|
requested
|
||||||
.0
|
.0
|
||||||
@@ -796,12 +1104,11 @@ impl ScrollPaintState {
|
|||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let scroll = (scroll_x, scroll_y);
|
let scroll = (scroll_x, scroll_y);
|
||||||
let sticky_layout = laid.root_sticky_layout(tree, viewport);
|
|
||||||
let active = scroll != (0.0, 0.0) || !sticky_layout.is_empty();
|
let active = scroll != (0.0, 0.0) || !sticky_layout.is_empty();
|
||||||
if !active {
|
if !active {
|
||||||
return Self {
|
return Self {
|
||||||
scroll,
|
scroll,
|
||||||
viewport_fixed: std::collections::HashSet::new(),
|
viewport_fixed,
|
||||||
sticky: std::collections::HashMap::new(),
|
sticky: std::collections::HashMap::new(),
|
||||||
sticky_clips: std::collections::HashMap::new(),
|
sticky_clips: std::collections::HashMap::new(),
|
||||||
active,
|
active,
|
||||||
@@ -812,7 +1119,7 @@ impl ScrollPaintState {
|
|||||||
let sticky_clips = sticky_layout.clip_translations_from(&sticky);
|
let sticky_clips = sticky_layout.clip_translations_from(&sticky);
|
||||||
Self {
|
Self {
|
||||||
scroll,
|
scroll,
|
||||||
viewport_fixed: laid.viewport_fixed_nodes(tree),
|
viewport_fixed,
|
||||||
sticky,
|
sticky,
|
||||||
sticky_clips,
|
sticky_clips,
|
||||||
active,
|
active,
|
||||||
@@ -1223,6 +1530,18 @@ pub fn screenshot_png_scrolled(
|
|||||||
.and_then(|pixmap| pixmap.encode_png().ok())
|
.and_then(|pixmap| pixmap.encode_png().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PNG convenience wrapper for a retained resource-aware layout.
|
||||||
|
pub fn screenshot_prepared(
|
||||||
|
tree: &DomTree,
|
||||||
|
prepared: &mut PreparedRender,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
scroll: (f32, f32),
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
paint_prepared(tree, prepared, resources, scroll)?
|
||||||
|
.encode_png()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// A representative visible color for `background-clip: text` text whose own
|
/// A representative visible color for `background-clip: text` text whose own
|
||||||
/// color is transparent, used on the word-split paint path (the cosmic-text IFC
|
/// color is transparent, used on the word-split paint path (the cosmic-text IFC
|
||||||
/// path samples the gradient per glyph in `inline`). Returns the gradient's mid
|
/// path samples the gradient per glyph in `inline`). Returns the gradient's mid
|
||||||
@@ -1440,30 +1759,39 @@ fn draw_text(
|
|||||||
|
|
||||||
/// Resolve `src` (a `data:` URI, or an absolute/relative URL against
|
/// Resolve `src` (a `data:` URI, or an absolute/relative URL against
|
||||||
/// `base_url`) to raw bytes, fetching over the network at most once per
|
/// `base_url`) to raw bytes, fetching over the network at most once per
|
||||||
/// distinct URL per screenshot via `cache`.
|
/// distinct URL through the retained resource cache.
|
||||||
fn fetch_bytes(
|
fn fetch_bytes(
|
||||||
src: &str,
|
src: &str,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Arc<[u8]>> {
|
||||||
if let Some(rest) = src.strip_prefix("data:") {
|
if let Some(rest) = src.strip_prefix("data:") {
|
||||||
let comma_idx = rest.find(',')?;
|
let comma_idx = rest.find(',')?;
|
||||||
let (meta, data) = (&rest[..comma_idx], &rest[comma_idx + 1..]);
|
let (meta, data) = (&rest[..comma_idx], &rest[comma_idx + 1..]);
|
||||||
// Data-backed SVGs and web fonts may be base64 or percent-escaped.
|
// Data-backed SVGs and web fonts may be base64 or percent-escaped.
|
||||||
// Decode from the encoding label rather than assuming every data URI
|
// Decode from the encoding label rather than assuming every data URI
|
||||||
// is base64.
|
// is base64.
|
||||||
return if meta.contains("base64") {
|
let bytes = if meta.contains("base64") {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
base64::engine::general_purpose::STANDARD.decode(data).ok()
|
base64::engine::general_purpose::STANDARD.decode(data).ok()
|
||||||
} else {
|
} else {
|
||||||
Some(percent_decode(data))
|
Some(percent_decode(data))
|
||||||
};
|
}?;
|
||||||
|
return Some(Arc::from(bytes));
|
||||||
|
}
|
||||||
|
let resolved = resolve_resource_url(src, base_url)?;
|
||||||
|
cache.get_or_load(&resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_resource_url(src: &str, base_url: Option<&str>) -> Option<String> {
|
||||||
|
if src.starts_with("data:") {
|
||||||
|
return Some(src.to_string());
|
||||||
}
|
}
|
||||||
// Resolve relative to the document's base URL: the overwhelming majority
|
// Resolve relative to the document's base URL: the overwhelming majority
|
||||||
// of real markup uses relative image paths ("logo.svg", not
|
// of real markup uses relative image paths ("logo.svg", not
|
||||||
// "https://example.com/logo.svg"), so without this every relative <img>
|
// "https://example.com/logo.svg"), so without this every relative <img>
|
||||||
// or mask/background reference silently fails to fetch.
|
// or mask/background reference silently fails to fetch.
|
||||||
let resolved = if src.starts_with("http://") || src.starts_with("https://") {
|
if src.starts_with("http://") || src.starts_with("https://") {
|
||||||
Some(src.to_string())
|
Some(src.to_string())
|
||||||
} else if let Some(rest) = src.strip_prefix("//") {
|
} else if let Some(rest) = src.strip_prefix("//") {
|
||||||
// Protocol-relative URL (`//upload.wikimedia.org/...`, ubiquitous on
|
// Protocol-relative URL (`//upload.wikimedia.org/...`, ubiquitous on
|
||||||
@@ -1481,16 +1809,7 @@ fn fetch_bytes(
|
|||||||
.and_then(|b| url::Url::parse(b).ok())
|
.and_then(|b| url::Url::parse(b).ok())
|
||||||
.and_then(|base| base.join(src).ok())
|
.and_then(|base| base.join(src).ok())
|
||||||
.map(|u| u.to_string())
|
.map(|u| u.to_string())
|
||||||
};
|
}
|
||||||
// The same icon/sprite/background is routinely referenced by dozens of
|
|
||||||
// elements on one page (every story's vote arrow, every repeated logo);
|
|
||||||
// fetch each distinct URL over the network once per screenshot rather
|
|
||||||
// than once per element.
|
|
||||||
let url = resolved?;
|
|
||||||
cache
|
|
||||||
.entry(url.clone())
|
|
||||||
.or_insert_with(|| http_get_bytes(&url))
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the Latin/ASCII face from each authored `@font-face` rule and decode
|
/// Fetch the Latin/ASCII face from each authored `@font-face` rule and decode
|
||||||
@@ -1498,8 +1817,11 @@ fn fetch_bytes(
|
|||||||
/// filtering is load-bearing for performance: generated font packages commonly
|
/// filtering is load-bearing for performance: generated font packages commonly
|
||||||
/// emit six or seven script subsets per face, while an English page needs only
|
/// emit six or seven script subsets per face, while an English page needs only
|
||||||
/// the subset containing ASCII.
|
/// the subset containing ASCII.
|
||||||
fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<crate::inline::WebFont> {
|
fn collect_web_fonts(
|
||||||
let mut cache = std::collections::HashMap::new();
|
tree: &DomTree,
|
||||||
|
base_url: Option<&str>,
|
||||||
|
cache: &mut RenderResourceCache,
|
||||||
|
) -> Vec<crate::inline::WebFont> {
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let mut fonts = Vec::new();
|
let mut fonts = Vec::new();
|
||||||
let mut rules = Vec::new();
|
let mut rules = Vec::new();
|
||||||
@@ -1561,7 +1883,7 @@ fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<crate::inlin
|
|||||||
if !seen.insert(key.clone()) {
|
if !seen.insert(key.clone()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(decoded) = fetch_and_decode_font(src, base_url, &mut cache) {
|
if let Some(decoded) = fetch_and_decode_font(src, base_url, cache) {
|
||||||
let metadata = rules.iter().find(|rule| rule.0 == key);
|
let metadata = rules.iter().find(|rule| rule.0 == key);
|
||||||
fonts.push(crate::inline::WebFont {
|
fonts.push(crate::inline::WebFont {
|
||||||
data: decoded,
|
data: decoded,
|
||||||
@@ -1579,7 +1901,7 @@ fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<crate::inlin
|
|||||||
if !seen.insert(key) {
|
if !seen.insert(key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(decoded) = fetch_and_decode_font(&src, base_url, &mut cache) {
|
if let Some(decoded) = fetch_and_decode_font(&src, base_url, cache) {
|
||||||
fonts.push(crate::inline::WebFont {
|
fonts.push(crate::inline::WebFont {
|
||||||
data: decoded,
|
data: decoded,
|
||||||
family,
|
family,
|
||||||
@@ -1606,7 +1928,7 @@ fn font_resource_key(src: &str, base_url: Option<&str>) -> String {
|
|||||||
fn fetch_and_decode_font(
|
fn fetch_and_decode_font(
|
||||||
src: &str,
|
src: &str,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
let compressed = fetch_bytes(src, base_url, cache)?;
|
let compressed = fetch_bytes(src, base_url, cache)?;
|
||||||
if compressed.len() > 8 * 1024 * 1024 {
|
if compressed.len() > 8 * 1024 * 1024 {
|
||||||
@@ -1617,7 +1939,7 @@ fn fetch_and_decode_font(
|
|||||||
Some(b"wOFF") => wuff::decompress_woff1(&compressed).ok(),
|
Some(b"wOFF") => wuff::decompress_woff1(&compressed).ok(),
|
||||||
// TrueType/OpenType collections and raw sfnt fonts already have the
|
// TrueType/OpenType collections and raw sfnt fonts already have the
|
||||||
// representation fontdb expects.
|
// representation fontdb expects.
|
||||||
Some(b"\0\x01\0\0" | b"OTTO" | b"ttcf") => Some(compressed),
|
Some(b"\0\x01\0\0" | b"OTTO" | b"ttcf") => Some(compressed.as_ref().to_vec()),
|
||||||
_ => None,
|
_ => None,
|
||||||
}?;
|
}?;
|
||||||
(decoded.len() <= 32 * 1024 * 1024).then_some(decoded)
|
(decoded.len() <= 32 * 1024 * 1024).then_some(decoded)
|
||||||
@@ -2191,7 +2513,7 @@ fn background_image_rect(
|
|||||||
em: f32,
|
em: f32,
|
||||||
rem: f32,
|
rem: f32,
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
) -> Option<crate::Rect> {
|
) -> Option<crate::Rect> {
|
||||||
let bytes = fetch_bytes(src, base_url, cache)?;
|
let bytes = fetch_bytes(src, base_url, cache)?;
|
||||||
let intrinsic = if is_svg(&bytes) {
|
let intrinsic = if is_svg(&bytes) {
|
||||||
@@ -2294,7 +2616,7 @@ fn paint_in_flow_generated_box(
|
|||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
root_font_size: f32,
|
root_font_size: f32,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
image_cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
image_cache: &mut RenderResourceCache,
|
||||||
) {
|
) {
|
||||||
let Some(host_style) = laid.styles.get(&generated.host) else {
|
let Some(host_style) = laid.styles.get(&generated.host) else {
|
||||||
return;
|
return;
|
||||||
@@ -2497,7 +2819,7 @@ fn paint_positioned_pseudo(
|
|||||||
root_font_size: f32,
|
root_font_size: f32,
|
||||||
ancestor_clip: Option<crate::Rect>,
|
ancestor_clip: Option<crate::Rect>,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
image_cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
image_cache: &mut RenderResourceCache,
|
||||||
) {
|
) {
|
||||||
if style.position != Some(taffy::Position::Absolute) {
|
if style.position != Some(taffy::Position::Absolute) {
|
||||||
return;
|
return;
|
||||||
@@ -2642,16 +2964,28 @@ fn collect_image_intrinsics(
|
|||||||
tree: &DomTree,
|
tree: &DomTree,
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
) -> std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)> {
|
) -> (
|
||||||
|
HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
|
HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||||
|
) {
|
||||||
let mut out = std::collections::HashMap::new();
|
let mut out = std::collections::HashMap::new();
|
||||||
|
let mut selected = HashMap::new();
|
||||||
for nid in tree.descendants(tree.document()) {
|
for nid in tree.descendants(tree.document()) {
|
||||||
let Some(node) = tree.get_node(nid) else { continue };
|
let Some(node) = tree.get_node(nid) else { continue };
|
||||||
if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) {
|
if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some((url, density)) = resolve_img_url(tree, nid, viewport) else { continue };
|
let Some((url, density)) = resolve_img_url(tree, nid, viewport) else { continue };
|
||||||
let Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue };
|
let resolved_url = resolve_resource_url(&url, base_url).unwrap_or(url);
|
||||||
|
selected.insert(
|
||||||
|
nid,
|
||||||
|
SelectedImage {
|
||||||
|
resolved_url: resolved_url.clone(),
|
||||||
|
density,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else { continue };
|
||||||
let dimensions = image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32))
|
let dimensions = image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32))
|
||||||
.or_else(|| svg_intrinsic(&bytes));
|
.or_else(|| svg_intrinsic(&bytes));
|
||||||
if let Some((w, h)) = dimensions {
|
if let Some((w, h)) = dimensions {
|
||||||
@@ -2663,7 +2997,7 @@ fn collect_image_intrinsics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
(out, selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add intrinsic metadata for CSS `content:url(...)` images after the first
|
/// Add intrinsic metadata for CSS `content:url(...)` images after the first
|
||||||
@@ -2673,8 +3007,9 @@ fn collect_content_image_intrinsics(
|
|||||||
tree: &DomTree,
|
tree: &DomTree,
|
||||||
styles: &std::collections::HashMap<obscura_dom::tree::NodeId, crate::LayoutStyle>,
|
styles: &std::collections::HashMap<obscura_dom::tree::NodeId, crate::LayoutStyle>,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
out: &mut std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
out: &mut std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
|
selected: &mut HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for (&nid, style) in styles {
|
for (&nid, style) in styles {
|
||||||
@@ -2690,7 +3025,16 @@ fn collect_content_image_intrinsics(
|
|||||||
let Some(url) = style.content_image.as_deref() else {
|
let Some(url) = style.content_image.as_deref() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(bytes) = fetch_bytes(url, base_url, cache) else {
|
let resolved_url =
|
||||||
|
resolve_resource_url(url, base_url).unwrap_or_else(|| url.to_string());
|
||||||
|
selected.insert(
|
||||||
|
nid,
|
||||||
|
SelectedImage {
|
||||||
|
resolved_url: resolved_url.clone(),
|
||||||
|
density: 1.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let dimensions = image_dimensions(&bytes)
|
let dimensions = image_dimensions(&bytes)
|
||||||
@@ -2960,7 +3304,7 @@ fn paint_image(
|
|||||||
visible_rect: &crate::Rect,
|
visible_rect: &crate::Rect,
|
||||||
object_fit: crate::ObjectFit,
|
object_fit: crate::ObjectFit,
|
||||||
pixmap: &mut Pixmap,
|
pixmap: &mut Pixmap,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
transform: Option<ImageAffine>,
|
transform: Option<ImageAffine>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||||
@@ -3507,7 +3851,7 @@ fn inject_external_sprites(
|
|||||||
root: obscura_dom::tree::NodeId,
|
root: obscura_dom::tree::NodeId,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
markup: &mut String,
|
markup: &mut String,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
sprite_cache: &mut std::collections::HashMap<String, Option<String>>,
|
sprite_cache: &mut std::collections::HashMap<String, Option<String>>,
|
||||||
) {
|
) {
|
||||||
// Distinct external references (full href, url, fragment id), in first-seen
|
// Distinct external references (full href, url, fragment id), in first-seen
|
||||||
@@ -3795,7 +4139,7 @@ fn paint_mask(
|
|||||||
mask_size: Option<(f32, f32)>,
|
mask_size: Option<(f32, f32)>,
|
||||||
mask_repeat: Option<(bool, bool)>,
|
mask_repeat: Option<(bool, bool)>,
|
||||||
pixmap: &mut Pixmap,
|
pixmap: &mut Pixmap,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut RenderResourceCache,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||||
return false;
|
return false;
|
||||||
@@ -3916,48 +4260,112 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn repeated_scroll_captures_reuse_immutable_layout_geometry() {
|
fn repeated_scroll_captures_reuse_immutable_layout_geometry() {
|
||||||
let tree = parse_html(
|
let tree = parse_html(
|
||||||
r#"<html style="margin:0"><body style="margin:0">
|
r#"<html style="margin:0"><head><style>
|
||||||
|
@font-face { font-family: Fixture; src: url("https://assets.test/font.ttf"); }
|
||||||
|
body { font-family: Fixture; }
|
||||||
|
</style></head><body style="margin:0">
|
||||||
|
<img id="hero" src="https://assets.test/fallback.svg"
|
||||||
|
srcset="https://assets.test/hero.svg 2x"
|
||||||
|
style="display:block;width:100px;height:auto">
|
||||||
|
<div style="position:sticky;top:0;height:10px;background:#00ff00"></div>
|
||||||
<div style="height:60px;background:#ff0000"></div>
|
<div style="height:60px;background:#ff0000"></div>
|
||||||
<div style="height:180px;overflow:hidden;background:#0000ff">
|
<div style="height:180px;overflow:hidden;background:#0000ff;
|
||||||
|
background-image:url('https://assets.test/background.svg')">
|
||||||
<div style="position:sticky;top:5px;height:20px;background:#00ff00"></div>
|
<div style="position:sticky;top:5px;height:20px;background:#00ff00"></div>
|
||||||
<div style="transform:translate(3px,4px);height:80px;color:#ffffff">stable text</div>
|
<div style="transform:translate(3px,4px);height:80px;color:#ffffff">stable text</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="fixed" style="position:fixed;top:2px;left:2px;width:8px;height:8px"></div>
|
||||||
</body></html>"#,
|
</body></html>"#,
|
||||||
);
|
);
|
||||||
let viewport = (100.0, 80.0);
|
let viewport = (100.0, 80.0);
|
||||||
let mut laid = crate::layout_dom(&tree, viewport);
|
let counts = Arc::new(std::sync::Mutex::new(HashMap::<String, usize>::new()));
|
||||||
let base_rects = laid.rects.clone();
|
let loader_counts = Arc::clone(&counts);
|
||||||
let base_translates = laid.translates.clone();
|
let mut resources = RenderResourceCache::with_loader(move |url: &str| {
|
||||||
let base_clips = laid.clip_rects.clone();
|
*loader_counts
|
||||||
|
.lock()
|
||||||
|
.expect("loader counts")
|
||||||
|
.entry(url.to_string())
|
||||||
|
.or_default() += 1;
|
||||||
|
match url {
|
||||||
|
"https://assets.test/font.ttf" => Some(FONT_BYTES.to_vec()),
|
||||||
|
"https://assets.test/hero.svg" => Some(
|
||||||
|
br##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
|
||||||
|
<rect width="200" height="100" fill="#ffff00"/>
|
||||||
|
</svg>"##
|
||||||
|
.to_vec(),
|
||||||
|
),
|
||||||
|
"https://assets.test/background.svg" => Some(
|
||||||
|
br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">
|
||||||
|
<rect width="20" height="20" fill="#0000ff"/>
|
||||||
|
</svg>"##
|
||||||
|
.to_vec(),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut prepared =
|
||||||
|
prepare_dom(&tree, viewport, None, &mut resources).expect("prepared render");
|
||||||
|
let hero = tree
|
||||||
|
.query_selector("#hero")
|
||||||
|
.expect("valid selector")
|
||||||
|
.expect("hero");
|
||||||
|
assert_eq!(
|
||||||
|
prepared.selected_image(hero),
|
||||||
|
Some(&SelectedImage {
|
||||||
|
resolved_url: "https://assets.test/hero.svg".to_string(),
|
||||||
|
density: 2.0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let hero_rect = prepared.layout().rects.get(&hero).expect("hero rect");
|
||||||
|
assert!((hero_rect.width - 100.0).abs() < 0.1);
|
||||||
|
assert!((hero_rect.height - 50.0).abs() < 0.1);
|
||||||
|
assert!(prepared.content_size().1 > viewport.1);
|
||||||
|
assert!(!prepared.sticky_layout().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
prepared.viewport_rect(hero, (0.0, 20.0)).unwrap().y,
|
||||||
|
prepared.document_rect(hero).unwrap().y - 20.0
|
||||||
|
);
|
||||||
|
let fixed = tree
|
||||||
|
.query_selector("#fixed")
|
||||||
|
.expect("valid selector")
|
||||||
|
.expect("fixed");
|
||||||
|
assert!(prepared.viewport_fixed_nodes().contains(&fixed));
|
||||||
|
assert_eq!(
|
||||||
|
prepared.viewport_rect(fixed, (0.0, 100.0)),
|
||||||
|
prepared.document_rect(fixed)
|
||||||
|
);
|
||||||
|
let base_rects = prepared.layout().rects.clone();
|
||||||
|
let base_translates = prepared.layout().translates.clone();
|
||||||
|
let base_clips = prepared.layout().clip_rects.clone();
|
||||||
|
|
||||||
fn capture(
|
let near = screenshot_prepared(
|
||||||
tree: &DomTree,
|
&tree,
|
||||||
laid: &mut crate::DomLayout,
|
&mut prepared,
|
||||||
viewport: (f32, f32),
|
&mut resources,
|
||||||
scroll: (f32, f32),
|
(0.0, 20.0),
|
||||||
) -> Vec<u8> {
|
)
|
||||||
let mut pixmap =
|
.expect("near capture");
|
||||||
Pixmap::new(viewport.0 as u32, viewport.1 as u32).expect("viewport pixmap");
|
let far = screenshot_prepared(
|
||||||
pixmap.fill(Color::WHITE);
|
&tree,
|
||||||
paint_laid_dom_scrolled(
|
&mut prepared,
|
||||||
tree,
|
&mut resources,
|
||||||
viewport,
|
(0.0, 100.0),
|
||||||
None,
|
)
|
||||||
scroll,
|
.expect("far capture");
|
||||||
pixmap,
|
let far_repeat = screenshot_prepared(
|
||||||
std::collections::HashMap::new(),
|
&tree,
|
||||||
svg_font_database(),
|
&mut prepared,
|
||||||
laid,
|
&mut resources,
|
||||||
)
|
(0.0, 100.0),
|
||||||
.expect("painted viewport")
|
)
|
||||||
.encode_png()
|
.expect("repeated far capture");
|
||||||
.expect("encoded viewport")
|
let near_after_far = screenshot_prepared(
|
||||||
}
|
&tree,
|
||||||
|
&mut prepared,
|
||||||
let near = capture(&tree, &mut laid, viewport, (0.0, 20.0));
|
&mut resources,
|
||||||
let far = capture(&tree, &mut laid, viewport, (0.0, 100.0));
|
(0.0, 20.0),
|
||||||
let far_repeat = capture(&tree, &mut laid, viewport, (0.0, 100.0));
|
)
|
||||||
let near_after_far = capture(&tree, &mut laid, viewport, (0.0, 20.0));
|
.expect("repeated near capture");
|
||||||
|
|
||||||
assert_ne!(near, far, "distinct scroll positions must paint distinct frames");
|
assert_ne!(near, far, "distinct scroll positions must paint distinct frames");
|
||||||
assert_eq!(far, far_repeat, "the same scroll position must be stable");
|
assert_eq!(far, far_repeat, "the same scroll position must be stable");
|
||||||
@@ -3965,9 +4373,20 @@ mod tests {
|
|||||||
near, near_after_far,
|
near, near_after_far,
|
||||||
"an intervening capture must not accumulate scroll movement"
|
"an intervening capture must not accumulate scroll movement"
|
||||||
);
|
);
|
||||||
assert_eq!(laid.rects, base_rects);
|
assert_eq!(prepared.layout().rects, base_rects);
|
||||||
assert_eq!(laid.translates, base_translates);
|
assert_eq!(prepared.layout().translates, base_translates);
|
||||||
assert_eq!(laid.clip_rects, base_clips);
|
assert_eq!(prepared.layout().clip_rects, base_clips);
|
||||||
|
let counts = counts.lock().expect("final loader counts");
|
||||||
|
for url in [
|
||||||
|
"https://assets.test/font.ttf",
|
||||||
|
"https://assets.test/hero.svg",
|
||||||
|
"https://assets.test/background.svg",
|
||||||
|
] {
|
||||||
|
assert_eq!(counts.get(url), Some(&1), "{url} must load exactly once");
|
||||||
|
}
|
||||||
|
assert!(!counts.contains_key("https://assets.test/fallback.svg"));
|
||||||
|
assert_eq!(resources.retained_entry_count(), 3);
|
||||||
|
assert!(resources.retained_byte_len() > FONT_BYTES.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Portable pixel counterpart to the Chromium root-sticky geometry probe
|
/// Portable pixel counterpart to the Chromium root-sticky geometry probe
|
||||||
@@ -4118,7 +4537,7 @@ mod tests {
|
|||||||
width: 132.0,
|
width: 132.0,
|
||||||
height: 60.0,
|
height: 60.0,
|
||||||
};
|
};
|
||||||
let mut cache = std::collections::HashMap::new();
|
let mut cache = RenderResourceCache::default();
|
||||||
let image = background_image_rect(
|
let image = background_image_rect(
|
||||||
source,
|
source,
|
||||||
None,
|
None,
|
||||||
@@ -4228,13 +4647,15 @@ mod tests {
|
|||||||
(host_rect.x, host_rect.y, host_rect.width, host_rect.height),
|
(host_rect.x, host_rect.y, host_rect.width, host_rect.height),
|
||||||
(0.0, 0.0, 100.0, 40.0)
|
(0.0, 0.0, 100.0, 40.0)
|
||||||
);
|
);
|
||||||
let mut cache = std::collections::HashMap::new();
|
let mut cache = RenderResourceCache::default();
|
||||||
|
let mut selected = HashMap::new();
|
||||||
assert!(collect_content_image_intrinsics(
|
assert!(collect_content_image_intrinsics(
|
||||||
&tree,
|
&tree,
|
||||||
&first.styles,
|
&first.styles,
|
||||||
None,
|
None,
|
||||||
&mut cache,
|
&mut cache,
|
||||||
&mut intrinsic,
|
&mut intrinsic,
|
||||||
|
&mut selected,
|
||||||
));
|
));
|
||||||
let laid = layout_dom_with_web_fonts(&tree, (120.0, 80.0), &intrinsic, &[]);
|
let laid = layout_dom_with_web_fonts(&tree, (120.0, 80.0), &intrinsic, &[]);
|
||||||
let image_id = tree
|
let image_id = tree
|
||||||
@@ -4863,7 +5284,7 @@ mod tests {
|
|||||||
let svg = tree.query_selector("svg").unwrap().unwrap();
|
let svg = tree.query_selector("svg").unwrap().unwrap();
|
||||||
let mut markup = serialize_svg(&tree, svg);
|
let mut markup = serialize_svg(&tree, svg);
|
||||||
let before = markup.clone();
|
let before = markup.clone();
|
||||||
let mut cache = std::collections::HashMap::new();
|
let mut cache = RenderResourceCache::default();
|
||||||
let mut sprite_cache = std::collections::HashMap::new();
|
let mut sprite_cache = std::collections::HashMap::new();
|
||||||
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
||||||
assert_eq!(markup, before, "same-document use must be untouched");
|
assert_eq!(markup, before, "same-document use must be untouched");
|
||||||
@@ -4881,7 +5302,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let svg = tree.query_selector("#icon").unwrap().unwrap();
|
let svg = tree.query_selector("#icon").unwrap().unwrap();
|
||||||
let mut markup = serialize_svg(&tree, svg);
|
let mut markup = serialize_svg(&tree, svg);
|
||||||
let mut cache = std::collections::HashMap::new();
|
let mut cache = RenderResourceCache::default();
|
||||||
let mut sprite_cache = std::collections::HashMap::new();
|
let mut sprite_cache = std::collections::HashMap::new();
|
||||||
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4904,7 +5325,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let svg = tree.query_selector("#icon").unwrap().unwrap();
|
let svg = tree.query_selector("#icon").unwrap().unwrap();
|
||||||
let mut markup = serialize_svg(&tree, svg);
|
let mut markup = serialize_svg(&tree, svg);
|
||||||
let mut cache = std::collections::HashMap::new();
|
let mut cache = RenderResourceCache::default();
|
||||||
let mut sprite_cache = std::collections::HashMap::new();
|
let mut sprite_cache = std::collections::HashMap::new();
|
||||||
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
||||||
inject_svg_current_color(&mut markup, [220, 20, 60, 255]);
|
inject_svg_current_color(&mut markup, [220, 20, 60, 255]);
|
||||||
|
|||||||
Reference in new issue
Block a user