Speed up retained rendering and honor PDF backgrounds
This commit is contained in:
1 parent
222b50e480
commit
ede14b7740
9 files changed
+1385
-92
No files matched your search
@@ -21,6 +21,7 @@ const MAX_PDF_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct RasterPdfOptions {
|
pub struct RasterPdfOptions {
|
||||||
pub landscape: bool,
|
pub landscape: bool,
|
||||||
|
pub print_background: bool,
|
||||||
pub paper_width_in: f32,
|
pub paper_width_in: f32,
|
||||||
pub paper_height_in: f32,
|
pub paper_height_in: f32,
|
||||||
pub margin_top_in: f32,
|
pub margin_top_in: f32,
|
||||||
@@ -33,6 +34,7 @@ impl Default for RasterPdfOptions {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
landscape: false,
|
landscape: false,
|
||||||
|
print_background: false,
|
||||||
paper_width_in: 8.5,
|
paper_width_in: 8.5,
|
||||||
paper_height_in: 11.0,
|
paper_height_in: 11.0,
|
||||||
// CDP's defaults are one centimetre.
|
// CDP's defaults are one centimetre.
|
||||||
@@ -232,13 +234,10 @@ impl Page {
|
|||||||
let y = page_index as f32 * plan.css_page_height;
|
let y = page_index as f32 * plan.css_page_height;
|
||||||
let slice_height = (content_height - y).min(plan.css_page_height);
|
let slice_height = (content_height - y).min(plan.css_page_height);
|
||||||
let png = js
|
let png = js
|
||||||
.screenshot_prepared_region(CaptureRegion::new(
|
.screenshot_prepared_region_with_backgrounds(
|
||||||
0.0,
|
CaptureRegion::new(0.0, y, content_width, slice_height, 1.0),
|
||||||
y,
|
options.print_background,
|
||||||
content_width,
|
)
|
||||||
slice_height,
|
|
||||||
1.0,
|
|
||||||
))
|
|
||||||
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
|
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
|
||||||
let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
|
let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
|
||||||
.map_err(|error| RasterPdfError::ImageDecode(error.to_string()))?;
|
.map_err(|error| RasterPdfError::ImageDecode(error.to_string()))?;
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Current Puppeteer and Playwright always send their false default. The
|
|
||||||
// raster paginator cannot suppress backgrounds without a second paint
|
|
||||||
// mode, so accept the protocol shape and disclose the limitation in the
|
|
||||||
// extension capability fields returned with the result.
|
|
||||||
let requested_print_background = boolean(params, "printBackground", false)?;
|
let requested_print_background = boolean(params, "printBackground", false)?;
|
||||||
// Puppeteer currently sends true by default. Raster PDFs have no semantic
|
// Puppeteer currently sends true by default. Raster PDFs have no semantic
|
||||||
// structure to tag, but rejecting the request makes the standard client
|
// structure to tag, but rejecting the request makes the standard client
|
||||||
@@ -114,6 +110,7 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
|
|||||||
Ok(ParsedPdfOptions {
|
Ok(ParsedPdfOptions {
|
||||||
raster: obscura_browser::RasterPdfOptions {
|
raster: obscura_browser::RasterPdfOptions {
|
||||||
landscape: boolean(params, "landscape", defaults.landscape)?,
|
landscape: boolean(params, "landscape", defaults.landscape)?,
|
||||||
|
print_background: requested_print_background,
|
||||||
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
|
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
|
||||||
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
|
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
|
||||||
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
|
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
|
||||||
@@ -144,20 +141,18 @@ pub async fn print_to_pdf(
|
|||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let mut response = json!({
|
let mut response = json!({
|
||||||
"obscuraPrintMode": "screen-raster",
|
"obscuraPrintMode": "screen-raster",
|
||||||
"obscuraPrintBackground": true,
|
"obscuraPrintBackground": options.requested_print_background,
|
||||||
"obscuraRequestedPrintBackground": options.requested_print_background,
|
"obscuraRequestedPrintBackground": options.requested_print_background,
|
||||||
"obscuraTaggedPDF": false,
|
"obscuraTaggedPDF": false,
|
||||||
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
|
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
|
||||||
"obscuraCapabilities": {
|
"obscuraCapabilities": {
|
||||||
"cssPagedMedia": false,
|
"cssPagedMedia": false,
|
||||||
"honorsPrintBackground": false,
|
"honorsPrintBackground": true,
|
||||||
|
"printColorAdjustExact": false,
|
||||||
"taggedPdf": false,
|
"taggedPdf": false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let mut ignored_options = Vec::new();
|
let mut ignored_options = Vec::new();
|
||||||
if !options.requested_print_background {
|
|
||||||
ignored_options.push("printBackground");
|
|
||||||
}
|
|
||||||
if options.requested_tagged_pdf {
|
if options.requested_tagged_pdf {
|
||||||
ignored_options.push("generateTaggedPDF");
|
ignored_options.push("generateTaggedPDF");
|
||||||
}
|
}
|
||||||
@@ -215,6 +210,7 @@ mod tests {
|
|||||||
.expect("current Puppeteer defaults must be accepted");
|
.expect("current Puppeteer defaults must be accepted");
|
||||||
assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream);
|
assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream);
|
||||||
assert!(!standard.requested_print_background);
|
assert!(!standard.requested_print_background);
|
||||||
|
assert!(!standard.raster.print_background);
|
||||||
assert!(standard.requested_tagged_pdf);
|
assert!(standard.requested_tagged_pdf);
|
||||||
|
|
||||||
for params in [
|
for params in [
|
||||||
@@ -315,13 +311,17 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("Puppeteer/Playwright-shaped stream request");
|
.expect("Puppeteer/Playwright-shaped stream request");
|
||||||
assert_eq!(streamed["data"], "");
|
assert_eq!(streamed["data"], "");
|
||||||
assert_eq!(streamed["obscuraPrintBackground"], true);
|
assert_eq!(streamed["obscuraPrintBackground"], false);
|
||||||
assert_eq!(streamed["obscuraRequestedPrintBackground"], false);
|
assert_eq!(streamed["obscuraRequestedPrintBackground"], false);
|
||||||
|
assert_eq!(
|
||||||
|
streamed["obscuraCapabilities"]["honorsPrintBackground"],
|
||||||
|
true
|
||||||
|
);
|
||||||
assert_eq!(streamed["obscuraTaggedPDF"], false);
|
assert_eq!(streamed["obscuraTaggedPDF"], false);
|
||||||
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
|
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
streamed["obscuraIgnoredOptions"],
|
streamed["obscuraIgnoredOptions"],
|
||||||
json!(["printBackground", "generateTaggedPDF"])
|
json!(["generateTaggedPDF"])
|
||||||
);
|
);
|
||||||
let handle = streamed["stream"].as_str().expect("protocol stream handle");
|
let handle = streamed["stream"].as_str().expect("protocol stream handle");
|
||||||
let mut streamed_bytes = Vec::new();
|
let mut streamed_bytes = Vec::new();
|
||||||
|
|||||||
@@ -129,11 +129,11 @@ pub struct ObscuraState {
|
|||||||
/// DOM/style/viewport changes clear this value but retain resource bytes.
|
/// DOM/style/viewport changes clear this value but retain resource bytes.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub prepared_render: Option<obscura_render::PreparedRender>,
|
pub prepared_render: Option<obscura_render::PreparedRender>,
|
||||||
/// Connected attribute mutations awaiting dependency-indexed retained
|
/// Connected mutations awaiting dependency-indexed retained style refresh.
|
||||||
/// style refresh. Tree/text and non-attribute invalidations still discard
|
/// Tree changes carry stable node/parent ids so a later geometry read can
|
||||||
/// the prepared render immediately.
|
/// coalesce framework DOM churn into one conservative local cascade.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub pending_attribute_style_mutations: Vec<obscura_render::AttributeStyleMutation>,
|
pub pending_style_mutations: Vec<obscura_render::RetainedStyleMutation>,
|
||||||
/// Page-lifetime raw image/font bytes. A new document resets this cache;
|
/// Page-lifetime raw image/font bytes. A new document resets this cache;
|
||||||
/// relayout of the same document reuses it without refetching.
|
/// relayout of the same document reuses it without refetching.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
@@ -202,7 +202,7 @@ impl ObscuraState {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
prepared_render: None,
|
prepared_render: None,
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pending_attribute_style_mutations: Vec::new(),
|
pending_style_mutations: Vec::new(),
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
render_resources: obscura_render::RenderResourceCache::default(),
|
render_resources: obscura_render::RenderResourceCache::default(),
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
@@ -473,17 +473,18 @@ fn render_mutation_impact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
fn retained_attribute_style_mutation(
|
fn retained_style_mutation(
|
||||||
dom: &DomTree,
|
dom: &DomTree,
|
||||||
cmd: &str,
|
cmd: &str,
|
||||||
arg1: &str,
|
arg1: &str,
|
||||||
arg2: &str,
|
arg2: &str,
|
||||||
) -> Option<obscura_render::AttributeStyleMutation> {
|
) -> Option<obscura_render::RetainedStyleMutation> {
|
||||||
let node = NodeId::new(arg1.parse::<u32>().ok()?);
|
let node = NodeId::new(arg1.parse::<u32>().ok()?);
|
||||||
let can_retain = |name: &str| {
|
let can_retain = |name: &str| {
|
||||||
let name = name.to_ascii_lowercase();
|
let name = name.to_ascii_lowercase();
|
||||||
name == "class"
|
name == "class"
|
||||||
|| name == "id"
|
|| name == "id"
|
||||||
|
|| name == "style"
|
||||||
|| name.starts_with("data-")
|
|| name.starts_with("data-")
|
||||||
|| name.starts_with("aria-")
|
|| name.starts_with("aria-")
|
||||||
};
|
};
|
||||||
@@ -493,23 +494,78 @@ fn retained_attribute_style_mutation(
|
|||||||
if !can_retain(name) {
|
if !can_retain(name) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let keeps_selector_value = !name.eq_ignore_ascii_case("style");
|
||||||
Some(obscura_render::AttributeStyleMutation {
|
Some(obscura_render::AttributeStyleMutation {
|
||||||
node,
|
node,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
old_value: dom
|
old_value: keeps_selector_value
|
||||||
.with_node(node, |node| node.get_attribute(name).map(str::to_owned))
|
.then(|| {
|
||||||
|
dom.with_node(node, |node| {
|
||||||
|
node.get_attribute(name).map(str::to_owned)
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
.flatten(),
|
.flatten(),
|
||||||
new_value: Some(value.to_string()),
|
new_value: keeps_selector_value.then(|| value.to_string()),
|
||||||
})
|
}
|
||||||
|
.into())
|
||||||
}
|
}
|
||||||
"remove_attribute" => can_retain(arg2).then(|| obscura_render::AttributeStyleMutation {
|
"remove_attribute" => can_retain(arg2).then(|| {
|
||||||
node,
|
let keeps_selector_value = !arg2.eq_ignore_ascii_case("style");
|
||||||
name: arg2.to_string(),
|
obscura_render::AttributeStyleMutation {
|
||||||
old_value: dom
|
node,
|
||||||
.with_node(node, |node| node.get_attribute(arg2).map(str::to_owned))
|
name: arg2.to_string(),
|
||||||
.flatten(),
|
old_value: keeps_selector_value
|
||||||
new_value: None,
|
.then(|| {
|
||||||
|
dom.with_node(node, |node| {
|
||||||
|
node.get_attribute(arg2).map(str::to_owned)
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
|
.flatten(),
|
||||||
|
new_value: None,
|
||||||
|
}
|
||||||
|
.into()
|
||||||
}),
|
}),
|
||||||
|
"append_child" => {
|
||||||
|
let child = NodeId::new(arg2.parse::<u32>().ok()?);
|
||||||
|
dom.get_node(node)?;
|
||||||
|
let old_parent = dom.get_node(child)?.parent;
|
||||||
|
Some(
|
||||||
|
obscura_render::TreeStyleMutation::Insert {
|
||||||
|
node: child,
|
||||||
|
old_parent,
|
||||||
|
new_parent: node,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"remove_child" => {
|
||||||
|
let old_parent = dom.get_node(node)?.parent?;
|
||||||
|
Some(
|
||||||
|
obscura_render::TreeStyleMutation::Remove { node, old_parent }.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"insert_before" => {
|
||||||
|
let reference = NodeId::new(arg2.parse::<u32>().ok()?);
|
||||||
|
let new_parent = dom.get_node(reference)?.parent?;
|
||||||
|
let old_parent = dom.get_node(node)?.parent;
|
||||||
|
Some(
|
||||||
|
obscura_render::TreeStyleMutation::Insert {
|
||||||
|
node,
|
||||||
|
old_parent,
|
||||||
|
new_parent,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"set_text_content" => Some(
|
||||||
|
obscura_render::TreeStyleMutation::Text {
|
||||||
|
node,
|
||||||
|
parent: dom.get_node(node)?.parent,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,10 +711,10 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
|
|||||||
.map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2))
|
.map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
let retained_attribute_mutation = state
|
let retained_style_mutation = state
|
||||||
.dom
|
.dom
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|dom| retained_attribute_style_mutation(dom, &cmd, &arg1, &arg2));
|
.and_then(|dom| retained_style_mutation(dom, &cmd, &arg1, &arg2));
|
||||||
let invalidate = impact.connected && impact.actual_change;
|
let invalidate = impact.connected && impact.actual_change;
|
||||||
if invalidate {
|
if invalidate {
|
||||||
state.activity_generation = state.activity_generation.wrapping_add(1);
|
state.activity_generation = state.activity_generation.wrapping_add(1);
|
||||||
@@ -674,18 +730,18 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
|
|||||||
let had_prepared_render = state.prepared_render.is_some();
|
let had_prepared_render = state.prepared_render.is_some();
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
if invalidate {
|
if invalidate {
|
||||||
if let Some(mutation) = retained_attribute_mutation {
|
if let Some(mutation) = retained_style_mutation {
|
||||||
if state.prepared_render.is_some()
|
if state.prepared_render.is_some()
|
||||||
&& state.pending_attribute_style_mutations.len() < 256
|
&& state.pending_style_mutations.len() < 256
|
||||||
{
|
{
|
||||||
state.pending_attribute_style_mutations.push(mutation);
|
state.pending_style_mutations.push(mutation);
|
||||||
} else {
|
} else {
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.pending_attribute_style_mutations.clear();
|
state.pending_style_mutations.clear();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.pending_attribute_style_mutations.clear();
|
state.pending_style_mutations.clear();
|
||||||
}
|
}
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
@@ -3024,7 +3080,7 @@ fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool
|
|||||||
if state.dynamic_fonts != fonts {
|
if state.dynamic_fonts != fonts {
|
||||||
state.dynamic_fonts = fonts;
|
state.dynamic_fonts = fonts;
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.pending_attribute_style_mutations.clear();
|
state.pending_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@@ -3108,13 +3164,13 @@ pub(crate) fn ensure_prepared_render(
|
|||||||
let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
|
let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
|
||||||
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
|
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
|
||||||
});
|
});
|
||||||
if stale || !state.pending_attribute_style_mutations.is_empty() {
|
if stale || !state.pending_style_mutations.is_empty() {
|
||||||
let previous = (!stale).then(|| state.prepared_render.take()).flatten();
|
let previous = (!stale).then(|| state.prepared_render.take()).flatten();
|
||||||
let mutations = std::mem::take(&mut state.pending_attribute_style_mutations);
|
let mutations = std::mem::take(&mut state.pending_style_mutations);
|
||||||
let prepared = {
|
let prepared = {
|
||||||
let dom = state.dom.as_ref()?;
|
let dom = state.dom.as_ref()?;
|
||||||
match previous {
|
match previous {
|
||||||
Some(previous) => obscura_render::prepare_dom_with_retained_attribute_styles(
|
Some(previous) => obscura_render::prepare_dom_with_retained_styles(
|
||||||
dom,
|
dom,
|
||||||
viewport,
|
viewport,
|
||||||
base_url.as_deref(),
|
base_url.as_deref(),
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ impl ObscuraJsRuntime {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
gs.prepared_render = None;
|
gs.prepared_render = None;
|
||||||
gs.pending_attribute_style_mutations.clear();
|
gs.pending_style_mutations.clear();
|
||||||
gs.render_resources = obscura_render::RenderResourceCache::default();
|
gs.render_resources = obscura_render::RenderResourceCache::default();
|
||||||
gs.stylesheet_cache = obscura_render::StylesheetCache::default();
|
gs.stylesheet_cache = obscura_render::StylesheetCache::default();
|
||||||
gs.dynamic_fonts.clear();
|
gs.dynamic_fonts.clear();
|
||||||
@@ -285,7 +285,7 @@ impl ObscuraJsRuntime {
|
|||||||
// present. Keep already-fetched absolute bytes, but rebuild
|
// present. Keep already-fetched absolute bytes, but rebuild
|
||||||
// candidate selection/layout against the new base.
|
// candidate selection/layout against the new base.
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.pending_attribute_style_mutations.clear();
|
state.pending_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +396,7 @@ impl ObscuraJsRuntime {
|
|||||||
if state.viewport != viewport {
|
if state.viewport != viewport {
|
||||||
state.viewport = viewport;
|
state.viewport = viewport;
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.pending_attribute_style_mutations.clear();
|
state.pending_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -465,6 +465,17 @@ impl ObscuraJsRuntime {
|
|||||||
pub fn screenshot_prepared_region(
|
pub fn screenshot_prepared_region(
|
||||||
&self,
|
&self,
|
||||||
region: obscura_render::CaptureRegion,
|
region: obscura_render::CaptureRegion,
|
||||||
|
) -> Result<Vec<u8>, obscura_render::CaptureError> {
|
||||||
|
self.screenshot_prepared_region_with_backgrounds(region, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a document-space rectangle with the PDF print-background
|
||||||
|
/// policy without mutating the page DOM or retained geometry.
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
pub fn screenshot_prepared_region_with_backgrounds(
|
||||||
|
&self,
|
||||||
|
region: obscura_render::CaptureRegion,
|
||||||
|
paint_backgrounds: bool,
|
||||||
) -> Result<Vec<u8>, obscura_render::CaptureError> {
|
) -> Result<Vec<u8>, obscura_render::CaptureError> {
|
||||||
let mut state = self.state.borrow_mut();
|
let mut state = self.state.borrow_mut();
|
||||||
with_sync_render_loading_disabled(&mut state, |state| {
|
with_sync_render_loading_disabled(&mut state, |state| {
|
||||||
@@ -479,7 +490,7 @@ impl ObscuraJsRuntime {
|
|||||||
let (_, scroll) = resolved_scroll
|
let (_, scroll) = resolved_scroll
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(obscura_render::CaptureError::PaintFailed)?;
|
.ok_or(obscura_render::CaptureError::PaintFailed)?;
|
||||||
obscura_render::screenshot_prepared_region_with_scroll(
|
obscura_render::screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||||
dom.as_ref()
|
dom.as_ref()
|
||||||
.ok_or(obscura_render::CaptureError::PaintFailed)?,
|
.ok_or(obscura_render::CaptureError::PaintFailed)?,
|
||||||
prepared_render
|
prepared_render
|
||||||
@@ -488,6 +499,7 @@ impl ObscuraJsRuntime {
|
|||||||
render_resources,
|
render_resources,
|
||||||
scroll,
|
scroll,
|
||||||
region,
|
region,
|
||||||
|
paint_backgrounds,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -557,7 +569,7 @@ impl ObscuraJsRuntime {
|
|||||||
Some(bytes) => {
|
Some(bytes) => {
|
||||||
state.render_resources.seed(url, bytes);
|
state.render_resources.seed(url, bytes);
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.pending_attribute_style_mutations.clear();
|
state.pending_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
None => state.render_resources.seed_missing(url),
|
None => state.render_resources.seed_missing(url),
|
||||||
@@ -1576,7 +1588,7 @@ impl ObscuraJsRuntime {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.pending_attribute_style_mutations.clear();
|
state.pending_style_mutations.clear();
|
||||||
state.render_resources = obscura_render::RenderResourceCache::default();
|
state.render_resources = obscura_render::RenderResourceCache::default();
|
||||||
state.stylesheet_cache = obscura_render::StylesheetCache::default();
|
state.stylesheet_cache = obscura_render::StylesheetCache::default();
|
||||||
state.dynamic_fonts.clear();
|
state.dynamic_fonts.clear();
|
||||||
@@ -6141,10 +6153,19 @@ mod tests {
|
|||||||
"document.getElementById('box').setAttribute('style', 'height:60px;width:40px')",
|
"document.getElementById('box').setAttribute('style', 'height:60px;width:40px')",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
{
|
||||||
rt.state.borrow().prepared_render.is_none(),
|
let state = rt.state.borrow();
|
||||||
"a changed attribute on a connected element must invalidate layout"
|
assert!(
|
||||||
);
|
state.prepared_render.is_some(),
|
||||||
|
"a retained inline-style change must keep the prior style maps until flush"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
state.pending_style_mutations.as_slice(),
|
||||||
|
[obscura_render::RetainedStyleMutation::Attribute(
|
||||||
|
obscura_render::AttributeStyleMutation { name, .. }
|
||||||
|
)] if name == "style"
|
||||||
|
));
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rt.evaluate("document.getElementById('box').getBoundingClientRect().height")
|
rt.evaluate("document.getElementById('box').getBoundingClientRect().height")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -6189,7 +6210,7 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let state = rt.state.borrow();
|
let state = rt.state.borrow();
|
||||||
assert!(state.prepared_render.is_some());
|
assert!(state.prepared_render.is_some());
|
||||||
assert_eq!(state.pending_attribute_style_mutations.len(), 1);
|
assert_eq!(state.pending_style_mutations.len(), 1);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rt.evaluate("document.getElementById('box').getBoundingClientRect().width")
|
rt.evaluate("document.getElementById('box').getBoundingClientRect().width")
|
||||||
@@ -6235,6 +6256,98 @@ mod tests {
|
|||||||
assert_eq!(state.stylesheet_cache.hit_count(), 1);
|
assert_eq!(state.stylesheet_cache.hit_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[test]
|
||||||
|
fn connected_tree_mutations_queue_retained_styles_until_geometry_flush() {
|
||||||
|
let dom = parse_html(
|
||||||
|
r#"<html style="margin:0"><head><style>
|
||||||
|
.item{display:block;width:40px;height:12px}
|
||||||
|
.item:nth-child(2){width:80px}
|
||||||
|
</style></head><body style="margin:0">
|
||||||
|
<main id="list"><div id="first" class="item"></div></main>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
let mut rt = ObscuraJsRuntime::new();
|
||||||
|
rt.set_dom(dom);
|
||||||
|
rt.set_viewport(200.0, 100.0);
|
||||||
|
rt.run_page_init();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("document.getElementById('first').getBoundingClientRect().width")
|
||||||
|
.unwrap()
|
||||||
|
.as_f64(),
|
||||||
|
Some(40.0)
|
||||||
|
);
|
||||||
|
rt.evaluate(
|
||||||
|
"const added=document.createElement('div');added.id='added';added.className='item';document.getElementById('list').appendChild(added)",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let state = rt.state.borrow();
|
||||||
|
assert!(state.prepared_render.is_some());
|
||||||
|
assert!(matches!(
|
||||||
|
state.pending_style_mutations.as_slice(),
|
||||||
|
[obscura_render::RetainedStyleMutation::Tree(
|
||||||
|
obscura_render::TreeStyleMutation::Insert { .. }
|
||||||
|
)]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("document.getElementById('added').getBoundingClientRect().width")
|
||||||
|
.unwrap()
|
||||||
|
.as_f64(),
|
||||||
|
Some(80.0)
|
||||||
|
);
|
||||||
|
assert!(rt.state.borrow().pending_style_mutations.is_empty());
|
||||||
|
|
||||||
|
rt.evaluate("document.getElementById('added').style.width='65px'")
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let state = rt.state.borrow();
|
||||||
|
assert!(state.prepared_render.is_some());
|
||||||
|
assert!(matches!(
|
||||||
|
state.pending_style_mutations.as_slice(),
|
||||||
|
[obscura_render::RetainedStyleMutation::Attribute(
|
||||||
|
obscura_render::AttributeStyleMutation { name, .. }
|
||||||
|
)] if name == "style"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("document.getElementById('added').getBoundingClientRect().width")
|
||||||
|
.unwrap()
|
||||||
|
.as_f64(),
|
||||||
|
Some(65.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("(function(){const added=document.getElementById('added');added.style.width='';return added.getBoundingClientRect().width})()")
|
||||||
|
.unwrap()
|
||||||
|
.as_f64(),
|
||||||
|
Some(80.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
rt.evaluate(
|
||||||
|
"document.getElementById('list').removeChild(document.getElementById('first'))",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let state = rt.state.borrow();
|
||||||
|
assert!(state.prepared_render.is_some());
|
||||||
|
assert!(matches!(
|
||||||
|
state.pending_style_mutations.as_slice(),
|
||||||
|
[obscura_render::RetainedStyleMutation::Tree(
|
||||||
|
obscura_render::TreeStyleMutation::Remove { .. }
|
||||||
|
)]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("document.getElementById('added').getBoundingClientRect().width")
|
||||||
|
.unwrap()
|
||||||
|
.as_f64(),
|
||||||
|
Some(40.0)
|
||||||
|
);
|
||||||
|
assert!(rt.state.borrow().pending_style_mutations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
#[test]
|
#[test]
|
||||||
fn root_overflow_clip_preserves_cssom_scroll_range() {
|
fn root_overflow_clip_preserves_cssom_scroll_range() {
|
||||||
|
|||||||
@@ -48,17 +48,20 @@ pub struct InvalidationDependency {
|
|||||||
|
|
||||||
/// Selector dependencies retained alongside the compiled stylesheet.
|
/// Selector dependencies retained alongside the compiled stylesheet.
|
||||||
///
|
///
|
||||||
/// This follows Gecko's conservative invalidation-map shape: mutations look
|
/// This follows Gecko's conservative invalidation-map shape: live mutations
|
||||||
/// up the changed id/class/attribute/state and receive one or more traversal
|
/// look up the changed id/class/attribute/local-name/state and receive one or
|
||||||
/// reaches. Phase 1 only builds and exposes this metadata; it deliberately does
|
/// more traversal reaches. The renderer uses those reaches to retain clean
|
||||||
/// not alter mutation handling or skip any cascade work.
|
/// computed styles, with a full-cascade fallback for unrepresentable paths.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct InvalidationMap {
|
pub struct InvalidationMap {
|
||||||
ids: HashMap<String, Vec<InvalidationDependency>>,
|
ids: HashMap<String, Vec<InvalidationDependency>>,
|
||||||
classes: HashMap<String, Vec<InvalidationDependency>>,
|
classes: HashMap<String, Vec<InvalidationDependency>>,
|
||||||
attributes: HashMap<String, Vec<InvalidationDependency>>,
|
attributes: HashMap<String, Vec<InvalidationDependency>>,
|
||||||
|
local_names: HashMap<String, Vec<InvalidationDependency>>,
|
||||||
states: HashMap<String, Vec<InvalidationDependency>>,
|
states: HashMap<String, Vec<InvalidationDependency>>,
|
||||||
conservative_rule_orders: Vec<usize>,
|
conservative_rule_orders: Vec<usize>,
|
||||||
|
relational_rule_orders: Vec<usize>,
|
||||||
|
unkeyed_relational_rule_orders: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InvalidationMap {
|
impl InvalidationMap {
|
||||||
@@ -77,6 +80,13 @@ impl InvalidationMap {
|
|||||||
.unwrap_or(&[])
|
.unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn local_name_dependencies(&self, local_name: &str) -> &[InvalidationDependency] {
|
||||||
|
self.local_names
|
||||||
|
.get(&local_name.to_ascii_lowercase())
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn state_dependencies(&self, state: &str) -> &[InvalidationDependency] {
|
pub fn state_dependencies(&self, state: &str) -> &[InvalidationDependency] {
|
||||||
self.states
|
self.states
|
||||||
.get(&state.to_ascii_lowercase())
|
.get(&state.to_ascii_lowercase())
|
||||||
@@ -92,11 +102,20 @@ impl InvalidationMap {
|
|||||||
!self.conservative_rule_orders.is_empty()
|
!self.conservative_rule_orders.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_relational_rule(&self, rule_order: usize) -> bool {
|
||||||
|
self.relational_rule_orders.contains(&rule_order)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_unkeyed_relational_rules(&self) -> bool {
|
||||||
|
!self.unkeyed_relational_rule_orders.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn dependency_count(&self) -> usize {
|
pub fn dependency_count(&self) -> usize {
|
||||||
self.ids
|
self.ids
|
||||||
.values()
|
.values()
|
||||||
.chain(self.classes.values())
|
.chain(self.classes.values())
|
||||||
.chain(self.attributes.values())
|
.chain(self.attributes.values())
|
||||||
|
.chain(self.local_names.values())
|
||||||
.chain(self.states.values())
|
.chain(self.states.values())
|
||||||
.map(Vec::len)
|
.map(Vec::len)
|
||||||
.sum()
|
.sum()
|
||||||
@@ -139,6 +158,15 @@ impl InvalidationMap {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_local_name(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
|
||||||
|
Self::push(
|
||||||
|
&mut self.local_names,
|
||||||
|
key.to_ascii_lowercase(),
|
||||||
|
rule_order,
|
||||||
|
reaches,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn push_state(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
|
fn push_state(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
|
||||||
Self::push(
|
Self::push(
|
||||||
&mut self.states,
|
&mut self.states,
|
||||||
@@ -155,6 +183,15 @@ impl InvalidationMap {
|
|||||||
self.conservative_rule_orders.push(rule_order);
|
self.conservative_rule_orders.push(rule_order);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mark_relational(&mut self, rule_order: usize, unkeyed: bool) {
|
||||||
|
if !self.relational_rule_orders.contains(&rule_order) {
|
||||||
|
self.relational_rule_orders.push(rule_order);
|
||||||
|
}
|
||||||
|
if unkeyed && !self.unkeyed_relational_rule_orders.contains(&rule_order) {
|
||||||
|
self.unkeyed_relational_rule_orders.push(rule_order);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compose_invalidation_reach(
|
fn compose_invalidation_reach(
|
||||||
@@ -422,12 +459,123 @@ fn nth_of_selector(arguments: &str) -> Option<&str> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compound_local_name(compound: &str) -> Option<String> {
|
||||||
|
let chars = compound.trim().chars().collect::<Vec<_>>();
|
||||||
|
let index;
|
||||||
|
if chars.first() == Some(&'|') {
|
||||||
|
index = 1;
|
||||||
|
} else if chars.first() == Some(&'*') {
|
||||||
|
if chars.get(1) != Some(&'|') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
index = 2;
|
||||||
|
} else {
|
||||||
|
let (first, end) = consume_css_identifier(&chars, 0);
|
||||||
|
if first.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if chars.get(end) != Some(&'|') {
|
||||||
|
return Some(first.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
index = end + 1;
|
||||||
|
}
|
||||||
|
if chars.get(index) == Some(&'*') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (local, _) = consume_css_identifier(&chars, index);
|
||||||
|
(!local.is_empty()).then(|| local.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the relative selector's subject compound has a positive key which
|
||||||
|
/// an inserted/removed subtree can look up. Keys hidden only inside `:is()` or
|
||||||
|
/// negation are deliberately not credited; treating those as unkeyed costs a
|
||||||
|
/// broader invalidation but cannot miss an activation.
|
||||||
|
fn relative_selector_subject_has_key(selector: &str) -> bool {
|
||||||
|
let selector = selector.trim();
|
||||||
|
let selector = selector
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.filter(|character| matches!(character, '>' | '+' | '~'))
|
||||||
|
.map(|character| selector[character.len_utf8()..].trim_start())
|
||||||
|
.unwrap_or(selector);
|
||||||
|
let (compounds, malformed) = invalidation_compounds(selector);
|
||||||
|
if malformed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some((subject, _)) = compounds.last() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if compound_local_name(subject).is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let chars = subject.chars().collect::<Vec<_>>();
|
||||||
|
let mut paren_depth = 0usize;
|
||||||
|
let mut bracket_depth = 0usize;
|
||||||
|
let mut quote = None;
|
||||||
|
let mut index = 0usize;
|
||||||
|
while index < chars.len() {
|
||||||
|
let ch = chars[index];
|
||||||
|
if ch == '\\' {
|
||||||
|
index = (index + 2).min(chars.len());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(active_quote) = quote {
|
||||||
|
if ch == active_quote {
|
||||||
|
quote = None;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '\'' || ch == '"' {
|
||||||
|
quote = Some(ch);
|
||||||
|
} else if ch == ':' && paren_depth == 0 && bracket_depth == 0 {
|
||||||
|
let (name, next) = consume_css_identifier(&chars, index + 1);
|
||||||
|
if matches!(name.to_ascii_lowercase().as_str(), "is" | "where")
|
||||||
|
&& chars.get(next) == Some(&'(')
|
||||||
|
{
|
||||||
|
let Some(close) = matching_delimiter(&chars, next, '(', ')') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let arguments = chars[next + 1..close].iter().collect::<String>();
|
||||||
|
let alternatives = split_selector_list(&arguments);
|
||||||
|
if !alternatives.is_empty()
|
||||||
|
&& alternatives.iter().all(|alternative| {
|
||||||
|
relative_selector_subject_has_key(alternative.trim())
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
index = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if ch == '(' {
|
||||||
|
paren_depth += 1;
|
||||||
|
} else if ch == ')' {
|
||||||
|
paren_depth = paren_depth.saturating_sub(1);
|
||||||
|
} else if ch == '[' && paren_depth == 0 {
|
||||||
|
if bracket_depth == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bracket_depth += 1;
|
||||||
|
} else if ch == ']' && paren_depth == 0 {
|
||||||
|
bracket_depth = bracket_depth.saturating_sub(1);
|
||||||
|
} else if paren_depth == 0 && bracket_depth == 0 && matches!(ch, '#' | '.') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn note_compound_dependencies(
|
fn note_compound_dependencies(
|
||||||
map: &mut InvalidationMap,
|
map: &mut InvalidationMap,
|
||||||
compound: &str,
|
compound: &str,
|
||||||
reaches: InvalidationReaches,
|
reaches: InvalidationReaches,
|
||||||
rule_order: usize,
|
rule_order: usize,
|
||||||
) {
|
) {
|
||||||
|
if let Some(local_name) = compound_local_name(compound) {
|
||||||
|
map.push_local_name(local_name, rule_order, reaches);
|
||||||
|
}
|
||||||
let chars: Vec<char> = compound.chars().collect();
|
let chars: Vec<char> = compound.chars().collect();
|
||||||
let mut index = 0usize;
|
let mut index = 0usize;
|
||||||
while index < chars.len() {
|
while index < chars.len() {
|
||||||
@@ -533,8 +681,18 @@ fn note_compound_dependencies(
|
|||||||
"has" => {
|
"has" => {
|
||||||
// Relative selectors invalidate anchors upwards, which
|
// Relative selectors invalidate anchors upwards, which
|
||||||
// Self/Descendants/Siblings cannot express soundly.
|
// Self/Descendants/Siblings cannot express soundly.
|
||||||
|
map.push_state(
|
||||||
|
name.clone(),
|
||||||
|
rule_order,
|
||||||
|
InvalidationReaches::CONSERVATIVE,
|
||||||
|
);
|
||||||
map.mark_conservative(rule_order);
|
map.mark_conservative(rule_order);
|
||||||
for alternative in split_selector_list(&arguments) {
|
let alternatives = split_selector_list(&arguments);
|
||||||
|
let unkeyed = alternatives.iter().any(|alternative| {
|
||||||
|
!relative_selector_subject_has_key(alternative.trim())
|
||||||
|
});
|
||||||
|
map.mark_relational(rule_order, unkeyed);
|
||||||
|
for alternative in alternatives {
|
||||||
note_selector_dependencies(
|
note_selector_dependencies(
|
||||||
map,
|
map,
|
||||||
alternative.trim(),
|
alternative.trim(),
|
||||||
@@ -2299,13 +2457,25 @@ impl Stylesheet {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|&(_, _, i)| self.rules[i].important_flags.has_custom_properties);
|
.any(|&(_, _, i)| self.rules[i].important_flags.has_custom_properties);
|
||||||
|
|
||||||
// Most elements neither declare custom properties nor participate in
|
// Registered properties are already represented in the parent's
|
||||||
// an `@property` registration. Reuse the inherited map directly in
|
// computed map. Most descendants need no changes: inherited
|
||||||
// that case instead of parsing every declaration stream, cloning the
|
// registrations keep that value, while a non-inherited registration
|
||||||
// map, and comparing the clone back to its source.
|
// whose parent already holds its initial value also stays identical.
|
||||||
let effective = if !has_own_custom_properties
|
// Detect the uncommon transition before cloning the potentially large
|
||||||
&& self.registered_custom_properties.is_empty()
|
// custom-property map. This follows the browser rule-tree model where
|
||||||
{
|
// unchanged computed values are shared down the tree.
|
||||||
|
let registrations_change_parent = self.registered_custom_properties.iter().any(
|
||||||
|
|(name, registration)| {
|
||||||
|
if registration.inherits && parent_props.contains_key(name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match ®istration.initial_value {
|
||||||
|
Some(initial) => parent_props.get(name) != Some(initial),
|
||||||
|
None => parent_props.contains_key(name),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let effective = if !has_own_custom_properties && !registrations_change_parent {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let mut own: Vec<(String, String)> = Vec::new();
|
let mut own: Vec<(String, String)> = Vec::new();
|
||||||
@@ -2349,7 +2519,7 @@ impl Stylesheet {
|
|||||||
resolved_props.remove(name);
|
resolved_props.remove(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let registration_changed_props = resolved_props != *parent_props;
|
let registration_changed_props = registrations_change_parent;
|
||||||
let has_own = !own.is_empty();
|
let has_own = !own.is_empty();
|
||||||
let mut own_names = std::collections::HashSet::new();
|
let mut own_names = std::collections::HashSet::new();
|
||||||
for (k, v) in own {
|
for (k, v) in own {
|
||||||
@@ -5673,6 +5843,10 @@ mod tests {
|
|||||||
map.state_dependencies("focus-visible"),
|
map.state_dependencies("focus-visible"),
|
||||||
InvalidationReaches::SELF,
|
InvalidationReaches::SELF,
|
||||||
));
|
));
|
||||||
|
assert!(dependencies_reach(
|
||||||
|
map.local_name_dependencies("BUTTON"),
|
||||||
|
InvalidationReaches::SELF,
|
||||||
|
));
|
||||||
assert!(dependencies_reach(
|
assert!(dependencies_reach(
|
||||||
map.attribute_dependencies("disabled"),
|
map.attribute_dependencies("disabled"),
|
||||||
InvalidationReaches::SELF,
|
InvalidationReaches::SELF,
|
||||||
@@ -5735,6 +5909,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(map.requires_conservative_invalidation());
|
assert!(map.requires_conservative_invalidation());
|
||||||
assert!(map.conservative_rule_orders().len() >= 3);
|
assert!(map.conservative_rule_orders().len() >= 3);
|
||||||
|
assert!(!map.has_unkeyed_relational_rules());
|
||||||
assert!(dependencies_reach(
|
assert!(dependencies_reach(
|
||||||
map.class_dependencies("badge"),
|
map.class_dependencies("badge"),
|
||||||
InvalidationReaches::CONSERVATIVE,
|
InvalidationReaches::CONSERVATIVE,
|
||||||
@@ -5770,6 +5945,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalidation_map_distinguishes_keyed_and_unkeyed_relational_subjects() {
|
||||||
|
let keyed = test_invalidation_map(
|
||||||
|
".card:has(> .badge), .row:has(.icon[data-live]), .copy:has(> span), .choice:has(:is(.yes,button)) { color:red }",
|
||||||
|
);
|
||||||
|
assert!(!keyed.has_unkeyed_relational_rules());
|
||||||
|
assert!(keyed
|
||||||
|
.class_dependencies("badge")
|
||||||
|
.iter()
|
||||||
|
.any(|dependency| keyed.is_relational_rule(dependency.rule_order)));
|
||||||
|
assert!(keyed
|
||||||
|
.local_name_dependencies("span")
|
||||||
|
.iter()
|
||||||
|
.any(|dependency| keyed.is_relational_rule(dependency.rule_order)));
|
||||||
|
|
||||||
|
for selector in [
|
||||||
|
".card:has(> *){color:red}",
|
||||||
|
".card:has(:is(.badge,*)){color:red}",
|
||||||
|
".card:has(:empty){color:red}",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
test_invalidation_map(selector).has_unkeyed_relational_rules(),
|
||||||
|
"{selector}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalidation_map_includes_generated_content_host_dependencies() {
|
fn invalidation_map_includes_generated_content_host_dependencies() {
|
||||||
let map = test_invalidation_map(
|
let map = test_invalidation_map(
|
||||||
@@ -7073,6 +7275,69 @@ mod tests {
|
|||||||
assert_eq!(initial_style.height, crate::Dimension::Percent(0.25));
|
assert_eq!(initial_style.height, crate::Dimension::Percent(0.25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registered_custom_properties_reuse_an_unchanged_parent_map() {
|
||||||
|
let tree = obscura_dom::parse_html(r#"<div id="target"></div>"#);
|
||||||
|
let css = r#"
|
||||||
|
@property --private {
|
||||||
|
syntax:"<percentage>";
|
||||||
|
inherits:false;
|
||||||
|
initial-value:75%;
|
||||||
|
}
|
||||||
|
@property --shared {
|
||||||
|
syntax:"<percentage>";
|
||||||
|
inherits:true;
|
||||||
|
initial-value:25%;
|
||||||
|
}
|
||||||
|
#target { width:var(--private); height:var(--shared) }
|
||||||
|
"#;
|
||||||
|
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
|
||||||
|
let target = tree.get_element_by_id("target").unwrap();
|
||||||
|
let parent_props = HashMap::from([
|
||||||
|
("--private".to_string(), "75%".to_string()),
|
||||||
|
("--shared".to_string(), "30%".to_string()),
|
||||||
|
]);
|
||||||
|
let mut matcher = tree.matcher();
|
||||||
|
let mut style = LayoutStyle::default();
|
||||||
|
let effective = sheet.apply(
|
||||||
|
&tree,
|
||||||
|
&mut matcher,
|
||||||
|
target,
|
||||||
|
Some("target"),
|
||||||
|
&[],
|
||||||
|
"div",
|
||||||
|
&mut style,
|
||||||
|
&parent_props,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(effective.is_none());
|
||||||
|
assert_eq!(style.width, crate::Dimension::Percent(0.75));
|
||||||
|
assert_eq!(style.height, crate::Dimension::Percent(0.30));
|
||||||
|
|
||||||
|
let overridden_parent = HashMap::from([
|
||||||
|
("--private".to_string(), "60%".to_string()),
|
||||||
|
("--shared".to_string(), "30%".to_string()),
|
||||||
|
]);
|
||||||
|
let mut style = LayoutStyle::default();
|
||||||
|
let effective = sheet
|
||||||
|
.apply(
|
||||||
|
&tree,
|
||||||
|
&mut matcher,
|
||||||
|
target,
|
||||||
|
Some("target"),
|
||||||
|
&[],
|
||||||
|
"div",
|
||||||
|
&mut style,
|
||||||
|
&overridden_parent,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("non-inherited registered property must reset on the child");
|
||||||
|
assert_eq!(effective.get("--private").map(String::as_str), Some("75%"));
|
||||||
|
assert_eq!(effective.get("--shared").map(String::as_str), Some("30%"));
|
||||||
|
assert_eq!(style.width, crate::Dimension::Percent(0.75));
|
||||||
|
assert_eq!(style.height, crate::Dimension::Percent(0.30));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_custom_property_overrides_and_var_fallbacks_stay_distinct() {
|
fn registered_custom_property_overrides_and_var_fallbacks_stay_distinct() {
|
||||||
let tree = obscura_dom::parse_html(
|
let tree = obscura_dom::parse_html(
|
||||||
|
|||||||
@@ -355,9 +355,9 @@ pub struct DomLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One connected element attribute mutation eligible for conservative
|
/// One connected element attribute mutation eligible for conservative
|
||||||
/// retained-style invalidation. Callers must use the ordinary full-layout
|
/// retained-style invalidation. Attributes with independent HTML rendering
|
||||||
/// path for tree/text mutations and for attributes with non-CSS rendering
|
/// semantics (image sources, form state, inline style, and similar) still use
|
||||||
/// semantics (image sources, form state, inline style, and similar).
|
/// the ordinary full-layout path.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct AttributeStyleMutation {
|
pub struct AttributeStyleMutation {
|
||||||
pub node: NodeId,
|
pub node: NodeId,
|
||||||
@@ -366,6 +366,47 @@ pub struct AttributeStyleMutation {
|
|||||||
pub new_value: Option<String>,
|
pub new_value: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A connected tree change which can reuse computed styles from the previous
|
||||||
|
/// render. Node ids are stable across detach/reparent operations; recording
|
||||||
|
/// both parents lets the post-mutation traversal invalidate the old and new
|
||||||
|
/// sibling scopes without retaining a second DOM snapshot.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TreeStyleMutation {
|
||||||
|
Insert {
|
||||||
|
node: NodeId,
|
||||||
|
old_parent: Option<NodeId>,
|
||||||
|
new_parent: NodeId,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
node: NodeId,
|
||||||
|
old_parent: NodeId,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
node: NodeId,
|
||||||
|
parent: Option<NodeId>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutation input for a retained-style rebuild. Attribute invalidation uses
|
||||||
|
/// selector-key dependencies; tree invalidation uses parent/sibling scopes.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RetainedStyleMutation {
|
||||||
|
Attribute(AttributeStyleMutation),
|
||||||
|
Tree(TreeStyleMutation),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AttributeStyleMutation> for RetainedStyleMutation {
|
||||||
|
fn from(mutation: AttributeStyleMutation) -> Self {
|
||||||
|
Self::Attribute(mutation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TreeStyleMutation> for RetainedStyleMutation {
|
||||||
|
fn from(mutation: TreeStyleMutation) -> Self {
|
||||||
|
Self::Tree(mutation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Style maps moved out of a previous layout. Moving, rather than cloning,
|
/// Style maps moved out of a previous layout. Moving, rather than cloning,
|
||||||
/// keeps incremental restyle from retaining a second heap-heavy LayoutStyle
|
/// keeps incremental restyle from retaining a second heap-heavy LayoutStyle
|
||||||
/// graph beside the final used styles.
|
/// graph beside the final used styles.
|
||||||
@@ -2396,6 +2437,111 @@ fn add_following_sibling_subtrees(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn subtree_contains_style_element(tree: &DomTree, root: NodeId) -> bool {
|
||||||
|
std::iter::once(root)
|
||||||
|
.chain(tree.descendants(root))
|
||||||
|
.any(|id| {
|
||||||
|
tree.get_node(id).is_some_and(|node| {
|
||||||
|
node.as_element()
|
||||||
|
.is_some_and(|element| element.local.as_ref() == "style")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_is_style_text(tree: &DomTree, node: NodeId, parent: Option<NodeId>) -> bool {
|
||||||
|
parent.is_some_and(|parent| {
|
||||||
|
tree.get_node(parent).is_some_and(|node| {
|
||||||
|
node.as_element()
|
||||||
|
.is_some_and(|element| element.local.as_ref() == "style")
|
||||||
|
})
|
||||||
|
}) || tree.get_node(node).is_some_and(|node| {
|
||||||
|
node.as_element()
|
||||||
|
.is_some_and(|element| element.local.as_ref() == "style")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies_include_relational_rule(
|
||||||
|
map: &crate::css::InvalidationMap,
|
||||||
|
dependencies: &[crate::css::InvalidationDependency],
|
||||||
|
) -> bool {
|
||||||
|
dependencies
|
||||||
|
.iter()
|
||||||
|
.any(|dependency| {
|
||||||
|
map.is_relational_rule(dependency.rule_order)
|
||||||
|
&& dependency
|
||||||
|
.reaches
|
||||||
|
.contains(crate::css::InvalidationReaches::CONSERVATIVE)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subtree_may_affect_relational_selector(
|
||||||
|
tree: &DomTree,
|
||||||
|
map: &crate::css::InvalidationMap,
|
||||||
|
root: NodeId,
|
||||||
|
) -> bool {
|
||||||
|
if map.has_unkeyed_relational_rules() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::iter::once(root)
|
||||||
|
.chain(tree.descendants(root))
|
||||||
|
.any(|id| {
|
||||||
|
let Some(node) = tree.get_node(id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(_element) = node.as_element() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if node.get_attribute("id").is_some_and(|id| {
|
||||||
|
dependencies_include_relational_rule(map, map.id_dependencies(id))
|
||||||
|
}) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if node.get_attribute("class").is_some_and(|classes| {
|
||||||
|
classes.split_whitespace().any(|class| {
|
||||||
|
dependencies_include_relational_rule(map, map.class_dependencies(class))
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if dependencies_include_relational_rule(
|
||||||
|
map,
|
||||||
|
map.local_name_dependencies(
|
||||||
|
node.as_element()
|
||||||
|
.map(|element| element.local.as_ref())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
node.attrs().is_some_and(|attributes| {
|
||||||
|
attributes.iter().any(|attribute| {
|
||||||
|
dependencies_include_relational_rule(
|
||||||
|
map,
|
||||||
|
map.attribute_dependencies(&attribute.qualified_name()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child-list change can alter every structural/sibling match under each
|
||||||
|
/// affected parent. Re-cascade that complete parent subtree, the parent's
|
||||||
|
/// following siblings (for `:empty +/~ ...`), and the ancestor context chain.
|
||||||
|
/// This deliberately does not depend on whether the current sheet happens to
|
||||||
|
/// contain nth/sibling selectors: the broad local scope remains correct when
|
||||||
|
/// multiple queued mutations introduce selector keys before the next flush.
|
||||||
|
fn add_tree_parent_scope(
|
||||||
|
tree: &DomTree,
|
||||||
|
parent: NodeId,
|
||||||
|
dirty_following_siblings: bool,
|
||||||
|
dirty: &mut HashSet<NodeId>,
|
||||||
|
) {
|
||||||
|
add_style_subtree(tree, parent, dirty);
|
||||||
|
if dirty_following_siblings {
|
||||||
|
add_following_sibling_subtrees(tree, parent, dirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert old/new selector keys into a conservative set of fresh cascade
|
/// Convert old/new selector keys into a conservative set of fresh cascade
|
||||||
/// roots. Every selected root is expanded to its complete subtree so ordinary
|
/// roots. Every selected root is expanded to its complete subtree so ordinary
|
||||||
/// CSS inheritance, custom properties, generated content, and later selector
|
/// CSS inheritance, custom properties, generated content, and later selector
|
||||||
@@ -2403,10 +2549,80 @@ fn add_following_sibling_subtrees(
|
|||||||
fn retained_style_plan(
|
fn retained_style_plan(
|
||||||
tree: &DomTree,
|
tree: &DomTree,
|
||||||
sheet: &crate::css::Stylesheet,
|
sheet: &crate::css::Stylesheet,
|
||||||
mutations: &[AttributeStyleMutation],
|
mutations: &[RetainedStyleMutation],
|
||||||
) -> RetainedStylePlan {
|
) -> RetainedStylePlan {
|
||||||
let mut dirty = HashSet::new();
|
let mut dirty = HashSet::new();
|
||||||
for mutation in mutations {
|
for mutation in mutations {
|
||||||
|
let RetainedStyleMutation::Attribute(mutation) = mutation else {
|
||||||
|
let RetainedStyleMutation::Tree(mutation) = mutation else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let map = sheet.invalidation_map();
|
||||||
|
let dirty_parent_siblings = map.state_dependencies("empty").iter().any(|dependency| {
|
||||||
|
dependency
|
||||||
|
.reaches
|
||||||
|
.contains(crate::css::InvalidationReaches::SIBLINGS)
|
||||||
|
|| dependency
|
||||||
|
.reaches
|
||||||
|
.contains(crate::css::InvalidationReaches::CONSERVATIVE)
|
||||||
|
});
|
||||||
|
match *mutation {
|
||||||
|
TreeStyleMutation::Insert {
|
||||||
|
node,
|
||||||
|
old_parent,
|
||||||
|
new_parent,
|
||||||
|
} => {
|
||||||
|
// Inserting or moving a style subtree changes the ordered
|
||||||
|
// author stylesheet, so parsing/index reuse is forbidden.
|
||||||
|
if subtree_contains_style_element(tree, node)
|
||||||
|
|| node_is_style_text(tree, node, old_parent)
|
||||||
|
|| node_is_style_text(tree, node, Some(new_parent))
|
||||||
|
{
|
||||||
|
return RetainedStylePlan::Full;
|
||||||
|
}
|
||||||
|
if subtree_may_affect_relational_selector(tree, map, node)
|
||||||
|
|| old_parent.is_some_and(|parent| {
|
||||||
|
subtree_may_affect_relational_selector(tree, map, parent)
|
||||||
|
})
|
||||||
|
|| subtree_may_affect_relational_selector(tree, map, new_parent)
|
||||||
|
{
|
||||||
|
return RetainedStylePlan::Full;
|
||||||
|
}
|
||||||
|
add_style_subtree(tree, node, &mut dirty);
|
||||||
|
if let Some(parent) = old_parent {
|
||||||
|
add_tree_parent_scope(tree, parent, dirty_parent_siblings, &mut dirty);
|
||||||
|
}
|
||||||
|
add_tree_parent_scope(tree, new_parent, dirty_parent_siblings, &mut dirty);
|
||||||
|
}
|
||||||
|
TreeStyleMutation::Remove { node, old_parent } => {
|
||||||
|
if subtree_contains_style_element(tree, node)
|
||||||
|
|| node_is_style_text(tree, node, Some(old_parent))
|
||||||
|
{
|
||||||
|
return RetainedStylePlan::Full;
|
||||||
|
}
|
||||||
|
if subtree_may_affect_relational_selector(tree, map, node)
|
||||||
|
|| subtree_may_affect_relational_selector(tree, map, old_parent)
|
||||||
|
{
|
||||||
|
return RetainedStylePlan::Full;
|
||||||
|
}
|
||||||
|
add_tree_parent_scope(tree, old_parent, dirty_parent_siblings, &mut dirty);
|
||||||
|
}
|
||||||
|
TreeStyleMutation::Text { node, parent } => {
|
||||||
|
if node_is_style_text(tree, node, parent) {
|
||||||
|
return RetainedStylePlan::Full;
|
||||||
|
}
|
||||||
|
if !map.state_dependencies("has").is_empty() {
|
||||||
|
return RetainedStylePlan::Full;
|
||||||
|
}
|
||||||
|
if let Some(parent) = parent {
|
||||||
|
add_tree_parent_scope(tree, parent, dirty_parent_siblings, &mut dirty);
|
||||||
|
} else {
|
||||||
|
dirty.insert(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let name = mutation.name.to_ascii_lowercase();
|
let name = mutation.name.to_ascii_lowercase();
|
||||||
// Phase one deliberately excludes attributes with independent HTML
|
// Phase one deliberately excludes attributes with independent HTML
|
||||||
// rendering behavior. class/id and data/ARIA state cover framework
|
// rendering behavior. class/id and data/ARIA state cover framework
|
||||||
@@ -2414,12 +2630,20 @@ fn retained_style_plan(
|
|||||||
// presentation hints, and inline declarations retain the full path.
|
// presentation hints, and inline declarations retain the full path.
|
||||||
if name != "class"
|
if name != "class"
|
||||||
&& name != "id"
|
&& name != "id"
|
||||||
|
&& name != "style"
|
||||||
&& !name.starts_with("data-")
|
&& !name.starts_with("data-")
|
||||||
&& !name.starts_with("aria-")
|
&& !name.starts_with("aria-")
|
||||||
{
|
{
|
||||||
return RetainedStylePlan::Full;
|
return RetainedStylePlan::Full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline declarations always change this element's cascade and may
|
||||||
|
// change inherited values/custom properties throughout its subtree,
|
||||||
|
// even when no author selector explicitly mentions `[style]`.
|
||||||
|
if name == "style" {
|
||||||
|
add_style_subtree(tree, mutation.node, &mut dirty);
|
||||||
|
}
|
||||||
|
|
||||||
let map = sheet.invalidation_map();
|
let map = sheet.invalidation_map();
|
||||||
let mut dependencies = Vec::new();
|
let mut dependencies = Vec::new();
|
||||||
dependencies.extend_from_slice(map.attribute_dependencies(&name));
|
dependencies.extend_from_slice(map.attribute_dependencies(&name));
|
||||||
@@ -2495,7 +2719,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles(
|
|||||||
fonts: &[crate::inline::WebFont],
|
fonts: &[crate::inline::WebFont],
|
||||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||||
retained: RetainedStyleMaps,
|
retained: RetainedStyleMaps,
|
||||||
mutations: &[AttributeStyleMutation],
|
mutations: &[RetainedStyleMutation],
|
||||||
) -> DomLayout {
|
) -> DomLayout {
|
||||||
layout_dom_with_web_fonts_pass_limit(
|
layout_dom_with_web_fonts_pass_limit(
|
||||||
tree,
|
tree,
|
||||||
@@ -2527,7 +2751,7 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
pass_limit: Option<usize>,
|
pass_limit: Option<usize>,
|
||||||
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
|
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
|
||||||
retained: Option<RetainedStyleMaps>,
|
retained: Option<RetainedStyleMaps>,
|
||||||
mutations: &[AttributeStyleMutation],
|
mutations: &[RetainedStyleMutation],
|
||||||
) -> (DomLayout, ContainerLayoutTelemetry) {
|
) -> (DomLayout, ContainerLayoutTelemetry) {
|
||||||
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
|
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
|
||||||
|
|
||||||
@@ -2558,7 +2782,7 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
let t_parse = t0.elapsed();
|
let t_parse = t0.elapsed();
|
||||||
|
|
||||||
let retained_requested = retained.as_ref().map_or(0, |retained| retained.styles.len());
|
let retained_requested = retained.as_ref().map_or(0, |retained| retained.styles.len());
|
||||||
let retained = retained.and_then(|retained| {
|
let retained = retained.and_then(|mut retained| {
|
||||||
if !stylesheet_cache_hit
|
if !stylesheet_cache_hit
|
||||||
|| retained
|
|| retained
|
||||||
.styles
|
.styles
|
||||||
@@ -2567,6 +2791,13 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let connected = std::iter::once(tree.document())
|
||||||
|
.chain(tree.descendants(tree.document()))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
retained.styles.retain(|node, _| connected.contains(node));
|
||||||
|
retained
|
||||||
|
.custom_properties
|
||||||
|
.retain(|node, _| connected.contains(node));
|
||||||
match retained_style_plan(tree, &sheet, mutations) {
|
match retained_style_plan(tree, &sheet, mutations) {
|
||||||
RetainedStylePlan::Reuse(dirty) => Some((retained, dirty)),
|
RetainedStylePlan::Reuse(dirty) => Some((retained, dirty)),
|
||||||
RetainedStylePlan::Full => None,
|
RetainedStylePlan::Full => None,
|
||||||
@@ -12482,19 +12713,22 @@ mod tests {
|
|||||||
name: "data-theme".into(),
|
name: "data-theme".into(),
|
||||||
old_value: Some("light".into()),
|
old_value: Some("light".into()),
|
||||||
new_value: Some("dark".into()),
|
new_value: Some("dark".into()),
|
||||||
},
|
}
|
||||||
|
.into(),
|
||||||
AttributeStyleMutation {
|
AttributeStyleMutation {
|
||||||
node: toggle,
|
node: toggle,
|
||||||
name: "data-open".into(),
|
name: "data-open".into(),
|
||||||
old_value: Some("false".into()),
|
old_value: Some("false".into()),
|
||||||
new_value: Some("true".into()),
|
new_value: Some("true".into()),
|
||||||
},
|
}
|
||||||
|
.into(),
|
||||||
AttributeStyleMutation {
|
AttributeStyleMutation {
|
||||||
node: counter_change,
|
node: counter_change,
|
||||||
name: "data-double".into(),
|
name: "data-double".into(),
|
||||||
old_value: Some("false".into()),
|
old_value: Some("false".into()),
|
||||||
new_value: Some("true".into()),
|
new_value: Some("true".into()),
|
||||||
},
|
}
|
||||||
|
.into(),
|
||||||
];
|
];
|
||||||
let retained = RetainedStyleMaps {
|
let retained = RetainedStyleMaps {
|
||||||
styles: std::mem::take(&mut initial.styles),
|
styles: std::mem::take(&mut initial.styles),
|
||||||
@@ -12648,7 +12882,8 @@ mod tests {
|
|||||||
name: case.attribute.into(),
|
name: case.attribute.into(),
|
||||||
old_value,
|
old_value,
|
||||||
new_value: Some(case.new_value.into()),
|
new_value: Some(case.new_value.into()),
|
||||||
}],
|
}
|
||||||
|
.into()],
|
||||||
);
|
);
|
||||||
let full = layout_dom(&tree, (320.0, 240.0));
|
let full = layout_dom(&tree, (320.0, 240.0));
|
||||||
|
|
||||||
@@ -12753,6 +12988,22 @@ mod tests {
|
|||||||
new_value: "on",
|
new_value: "on",
|
||||||
expectation: Incremental,
|
expectation: Incremental,
|
||||||
},
|
},
|
||||||
|
RetainedDifferentialCase {
|
||||||
|
name: "inline style and inherited custom property",
|
||||||
|
css: ".subject .grand{width:var(--leaf-width,17px);color:inherit}",
|
||||||
|
target: "subject",
|
||||||
|
attribute: "style",
|
||||||
|
new_value: "width:113px;height:29px;--leaf-width:37px;color:#345678",
|
||||||
|
expectation: Incremental,
|
||||||
|
},
|
||||||
|
RetainedDifferentialCase {
|
||||||
|
name: "inline style attribute selector reaches sibling",
|
||||||
|
css: ".subject[style]+.panel{width:127px;height:31px}",
|
||||||
|
target: "subject",
|
||||||
|
attribute: "style",
|
||||||
|
new_value: "height:23px",
|
||||||
|
expectation: Incremental,
|
||||||
|
},
|
||||||
RetainedDifferentialCase {
|
RetainedDifferentialCase {
|
||||||
name: "counter propagation through retained siblings",
|
name: "counter propagation through retained siblings",
|
||||||
css: ".scope{counter-reset:item}.subject,.panel{counter-increment:item}.subject[data-state=on]{counter-increment:item 3}.subject::before,.panel::before{content:counter(item)}",
|
css: ".scope{counter-reset:item}.subject,.panel{counter-increment:item}.subject[data-state=on]{counter-increment:item 3}.subject::before,.panel::before{content:counter(item)}",
|
||||||
@@ -12832,6 +13083,320 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn finish_retained_tree_case(
|
||||||
|
name: &str,
|
||||||
|
tree: &DomTree,
|
||||||
|
cache: &mut crate::css::StylesheetCache,
|
||||||
|
mut initial: DomLayout,
|
||||||
|
mutation: TreeStyleMutation,
|
||||||
|
expectation: RetainedDifferentialExpectation,
|
||||||
|
) -> DomLayout {
|
||||||
|
let retained = RetainedStyleMaps {
|
||||||
|
styles: std::mem::take(&mut initial.styles),
|
||||||
|
custom_properties: std::mem::take(&mut initial.custom_properties),
|
||||||
|
};
|
||||||
|
let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit(
|
||||||
|
tree,
|
||||||
|
(360.0, 260.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
Some(cache),
|
||||||
|
Some(retained),
|
||||||
|
&[mutation.into()],
|
||||||
|
);
|
||||||
|
let full = layout_dom(tree, (360.0, 260.0));
|
||||||
|
match expectation {
|
||||||
|
RetainedDifferentialExpectation::Incremental => {
|
||||||
|
assert_eq!(telemetry.retained_fallback, 0, "{name}");
|
||||||
|
assert!(telemetry.retained_fresh > 0, "{name}");
|
||||||
|
assert!(telemetry.retained_reused > 0, "{name}");
|
||||||
|
}
|
||||||
|
RetainedDifferentialExpectation::ReuseAll => {
|
||||||
|
assert_eq!(telemetry.retained_fallback, 0, "{name}");
|
||||||
|
assert_eq!(telemetry.retained_fresh, 0, "{name}");
|
||||||
|
assert!(telemetry.retained_reused > 0, "{name}");
|
||||||
|
}
|
||||||
|
RetainedDifferentialExpectation::ConservativeFallback => {
|
||||||
|
assert_eq!(telemetry.retained_fallback, 1, "{name}");
|
||||||
|
assert_eq!(telemetry.retained_reused, 0, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_computed_styles_match(name, &incremental, &full);
|
||||||
|
assert_eq!(incremental.rects, full.rects, "{name}: border boxes");
|
||||||
|
assert_eq!(
|
||||||
|
incremental.inline_fragments, full.inline_fragments,
|
||||||
|
"{name}: inline fragments"
|
||||||
|
);
|
||||||
|
assert_eq!(incremental.text_runs, full.text_runs, "{name}: text runs");
|
||||||
|
assert_eq!(
|
||||||
|
incremental.custom_properties, full.custom_properties,
|
||||||
|
"{name}: custom properties"
|
||||||
|
);
|
||||||
|
incremental
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_tree_styles_match_forced_full_mutation_matrix() {
|
||||||
|
use RetainedDifferentialExpectation::{ConservativeFallback, Incremental};
|
||||||
|
|
||||||
|
// A fresh insertion must cascade the inserted subtree and every
|
||||||
|
// structurally affected sibling while retaining an unrelated branch.
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<style>
|
||||||
|
.list{counter-reset:item}.item{counter-increment:item;height:9px}
|
||||||
|
.item::before{content:counter(item)}
|
||||||
|
.item:nth-child(2){width:83px}.item:first-child{color:#123456}
|
||||||
|
.list:empty + .after{height:77px}.clean{width:91px;height:13px}
|
||||||
|
</style><main><section id="list" class="list">
|
||||||
|
<i id="first" class="item"></i><i id="insert" class="item"></i><i id="last" class="item"></i>
|
||||||
|
</section><div class="after"></div><aside class="clean"></aside></main>"#,
|
||||||
|
);
|
||||||
|
let list = tree.get_element_by_id("list").unwrap();
|
||||||
|
let insert = tree.get_element_by_id("insert").unwrap();
|
||||||
|
let last = tree.get_element_by_id("last").unwrap();
|
||||||
|
tree.remove_child(insert);
|
||||||
|
let mut cache = crate::css::StylesheetCache::default();
|
||||||
|
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||||
|
&tree,
|
||||||
|
(360.0, 260.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
&mut cache,
|
||||||
|
);
|
||||||
|
tree.insert_before(last, insert);
|
||||||
|
finish_retained_tree_case(
|
||||||
|
"fresh insert with nth, counters, and pseudos",
|
||||||
|
&tree,
|
||||||
|
&mut cache,
|
||||||
|
initial,
|
||||||
|
TreeStyleMutation::Insert {
|
||||||
|
node: insert,
|
||||||
|
old_parent: None,
|
||||||
|
new_parent: list,
|
||||||
|
},
|
||||||
|
Incremental,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Removing a subtree must purge its now-detached style/custom-property
|
||||||
|
// entries instead of growing the retained maps forever.
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<style>.row:nth-child(even){width:72px}.row::before{content:'x'}.clean{height:15px}</style>
|
||||||
|
<main id="rows"><div class="row"></div><div id="removed" class="row"><b id="removed-child"></b></div><div class="row"></div></main><aside class="clean"></aside>"#,
|
||||||
|
);
|
||||||
|
let rows = tree.get_element_by_id("rows").unwrap();
|
||||||
|
let removed = tree.get_element_by_id("removed").unwrap();
|
||||||
|
let removed_child = tree.get_element_by_id("removed-child").unwrap();
|
||||||
|
let mut cache = crate::css::StylesheetCache::default();
|
||||||
|
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||||
|
&tree,
|
||||||
|
(360.0, 260.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
&mut cache,
|
||||||
|
);
|
||||||
|
tree.remove_child(removed);
|
||||||
|
let incremental = finish_retained_tree_case(
|
||||||
|
"remove and purge detached subtree",
|
||||||
|
&tree,
|
||||||
|
&mut cache,
|
||||||
|
initial,
|
||||||
|
TreeStyleMutation::Remove {
|
||||||
|
node: removed,
|
||||||
|
old_parent: rows,
|
||||||
|
},
|
||||||
|
Incremental,
|
||||||
|
);
|
||||||
|
assert!(!incremental.styles.contains_key(&removed));
|
||||||
|
assert!(!incremental.styles.contains_key(&removed_child));
|
||||||
|
assert!(!incremental.custom_properties.contains_key(&removed));
|
||||||
|
assert!(!incremental.custom_properties.contains_key(&removed_child));
|
||||||
|
|
||||||
|
// Character-data changes keep selector matching local but must rebuild
|
||||||
|
// text shaping and parent geometry.
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<style>#label{display:block;width:120px}.clean{height:17px}</style><div id="label">a</div><aside class="clean"></aside>"#,
|
||||||
|
);
|
||||||
|
let label = tree.get_element_by_id("label").unwrap();
|
||||||
|
let text = tree.children(label)[0];
|
||||||
|
let mut cache = crate::css::StylesheetCache::default();
|
||||||
|
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||||
|
&tree,
|
||||||
|
(360.0, 260.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
&mut cache,
|
||||||
|
);
|
||||||
|
tree.with_node_mut(text, |node| {
|
||||||
|
if let obscura_dom::tree::NodeData::Text { contents } = &mut node.data {
|
||||||
|
*contents = "a substantially longer replacement".into();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
finish_retained_tree_case(
|
||||||
|
"text replacement",
|
||||||
|
&tree,
|
||||||
|
&mut cache,
|
||||||
|
initial,
|
||||||
|
TreeStyleMutation::Text {
|
||||||
|
node: text,
|
||||||
|
parent: Some(label),
|
||||||
|
},
|
||||||
|
Incremental,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keyed :has() rules do not poison unrelated mutations, but a matching
|
||||||
|
// inserted key still takes the correctness-first full path.
|
||||||
|
for (name, inserted_class, expectation) in [
|
||||||
|
("unrelated keyed has insertion", "other", Incremental),
|
||||||
|
("matching keyed has insertion", "signal", ConservativeFallback),
|
||||||
|
] {
|
||||||
|
let html = format!(
|
||||||
|
r#"<style>.host:has(.signal) .dependent{{width:101px}}.clean{{height:19px}}</style>
|
||||||
|
<section id="host" class="host"><div id="target"><i id="insert" class="{inserted_class}"></i></div><span class="dependent"></span></section><aside class="clean"></aside>"#
|
||||||
|
);
|
||||||
|
let tree = parse_html(&html);
|
||||||
|
let target = tree.get_element_by_id("target").unwrap();
|
||||||
|
let insert = tree.get_element_by_id("insert").unwrap();
|
||||||
|
tree.remove_child(insert);
|
||||||
|
let mut cache = crate::css::StylesheetCache::default();
|
||||||
|
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||||
|
&tree,
|
||||||
|
(360.0, 260.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
&mut cache,
|
||||||
|
);
|
||||||
|
tree.append_child(target, insert);
|
||||||
|
finish_retained_tree_case(
|
||||||
|
name,
|
||||||
|
&tree,
|
||||||
|
&mut cache,
|
||||||
|
initial,
|
||||||
|
TreeStyleMutation::Insert {
|
||||||
|
node: insert,
|
||||||
|
old_parent: None,
|
||||||
|
new_parent: target,
|
||||||
|
},
|
||||||
|
expectation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, inserted_tag, expectation) in [
|
||||||
|
("unrelated keyed tag has insertion", "i", Incremental),
|
||||||
|
("matching keyed tag has insertion", "span", ConservativeFallback),
|
||||||
|
] {
|
||||||
|
let html = format!(
|
||||||
|
r#"<style>.host:has(> span) .dependent{{width:103px}}.clean{{height:21px}}</style>
|
||||||
|
<section id="host" class="host"><{inserted_tag} id="insert"></{inserted_tag}><div class="dependent"></div></section><aside class="clean"></aside>"#
|
||||||
|
);
|
||||||
|
let tree = parse_html(&html);
|
||||||
|
let target = tree.get_element_by_id("host").unwrap();
|
||||||
|
let insert = tree.get_element_by_id("insert").unwrap();
|
||||||
|
tree.remove_child(insert);
|
||||||
|
let mut cache = crate::css::StylesheetCache::default();
|
||||||
|
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||||
|
&tree,
|
||||||
|
(360.0, 260.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
&mut cache,
|
||||||
|
);
|
||||||
|
tree.append_child(target, insert);
|
||||||
|
finish_retained_tree_case(
|
||||||
|
name,
|
||||||
|
&tree,
|
||||||
|
&mut cache,
|
||||||
|
initial,
|
||||||
|
TreeStyleMutation::Insert {
|
||||||
|
node: insert,
|
||||||
|
old_parent: None,
|
||||||
|
new_parent: target,
|
||||||
|
},
|
||||||
|
expectation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style text changes alter the cache key and must never reuse styles.
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<style id="sheet">.subject{width:20px}</style><div class="subject"></div>"#,
|
||||||
|
);
|
||||||
|
let style = tree.get_element_by_id("sheet").unwrap();
|
||||||
|
let text = tree.children(style)[0];
|
||||||
|
let mut cache = crate::css::StylesheetCache::default();
|
||||||
|
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||||
|
&tree,
|
||||||
|
(360.0, 260.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
&mut cache,
|
||||||
|
);
|
||||||
|
tree.with_node_mut(text, |node| {
|
||||||
|
if let obscura_dom::tree::NodeData::Text { contents } = &mut node.data {
|
||||||
|
*contents = ".subject{width:140px}".into();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
finish_retained_tree_case(
|
||||||
|
"style text fallback",
|
||||||
|
&tree,
|
||||||
|
&mut cache,
|
||||||
|
initial,
|
||||||
|
TreeStyleMutation::Text {
|
||||||
|
node: text,
|
||||||
|
parent: Some(style),
|
||||||
|
},
|
||||||
|
ConservativeFallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_tree_invalidation_keeps_large_unrelated_branch_clean() {
|
||||||
|
let mut clean = String::new();
|
||||||
|
for index in 0..2_000 {
|
||||||
|
clean.push_str(&format!("<span class=clean data-index={index}></span>"));
|
||||||
|
}
|
||||||
|
let tree = parse_html(&format!(
|
||||||
|
r#"<style>.item:nth-child(2){{width:88px}}.clean{{height:1px}}</style>
|
||||||
|
<main><section id=list><i class=item></i><i id=insert class=item></i></section><aside>{clean}</aside></main>"#
|
||||||
|
));
|
||||||
|
let list = tree.get_element_by_id("list").unwrap();
|
||||||
|
let insert = tree.get_element_by_id("insert").unwrap();
|
||||||
|
tree.remove_child(insert);
|
||||||
|
let mut cache = crate::css::StylesheetCache::default();
|
||||||
|
let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||||
|
&tree,
|
||||||
|
(800.0, 600.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
&mut cache,
|
||||||
|
);
|
||||||
|
tree.append_child(list, insert);
|
||||||
|
let retained = RetainedStyleMaps {
|
||||||
|
styles: std::mem::take(&mut initial.styles),
|
||||||
|
custom_properties: std::mem::take(&mut initial.custom_properties),
|
||||||
|
};
|
||||||
|
let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit(
|
||||||
|
&tree,
|
||||||
|
(800.0, 600.0),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
Some(&mut cache),
|
||||||
|
Some(retained),
|
||||||
|
&[TreeStyleMutation::Insert {
|
||||||
|
node: insert,
|
||||||
|
old_parent: None,
|
||||||
|
new_parent: list,
|
||||||
|
}
|
||||||
|
.into()],
|
||||||
|
);
|
||||||
|
let full = layout_dom(&tree, (800.0, 600.0));
|
||||||
|
assert_computed_styles_match("large unrelated branch", &incremental, &full);
|
||||||
|
assert_eq!(incremental.rects, full.rects);
|
||||||
|
assert_eq!(telemetry.retained_fallback, 0);
|
||||||
|
assert!(telemetry.retained_reused >= 2_000, "{telemetry:?}");
|
||||||
|
assert!(telemetry.retained_fresh < 16, "{telemetry:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn container_convergence_classifies_self_consistent_stability() {
|
fn container_convergence_classifies_self_consistent_stability() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -2932,6 +2932,27 @@ impl TextEngine {
|
|||||||
clip_override: Option<Rect>,
|
clip_override: Option<Rect>,
|
||||||
clip_mask: Option<&tiny_skia::Mask>,
|
clip_mask: Option<&tiny_skia::Mask>,
|
||||||
raster_scale: f32,
|
raster_scale: f32,
|
||||||
|
) {
|
||||||
|
self.paint_item_with_clip_mask_scaled_for_print(
|
||||||
|
idx,
|
||||||
|
pixmap,
|
||||||
|
offset,
|
||||||
|
clip_override,
|
||||||
|
clip_mask,
|
||||||
|
raster_scale,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn paint_item_with_clip_mask_scaled_for_print(
|
||||||
|
&mut self,
|
||||||
|
idx: usize,
|
||||||
|
pixmap: &mut tiny_skia::Pixmap,
|
||||||
|
offset: (f32, f32),
|
||||||
|
clip_override: Option<Rect>,
|
||||||
|
clip_mask: Option<&tiny_skia::Mask>,
|
||||||
|
raster_scale: f32,
|
||||||
|
print_economy: bool,
|
||||||
) {
|
) {
|
||||||
let TextEngine {
|
let TextEngine {
|
||||||
font_system,
|
font_system,
|
||||||
@@ -3030,10 +3051,13 @@ impl TextEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let col = g
|
let mut col = g
|
||||||
.color_opt
|
.color_opt
|
||||||
.map(|c| [c.r(), c.g(), c.b(), c.a()])
|
.map(|c| [c.r(), c.g(), c.b(), c.a()])
|
||||||
.unwrap_or([0, 0, 0, 255]);
|
.unwrap_or([0, 0, 0, 255]);
|
||||||
|
if print_economy {
|
||||||
|
col = crate::paint::print_economy_color(col);
|
||||||
|
}
|
||||||
if underlined {
|
if underlined {
|
||||||
match &mut seg {
|
match &mut seg {
|
||||||
Some((_, x1, fs, c, prior_relative))
|
Some((_, x1, fs, c, prior_relative))
|
||||||
@@ -3125,6 +3149,9 @@ impl TextEngine {
|
|||||||
let physical = glyph.physical((line_offset + relative.0, relative.1), raster_scale);
|
let physical = glyph.physical((line_offset + relative.0, relative.1), raster_scale);
|
||||||
let glyph_color = glyph.color_opt.unwrap_or(default);
|
let glyph_color = glyph.color_opt.unwrap_or(default);
|
||||||
let fill_index = metadata_fill(glyph.metadata);
|
let fill_index = metadata_fill(glyph.metadata);
|
||||||
|
if print_economy && fill_index.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut draw_pixel = |x, y, color: Color| {
|
let mut draw_pixel = |x, y, color: Color| {
|
||||||
// cosmic-text's mask rasterizer replaces the authored
|
// cosmic-text's mask rasterizer replaces the authored
|
||||||
// alpha with glyph coverage (see its `blend base alpha?`
|
// alpha with glyph coverage (see its `blend base alpha?`
|
||||||
@@ -3136,7 +3163,7 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
let gx = physical.x + x;
|
let gx = physical.x + x;
|
||||||
let gy = (run.line_y * raster_scale) as i32 + physical.y + y;
|
let gy = (run.line_y * raster_scale) as i32 + physical.y + y;
|
||||||
let (r, g, b) = fill_index
|
let (mut r, mut g, mut b) = fill_index
|
||||||
.and_then(|index| {
|
.and_then(|index| {
|
||||||
let fill = clip_fills.get(index)?;
|
let fill = clip_fills.get(index)?;
|
||||||
let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?;
|
let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?;
|
||||||
@@ -3150,6 +3177,10 @@ impl TextEngine {
|
|||||||
Some((sampled[0], sampled[1], sampled[2]))
|
Some((sampled[0], sampled[1], sampled[2]))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| (color.r(), color.g(), color.b()));
|
.unwrap_or_else(|| (color.r(), color.g(), color.b()));
|
||||||
|
if print_economy {
|
||||||
|
let adjusted = crate::paint::print_economy_color([r, g, b, 255]);
|
||||||
|
(r, g, b) = (adjusted[0], adjusted[1], adjusted[2]);
|
||||||
|
}
|
||||||
let px = ox as i32 + gx;
|
let px = ox as i32 + gx;
|
||||||
let py = oy as i32 + gy;
|
let py = oy as i32 + gy;
|
||||||
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub use border::{
|
|||||||
pub mod dom;
|
pub mod dom;
|
||||||
pub use dom::{
|
pub use dom::{
|
||||||
layout_dom, layout_dom_with_images, layout_dom_with_resources, AttributeStyleMutation,
|
layout_dom, layout_dom_with_images, layout_dom_with_resources, AttributeStyleMutation,
|
||||||
DomLayout, StickyLayout,
|
DomLayout, RetainedStyleMutation, StickyLayout, TreeStyleMutation,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Whether an image MIME type names a format supported by the renderer build.
|
/// Whether an image MIME type names a format supported by the renderer build.
|
||||||
@@ -100,8 +100,10 @@ pub use paint::{
|
|||||||
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
|
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
|
||||||
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
|
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
|
||||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
|
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
|
||||||
prepare_dom_with_retained_attribute_styles, screenshot_prepared,
|
prepare_dom_with_retained_attribute_styles, prepare_dom_with_retained_styles,
|
||||||
screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll,
|
screenshot_prepared,
|
||||||
|
screenshot_prepared_region_with_scroll,
|
||||||
|
screenshot_prepared_region_with_scroll_and_backgrounds, screenshot_prepared_with_scroll,
|
||||||
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
|
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
|
||||||
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
|
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
|
||||||
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||||
|
|||||||
@@ -1634,8 +1634,37 @@ pub fn prepare_dom_with_retained_attribute_styles(
|
|||||||
resources: &mut RenderResourceCache,
|
resources: &mut RenderResourceCache,
|
||||||
dynamic_fonts: &[DynamicFontFace],
|
dynamic_fonts: &[DynamicFontFace],
|
||||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||||
mut previous: PreparedRender,
|
previous: PreparedRender,
|
||||||
mutations: &[crate::dom::AttributeStyleMutation],
|
mutations: &[crate::dom::AttributeStyleMutation],
|
||||||
|
) -> Option<PreparedRender> {
|
||||||
|
let mutations = mutations
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(crate::dom::RetainedStyleMutation::Attribute)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
prepare_dom_with_retained_styles(
|
||||||
|
tree,
|
||||||
|
viewport,
|
||||||
|
base_url,
|
||||||
|
resources,
|
||||||
|
dynamic_fonts,
|
||||||
|
stylesheet_cache,
|
||||||
|
previous,
|
||||||
|
&mutations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild geometry while retaining clean styles across both selector-key
|
||||||
|
/// attribute changes and conservatively scoped tree/text changes.
|
||||||
|
pub fn prepare_dom_with_retained_styles(
|
||||||
|
tree: &DomTree,
|
||||||
|
viewport: (f32, f32),
|
||||||
|
base_url: Option<&str>,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
dynamic_fonts: &[DynamicFontFace],
|
||||||
|
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||||
|
mut previous: PreparedRender,
|
||||||
|
mutations: &[crate::dom::RetainedStyleMutation],
|
||||||
) -> Option<PreparedRender> {
|
) -> Option<PreparedRender> {
|
||||||
let retained = RetainedStyleMaps {
|
let retained = RetainedStyleMaps {
|
||||||
styles: std::mem::take(&mut previous.layout.styles),
|
styles: std::mem::take(&mut previous.layout.styles),
|
||||||
@@ -1660,7 +1689,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
|||||||
resources: &mut RenderResourceCache,
|
resources: &mut RenderResourceCache,
|
||||||
dynamic_fonts: &[DynamicFontFace],
|
dynamic_fonts: &[DynamicFontFace],
|
||||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||||
retained: Option<(RetainedStyleMaps, &[crate::dom::AttributeStyleMutation])>,
|
retained: Option<(RetainedStyleMaps, &[crate::dom::RetainedStyleMutation])>,
|
||||||
) -> Option<PreparedRender> {
|
) -> Option<PreparedRender> {
|
||||||
if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0
|
if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0
|
||||||
{
|
{
|
||||||
@@ -1812,6 +1841,7 @@ pub fn paint_prepared(
|
|||||||
None,
|
None,
|
||||||
(0.0, 0.0),
|
(0.0, 0.0),
|
||||||
1.0,
|
1.0,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1856,6 +1886,7 @@ pub fn paint_prepared_with_scroll(
|
|||||||
None,
|
None,
|
||||||
(0.0, 0.0),
|
(0.0, 0.0),
|
||||||
1.0,
|
1.0,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1869,6 +1900,27 @@ pub fn paint_prepared_region_with_scroll(
|
|||||||
resources: &mut RenderResourceCache,
|
resources: &mut RenderResourceCache,
|
||||||
scroll: &ResolvedScrollState,
|
scroll: &ResolvedScrollState,
|
||||||
region: CaptureRegion,
|
region: CaptureRegion,
|
||||||
|
) -> Result<Pixmap, CaptureError> {
|
||||||
|
paint_prepared_region_with_scroll_policy(tree, prepared, resources, scroll, region, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_prepared_region_with_scroll_with_print_economy(
|
||||||
|
tree: &DomTree,
|
||||||
|
prepared: &mut PreparedRender,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
scroll: &ResolvedScrollState,
|
||||||
|
region: CaptureRegion,
|
||||||
|
) -> Result<Pixmap, CaptureError> {
|
||||||
|
paint_prepared_region_with_scroll_policy(tree, prepared, resources, scroll, region, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_prepared_region_with_scroll_policy(
|
||||||
|
tree: &DomTree,
|
||||||
|
prepared: &mut PreparedRender,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
scroll: &ResolvedScrollState,
|
||||||
|
region: CaptureRegion,
|
||||||
|
print_economy: bool,
|
||||||
) -> Result<Pixmap, CaptureError> {
|
) -> Result<Pixmap, CaptureError> {
|
||||||
let (native_width, native_height, output_width, output_height) =
|
let (native_width, native_height, output_width, output_height) =
|
||||||
checked_capture_dimensions(region)?;
|
checked_capture_dimensions(region)?;
|
||||||
@@ -1917,6 +1969,7 @@ pub fn paint_prepared_region_with_scroll(
|
|||||||
Some((region.width, region.height)),
|
Some((region.width, region.height)),
|
||||||
surface_offset,
|
surface_offset,
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
)
|
)
|
||||||
.ok_or(CaptureError::PaintFailed)?;
|
.ok_or(CaptureError::PaintFailed)?;
|
||||||
|
|
||||||
@@ -2140,6 +2193,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
surface_extent: Option<(f32, f32)>,
|
surface_extent: Option<(f32, f32)>,
|
||||||
surface_offset: (f32, f32),
|
surface_offset: (f32, f32),
|
||||||
raster_scale: f32,
|
raster_scale: f32,
|
||||||
|
print_economy: bool,
|
||||||
) -> Option<Pixmap> {
|
) -> Option<Pixmap> {
|
||||||
let scroll_state = match resolved_scroll {
|
let scroll_state = match resolved_scroll {
|
||||||
Some(resolved) => ScrollPaintState::from_resolved(
|
Some(resolved) => ScrollPaintState::from_resolved(
|
||||||
@@ -2348,6 +2402,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
surface_extent,
|
surface_extent,
|
||||||
surface_offset,
|
surface_offset,
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
)?;
|
)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2372,13 +2427,14 @@ fn paint_laid_dom_scrolled(
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
for &item in items {
|
for &item in items {
|
||||||
laid.text_engine.paint_item_with_clip_mask_scaled(
|
laid.text_engine.paint_item_with_clip_mask_scaled_for_print(
|
||||||
item,
|
item,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
offset,
|
offset,
|
||||||
clip,
|
clip,
|
||||||
clip_mask.as_ref(),
|
clip_mask.as_ref(),
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2445,6 +2501,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
surface_extent,
|
surface_extent,
|
||||||
surface_offset,
|
surface_offset,
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
)?;
|
)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2519,6 +2576,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
surface_offset.1 + layer_delta.1,
|
surface_offset.1 + layer_delta.1,
|
||||||
),
|
),
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
)?;
|
)?;
|
||||||
let transform =
|
let transform =
|
||||||
display_transform.then(crate::Affine2::translate(-layer_delta.0, -layer_delta.1));
|
display_transform.then(crate::Affine2::translate(-layer_delta.0, -layer_delta.1));
|
||||||
@@ -2587,6 +2645,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
surface_extent,
|
surface_extent,
|
||||||
surface_offset,
|
surface_offset,
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
)?;
|
)?;
|
||||||
let group_paint = tiny_skia::PixmapPaint {
|
let group_paint = tiny_skia::PixmapPaint {
|
||||||
opacity: own_opacity,
|
opacity: own_opacity,
|
||||||
@@ -3277,26 +3336,28 @@ fn paint_laid_dom_scrolled(
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
if let Some(idx) = whole {
|
if let Some(idx) = whole {
|
||||||
laid.text_engine.paint_item_with_clip_mask_scaled(
|
laid.text_engine.paint_item_with_clip_mask_scaled_for_print(
|
||||||
idx,
|
idx,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
off,
|
off,
|
||||||
clip,
|
clip,
|
||||||
clip_mask.as_ref(),
|
clip_mask.as_ref(),
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Anonymous inline-run leaves of a mixed block (see
|
// Anonymous inline-run leaves of a mixed block (see
|
||||||
// `build_mixed_block`), pinned to their own boxes at finalize.
|
// `build_mixed_block`), pinned to their own boxes at finalize.
|
||||||
if let Some(items) = run_items {
|
if let Some(items) = run_items {
|
||||||
for idx in items {
|
for idx in items {
|
||||||
laid.text_engine.paint_item_with_clip_mask_scaled(
|
laid.text_engine.paint_item_with_clip_mask_scaled_for_print(
|
||||||
idx,
|
idx,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
off,
|
off,
|
||||||
clip,
|
clip,
|
||||||
clip_mask.as_ref(),
|
clip_mask.as_ref(),
|
||||||
raster_scale,
|
raster_scale,
|
||||||
|
print_economy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4549,6 +4610,144 @@ pub fn screenshot_prepared_region_with_scroll(
|
|||||||
.map_err(|_| CaptureError::EncodeFailed)
|
.map_err(|_| CaptureError::EncodeFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capture a retained region using the PDF print-background policy. Ordinary
|
||||||
|
/// screenshots pass `true`. When false, authored fills are replaced with the
|
||||||
|
/// browser print-economy white fill and light text is darkened for legibility;
|
||||||
|
/// borders, shadows, replaced content, and masks remain paintable.
|
||||||
|
pub fn screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||||
|
tree: &DomTree,
|
||||||
|
prepared: &mut PreparedRender,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
scroll: &ResolvedScrollState,
|
||||||
|
region: CaptureRegion,
|
||||||
|
paint_backgrounds: bool,
|
||||||
|
) -> Result<Vec<u8>, CaptureError> {
|
||||||
|
if paint_backgrounds {
|
||||||
|
return screenshot_prepared_region_with_scroll(tree, prepared, resources, scroll, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshots = prepared
|
||||||
|
.layout
|
||||||
|
.styles
|
||||||
|
.iter_mut()
|
||||||
|
.map(|(&node, style)| (node, PrintEconomyStyleSnapshot::apply(style)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let result = paint_prepared_region_with_scroll_with_print_economy(
|
||||||
|
tree, prepared, resources, scroll, region,
|
||||||
|
)
|
||||||
|
.and_then(|pixmap| pixmap.encode_png().map_err(|_| CaptureError::EncodeFailed));
|
||||||
|
for (node, snapshot) in snapshots {
|
||||||
|
if let Some(style) = prepared.layout.styles.get_mut(&node) {
|
||||||
|
snapshot.restore(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrintEconomyStyleSnapshot {
|
||||||
|
background_color: Option<[u8; 4]>,
|
||||||
|
background_gradient: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
|
||||||
|
background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||||
|
background_conic_gradient: Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||||
|
background_gradient_layers: Vec<crate::BackgroundGradientLayer>,
|
||||||
|
background_image: Option<String>,
|
||||||
|
color: Option<[u8; 4]>,
|
||||||
|
before_pseudo: Option<Box<PrintEconomyStyleSnapshot>>,
|
||||||
|
after_pseudo: Option<Box<PrintEconomyStyleSnapshot>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrintEconomyStyleSnapshot {
|
||||||
|
fn apply(style: &mut crate::LayoutStyle) -> Self {
|
||||||
|
let background_color = style.background_color.take();
|
||||||
|
let background_gradient = style.background_gradient.take();
|
||||||
|
let background_radial_gradient = style.background_radial_gradient.take();
|
||||||
|
let background_conic_gradient = style.background_conic_gradient.take();
|
||||||
|
let background_gradient_layers = std::mem::take(&mut style.background_gradient_layers);
|
||||||
|
let background_image = style.background_image.take();
|
||||||
|
let had_background = background_color.is_some_and(|color| color[3] != 0)
|
||||||
|
|| background_gradient.is_some()
|
||||||
|
|| background_radial_gradient.is_some()
|
||||||
|
|| background_conic_gradient.is_some()
|
||||||
|
|| !background_gradient_layers.is_empty()
|
||||||
|
|| background_image.is_some();
|
||||||
|
style.background_color = had_background.then_some([255, 255, 255, 255]);
|
||||||
|
let color = style.color;
|
||||||
|
style.color = color.map(print_economy_color);
|
||||||
|
let before_pseudo = style
|
||||||
|
.before_pseudo
|
||||||
|
.as_deref_mut()
|
||||||
|
.map(Self::apply)
|
||||||
|
.map(Box::new);
|
||||||
|
let after_pseudo = style
|
||||||
|
.after_pseudo
|
||||||
|
.as_deref_mut()
|
||||||
|
.map(Self::apply)
|
||||||
|
.map(Box::new);
|
||||||
|
Self {
|
||||||
|
background_color,
|
||||||
|
background_gradient,
|
||||||
|
background_radial_gradient,
|
||||||
|
background_conic_gradient,
|
||||||
|
background_gradient_layers,
|
||||||
|
background_image,
|
||||||
|
color,
|
||||||
|
before_pseudo,
|
||||||
|
after_pseudo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(self, style: &mut crate::LayoutStyle) {
|
||||||
|
style.background_color = self.background_color;
|
||||||
|
style.background_gradient = self.background_gradient;
|
||||||
|
style.background_radial_gradient = self.background_radial_gradient;
|
||||||
|
style.background_conic_gradient = self.background_conic_gradient;
|
||||||
|
style.background_gradient_layers = self.background_gradient_layers;
|
||||||
|
style.background_image = self.background_image;
|
||||||
|
style.color = self.color;
|
||||||
|
if let (Some(snapshot), Some(pseudo)) =
|
||||||
|
(self.before_pseudo, style.before_pseudo.as_deref_mut())
|
||||||
|
{
|
||||||
|
snapshot.restore(pseudo);
|
||||||
|
}
|
||||||
|
if let (Some(snapshot), Some(pseudo)) =
|
||||||
|
(self.after_pseudo, style.after_pseudo.as_deref_mut())
|
||||||
|
{
|
||||||
|
snapshot.restore(pseudo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blink's print-economy foreground correction: colors too close to white
|
||||||
|
/// move one third down the HSV value axis while preserving hue and alpha.
|
||||||
|
pub(crate) fn print_economy_color(color: [u8; 4]) -> [u8; 4] {
|
||||||
|
const MIN_DIFFERENCE_SQUARED: i32 = 65_025;
|
||||||
|
let difference = |target: u8| -> i32 {
|
||||||
|
color[..3]
|
||||||
|
.iter()
|
||||||
|
.map(|component| {
|
||||||
|
let delta = i32::from(*component) - i32::from(target);
|
||||||
|
delta * delta
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
};
|
||||||
|
if difference(255) > MIN_DIFFERENCE_SQUARED {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
let max = color[0].max(color[1]).max(color[2]) as f32 / 255.0;
|
||||||
|
if max <= f32::EPSILON {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
let scale = (max - 0.33).max(0.0) / max;
|
||||||
|
let adjusted =
|
||||||
|
|component: u8| ((f32::from(component) / 255.0 * scale * 256.0) as u16).min(255) as u8;
|
||||||
|
[
|
||||||
|
adjusted(color[0]),
|
||||||
|
adjusted(color[1]),
|
||||||
|
adjusted(color[2]),
|
||||||
|
color[3],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -11692,6 +11891,69 @@ mod tests {
|
|||||||
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
|
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn print_background_policy_matches_chromium_economy_and_restores_screen_paint() {
|
||||||
|
assert_eq!(
|
||||||
|
print_economy_color([255, 255, 255, 255]),
|
||||||
|
[171, 171, 171, 255]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
print_economy_color([105, 105, 105, 255]),
|
||||||
|
[105, 105, 105, 255]
|
||||||
|
);
|
||||||
|
assert_eq!(print_economy_color([110, 110, 110, 255]), [25, 25, 25, 255]);
|
||||||
|
assert_eq!(print_economy_color([255, 0, 0, 255]), [255, 0, 0, 255]);
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<html style="margin:0"><body style="margin:0;background:#123456">
|
||||||
|
<div style="box-sizing:border-box;width:100px;height:100px;
|
||||||
|
background:#ff0000;border:10px solid #0000ff;
|
||||||
|
color:#ffffff;font:40px sans-serif">X</div>
|
||||||
|
<div style="position:relative;width:120px;height:120px">
|
||||||
|
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Crect width='120' height='120' fill='orange'/%3E%3C/svg%3E"
|
||||||
|
style="position:absolute;inset:0;width:120px;height:120px">
|
||||||
|
<div style="position:absolute;left:20px;top:20px;width:80px;height:80px;
|
||||||
|
background-image:linear-gradient(#00ff00,#00ff00)"></div>
|
||||||
|
</div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
let mut resources = RenderResourceCache::default();
|
||||||
|
let mut prepared = prepare_dom(&tree, (120.0, 220.0), None, &mut resources)
|
||||||
|
.expect("prepared print fixture");
|
||||||
|
let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new());
|
||||||
|
let region = CaptureRegion::new(0.0, 0.0, 120.0, 220.0, 1.0);
|
||||||
|
|
||||||
|
let screen_before = screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||||
|
&tree, &mut prepared, &mut resources, &scroll, region, true,
|
||||||
|
)
|
||||||
|
.expect("screen capture before print");
|
||||||
|
let economy = screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||||
|
&tree, &mut prepared, &mut resources, &scroll, region, false,
|
||||||
|
)
|
||||||
|
.expect("print economy capture");
|
||||||
|
let screen_after = screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||||
|
&tree, &mut prepared, &mut resources, &scroll, region, true,
|
||||||
|
)
|
||||||
|
.expect("screen capture after print");
|
||||||
|
assert_eq!(screen_before, screen_after, "print paint must not mutate retained style");
|
||||||
|
|
||||||
|
let screen = image::load_from_memory_with_format(&screen_before, image::ImageFormat::Png)
|
||||||
|
.unwrap()
|
||||||
|
.into_rgb8();
|
||||||
|
let economy = image::load_from_memory_with_format(&economy, image::ImageFormat::Png)
|
||||||
|
.unwrap()
|
||||||
|
.into_rgb8();
|
||||||
|
assert_eq!(screen.get_pixel(80, 80).0, [255, 0, 0]);
|
||||||
|
assert_eq!(economy.get_pixel(80, 80).0, [255, 255, 255]);
|
||||||
|
assert_eq!(economy.get_pixel(5, 50).0, [0, 0, 255]);
|
||||||
|
assert_eq!(screen.get_pixel(50, 150).0, [0, 255, 0]);
|
||||||
|
assert_eq!(economy.get_pixel(5, 105).0, [255, 165, 0]);
|
||||||
|
assert_eq!(economy.get_pixel(50, 150).0, [255, 255, 255]);
|
||||||
|
assert!((10..90).any(|y| (10..90).any(|x| {
|
||||||
|
let [r, g, b] = economy.get_pixel(x, y).0;
|
||||||
|
(140..=200).contains(&r) && r.abs_diff(g) <= 2 && r.abs_diff(b) <= 2
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn document_region_capture_rejects_invalid_and_oversized_surfaces_before_paint() {
|
fn document_region_capture_rejects_invalid_and_oversized_surfaces_before_paint() {
|
||||||
let tree = parse_html("<div style='width:10px;height:10px;background:red'></div>");
|
let tree = parse_html("<div style='width:10px;height:10px;background:red'></div>");
|
||||||
|
|||||||
Reference in new issue
Block a user