Improve render lifecycle, geometry, and PDF parity
This commit is contained in:
1 parent
50b2ea14ff
commit
68f20cf525
12 files changed
+3279
-411
No files matched your search
@@ -12,7 +12,7 @@ pub use obscura_js::HTML_TO_MARKDOWN_JS;
|
||||
pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion};
|
||||
pub use page::{NetworkEvent, Page, PageError};
|
||||
#[cfg(feature = "render")]
|
||||
pub use pdf::{RasterPdfError, RasterPdfOptions};
|
||||
pub use pdf::{RasterPdfError, RasterPdfOptions, RasterPdfPageRange};
|
||||
// Re-exported so the embeddable `obscura` crate (which depends on obscura-browser,
|
||||
// not obscura-js) can surface the interception channel types.
|
||||
pub use obscura_js::ops::{InterceptResolution, InterceptedRequest};
|
||||
@@ -14,14 +14,28 @@ use crate::Page;
|
||||
const POINTS_PER_INCH: f32 = 72.0;
|
||||
const MAX_PAPER_INCHES: f32 = 200.0;
|
||||
const MAX_PDF_PAGES: usize = 250;
|
||||
// Page ranges may select a small bounded subset from a much longer document.
|
||||
// Keep the arithmetic/index space finite without charging unselected pages
|
||||
// against the output-page limit.
|
||||
const MAX_PDF_DOCUMENT_PAGES: usize = 1_000_000;
|
||||
const MAX_PDF_PAGE_PIXELS: u64 = 16 * 1024 * 1024;
|
||||
const MAX_PDF_TOTAL_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
|
||||
const MAX_PDF_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RasterPdfPageRange {
|
||||
/// One-based inclusive first page. `None` means the first page.
|
||||
pub start: Option<usize>,
|
||||
/// One-based inclusive last page. `None` means the final page.
|
||||
pub end: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RasterPdfOptions {
|
||||
pub landscape: bool,
|
||||
pub print_background: bool,
|
||||
pub scale: f32,
|
||||
pub page_ranges: Vec<RasterPdfPageRange>,
|
||||
pub paper_width_in: f32,
|
||||
pub paper_height_in: f32,
|
||||
pub margin_top_in: f32,
|
||||
@@ -35,6 +49,8 @@ impl Default for RasterPdfOptions {
|
||||
Self {
|
||||
landscape: false,
|
||||
print_background: false,
|
||||
scale: 1.0,
|
||||
page_ranges: Vec::new(),
|
||||
paper_width_in: 8.5,
|
||||
paper_height_in: 11.0,
|
||||
// CDP's defaults are one centimetre.
|
||||
@@ -52,6 +68,10 @@ pub enum RasterPdfError {
|
||||
InvalidPaperSize,
|
||||
#[error("PDF margins must be finite, non-negative, and leave a printable area")]
|
||||
InvalidMargins,
|
||||
#[error("PDF scale must be finite and between 0.1 and 2")]
|
||||
InvalidScale,
|
||||
#[error("PDF page ranges select no pages from this document")]
|
||||
EmptyPageRange,
|
||||
#[error("the page has no retained renderable document")]
|
||||
NoRenderableDocument,
|
||||
#[error("PDF pagination would exceed the {0}-page safety limit")]
|
||||
@@ -85,7 +105,7 @@ struct PaginationPlan {
|
||||
}
|
||||
|
||||
impl RasterPdfOptions {
|
||||
fn page_geometry(self) -> Result<(f32, f32, f32, f32, f32, f32), RasterPdfError> {
|
||||
fn page_geometry(&self) -> Result<(f32, f32, f32, f32, f32, f32), RasterPdfError> {
|
||||
let values = [self.paper_width_in, self.paper_height_in];
|
||||
if values
|
||||
.iter()
|
||||
@@ -137,8 +157,12 @@ fn pagination_plan(
|
||||
content_height: f32,
|
||||
printable_width: f32,
|
||||
printable_height: f32,
|
||||
scale: f32,
|
||||
) -> Result<PaginationPlan, RasterPdfError> {
|
||||
let points_per_css_pixel = printable_width / content_width;
|
||||
if !scale.is_finite() || !(0.1..=2.0).contains(&scale) {
|
||||
return Err(RasterPdfError::InvalidScale);
|
||||
}
|
||||
let points_per_css_pixel = printable_width / content_width * scale;
|
||||
let css_page_height = printable_height / points_per_css_pixel;
|
||||
if !points_per_css_pixel.is_finite()
|
||||
|| points_per_css_pixel <= 0.0
|
||||
@@ -149,10 +173,24 @@ fn pagination_plan(
|
||||
}
|
||||
|
||||
let page_count_value = (content_height / css_page_height).ceil().max(1.0);
|
||||
if !page_count_value.is_finite() || page_count_value > MAX_PDF_PAGES as f32 {
|
||||
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
|
||||
if !page_count_value.is_finite() || page_count_value > MAX_PDF_DOCUMENT_PAGES as f32 {
|
||||
return Err(RasterPdfError::TooManyPages(MAX_PDF_DOCUMENT_PAGES));
|
||||
}
|
||||
let page_count = page_count_value as usize;
|
||||
|
||||
Ok(PaginationPlan {
|
||||
points_per_css_pixel,
|
||||
css_page_height,
|
||||
page_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_selected_raster_work(
|
||||
content_width: f32,
|
||||
content_height: f32,
|
||||
plan: PaginationPlan,
|
||||
selected_pages: &[usize],
|
||||
) -> Result<(), RasterPdfError> {
|
||||
let pixel_width = content_width.ceil();
|
||||
if !pixel_width.is_finite()
|
||||
|| pixel_width <= 0.0
|
||||
@@ -162,9 +200,12 @@ fn pagination_plan(
|
||||
}
|
||||
let pixel_width = pixel_width as u64;
|
||||
let mut total_pixels = 0u64;
|
||||
for page_index in 0..page_count {
|
||||
let y = page_index as f32 * css_page_height;
|
||||
let slice_height = (content_height - y).min(css_page_height).ceil();
|
||||
for &page_index in selected_pages {
|
||||
if page_index >= plan.page_count {
|
||||
return Err(RasterPdfError::EmptyPageRange);
|
||||
}
|
||||
let y = page_index as f32 * plan.css_page_height;
|
||||
let slice_height = (content_height - y).min(plan.css_page_height).ceil();
|
||||
if !slice_height.is_finite()
|
||||
|| slice_height <= 0.0
|
||||
|| slice_height > obscura_js::MAX_CAPTURE_DIMENSION as f32
|
||||
@@ -185,11 +226,49 @@ fn pagination_plan(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PaginationPlan {
|
||||
points_per_css_pixel,
|
||||
css_page_height,
|
||||
page_count,
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn selected_page_indices(
|
||||
page_count: usize,
|
||||
ranges: &[RasterPdfPageRange],
|
||||
) -> Result<Vec<usize>, RasterPdfError> {
|
||||
if page_count == 0 {
|
||||
return Err(RasterPdfError::EmptyPageRange);
|
||||
}
|
||||
if ranges.is_empty() {
|
||||
if page_count > MAX_PDF_PAGES {
|
||||
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
|
||||
}
|
||||
return Ok((0..page_count).collect());
|
||||
}
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
for range in ranges {
|
||||
let start = range.start.unwrap_or(1);
|
||||
let end = range.end.unwrap_or(page_count);
|
||||
if start == 0 || end == 0 || start > end {
|
||||
return Err(RasterPdfError::EmptyPageRange);
|
||||
}
|
||||
if start > page_count {
|
||||
continue;
|
||||
}
|
||||
let end = end.min(page_count);
|
||||
let span = end - start + 1;
|
||||
if span > MAX_PDF_PAGES {
|
||||
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
|
||||
}
|
||||
for page in start..=end {
|
||||
selected.insert(page - 1);
|
||||
if selected.len() > MAX_PDF_PAGES {
|
||||
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
|
||||
}
|
||||
}
|
||||
}
|
||||
let selected = selected.into_iter().collect::<Vec<_>>();
|
||||
if selected.is_empty() {
|
||||
return Err(RasterPdfError::EmptyPageRange);
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
impl Page {
|
||||
@@ -197,7 +276,7 @@ impl Page {
|
||||
///
|
||||
/// The full document width is scaled uniformly into the printable width;
|
||||
/// vertical slices become pages. This does not reflow into `@media print`
|
||||
/// or implement CSS paged media, headers, footers, or page ranges.
|
||||
/// or implement CSS paged media, headers, or footers.
|
||||
pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result<Vec<u8>, RasterPdfError> {
|
||||
let (page_width, page_height, printable_width, printable_height, left, bottom) =
|
||||
options.page_geometry()?;
|
||||
@@ -221,16 +300,20 @@ impl Page {
|
||||
content_height,
|
||||
printable_width,
|
||||
printable_height,
|
||||
options.scale,
|
||||
)?;
|
||||
let selected_pages = selected_page_indices(plan.page_count, &options.page_ranges)?;
|
||||
validate_selected_raster_work(content_width, content_height, plan, &selected_pages)?;
|
||||
|
||||
encode_pdf_pages(
|
||||
plan.page_count,
|
||||
selected_pages.len(),
|
||||
page_width,
|
||||
page_height,
|
||||
left,
|
||||
bottom,
|
||||
printable_height,
|
||||
|page_index| {
|
||||
|output_page_index| {
|
||||
let page_index = selected_pages[output_page_index];
|
||||
let y = page_index as f32 * plan.css_page_height;
|
||||
let slice_height = (content_height - y).min(plan.css_page_height);
|
||||
let png = js
|
||||
@@ -249,7 +332,7 @@ impl Page {
|
||||
let rgb = decoded.into_rgb8();
|
||||
Ok(RasterPage {
|
||||
rgb,
|
||||
draw_width_pt: printable_width,
|
||||
draw_width_pt: content_width * plan.points_per_css_pixel,
|
||||
draw_height_pt: slice_height * plan.points_per_css_pixel,
|
||||
#[cfg(test)]
|
||||
_lifetime_probe: None,
|
||||
@@ -443,26 +526,166 @@ mod tests {
|
||||
fn pagination_preflight_bounds_pages_and_raster_work() {
|
||||
let (_, _, printable_width, printable_height, _, _) =
|
||||
RasterPdfOptions::default().page_geometry().unwrap();
|
||||
let ordinary = pagination_plan(1280.0, 10_000.0, printable_width, printable_height)
|
||||
let ordinary = pagination_plan(1280.0, 10_000.0, printable_width, printable_height, 1.0)
|
||||
.expect("an ordinary multi-page document stays inside the budget");
|
||||
assert!(ordinary.page_count > 1);
|
||||
let ordinary_pages = selected_page_indices(ordinary.page_count, &[]).unwrap();
|
||||
validate_selected_raster_work(1280.0, 10_000.0, ordinary, &ordinary_pages).unwrap();
|
||||
|
||||
let oversized_page =
|
||||
pagination_plan(5_000.0, 5_000.0, printable_width, printable_height, 1.0).unwrap();
|
||||
let oversized_page_selection =
|
||||
selected_page_indices(oversized_page.page_count, &[]).unwrap();
|
||||
assert_eq!(
|
||||
pagination_plan(5_000.0, 5_000.0, printable_width, printable_height).unwrap_err(),
|
||||
validate_selected_raster_work(
|
||||
5_000.0,
|
||||
5_000.0,
|
||||
oversized_page,
|
||||
&oversized_page_selection,
|
||||
)
|
||||
.unwrap_err(),
|
||||
RasterPdfError::RasterWorkLimitExceeded,
|
||||
"one excessively large raster page must fail before capture"
|
||||
);
|
||||
|
||||
let too_much_total =
|
||||
pagination_plan(1_000.0, 70_000.0, printable_width, printable_height, 1.0).unwrap();
|
||||
let too_much_total_selection =
|
||||
selected_page_indices(too_much_total.page_count, &[]).unwrap();
|
||||
assert_eq!(
|
||||
pagination_plan(1_000.0, 70_000.0, printable_width, printable_height).unwrap_err(),
|
||||
validate_selected_raster_work(
|
||||
1_000.0,
|
||||
70_000.0,
|
||||
too_much_total,
|
||||
&too_much_total_selection,
|
||||
)
|
||||
.unwrap_err(),
|
||||
RasterPdfError::RasterWorkLimitExceeded,
|
||||
"many individually valid pages must still respect a total work budget"
|
||||
);
|
||||
|
||||
let too_many =
|
||||
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height, 1.0).unwrap();
|
||||
assert_eq!(
|
||||
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height).unwrap_err(),
|
||||
selected_page_indices(too_many.page_count, &[]).unwrap_err(),
|
||||
RasterPdfError::TooManyPages(MAX_PDF_PAGES),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_ranges_alone_determine_output_and_raster_budgets() {
|
||||
let (_, _, printable_width, printable_height, _, _) =
|
||||
RasterPdfOptions::default().page_geometry().unwrap();
|
||||
let long =
|
||||
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height, 1.0).unwrap();
|
||||
assert!(long.page_count > MAX_PDF_PAGES);
|
||||
let selected = selected_page_indices(
|
||||
long.page_count,
|
||||
&[RasterPdfPageRange {
|
||||
start: Some(1),
|
||||
end: Some(1),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected, vec![0]);
|
||||
validate_selected_raster_work(1_000.0, 400_000.0, long, &selected).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
selected_page_indices(
|
||||
long.page_count,
|
||||
&[RasterPdfPageRange {
|
||||
start: Some(1),
|
||||
end: Some(MAX_PDF_PAGES + 1),
|
||||
}],
|
||||
),
|
||||
Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES))
|
||||
);
|
||||
|
||||
let base = pagination_plan(800.0, 2_000.0, printable_width, printable_height, 1.0)
|
||||
.expect("base geometry");
|
||||
let impossible_height =
|
||||
base.css_page_height * (MAX_PDF_DOCUMENT_PAGES as f32 + 16.0);
|
||||
assert_eq!(
|
||||
pagination_plan(
|
||||
800.0,
|
||||
impossible_height,
|
||||
printable_width,
|
||||
printable_height,
|
||||
1.0,
|
||||
)
|
||||
.unwrap_err(),
|
||||
RasterPdfError::TooManyPages(MAX_PDF_DOCUMENT_PAGES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_changes_css_page_span_and_rejects_invalid_values() {
|
||||
let (_, _, printable_width, printable_height, _, _) =
|
||||
RasterPdfOptions::default().page_geometry().unwrap();
|
||||
let normal =
|
||||
pagination_plan(800.0, 2_000.0, printable_width, printable_height, 1.0).unwrap();
|
||||
let enlarged =
|
||||
pagination_plan(800.0, 2_000.0, printable_width, printable_height, 2.0).unwrap();
|
||||
assert_eq!(
|
||||
enlarged.points_per_css_pixel,
|
||||
normal.points_per_css_pixel * 2.0
|
||||
);
|
||||
assert_eq!(enlarged.css_page_height, normal.css_page_height / 2.0);
|
||||
assert!(enlarged.page_count >= normal.page_count);
|
||||
assert_eq!(
|
||||
pagination_plan(800.0, 2_000.0, printable_width, printable_height, 0.09,)
|
||||
.unwrap_err(),
|
||||
RasterPdfError::InvalidScale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ranges_clip_deduplicate_and_preserve_document_order() {
|
||||
assert_eq!(selected_page_indices(4, &[]).unwrap(), vec![0, 1, 2, 3]);
|
||||
assert_eq!(
|
||||
selected_page_indices(
|
||||
6,
|
||||
&[
|
||||
RasterPdfPageRange {
|
||||
start: Some(3),
|
||||
end: Some(5),
|
||||
},
|
||||
RasterPdfPageRange {
|
||||
start: Some(1),
|
||||
end: Some(3),
|
||||
},
|
||||
RasterPdfPageRange {
|
||||
start: Some(5),
|
||||
end: None,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap(),
|
||||
vec![0, 1, 2, 3, 4, 5]
|
||||
);
|
||||
assert_eq!(
|
||||
selected_page_indices(
|
||||
6,
|
||||
&[RasterPdfPageRange {
|
||||
start: None,
|
||||
end: Some(2),
|
||||
}],
|
||||
)
|
||||
.unwrap(),
|
||||
vec![0, 1]
|
||||
);
|
||||
assert_eq!(
|
||||
selected_page_indices(
|
||||
3,
|
||||
&[RasterPdfPageRange {
|
||||
start: Some(9),
|
||||
end: Some(12),
|
||||
}],
|
||||
),
|
||||
Err(RasterPdfError::EmptyPageRange)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_emits_xref_and_one_image_per_page() {
|
||||
let pdf = encode_pdf_pages(1, 612.0, 792.0, 36.0, 36.0, 720.0, |_| {
|
||||
|
||||
@@ -6,6 +6,10 @@ use crate::dispatch::CdpContext;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
const MAX_BASE64_PDF_BYTES: usize = 64 * 1024 * 1024;
|
||||
#[cfg(feature = "render")]
|
||||
const MAX_PAGE_RANGES_BYTES: usize = 16 * 1024;
|
||||
#[cfg(feature = "render")]
|
||||
const MAX_PAGE_RANGE_PARTS: usize = 512;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn base64_encoded_len(raw_len: usize) -> Option<usize> {
|
||||
@@ -20,7 +24,7 @@ enum PdfTransferMode {
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct ParsedPdfOptions {
|
||||
raster: obscura_browser::RasterPdfOptions,
|
||||
transfer_mode: PdfTransferMode,
|
||||
@@ -49,6 +53,92 @@ fn boolean(params: &Value, name: &str, default: bool) -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn parse_page_ranges(value: &str) -> Result<Vec<obscura_browser::RasterPdfPageRange>, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if value.len() > MAX_PAGE_RANGES_BYTES {
|
||||
return Err(format!(
|
||||
"Invalid parameters: pageRanges exceeds the {MAX_PAGE_RANGES_BYTES}-byte limit"
|
||||
));
|
||||
}
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
for (index, part) in value.split(',').enumerate() {
|
||||
if index >= MAX_PAGE_RANGE_PARTS {
|
||||
return Err(format!(
|
||||
"Invalid parameters: pageRanges exceeds the {MAX_PAGE_RANGE_PARTS}-range limit"
|
||||
));
|
||||
}
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
return Err("Invalid parameters: pageRanges contains an empty range".to_string());
|
||||
}
|
||||
let page = |text: &str| -> Result<Option<usize>, String> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
text.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|page| *page > 0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
format!("Invalid parameters: pageRanges has invalid page {text:?}")
|
||||
})
|
||||
};
|
||||
let (start, end) = match part.split_once('-') {
|
||||
Some((start, end)) if !end.contains('-') => (page(start)?, page(end)?),
|
||||
Some(_) => {
|
||||
return Err(format!(
|
||||
"Invalid parameters: pageRanges has invalid range {part:?}"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
let page = page(part)?.ok_or_else(|| {
|
||||
"Invalid parameters: pageRanges contains an empty page".to_string()
|
||||
})?;
|
||||
(Some(page), Some(page))
|
||||
}
|
||||
};
|
||||
if start.is_none() && end.is_none() {
|
||||
return Err("Invalid parameters: pageRanges range '-' is empty".to_string());
|
||||
}
|
||||
if matches!((start, end), (Some(start), Some(end)) if start > end) {
|
||||
return Err(format!(
|
||||
"Invalid parameters: pageRanges range {part:?} is descending"
|
||||
));
|
||||
}
|
||||
ranges.push(obscura_browser::RasterPdfPageRange { start, end });
|
||||
}
|
||||
|
||||
// Normalize before crossing the CDP/browser boundary. This bounds later
|
||||
// selection work and makes repeated or overlapping user ranges occupy one
|
||||
// entry while preserving Chromium's document-order, print-once semantics.
|
||||
ranges.sort_by_key(|range| (range.start.unwrap_or(1), range.end.unwrap_or(usize::MAX)));
|
||||
let mut normalized: Vec<obscura_browser::RasterPdfPageRange> = Vec::new();
|
||||
for range in ranges {
|
||||
let start = range.start.unwrap_or(1);
|
||||
if let Some(previous) = normalized.last_mut() {
|
||||
let previous_end = previous.end.unwrap_or(usize::MAX);
|
||||
if start <= previous_end.saturating_add(1) {
|
||||
previous.end = match (previous.end, range.end) {
|
||||
(None, _) | (_, None) => None,
|
||||
(Some(previous), Some(next)) => Some(previous.max(next)),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
normalized.push(obscura_browser::RasterPdfPageRange {
|
||||
start: Some(start),
|
||||
end: range.end,
|
||||
});
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
|
||||
if !params.is_object() {
|
||||
@@ -72,20 +162,18 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
|
||||
// unusable. Accept and truthfully report `taggedPdf:false` in the response.
|
||||
let requested_tagged_pdf = boolean(params, "generateTaggedPDF", false)?;
|
||||
let scale = number(params, "scale", 1.0)?;
|
||||
if (scale - 1.0).abs() > f32::EPSILON {
|
||||
return Err(
|
||||
"Page.printToPDF scale is not supported by the raster paginator; only scale=1 is accepted"
|
||||
.to_string(),
|
||||
);
|
||||
if !(0.1..=2.0).contains(&scale) {
|
||||
return Err("Invalid parameters: scale must be between 0.1 and 2".to_string());
|
||||
}
|
||||
if let Some(value) = params.get("pageRanges") {
|
||||
let ranges = value
|
||||
.as_str()
|
||||
.ok_or("Invalid parameters: pageRanges must be a string")?;
|
||||
if !ranges.trim().is_empty() {
|
||||
return Err("Page.printToPDF pageRanges are not yet supported".to_string());
|
||||
let page_ranges = match params.get("pageRanges") {
|
||||
Some(value) => {
|
||||
let ranges = value
|
||||
.as_str()
|
||||
.ok_or("Invalid parameters: pageRanges must be a string")?;
|
||||
parse_page_ranges(ranges)?
|
||||
}
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
for name in ["headerTemplate", "footerTemplate"] {
|
||||
if let Some(value) = params.get(name) {
|
||||
let template = value
|
||||
@@ -111,6 +199,8 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
|
||||
raster: obscura_browser::RasterPdfOptions {
|
||||
landscape: boolean(params, "landscape", defaults.landscape)?,
|
||||
print_background: requested_print_background,
|
||||
scale,
|
||||
page_ranges,
|
||||
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
|
||||
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
|
||||
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
|
||||
@@ -148,6 +238,8 @@ pub async fn print_to_pdf(
|
||||
"obscuraCapabilities": {
|
||||
"cssPagedMedia": false,
|
||||
"honorsPrintBackground": true,
|
||||
"honorsScale": true,
|
||||
"pageRanges": true,
|
||||
"printColorAdjustExact": false,
|
||||
"taggedPdf": false,
|
||||
},
|
||||
@@ -212,17 +304,78 @@ mod tests {
|
||||
assert!(!standard.requested_print_background);
|
||||
assert!(!standard.raster.print_background);
|
||||
assert!(standard.requested_tagged_pdf);
|
||||
assert_eq!(standard.raster.scale, 1.0);
|
||||
assert!(standard.raster.page_ranges.is_empty());
|
||||
|
||||
for params in [
|
||||
json!({"displayHeaderFooter": true}),
|
||||
json!({"preferCSSPageSize": true}),
|
||||
json!({"scale": 0.5}),
|
||||
json!({"pageRanges": "2-3"}),
|
||||
json!({"headerTemplate": "<span>title</span>"}),
|
||||
json!({"generateDocumentOutline": true}),
|
||||
] {
|
||||
assert!(parse_options(¶ms).is_err(), "must reject {params}");
|
||||
}
|
||||
|
||||
let ranged = parse_options(&json!({
|
||||
"scale": 0.5,
|
||||
"pageRanges": "1, 3-5, 8-",
|
||||
}))
|
||||
.expect("CDP scale and page ranges");
|
||||
assert_eq!(ranged.raster.scale, 0.5);
|
||||
assert_eq!(
|
||||
ranged.raster.page_ranges,
|
||||
vec![
|
||||
obscura_browser::RasterPdfPageRange {
|
||||
start: Some(1),
|
||||
end: Some(1),
|
||||
},
|
||||
obscura_browser::RasterPdfPageRange {
|
||||
start: Some(3),
|
||||
end: Some(5),
|
||||
},
|
||||
obscura_browser::RasterPdfPageRange {
|
||||
start: Some(8),
|
||||
end: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_page_ranges("5-8, 1-3, 3-6, 10, 9, 12-").unwrap(),
|
||||
vec![
|
||||
obscura_browser::RasterPdfPageRange {
|
||||
start: Some(1),
|
||||
end: Some(10),
|
||||
},
|
||||
obscura_browser::RasterPdfPageRange {
|
||||
start: Some(12),
|
||||
end: None,
|
||||
},
|
||||
],
|
||||
"overlapping, adjacent, and repeated ranges must normalize before pagination"
|
||||
);
|
||||
for params in [
|
||||
json!({"scale": 0.09}),
|
||||
json!({"scale": 2.01}),
|
||||
json!({"pageRanges": "0"}),
|
||||
json!({"pageRanges": "3-2"}),
|
||||
json!({"pageRanges": "1,,2"}),
|
||||
json!({"pageRanges": "1-2-3"}),
|
||||
] {
|
||||
assert!(parse_options(¶ms).is_err(), "must reject {params}");
|
||||
}
|
||||
|
||||
let too_many_parts = std::iter::repeat_n("1", MAX_PAGE_RANGE_PARTS + 1)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
assert!(
|
||||
parse_page_ranges(&too_many_parts).is_err(),
|
||||
"range-token work must remain bounded before output allocation"
|
||||
);
|
||||
let too_many_bytes = "1".repeat(MAX_PAGE_RANGES_BYTES + 1);
|
||||
assert!(
|
||||
parse_page_ranges(&too_many_bytes).is_err(),
|
||||
"range input bytes must be bounded before numeric parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -317,6 +470,8 @@ mod tests {
|
||||
streamed["obscuraCapabilities"]["honorsPrintBackground"],
|
||||
true
|
||||
);
|
||||
assert_eq!(streamed["obscuraCapabilities"]["honorsScale"], true);
|
||||
assert_eq!(streamed["obscuraCapabilities"]["pageRanges"], true);
|
||||
assert_eq!(streamed["obscuraTaggedPDF"], false);
|
||||
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
|
||||
assert_eq!(
|
||||
@@ -353,5 +508,25 @@ mod tests {
|
||||
.is_err(),
|
||||
"closed PDF handles must release their buffer"
|
||||
);
|
||||
|
||||
let ranged = print_to_pdf(
|
||||
&json!({
|
||||
"paperWidth": 4.0, "paperHeight": 6.0,
|
||||
"marginTop": 0.5, "marginBottom": 0.5,
|
||||
"marginLeft": 0.5, "marginRight": 0.5,
|
||||
"scale": 1,
|
||||
"pageRanges": "2",
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("single selected page");
|
||||
let ranged_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(ranged["data"].as_str().unwrap())
|
||||
.unwrap();
|
||||
let ranged_text = String::from_utf8_lossy(&ranged_bytes);
|
||||
assert!(ranged_text.contains("/Count 1"));
|
||||
assert_eq!(ranged_text.matches("/Subtype /Image").count(), 1);
|
||||
}
|
||||
}
|
||||
Vendored
+394
-61
@@ -90,7 +90,14 @@ const _DOM_MUTATION_COMMANDS = new Set([
|
||||
]);
|
||||
const _dom = (cmd, a1, a2) => {
|
||||
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
|
||||
if (_DOM_MUTATION_COMMANDS.has(cmd)) _domMutationEpoch++;
|
||||
if (_DOM_MUTATION_COMMANDS.has(cmd)) {
|
||||
_domMutationEpoch++;
|
||||
// Resize observation is tied to rendering-invalidating DOM work. The
|
||||
// hook is installed later in bootstrap, before page script can run.
|
||||
if (typeof globalThis.__obscura_recompute_resizes === "function") {
|
||||
globalThis.__obscura_recompute_resizes();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -3240,6 +3247,7 @@ class Element extends Node {
|
||||
if (changed && !this._scrollSuppress) this._fireScroll();
|
||||
if (changed &&
|
||||
typeof globalThis.__obscura_recompute_intersections === "function") {
|
||||
// Scrolling changes target positions, not ResizeObserver box sizes.
|
||||
globalThis.__obscura_recompute_intersections();
|
||||
}
|
||||
}
|
||||
@@ -4634,6 +4642,13 @@ class HTMLImageElement extends Element {
|
||||
|
||||
_applyImageMetadata(metadata, request, dispatchEvent) {
|
||||
if (request !== this._imageRequest) return;
|
||||
const previousLifecycle = [
|
||||
this._imageComplete,
|
||||
this._imageDecoded,
|
||||
this._imageCurrentSrc,
|
||||
this._imageNaturalWidth,
|
||||
this._imageNaturalHeight,
|
||||
];
|
||||
const selected = metadata && metadata.currentSrc
|
||||
? String(metadata.currentSrc)
|
||||
: "";
|
||||
@@ -4666,6 +4681,18 @@ class HTMLImageElement extends Element {
|
||||
try { this.dispatchEvent(new Event("error")); } catch (_error) {}
|
||||
}
|
||||
}
|
||||
const lifecycleChanged =
|
||||
previousLifecycle[0] !== this._imageComplete ||
|
||||
previousLifecycle[1] !== this._imageDecoded ||
|
||||
previousLifecycle[2] !== this._imageCurrentSrc ||
|
||||
previousLifecycle[3] !== this._imageNaturalWidth ||
|
||||
previousLifecycle[4] !== this._imageNaturalHeight;
|
||||
if (lifecycleChanged) {
|
||||
// Intrinsic dimensions can become layout input at request completion
|
||||
// even though no DOM attribute changed. Stable cache-only getters must
|
||||
// not manufacture rendering updates on every read.
|
||||
_scheduleResizeRenderCheckpoint();
|
||||
}
|
||||
}
|
||||
|
||||
_resolveImageDecodes(request) {
|
||||
@@ -5959,43 +5986,280 @@ if (!('isConnected' in Node.prototype)) {
|
||||
});
|
||||
}
|
||||
|
||||
// Resize observation is part of the rendering update, not a timer. Keep the
|
||||
// last delivered size for each observed box and perform one coalesced geometry
|
||||
// checkpoint after DOM/viewport work. This follows the browser lifecycle and,
|
||||
// importantly, does not keep the event loop alive with speculative re-fires.
|
||||
globalThis.__resizeObservers = [];
|
||||
let _resizeRenderCheckpointPending = false;
|
||||
let _resizeRenderCheckpointRunning = false;
|
||||
let _resizeRenderCheckpointRerun = false;
|
||||
function _registerResizeObserver(observer) {
|
||||
if (!globalThis.__resizeObservers.includes(observer)) {
|
||||
globalThis.__resizeObservers.push(observer);
|
||||
}
|
||||
}
|
||||
function _unregisterResizeObserver(observer) {
|
||||
const index = globalThis.__resizeObservers.indexOf(observer);
|
||||
if (index >= 0) globalThis.__resizeObservers.splice(index, 1);
|
||||
}
|
||||
function _scheduleResizeRenderCheckpoint() {
|
||||
if (!globalThis.__resizeObservers.length) return;
|
||||
if (_resizeRenderCheckpointRunning) {
|
||||
_resizeRenderCheckpointRerun = true;
|
||||
return;
|
||||
}
|
||||
if (_resizeRenderCheckpointPending) return;
|
||||
_resizeRenderCheckpointPending = true;
|
||||
_scheduleAfter(0, () => {
|
||||
_resizeRenderCheckpointPending = false;
|
||||
_resizeRenderCheckpointRunning = true;
|
||||
let depth = 0;
|
||||
let skipped = false;
|
||||
// Depth strictly increases after each broadcast, so this is naturally
|
||||
// bounded by tree depth. Keep a hard ceiling for adversarial callbacks
|
||||
// that manufacture an ever-deeper subtree during one delivery cycle.
|
||||
for (let iteration = 0; iteration < 64; iteration++) {
|
||||
_resizeRenderCheckpointRerun = false;
|
||||
const measurements = new Map();
|
||||
let shallowest = Infinity;
|
||||
let active = false;
|
||||
skipped = false;
|
||||
const observers = [...globalThis.__resizeObservers];
|
||||
// Gather every observer before invoking any callback. A callback from an
|
||||
// earlier observer must not change the geometry gathered for a later one.
|
||||
for (const observer of observers) {
|
||||
const gathered = observer._gather(measurements, depth);
|
||||
active = active || gathered.active;
|
||||
skipped = skipped || gathered.skipped;
|
||||
shallowest = Math.min(shallowest, gathered.shallowest);
|
||||
}
|
||||
if (!active) break;
|
||||
for (const observer of observers) observer._broadcast();
|
||||
depth = shallowest;
|
||||
if (!_resizeRenderCheckpointRerun) break;
|
||||
if (iteration === 63) skipped = true;
|
||||
}
|
||||
_resizeRenderCheckpointRunning = false;
|
||||
_resizeRenderCheckpointRerun = false;
|
||||
if (skipped) {
|
||||
// Match the standardized loop-limit signal without queuing another
|
||||
// internal task that could keep a pathological page permanently busy.
|
||||
try {
|
||||
globalThis.dispatchEvent(new ErrorEvent("error", {
|
||||
message: "ResizeObserver loop completed with undelivered notifications."
|
||||
}));
|
||||
} catch (_error) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
globalThis.__obscura_recompute_resizes = _scheduleResizeRenderCheckpoint;
|
||||
function _roNumber(value) {
|
||||
const number = Number.parseFloat(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
function _roPhysicalSize(inlineSize, blockSize, vertical) {
|
||||
return vertical
|
||||
? new ResizeObserverSize(_roConstructionKey, blockSize, inlineSize)
|
||||
: new ResizeObserverSize(_roConstructionKey, inlineSize, blockSize);
|
||||
}
|
||||
function _roNodeDepth(target) {
|
||||
let depth = 1;
|
||||
let node = target;
|
||||
while (node && (node = node.parentNode || node.host || null)) depth++;
|
||||
return depth;
|
||||
}
|
||||
function _roMeasurement(target) {
|
||||
let geometry = null;
|
||||
const hasRenderer = typeof Deno.core.ops.op_layout_geometry === "function";
|
||||
if (hasRenderer && target?._nid != null) {
|
||||
try {
|
||||
const raw = Deno.core.ops.op_layout_geometry(String(target._nid | 0));
|
||||
geometry = raw ? JSON.parse(raw) : null;
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
// Preserve deterministic geometry in non-render builds. This path has no
|
||||
// native layout cache, but lifecycle behavior (initial delivery and
|
||||
// change-only rechecks) should remain useful to automation consumers.
|
||||
if (!hasRenderer && target?.getBoundingClientRect) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
geometry = {
|
||||
x: rect.x, y: rect.y,
|
||||
clientWidth: rect.width, clientHeight: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
// No renderer box (detached, display:none) has zero sizes. The initial zero
|
||||
// is still delivered because an observation starts without a reported size.
|
||||
if (!geometry) {
|
||||
const zero = _roPhysicalSize(0, 0, false);
|
||||
return {
|
||||
contentRect: _ioRect(0, 0, 0, 0),
|
||||
contentBoxSize: [zero],
|
||||
borderBoxSize: [_roPhysicalSize(0, 0, false)],
|
||||
devicePixelContentBoxSize: [_roPhysicalSize(0, 0, false)],
|
||||
selected: { "content-box": [0, 0], "border-box": [0, 0], "device-pixel-content-box": [0, 0] },
|
||||
};
|
||||
}
|
||||
|
||||
const style = getComputedStyle(target);
|
||||
const paddingTop = _roNumber(style.paddingTop);
|
||||
const paddingRight = _roNumber(style.paddingRight);
|
||||
const paddingBottom = _roNumber(style.paddingBottom);
|
||||
const paddingLeft = _roNumber(style.paddingLeft);
|
||||
const borderTop = _roNumber(style.borderTopWidth);
|
||||
const borderRight = _roNumber(style.borderRightWidth);
|
||||
const borderBottom = _roNumber(style.borderBottomWidth);
|
||||
const borderLeft = _roNumber(style.borderLeftWidth);
|
||||
const clientWidth = Math.max(0, Number(geometry.clientWidth) || 0);
|
||||
const clientHeight = Math.max(0, Number(geometry.clientHeight) || 0);
|
||||
const contentWidth = Math.max(0, clientWidth - paddingLeft - paddingRight);
|
||||
const contentHeight = Math.max(0, clientHeight - paddingTop - paddingBottom);
|
||||
const borderWidth = Math.max(0, clientWidth + borderLeft + borderRight);
|
||||
const borderHeight = Math.max(0, clientHeight + borderTop + borderBottom);
|
||||
const vertical = /^(?:vertical|sideways)/.test(style.writingMode || "");
|
||||
// Per Resize Observer, ordinary non-replaced inline elements have an empty
|
||||
// observed box even though getBoundingClientRect() encloses their glyphs.
|
||||
const replaced = /^(?:IMG|VIDEO|AUDIO|IFRAME|EMBED|OBJECT|INPUT|TEXTAREA|SELECT|CANVAS|SVG)$/.test(
|
||||
target.tagName || ""
|
||||
);
|
||||
const emptyInline = style.display === "inline" && !replaced;
|
||||
const observedContentWidth = emptyInline ? 0 : contentWidth;
|
||||
const observedContentHeight = emptyInline ? 0 : contentHeight;
|
||||
const observedBorderWidth = emptyInline ? 0 : borderWidth;
|
||||
const observedBorderHeight = emptyInline ? 0 : borderHeight;
|
||||
const contentSize = _roPhysicalSize(observedContentWidth, observedContentHeight, vertical);
|
||||
const borderSize = _roPhysicalSize(observedBorderWidth, observedBorderHeight, vertical);
|
||||
|
||||
// Device-pixel content sizes snap the content edges, rather than merely
|
||||
// rounding a CSS size multiplied by DPR. Preserve that distinction for
|
||||
// fractional positions and dimensions.
|
||||
const dpr = Math.max(0, Number(globalThis.devicePixelRatio) || 1);
|
||||
const contentLeft = (Number(geometry.x) + borderLeft + paddingLeft) * dpr;
|
||||
const contentTop = (Number(geometry.y) + borderTop + paddingTop) * dpr;
|
||||
const deviceWidth = emptyInline ? 0 : Math.max(0,
|
||||
Math.round(contentLeft + contentWidth * dpr) - Math.round(contentLeft));
|
||||
const deviceHeight = emptyInline ? 0 : Math.max(0,
|
||||
Math.round(contentTop + contentHeight * dpr) - Math.round(contentTop));
|
||||
const deviceSize = _roPhysicalSize(deviceWidth, deviceHeight, vertical);
|
||||
return {
|
||||
contentRect: emptyInline
|
||||
? _ioRect(0, 0, 0, 0)
|
||||
: _ioRect(paddingLeft, paddingTop, contentWidth, contentHeight),
|
||||
contentBoxSize: [contentSize],
|
||||
borderBoxSize: [borderSize],
|
||||
devicePixelContentBoxSize: [deviceSize],
|
||||
selected: {
|
||||
"content-box": [contentSize.inlineSize, contentSize.blockSize],
|
||||
"border-box": [borderSize.inlineSize, borderSize.blockSize],
|
||||
"device-pixel-content-box": [deviceSize.inlineSize, deviceSize.blockSize],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const _roConstructionKey = {};
|
||||
const _roSizeValues = new WeakMap();
|
||||
globalThis.ResizeObserverSize = class ResizeObserverSize {
|
||||
constructor(key, inlineSize, blockSize) {
|
||||
if (key !== _roConstructionKey) throw new TypeError("Illegal constructor");
|
||||
_roSizeValues.set(this, { inlineSize, blockSize });
|
||||
}
|
||||
get inlineSize() { return _roSizeValues.get(this)?.inlineSize; }
|
||||
get blockSize() { return _roSizeValues.get(this)?.blockSize; }
|
||||
};
|
||||
const _roEntryValues = new WeakMap();
|
||||
globalThis.ResizeObserverEntry = class ResizeObserverEntry {
|
||||
constructor(key, target, measurement) {
|
||||
if (key !== _roConstructionKey) throw new TypeError("Illegal constructor");
|
||||
_roEntryValues.set(this, { target, measurement });
|
||||
}
|
||||
get target() { return _roEntryValues.get(this)?.target; }
|
||||
get contentRect() { return _roEntryValues.get(this)?.measurement.contentRect; }
|
||||
get borderBoxSize() { return _roEntryValues.get(this)?.measurement.borderBoxSize; }
|
||||
get contentBoxSize() { return _roEntryValues.get(this)?.measurement.contentBoxSize; }
|
||||
get devicePixelContentBoxSize() {
|
||||
return _roEntryValues.get(this)?.measurement.devicePixelContentBoxSize;
|
||||
}
|
||||
};
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
constructor(callback) {
|
||||
if (typeof callback !== "function") {
|
||||
throw new TypeError("ResizeObserver callback must be a function");
|
||||
}
|
||||
this._callback = callback;
|
||||
this._targets = new Set();
|
||||
this._connected = true;
|
||||
this._fireCount = 0;
|
||||
this._targets = new Map();
|
||||
this._active = [];
|
||||
this._skipped = false;
|
||||
}
|
||||
_fireFor(targets) {
|
||||
if (!this._connected || !targets.length) return;
|
||||
const records = targets.map(target => {
|
||||
const r = target.getBoundingClientRect ? target.getBoundingClientRect() : { x: 0, y: 0, width: 100, height: 20 };
|
||||
return {
|
||||
target,
|
||||
contentRect: { x: r.x || 0, y: r.y || 0, width: r.width || 100, height: r.height || 20, top: r.top || 0, left: r.left || 0, bottom: r.bottom || 20, right: r.right || 100 },
|
||||
borderBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
|
||||
contentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
|
||||
devicePixelContentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
|
||||
};
|
||||
_gather(measurements, depth) {
|
||||
this._active = [];
|
||||
this._skipped = false;
|
||||
let shallowest = Infinity;
|
||||
for (const [target, observation] of this._targets) {
|
||||
let measurement = measurements.get(target);
|
||||
if (!measurement) {
|
||||
measurement = _roMeasurement(target);
|
||||
measurements.set(target, measurement);
|
||||
}
|
||||
const size = measurement.selected[observation.box];
|
||||
const last = observation.last;
|
||||
if (last && last[0] === size[0] && last[1] === size[1]) continue;
|
||||
const targetDepth = _roNodeDepth(target);
|
||||
// A callback may disconnect and begin observing a different target.
|
||||
// Browsers deliver that initial observation on the next rendering
|
||||
// opportunity. We fold that opportunity into this bounded cycle so it
|
||||
// does not require a persistent frame timer; already-reported targets
|
||||
// still obey the loop-depth guard.
|
||||
if (targetDepth <= depth && last) {
|
||||
this._skipped = true;
|
||||
continue;
|
||||
}
|
||||
shallowest = Math.min(shallowest, targetDepth);
|
||||
this._active.push({ target, observation, measurement, size });
|
||||
}
|
||||
return {
|
||||
active: this._active.length > 0,
|
||||
skipped: this._skipped,
|
||||
shallowest,
|
||||
};
|
||||
}
|
||||
_broadcast() {
|
||||
if (!this._active.length) return;
|
||||
const entries = this._active.map(({ target, observation, measurement, size }) => {
|
||||
// Update before invoking callbacks. Callback-driven mutations are
|
||||
// compared against this delivery in the same bounded delivery cycle.
|
||||
observation.last = size.slice();
|
||||
return new ResizeObserverEntry(_roConstructionKey, target, measurement);
|
||||
});
|
||||
try { this._callback(records, this); } catch (e) { /* RO callbacks must not propagate */ }
|
||||
this._active = [];
|
||||
try { this._callback(entries, this); } catch (_error) {}
|
||||
}
|
||||
observe(el) {
|
||||
if (!el || !this._connected) return;
|
||||
if (this._targets.has(el)) return;
|
||||
this._targets.add(el);
|
||||
Promise.resolve().then(() => this._fireFor([el]));
|
||||
[200, 800].forEach(delay => {
|
||||
setTimeout(() => {
|
||||
if (this._connected && this._targets.has(el) && this._fireCount < 16) {
|
||||
this._fireCount++;
|
||||
this._fireFor([el]);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
observe(target, options = {}) {
|
||||
if (!(target instanceof Element)) {
|
||||
throw new TypeError("ResizeObserver.observe requires an Element");
|
||||
}
|
||||
const box = options && options.box != null ? String(options.box) : "content-box";
|
||||
if (box !== "content-box" && box !== "border-box" &&
|
||||
box !== "device-pixel-content-box") {
|
||||
throw new TypeError(`Invalid ResizeObserver box option: ${box}`);
|
||||
}
|
||||
const current = this._targets.get(target);
|
||||
if (current && current.box === box) return;
|
||||
this._targets.set(target, { box, last: null });
|
||||
_registerResizeObserver(this);
|
||||
_scheduleResizeRenderCheckpoint();
|
||||
}
|
||||
unobserve(target) {
|
||||
this._targets.delete(target);
|
||||
if (!this._targets.size) _unregisterResizeObserver(this);
|
||||
}
|
||||
disconnect() {
|
||||
this._targets.clear();
|
||||
this._active = [];
|
||||
this._skipped = false;
|
||||
_unregisterResizeObserver(this);
|
||||
}
|
||||
unobserve(el) { this._targets.delete(el); }
|
||||
disconnect() { this._connected = false; this._targets.clear(); }
|
||||
};
|
||||
|
||||
if (typeof TextEncoder === 'undefined') {
|
||||
@@ -6786,12 +7050,19 @@ function _ioMargins(value) {
|
||||
if (parsed.length === 3) return [parsed[0], parsed[1], parsed[2], parsed[1]];
|
||||
return parsed;
|
||||
}
|
||||
function _ioUnsupportedRoot() {
|
||||
const error = new Error(
|
||||
"Obscura IntersectionObserver element roots require element scroll-container layout"
|
||||
);
|
||||
error.name = "NotSupportedError";
|
||||
return error;
|
||||
function _ioClipsOverflow(value) {
|
||||
return /^(?:auto|clip|hidden|overlay|scroll)$/.test(String(value || ""));
|
||||
}
|
||||
function _ioElementPaddingBox(element, style) {
|
||||
const geometry = element._renderBoxGeometry();
|
||||
const rect = geometry
|
||||
? element._rectFromRenderGeometry(geometry)
|
||||
: element.getBoundingClientRect();
|
||||
const borderLeft = _roNumber(style.borderLeftWidth);
|
||||
const borderTop = _roNumber(style.borderTopWidth);
|
||||
const width = geometry ? geometry.clientWidth : element.clientWidth;
|
||||
const height = geometry ? geometry.clientHeight : element.clientHeight;
|
||||
return _ioRect(rect.left + borderLeft, rect.top + borderTop, width, height);
|
||||
}
|
||||
globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
constructor(callback, options) {
|
||||
@@ -6800,7 +7071,11 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
}
|
||||
this._callback = callback;
|
||||
this._options = options || {};
|
||||
if (this._options.root != null) throw _ioUnsupportedRoot();
|
||||
this._root = this._options.root == null ? null : this._options.root;
|
||||
if (this._root !== null && !(this._root instanceof Element) &&
|
||||
this._root?.nodeType !== 9) {
|
||||
throw new TypeError("IntersectionObserver root must be an Element or Document");
|
||||
}
|
||||
this._margins = _ioMargins(this._options.rootMargin || "0px");
|
||||
if (!this._margins) throw new SyntaxError("Invalid IntersectionObserver rootMargin");
|
||||
const raw = this._options.threshold == null
|
||||
@@ -6819,8 +7094,29 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
globalThis.__intersectionObservers.push(this);
|
||||
}
|
||||
_rootBounds() {
|
||||
const width = globalThis.innerWidth || 1280;
|
||||
const height = globalThis.innerHeight || 720;
|
||||
let x = 0, y = 0;
|
||||
let width = globalThis.innerWidth || 1280;
|
||||
let height = globalThis.innerHeight || 720;
|
||||
if (this._root instanceof Element) {
|
||||
const style = getComputedStyle(this._root);
|
||||
const clips = _ioClipsOverflow(style.overflowX) ||
|
||||
_ioClipsOverflow(style.overflowY);
|
||||
if (clips) {
|
||||
const paddingBox = _ioElementPaddingBox(this._root, style);
|
||||
x = paddingBox.left;
|
||||
y = paddingBox.top;
|
||||
// The intersection root for a content-clipping element is its padding
|
||||
// box (the CSSOM client box), independent of its current scroll offset.
|
||||
width = paddingBox.width;
|
||||
height = paddingBox.height;
|
||||
} else {
|
||||
const rect = this._root.getBoundingClientRect();
|
||||
x = rect.left;
|
||||
y = rect.top;
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
}
|
||||
}
|
||||
const resolve = (margin, basis) =>
|
||||
margin.unit === "%" ? margin.value * basis / 100 : margin.value;
|
||||
// IntersectionObserver resolves every rootMargin percentage against the
|
||||
@@ -6829,27 +7125,55 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
const right = resolve(this._margins[1], width);
|
||||
const bottom = resolve(this._margins[2], width);
|
||||
const left = resolve(this._margins[3], width);
|
||||
return _ioRect(-left, -top, width + left + right, height + top + bottom);
|
||||
return _ioRect(x - left, y - top, width + left + right, height + top + bottom);
|
||||
}
|
||||
_entry(target) {
|
||||
_entry(target, root) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const root = this._rootBounds();
|
||||
const left = Math.max(rect.left, root.left);
|
||||
const top = Math.max(rect.top, root.top);
|
||||
const right = Math.min(rect.right, root.right);
|
||||
const bottom = Math.min(rect.bottom, root.bottom);
|
||||
const edgesTouch = right >= left && bottom >= top;
|
||||
let inRootTree = !(this._root instanceof Element) || this._root.contains(target);
|
||||
let left = Math.max(rect.left, root.left);
|
||||
let top = Math.max(rect.top, root.top);
|
||||
let right = Math.min(rect.right, root.right);
|
||||
let bottom = Math.min(rect.bottom, root.bottom);
|
||||
|
||||
// Mapping a target to its intersection root clips it at every intervening
|
||||
// overflow container. Intersecting only with the final root incorrectly
|
||||
// exposes offscreen children of nested carousels, virtual lists, and lazy
|
||||
// loading viewports. Use each ancestor's padding box, independently by
|
||||
// axis, matching Chromium's rectangular overflow clip chain.
|
||||
let ancestor = target.parentNode || target.host || null;
|
||||
while (inRootTree && ancestor && ancestor !== this._root && ancestor.nodeType !== 9) {
|
||||
if (ancestor instanceof Element) {
|
||||
const style = getComputedStyle(ancestor);
|
||||
const clipX = _ioClipsOverflow(style.overflowX);
|
||||
const clipY = _ioClipsOverflow(style.overflowY);
|
||||
if (clipX || clipY) {
|
||||
const clip = _ioElementPaddingBox(ancestor, style);
|
||||
if (clipX) {
|
||||
left = Math.max(left, clip.left);
|
||||
right = Math.min(right, clip.right);
|
||||
}
|
||||
if (clipY) {
|
||||
top = Math.max(top, clip.top);
|
||||
bottom = Math.min(bottom, clip.bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
ancestor = ancestor.parentNode || ancestor.host || null;
|
||||
}
|
||||
if (this._root instanceof Element && ancestor !== this._root) inRootTree = false;
|
||||
|
||||
const edgesTouch = inRootTree && right >= left && bottom >= top;
|
||||
const width = Math.max(0, right - left);
|
||||
const height = Math.max(0, bottom - top);
|
||||
const targetArea = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
const area = width * height;
|
||||
const isIntersecting = edgesTouch;
|
||||
const area = isIntersecting ? width * height : 0;
|
||||
return {
|
||||
target,
|
||||
isIntersecting,
|
||||
intersectionRatio: targetArea > 0 ? area / targetArea : (isIntersecting ? 1 : 0),
|
||||
boundingClientRect: _ioRect(rect.x, rect.y, rect.width, rect.height),
|
||||
intersectionRect: _ioRect(left, top, width, height),
|
||||
intersectionRect: isIntersecting ? _ioRect(left, top, width, height) : _ioRect(0, 0, 0, 0),
|
||||
rootBounds: root,
|
||||
time: performance.now(),
|
||||
};
|
||||
@@ -6859,8 +7183,8 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
while (index < this._thresholds.length && this._thresholds[index] <= ratio) index++;
|
||||
return index;
|
||||
}
|
||||
_queueChanged(target, forceInitial) {
|
||||
const entry = this._entry(target);
|
||||
_queueChanged(target, forceInitial, root) {
|
||||
const entry = this._entry(target, root);
|
||||
const previous = this._previous.get(target);
|
||||
const changed = forceInitial || !previous ||
|
||||
previous.isIntersecting !== entry.isIntersecting ||
|
||||
@@ -6874,8 +7198,9 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
}
|
||||
_check(targets, forceInitial) {
|
||||
if (!this._connected) return;
|
||||
const root = this._rootBounds();
|
||||
for (const target of targets) {
|
||||
if (this._targets.has(target)) this._queueChanged(target, !!forceInitial);
|
||||
if (this._targets.has(target)) this._queueChanged(target, !!forceInitial, root);
|
||||
}
|
||||
if (!this._records.length || this._deliveryPending) return;
|
||||
this._deliveryPending = true;
|
||||
@@ -6915,23 +7240,30 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
if (index >= 0) globalThis.__intersectionObservers.splice(index, 1);
|
||||
}
|
||||
takeRecords() { return this._records.splice(0); }
|
||||
get root() { return null; }
|
||||
get root() { return this._root; }
|
||||
get rootMargin() {
|
||||
return this._margins.map((margin) => `${margin.value}${margin.unit}`).join(" ");
|
||||
}
|
||||
get thresholds() { return this._thresholds.slice(); }
|
||||
};
|
||||
(function() {
|
||||
const recompute = () => {
|
||||
const renderingUpdate = () => {
|
||||
_scheduleIntersectionRenderCheckpoint();
|
||||
_scheduleResizeRenderCheckpoint();
|
||||
};
|
||||
globalThis.__obscura_recompute_intersections = recompute;
|
||||
globalThis.addEventListener("resize", recompute);
|
||||
// Scrolling calls the IO-only hook. Actual viewport resizing remains a full
|
||||
// rendering update and schedules both observer families.
|
||||
globalThis.__obscura_recompute_intersections = _scheduleIntersectionRenderCheckpoint;
|
||||
globalThis.addEventListener("resize", renderingUpdate);
|
||||
const wireUp = () => {
|
||||
if (!globalThis.document?.body) return;
|
||||
const observer = new MutationObserver(recompute);
|
||||
if (!globalThis.document) return;
|
||||
// DOM writes synchronously mark ResizeObserver dirty through `_dom`; this
|
||||
// MutationObserver is only needed for intersection geometry. Scheduling RO
|
||||
// again here would escape its depth-bounded delivery cycle and allow a
|
||||
// self-resizing callback to create an infinite chain of zero-delay tasks.
|
||||
const observer = new MutationObserver(_scheduleIntersectionRenderCheckpoint);
|
||||
try {
|
||||
observer.observe(globalThis.document.body, {
|
||||
observer.observe(globalThis.document, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
@@ -6939,7 +7271,7 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
if (globalThis.document?.body) wireUp();
|
||||
if (globalThis.document) wireUp();
|
||||
else Promise.resolve().then(wireUp);
|
||||
})();
|
||||
globalThis.IntersectionObserverEntry = class IntersectionObserverEntry {};
|
||||
@@ -11491,6 +11823,7 @@ if (typeof FontFace === 'undefined') {
|
||||
unicodeRange: face.unicodeRange
|
||||
});
|
||||
Deno.core.ops.op_set_dynamic_fonts(JSON.stringify(registrations.filter(face => !face.skip)));
|
||||
_scheduleResizeRenderCheckpoint();
|
||||
}
|
||||
_faceChanged(face) {
|
||||
this._syncNative();
|
||||
|
||||
@@ -3271,6 +3271,20 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
|
||||
}
|
||||
let base_url = document_base_url(&gs);
|
||||
let viewport = gs.viewport;
|
||||
let previous_dimensions = (!cached_only)
|
||||
.then(|| {
|
||||
gs.dom.as_ref().and_then(|dom| {
|
||||
gs.render_resources
|
||||
.cached_image_element_metadata(
|
||||
dom,
|
||||
node_id,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
)
|
||||
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let ObscuraState {
|
||||
dom,
|
||||
render_resources,
|
||||
@@ -3317,6 +3331,15 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
// The page transport invalidates prepared layout when it seeds successful
|
||||
// bytes. Cover the synchronous compatibility-loader path too, but only
|
||||
// when this call actually discovers a new decodable intrinsic size. A
|
||||
// cache hit or retained decode failure cannot change used geometry.
|
||||
if !cached_only && dimensions.is_some() && dimensions != previous_dimensions {
|
||||
gs.prepared_render = None;
|
||||
gs.pending_style_mutations.clear();
|
||||
gs.resolved_scroll = None;
|
||||
}
|
||||
match dimensions {
|
||||
Some((width, height)) => serde_json::json!({
|
||||
"state": "loaded",
|
||||
|
||||
@@ -412,6 +412,9 @@ impl ObscuraJsRuntime {
|
||||
}}\
|
||||
if(typeof globalThis.__obscura_recompute_intersections==='function'){{\
|
||||
globalThis.__obscura_recompute_intersections();\
|
||||
}}\
|
||||
if(typeof globalThis.__obscura_recompute_resizes==='function'){{\
|
||||
globalThis.__obscura_recompute_resizes();\
|
||||
}}",
|
||||
),
|
||||
);
|
||||
@@ -6444,6 +6447,310 @@ mod tests {
|
||||
assert_eq!(values[2], values[3]);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn resize_observer_reports_real_boxes_only_when_selected_size_changes() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="target" style="box-sizing:border-box;width:120px;height:80px;
|
||||
padding:5px 7px;border:2px solid black"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(300.0, 200.0);
|
||||
rt.run_page_init();
|
||||
rt.execute_script(
|
||||
"resize-observer-boxes",
|
||||
r#"
|
||||
globalThis.__resizeRecords = [];
|
||||
globalThis.__resizeObserver = new ResizeObserver(entries => {
|
||||
__resizeRecords.push(...entries.map(entry => ({
|
||||
interfaces: [
|
||||
entry instanceof ResizeObserverEntry,
|
||||
entry.contentBoxSize[0] instanceof ResizeObserverSize,
|
||||
entry.borderBoxSize[0] instanceof ResizeObserverSize,
|
||||
entry.devicePixelContentBoxSize[0] instanceof ResizeObserverSize,
|
||||
],
|
||||
contentRect: [
|
||||
entry.contentRect.x, entry.contentRect.y,
|
||||
entry.contentRect.width, entry.contentRect.height,
|
||||
],
|
||||
content: [
|
||||
entry.contentBoxSize[0].inlineSize,
|
||||
entry.contentBoxSize[0].blockSize,
|
||||
],
|
||||
border: [
|
||||
entry.borderBoxSize[0].inlineSize,
|
||||
entry.borderBoxSize[0].blockSize,
|
||||
],
|
||||
device: [
|
||||
entry.devicePixelContentBoxSize[0].inlineSize,
|
||||
entry.devicePixelContentBoxSize[0].blockSize,
|
||||
],
|
||||
})));
|
||||
});
|
||||
__resizeObserver.observe(document.getElementById("target"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__resizeRecords").unwrap(),
|
||||
serde_json::json!([{
|
||||
"interfaces": [true, true, true, true],
|
||||
"contentRect": [7, 5, 102, 66],
|
||||
"content": [102, 66],
|
||||
"border": [120, 80],
|
||||
"device": [102, 66],
|
||||
}])
|
||||
);
|
||||
|
||||
// A style mutation still causes a rendering checkpoint, but unchanged
|
||||
// observed geometry must not produce a speculative notification.
|
||||
rt.evaluate(r#"document.getElementById("target").style.color = "red""#)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__resizeRecords.length").unwrap().as_f64(),
|
||||
Some(1.0)
|
||||
);
|
||||
|
||||
rt.evaluate(r#"document.getElementById("target").style.width = "140px""#)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
"__resizeRecords.map(record => [record.content[0], record.border[0]])"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([[102, 120], [122, 140]])
|
||||
);
|
||||
assert_eq!(
|
||||
rt.evaluate("__obscura_nextPendingTimeoutDelay()")
|
||||
.unwrap()
|
||||
.as_f64(),
|
||||
Some(-1.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn resize_observer_selected_box_and_viewport_lifecycle_match_chromium() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="target" style="box-sizing:border-box;width:50vw;height:40px;
|
||||
padding:4px;border:2px solid"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
rt.execute_script(
|
||||
"resize-observer-selected-box",
|
||||
r#"
|
||||
globalThis.__contentWidths = [];
|
||||
globalThis.__borderWidths = [];
|
||||
const target = document.getElementById("target");
|
||||
globalThis.__contentObserver = new ResizeObserver(entries => {
|
||||
__contentWidths.push(entries[0].contentBoxSize[0].inlineSize);
|
||||
});
|
||||
globalThis.__borderObserver = new ResizeObserver(entries => {
|
||||
__borderWidths.push(entries[0].borderBoxSize[0].inlineSize);
|
||||
});
|
||||
__contentObserver.observe(target, { box: "content-box" });
|
||||
__borderObserver.observe(target, { box: "border-box" });
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
|
||||
serde_json::json!([[88], [100]])
|
||||
);
|
||||
|
||||
// A viewport update is a rendering update even without a DOM mutation.
|
||||
rt.set_viewport(300.0, 100.0);
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
|
||||
serde_json::json!([[88, 138], [100, 150]])
|
||||
);
|
||||
|
||||
// With border-box sizing a thicker border shrinks the content box but
|
||||
// leaves the selected border box unchanged.
|
||||
rt.evaluate(r#"document.getElementById("target").style.borderWidth = "4px""#)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
|
||||
serde_json::json!([[88, 138, 134], [100, 150]])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scrolling_does_not_remeasure_resize_observer_targets() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0;height:1000px">
|
||||
<div id="probe" style="width:40px;height:20px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
rt.execute_script(
|
||||
"observe-before-scroll",
|
||||
r#"
|
||||
globalThis.__scrollResizeRecords = 0;
|
||||
globalThis.__scrollResizeObserver = new ResizeObserver(entries => {
|
||||
__scrollResizeRecords += entries.length;
|
||||
});
|
||||
__scrollResizeObserver.observe(document.getElementById("probe"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__scrollResizeRecords").unwrap().as_f64(),
|
||||
Some(1.0)
|
||||
);
|
||||
|
||||
rt.execute_script(
|
||||
"count-scroll-geometry-reads",
|
||||
r#"
|
||||
globalThis.__scrollGeometryReads = 0;
|
||||
globalThis.__nativeLayoutGeometry = Deno.core.ops.op_layout_geometry;
|
||||
Deno.core.ops.op_layout_geometry = (...args) => {
|
||||
__scrollGeometryReads++;
|
||||
return __nativeLayoutGeometry(...args);
|
||||
};
|
||||
window.scrollTo(0, 50);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
let result = rt
|
||||
.evaluate("[scrollY, __scrollGeometryReads, __scrollResizeRecords]")
|
||||
.unwrap();
|
||||
rt.execute_script(
|
||||
"restore-layout-geometry-op",
|
||||
"Deno.core.ops.op_layout_geometry = __nativeLayoutGeometry;",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, serde_json::json!([50, 0, 1]));
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn resize_observer_disconnect_is_reusable_and_inline_boxes_are_empty() {
|
||||
let dom = parse_html(
|
||||
r#"<html><body><div id="first" style="width:40px;height:20px"></div>
|
||||
<span id="inline" style="padding:8px;border:2px solid">text</span>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
let result = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
const deliveries = [];
|
||||
const observer = new ResizeObserver(entries => {
|
||||
deliveries.push(entries.map(entry => [
|
||||
entry.target.id,
|
||||
entry.contentRect.width,
|
||||
entry.borderBoxSize[0].inlineSize,
|
||||
]));
|
||||
if (deliveries.length === 1) {
|
||||
observer.disconnect();
|
||||
observer.observe(document.getElementById("inline"));
|
||||
} else {
|
||||
observer.disconnect();
|
||||
resolve([deliveries, __resizeObservers.length]);
|
||||
}
|
||||
});
|
||||
observer.observe(document.getElementById("first"));
|
||||
setTimeout(() => resolve(["timed out"]), 100);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.value.unwrap(),
|
||||
serde_json::json!([[[["first", 40, 40]], [["inline", 0, 0]]], 0])
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
r#"[
|
||||
(() => { try { new ResizeObserver(null); } catch (e) { return e.name; } })(),
|
||||
(() => { try { new ResizeObserver(() => {}).observe(document); } catch (e) { return e.name; } })(),
|
||||
(() => { try { new ResizeObserver(() => {}).observe(document.body, {box:"margin-box"}); } catch (e) { return e.name; } })(),
|
||||
]"#,
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!(["TypeError", "TypeError", "TypeError"])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn resize_observer_self_resize_is_depth_bounded_without_timer_spin() {
|
||||
let dom = parse_html(
|
||||
r#"<html><body><div id="target" style="width:40px;height:20px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
rt.execute_script(
|
||||
"resize-observer-loop-limit",
|
||||
r#"
|
||||
globalThis.__resizeCallbacks = 0;
|
||||
globalThis.__resizeLoopErrors = 0;
|
||||
addEventListener("error", event => {
|
||||
if (event.message === "ResizeObserver loop completed with undelivered notifications.") {
|
||||
__resizeLoopErrors++;
|
||||
}
|
||||
});
|
||||
const target = document.getElementById("target");
|
||||
globalThis.__loopingResizeObserver = new ResizeObserver(() => {
|
||||
__resizeCallbacks++;
|
||||
target.style.width = (40 + __resizeCallbacks) + "px";
|
||||
});
|
||||
__loopingResizeObserver.observe(target);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
"[__resizeCallbacks, __resizeLoopErrors, __obscura_nextPendingTimeoutDelay()]"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([1, 1, -1])
|
||||
);
|
||||
|
||||
// A later external rendering change starts a fresh bounded cycle; the
|
||||
// suppressed same-depth observation did not poison future delivery.
|
||||
rt.evaluate(r#"document.getElementById("target").style.width = "60px""#)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(40).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("[__resizeCallbacks, __resizeLoopErrors]").unwrap(),
|
||||
serde_json::json!([2, 2])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_tracks_viewport_threshold_crossings() {
|
||||
@@ -6492,6 +6799,134 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_element_root_uses_live_padding_box_and_scroll() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="root" style="position:absolute;left:10px;top:20px;width:100px;
|
||||
height:80px;padding:10px;border:5px solid;overflow:auto">
|
||||
<div style="height:100px"></div>
|
||||
<div id="target" style="height:20px"></div>
|
||||
</div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(300.0, 200.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
const records = [];
|
||||
const root = document.getElementById("root");
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
records.push(...entries.map(entry => ({
|
||||
intersecting: entry.isIntersecting,
|
||||
ratio: entry.intersectionRatio,
|
||||
root: [
|
||||
entry.rootBounds.x, entry.rootBounds.y,
|
||||
entry.rootBounds.width, entry.rootBounds.height,
|
||||
],
|
||||
intersection: [
|
||||
entry.intersectionRect.x, entry.intersectionRect.y,
|
||||
entry.intersectionRect.width, entry.intersectionRect.height,
|
||||
],
|
||||
})));
|
||||
}, { root, threshold: [0, 1] });
|
||||
observer.observe(document.getElementById("target"));
|
||||
setTimeout(() => { root.scrollTop = 999; }, 5);
|
||||
setTimeout(() => resolve(records), 30);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.value.unwrap(),
|
||||
serde_json::json!([
|
||||
{
|
||||
"intersecting": false,
|
||||
"ratio": 0,
|
||||
"root": [15, 25, 120, 100],
|
||||
"intersection": [0, 0, 0, 0],
|
||||
},
|
||||
{
|
||||
"intersecting": true,
|
||||
"ratio": 1,
|
||||
"root": [15, 25, 120, 100],
|
||||
"intersection": [25, 95, 100, 20],
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_clips_through_intermediate_overflow_ancestors() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="root" style="position:absolute;left:10px;top:20px;
|
||||
width:300px;height:300px;overflow:visible">
|
||||
<div id="clip" style="width:100px;height:100px;overflow:hidden">
|
||||
<div style="height:150px"></div>
|
||||
<div id="target" style="height:20px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(400.0, 400.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
const records = [];
|
||||
const clip = document.getElementById("clip");
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
records.push(...entries.map(entry => [
|
||||
entry.isIntersecting,
|
||||
entry.intersectionRatio,
|
||||
[
|
||||
entry.intersectionRect.x,
|
||||
entry.intersectionRect.y,
|
||||
entry.intersectionRect.width,
|
||||
entry.intersectionRect.height,
|
||||
],
|
||||
]));
|
||||
}, {
|
||||
root: document.getElementById("root"),
|
||||
threshold: [0, 1],
|
||||
});
|
||||
observer.observe(document.getElementById("target"));
|
||||
setTimeout(() => { clip.scrollTop = 999; }, 5);
|
||||
setTimeout(() => resolve(records), 30);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Chromium reports the initial target as non-intersecting: although it
|
||||
// lies inside the explicit root, the intermediate overflow container
|
||||
// clips it. Programmatic scrolling then reveals the complete box.
|
||||
assert_eq!(
|
||||
result.value.unwrap(),
|
||||
serde_json::json!([
|
||||
[false, 0, [0, 0, 0, 0]],
|
||||
[true, 1, [10, 100, 100, 20]],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_initial_geometry_waits_for_one_render_checkpoint() {
|
||||
let mut rt = setup_runtime(
|
||||
@@ -6571,16 +7006,17 @@ mod tests {
|
||||
);
|
||||
marginObserver.observe(document.getElementById("margin-target"));
|
||||
zeroObserver.observe(document.getElementById("zero"));
|
||||
let unsupported = "";
|
||||
let elementRoot = false;
|
||||
try {
|
||||
new IntersectionObserver(() => {}, {
|
||||
const rooted = new IntersectionObserver(() => {}, {
|
||||
root: document.getElementById("root")
|
||||
});
|
||||
elementRoot = rooted.root === document.getElementById("root");
|
||||
} catch (error) {
|
||||
unsupported = error.name;
|
||||
elementRoot = error.name;
|
||||
}
|
||||
setTimeout(() => resolve([
|
||||
marginRecords, zeroRecords, unsupported,
|
||||
marginRecords, zeroRecords, elementRoot,
|
||||
marginObserver.rootMargin, marginObserver.thresholds,
|
||||
]), 200);
|
||||
})
|
||||
@@ -6595,7 +7031,7 @@ mod tests {
|
||||
serde_json::json!([
|
||||
[[true, 1, 120]],
|
||||
[[true, 1]],
|
||||
"NotSupportedError",
|
||||
true,
|
||||
"0px 0px 20px 0px",
|
||||
[0, 1],
|
||||
])
|
||||
@@ -7776,6 +8212,177 @@ mod tests {
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn parser_image_fallback_invalidates_only_new_intrinsic_geometry() {
|
||||
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let loader_calls = calls.clone();
|
||||
let png = two_by_three_png();
|
||||
let mut rt = parser_image_runtime(
|
||||
r#"<img id="late" src="late.png">"#,
|
||||
move |_url: &str| {
|
||||
loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some(png.clone())
|
||||
},
|
||||
);
|
||||
{
|
||||
let mut state = rt.state.borrow_mut();
|
||||
let previous = state.render_resources.set_sync_loading_enabled(false);
|
||||
assert!(ensure_prepared_render(&mut state).is_some());
|
||||
state
|
||||
.render_resources
|
||||
.set_sync_loading_enabled(previous);
|
||||
assert!(state.prepared_render.is_some());
|
||||
}
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
|
||||
rt.execute_script(
|
||||
"load-image-after-layout",
|
||||
r#"
|
||||
globalThis.__lateEvents = [];
|
||||
const late = document.getElementById("late");
|
||||
late.addEventListener("load", () => __lateEvents.push("load"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
"[late.complete, late.naturalWidth, late.naturalHeight, __lateEvents]"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([true, 2, 3, ["load"]])
|
||||
);
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert!(rt.state.borrow().prepared_render.is_none());
|
||||
|
||||
// Once the successful dimensions are retained, another loading-form
|
||||
// metadata probe is only a cache hit and must preserve fresh layout.
|
||||
{
|
||||
let mut state = rt.state.borrow_mut();
|
||||
assert!(ensure_prepared_render(&mut state).is_some());
|
||||
assert!(state.prepared_render.is_some());
|
||||
}
|
||||
rt.execute_script(
|
||||
"reload-retained-image",
|
||||
r#"late.src = "late.png";"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert!(rt.state.borrow().prepared_render.is_some());
|
||||
|
||||
let missing_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let missing_loader_calls = missing_calls.clone();
|
||||
let mut missing = parser_image_runtime(
|
||||
r#"<img id="missing" src="missing.png">"#,
|
||||
move |_url: &str| {
|
||||
missing_loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
None
|
||||
},
|
||||
);
|
||||
{
|
||||
let mut state = missing.state.borrow_mut();
|
||||
let previous = state.render_resources.set_sync_loading_enabled(false);
|
||||
assert!(ensure_prepared_render(&mut state).is_some());
|
||||
state
|
||||
.render_resources
|
||||
.set_sync_loading_enabled(previous);
|
||||
assert!(state.prepared_render.is_some());
|
||||
}
|
||||
missing
|
||||
.execute_script(
|
||||
"fail-image-after-layout",
|
||||
r#"
|
||||
globalThis.__missingEvents = [];
|
||||
const missing = document.getElementById("missing");
|
||||
missing.addEventListener("error", () => __missingEvents.push("error"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
missing.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
missing
|
||||
.evaluate(
|
||||
"[missing.complete, missing.naturalWidth, missing.naturalHeight, __missingEvents]"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([true, 0, 0, ["error"]])
|
||||
);
|
||||
assert_eq!(
|
||||
missing_calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert!(missing.state.borrow().prepared_render.is_some());
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_cached_image_getters_do_not_queue_resize_geometry_work() {
|
||||
let png = two_by_three_png();
|
||||
let mut rt = parser_image_runtime(
|
||||
r#"<img id="cached" src="cached.png">
|
||||
<div id="probe" style="width:40px;height:20px"></div>"#,
|
||||
move |_url: &str| Some(png.clone()),
|
||||
);
|
||||
rt.execute_script(
|
||||
"settle-cached-image",
|
||||
r#"
|
||||
const cached = document.getElementById("cached");
|
||||
void cached.complete;
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("[cached.complete, cached.naturalWidth, cached.naturalHeight]")
|
||||
.unwrap(),
|
||||
serde_json::json!([true, 2, 3])
|
||||
);
|
||||
|
||||
rt.execute_script(
|
||||
"observe-unrelated-geometry",
|
||||
r#"
|
||||
globalThis.__stableGetterResizeRecords = 0;
|
||||
globalThis.__stableGetterObserver = new ResizeObserver(entries => {
|
||||
__stableGetterResizeRecords += entries.length;
|
||||
});
|
||||
__stableGetterObserver.observe(document.getElementById("probe"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
"[__stableGetterResizeRecords, __obscura_nextPendingTimeoutDelay()]"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([1, -1])
|
||||
);
|
||||
|
||||
rt.execute_script(
|
||||
"read-stable-image-cache",
|
||||
r#"
|
||||
for (let i = 0; i < 50; i++) {
|
||||
void cached.complete;
|
||||
void cached.currentSrc;
|
||||
void cached.naturalWidth;
|
||||
void cached.naturalHeight;
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Cached lifecycle reads do not change intrinsic dimensions, so they
|
||||
// must not enqueue a rendering checkpoint (and its geometry walk).
|
||||
assert_eq!(
|
||||
rt.evaluate(
|
||||
"[__stableGetterResizeRecords, __obscura_nextPendingTimeoutDelay()]"
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([1, -1])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn parser_image_source_replacement_cancels_queued_completion() {
|
||||
|
||||
@@ -79,36 +79,59 @@ pub(crate) struct RelationalInvalidation {
|
||||
pub unrepresentable_outer_path: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct StructuralInvalidation {
|
||||
pub rule_order: usize,
|
||||
pub state: String,
|
||||
subject_key: Option<RelationalSelectorKey>,
|
||||
pub reaches: InvalidationReaches,
|
||||
pub inside_relational: bool,
|
||||
}
|
||||
|
||||
impl StructuralInvalidation {
|
||||
pub(crate) fn subject_may_match(&self, tree: &DomTree, node: NodeId) -> bool {
|
||||
relational_key_may_match(self.subject_key.as_ref(), tree, node)
|
||||
}
|
||||
}
|
||||
|
||||
fn relational_key_may_match(
|
||||
key: Option<&RelationalSelectorKey>,
|
||||
tree: &DomTree,
|
||||
node: NodeId,
|
||||
) -> bool {
|
||||
let Some(dom_node) = tree.get_node(node) else {
|
||||
return false;
|
||||
};
|
||||
if dom_node.as_element().is_none() {
|
||||
return false;
|
||||
}
|
||||
let quirks = tree.is_quirks();
|
||||
match key {
|
||||
None => true,
|
||||
Some(RelationalSelectorKey::Id(expected)) => dom_node
|
||||
.get_attribute("id")
|
||||
.is_some_and(|actual| {
|
||||
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
|
||||
}),
|
||||
Some(RelationalSelectorKey::Class(expected)) => dom_node
|
||||
.get_attribute("class")
|
||||
.is_some_and(|classes| {
|
||||
classes.split_whitespace().any(|actual| {
|
||||
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
|
||||
})
|
||||
}),
|
||||
Some(RelationalSelectorKey::Attribute(expected)) => {
|
||||
dom_node.get_attribute(expected).is_some()
|
||||
}
|
||||
Some(RelationalSelectorKey::LocalName(expected)) => dom_node
|
||||
.as_element()
|
||||
.is_some_and(|element| element.local.as_ref().eq_ignore_ascii_case(expected)),
|
||||
}
|
||||
}
|
||||
|
||||
impl RelationalInvalidation {
|
||||
pub(crate) fn anchor_may_match(&self, tree: &DomTree, node: NodeId) -> bool {
|
||||
let Some(dom_node) = tree.get_node(node) else {
|
||||
return false;
|
||||
};
|
||||
if dom_node.as_element().is_none() {
|
||||
return false;
|
||||
}
|
||||
let quirks = tree.is_quirks();
|
||||
match self.anchor_key.as_ref() {
|
||||
None => true,
|
||||
Some(RelationalSelectorKey::Id(expected)) => dom_node
|
||||
.get_attribute("id")
|
||||
.is_some_and(|actual| {
|
||||
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
|
||||
}),
|
||||
Some(RelationalSelectorKey::Class(expected)) => dom_node
|
||||
.get_attribute("class")
|
||||
.is_some_and(|classes| {
|
||||
classes.split_whitespace().any(|actual| {
|
||||
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
|
||||
})
|
||||
}),
|
||||
Some(RelationalSelectorKey::Attribute(expected)) => {
|
||||
dom_node.get_attribute(expected).is_some()
|
||||
}
|
||||
Some(RelationalSelectorKey::LocalName(expected)) => dom_node
|
||||
.as_element()
|
||||
.is_some_and(|element| element.local.as_ref().eq_ignore_ascii_case(expected)),
|
||||
}
|
||||
relational_key_may_match(self.anchor_key.as_ref(), tree, node)
|
||||
}
|
||||
|
||||
pub(crate) fn relative_path_may_match(&self, tree: &DomTree, node: NodeId) -> bool {
|
||||
@@ -161,6 +184,10 @@ pub struct InvalidationMap {
|
||||
relational_rule_orders: Vec<usize>,
|
||||
unkeyed_relational_rule_orders: Vec<usize>,
|
||||
relational_invalidations: Vec<RelationalInvalidation>,
|
||||
structural_invalidations: Vec<StructuralInvalidation>,
|
||||
adjacent_sibling_selectors: bool,
|
||||
general_sibling_selectors: bool,
|
||||
unkeyed_sibling_selectors: bool,
|
||||
}
|
||||
|
||||
impl InvalidationMap {
|
||||
@@ -213,6 +240,68 @@ impl InvalidationMap {
|
||||
&self.relational_invalidations
|
||||
}
|
||||
|
||||
pub(crate) fn structural_invalidations<'a>(
|
||||
&'a self,
|
||||
state: &str,
|
||||
) -> Vec<&'a StructuralInvalidation> {
|
||||
self.structural_invalidations
|
||||
.iter()
|
||||
.filter(move |invalidation| invalidation.state == state)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn has_adjacent_sibling_selectors(&self) -> bool {
|
||||
self.adjacent_sibling_selectors
|
||||
}
|
||||
|
||||
pub(crate) fn has_general_sibling_selectors(&self) -> bool {
|
||||
self.general_sibling_selectors
|
||||
}
|
||||
|
||||
pub(crate) fn node_may_start_sibling_selector(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
node: NodeId,
|
||||
) -> bool {
|
||||
if self.unkeyed_sibling_selectors
|
||||
|| (tree.is_quirks()
|
||||
&& (self.adjacent_sibling_selectors || self.general_sibling_selectors))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(node) = tree.get_node(node) else {
|
||||
return false;
|
||||
};
|
||||
let reaches_sibling = |dependencies: &[InvalidationDependency]| {
|
||||
dependencies.iter().any(|dependency| {
|
||||
dependency.reaches.contains(InvalidationReaches::SIBLINGS)
|
||||
})
|
||||
};
|
||||
if node
|
||||
.get_attribute("id")
|
||||
.is_some_and(|id| reaches_sibling(self.id_dependencies(id)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if node.get_attribute("class").is_some_and(|classes| {
|
||||
classes
|
||||
.split_whitespace()
|
||||
.any(|class| reaches_sibling(self.class_dependencies(class)))
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
if node.attrs().is_some_and(|attributes| {
|
||||
attributes.iter().any(|attribute| {
|
||||
reaches_sibling(self.attribute_dependencies(attribute.name.local.as_ref()))
|
||||
})
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
node.as_element().is_some_and(|element| {
|
||||
reaches_sibling(self.local_name_dependencies(element.local.as_ref()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dependency_count(&self) -> usize {
|
||||
self.ids
|
||||
.values()
|
||||
@@ -301,6 +390,12 @@ impl InvalidationMap {
|
||||
self.relational_invalidations.push(invalidation);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_structural_invalidation(&mut self, invalidation: StructuralInvalidation) {
|
||||
if !self.structural_invalidations.contains(&invalidation) {
|
||||
self.structural_invalidations.push(invalidation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_invalidation_reach(
|
||||
@@ -929,11 +1024,54 @@ fn selector_contains_adjacent_combinator(selector: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Sibling combinators which participate in the current selector path.
|
||||
///
|
||||
/// Parentheses are deliberately skipped here. `:is()`/`:where()`/`:not()`
|
||||
/// alternatives are fed back through dependency collection separately, while
|
||||
/// `:has()` owns an upward invalidation path and must not poison ordinary
|
||||
/// sibling invalidation outside its anchor.
|
||||
fn selector_sibling_combinators(selector: &str) -> (bool, bool) {
|
||||
let chars = selector.chars().collect::<Vec<_>>();
|
||||
let mut index = 0usize;
|
||||
let mut quote = None;
|
||||
let mut bracket_depth = 0usize;
|
||||
let mut paren_depth = 0usize;
|
||||
let mut adjacent = false;
|
||||
let mut general = false;
|
||||
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;
|
||||
}
|
||||
match ch {
|
||||
'\'' | '"' => quote = Some(ch),
|
||||
'[' => bracket_depth += 1,
|
||||
']' => bracket_depth = bracket_depth.saturating_sub(1),
|
||||
'(' if bracket_depth == 0 => paren_depth += 1,
|
||||
')' if bracket_depth == 0 => paren_depth = paren_depth.saturating_sub(1),
|
||||
'+' if bracket_depth == 0 && paren_depth == 0 => adjacent = true,
|
||||
'~' if bracket_depth == 0 && paren_depth == 0 => general = true,
|
||||
_ => {}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
(adjacent, general)
|
||||
}
|
||||
|
||||
fn note_compound_dependencies(
|
||||
map: &mut InvalidationMap,
|
||||
compound: &str,
|
||||
reaches: InvalidationReaches,
|
||||
rule_order: usize,
|
||||
inside_relational: bool,
|
||||
) {
|
||||
if let Some(local_name) = compound_local_name(compound) {
|
||||
map.push_local_name(local_name, rule_order, reaches);
|
||||
@@ -1000,6 +1138,24 @@ fn note_compound_dependencies(
|
||||
}
|
||||
if chars.get(next) != Some(&'(') {
|
||||
map.push_state(name.clone(), rule_order, reaches);
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"empty"
|
||||
| "first-child"
|
||||
| "last-child"
|
||||
| "only-child"
|
||||
| "first-of-type"
|
||||
| "last-of-type"
|
||||
| "only-of-type"
|
||||
) {
|
||||
map.push_structural_invalidation(StructuralInvalidation {
|
||||
rule_order,
|
||||
state: name.clone(),
|
||||
subject_key: relational_anchor_key(compound),
|
||||
reaches,
|
||||
inside_relational,
|
||||
});
|
||||
}
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"root"
|
||||
@@ -1037,6 +1193,7 @@ fn note_compound_dependencies(
|
||||
alternative.trim(),
|
||||
reaches,
|
||||
rule_order,
|
||||
!inside_relational,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1105,11 +1262,19 @@ fn note_compound_dependencies(
|
||||
alternative.trim(),
|
||||
InvalidationReaches::CONSERVATIVE,
|
||||
rule_order,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
"nth-child" | "nth-last-child" | "nth-of-type" | "nth-last-of-type" => {
|
||||
map.push_state(name, rule_order, reaches);
|
||||
map.push_state(name.clone(), rule_order, reaches);
|
||||
map.push_structural_invalidation(StructuralInvalidation {
|
||||
rule_order,
|
||||
state: name,
|
||||
subject_key: relational_anchor_key(compound),
|
||||
reaches,
|
||||
inside_relational,
|
||||
});
|
||||
// Structural index changes and `of <complex-selector>`
|
||||
// need sibling-wide bookkeeping not present in phase 1.
|
||||
map.mark_conservative(rule_order);
|
||||
@@ -1120,6 +1285,7 @@ fn note_compound_dependencies(
|
||||
alternative.trim(),
|
||||
InvalidationReaches::CONSERVATIVE,
|
||||
rule_order,
|
||||
!inside_relational,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1144,17 +1310,35 @@ fn note_selector_dependencies(
|
||||
selector: &str,
|
||||
outer_reaches: InvalidationReaches,
|
||||
rule_order: usize,
|
||||
record_tree_siblings: bool,
|
||||
) {
|
||||
if record_tree_siblings {
|
||||
let (adjacent, general) = selector_sibling_combinators(selector);
|
||||
map.adjacent_sibling_selectors |= adjacent;
|
||||
map.general_sibling_selectors |= general;
|
||||
}
|
||||
let (compounds, malformed) = invalidation_compounds(selector);
|
||||
if malformed {
|
||||
map.mark_conservative(rule_order);
|
||||
}
|
||||
for (compound, local_reaches) in compounds {
|
||||
if record_tree_siblings
|
||||
&& local_reaches.contains(InvalidationReaches::SIBLINGS)
|
||||
&& !relative_selector_subject_has_key(&compound)
|
||||
{
|
||||
map.unkeyed_sibling_selectors = true;
|
||||
}
|
||||
if local_reaches.contains(InvalidationReaches::CONSERVATIVE) {
|
||||
map.mark_conservative(rule_order);
|
||||
}
|
||||
let reaches = compose_invalidation_reach(map, local_reaches, outer_reaches, rule_order);
|
||||
note_compound_dependencies(map, &compound, reaches, rule_order);
|
||||
note_compound_dependencies(
|
||||
map,
|
||||
&compound,
|
||||
reaches,
|
||||
rule_order,
|
||||
!record_tree_siblings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,7 +1347,13 @@ fn note_selector_for_invalidation(
|
||||
selector: &str,
|
||||
rule_order: usize,
|
||||
) {
|
||||
note_selector_dependencies(map, selector, InvalidationReaches::SELF, rule_order);
|
||||
note_selector_dependencies(
|
||||
map,
|
||||
selector,
|
||||
InvalidationReaches::SELF,
|
||||
rule_order,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Record element attributes read from declaration values.
|
||||
@@ -6041,12 +6231,27 @@ fn extract_length(s: &str, prop: &str, viewport: (f32, f32), axis: LengthAxis) -
|
||||
let start = s.find(prop)? + prop.len();
|
||||
let rest = &s[start..];
|
||||
if let Some(inner) = rest.strip_prefix("calc(") {
|
||||
let end = inner.find(')')?;
|
||||
let end = matching_paren_end(inner)?;
|
||||
return eval_length_sum(&inner[..end], viewport, axis);
|
||||
}
|
||||
parse_length_prefix(rest, viewport, axis)
|
||||
}
|
||||
|
||||
/// Find the closing parenthesis paired with an opening parenthesis immediately
|
||||
/// before `input`. Nested CSS math groups must not terminate the outer calc.
|
||||
fn matching_paren_end(input: &str) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (index, character) in input.char_indices() {
|
||||
match character {
|
||||
'(' => depth += 1,
|
||||
')' if depth == 0 => return Some(index),
|
||||
')' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the length immediately before a range marker (`64rem<=width`).
|
||||
fn extract_length_before(
|
||||
s: &str,
|
||||
@@ -6056,12 +6261,23 @@ fn extract_length_before(
|
||||
) -> Option<f32> {
|
||||
let end = s.find(marker)?;
|
||||
let prefix = &s[..end];
|
||||
let start = prefix
|
||||
.rfind(|c: char| matches!(c, '(' | ')' | ':' | ','))
|
||||
.map_or(0, |idx| idx + 1);
|
||||
let value = &prefix[start..];
|
||||
let mut depth = 0usize;
|
||||
let mut start = 0usize;
|
||||
for (index, character) in prefix.char_indices().rev() {
|
||||
match character {
|
||||
')' => depth += 1,
|
||||
'(' if depth > 0 => depth -= 1,
|
||||
'(' | ':' | ',' if depth == 0 => {
|
||||
start = index + character.len_utf8();
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let value = prefix[start..].trim();
|
||||
if let Some(inner) = value.strip_prefix("calc(") {
|
||||
return eval_length_sum(inner, viewport, axis);
|
||||
let end = matching_paren_end(inner)?;
|
||||
return eval_length_sum(&inner[..end], viewport, axis);
|
||||
}
|
||||
parse_length_prefix(value, viewport, axis)
|
||||
}
|
||||
@@ -6102,32 +6318,23 @@ fn parse_length_prefix(input: &str, viewport: (f32, f32), axis: LengthAxis) -> O
|
||||
px.is_finite().then_some(px)
|
||||
}
|
||||
|
||||
/// Sum the common media-query `calc()` form (`64rem - 1px`) left to right.
|
||||
/// Resolve media-query `calc()` lengths with the same typed CSS math used by
|
||||
/// layout. Real responsive breakpoints combine grouping and scalar arithmetic
|
||||
/// (for example `calc(1rem * 2 + (15rem + 2rem) * 2 + 31rem)`), so a flat
|
||||
/// plus/minus scanner silently turns those queries into unconditional rules.
|
||||
fn eval_length_sum(expr: &str, viewport: (f32, f32), axis: LengthAxis) -> Option<f32> {
|
||||
let mut total = 0.0;
|
||||
let mut sign = 1.0;
|
||||
let mut term = String::new();
|
||||
let flush = |term: &mut String, sign: f32, total: &mut f32| -> Option<()> {
|
||||
let value = parse_length_prefix(term, viewport, axis)?;
|
||||
*total += sign * value;
|
||||
term.clear();
|
||||
Some(())
|
||||
let percent_base = match axis {
|
||||
LengthAxis::Width => viewport.0,
|
||||
LengthAxis::Height => viewport.1,
|
||||
};
|
||||
for (idx, c) in expr.char_indices() {
|
||||
match c {
|
||||
'+' if idx > 0 => {
|
||||
flush(&mut term, sign, &mut total)?;
|
||||
sign = 1.0;
|
||||
}
|
||||
'-' if idx > 0 => {
|
||||
flush(&mut term, sign, &mut total)?;
|
||||
sign = -1.0;
|
||||
}
|
||||
_ => term.push(c),
|
||||
}
|
||||
}
|
||||
flush(&mut term, sign, &mut total)?;
|
||||
Some(total)
|
||||
crate::style::resolve_contextual_length(
|
||||
&format!("calc({expr})"),
|
||||
16.0,
|
||||
16.0,
|
||||
viewport.0 / 100.0,
|
||||
viewport.1 / 100.0,
|
||||
percent_base,
|
||||
)
|
||||
}
|
||||
|
||||
fn split_media_query_list(query: &str) -> Vec<&str> {
|
||||
@@ -6214,6 +6421,25 @@ mod tests {
|
||||
assert!(!map.requires_conservative_invalidation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidation_map_distinguishes_tree_sibling_paths_from_has_paths() {
|
||||
let map = test_invalidation_map(
|
||||
r#"
|
||||
.left + .right { color:red }
|
||||
:is(.early ~ .late, .plain) { color:blue }
|
||||
.host:has(.inside + .peer) { color:green }
|
||||
[data-token="+"] { color:black }
|
||||
"#,
|
||||
);
|
||||
assert!(map.has_adjacent_sibling_selectors());
|
||||
assert!(map.has_general_sibling_selectors());
|
||||
|
||||
let relational_only =
|
||||
test_invalidation_map(".host:has(.inside + .peer){color:red}");
|
||||
assert!(!relational_only.has_adjacent_sibling_selectors());
|
||||
assert!(!relational_only.has_general_sibling_selectors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidation_map_indexes_attributes_states_functions_and_escaped_keys() {
|
||||
let map = test_invalidation_map(
|
||||
@@ -8674,6 +8900,35 @@ mod tests {
|
||||
"@media (width > calc(60em - 1px))",
|
||||
(900.0, 1000.0)
|
||||
));
|
||||
let two_sidebar_breakpoint =
|
||||
"@media (width < calc(1rem * 2 + (15rem + 2rem) * 2 + 31rem))";
|
||||
assert!(media_query_applies_for_viewport(
|
||||
two_sidebar_breakpoint,
|
||||
(1000.0, 900.0)
|
||||
));
|
||||
assert!(!media_query_applies_for_viewport(
|
||||
two_sidebar_breakpoint,
|
||||
(1440.0, 1000.0)
|
||||
));
|
||||
let left_width_calc =
|
||||
"@media (calc(1rem * 2 + (15rem + 2rem) * 2 + 31rem) <= width)";
|
||||
assert!(!media_query_applies_for_viewport(
|
||||
left_width_calc,
|
||||
(1000.0, 900.0)
|
||||
));
|
||||
assert!(media_query_applies_for_viewport(
|
||||
left_width_calc,
|
||||
(1440.0, 1000.0)
|
||||
));
|
||||
let left_height_calc = "@media (calc(40rem + (2rem * 2)) < height)";
|
||||
assert!(!media_query_applies_for_viewport(
|
||||
left_height_calc,
|
||||
(1280.0, 704.0)
|
||||
));
|
||||
assert!(media_query_applies_for_viewport(
|
||||
left_height_calc,
|
||||
(1280.0, 705.0)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+583
-184
File diff suppressed because it is too large.
Load diff
@@ -8,9 +8,15 @@ use std::io::{Read, Write};
|
||||
|
||||
use obscura::Browser;
|
||||
|
||||
/// Minimal HTTP/1.1 server returning HTML that wraps document.createElement to
|
||||
/// plant a non-configurable `src` on every <img>, then calls `new Image()`.
|
||||
/// Minimal HTTP/1.1 server returning the test page and a valid 1x1 PNG.
|
||||
fn spawn_server(html: &'static str) -> String {
|
||||
const PIXEL_PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
|
||||
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
|
||||
0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78,
|
||||
0xda, 0x63, 0xfc, 0xcf, 0xc0, 0x50, 0x0f, 0x00, 0x05, 0x83, 0x02, 0x7f, 0x94, 0xff,
|
||||
0x2f, 0x59, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
@@ -20,13 +26,19 @@ fn spawn_server(html: &'static str) -> String {
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = s.read(&mut buf);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
html.len(),
|
||||
html
|
||||
let read = s.read(&mut buf).unwrap_or(0);
|
||||
let is_pixel = buf[..read].starts_with(b"GET /pixel.png ");
|
||||
let (content_type, body) = if is_pixel {
|
||||
("image/png", PIXEL_PNG)
|
||||
} else {
|
||||
("text/html", html.as_bytes())
|
||||
};
|
||||
let headers = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = s.write_all(resp.as_bytes());
|
||||
let _ = s.write_all(headers.as_bytes());
|
||||
let _ = s.write_all(body);
|
||||
let _ = s.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
});
|
||||
@@ -100,3 +112,39 @@ async fn new_image_still_emulates_load_when_src_is_configurable() {
|
||||
"load emulation regressed for the normal (configurable src) path"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_image_bytes_emit_error_like_chromium() {
|
||||
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
|
||||
let base = spawn_server(
|
||||
r#"<!doctype html><html><body><div id="r">waiting</div>
|
||||
<script>
|
||||
var img = new Image();
|
||||
img.onerror = function () {
|
||||
document.getElementById('r').textContent =
|
||||
'error complete=' + img.complete +
|
||||
' natural=' + img.naturalWidth + 'x' + img.naturalHeight;
|
||||
};
|
||||
img.src = '/broken.png';
|
||||
</script>
|
||||
</body></html>"#,
|
||||
);
|
||||
|
||||
let browser = Browser::new().unwrap();
|
||||
let mut page = browser.new_page().await.unwrap();
|
||||
page.goto(&base).await.unwrap();
|
||||
for _ in 0..10 {
|
||||
page.settle(500).await;
|
||||
let text = page.evaluate("document.getElementById('r').textContent");
|
||||
if text.as_str().unwrap_or("").starts_with("error") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let text = page.evaluate("document.getElementById('r').textContent");
|
||||
assert_eq!(
|
||||
text.as_str().unwrap_or(""),
|
||||
"error complete=true natural=0x0",
|
||||
"invalid image bytes must fail instead of being treated as a decoded image"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user