Improve modern rendering and output parity
This commit is contained in:
1 parent
f428aa37fa
commit
c8a5477296
25 files changed
+12885
-5053
No files matched your search
Generated
+17
@@ -1870,6 +1870,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"futures",
|
||||
"image",
|
||||
"obscura-dom",
|
||||
"obscura-js",
|
||||
"obscura-net",
|
||||
@@ -1957,6 +1958,7 @@ dependencies = [
|
||||
"obscura-net",
|
||||
"obscura-render",
|
||||
"pbkdf2",
|
||||
"psl",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2358,6 +2360,21 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl"
|
||||
version = "2.1.223"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0dedad316e05de220cbf3fd8bfb69f3df8755dde2d7d479211827b595168a93"
|
||||
dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
|
||||
@@ -6,7 +6,7 @@ edition.workspace = true
|
||||
[features]
|
||||
default = []
|
||||
stealth = ["obscura-net/stealth", "obscura-js/stealth"]
|
||||
render = ["obscura-js/render"]
|
||||
render = ["obscura-js/render", "dep:image"]
|
||||
|
||||
[dependencies]
|
||||
obscura-dom = { workspace = true }
|
||||
@@ -20,3 +20,4 @@ thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"], optional = true }
|
||||
@@ -1,12 +1,18 @@
|
||||
pub mod page;
|
||||
pub mod context;
|
||||
pub mod lifecycle;
|
||||
pub mod page;
|
||||
#[cfg(feature = "render")]
|
||||
pub mod pdf;
|
||||
pub mod profiles;
|
||||
|
||||
pub use page::{NetworkEvent, Page, PageError};
|
||||
pub use context::BrowserContext;
|
||||
pub use lifecycle::{LifecycleState, WaitUntil};
|
||||
pub use obscura_js::HTML_TO_MARKDOWN_JS;
|
||||
#[cfg(feature = "render")]
|
||||
pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion};
|
||||
pub use page::{NetworkEvent, Page, PageError};
|
||||
#[cfg(feature = "render")]
|
||||
pub use pdf::{RasterPdfError, RasterPdfOptions};
|
||||
// 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};
|
||||
+846
-337
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,556 @@
|
||||
//! Bounded raster-backed PDF export over the retained document-space painter.
|
||||
//!
|
||||
//! This deliberately does not claim CSS paged-media support. It preserves the
|
||||
//! screen layout, fits the full document width into the printable area, and
|
||||
//! slices that immutable layout vertically across PDF pages.
|
||||
|
||||
use std::io;
|
||||
|
||||
use image::ImageEncoder as _;
|
||||
use obscura_js::CaptureRegion;
|
||||
|
||||
use crate::Page;
|
||||
|
||||
const POINTS_PER_INCH: f32 = 72.0;
|
||||
const MAX_PAPER_INCHES: f32 = 200.0;
|
||||
const MAX_PDF_PAGES: usize = 250;
|
||||
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)]
|
||||
pub struct RasterPdfOptions {
|
||||
pub landscape: bool,
|
||||
pub paper_width_in: f32,
|
||||
pub paper_height_in: f32,
|
||||
pub margin_top_in: f32,
|
||||
pub margin_bottom_in: f32,
|
||||
pub margin_left_in: f32,
|
||||
pub margin_right_in: f32,
|
||||
}
|
||||
|
||||
impl Default for RasterPdfOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
landscape: false,
|
||||
paper_width_in: 8.5,
|
||||
paper_height_in: 11.0,
|
||||
// CDP's defaults are one centimetre.
|
||||
margin_top_in: 0.3937,
|
||||
margin_bottom_in: 0.3937,
|
||||
margin_left_in: 0.3937,
|
||||
margin_right_in: 0.3937,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum RasterPdfError {
|
||||
#[error("PDF paper dimensions must be finite and between 0 and 200 inches")]
|
||||
InvalidPaperSize,
|
||||
#[error("PDF margins must be finite, non-negative, and leave a printable area")]
|
||||
InvalidMargins,
|
||||
#[error("the page has no retained renderable document")]
|
||||
NoRenderableDocument,
|
||||
#[error("PDF pagination would exceed the {0}-page safety limit")]
|
||||
TooManyPages(usize),
|
||||
#[error("PDF raster work would exceed the bounded page or document pixel budget")]
|
||||
RasterWorkLimitExceeded,
|
||||
#[error("document-space PDF capture failed: {0}")]
|
||||
CaptureFailed(String),
|
||||
#[error("PDF raster image decoding failed: {0}")]
|
||||
ImageDecode(String),
|
||||
#[error("PDF JPEG encoding failed: {0}")]
|
||||
ImageEncode(String),
|
||||
#[error("encoded PDF would exceed the 64 MiB safety limit")]
|
||||
OutputLimitExceeded,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RasterPage {
|
||||
rgb: image::RgbImage,
|
||||
draw_width_pt: f32,
|
||||
draw_height_pt: f32,
|
||||
#[cfg(test)]
|
||||
_lifetime_probe: Option<std::rc::Rc<()>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PaginationPlan {
|
||||
points_per_css_pixel: f32,
|
||||
css_page_height: f32,
|
||||
page_count: usize,
|
||||
}
|
||||
|
||||
impl RasterPdfOptions {
|
||||
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()
|
||||
.any(|value| !value.is_finite() || *value <= 0.0 || *value > MAX_PAPER_INCHES)
|
||||
{
|
||||
return Err(RasterPdfError::InvalidPaperSize);
|
||||
}
|
||||
let margins = [
|
||||
self.margin_top_in,
|
||||
self.margin_bottom_in,
|
||||
self.margin_left_in,
|
||||
self.margin_right_in,
|
||||
];
|
||||
if margins
|
||||
.iter()
|
||||
.any(|value| !value.is_finite() || *value < 0.0)
|
||||
{
|
||||
return Err(RasterPdfError::InvalidMargins);
|
||||
}
|
||||
let (paper_width_in, paper_height_in) = if self.landscape {
|
||||
(self.paper_height_in, self.paper_width_in)
|
||||
} else {
|
||||
(self.paper_width_in, self.paper_height_in)
|
||||
};
|
||||
let page_width = paper_width_in * POINTS_PER_INCH;
|
||||
let page_height = paper_height_in * POINTS_PER_INCH;
|
||||
let left = self.margin_left_in * POINTS_PER_INCH;
|
||||
let bottom = self.margin_bottom_in * POINTS_PER_INCH;
|
||||
let printable_width =
|
||||
page_width - (self.margin_left_in + self.margin_right_in) * POINTS_PER_INCH;
|
||||
let printable_height =
|
||||
page_height - (self.margin_top_in + self.margin_bottom_in) * POINTS_PER_INCH;
|
||||
if printable_width <= 0.0 || printable_height <= 0.0 {
|
||||
return Err(RasterPdfError::InvalidMargins);
|
||||
}
|
||||
Ok((
|
||||
page_width,
|
||||
page_height,
|
||||
printable_width,
|
||||
printable_height,
|
||||
left,
|
||||
bottom,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn pagination_plan(
|
||||
content_width: f32,
|
||||
content_height: f32,
|
||||
printable_width: f32,
|
||||
printable_height: f32,
|
||||
) -> Result<PaginationPlan, RasterPdfError> {
|
||||
let points_per_css_pixel = printable_width / content_width;
|
||||
let css_page_height = printable_height / points_per_css_pixel;
|
||||
if !points_per_css_pixel.is_finite()
|
||||
|| points_per_css_pixel <= 0.0
|
||||
|| !css_page_height.is_finite()
|
||||
|| css_page_height <= 0.0
|
||||
{
|
||||
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
let page_count = page_count_value as usize;
|
||||
let pixel_width = content_width.ceil();
|
||||
if !pixel_width.is_finite()
|
||||
|| pixel_width <= 0.0
|
||||
|| pixel_width > obscura_js::MAX_CAPTURE_DIMENSION as f32
|
||||
{
|
||||
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||
}
|
||||
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();
|
||||
if !slice_height.is_finite()
|
||||
|| slice_height <= 0.0
|
||||
|| slice_height > obscura_js::MAX_CAPTURE_DIMENSION as f32
|
||||
{
|
||||
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||
}
|
||||
let page_pixels = pixel_width
|
||||
.checked_mul(slice_height as u64)
|
||||
.ok_or(RasterPdfError::RasterWorkLimitExceeded)?;
|
||||
if page_pixels > MAX_PDF_PAGE_PIXELS {
|
||||
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||
}
|
||||
total_pixels = total_pixels
|
||||
.checked_add(page_pixels)
|
||||
.ok_or(RasterPdfError::RasterWorkLimitExceeded)?;
|
||||
if total_pixels > MAX_PDF_TOTAL_RASTER_PIXELS {
|
||||
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PaginationPlan {
|
||||
points_per_css_pixel,
|
||||
css_page_height,
|
||||
page_count,
|
||||
})
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// Export the current screen layout as a paginated raster PDF.
|
||||
///
|
||||
/// 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.
|
||||
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()?;
|
||||
let js = self
|
||||
.js
|
||||
.as_ref()
|
||||
.ok_or(RasterPdfError::NoRenderableDocument)?;
|
||||
let (content_width, content_height) = js
|
||||
.prepared_content_size()
|
||||
.ok_or(RasterPdfError::NoRenderableDocument)?;
|
||||
if !content_width.is_finite()
|
||||
|| !content_height.is_finite()
|
||||
|| content_width <= 0.0
|
||||
|| content_height <= 0.0
|
||||
{
|
||||
return Err(RasterPdfError::NoRenderableDocument);
|
||||
}
|
||||
|
||||
let plan = pagination_plan(
|
||||
content_width,
|
||||
content_height,
|
||||
printable_width,
|
||||
printable_height,
|
||||
)?;
|
||||
|
||||
encode_pdf_pages(
|
||||
plan.page_count,
|
||||
page_width,
|
||||
page_height,
|
||||
left,
|
||||
bottom,
|
||||
printable_height,
|
||||
|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
|
||||
.screenshot_prepared_region(CaptureRegion::new(
|
||||
0.0,
|
||||
y,
|
||||
content_width,
|
||||
slice_height,
|
||||
1.0,
|
||||
))
|
||||
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
|
||||
let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
|
||||
.map_err(|error| RasterPdfError::ImageDecode(error.to_string()))?;
|
||||
// The document capture is already a complete PNG allocation. Drop
|
||||
// it before converting the decoded pixels and, below, encoding the
|
||||
// JPEG directly into the final PDF buffer. At no point do we retain
|
||||
// PNGs or JPEGs for earlier pages.
|
||||
drop(png);
|
||||
let rgb = decoded.into_rgb8();
|
||||
Ok(RasterPage {
|
||||
rgb,
|
||||
draw_width_pt: printable_width,
|
||||
draw_height_pt: slice_height * plan.points_per_css_pixel,
|
||||
#[cfg(test)]
|
||||
_lifetime_probe: None,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_pdf_pages(
|
||||
page_count: usize,
|
||||
page_width: f32,
|
||||
page_height: f32,
|
||||
left: f32,
|
||||
bottom: f32,
|
||||
printable_height: f32,
|
||||
mut page_source: impl FnMut(usize) -> Result<RasterPage, RasterPdfError>,
|
||||
) -> Result<Vec<u8>, RasterPdfError> {
|
||||
let object_count = 2usize
|
||||
.checked_add(
|
||||
page_count
|
||||
.checked_mul(3)
|
||||
.ok_or(RasterPdfError::OutputLimitExceeded)?,
|
||||
)
|
||||
.ok_or(RasterPdfError::OutputLimitExceeded)?;
|
||||
let mut writer = PdfWriter::new(object_count, MAX_PDF_OUTPUT_BYTES)?;
|
||||
writer.write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>")?;
|
||||
|
||||
let kids = (0..page_count)
|
||||
.map(|index| format!("{} 0 R", 3 + index * 3))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let pages_dictionary = format!("<< /Type /Pages /Count {page_count} /Kids [{kids}] >>");
|
||||
writer.write_object(2, pages_dictionary.as_bytes())?;
|
||||
|
||||
for index in 0..page_count {
|
||||
let page = page_source(index)?;
|
||||
let page_id = 3 + index * 3;
|
||||
let content_id = page_id + 1;
|
||||
let image_id = page_id + 2;
|
||||
let page_dictionary = format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_width:.3} {page_height:.3}] /Resources << /XObject << /Im0 {image_id} 0 R >> >> /Contents {content_id} 0 R >>"
|
||||
);
|
||||
writer.write_object(page_id, page_dictionary.as_bytes())?;
|
||||
|
||||
let draw_y = bottom + printable_height - page.draw_height_pt;
|
||||
let commands = format!(
|
||||
"q\n{:.3} 0 0 {:.3} {:.3} {:.3} cm\n/Im0 Do\nQ\n",
|
||||
page.draw_width_pt, page.draw_height_pt, left, draw_y,
|
||||
);
|
||||
let content = format!(
|
||||
"<< /Length {} >>\nstream\n{}endstream",
|
||||
commands.len(),
|
||||
commands
|
||||
);
|
||||
writer.write_object(content_id, content.as_bytes())?;
|
||||
writer.write_rgb_image(image_id, &page.rgb)?;
|
||||
// `page`, including its decoded RGB raster, is dropped here before
|
||||
// the next page is captured. Only the bounded final PDF survives.
|
||||
}
|
||||
|
||||
writer.finish()
|
||||
}
|
||||
|
||||
struct PdfWriter {
|
||||
output: Vec<u8>,
|
||||
offsets: Vec<usize>,
|
||||
limit: usize,
|
||||
limit_exceeded: bool,
|
||||
}
|
||||
|
||||
impl PdfWriter {
|
||||
fn new(object_count: usize, limit: usize) -> Result<Self, RasterPdfError> {
|
||||
let mut writer = Self {
|
||||
output: Vec::new(),
|
||||
offsets: vec![0usize; object_count + 1],
|
||||
limit,
|
||||
limit_exceeded: false,
|
||||
};
|
||||
writer.append(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")?;
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
fn append(&mut self, bytes: &[u8]) -> Result<(), RasterPdfError> {
|
||||
let new_len = self
|
||||
.output
|
||||
.len()
|
||||
.checked_add(bytes.len())
|
||||
.ok_or(RasterPdfError::OutputLimitExceeded)?;
|
||||
if new_len > self.limit {
|
||||
self.limit_exceeded = true;
|
||||
return Err(RasterPdfError::OutputLimitExceeded);
|
||||
}
|
||||
self.output.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_object(&mut self, id: usize, body: &[u8]) -> Result<(), RasterPdfError> {
|
||||
self.offsets[id] = self.output.len();
|
||||
self.append(format!("{id} 0 obj\n").as_bytes())?;
|
||||
self.append(body)?;
|
||||
self.append(b"\nendobj\n")
|
||||
}
|
||||
|
||||
fn write_rgb_image(&mut self, id: usize, rgb: &image::RgbImage) -> Result<(), RasterPdfError> {
|
||||
self.offsets[id] = self.output.len();
|
||||
self.append(format!(
|
||||
"{id} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ",
|
||||
rgb.width(), rgb.height(),
|
||||
).as_bytes())?;
|
||||
// Encode into the final PDF rather than building a second JPEG Vec.
|
||||
// A fixed-width decimal token lets us patch /Length after encoding.
|
||||
const LENGTH_DIGITS: usize = 20;
|
||||
let length_offset = self.output.len();
|
||||
self.append(b"00000000000000000000 >>\nstream\n")?;
|
||||
let stream_offset = self.output.len();
|
||||
let encode_result = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut *self, 90)
|
||||
.write_image(
|
||||
rgb.as_raw(),
|
||||
rgb.width(),
|
||||
rgb.height(),
|
||||
image::ExtendedColorType::Rgb8,
|
||||
);
|
||||
if let Err(error) = encode_result {
|
||||
return if self.limit_exceeded {
|
||||
Err(RasterPdfError::OutputLimitExceeded)
|
||||
} else {
|
||||
Err(RasterPdfError::ImageEncode(error.to_string()))
|
||||
};
|
||||
}
|
||||
let stream_len = self.output.len() - stream_offset;
|
||||
let length = format!("{stream_len:0LENGTH_DIGITS$}");
|
||||
if length.len() != LENGTH_DIGITS {
|
||||
return Err(RasterPdfError::OutputLimitExceeded);
|
||||
}
|
||||
self.output[length_offset..length_offset + LENGTH_DIGITS]
|
||||
.copy_from_slice(length.as_bytes());
|
||||
self.append(b"\nendstream\nendobj\n")
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Result<Vec<u8>, RasterPdfError> {
|
||||
let object_count = self.offsets.len() - 1;
|
||||
let xref_offset = self.output.len();
|
||||
self.append(format!("xref\n0 {}\n0000000000 65535 f \n", object_count + 1).as_bytes())?;
|
||||
for index in 1..self.offsets.len() {
|
||||
let offset = self.offsets[index];
|
||||
self.append(format!("{offset:010} 00000 n \n").as_bytes())?;
|
||||
}
|
||||
self.append(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n",
|
||||
object_count + 1,
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
Ok(self.output)
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for PdfWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.append(bytes)
|
||||
.map(|()| bytes.len())
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn options_reject_impossible_media_boxes() {
|
||||
let mut options = RasterPdfOptions::default();
|
||||
options.paper_width_in = 0.0;
|
||||
assert_eq!(
|
||||
options.page_geometry(),
|
||||
Err(RasterPdfError::InvalidPaperSize)
|
||||
);
|
||||
let mut options = RasterPdfOptions::default();
|
||||
options.margin_left_in = 5.0;
|
||||
options.margin_right_in = 5.0;
|
||||
assert_eq!(options.page_geometry(), Err(RasterPdfError::InvalidMargins));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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)
|
||||
.expect("an ordinary multi-page document stays inside the budget");
|
||||
assert!(ordinary.page_count > 1);
|
||||
|
||||
assert_eq!(
|
||||
pagination_plan(5_000.0, 5_000.0, printable_width, printable_height).unwrap_err(),
|
||||
RasterPdfError::RasterWorkLimitExceeded,
|
||||
"one excessively large raster page must fail before capture"
|
||||
);
|
||||
assert_eq!(
|
||||
pagination_plan(1_000.0, 70_000.0, printable_width, printable_height).unwrap_err(),
|
||||
RasterPdfError::RasterWorkLimitExceeded,
|
||||
"many individually valid pages must still respect a total work budget"
|
||||
);
|
||||
assert_eq!(
|
||||
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height).unwrap_err(),
|
||||
RasterPdfError::TooManyPages(MAX_PDF_PAGES),
|
||||
);
|
||||
}
|
||||
|
||||
#[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, |_| {
|
||||
Ok(RasterPage {
|
||||
rgb: image::RgbImage::from_pixel(2, 3, image::Rgb([10, 20, 30])),
|
||||
draw_width_pt: 100.0,
|
||||
draw_height_pt: 150.0,
|
||||
_lifetime_probe: None,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-1.4"));
|
||||
assert!(pdf.ends_with(b"%%EOF\n"));
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert!(text.contains("/Count 1"));
|
||||
assert!(text.contains("/Subtype /Image"));
|
||||
assert!(text.contains("xref\n0 6"));
|
||||
let startxref = text
|
||||
.rsplit_once("startxref\n")
|
||||
.unwrap()
|
||||
.1
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
assert!(pdf[startxref..].starts_with(b"xref\n"));
|
||||
let object_one_offset = text
|
||||
.split("xref\n0 6\n")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.nth(1)
|
||||
.unwrap()[..10]
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
assert!(pdf[object_one_offset..].starts_with(b"1 0 obj\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_rasters_are_released_before_capturing_the_next_page() {
|
||||
let previous = std::cell::RefCell::new(None::<std::rc::Weak<()>>);
|
||||
let pdf = encode_pdf_pages(4, 612.0, 792.0, 36.0, 36.0, 720.0, |index| {
|
||||
if let Some(previous) = previous.borrow().as_ref() {
|
||||
assert!(
|
||||
previous.upgrade().is_none(),
|
||||
"page {index} was requested while the prior raster was still retained"
|
||||
);
|
||||
}
|
||||
let probe = std::rc::Rc::new(());
|
||||
*previous.borrow_mut() = Some(std::rc::Rc::downgrade(&probe));
|
||||
Ok(RasterPage {
|
||||
rgb: image::RgbImage::from_pixel(8, 8, image::Rgb([index as u8, 0, 0])),
|
||||
draw_width_pt: 100.0,
|
||||
draw_height_pt: 100.0,
|
||||
_lifetime_probe: Some(probe),
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&pdf)
|
||||
.matches("/Subtype /Image")
|
||||
.count(),
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_enforces_the_output_limit_while_encoding_the_image_stream() {
|
||||
let mut writer = PdfWriter::new(5, 600).unwrap();
|
||||
writer
|
||||
.write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.unwrap();
|
||||
writer
|
||||
.write_object(2, b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>")
|
||||
.unwrap();
|
||||
let noisy = image::RgbImage::from_fn(128, 128, |x, y| {
|
||||
image::Rgb([
|
||||
x.wrapping_mul(37) as u8,
|
||||
y.wrapping_mul(53) as u8,
|
||||
x.wrapping_add(y).wrapping_mul(71) as u8,
|
||||
])
|
||||
});
|
||||
assert_eq!(
|
||||
writer.write_rgb_image(5, &noisy),
|
||||
Err(RasterPdfError::OutputLimitExceeded)
|
||||
);
|
||||
assert!(writer.output.len() <= 600);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ use crate::types::{CdpEvent, CdpRequest, CdpResponse};
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ScreencastFormat { Png, Jpeg }
|
||||
pub(crate) enum ScreencastFormat {
|
||||
Png,
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -257,10 +260,15 @@ impl CdpContext {
|
||||
self.pages.retain(|p| p.id != id);
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
let removed: Vec<String> = self.sessions.iter()
|
||||
let removed: Vec<String> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|(_, page_id)| page_id.as_str() == id)
|
||||
.map(|(session_id, _)| session_id.clone()).collect();
|
||||
for session_id in removed { self.screencasts.remove(&session_id); }
|
||||
.map(|(session_id, _)| session_id.clone())
|
||||
.collect();
|
||||
for session_id in removed {
|
||||
self.screencasts.remove(&session_id);
|
||||
}
|
||||
}
|
||||
self.sessions.retain(|_, v| v != id);
|
||||
}
|
||||
@@ -273,9 +281,7 @@ impl CdpContext {
|
||||
}
|
||||
|
||||
pub fn get_session_page(&self, session_id: &Option<String>) -> Option<&Page> {
|
||||
let page_id = session_id
|
||||
.as_ref()
|
||||
.and_then(|sid| self.sessions.get(sid))?;
|
||||
let page_id = session_id.as_ref().and_then(|sid| self.sessions.get(sid))?;
|
||||
self.get_page(page_id)
|
||||
}
|
||||
|
||||
@@ -311,38 +317,67 @@ impl CdpContext {
|
||||
/// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and
|
||||
/// must stay behind the lock.
|
||||
fn is_v8_free_method(method: &str) -> bool {
|
||||
matches!(method,
|
||||
"Target.getTargets" | "Target.setDiscoverTargets"
|
||||
| "Target.attachToTarget" | "Target.attachToBrowserTarget"
|
||||
| "Target.setAutoAttach"
|
||||
| "Target.getBrowserContexts" | "Target.createBrowserContext"
|
||||
| "Target.disposeBrowserContext" | "Target.getTargetInfo"
|
||||
| "Target.detachFromTarget" | "Target.activateTarget"
|
||||
| "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget"
|
||||
| "Browser.setDownloadBehavior" | "Browser.getWindowBounds" | "Browser.setWindowBounds"
|
||||
| "Page.enable" | "Page.disable" | "Page.getFrameTree"
|
||||
| "Page.setDownloadBehavior"
|
||||
| "Page.setLifecycleEventsEnabled"
|
||||
| "Page.addScriptToEvaluateOnNewDocument" | "Page.removeScriptToEvaluateOnNewDocument"
|
||||
| "Page.setInterceptFileChooserDialog" | "Page.getNavigationHistory"
|
||||
| "Page.resetNavigationHistory" | "Page.printToPDF"
|
||||
| "Page.captureSnapshot"
|
||||
| "Page.stopScreencast" | "Page.screencastFrameAck"
|
||||
| "Page.createIsolatedWorld"
|
||||
| "Runtime.enable" | "Runtime.disable"
|
||||
| "Runtime.runIfWaitingForDebugger" | "Runtime.getExceptionDetails"
|
||||
| "Runtime.discardConsoleEntries"
|
||||
| "Network.enable" | "Network.disable" | "Network.setCacheDisabled"
|
||||
| "Network.setRequestInterception" | "Network.setBlockedURLs"
|
||||
| "Network.setExtraHTTPHeaders"
|
||||
| "Network.setUserAgentOverride"
|
||||
| "Network.getCookies" | "Network.getAllCookies"
|
||||
| "Network.setCookie" | "Network.setCookies"
|
||||
| "Network.deleteCookies" | "Network.clearBrowserCookies"
|
||||
| "Network.getResponseBody"
|
||||
| "Fetch.continueRequest" | "Fetch.fulfillRequest"
|
||||
| "Fetch.failRequest" | "Fetch.getResponseBody"
|
||||
| "Storage.getCookies" | "Storage.setCookies" | "Storage.deleteCookies"
|
||||
matches!(
|
||||
method,
|
||||
"Target.getTargets"
|
||||
| "Target.setDiscoverTargets"
|
||||
| "Target.attachToTarget"
|
||||
| "Target.attachToBrowserTarget"
|
||||
| "Target.setAutoAttach"
|
||||
| "Target.getBrowserContexts"
|
||||
| "Target.createBrowserContext"
|
||||
| "Target.disposeBrowserContext"
|
||||
| "Target.getTargetInfo"
|
||||
| "Target.detachFromTarget"
|
||||
| "Target.activateTarget"
|
||||
| "Browser.getVersion"
|
||||
| "Browser.close"
|
||||
| "Browser.getWindowForTarget"
|
||||
| "Browser.setDownloadBehavior"
|
||||
| "Browser.getWindowBounds"
|
||||
| "Browser.setWindowBounds"
|
||||
| "Page.enable"
|
||||
| "Page.disable"
|
||||
| "Page.getFrameTree"
|
||||
| "Page.setDownloadBehavior"
|
||||
| "Page.setLifecycleEventsEnabled"
|
||||
| "Page.addScriptToEvaluateOnNewDocument"
|
||||
| "Page.removeScriptToEvaluateOnNewDocument"
|
||||
| "Page.setInterceptFileChooserDialog"
|
||||
| "Page.getNavigationHistory"
|
||||
| "Page.resetNavigationHistory"
|
||||
| "Page.captureSnapshot"
|
||||
| "Page.stopScreencast"
|
||||
| "Page.screencastFrameAck"
|
||||
| "Page.createIsolatedWorld"
|
||||
| "Runtime.enable"
|
||||
| "Runtime.disable"
|
||||
| "Runtime.runIfWaitingForDebugger"
|
||||
| "Runtime.getExceptionDetails"
|
||||
| "Runtime.discardConsoleEntries"
|
||||
| "Network.enable"
|
||||
| "Network.disable"
|
||||
| "Network.setCacheDisabled"
|
||||
| "Network.setRequestInterception"
|
||||
| "Network.setBlockedURLs"
|
||||
| "Network.setExtraHTTPHeaders"
|
||||
| "Network.setUserAgentOverride"
|
||||
| "Network.getCookies"
|
||||
| "Network.getAllCookies"
|
||||
| "Network.setCookie"
|
||||
| "Network.setCookies"
|
||||
| "Network.deleteCookies"
|
||||
| "Network.clearBrowserCookies"
|
||||
| "Network.getResponseBody"
|
||||
| "Fetch.continueRequest"
|
||||
| "Fetch.fulfillRequest"
|
||||
| "Fetch.failRequest"
|
||||
| "Fetch.getResponseBody"
|
||||
| "IO.read"
|
||||
| "IO.close"
|
||||
| "Storage.getCookies"
|
||||
| "Storage.setCookies"
|
||||
| "Storage.deleteCookies"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -401,7 +436,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
|
||||
} else {
|
||||
ctx.get_session_page(&req.session_id)
|
||||
.and_then(|p| p.isolate_handle())
|
||||
.map(|h| obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms)))
|
||||
.map(|h| {
|
||||
obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms))
|
||||
})
|
||||
};
|
||||
|
||||
let (domain, method) = match req.method.split_once('.') {
|
||||
@@ -421,7 +458,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
|
||||
"Browser" => domains::browser::handle(method, &req.params).await,
|
||||
"Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"DOMSnapshot" => domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"DOMSnapshot" => {
|
||||
domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await
|
||||
}
|
||||
"Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
@@ -430,16 +469,14 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
|
||||
"Emulation" => domains::emulation::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Accessibility" => domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Accessibility" => {
|
||||
domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await
|
||||
}
|
||||
// Accepted but no-op. Puppeteer's FrameManager.initialize calls
|
||||
// Audits.enable on connect — refusing it breaks puppeteer.connect()
|
||||
// before any user code runs.
|
||||
"Log" | "Performance" | "Security" | "CSS"
|
||||
| "ServiceWorker" | "Inspector"
|
||||
| "Debugger" | "Profiler" | "HeapProfiler" | "Overlay"
|
||||
| "Audits" => {
|
||||
Ok(json!({}))
|
||||
}
|
||||
"Log" | "Performance" | "Security" | "CSS" | "ServiceWorker" | "Inspector" | "Debugger"
|
||||
| "Profiler" | "HeapProfiler" | "Overlay" | "Audits" => Ok(json!({})),
|
||||
_ => Err(format!("Unknown domain: {}", domain)),
|
||||
};
|
||||
|
||||
@@ -599,7 +636,11 @@ mod tests {
|
||||
async fn audits_enable_returns_empty_success() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let resp = dispatch(&req("Audits.enable"), &mut ctx).await;
|
||||
assert!(resp.error.is_none(), "Audits.enable should not error: {:?}", resp.error);
|
||||
assert!(
|
||||
resp.error.is_none(),
|
||||
"Audits.enable should not error: {:?}",
|
||||
resp.error
|
||||
);
|
||||
assert_eq!(resp.result, Some(json!({})));
|
||||
}
|
||||
|
||||
@@ -631,7 +672,11 @@ mod tests {
|
||||
};
|
||||
|
||||
let resp = dispatch(&outer, &mut ctx).await;
|
||||
assert!(resp.error.is_none(), "wrapper must succeed: {:?}", resp.error);
|
||||
assert!(
|
||||
resp.error.is_none(),
|
||||
"wrapper must succeed: {:?}",
|
||||
resp.error
|
||||
);
|
||||
assert_eq!(resp.id, 99);
|
||||
assert_eq!(resp.result, Some(json!({})));
|
||||
|
||||
@@ -647,8 +692,14 @@ mod tests {
|
||||
let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap();
|
||||
assert_eq!(parsed["id"], 42);
|
||||
// Browser.getVersion returns a populated result object, not an error.
|
||||
assert!(parsed.get("result").is_some(), "inner response carries result");
|
||||
assert!(parsed.get("error").is_none(), "inner response is not an error");
|
||||
assert!(
|
||||
parsed.get("result").is_some(),
|
||||
"inner response carries result"
|
||||
);
|
||||
assert!(
|
||||
parsed.get("error").is_none(),
|
||||
"inner response is not an error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2,6 +2,22 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
const MAX_DEVICE_METRIC_DIMENSION: i64 = 10_000_000;
|
||||
const DEFAULT_VIEWPORT: (f32, f32) = (1280.0, 720.0);
|
||||
|
||||
fn metric_dimension(params: &Value, name: &str) -> Result<u32, String> {
|
||||
let value = params
|
||||
.get(name)
|
||||
.and_then(Value::as_i64)
|
||||
.ok_or_else(|| format!("Emulation.setDeviceMetricsOverride requires integer {name}"))?;
|
||||
if !(0..=MAX_DEVICE_METRIC_DIMENSION).contains(&value) {
|
||||
return Err(format!(
|
||||
"Emulation.setDeviceMetricsOverride {name} must be between 0 and {MAX_DEVICE_METRIC_DIMENSION}"
|
||||
));
|
||||
}
|
||||
Ok(value as u32)
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
@@ -10,31 +26,42 @@ pub async fn handle(
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"setDeviceMetricsOverride" => {
|
||||
let width = params
|
||||
.get("width")
|
||||
let width = metric_dimension(params, "width")?;
|
||||
let height = metric_dimension(params, "height")?;
|
||||
let device_scale_factor = params
|
||||
.get("deviceScaleFactor")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or("Emulation.setDeviceMetricsOverride requires width")?;
|
||||
let height = params
|
||||
.get("height")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or("Emulation.setDeviceMetricsOverride requires height")?;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
.ok_or("Emulation.setDeviceMetricsOverride requires deviceScaleFactor")?;
|
||||
if !device_scale_factor.is_finite() || device_scale_factor < 0.0 {
|
||||
return Err(
|
||||
"Emulation.setDeviceMetricsOverride requires positive finite dimensions"
|
||||
"Emulation.setDeviceMetricsOverride requires a non-negative finite deviceScaleFactor"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let page = ctx
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page for session")?;
|
||||
page.set_viewport((width as f32, height as f32));
|
||||
page.set_viewport((
|
||||
if width == 0 {
|
||||
DEFAULT_VIEWPORT.0
|
||||
} else {
|
||||
width as f32
|
||||
},
|
||||
if height == 0 {
|
||||
DEFAULT_VIEWPORT.1
|
||||
} else {
|
||||
height as f32
|
||||
},
|
||||
));
|
||||
page.set_device_scale_factor(device_scale_factor as f32);
|
||||
Ok(json!({}))
|
||||
}
|
||||
"clearDeviceMetricsOverride" => {
|
||||
let page = ctx
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page for session")?;
|
||||
page.set_viewport((1280.0, 720.0));
|
||||
page.set_viewport(DEFAULT_VIEWPORT);
|
||||
page.set_device_scale_factor(1.0);
|
||||
Ok(json!({}))
|
||||
}
|
||||
// Touch emulation does not affect layout yet, but acknowledging it is
|
||||
@@ -53,12 +80,11 @@ mod tests {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = Some("viewport-session".to_string());
|
||||
ctx.sessions
|
||||
.insert(session_id.clone().unwrap(), page_id);
|
||||
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||
|
||||
handle(
|
||||
"setDeviceMetricsOverride",
|
||||
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 1}),
|
||||
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 2}),
|
||||
&mut ctx,
|
||||
&session_id,
|
||||
)
|
||||
@@ -69,30 +95,103 @@ mod tests {
|
||||
.get_session_page_mut(&session_id)
|
||||
.expect("page for session");
|
||||
assert_eq!(page.viewport, (1024.0, 768.0));
|
||||
assert_eq!(page.device_scale_factor, 2.0);
|
||||
assert_eq!(
|
||||
page.evaluate(
|
||||
"return [innerWidth, innerHeight, visualViewport.width,\
|
||||
visualViewport.height];"
|
||||
visualViewport.height, devicePixelRatio];"
|
||||
),
|
||||
json!([1024, 768, 1024, 768])
|
||||
json!([1024, 768, 1024, 768, 2])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_metrics_override_rejects_zero_dimensions() {
|
||||
async fn device_metrics_override_rejects_fractional_and_out_of_range_dimensions() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = Some("viewport-session".to_string());
|
||||
ctx.sessions
|
||||
.insert(session_id.clone().unwrap(), page_id);
|
||||
let error = handle(
|
||||
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||
for params in [
|
||||
json!({"width": 0.5, "height": 768, "deviceScaleFactor": 1}),
|
||||
json!({"width": 10_000_001, "height": 768, "deviceScaleFactor": 1}),
|
||||
json!({"width": -1, "height": 768, "deviceScaleFactor": 1}),
|
||||
] {
|
||||
assert!(
|
||||
handle("setDeviceMetricsOverride", ¶ms, &mut ctx, &session_id)
|
||||
.await
|
||||
.is_err(),
|
||||
"must reject {params}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_dimensions_disable_size_override() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = Some("zero-size-session".to_string());
|
||||
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||
handle(
|
||||
"setDeviceMetricsOverride",
|
||||
&json!({"width": 0, "height": 768}),
|
||||
&json!({"width": 0, "height": 0, "deviceScaleFactor": 0}),
|
||||
&mut ctx,
|
||||
&session_id,
|
||||
)
|
||||
.await
|
||||
.expect_err("zero width must fail");
|
||||
assert!(error.contains("positive finite dimensions"));
|
||||
.expect("zero disables the overrides");
|
||||
|
||||
let page = ctx.get_session_page_mut(&session_id).unwrap();
|
||||
assert_eq!(page.viewport, DEFAULT_VIEWPORT);
|
||||
assert_eq!(page.device_scale_factor, 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_scale_factor_zero_disables_override_and_clear_restores_defaults() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = Some("scale-session".to_string());
|
||||
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||
|
||||
handle(
|
||||
"setDeviceMetricsOverride",
|
||||
&json!({"width": 640, "height": 480, "deviceScaleFactor": 3}),
|
||||
&mut ctx,
|
||||
&session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ctx.get_session_page(&session_id)
|
||||
.unwrap()
|
||||
.device_scale_factor,
|
||||
3.0
|
||||
);
|
||||
|
||||
handle(
|
||||
"setDeviceMetricsOverride",
|
||||
&json!({"width": 640, "height": 480, "deviceScaleFactor": 0}),
|
||||
&mut ctx,
|
||||
&session_id,
|
||||
)
|
||||
.await
|
||||
.expect("zero disables the scale override");
|
||||
assert_eq!(
|
||||
ctx.get_session_page(&session_id)
|
||||
.unwrap()
|
||||
.device_scale_factor,
|
||||
1.0
|
||||
);
|
||||
|
||||
handle(
|
||||
"clearDeviceMetricsOverride",
|
||||
&json!({}),
|
||||
&mut ctx,
|
||||
&session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let page = ctx.get_session_page(&session_id).unwrap();
|
||||
assert_eq!(page.viewport, (1280.0, 720.0));
|
||||
assert_eq!(page.device_scale_factor, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,19 @@ pub async fn handle(
|
||||
|
||||
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
|
||||
let _ = paused.resolver.send(FetchResolution::Continue {
|
||||
url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
url: params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
method: params
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
headers: None,
|
||||
post_data: params.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
post_data: params
|
||||
.get("postData")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
Ok(json!({}))
|
||||
@@ -178,9 +187,7 @@ pub async fn handle(
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"getResponseBody" => {
|
||||
Ok(json!({ "body": "", "base64Encoded": false }))
|
||||
}
|
||||
"getResponseBody" => Ok(json!({ "body": "", "base64Encoded": false })),
|
||||
"takeResponseBodyAsStream" => {
|
||||
// Hand the client a streaming handle for a large response body so it
|
||||
// can pull it in chunks via IO.read and free it with IO.close,
|
||||
@@ -206,7 +213,10 @@ pub async fn handle(
|
||||
format!("Fetch.takeResponseBodyAsStream: no cached body for {request_id}")
|
||||
})?;
|
||||
|
||||
let handle = ctx.io_streams.insert(bytes);
|
||||
let handle = ctx
|
||||
.io_streams
|
||||
.insert(bytes)
|
||||
.map_err(|error| format!("Fetch.takeResponseBodyAsStream: {error}"))?;
|
||||
Ok(json!({ "stream": handle }))
|
||||
}
|
||||
_ => Err(format!("Unknown Fetch method: {}", method)),
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::dispatch::CdpContext;
|
||||
// order of magnitude; keeping chunks bounded is the point of streaming (issue
|
||||
// #360), so we never return the whole body in one IO.read.
|
||||
const DEFAULT_CHUNK: usize = 1 << 20; // 1 MiB
|
||||
const MAX_READ_CHUNK: usize = 4 << 20; // 4 MiB
|
||||
|
||||
fn io_stream_max_entries() -> usize {
|
||||
std::env::var("OBSCURA_IO_STREAM_MAX_ENTRIES")
|
||||
@@ -35,48 +36,90 @@ fn io_stream_max_bytes() -> usize {
|
||||
/// memory stays bounded regardless of client behavior. Reading an evicted
|
||||
/// handle fails cleanly (the client re-takes or gives up), which is the right
|
||||
/// trade against an OOM.
|
||||
#[derive(Default)]
|
||||
pub struct IoStreamStore {
|
||||
streams: HashMap<String, (Vec<u8>, usize)>,
|
||||
order: VecDeque<String>,
|
||||
total_bytes: usize,
|
||||
counter: u64,
|
||||
max_entries: usize,
|
||||
max_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for IoStreamStore {
|
||||
fn default() -> Self {
|
||||
Self::with_limits(io_stream_max_entries(), io_stream_max_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl IoStreamStore {
|
||||
/// Store a body and return its handle, evicting the oldest streams if this
|
||||
/// pushes the store past its entry or byte cap.
|
||||
pub fn insert(&mut self, bytes: Vec<u8>) -> String {
|
||||
let handle = format!("stream-{}", self.counter);
|
||||
self.counter += 1;
|
||||
self.total_bytes += bytes.len();
|
||||
self.streams.insert(handle.clone(), (bytes, 0));
|
||||
self.order.push_back(handle.clone());
|
||||
fn with_limits(max_entries: usize, max_bytes: usize) -> Self {
|
||||
Self {
|
||||
streams: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
total_bytes: 0,
|
||||
counter: 0,
|
||||
max_entries: max_entries.max(1),
|
||||
max_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
let max_entries = io_stream_max_entries().max(1);
|
||||
let max_bytes = io_stream_max_bytes();
|
||||
// Evict oldest streams while over either cap. Never evict the stream we
|
||||
// just inserted (it sits at the back of `order`); a lone oversized body
|
||||
// is kept, since the client explicitly asked to stream it.
|
||||
while self.order.len() > 1
|
||||
&& (self.order.len() > max_entries || self.total_bytes > max_bytes)
|
||||
/// Store a body and return its handle, evicting the oldest streams if this
|
||||
/// would push the store past its entry or byte cap. A single body larger
|
||||
/// than the byte cap is rejected rather than becoming an unbounded
|
||||
/// exception to the store's memory contract.
|
||||
pub fn insert(&mut self, bytes: Vec<u8>) -> Result<String, String> {
|
||||
if bytes.len() > self.max_bytes {
|
||||
return Err(format!(
|
||||
"IO stream body is {} bytes, exceeding the {}-byte per-context limit",
|
||||
bytes.len(),
|
||||
self.max_bytes,
|
||||
));
|
||||
}
|
||||
|
||||
while !self.order.is_empty()
|
||||
&& (self.order.len() >= self.max_entries
|
||||
|| self
|
||||
.total_bytes
|
||||
.checked_add(bytes.len())
|
||||
.is_none_or(|total| total > self.max_bytes))
|
||||
{
|
||||
if let Some(oldest) = self.order.pop_front() {
|
||||
if let Some((b, _)) = self.streams.remove(&oldest) {
|
||||
self.total_bytes -= b.len();
|
||||
if let Some((body, _)) = self.streams.remove(&oldest) {
|
||||
self.total_bytes = self.total_bytes.saturating_sub(body.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
handle
|
||||
|
||||
let handle = format!("stream-{}", self.counter);
|
||||
self.counter = self
|
||||
.counter
|
||||
.checked_add(1)
|
||||
.ok_or("IO stream handle space exhausted")?;
|
||||
self.total_bytes = self
|
||||
.total_bytes
|
||||
.checked_add(bytes.len())
|
||||
.ok_or("IO stream byte accounting overflow")?;
|
||||
self.streams.insert(handle.clone(), (bytes, 0));
|
||||
self.order.push_back(handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Read up to `size` bytes from the stream, advancing its cursor. Returns
|
||||
/// the base64 chunk and whether EOF was reached, or None for an unknown or
|
||||
/// already-freed handle.
|
||||
pub fn read(&mut self, handle: &str, size: usize) -> Option<(String, bool)> {
|
||||
pub fn read(
|
||||
&mut self,
|
||||
handle: &str,
|
||||
offset: Option<usize>,
|
||||
size: usize,
|
||||
) -> Option<(String, bool)> {
|
||||
let (bytes, cursor) = self.streams.get_mut(handle)?;
|
||||
if let Some(offset) = offset {
|
||||
*cursor = offset.min(bytes.len());
|
||||
}
|
||||
let size = size.min(MAX_READ_CHUNK);
|
||||
let start = (*cursor).min(bytes.len());
|
||||
let end = start.saturating_add(size.max(1)).min(bytes.len());
|
||||
let end = start.saturating_add(size).min(bytes.len());
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes[start..end]);
|
||||
*cursor = end;
|
||||
Some((data, end >= bytes.len()))
|
||||
@@ -103,13 +146,29 @@ pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Resul
|
||||
.ok_or("IO.read requires handle")?;
|
||||
let size = params
|
||||
.get("size")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|s| s as usize)
|
||||
.map(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.filter(|size| *size >= 0)
|
||||
.and_then(|size| usize::try_from(size).ok())
|
||||
.ok_or("IO.read size must be a non-negative integer")
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(DEFAULT_CHUNK);
|
||||
let offset = params
|
||||
.get("offset")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.filter(|offset| *offset >= 0)
|
||||
.and_then(|offset| usize::try_from(offset).ok())
|
||||
.ok_or("IO.read offset must be a non-negative integer")
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let (data, eof) = ctx
|
||||
.io_streams
|
||||
.read(handle, size)
|
||||
.read(handle, offset, size)
|
||||
.ok_or_else(|| format!("IO.read: unknown handle {handle}"))?;
|
||||
|
||||
Ok(json!({ "data": data, "eof": eof, "base64Encoded": true }))
|
||||
@@ -136,48 +195,89 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reads_chunks_then_frees() {
|
||||
let mut store = IoStreamStore::default();
|
||||
let h = store.insert(b"hello".to_vec());
|
||||
let mut store = IoStreamStore::with_limits(4, 1024);
|
||||
let h = store.insert(b"hello".to_vec()).unwrap();
|
||||
|
||||
let (d1, eof1) = store.read(&h, 3).unwrap();
|
||||
let (d1, eof1) = store.read(&h, None, 3).unwrap();
|
||||
assert_eq!(decode(&d1), b"hel");
|
||||
assert!(!eof1);
|
||||
|
||||
let (d2, eof2) = store.read(&h, 3).unwrap();
|
||||
let (d2, eof2) = store.read(&h, None, 3).unwrap();
|
||||
assert_eq!(decode(&d2), b"lo");
|
||||
assert!(eof2);
|
||||
|
||||
store.remove(&h);
|
||||
assert!(store.read(&h, 3).is_none());
|
||||
assert!(store.read(&h, None, 3).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_offset_seeks_and_zero_size_does_not_advance() {
|
||||
let mut store = IoStreamStore::with_limits(2, 1024);
|
||||
let handle = store.insert(b"abcdef".to_vec()).unwrap();
|
||||
|
||||
let (empty, eof) = store.read(&handle, Some(1), 0).unwrap();
|
||||
assert_eq!(decode(&empty), b"");
|
||||
assert!(!eof);
|
||||
let (middle, eof) = store.read(&handle, None, 2).unwrap();
|
||||
assert_eq!(decode(&middle), b"bc");
|
||||
assert!(!eof);
|
||||
let (tail, eof) = store.read(&handle, Some(4), 10).unwrap();
|
||||
assert_eq!(decode(&tail), b"ef");
|
||||
assert!(eof);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_rejects_negative_or_non_integer_ranges() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let handle_id = ctx.io_streams.insert(b"data".to_vec()).unwrap();
|
||||
for params in [
|
||||
json!({"handle": handle_id.clone(), "offset": -1}),
|
||||
json!({"handle": handle_id.clone(), "size": -1}),
|
||||
json!({"handle": handle_id, "offset": 1.5}),
|
||||
] {
|
||||
assert!(handle("read", ¶ms, &mut ctx).await.is_err(), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_oldest_over_entry_cap() {
|
||||
std::env::set_var("OBSCURA_IO_STREAM_MAX_ENTRIES", "3");
|
||||
let mut store = IoStreamStore::default();
|
||||
let h0 = store.insert(vec![0]);
|
||||
let h1 = store.insert(vec![1]);
|
||||
let _h2 = store.insert(vec![2]);
|
||||
let h3 = store.insert(vec![3]); // 4th entry, cap 3 -> h0 evicted
|
||||
std::env::remove_var("OBSCURA_IO_STREAM_MAX_ENTRIES");
|
||||
let mut store = IoStreamStore::with_limits(3, 1024);
|
||||
let h0 = store.insert(vec![0]).unwrap();
|
||||
let h1 = store.insert(vec![1]).unwrap();
|
||||
let _h2 = store.insert(vec![2]).unwrap();
|
||||
let h3 = store.insert(vec![3]).unwrap(); // 4th entry, cap 3 -> h0 evicted
|
||||
|
||||
assert!(store.read(&h0, 10).is_none(), "oldest stream should be evicted");
|
||||
assert!(store.read(&h1, 10).is_some());
|
||||
assert!(store.read(&h3, 10).is_some());
|
||||
assert!(
|
||||
store.read(&h0, None, 10).is_none(),
|
||||
"oldest stream should be evicted"
|
||||
);
|
||||
assert!(store.read(&h1, None, 10).is_some());
|
||||
assert!(store.read(&h3, None, 10).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_over_byte_cap_but_keeps_newest() {
|
||||
std::env::set_var("OBSCURA_IO_STREAM_MAX_BYTES", "10");
|
||||
let mut store = IoStreamStore::default();
|
||||
let h0 = store.insert(vec![0u8; 8]);
|
||||
let h1 = store.insert(vec![1u8; 8]); // 16 > 10 -> h0 evicted
|
||||
// A single body larger than the cap is still kept (client asked for it).
|
||||
let big = store.insert(vec![2u8; 100]);
|
||||
std::env::remove_var("OBSCURA_IO_STREAM_MAX_BYTES");
|
||||
fn evicts_over_byte_cap_and_rejects_oversized_body() {
|
||||
let mut store = IoStreamStore::with_limits(4, 10);
|
||||
let h0 = store.insert(vec![0u8; 8]).unwrap();
|
||||
let h1 = store.insert(vec![1u8; 8]).unwrap(); // 16 > 10 -> h0 evicted
|
||||
let error = store
|
||||
.insert(vec![2u8; 100])
|
||||
.expect_err("a single body cannot bypass the context byte cap");
|
||||
|
||||
assert!(store.read(&h0, 100).is_none());
|
||||
assert!(store.read(&big, 200).is_some(), "the just-inserted body is never evicted");
|
||||
let _ = h1;
|
||||
assert!(store.read(&h0, None, 100).is_none());
|
||||
assert!(store.read(&h1, None, 100).is_some());
|
||||
assert!(error.contains("exceeding"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_read_size_is_capped() {
|
||||
let mut store = IoStreamStore::with_limits(2, MAX_READ_CHUNK * 2);
|
||||
let handle = store.insert(vec![7u8; MAX_READ_CHUNK + 17]).unwrap();
|
||||
let (first, eof) = store.read(&handle, None, usize::MAX).unwrap();
|
||||
assert_eq!(decode(&first).len(), MAX_READ_CHUNK);
|
||||
assert!(!eof);
|
||||
let (second, eof) = store.read(&handle, None, usize::MAX).unwrap();
|
||||
assert_eq!(decode(&second).len(), 17);
|
||||
assert!(eof);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
pub mod target;
|
||||
pub mod accessibility;
|
||||
pub mod browser;
|
||||
pub mod page;
|
||||
pub mod dom;
|
||||
pub mod domsnapshot;
|
||||
pub mod runtime;
|
||||
pub mod network;
|
||||
pub mod fetch;
|
||||
pub mod io;
|
||||
pub mod input;
|
||||
pub mod storage;
|
||||
pub mod accessibility;
|
||||
pub mod lp;
|
||||
pub mod emulation;
|
||||
pub mod fetch;
|
||||
pub mod input;
|
||||
pub mod io;
|
||||
pub mod lp;
|
||||
pub mod network;
|
||||
pub mod page;
|
||||
pub mod pdf;
|
||||
pub mod runtime;
|
||||
pub mod storage;
|
||||
pub mod target;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
||||
#[cfg(feature = "render")]
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
const MAX_BASE64_PDF_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn base64_encoded_len(raw_len: usize) -> Option<usize> {
|
||||
raw_len.checked_add(2)?.checked_div(3)?.checked_mul(4)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum PdfTransferMode {
|
||||
ReturnAsBase64,
|
||||
ReturnAsStream,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ParsedPdfOptions {
|
||||
raster: obscura_browser::RasterPdfOptions,
|
||||
transfer_mode: PdfTransferMode,
|
||||
requested_print_background: bool,
|
||||
requested_tagged_pdf: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn number(params: &Value, name: &str, default: f32) -> Result<f32, String> {
|
||||
match params.get(name) {
|
||||
None => Ok(default),
|
||||
Some(value) => value
|
||||
.as_f64()
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| value as f32)
|
||||
.ok_or_else(|| format!("Invalid parameters: {name} must be a finite number")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn boolean(params: &Value, name: &str, default: bool) -> Result<bool, String> {
|
||||
match params.get(name) {
|
||||
None => Ok(default),
|
||||
Some(Value::Bool(value)) => Ok(*value),
|
||||
Some(_) => Err(format!("Invalid parameters: {name} must be a boolean")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
|
||||
if !params.is_object() {
|
||||
return Err("Invalid parameters: expected an object".to_string());
|
||||
}
|
||||
let unsupported_true = [
|
||||
("displayHeaderFooter", "headers and footers"),
|
||||
("preferCSSPageSize", "CSS @page sizing"),
|
||||
("generateDocumentOutline", "document outlines"),
|
||||
];
|
||||
for (name, capability) in unsupported_true {
|
||||
if boolean(params, name, false)? {
|
||||
return Err(format!(
|
||||
"Page.printToPDF does not yet support {capability} ({name}=true)"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Current Puppeteer and Playwright always send their false default. The
|
||||
// raster paginator cannot suppress backgrounds without a second paint
|
||||
// mode, so accept the protocol shape and disclose the limitation in the
|
||||
// extension capability fields returned with the result.
|
||||
let requested_print_background = boolean(params, "printBackground", false)?;
|
||||
// Puppeteer currently sends true by default. Raster PDFs have no semantic
|
||||
// structure to tag, but rejecting the request makes the standard client
|
||||
// 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 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());
|
||||
}
|
||||
}
|
||||
for name in ["headerTemplate", "footerTemplate"] {
|
||||
if let Some(value) = params.get(name) {
|
||||
let template = value
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("Invalid parameters: {name} must be a string"))?;
|
||||
if !template.is_empty() {
|
||||
return Err(format!("Page.printToPDF {name} is not yet supported"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let transfer_mode = match params.get("transferMode") {
|
||||
None => PdfTransferMode::ReturnAsBase64,
|
||||
Some(Value::String(value)) => match value.as_str() {
|
||||
"ReturnAsBase64" => PdfTransferMode::ReturnAsBase64,
|
||||
"ReturnAsStream" => PdfTransferMode::ReturnAsStream,
|
||||
_ => return Err("Invalid parameters: unknown transferMode".into()),
|
||||
},
|
||||
Some(_) => return Err("Invalid parameters: transferMode must be a string".into()),
|
||||
};
|
||||
|
||||
let defaults = obscura_browser::RasterPdfOptions::default();
|
||||
Ok(ParsedPdfOptions {
|
||||
raster: obscura_browser::RasterPdfOptions {
|
||||
landscape: boolean(params, "landscape", defaults.landscape)?,
|
||||
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)?,
|
||||
margin_bottom_in: number(params, "marginBottom", defaults.margin_bottom_in)?,
|
||||
margin_left_in: number(params, "marginLeft", defaults.margin_left_in)?,
|
||||
margin_right_in: number(params, "marginRight", defaults.margin_right_in)?,
|
||||
},
|
||||
transfer_mode,
|
||||
requested_print_background,
|
||||
requested_tagged_pdf,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn print_to_pdf(
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
let options = parse_options(params)?;
|
||||
let deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(3_000);
|
||||
let page = ctx
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page for session")?;
|
||||
let _ = page.prepare_screenshot_resources(deadline_ms).await;
|
||||
let pdf = page
|
||||
.raster_pdf(options.raster)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut response = json!({
|
||||
"obscuraPrintMode": "screen-raster",
|
||||
"obscuraPrintBackground": true,
|
||||
"obscuraRequestedPrintBackground": options.requested_print_background,
|
||||
"obscuraTaggedPDF": false,
|
||||
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
|
||||
"obscuraCapabilities": {
|
||||
"cssPagedMedia": false,
|
||||
"honorsPrintBackground": false,
|
||||
"taggedPdf": false,
|
||||
},
|
||||
});
|
||||
let mut ignored_options = Vec::new();
|
||||
if !options.requested_print_background {
|
||||
ignored_options.push("printBackground");
|
||||
}
|
||||
if options.requested_tagged_pdf {
|
||||
ignored_options.push("generateTaggedPDF");
|
||||
}
|
||||
response["obscuraIgnoredOptions"] = json!(ignored_options);
|
||||
match options.transfer_mode {
|
||||
PdfTransferMode::ReturnAsBase64 => {
|
||||
use base64::Engine as _;
|
||||
let encoded_len = base64_encoded_len(pdf.len())
|
||||
.ok_or("Page.printToPDF base64 response size overflow")?;
|
||||
if encoded_len > MAX_BASE64_PDF_BYTES {
|
||||
return Err(format!(
|
||||
"Page.printToPDF base64 response would be {encoded_len} bytes, exceeding the {MAX_BASE64_PDF_BYTES}-byte response limit; use transferMode=ReturnAsStream"
|
||||
));
|
||||
}
|
||||
response["data"] =
|
||||
Value::String(base64::engine::general_purpose::STANDARD.encode(pdf));
|
||||
}
|
||||
PdfTransferMode::ReturnAsStream => {
|
||||
let handle = ctx
|
||||
.io_streams
|
||||
.insert(pdf)
|
||||
.map_err(|error| format!("Page.printToPDF could not open stream: {error}"))?;
|
||||
response["data"] = Value::String(String::new());
|
||||
response["stream"] = Value::String(handle);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
#[cfg(not(feature = "render"))]
|
||||
{
|
||||
let _ = (params, ctx, session_id);
|
||||
Err("Page.printToPDF requires a build with the render feature".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "render"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domains::page;
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_standard_client_defaults_and_rejects_unrepresented_features() {
|
||||
let standard = parse_options(&json!({
|
||||
"transferMode": "ReturnAsStream",
|
||||
"displayHeaderFooter": false,
|
||||
"headerTemplate": "",
|
||||
"footerTemplate": "",
|
||||
"printBackground": false,
|
||||
"scale": 1,
|
||||
"pageRanges": "",
|
||||
"preferCSSPageSize": false,
|
||||
"generateTaggedPDF": true,
|
||||
"generateDocumentOutline": false,
|
||||
}))
|
||||
.expect("current Puppeteer defaults must be accepted");
|
||||
assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream);
|
||||
assert!(!standard.requested_print_background);
|
||||
assert!(standard.requested_tagged_pdf);
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_size_preflight_is_exact_and_overflow_safe() {
|
||||
assert_eq!(base64_encoded_len(0), Some(0));
|
||||
assert_eq!(base64_encoded_len(1), Some(4));
|
||||
assert_eq!(base64_encoded_len(2), Some(4));
|
||||
assert_eq!(base64_encoded_len(3), Some(4));
|
||||
assert_eq!(base64_encoded_len(4), Some(8));
|
||||
assert_eq!(base64_encoded_len(usize::MAX), None);
|
||||
let largest_raw = (MAX_BASE64_PDF_BYTES / 4) * 3;
|
||||
assert_eq!(base64_encoded_len(largest_raw), Some(MAX_BASE64_PDF_BYTES));
|
||||
assert!(base64_encoded_len(largest_raw + 1).unwrap() > MAX_BASE64_PDF_BYTES);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn print_to_pdf_returns_paginated_pdf_with_requested_media_box() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = format!("{page_id}-session");
|
||||
ctx.sessions.insert(session_id.clone(), page_id);
|
||||
let session = Some(session_id);
|
||||
ctx.get_session_page_mut(&session)
|
||||
.unwrap()
|
||||
.set_viewport((100.0, 80.0));
|
||||
page::handle(
|
||||
"navigate",
|
||||
&json!({
|
||||
"url": "data:text/html,<html style='margin:0'><body style='margin:0;height:400px;background:linear-gradient(red,blue)'></body></html>",
|
||||
"waitUntil": "load",
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
).await.expect("navigate fixture");
|
||||
let response = print_to_pdf(
|
||||
&json!({
|
||||
"paperWidth": 4.0, "paperHeight": 6.0,
|
||||
"marginTop": 0.5, "marginBottom": 0.5,
|
||||
"marginLeft": 0.5, "marginRight": 0.5,
|
||||
"printBackground": true,
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("raster PDF");
|
||||
assert_eq!(response["obscuraPrintMode"], "screen-raster");
|
||||
assert_eq!(response["obscuraPrintBackground"], true);
|
||||
use base64::Engine as _;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(response["data"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-1.4"));
|
||||
assert!(bytes.ends_with(b"%%EOF\n"));
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.contains("/MediaBox [0 0 288.000 432.000]"));
|
||||
assert!(
|
||||
text.matches("/Subtype /Image").count() >= 2,
|
||||
"400px tall fixture should paginate at this printable aspect ratio"
|
||||
);
|
||||
|
||||
let streamed = page::handle(
|
||||
"printToPDF",
|
||||
&json!({
|
||||
"transferMode": "ReturnAsStream",
|
||||
"landscape": false,
|
||||
"displayHeaderFooter": false,
|
||||
"headerTemplate": "",
|
||||
"footerTemplate": "",
|
||||
"printBackground": false,
|
||||
"scale": 1,
|
||||
"paperWidth": 8.5,
|
||||
"paperHeight": 11,
|
||||
"marginTop": 0,
|
||||
"marginBottom": 0,
|
||||
"marginLeft": 0,
|
||||
"marginRight": 0,
|
||||
"pageRanges": "",
|
||||
"preferCSSPageSize": false,
|
||||
"generateTaggedPDF": true,
|
||||
"generateDocumentOutline": false,
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("Puppeteer/Playwright-shaped stream request");
|
||||
assert_eq!(streamed["data"], "");
|
||||
assert_eq!(streamed["obscuraPrintBackground"], true);
|
||||
assert_eq!(streamed["obscuraRequestedPrintBackground"], false);
|
||||
assert_eq!(streamed["obscuraTaggedPDF"], false);
|
||||
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
|
||||
assert_eq!(
|
||||
streamed["obscuraIgnoredOptions"],
|
||||
json!(["printBackground", "generateTaggedPDF"])
|
||||
);
|
||||
let handle = streamed["stream"].as_str().expect("protocol stream handle");
|
||||
let mut streamed_bytes = Vec::new();
|
||||
loop {
|
||||
let chunk = crate::domains::io::handle(
|
||||
"read",
|
||||
&json!({"handle": handle, "size": 1024}),
|
||||
&mut ctx,
|
||||
)
|
||||
.await
|
||||
.expect("read PDF stream");
|
||||
streamed_bytes.extend(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(chunk["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
);
|
||||
if chunk["eof"] == true {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(streamed_bytes.starts_with(b"%PDF-1.4"));
|
||||
assert!(streamed_bytes.ends_with(b"%%EOF\n"));
|
||||
crate::domains::io::handle("close", &json!({"handle": handle}), &mut ctx)
|
||||
.await
|
||||
.expect("close PDF stream");
|
||||
assert!(
|
||||
crate::domains::io::handle("read", &json!({"handle": handle}), &mut ctx,)
|
||||
.await
|
||||
.is_err(),
|
||||
"closed PDF handles must release their buffer"
|
||||
);
|
||||
}
|
||||
}
|
||||
+379
-186
@@ -129,8 +129,11 @@ enum Command {
|
||||
#[arg(long)]
|
||||
selector: Option<String>,
|
||||
|
||||
#[arg(long, default_value_t = 5)]
|
||||
wait: u64,
|
||||
/// Maximum adaptive post-load settle time in seconds. When supplied
|
||||
/// explicitly, this is a fixed delay; the default is a 5-second cap
|
||||
/// that returns once the page is quiescent.
|
||||
#[arg(long)]
|
||||
wait: Option<u64>,
|
||||
|
||||
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))]
|
||||
timeout: u64,
|
||||
@@ -194,10 +197,8 @@ enum Command {
|
||||
#[arg(long)]
|
||||
user_agent: Option<String>,
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)]
|
||||
enum DumpFormat {
|
||||
Html,
|
||||
@@ -221,7 +222,8 @@ enum DumpFormat {
|
||||
}
|
||||
|
||||
fn print_banner(port: u16) {
|
||||
println!(r#"
|
||||
println!(
|
||||
r#"
|
||||
____ _
|
||||
/ __ \| |
|
||||
| | | | |__ ___ ___ _ _ _ __ __ _
|
||||
@@ -231,7 +233,10 @@ fn print_banner(port: u16) {
|
||||
|
||||
Headless Browser v{}
|
||||
CDP server: ws://127.0.0.1:{}/devtools/browser
|
||||
"#, env!("OBSCURA_BUILD_VERSION"), port);
|
||||
"#,
|
||||
env!("OBSCURA_BUILD_VERSION"),
|
||||
port
|
||||
);
|
||||
}
|
||||
|
||||
fn select_log_filter(verbose: bool, quiet: bool) -> &'static str {
|
||||
@@ -282,9 +287,11 @@ fn normalize_v8_flags(raw: Option<&str>) -> Option<String> {
|
||||
/// footprint. Together they cut RSS ~18% on heavy pages (ycombinator.com
|
||||
/// 173 MB -> 140 MB) at no measurable speed cost (V8 still JITs hot paths).
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
const DEFAULT_V8_FLAGS: &str = "--max-old-space-size=4096 --max-semi-space-size=4 --optimize-for-size";
|
||||
const DEFAULT_V8_FLAGS: &str =
|
||||
"--max-old-space-size=4096 --max-semi-space-size=4 --optimize-for-size";
|
||||
#[cfg(not(target_pointer_width = "64"))]
|
||||
const DEFAULT_V8_FLAGS: &str = "--max-old-space-size=1024 --max-semi-space-size=4 --optimize-for-size";
|
||||
const DEFAULT_V8_FLAGS: &str =
|
||||
"--max-old-space-size=1024 --max-semi-space-size=4 --optimize-for-size";
|
||||
|
||||
fn effective_v8_flags(user: Option<&str>) -> String {
|
||||
match normalize_v8_flags(user) {
|
||||
@@ -305,10 +312,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
// TZ from the host is respected.
|
||||
// SAFETY: runs before any V8 isolate or worker thread starts, so the env is
|
||||
// effectively single threaded here.
|
||||
if let Some(tz) = std::env::var("OBSCURA_TIMEZONE").ok().filter(|s| !s.trim().is_empty()) {
|
||||
unsafe { std::env::set_var("TZ", tz); }
|
||||
if let Some(tz) = std::env::var("OBSCURA_TIMEZONE")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
unsafe {
|
||||
std::env::set_var("TZ", tz);
|
||||
}
|
||||
} else if std::env::var_os("TZ").is_none() {
|
||||
unsafe { std::env::set_var("TZ", "Europe/Berlin"); }
|
||||
unsafe {
|
||||
std::env::set_var("TZ", "Europe/Berlin");
|
||||
}
|
||||
}
|
||||
|
||||
let quiet = is_quiet_command(&args.command);
|
||||
@@ -333,19 +347,34 @@ async fn main() -> anyhow::Result<()> {
|
||||
// SAFETY: set_var is unsafe in newer rustc; this runs before any
|
||||
// spawned thread inspects the env, so it's effectively single
|
||||
// threaded at this point.
|
||||
unsafe { std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); }
|
||||
unsafe {
|
||||
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
|
||||
}
|
||||
}
|
||||
|
||||
let global_proxy = args.proxy.clone();
|
||||
let stealth = args.stealth;
|
||||
|
||||
match args.command {
|
||||
Some(Command::Serve { port, host, proxy, user_agent, workers, max_connections, allow_file_access, storage_dir, quiet: _ }) => {
|
||||
Some(Command::Serve {
|
||||
port,
|
||||
host,
|
||||
proxy,
|
||||
user_agent,
|
||||
workers,
|
||||
max_connections,
|
||||
allow_file_access,
|
||||
storage_dir,
|
||||
quiet: _,
|
||||
}) => {
|
||||
// Fall back to OBSCURA_PROXY so a proxy can be supplied without
|
||||
// putting credentials on the command line. The multi-worker load
|
||||
// balancer passes the proxy to each worker this way (issue #366).
|
||||
let proxy = merge_proxy(global_proxy.clone(), proxy)
|
||||
.or_else(|| std::env::var("OBSCURA_PROXY").ok().filter(|s| !s.is_empty()));
|
||||
let proxy = merge_proxy(global_proxy.clone(), proxy).or_else(|| {
|
||||
std::env::var("OBSCURA_PROXY")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
});
|
||||
print_banner(port);
|
||||
if let Some(ref dir) = storage_dir {
|
||||
tracing::info!("Storage dir: {}", dir.display());
|
||||
@@ -370,12 +399,35 @@ async fn main() -> anyhow::Result<()> {
|
||||
run_multi_worker_serve(port, host, workers, proxy, stealth, user_agent).await?;
|
||||
} else {
|
||||
obscura_cdp::start_with_serve_options_and_limit(
|
||||
port, &host, proxy, stealth, user_agent, allow_file_access, storage_dir,
|
||||
args.allow_private_network, max_connections,
|
||||
).await?;
|
||||
port,
|
||||
&host,
|
||||
proxy,
|
||||
stealth,
|
||||
user_agent,
|
||||
allow_file_access,
|
||||
storage_dir,
|
||||
args.allow_private_network,
|
||||
max_connections,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, eval, output, quiet, storage_dir, file, concurrency, screenshot }) => {
|
||||
Some(Command::Fetch {
|
||||
url,
|
||||
dump,
|
||||
selector,
|
||||
wait,
|
||||
timeout,
|
||||
wait_until,
|
||||
user_agent,
|
||||
eval,
|
||||
output,
|
||||
quiet,
|
||||
storage_dir,
|
||||
file,
|
||||
concurrency,
|
||||
screenshot,
|
||||
}) => {
|
||||
if let Some(file) = file {
|
||||
if url.is_some() {
|
||||
anyhow::bail!("Pass URLs via a positional argument or --file, not both.");
|
||||
@@ -392,18 +444,71 @@ async fn main() -> anyhow::Result<()> {
|
||||
),
|
||||
}
|
||||
let urls = read_urls_from_file(&file)?;
|
||||
run_batch_fetch(urls, concurrency.get(), timeout, user_agent, global_proxy, output, quiet).await?;
|
||||
run_batch_fetch(
|
||||
urls,
|
||||
concurrency.get(),
|
||||
timeout,
|
||||
user_agent,
|
||||
global_proxy,
|
||||
output,
|
||||
quiet,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let url = url.ok_or_else(|| {
|
||||
anyhow::anyhow!("No URL provided. Pass a URL, or a list of URLs with --file <path>.")
|
||||
anyhow::anyhow!(
|
||||
"No URL provided. Pass a URL, or a list of URLs with --file <path>."
|
||||
)
|
||||
})?;
|
||||
run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network, screenshot).await?;
|
||||
let wait_is_fixed = wait.is_some();
|
||||
run_fetch(
|
||||
&url,
|
||||
dump,
|
||||
selector,
|
||||
wait.unwrap_or(5),
|
||||
wait_is_fixed,
|
||||
timeout,
|
||||
&wait_until,
|
||||
user_agent,
|
||||
stealth,
|
||||
eval,
|
||||
output,
|
||||
quiet,
|
||||
global_proxy,
|
||||
storage_dir,
|
||||
args.allow_private_network,
|
||||
screenshot,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => {
|
||||
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy, stealth).await?;
|
||||
Some(Command::Scrape {
|
||||
urls,
|
||||
eval,
|
||||
concurrency,
|
||||
format,
|
||||
timeout,
|
||||
quiet,
|
||||
}) => {
|
||||
run_parallel_scrape(
|
||||
urls,
|
||||
eval,
|
||||
concurrency.get(),
|
||||
&format,
|
||||
timeout,
|
||||
quiet,
|
||||
global_proxy,
|
||||
stealth,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some(Command::Mcp { http, host, port, proxy, user_agent }) => {
|
||||
Some(Command::Mcp {
|
||||
http,
|
||||
host,
|
||||
port,
|
||||
proxy,
|
||||
user_agent,
|
||||
}) => {
|
||||
let mcp_proxy = merge_proxy(global_proxy.clone(), proxy);
|
||||
if http {
|
||||
obscura_mcp::http::run(host, port, mcp_proxy, user_agent, stealth).await?;
|
||||
@@ -431,8 +536,8 @@ async fn run_multi_worker_serve(
|
||||
stealth: bool,
|
||||
user_agent: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut children = Vec::new();
|
||||
@@ -514,11 +619,8 @@ async fn run_multi_worker_serve(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = tokio::io::copy_bidirectional(
|
||||
&mut client,
|
||||
&mut worker_stream,
|
||||
)
|
||||
.await;
|
||||
let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -526,9 +628,7 @@ async fn run_multi_worker_serve(
|
||||
tokio::spawn(async move {
|
||||
let mut s = client_stream;
|
||||
let _ = s
|
||||
.write_all(
|
||||
b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n")
|
||||
.await;
|
||||
let _ = s.shutdown().await;
|
||||
});
|
||||
@@ -543,8 +643,7 @@ async fn run_multi_worker_serve(
|
||||
match tokio::net::TcpStream::connect(&worker_addr).await {
|
||||
Ok(mut worker_stream) => {
|
||||
let mut client = client_stream;
|
||||
let _ =
|
||||
tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await;
|
||||
let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("worker {} unreachable: {}", worker_addr, e);
|
||||
@@ -559,11 +658,21 @@ async fn run_multi_worker_serve(
|
||||
}
|
||||
}
|
||||
|
||||
async fn settle_page(page: &mut Page, wait_secs: u64, fixed: bool) {
|
||||
let wait_ms = wait_secs.saturating_mul(1000);
|
||||
if fixed {
|
||||
page.settle_for_duration(wait_ms).await;
|
||||
} else {
|
||||
page.settle(wait_ms).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_fetch(
|
||||
url_str: &str,
|
||||
dump: Option<DumpFormat>,
|
||||
selector: Option<String>,
|
||||
wait_secs: u64,
|
||||
wait_is_fixed: bool,
|
||||
timeout_secs: u64,
|
||||
wait_until: &str,
|
||||
user_agent: Option<String>,
|
||||
@@ -587,13 +696,7 @@ async fn run_fetch(
|
||||
// payloads (images, fonts, …) and any non-HTML resource where parsing the
|
||||
// body through the DOM/JS layer would corrupt or discard data.
|
||||
if dump == DumpFormat::Original {
|
||||
let bytes = fetch_original_bytes(
|
||||
url_str,
|
||||
proxy,
|
||||
user_agent.clone(),
|
||||
timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
let bytes = fetch_original_bytes(url_str, proxy, user_agent.clone(), timeout_secs).await?;
|
||||
write_or_print_bytes(&bytes, output.as_ref()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -643,8 +746,7 @@ async fn run_fetch(
|
||||
// absent.
|
||||
let eval_at_capture_boundary = screenshot.is_some()
|
||||
&& eval.is_some()
|
||||
&& std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE")
|
||||
.is_ok_and(|value| value == "1");
|
||||
&& std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE").is_ok_and(|value| value == "1");
|
||||
let controlled_scroll_request = screenshot.as_ref().and_then(|_| {
|
||||
let raw_y = std::env::var("OBSCURA_SHOT_SCROLL_Y").ok()?;
|
||||
let x = match std::env::var("OBSCURA_SHOT_SCROLL_X") {
|
||||
@@ -673,9 +775,7 @@ async fn run_fetch(
|
||||
{
|
||||
let settle_passes = if eval_at_capture_boundary {
|
||||
1 + u64::from(controlled_scroll_request.is_some())
|
||||
} else if eval.is_some()
|
||||
&& (screenshot.is_some() || selector.is_some() || dump_specified)
|
||||
{
|
||||
} else if eval.is_some() && (screenshot.is_some() || selector.is_some() || dump_specified) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
@@ -687,13 +787,23 @@ async fn run_fetch(
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(hard);
|
||||
eprintln!("obscura: hard timeout exceeded ({}s); forcing exit", hard.as_secs());
|
||||
eprintln!(
|
||||
"obscura: hard timeout exceeded ({}s); forcing exit",
|
||||
hard.as_secs()
|
||||
);
|
||||
std::process::exit(124);
|
||||
});
|
||||
}
|
||||
|
||||
match timeout(Duration::from_secs(timeout_secs), page.navigate_with_wait(url_str, wait_condition)).await {
|
||||
Ok(result) => result.map_err(|e| anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e))?,
|
||||
match timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
page.navigate_with_wait(url_str, wait_condition),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
result.map_err(|e| anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e))?
|
||||
}
|
||||
Err(_) => anyhow::bail!(
|
||||
"Timed out navigating to {} after {}s",
|
||||
url_str,
|
||||
@@ -709,7 +819,7 @@ async fn run_fetch(
|
||||
// and completion callbacks (e.g. testharness's add_completion_callback) run
|
||||
// before we read the page. Returns early once the loop is idle, so static
|
||||
// pages stay fast.
|
||||
page.settle(wait_secs.saturating_mul(1000)).await;
|
||||
settle_page(&mut page, wait_secs, wait_is_fixed).await;
|
||||
|
||||
let mut deferred_eval_output = None;
|
||||
let initial_controlled_scroll = if eval_at_capture_boundary {
|
||||
@@ -731,7 +841,7 @@ async fn run_fetch(
|
||||
None
|
||||
};
|
||||
if initial_controlled_scroll.is_some() {
|
||||
page.settle(wait_secs.saturating_mul(1000)).await;
|
||||
settle_page(&mut page, wait_secs, wait_is_fixed).await;
|
||||
}
|
||||
|
||||
if !eval_at_capture_boundary {
|
||||
@@ -764,7 +874,7 @@ async fn run_fetch(
|
||||
// listener) that writes the DOM. Drive the event loop again so that
|
||||
// work completes, then fall through to selector/capture/dump instead
|
||||
// of returning the still-pending eval value (issue #248).
|
||||
page.settle(wait_secs.saturating_mul(1000)).await;
|
||||
settle_page(&mut page, wait_secs, wait_is_fixed).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,7 +895,9 @@ async fn run_fetch(
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(3_000);
|
||||
let _ = page.prepare_screenshot_resources(resource_deadline_ms).await;
|
||||
let _ = page
|
||||
.prepare_screenshot_resources(resource_deadline_ms)
|
||||
.await;
|
||||
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
|
||||
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
||||
// capture below-the-fold content in one shot).
|
||||
@@ -795,8 +907,8 @@ async fn run_fetch(
|
||||
// render resources during prepare/paint, so sampling first would
|
||||
// compare pre-paint Obscura state with post-load Chromium state.
|
||||
// Keep this private opt-in out of ordinary CLI screenshots.
|
||||
let warmup_capture = std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP")
|
||||
.is_ok_and(|value| value == "1");
|
||||
let warmup_capture =
|
||||
std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP").is_ok_and(|value| value == "1");
|
||||
if warmup_capture {
|
||||
if page.screenshot(viewport).is_none() {
|
||||
anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render");
|
||||
@@ -836,9 +948,8 @@ async fn run_fetch(
|
||||
});
|
||||
if eval_at_capture_boundary {
|
||||
if let Some(ref expr) = eval {
|
||||
deferred_eval_output = Some(
|
||||
page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs))
|
||||
);
|
||||
deferred_eval_output =
|
||||
Some(page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)));
|
||||
}
|
||||
}
|
||||
let capture_state = deferred_eval_output.as_ref().map(|_| {
|
||||
@@ -896,7 +1007,11 @@ async fn run_fetch(
|
||||
);
|
||||
}
|
||||
if !quiet {
|
||||
eprintln!("Screenshot written: {} ({} bytes)", path.display(), std::fs::metadata(path).map(|m| m.len()).unwrap_or(0));
|
||||
eprintln!(
|
||||
"Screenshot written: {} ({} bytes)",
|
||||
path.display(),
|
||||
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
|
||||
);
|
||||
}
|
||||
context.save_cookies();
|
||||
return Ok(());
|
||||
@@ -958,7 +1073,11 @@ async fn fetch_original_bytes(
|
||||
user_agent: Option<String>,
|
||||
timeout_secs: u64,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
Ok(fetch_original_response(url_str, proxy, user_agent, timeout_secs).await?.body)
|
||||
Ok(
|
||||
fetch_original_response(url_str, proxy, user_agent, timeout_secs)
|
||||
.await?
|
||||
.body,
|
||||
)
|
||||
}
|
||||
|
||||
/// Read newline-delimited URLs from `path` (or stdin when `path` is `-`).
|
||||
@@ -1024,9 +1143,13 @@ async fn run_batch_fetch(
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let task_start = Instant::now();
|
||||
let result =
|
||||
fetch_original_response(&url, (*proxy).clone(), (*user_agent).clone(), timeout_secs)
|
||||
.await;
|
||||
let result = fetch_original_response(
|
||||
&url,
|
||||
(*proxy).clone(),
|
||||
(*user_agent).clone(),
|
||||
timeout_secs,
|
||||
)
|
||||
.await;
|
||||
let elapsed_ms = task_start.elapsed().as_millis();
|
||||
|
||||
let line = match result {
|
||||
@@ -1097,7 +1220,10 @@ async fn run_batch_fetch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) -> anyhow::Result<()> {
|
||||
async fn write_or_print(
|
||||
content: String,
|
||||
output: Option<&std::path::PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(path) = output {
|
||||
tokio::fs::write(path, content)
|
||||
.await
|
||||
@@ -1135,9 +1261,9 @@ async fn write_or_print_bytes(
|
||||
async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let found = page.with_dom(|dom| {
|
||||
dom.query_selector(selector).ok().flatten().is_some()
|
||||
}).unwrap_or(false);
|
||||
let found = page
|
||||
.with_dom(|dom| dom.query_selector(selector).ok().flatten().is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
if found {
|
||||
return true;
|
||||
@@ -1176,7 +1302,8 @@ fn dump_html(page: &Page) -> String {
|
||||
let doc = dom.document();
|
||||
dom.inner_html(doc)
|
||||
}
|
||||
}).unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn dump_text(page: &mut Page) -> String {
|
||||
@@ -1187,7 +1314,8 @@ fn dump_text(page: &mut Page) -> String {
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}).unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn dump_markdown(page: &mut Page) -> String {
|
||||
@@ -1249,18 +1377,47 @@ fn extract_readable_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeI
|
||||
// Boilerplate elements rarely contain content the user wants to
|
||||
// scrape — strip them so `--dump text` returns the article body
|
||||
// instead of menus, footers, and cookie banners.
|
||||
if matches!(tag, "script" | "style" | "nav" | "header" | "footer" | "aside") {
|
||||
if matches!(
|
||||
tag,
|
||||
"script" | "style" | "nav" | "header" | "footer" | "aside"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_block = matches!(
|
||||
tag,
|
||||
"div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
||||
| "li" | "tr" | "br" | "hr" | "blockquote" | "pre"
|
||||
| "section" | "article" | "header" | "footer" | "nav"
|
||||
| "main" | "aside" | "figure" | "figcaption" | "table"
|
||||
| "thead" | "tbody" | "tfoot" | "dl" | "dt" | "dd"
|
||||
| "ul" | "ol"
|
||||
"div"
|
||||
| "p"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "li"
|
||||
| "tr"
|
||||
| "br"
|
||||
| "hr"
|
||||
| "blockquote"
|
||||
| "pre"
|
||||
| "section"
|
||||
| "article"
|
||||
| "header"
|
||||
| "footer"
|
||||
| "nav"
|
||||
| "main"
|
||||
| "aside"
|
||||
| "figure"
|
||||
| "figcaption"
|
||||
| "table"
|
||||
| "thead"
|
||||
| "tbody"
|
||||
| "tfoot"
|
||||
| "dl"
|
||||
| "dt"
|
||||
| "dd"
|
||||
| "ul"
|
||||
| "ol"
|
||||
);
|
||||
|
||||
if is_block {
|
||||
@@ -1308,7 +1465,11 @@ async fn run_parallel_scrape(
|
||||
);
|
||||
}
|
||||
|
||||
let worker_name = if cfg!(windows) { "obscura-worker.exe" } else { "obscura-worker" };
|
||||
let worker_name = if cfg!(windows) {
|
||||
"obscura-worker.exe"
|
||||
} else {
|
||||
"obscura-worker"
|
||||
};
|
||||
let worker_path = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join(worker_name)))
|
||||
@@ -1382,44 +1543,10 @@ async fn run_parallel_scrape(
|
||||
};
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let worker_result: Result<serde_json::Value, String> = match timeout(worker_timeout, async {
|
||||
let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url});
|
||||
let mut line = serde_json::to_string(&nav_cmd).unwrap();
|
||||
line.push('\n');
|
||||
if stdin.write_all(line.as_bytes()).await.is_err() {
|
||||
return Err("Write failed".to_string());
|
||||
}
|
||||
if stdin.flush().await.is_err() {
|
||||
return Err("Write failed".to_string());
|
||||
}
|
||||
|
||||
let mut resp_line = String::new();
|
||||
match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
|
||||
Ok(Ok(bytes)) if bytes > 0 => {}
|
||||
Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()),
|
||||
Err(_) => return Err("timeout".to_string()),
|
||||
};
|
||||
|
||||
let nav_resp: serde_json::Value =
|
||||
serde_json::from_str(resp_line.trim()).unwrap_or(serde_json::json!({"ok": false}));
|
||||
|
||||
if !nav_resp["ok"].as_bool().unwrap_or(false) {
|
||||
return Err(
|
||||
nav_resp["error"]
|
||||
.as_str()
|
||||
.unwrap_or("navigate failed")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let title = nav_resp["result"]["title"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let eval_result = if let Some(ref expr) = *eval {
|
||||
let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr});
|
||||
let mut line = serde_json::to_string(&eval_cmd).unwrap();
|
||||
let worker_result: Result<serde_json::Value, String> =
|
||||
match timeout(worker_timeout, async {
|
||||
let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url});
|
||||
let mut line = serde_json::to_string(&nav_cmd).unwrap();
|
||||
line.push('\n');
|
||||
if stdin.write_all(line.as_bytes()).await.is_err() {
|
||||
return Err("Write failed".to_string());
|
||||
@@ -1430,39 +1557,73 @@ async fn run_parallel_scrape(
|
||||
|
||||
let mut resp_line = String::new();
|
||||
match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
|
||||
Ok(Ok(bytes)) if bytes > 0 => {
|
||||
let resp: serde_json::Value = serde_json::from_str(resp_line.trim())
|
||||
.unwrap_or(serde_json::json!({"ok": false}));
|
||||
resp["result"].clone()
|
||||
}
|
||||
Ok(Ok(bytes)) if bytes > 0 => {}
|
||||
Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()),
|
||||
Err(_) => return Err("timeout".to_string()),
|
||||
};
|
||||
|
||||
let nav_resp: serde_json::Value = serde_json::from_str(resp_line.trim())
|
||||
.unwrap_or(serde_json::json!({"ok": false}));
|
||||
|
||||
if !nav_resp["ok"].as_bool().unwrap_or(false) {
|
||||
return Err(nav_resp["error"]
|
||||
.as_str()
|
||||
.unwrap_or("navigate failed")
|
||||
.to_string());
|
||||
}
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
|
||||
let title = nav_resp["result"]["title"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let eval_result = if let Some(ref expr) = *eval {
|
||||
let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr});
|
||||
let mut line = serde_json::to_string(&eval_cmd).unwrap();
|
||||
line.push('\n');
|
||||
if stdin.write_all(line.as_bytes()).await.is_err() {
|
||||
return Err("Write failed".to_string());
|
||||
}
|
||||
if stdin.flush().await.is_err() {
|
||||
return Err("Write failed".to_string());
|
||||
}
|
||||
|
||||
let mut resp_line = String::new();
|
||||
match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
|
||||
Ok(Ok(bytes)) if bytes > 0 => {
|
||||
let resp: serde_json::Value =
|
||||
serde_json::from_str(resp_line.trim())
|
||||
.unwrap_or(serde_json::json!({"ok": false}));
|
||||
resp["result"].clone()
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()),
|
||||
Err(_) => return Err("timeout".to_string()),
|
||||
}
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
};
|
||||
|
||||
let shutdown_cmd = serde_json::json!({"cmd": "shutdown"});
|
||||
let mut line = serde_json::to_string(&shutdown_cmd).unwrap();
|
||||
line.push('\n');
|
||||
let _ = stdin.write_all(line.as_bytes()).await;
|
||||
let _ = stdin.flush().await;
|
||||
let _ = timeout(shutdown_timeout, child.wait()).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"url": url,
|
||||
"title": title,
|
||||
"eval": eval_result,
|
||||
"time_ms": task_start.elapsed().as_millis(),
|
||||
"worker": i,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("timeout".to_string()),
|
||||
};
|
||||
|
||||
let shutdown_cmd = serde_json::json!({"cmd": "shutdown"});
|
||||
let mut line = serde_json::to_string(&shutdown_cmd).unwrap();
|
||||
line.push('\n');
|
||||
let _ = stdin.write_all(line.as_bytes()).await;
|
||||
let _ = stdin.flush().await;
|
||||
let _ = timeout(shutdown_timeout, child.wait()).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"url": url,
|
||||
"title": title,
|
||||
"eval": eval_result,
|
||||
"time_ms": task_start.elapsed().as_millis(),
|
||||
"worker": i,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("timeout".to_string()),
|
||||
};
|
||||
|
||||
match worker_result {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
@@ -1537,7 +1698,9 @@ fn dump_links(page: &Page) -> String {
|
||||
let full_url = if href.starts_with("http://") || href.starts_with("https://") {
|
||||
href.clone()
|
||||
} else if let Some(ref base) = base_url {
|
||||
base.join(&href).map(|u| u.to_string()).unwrap_or(href.clone())
|
||||
base.join(&href)
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or(href.clone())
|
||||
} else {
|
||||
href.clone()
|
||||
};
|
||||
@@ -1552,7 +1715,8 @@ fn dump_links(page: &Page) -> String {
|
||||
}
|
||||
}
|
||||
rendered.join("\n")
|
||||
}).unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Selectors paired with the attribute whose URL we extract and the
|
||||
@@ -1575,7 +1739,13 @@ const ASSET_SELECTORS: &[(&str, &str, &str)] = &[
|
||||
/// Unknown / missing `rel` falls back to a generic "link" so the
|
||||
/// caller still sees the URL.
|
||||
fn link_kind_from_rel(rel: &str) -> &'static str {
|
||||
match rel.split_ascii_whitespace().next().unwrap_or("").to_ascii_lowercase().as_str() {
|
||||
match rel
|
||||
.split_ascii_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"stylesheet" => "stylesheet",
|
||||
"icon" | "shortcut" => "icon",
|
||||
"manifest" => "manifest",
|
||||
@@ -1616,9 +1786,13 @@ fn extract_assets(dom: &obscura_dom::DomTree, base_url: Option<&url::Url>) -> St
|
||||
for (selector, attr, default_kind) in ASSET_SELECTORS {
|
||||
let nodes = dom.query_selector_all(selector).unwrap_or_default();
|
||||
for node_id in nodes {
|
||||
let Some(node) = dom.get_node(node_id) else { continue };
|
||||
let Some(node) = dom.get_node(node_id) else {
|
||||
continue;
|
||||
};
|
||||
let raw = node.get_attribute(attr).unwrap_or_default().to_string();
|
||||
let Some(url) = resolve_asset_url(&raw, base_url) else { continue };
|
||||
let Some(url) = resolve_asset_url(&raw, base_url) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let kind = if *default_kind == "link" {
|
||||
let rel = node.get_attribute("rel").unwrap_or_default().to_string();
|
||||
@@ -1643,8 +1817,11 @@ fn dump_assets(page: &Page) -> String {
|
||||
.with_dom(|dom| extract_assets(dom, base_url.as_ref()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut lines: Vec<String> =
|
||||
dom_ndjson.lines().filter(|l| !l.is_empty()).map(|l| l.to_string()).collect();
|
||||
let mut lines: Vec<String> = dom_ndjson
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
|
||||
// URLs already listed from static DOM attributes, so a resource the script
|
||||
// fetches that the markup also references is not emitted twice.
|
||||
@@ -1669,9 +1846,9 @@ fn dump_assets(page: &Page) -> String {
|
||||
mod tests {
|
||||
use super::{
|
||||
effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes,
|
||||
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags,
|
||||
read_urls_from_file, resolve_asset_url, select_log_filter, write_or_print,
|
||||
write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS,
|
||||
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags, read_urls_from_file,
|
||||
resolve_asset_url, select_log_filter, write_or_print, write_or_print_bytes, Args, Command,
|
||||
DumpFormat, DEFAULT_V8_FLAGS,
|
||||
};
|
||||
use clap::Parser;
|
||||
use obscura_dom::parse_html;
|
||||
@@ -1720,7 +1897,13 @@ mod tests {
|
||||
])
|
||||
.expect("clap should accept --file with --concurrency and no positional URL");
|
||||
match args.command {
|
||||
Some(Command::Fetch { url, file, concurrency, dump, .. }) => {
|
||||
Some(Command::Fetch {
|
||||
url,
|
||||
file,
|
||||
concurrency,
|
||||
dump,
|
||||
..
|
||||
}) => {
|
||||
assert!(url.is_none());
|
||||
assert_eq!(file, Some(std::path::PathBuf::from("urls.txt")));
|
||||
assert_eq!(concurrency.get(), 25);
|
||||
@@ -1734,7 +1917,8 @@ mod tests {
|
||||
fn concurrency_rejects_zero() {
|
||||
// NonZeroUsize means --concurrency 0 is a parse error, not a silent hang
|
||||
// on a zero-permit semaphore.
|
||||
let err = Args::try_parse_from(["obscura", "fetch", "--file", "u.txt", "--concurrency", "0"]);
|
||||
let err =
|
||||
Args::try_parse_from(["obscura", "fetch", "--file", "u.txt", "--concurrency", "0"]);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
@@ -1788,7 +1972,10 @@ mod tests {
|
||||
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
|
||||
assert_eq!(bytes, PNG_BYTES, "raw response body must match the file byte-for-byte");
|
||||
assert_eq!(
|
||||
bytes, PNG_BYTES,
|
||||
"raw response body must match the file byte-for-byte"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -1811,7 +1998,10 @@ mod tests {
|
||||
let read_back = tokio::fs::read(&path).await.expect("read back");
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
|
||||
assert_eq!(read_back, payload, "file bytes must match the payload exactly");
|
||||
assert_eq!(
|
||||
read_back, payload,
|
||||
"file bytes must match the payload exactly"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -1856,13 +2046,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parsed_fetch_with_quiet_flag_is_detected() {
|
||||
let args = Args::try_parse_from([
|
||||
"obscura",
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"https://example.com",
|
||||
])
|
||||
.expect("clap should accept --quiet on fetch");
|
||||
let args = Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"])
|
||||
.expect("clap should accept --quiet on fetch");
|
||||
assert!(is_quiet_command(&args.command));
|
||||
}
|
||||
|
||||
@@ -1875,8 +2060,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parsed_serve_command_is_not_quiet() {
|
||||
let args = Args::try_parse_from(["obscura", "serve"])
|
||||
.expect("clap should accept serve");
|
||||
let args = Args::try_parse_from(["obscura", "serve"]).expect("clap should accept serve");
|
||||
assert!(!is_quiet_command(&args.command));
|
||||
}
|
||||
|
||||
@@ -1938,14 +2122,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parsed_v8_flags_empty_string_is_accepted() {
|
||||
let args = Args::try_parse_from([
|
||||
"obscura",
|
||||
"--v8-flags",
|
||||
"",
|
||||
"fetch",
|
||||
"https://example.com",
|
||||
])
|
||||
.expect("clap should accept empty --v8-flags value");
|
||||
let args =
|
||||
Args::try_parse_from(["obscura", "--v8-flags", "", "fetch", "https://example.com"])
|
||||
.expect("clap should accept empty --v8-flags value");
|
||||
assert_eq!(args.v8_flags.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
@@ -2001,17 +2180,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parsed_fetch_quiet_resolves_to_off_filter() {
|
||||
let args = Args::try_parse_from([
|
||||
"obscura",
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"https://example.com",
|
||||
])
|
||||
.unwrap();
|
||||
let args =
|
||||
Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"]).unwrap();
|
||||
let filter = select_log_filter(args.verbose, is_quiet_command(&args.command));
|
||||
assert_eq!(filter, "off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_wait_distinguishes_adaptive_default_from_fixed_delay() {
|
||||
let default = Args::try_parse_from(["obscura", "fetch", "https://example.com"]).unwrap();
|
||||
match default.command {
|
||||
Some(Command::Fetch { wait, .. }) => assert_eq!(wait, None),
|
||||
_ => panic!("expected Fetch command"),
|
||||
}
|
||||
|
||||
let fixed =
|
||||
Args::try_parse_from(["obscura", "fetch", "https://example.com", "--wait", "0"])
|
||||
.unwrap();
|
||||
match fixed.command {
|
||||
Some(Command::Fetch { wait, .. }) => assert_eq!(wait, Some(0)),
|
||||
_ => panic!("expected Fetch command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_still_uses_fetch_variant() {
|
||||
let cmd = Some(Command::Fetch {
|
||||
@@ -2020,7 +2211,7 @@ mod tests {
|
||||
selector: None,
|
||||
file: None,
|
||||
concurrency: std::num::NonZeroUsize::new(1).unwrap(),
|
||||
wait: 5,
|
||||
wait: Some(5),
|
||||
timeout: 30,
|
||||
wait_until: "load".to_string(),
|
||||
user_agent: None,
|
||||
@@ -2040,7 +2231,10 @@ mod tests {
|
||||
.ok()
|
||||
.flatten()
|
||||
.expect("body must exist");
|
||||
extract_readable_text(&dom, body).split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
extract_readable_text(&dom, body)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2267,5 +2461,4 @@ mod tests {
|
||||
assert!(lines[0].contains("\"https://example.test/ok.html\""));
|
||||
assert!(lines[0].contains("\"iframe\""));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,8 +9,8 @@ use selectors::matching::{
|
||||
NeedsSelectorFlags,
|
||||
};
|
||||
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
|
||||
use selectors::{Element, OpaqueElement, SelectorList};
|
||||
use selectors::visitor::SelectorVisitor;
|
||||
use selectors::{Element, OpaqueElement, SelectorList};
|
||||
|
||||
use crate::tree::{DomTree, NodeData, NodeId};
|
||||
|
||||
@@ -251,7 +251,13 @@ impl<'a> DomElement<'a> {
|
||||
.map(|name| {
|
||||
matches!(
|
||||
name.local.as_ref(),
|
||||
"input" | "button" | "select" | "textarea" | "optgroup" | "option" | "fieldset"
|
||||
"input"
|
||||
| "button"
|
||||
| "select"
|
||||
| "textarea"
|
||||
| "optgroup"
|
||||
| "option"
|
||||
| "fieldset"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
@@ -390,20 +396,26 @@ impl<'a> Element for DomElement<'a> {
|
||||
fn has_namespace(&self, ns: &CssNamespace) -> bool {
|
||||
self.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.as_element()
|
||||
.map(|name| name.ns == ns.0)
|
||||
.unwrap_or(false)
|
||||
n.as_element().map(|name| name.ns == ns.0).unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_same_type(&self, other: &Self) -> bool {
|
||||
let self_name = self.tree.with_node(self.node_id, |n| {
|
||||
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
||||
}).flatten();
|
||||
let other_name = self.tree.with_node(other.node_id, |n| {
|
||||
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
||||
}).flatten();
|
||||
let self_name = self
|
||||
.tree
|
||||
.with_node(self.node_id, |n| {
|
||||
n.as_element()
|
||||
.map(|name| (name.local.clone(), name.ns.clone()))
|
||||
})
|
||||
.flatten();
|
||||
let other_name = self
|
||||
.tree
|
||||
.with_node(other.node_id, |n| {
|
||||
n.as_element()
|
||||
.map(|name| (name.local.clone(), name.ns.clone()))
|
||||
})
|
||||
.flatten();
|
||||
match (self_name, other_name) {
|
||||
(Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
|
||||
_ => false,
|
||||
@@ -441,10 +453,9 @@ impl<'a> Element for DomElement<'a> {
|
||||
.with_node(self.node_id, |node| {
|
||||
node.attrs()
|
||||
.map(|attrs| {
|
||||
attrs.iter().any(|a| {
|
||||
a.name.ns == html5ever::ns!()
|
||||
&& a.name.local == local_name.0
|
||||
})
|
||||
attrs
|
||||
.iter()
|
||||
.any(|a| a.name.ns == html5ever::ns!() && a.name.local == local_name.0)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
@@ -469,7 +480,9 @@ impl<'a> Element for DomElement<'a> {
|
||||
// correctly are) made every such base rule silently inert.
|
||||
PseudoClass::Enabled => self.is_form_control() && !self.has_boolean_attr("disabled"),
|
||||
PseudoClass::Disabled => self.is_form_control() && self.has_boolean_attr("disabled"),
|
||||
PseudoClass::Checked => self.has_boolean_attr("checked") || self.has_boolean_attr("selected"),
|
||||
PseudoClass::Checked => {
|
||||
self.has_boolean_attr("checked") || self.has_boolean_attr("selected")
|
||||
}
|
||||
// Dynamic user-interaction pseudo-classes have no meaning against
|
||||
// a static DOM snapshot with no live user input.
|
||||
PseudoClass::Hover
|
||||
@@ -640,7 +653,10 @@ fn simple_id_selector(selector: &str) -> Option<&str> {
|
||||
if !(first.is_ascii_alphabetic() || first == b'_') {
|
||||
return None;
|
||||
}
|
||||
if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
|
||||
if id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
{
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
@@ -656,7 +672,11 @@ impl DomTree {
|
||||
self.query_selector_all_from(self.document(), selector)
|
||||
}
|
||||
|
||||
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result<Option<NodeId>, String> {
|
||||
pub fn query_selector_from(
|
||||
&self,
|
||||
root: NodeId,
|
||||
selector: &str,
|
||||
) -> Result<Option<NodeId>, String> {
|
||||
// Fast path: a bare "#id" selector resolves through the id index in O(1)
|
||||
// instead of scanning every descendant. The index holds the first element
|
||||
// in tree order per id, which is exactly what the full scan would return.
|
||||
@@ -714,7 +734,11 @@ impl DomTree {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> {
|
||||
pub fn query_selector_all_from(
|
||||
&self,
|
||||
root: NodeId,
|
||||
selector: &str,
|
||||
) -> Result<Vec<NodeId>, String> {
|
||||
let selector_list = parse_selector(selector)?;
|
||||
let mut caches = selectors::context::SelectorCaches::default();
|
||||
let mut context = MatchingContext::new(
|
||||
@@ -746,7 +770,10 @@ impl DomTree {
|
||||
/// Test one element as the selector subject, including when it is
|
||||
/// detached or is a direct child of a shadow-tree compatibility root.
|
||||
pub fn matches_selector(&self, nid: NodeId, selector: &str) -> Result<bool, String> {
|
||||
if !self.with_node(nid, |node| node.is_element()).unwrap_or(false) {
|
||||
if !self
|
||||
.with_node(nid, |node| node.is_element())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let selector_list = parse_selector(selector)?;
|
||||
@@ -768,7 +795,7 @@ impl DomTree {
|
||||
|
||||
/// Parse a single selector once for repeated single-element matching, and
|
||||
/// precompute its specificity, ancestor hashes, and "subject key" (the
|
||||
/// rightmost id/class/tag used to bucket the rule for fast candidate
|
||||
/// rightmost id/class/attribute/tag used to bucket the rule for fast candidate
|
||||
/// lookup). Returns `None` if the selector fails to parse.
|
||||
///
|
||||
/// This is the primitive a stylesheet cascade builds on: compile every rule
|
||||
@@ -778,9 +805,14 @@ impl DomTree {
|
||||
let list = parse_selector(selector).ok()?;
|
||||
let sel = list.slice().first()?.clone();
|
||||
let specificity = sel.specificity();
|
||||
let key = subject_key(&sel);
|
||||
let keys = SubjectKeys::from_vec(subject_keys(&sel));
|
||||
let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks);
|
||||
Some(CompiledSelector { sel, specificity, key, hashes })
|
||||
Some(CompiledSelector {
|
||||
sel,
|
||||
specificity,
|
||||
keys,
|
||||
hashes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Does a single element match a precompiled selector? Allocates a fresh
|
||||
@@ -798,6 +830,8 @@ impl DomTree {
|
||||
Matcher {
|
||||
caches: selectors::context::SelectorCaches::default(),
|
||||
ancestors: AncestorFilter::new(),
|
||||
candidate_generation: 0,
|
||||
candidate_seen: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -809,9 +843,41 @@ impl DomTree {
|
||||
pub struct Matcher {
|
||||
caches: selectors::context::SelectorCaches,
|
||||
ancestors: AncestorFilter,
|
||||
candidate_generation: u32,
|
||||
candidate_seen: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
/// Start one indexed-candidate collection without clearing the reusable
|
||||
/// seen table. Stylesheet rules that live in several selector buckets use
|
||||
/// this to avoid matching and cascading the same rule twice.
|
||||
#[doc(hidden)]
|
||||
pub fn begin_candidate_collection(&mut self, candidate_count: usize) {
|
||||
self.candidate_generation = self.candidate_generation.wrapping_add(1);
|
||||
if self.candidate_generation == 0 {
|
||||
self.candidate_seen.fill(0);
|
||||
self.candidate_generation = 1;
|
||||
}
|
||||
self.candidate_seen.resize(candidate_count, 0);
|
||||
}
|
||||
|
||||
/// Return true once per candidate index in the current collection.
|
||||
#[doc(hidden)]
|
||||
pub fn mark_candidate(&mut self, index: usize) -> bool {
|
||||
debug_assert!(index < self.candidate_seen.len());
|
||||
let Some(seen) = self.candidate_seen.get_mut(index) else {
|
||||
// Candidate collection is an optimization. If a future caller
|
||||
// supplies inconsistent bounds, fail open to an extra match rather
|
||||
// than silently dropping authored CSS.
|
||||
return true;
|
||||
};
|
||||
if *seen == self.candidate_generation {
|
||||
return false;
|
||||
}
|
||||
*seen = self.candidate_generation;
|
||||
true
|
||||
}
|
||||
|
||||
/// Match `nid` (matched as if it were the subject element; the ancestor
|
||||
/// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`.
|
||||
pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool {
|
||||
@@ -827,7 +893,13 @@ impl Matcher {
|
||||
MatchingForInvalidation::No,
|
||||
);
|
||||
let element = DomElement::new(tree, nid);
|
||||
selectors::matching::matches_selector(&compiled.sel, 0, Some(&compiled.hashes), &element, &mut context)
|
||||
selectors::matching::matches_selector(
|
||||
&compiled.sel,
|
||||
0,
|
||||
Some(&compiled.hashes),
|
||||
&element,
|
||||
&mut context,
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `nid`'s hashes onto the ancestor filter before descending into its
|
||||
@@ -854,7 +926,10 @@ struct AncestorFilter {
|
||||
|
||||
impl AncestorFilter {
|
||||
fn new() -> Self {
|
||||
AncestorFilter { filter: Box::default(), levels: Vec::new() }
|
||||
AncestorFilter {
|
||||
filter: Box::default(),
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(&self) -> &selectors::bloom::BloomFilter {
|
||||
@@ -865,13 +940,25 @@ impl AncestorFilter {
|
||||
let mut hashes = Vec::new();
|
||||
tree.with_node(nid, |node| {
|
||||
if let Some(elem) = node.as_element() {
|
||||
push_hash(&mut hashes, &mut self.filter, CssLocalName(elem.local.clone()).precomputed_hash());
|
||||
push_hash(
|
||||
&mut hashes,
|
||||
&mut self.filter,
|
||||
CssLocalName(elem.local.clone()).precomputed_hash(),
|
||||
);
|
||||
if let Some(id) = node.get_attribute("id") {
|
||||
push_hash(&mut hashes, &mut self.filter, CssString(id.to_string()).precomputed_hash());
|
||||
push_hash(
|
||||
&mut hashes,
|
||||
&mut self.filter,
|
||||
CssString(id.to_string()).precomputed_hash(),
|
||||
);
|
||||
}
|
||||
if let Some(class) = node.get_attribute("class") {
|
||||
for c in class.split_whitespace() {
|
||||
push_hash(&mut hashes, &mut self.filter, CssString(c.to_string()).precomputed_hash());
|
||||
push_hash(
|
||||
&mut hashes,
|
||||
&mut self.filter,
|
||||
CssString(c.to_string()).precomputed_hash(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,8 +985,13 @@ fn push_hash(hashes: &mut Vec<u32>, filter: &mut selectors::bloom::BloomFilter,
|
||||
/// only tested against elements that carry its key.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum SelectorKey {
|
||||
/// The document element. `:root` can match only this one subject, so it is
|
||||
/// substantially more selective than the universal bucket despite having
|
||||
/// no id/class/tag token.
|
||||
Root,
|
||||
Id(String),
|
||||
Class(String),
|
||||
Attribute(String),
|
||||
Local(String),
|
||||
Universal,
|
||||
}
|
||||
@@ -908,51 +1000,359 @@ pub enum SelectorKey {
|
||||
pub struct CompiledSelector {
|
||||
sel: parser::Selector<ObscuraSelector>,
|
||||
specificity: u32,
|
||||
key: SelectorKey,
|
||||
keys: SubjectKeys,
|
||||
hashes: parser::AncestorHashes,
|
||||
}
|
||||
|
||||
impl CompiledSelector {
|
||||
/// The one bucket usable by legacy selector indexes. Selectors whose
|
||||
/// subject has multiple disjoint alternatives deliberately report
|
||||
/// `Universal`; use [`Self::candidate_keys`] to opt into multi-bucket
|
||||
/// indexing.
|
||||
pub fn key(&self) -> &SelectorKey {
|
||||
&self.key
|
||||
match &self.keys {
|
||||
SubjectKeys::One(key) => key,
|
||||
// Existing single-bucket consumers must keep treating disjoint
|
||||
// alternatives as universal until they opt into `candidate_keys`.
|
||||
SubjectKeys::Many(_) => &SelectorKey::Universal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduplicated buckets that together cover every element this selector
|
||||
/// can match. A consumer must insert the rule in every returned bucket and
|
||||
/// deduplicate rule candidates before matching, since one element can
|
||||
/// carry keys from more than one alternative.
|
||||
pub fn candidate_keys(&self) -> &[SelectorKey] {
|
||||
match &self.keys {
|
||||
SubjectKeys::One(key) => std::slice::from_ref(key),
|
||||
SubjectKeys::Many(keys) => keys,
|
||||
}
|
||||
}
|
||||
pub fn specificity(&self) -> u32 {
|
||||
self.specificity
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the index key from a selector's rightmost compound: prefer id, then
|
||||
/// class, then local name; fall back to Universal for `*`, attribute-only, or
|
||||
/// pseudo-only subjects.
|
||||
fn subject_key(sel: &parser::Selector<ObscuraSelector>) -> SelectorKey {
|
||||
enum SubjectKeys {
|
||||
One(SelectorKey),
|
||||
Many(Box<[SelectorKey]>),
|
||||
}
|
||||
|
||||
impl SubjectKeys {
|
||||
fn from_vec(mut keys: Vec<SelectorKey>) -> Self {
|
||||
if keys.len() == 1 {
|
||||
Self::One(keys.pop().unwrap())
|
||||
} else {
|
||||
Self::Many(keys.into_boxed_slice())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_rank(key: &SelectorKey) -> u8 {
|
||||
match key {
|
||||
SelectorKey::Universal => 0,
|
||||
SelectorKey::Local(_) => 1,
|
||||
SelectorKey::Attribute(_) => 2,
|
||||
SelectorKey::Class(_) => 3,
|
||||
SelectorKey::Id(_) => 4,
|
||||
SelectorKey::Root => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique_key(keys: &mut Vec<SelectorKey>, key: SelectorKey) {
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Candidate buckets for a selector's rightmost compound. A single ordinary
|
||||
/// id/class/local selector keeps the allocation-free common path. A pure
|
||||
/// `:is()`/`:where()` subject returns the deduplicated union of its arms, but
|
||||
/// only when every arm has a concrete key; one universal/pseudo-only
|
||||
/// arm makes the whole set universal because that arm can match an element
|
||||
/// carrying none of the other keys.
|
||||
///
|
||||
/// When an outer compound key exists, disjoint functional-pseudo keys replace
|
||||
/// it only if every arm is strictly more selective. This is the same
|
||||
/// conservative contract as Gecko's `SelectorMap::find_bucket`: for
|
||||
/// `.control:is(button, #save)` keep `.control`, while
|
||||
/// `.control:is(#save, #cancel)` may use the two id buckets.
|
||||
fn subject_keys(sel: &parser::Selector<ObscuraSelector>) -> Vec<SelectorKey> {
|
||||
use parser::Component;
|
||||
let mut local: Option<String> = None;
|
||||
let mut attribute: Option<String> = None;
|
||||
let mut class: Option<String> = None;
|
||||
let mut id: Option<String> = None;
|
||||
let mut root = false;
|
||||
let mut disjoint_sets: Vec<Vec<SelectorKey>> = Vec::new();
|
||||
for comp in sel.iter_raw_match_order() {
|
||||
match comp {
|
||||
Component::Combinator(_) => break,
|
||||
Component::ID(v) => id = Some(v.0.clone()),
|
||||
Component::Root => root = true,
|
||||
Component::Class(v) => class = Some(v.0.clone()),
|
||||
Component::LocalName(l) => local = Some(l.lower_name.0.to_string()),
|
||||
Component::AttributeInNoNamespaceExists {
|
||||
local_name_lower, ..
|
||||
} => attribute = Some(local_name_lower.0.to_string()),
|
||||
Component::AttributeInNoNamespace { local_name, .. } => {
|
||||
attribute = Some(local_name.0.to_string())
|
||||
}
|
||||
Component::AttributeOther(selector) => {
|
||||
attribute = Some(selector.local_name_lower.0.to_string())
|
||||
}
|
||||
Component::Is(list) | Component::Where(list) => {
|
||||
let mut keys = Vec::new();
|
||||
let mut universal = false;
|
||||
for alternative in list.slice() {
|
||||
let alternative_keys = subject_keys(alternative);
|
||||
if alternative_keys
|
||||
.iter()
|
||||
.any(|key| matches!(key, SelectorKey::Universal))
|
||||
{
|
||||
universal = true;
|
||||
break;
|
||||
}
|
||||
for key in alternative_keys {
|
||||
push_unique_key(&mut keys, key);
|
||||
}
|
||||
}
|
||||
if universal || keys.is_empty() {
|
||||
keys.clear();
|
||||
keys.push(SelectorKey::Universal);
|
||||
}
|
||||
disjoint_sets.push(keys);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(i) = id {
|
||||
let direct = if root {
|
||||
SelectorKey::Root
|
||||
} else if let Some(i) = id {
|
||||
SelectorKey::Id(i)
|
||||
} else if let Some(c) = class {
|
||||
SelectorKey::Class(c)
|
||||
} else if let Some(a) = attribute {
|
||||
SelectorKey::Attribute(a)
|
||||
} else if let Some(l) = local {
|
||||
SelectorKey::Local(l)
|
||||
} else {
|
||||
SelectorKey::Universal
|
||||
};
|
||||
|
||||
let direct_rank = key_rank(&direct);
|
||||
let mut best: Option<Vec<SelectorKey>> = None;
|
||||
for keys in disjoint_sets {
|
||||
if keys.iter().any(|key| key_rank(key) <= direct_rank) {
|
||||
continue;
|
||||
}
|
||||
let min_rank = keys.iter().map(key_rank).min().unwrap_or(0);
|
||||
let replaces = best.as_ref().is_none_or(|current| {
|
||||
let current_min = current.iter().map(key_rank).min().unwrap_or(0);
|
||||
min_rank > current_min || (min_rank == current_min && keys.len() < current.len())
|
||||
});
|
||||
if replaces {
|
||||
best = Some(keys);
|
||||
}
|
||||
}
|
||||
best.unwrap_or_else(|| vec![direct])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tree_sink::parse_html;
|
||||
|
||||
use super::SelectorKey;
|
||||
|
||||
fn candidate_keys(tree: &crate::tree::DomTree, selector: &str) -> Vec<SelectorKey> {
|
||||
tree.compile_rule_selector(selector)
|
||||
.unwrap_or_else(|| panic!("failed to compile {selector}"))
|
||||
.candidate_keys()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
fn element_has_selector_key(
|
||||
tree: &crate::tree::DomTree,
|
||||
node: crate::tree::NodeId,
|
||||
key: &SelectorKey,
|
||||
) -> bool {
|
||||
let node = tree.get_node(node).expect("matched node must exist");
|
||||
match key {
|
||||
SelectorKey::Root => node
|
||||
.parent
|
||||
.and_then(|parent| tree.get_node(parent))
|
||||
.is_some_and(|parent| parent.is_document()),
|
||||
SelectorKey::Universal => true,
|
||||
SelectorKey::Id(id) => node.get_attribute("id") == Some(id.as_str()),
|
||||
SelectorKey::Class(class) => node.get_attribute("class").is_some_and(|classes| {
|
||||
classes.split_ascii_whitespace().any(|value| value == class)
|
||||
}),
|
||||
SelectorKey::Attribute(attribute) => node.get_attribute(attribute).is_some(),
|
||||
SelectorKey::Local(local) => node
|
||||
.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == local),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_expand_rightmost_is_and_where() {
|
||||
let tree = parse_html("<main></main>");
|
||||
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(.IssueLabel, .Label):hover"),
|
||||
vec![
|
||||
SelectorKey::Class("IssueLabel".into()),
|
||||
SelectorKey::Class("Label".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":where(button, input, select)"),
|
||||
vec![
|
||||
SelectorKey::Local("button".into()),
|
||||
SelectorKey::Local("input".into()),
|
||||
SelectorKey::Local("select".into()),
|
||||
]
|
||||
);
|
||||
|
||||
// A legacy single-bucket index stays correct until it explicitly opts
|
||||
// into inserting all candidate keys.
|
||||
let compiled = tree
|
||||
.compile_rule_selector(":is(.IssueLabel, .Label):hover")
|
||||
.unwrap();
|
||||
assert_eq!(compiled.key(), &SelectorKey::Universal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_deduplicate_equivalent_arms() {
|
||||
let tree = parse_html("<main></main>");
|
||||
let compiled = tree
|
||||
.compile_rule_selector(":is(.btn, .btn:hover, :where(.btn))")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
compiled.candidate_keys(),
|
||||
&[SelectorKey::Class("btn".into())]
|
||||
);
|
||||
assert_eq!(compiled.key(), &SelectorKey::Class("btn".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_index_attributes_and_fall_back_for_pseudos() {
|
||||
let tree = parse_html("<main></main>");
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(.btn, [data-kind])"),
|
||||
vec![
|
||||
SelectorKey::Class("btn".into()),
|
||||
SelectorKey::Attribute("data-kind".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(.btn, :first-child)"),
|
||||
vec![SelectorKey::Universal]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_index_root_subjects_and_functional_arms() {
|
||||
let tree = parse_html("<html><body><main class=app></main></body></html>");
|
||||
assert_eq!(candidate_keys(&tree, ":root"), vec![SelectorKey::Root]);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":root[data-color-mode]"),
|
||||
vec![SelectorKey::Root],
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ":is(:root, .app)"),
|
||||
vec![SelectorKey::Root, SelectorKey::Class("app".into())],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_only_replace_outer_key_when_more_selective() {
|
||||
let tree = parse_html("<main></main>");
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ".control:is(#save, #cancel)"),
|
||||
vec![
|
||||
SelectorKey::Id("save".into()),
|
||||
SelectorKey::Id("cancel".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ".control:is(button, #save)"),
|
||||
vec![SelectorKey::Class("control".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_use_subjects_of_complex_is_arms() {
|
||||
let tree = parse_html("<main></main>");
|
||||
assert_eq!(
|
||||
candidate_keys(&tree, ".scope :is(.a > .x, .b > .y)"),
|
||||
vec![
|
||||
SelectorKey::Class("x".into()),
|
||||
SelectorKey::Class("y".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_candidate_keys_cover_every_actual_match() {
|
||||
let tree = parse_html(
|
||||
r#"<section class="scope">
|
||||
<div class="alpha control" id="save" data-kind="x"></div>
|
||||
<button class="beta control"></button>
|
||||
<input class="control" id="cancel">
|
||||
<div class="a"><span class="x"></span></div>
|
||||
<div class="b"><span class="y"></span></div>
|
||||
</section>"#,
|
||||
);
|
||||
let selectors = [
|
||||
":is(.alpha, .beta)",
|
||||
":where(button, input)",
|
||||
".control:is(#save, #cancel)",
|
||||
".control:is(button, #save)",
|
||||
":is(.alpha, [data-kind])",
|
||||
":is(:root, .control)",
|
||||
".scope :is(.a > .x, .b > .y)",
|
||||
];
|
||||
|
||||
for selector in selectors {
|
||||
let keys = candidate_keys(&tree, selector);
|
||||
let matches = tree.query_selector_all(selector).unwrap();
|
||||
assert!(!matches.is_empty(), "fixture must exercise {selector}");
|
||||
for matched in matches {
|
||||
assert!(
|
||||
keys.iter()
|
||||
.any(|key| element_has_selector_key(&tree, matched, key)),
|
||||
"{selector} matched an element outside all candidate buckets: {keys:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_candidate_collection_deduplicates_and_reuses_indices() {
|
||||
let tree = parse_html("<main></main>");
|
||||
let mut matcher = tree.matcher();
|
||||
|
||||
matcher.begin_candidate_collection(4);
|
||||
assert!(matcher.mark_candidate(2));
|
||||
assert!(!matcher.mark_candidate(2));
|
||||
|
||||
matcher.begin_candidate_collection(4);
|
||||
assert!(matcher.mark_candidate(2));
|
||||
assert!(!matcher.mark_candidate(2));
|
||||
|
||||
matcher.begin_candidate_collection(9);
|
||||
assert!(matcher.mark_candidate(8));
|
||||
assert!(!matcher.mark_candidate(8));
|
||||
|
||||
matcher.candidate_generation = u32::MAX;
|
||||
matcher.begin_candidate_collection(9);
|
||||
assert_eq!(matcher.candidate_generation, 1);
|
||||
assert!(matcher.mark_candidate(2));
|
||||
assert!(!matcher.mark_candidate(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_tag() {
|
||||
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
|
||||
@@ -964,8 +1364,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_class() {
|
||||
let tree =
|
||||
parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
|
||||
let tree = parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
|
||||
let result = tree.query_selector(".foo").unwrap();
|
||||
assert!(result.is_some());
|
||||
let node = tree.get_node(result.unwrap()).unwrap();
|
||||
@@ -989,7 +1388,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_has_parses() {
|
||||
// Isolate parse from match: :has must parse, not error.
|
||||
assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse");
|
||||
assert!(
|
||||
super::parse_selector("a:has(p.bt)").is_ok(),
|
||||
":has failed to parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1005,9 +1407,17 @@ mod tests {
|
||||
fn test_enabled_matches_form_control_without_disabled_attr() {
|
||||
let tree = parse_html(r#"<button>Click</button><button disabled>Nope</button>"#);
|
||||
let enabled = tree.query_selector_all("button:enabled").unwrap();
|
||||
assert_eq!(enabled.len(), 1, ":enabled should match only the non-disabled button");
|
||||
assert_eq!(
|
||||
enabled.len(),
|
||||
1,
|
||||
":enabled should match only the non-disabled button"
|
||||
);
|
||||
let disabled = tree.query_selector_all("button:disabled").unwrap();
|
||||
assert_eq!(disabled.len(), 1, ":disabled should match only the disabled button");
|
||||
assert_eq!(
|
||||
disabled.len(),
|
||||
1,
|
||||
":disabled should match only the disabled button"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1021,33 +1431,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_checked_matches_checked_attribute() {
|
||||
let tree = parse_html(
|
||||
r#"<input type="checkbox" checked><input type="checkbox">"#,
|
||||
);
|
||||
let tree = parse_html(r#"<input type="checkbox" checked><input type="checkbox">"#);
|
||||
assert_eq!(tree.query_selector_all("input:checked").unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfocused_snapshot_matches_focus_negation_selectors() {
|
||||
let tree = parse_html(
|
||||
r#"<div class="visually-hidden-focusable">Skip</div>"#,
|
||||
);
|
||||
let tree = parse_html(r#"<div class="visually-hidden-focusable">Skip</div>"#);
|
||||
let hidden = tree
|
||||
.query_selector_all(
|
||||
".visually-hidden-focusable:not(:focus):not(:focus-within)",
|
||||
)
|
||||
.query_selector_all(".visually-hidden-focusable:not(:focus):not(:focus-within)")
|
||||
.unwrap();
|
||||
assert_eq!(hidden.len(), 1);
|
||||
assert_eq!(
|
||||
tree.query_selector_all(":focus-visible").unwrap().len(),
|
||||
0
|
||||
);
|
||||
assert_eq!(tree.query_selector_all(":focus-visible").unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_descendant() {
|
||||
let tree =
|
||||
parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
|
||||
let tree = parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
|
||||
let result = tree.query_selector("#outer span").unwrap();
|
||||
assert!(result.is_some());
|
||||
let node = tree.get_node(result.unwrap()).unwrap();
|
||||
@@ -1072,9 +1472,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_attribute() {
|
||||
let tree = parse_html(
|
||||
r#"<input type="text" name="user"><input type="password" name="pass">"#,
|
||||
);
|
||||
let tree =
|
||||
parse_html(r#"<input type="text" name="user"><input type="password" name="pass">"#);
|
||||
let result = tree.query_selector(r#"input[type="password"]"#).unwrap();
|
||||
assert!(result.is_some());
|
||||
let node = tree.get_node(result.unwrap()).unwrap();
|
||||
@@ -1151,13 +1550,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_from_returns_first_in_subtree_only() {
|
||||
let tree = parse_html(
|
||||
r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#,
|
||||
);
|
||||
let tree =
|
||||
parse_html(r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#);
|
||||
let s = tree.get_element_by_id("s").expect("section#s");
|
||||
|
||||
// Scoped to #s: skip the outside paragraph; return the first inside.
|
||||
let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside");
|
||||
let first_in_s = tree
|
||||
.query_selector_from(s, "p")
|
||||
.unwrap()
|
||||
.expect("a p inside");
|
||||
assert_eq!(tree.text_content(first_in_s), "first");
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
# document.domain must reject public suffixes (including private suffixes such
|
||||
# as github.io), not just compare dotted strings. `psl` embeds the Mozilla
|
||||
# Public Suffix List and adds no runtime I/O.
|
||||
psl = "2"
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
Vendored
+400
-50
@@ -87,8 +87,6 @@ const _DOM_MUTATION_COMMANDS = new Set([
|
||||
"set_attribute", "remove_attribute",
|
||||
"set_text_content", "set_inner_html", "set_inner_html_context",
|
||||
"set_fragment_html_executable",
|
||||
"create_element", "create_text_node", "create_comment_node",
|
||||
"create_document_fragment", "clone_node",
|
||||
]);
|
||||
const _dom = (cmd, a1, a2) => {
|
||||
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
|
||||
@@ -622,11 +620,27 @@ globalThis.console = {
|
||||
let _tid = 0;
|
||||
const _clearedTimers = new Set();
|
||||
const _intervals = new Set();
|
||||
const _nativeTimerIds = new Map();
|
||||
const __obscuraPendingTimeoutDeadlines = new Map();
|
||||
|
||||
const _scheduleAfter = (delay, fn) => {
|
||||
const d = Math.max(0, Number(delay) || 0);
|
||||
if (d === 0) Promise.resolve().then(fn);
|
||||
else Deno.core.ops.op_sleep(d).then(fn);
|
||||
// HTML timers queue tasks even when their delay is zero. Treating a
|
||||
// zero-delay timer as a Promise reaction turns recursive framework
|
||||
// schedulers into an unbounded microtask checkpoint: timers and networking
|
||||
// never regain control and V8 can burn seconds before navigation completes.
|
||||
// deno_core's timer queue requires a Tokio reactor even to enqueue. Some
|
||||
// low-level embedders intentionally do a synchronous geometry mutation and
|
||||
// capture without pumping an event loop. Such a host cannot observe queued
|
||||
// tasks, so leave them pending instead of aborting or incorrectly turning a
|
||||
// task into a microtask. Normal browser and CDP execution always takes the
|
||||
// task-queue path below.
|
||||
if (!Deno.core.ops.op_async_runtime_available()) {
|
||||
return undefined;
|
||||
}
|
||||
// The callback runs only when the embedder pumps the event loop, after the
|
||||
// current microtask checkpoint.
|
||||
return Deno.core.queueUserTimer(0, false, d, fn);
|
||||
};
|
||||
|
||||
// Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF
|
||||
@@ -652,14 +666,29 @@ globalThis.setTimeout = (fn, delay = 0, ...args) => {
|
||||
const f = _coerceTimerFn(fn);
|
||||
if (f === null) return ++_tid;
|
||||
const id = ++_tid;
|
||||
_scheduleAfter(delay, () => {
|
||||
const normalizedDelay = Math.max(0, Number(delay) || 0);
|
||||
const nativeId = _scheduleAfter(normalizedDelay, () => {
|
||||
_nativeTimerIds.delete(id);
|
||||
__obscuraPendingTimeoutDeadlines.delete(id);
|
||||
if (_clearedTimers.has(id)) return;
|
||||
try { f(...args); } catch(e) { console.error("Timer error:", e); }
|
||||
});
|
||||
if (nativeId !== undefined) {
|
||||
_nativeTimerIds.set(id, nativeId);
|
||||
__obscuraPendingTimeoutDeadlines.set(id, performance.now() + normalizedDelay);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); };
|
||||
globalThis.clearTimeout = (id) => {
|
||||
_clearedTimers.add(id);
|
||||
__obscuraPendingTimeoutDeadlines.delete(id);
|
||||
const nativeId = _nativeTimerIds.get(id);
|
||||
if (nativeId !== undefined) {
|
||||
Deno.core.cancelTimer(nativeId);
|
||||
_nativeTimerIds.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.setInterval = (fn, delay = 0, ...args) => {
|
||||
const f = _coerceTimerFn(fn);
|
||||
@@ -670,13 +699,18 @@ globalThis.setInterval = (fn, delay = 0, ...args) => {
|
||||
if (!_intervals.has(id)) return;
|
||||
try { f(...args); } catch(e) { console.error("Interval error:", e); }
|
||||
if (!_intervals.has(id)) return;
|
||||
_scheduleAfter(delay, tick);
|
||||
const nativeId = _scheduleAfter(delay, tick);
|
||||
if (nativeId !== undefined) _nativeTimerIds.set(id, nativeId);
|
||||
};
|
||||
_scheduleAfter(delay, tick);
|
||||
const nativeId = _scheduleAfter(delay, tick);
|
||||
if (nativeId !== undefined) _nativeTimerIds.set(id, nativeId);
|
||||
return id;
|
||||
};
|
||||
|
||||
globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); };
|
||||
globalThis.clearInterval = (id) => {
|
||||
_intervals.delete(id);
|
||||
globalThis.clearTimeout(id);
|
||||
};
|
||||
|
||||
// Animation callbacks are a rendering-phase batch, not zero-delay
|
||||
// microtasks. In particular, a callback which queues itself must yield to
|
||||
@@ -739,20 +773,155 @@ globalThis.cancelAnimationFrame = (id) => {
|
||||
};
|
||||
globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
|
||||
|
||||
// MessagePort is a task-backed EventTarget, not a pair of callback slots.
|
||||
// React currently uses `onmessage`, while Angular/Zone.js and worker-style
|
||||
// schedulers commonly use addEventListener + start and inspect the prototype.
|
||||
// Keep stopped-port messages queued, clone payloads synchronously, and deliver
|
||||
// one message per task so every delivery gets its own microtask checkpoint.
|
||||
const _messagePortConstructionKey = {};
|
||||
const _messagePortState = new WeakMap();
|
||||
function _messagePortStateFor(port) {
|
||||
const state = _messagePortState.get(port);
|
||||
if (!state) throw new TypeError("Illegal invocation");
|
||||
return state;
|
||||
}
|
||||
function _messagePortInstallEventHandler(port, type, callback) {
|
||||
const state = _messagePortStateFor(port);
|
||||
const slot = type === "message" ? "onmessage" : "onmessageerror";
|
||||
const wrapperSlot = type === "message" ? "messageHandlerWrapper" : "messageErrorHandlerWrapper";
|
||||
const oldCallback = state[slot];
|
||||
state[slot] = callback;
|
||||
|
||||
// Event-handler IDL attributes participate in the same listener list as
|
||||
// addEventListener. Install their stable wrapper when the slot first becomes
|
||||
// non-null so mixed registrations run in registration order. Reassigning a
|
||||
// live handler keeps its position; clearing and setting it again appends it.
|
||||
if (callback && !oldCallback) {
|
||||
const wrapper = (event) => {
|
||||
const current = _messagePortState.get(port)?.[slot];
|
||||
if (!current) return;
|
||||
if (typeof current === "function") current.call(port, event);
|
||||
else current.handleEvent.call(current, event);
|
||||
};
|
||||
state[wrapperSlot] = wrapper;
|
||||
_eventTargetAdd(port, type, wrapper);
|
||||
} else if (!callback && oldCallback) {
|
||||
_eventTargetRemove(port, type, state[wrapperSlot]);
|
||||
state[wrapperSlot] = null;
|
||||
}
|
||||
}
|
||||
function _messagePortScheduleDelivery(port) {
|
||||
const state = _messagePortStateFor(port);
|
||||
if (state.closed || !state.messageQueueEnabled || state.messageDeliveryPending || !state.messageQueue.length) return;
|
||||
state.messageDeliveryPending = true;
|
||||
_scheduleAfter(0, () => {
|
||||
const current = _messagePortState.get(port);
|
||||
if (!current) return;
|
||||
current.messageDeliveryPending = false;
|
||||
if (current.closed || !current.messageQueueEnabled || !current.messageQueue.length) return;
|
||||
const data = current.messageQueue.shift();
|
||||
const event = new MessageEvent("message", {
|
||||
data,
|
||||
origin: "",
|
||||
lastEventId: "",
|
||||
source: null,
|
||||
ports: [],
|
||||
});
|
||||
_eventTargetDispatch(port, event);
|
||||
_messagePortScheduleDelivery(port);
|
||||
});
|
||||
}
|
||||
class MessagePort {
|
||||
constructor(key) {
|
||||
if (key !== _messagePortConstructionKey) throw new TypeError("Illegal constructor");
|
||||
_messagePortState.set(this, {
|
||||
entangled: null,
|
||||
messageQueue: [],
|
||||
messageQueueEnabled: false,
|
||||
messageDeliveryPending: false,
|
||||
closed: false,
|
||||
onmessage: null,
|
||||
onmessageerror: null,
|
||||
messageHandlerWrapper: null,
|
||||
messageErrorHandlerWrapper: null,
|
||||
});
|
||||
}
|
||||
postMessage(message, options) {
|
||||
// Structured serialization happens before inspecting the entanglement.
|
||||
// This preserves the browser-observable DataCloneError on closed ports and
|
||||
// prevents mutations after postMessage from changing the delivered value.
|
||||
let cloned;
|
||||
try {
|
||||
cloned = globalThis.structuredClone(message, options);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
const state = _messagePortStateFor(this);
|
||||
const target = state.entangled;
|
||||
const targetState = target && _messagePortState.get(target);
|
||||
if (state.closed || !targetState || targetState.closed) return;
|
||||
targetState.messageQueue.push(cloned);
|
||||
_messagePortScheduleDelivery(target);
|
||||
}
|
||||
start() {
|
||||
const state = _messagePortStateFor(this);
|
||||
if (state.messageQueueEnabled || state.closed) return;
|
||||
state.messageQueueEnabled = true;
|
||||
_messagePortScheduleDelivery(this);
|
||||
}
|
||||
close() {
|
||||
const state = _messagePortStateFor(this);
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
state.messageQueue.length = 0;
|
||||
state.messageQueueEnabled = false;
|
||||
const peer = state.entangled;
|
||||
state.entangled = null;
|
||||
const peerState = peer && _messagePortState.get(peer);
|
||||
if (peerState?.entangled === this) peerState.entangled = null;
|
||||
// A previously scheduled task cannot be removed from the shared task
|
||||
// source, but it observes `closed` and therefore cannot dispatch.
|
||||
}
|
||||
addEventListener(type, callback, options) {
|
||||
_eventTargetAdd(this, type, callback, options);
|
||||
}
|
||||
removeEventListener(type, callback, options) {
|
||||
_eventTargetRemove(this, type, callback, options);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
_messagePortStateFor(this);
|
||||
return _eventTargetDispatch(this, event);
|
||||
}
|
||||
get onmessage() { return _messagePortStateFor(this).onmessage; }
|
||||
set onmessage(callback) {
|
||||
callback = typeof callback === "function"
|
||||
|| (callback && typeof callback.handleEvent === "function")
|
||||
? callback : null;
|
||||
_messagePortInstallEventHandler(this, "message", callback);
|
||||
// Setting the event-handler IDL attribute implicitly starts the port,
|
||||
// including when the assigned value is null.
|
||||
this.start();
|
||||
}
|
||||
get onmessageerror() { return _messagePortStateFor(this).onmessageerror; }
|
||||
set onmessageerror(callback) {
|
||||
callback = typeof callback === "function"
|
||||
|| (callback && typeof callback.handleEvent === "function")
|
||||
? callback : null;
|
||||
_messagePortInstallEventHandler(this, "messageerror", callback);
|
||||
}
|
||||
get [Symbol.toStringTag]() { return "MessagePort"; }
|
||||
}
|
||||
|
||||
class MessageChannel {
|
||||
constructor() {
|
||||
this.port1 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} };
|
||||
this.port2 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} };
|
||||
this.port1.postMessage = (data) => {
|
||||
Promise.resolve().then(() => { if (this.port2.onmessage) this.port2.onmessage({ data }); });
|
||||
};
|
||||
this.port2.postMessage = (data) => {
|
||||
Promise.resolve().then(() => { if (this.port1.onmessage) this.port1.onmessage({ data }); });
|
||||
};
|
||||
this.port1 = new MessagePort(_messagePortConstructionKey);
|
||||
this.port2 = new MessagePort(_messagePortConstructionKey);
|
||||
_messagePortStateFor(this.port1).entangled = this.port2;
|
||||
_messagePortStateFor(this.port2).entangled = this.port1;
|
||||
}
|
||||
}
|
||||
globalThis.MessageChannel = MessageChannel;
|
||||
globalThis.MessagePort = class MessagePort { constructor(){} postMessage(){} close(){} addEventListener(){} removeEventListener(){} };
|
||||
globalThis.MessagePort = MessagePort;
|
||||
|
||||
const _cssCamelToKebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
const _cssKebabToCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
@@ -3529,6 +3698,26 @@ function _makeXPathResult(type, nodes) {
|
||||
};
|
||||
}
|
||||
|
||||
// `document.domain` exposes the document's effective host. Keep the relaxed
|
||||
// value on the live Document object so a navigation (which installs a new
|
||||
// Document in __obscura_init) naturally restores the URL host. Detached
|
||||
// documents inherit the incumbent realm's principal for reads, which is why a
|
||||
// `new Document().domain` read reflects the live document rather than its own
|
||||
// about:blank URL.
|
||||
function _documentUrlHost() {
|
||||
try { return new URL(_domParse("document_url") || "about:blank").hostname; }
|
||||
catch (_) { return ""; }
|
||||
}
|
||||
function _incumbentDocumentDomain() {
|
||||
const live = globalThis.document;
|
||||
return live && typeof live._effectiveDomain === "string"
|
||||
? live._effectiveDomain
|
||||
: _documentUrlHost();
|
||||
}
|
||||
function _throwDocumentDomainSecurityError() {
|
||||
throw new DOMException("Failed to set the 'domain' property on 'Document'", "SecurityError");
|
||||
}
|
||||
|
||||
class Document extends Node {
|
||||
get documentElement() { return _wrapEl(+_dom("document_element")); }
|
||||
get children() {
|
||||
@@ -3569,6 +3758,28 @@ class Document extends Node {
|
||||
}
|
||||
get URL() { return _domParse("document_url") ?? ""; }
|
||||
get documentURI() { return this.URL; }
|
||||
get domain() {
|
||||
return this === globalThis.document
|
||||
? (typeof this._effectiveDomain === "string" ? this._effectiveDomain : _documentUrlHost())
|
||||
: _incumbentDocumentDomain();
|
||||
}
|
||||
set domain(value) {
|
||||
// Web IDL performs DOMString conversion before the setter algorithm checks
|
||||
// whether the Document has a browsing context.
|
||||
const input = String(value);
|
||||
if (this !== globalThis.document) _throwDocumentDomainSecurityError();
|
||||
const current = this.domain;
|
||||
if (!current) _throwDocumentDomainSecurityError();
|
||||
const candidate = Deno.core.ops.op_document_domain_candidate(current, input);
|
||||
if (!candidate) _throwDocumentDomainSecurityError();
|
||||
// This runtime currently has one top-level browsing context and no
|
||||
// principal-backed same-origin-domain comparison. Persisting the
|
||||
// validated effective domain supplies the standards-shaped API without
|
||||
// weakening iframe/fetch/storage origin checks; those must be wired to a
|
||||
// future browsing-context principal model before domain relaxation can
|
||||
// grant cross-document access.
|
||||
this._effectiveDomain = candidate;
|
||||
}
|
||||
get referrer() { return _domParse("document_referrer") ?? ""; }
|
||||
get location() { return globalThis.location; }
|
||||
set location(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); }
|
||||
@@ -6054,28 +6265,36 @@ globalThis.matchMedia = _markNative(function matchMedia(q) {
|
||||
dispatchEvent(){return true;}
|
||||
};
|
||||
});
|
||||
// getComputedStyle() returns a fresh declaration object, but those objects all
|
||||
// observe the same computed style for an element until the document mutates.
|
||||
// Share the immutable native snapshot behind them. Frameworks routinely call
|
||||
// getComputedStyle() repeatedly on the same few roots; rebuilding and parsing
|
||||
// several hundred properties for every wrapper dominated real-page startup.
|
||||
const _computedStyleSnapshotCache = new WeakMap();
|
||||
globalThis.getComputedStyle = (el) => {
|
||||
if (!el) el = document.body || {};
|
||||
const style = el?.style || el?._style || new CSSStyleDeclaration();
|
||||
// Render builds expose one immutable snapshot from the retained final
|
||||
// cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a
|
||||
// dozen properties must not trigger a dozen layout/native crossings.
|
||||
let rendered = null;
|
||||
let renderedEpoch = -1;
|
||||
let computedNames = [];
|
||||
// cascade/layout. The native snapshot is shared per element and epoch while
|
||||
// each call still returns a distinct, live CSSStyleDeclaration proxy.
|
||||
const cacheable = (typeof el === 'object' && el !== null) || typeof el === 'function';
|
||||
let snapshot = cacheable ? _computedStyleSnapshotCache.get(el) : null;
|
||||
if (!snapshot) {
|
||||
snapshot = { rendered: null, epoch: -1, names: [] };
|
||||
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
|
||||
}
|
||||
const refreshRendered = () => {
|
||||
if (renderedEpoch === _domMutationEpoch) return;
|
||||
renderedEpoch = _domMutationEpoch;
|
||||
rendered = null;
|
||||
if (snapshot.epoch === _domMutationEpoch) return;
|
||||
snapshot.epoch = _domMutationEpoch;
|
||||
snapshot.rendered = null;
|
||||
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) {
|
||||
try {
|
||||
const raw = Deno.core.ops.op_computed_style(String(el._nid | 0));
|
||||
rendered = raw ? JSON.parse(raw) : null;
|
||||
snapshot.rendered = raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {}
|
||||
}
|
||||
computedNames = rendered ? Object.keys(rendered) : [];
|
||||
snapshot.names = snapshot.rendered ? Object.keys(snapshot.rendered) : [];
|
||||
};
|
||||
refreshRendered();
|
||||
// React virtualization libraries (react-window, tanstack-virtual,
|
||||
// react-virtuoso) all compute container dimensions via getComputedStyle.
|
||||
// The defaults table previously returned `auto` for width/height and
|
||||
@@ -6139,8 +6358,8 @@ globalThis.getComputedStyle = (el) => {
|
||||
// (`-webkit-line-clamp`). Normalize the prefix once for every WebKit
|
||||
// property instead of adding per-property aliases to the native snapshot.
|
||||
if (kebab.startsWith('webkit-')) kebab = '-' + kebab;
|
||||
if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab))
|
||||
return rendered[kebab];
|
||||
if (snapshot.rendered && Object.prototype.hasOwnProperty.call(snapshot.rendered, kebab))
|
||||
return snapshot.rendered[kebab];
|
||||
// Non-render builds and properties outside the renderer snapshot retain
|
||||
// the lightweight inline CSSOM behavior.
|
||||
const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : '';
|
||||
@@ -6160,11 +6379,11 @@ globalThis.getComputedStyle = (el) => {
|
||||
if (prop === 'getPropertyPriority') return () => '';
|
||||
if (prop === 'item') return (i) => {
|
||||
refreshRendered();
|
||||
return computedNames[i | 0] || '';
|
||||
return snapshot.names[i | 0] || '';
|
||||
};
|
||||
if (prop === 'length') {
|
||||
refreshRendered();
|
||||
return computedNames.length;
|
||||
return snapshot.names.length;
|
||||
}
|
||||
if (prop === 'cssText') return '';
|
||||
if (prop === 'parentRule') return null;
|
||||
@@ -6530,6 +6749,23 @@ globalThis.NodeFilter = {
|
||||
// target boxes through getBoundingClientRect. Element scroll-container roots
|
||||
// remain intentionally unsupported until their offsets participate in layout.
|
||||
globalThis.__intersectionObservers = [];
|
||||
let _intersectionRenderCheckpointPending = false;
|
||||
function _scheduleIntersectionRenderCheckpoint() {
|
||||
if (_intersectionRenderCheckpointPending) return;
|
||||
_intersectionRenderCheckpointPending = true;
|
||||
// Intersection observation is part of the browser's rendering update, not
|
||||
// a synchronous side effect of observe() or a DOM mutation. One queued task
|
||||
// coalesces every observer/target and therefore performs at most one layout
|
||||
// flush for the checkpoint.
|
||||
_scheduleAfter(0, () => {
|
||||
_intersectionRenderCheckpointPending = false;
|
||||
for (const observer of globalThis.__intersectionObservers) {
|
||||
if (observer._connected && observer._targets.size) {
|
||||
observer._check([...observer._targets], false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function _ioRect(x, y, width, height) {
|
||||
return {
|
||||
x, y, width, height,
|
||||
@@ -6664,7 +6900,7 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
}
|
||||
this._targets.add(el);
|
||||
this._previous.delete(el);
|
||||
this._check([el], true);
|
||||
_scheduleIntersectionRenderCheckpoint();
|
||||
}
|
||||
unobserve(el) {
|
||||
this._targets.delete(el);
|
||||
@@ -6686,18 +6922,8 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
get thresholds() { return this._thresholds.slice(); }
|
||||
};
|
||||
(function() {
|
||||
let pending = false;
|
||||
const recompute = () => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
Promise.resolve().then(() => {
|
||||
pending = false;
|
||||
for (const observer of globalThis.__intersectionObservers) {
|
||||
if (observer._connected && observer._targets.size) {
|
||||
observer._check([...observer._targets], false);
|
||||
}
|
||||
}
|
||||
});
|
||||
_scheduleIntersectionRenderCheckpoint();
|
||||
};
|
||||
globalThis.__obscura_recompute_intersections = recompute;
|
||||
globalThis.addEventListener("resize", recompute);
|
||||
@@ -6920,7 +7146,16 @@ globalThis.PopStateEvent = class extends Event {
|
||||
}
|
||||
};
|
||||
globalThis.HashChangeEvent = class extends Event {};
|
||||
globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data; } };
|
||||
globalThis.MessageEvent = class extends Event {
|
||||
constructor(t,o={}) {
|
||||
super(t,o);
|
||||
this.data = Object.prototype.hasOwnProperty.call(o, "data") ? o.data : null;
|
||||
this.origin = o.origin == null ? "" : String(o.origin);
|
||||
this.lastEventId = o.lastEventId == null ? "" : String(o.lastEventId);
|
||||
this.source = o.source == null ? null : o.source;
|
||||
this.ports = Array.isArray(o.ports) ? o.ports.slice() : [];
|
||||
}
|
||||
};
|
||||
globalThis.ProgressEvent = class ProgressEvent extends Event {
|
||||
constructor(type, init) {
|
||||
super(type, init || {});
|
||||
@@ -7315,6 +7550,8 @@ globalThis.DOMParser = class DOMParser {
|
||||
// URL about:blank, are already fully parsed, and carry no stylesheets.
|
||||
get URL() { return "about:blank"; },
|
||||
get documentURI() { return "about:blank"; },
|
||||
get domain() { return _incumbentDocumentDomain(); },
|
||||
set domain(value) { String(value); _throwDocumentDomainSecurityError(); },
|
||||
get referrer() { return ""; },
|
||||
get baseURI() { return "about:blank"; },
|
||||
get compatMode() { return "CSS1Compat"; },
|
||||
@@ -10813,14 +11050,127 @@ if (typeof WebSocket === 'undefined') {
|
||||
}
|
||||
|
||||
if (typeof BroadcastChannel === 'undefined') {
|
||||
// BroadcastChannel is used by authentication/session coordinators and by
|
||||
// modern framework dev/runtime clients. Keep the registry realm-local: one
|
||||
// Obscura page is one origin-bound browsing context today, so every channel
|
||||
// in this registry has the same storage key and origin by construction.
|
||||
const channelsByName = new Map();
|
||||
const channelState = new WeakMap();
|
||||
const stateFor = (channel) => {
|
||||
const state = channelState.get(channel);
|
||||
if (!state) throw new TypeError('Illegal invocation');
|
||||
return state;
|
||||
};
|
||||
const installHandler = (channel, type, callback) => {
|
||||
const state = stateFor(channel);
|
||||
const slot = type === 'message' ? 'onmessage' : 'onmessageerror';
|
||||
const wrapperSlot = type === 'message' ? 'messageWrapper' : 'messageErrorWrapper';
|
||||
const oldCallback = state[slot];
|
||||
state[slot] = callback;
|
||||
if (callback && !oldCallback) {
|
||||
const wrapper = (event) => {
|
||||
const current = channelState.get(channel)?.[slot];
|
||||
if (!current) return;
|
||||
if (typeof current === 'function') current.call(channel, event);
|
||||
else current.handleEvent.call(current, event);
|
||||
};
|
||||
state[wrapperSlot] = wrapper;
|
||||
_eventTargetAdd(channel, type, wrapper);
|
||||
} else if (!callback && oldCallback) {
|
||||
_eventTargetRemove(channel, type, state[wrapperSlot]);
|
||||
state[wrapperSlot] = null;
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.BroadcastChannel = class BroadcastChannel {
|
||||
constructor(name) {
|
||||
this.name = name; this.onmessage = null; this.onmessageerror = null;
|
||||
_makeListenerBox(this);
|
||||
if (arguments.length < 1) {
|
||||
throw new TypeError("Failed to construct 'BroadcastChannel': 1 argument required.");
|
||||
}
|
||||
const normalizedName = String(name);
|
||||
const state = {
|
||||
name: normalizedName,
|
||||
closed: false,
|
||||
onmessage: null,
|
||||
onmessageerror: null,
|
||||
messageWrapper: null,
|
||||
messageErrorWrapper: null,
|
||||
};
|
||||
channelState.set(this, state);
|
||||
let channels = channelsByName.get(normalizedName);
|
||||
if (!channels) channelsByName.set(normalizedName, channels = new Set());
|
||||
channels.add(this);
|
||||
}
|
||||
postMessage(msg) {}
|
||||
close() {}
|
||||
get name() { return stateFor(this).name; }
|
||||
get onmessage() { return stateFor(this).onmessage; }
|
||||
set onmessage(callback) {
|
||||
callback = typeof callback === 'function'
|
||||
|| (callback && typeof callback.handleEvent === 'function')
|
||||
? callback : null;
|
||||
installHandler(this, 'message', callback);
|
||||
}
|
||||
get onmessageerror() { return stateFor(this).onmessageerror; }
|
||||
set onmessageerror(callback) {
|
||||
callback = typeof callback === 'function'
|
||||
|| (callback && typeof callback.handleEvent === 'function')
|
||||
? callback : null;
|
||||
installHandler(this, 'messageerror', callback);
|
||||
}
|
||||
addEventListener(type, callback, options) {
|
||||
stateFor(this);
|
||||
_eventTargetAdd(this, type, callback, options);
|
||||
}
|
||||
removeEventListener(type, callback, options) {
|
||||
stateFor(this);
|
||||
_eventTargetRemove(this, type, callback, options);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
stateFor(this);
|
||||
return _eventTargetDispatch(this, event);
|
||||
}
|
||||
postMessage(message) {
|
||||
const state = stateFor(this);
|
||||
if (state.closed) {
|
||||
throw new DOMException("BroadcastChannel is closed.", "InvalidStateError");
|
||||
}
|
||||
|
||||
// Serialization is synchronous and precedes recipient selection. This
|
||||
// preserves DataCloneError even when no peer is listening and freezes
|
||||
// the posted graph before the caller can mutate it.
|
||||
const snapshot = globalThis.structuredClone(message);
|
||||
const recipients = Array.from(channelsByName.get(state.name) || [])
|
||||
.filter((channel) => channel !== this && !channelState.get(channel)?.closed);
|
||||
const origin = globalThis.location?.origin || '';
|
||||
for (const recipient of recipients) {
|
||||
// Each destination gets an independent deserialization, not a shared
|
||||
// JS object. Clone now so all serialization remains part of postMessage.
|
||||
const data = globalThis.structuredClone(snapshot);
|
||||
_scheduleAfter(0, () => {
|
||||
const recipientState = channelState.get(recipient);
|
||||
if (!recipientState || recipientState.closed) return;
|
||||
_eventTargetDispatch(recipient, new MessageEvent('message', {
|
||||
data,
|
||||
origin,
|
||||
source: null,
|
||||
ports: [],
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
close() {
|
||||
const state = stateFor(this);
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
const channels = channelsByName.get(state.name);
|
||||
if (!channels) return;
|
||||
channels.delete(this);
|
||||
if (!channels.size) channelsByName.delete(state.name);
|
||||
}
|
||||
get [Symbol.toStringTag]() { return 'BroadcastChannel'; }
|
||||
};
|
||||
// EventTarget is currently Node-backed in this runtime; link the prototype
|
||||
// without invoking Node's DOM-node constructor or exposing a fake `_nid`.
|
||||
Object.setPrototypeOf(globalThis.BroadcastChannel.prototype, globalThis.EventTarget.prototype);
|
||||
}
|
||||
|
||||
if (typeof MediaQueryList === 'undefined') {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod cdp_watchdog;
|
||||
mod import_map;
|
||||
pub mod module_loader;
|
||||
pub mod runtime;
|
||||
pub mod ops;
|
||||
pub mod v8_flags;
|
||||
pub mod markdown;
|
||||
pub mod module_loader;
|
||||
pub mod ops;
|
||||
pub mod runtime;
|
||||
pub mod v8_flags;
|
||||
|
||||
pub use markdown::HTML_TO_MARKDOWN_JS;
|
||||
pub use v8_flags::set_v8_flags;
|
||||
@@ -13,6 +13,6 @@ pub use v8_flags::set_v8_flags;
|
||||
// render feature (which enables obscura-render/paint) is compiled in.
|
||||
#[cfg(feature = "render")]
|
||||
pub use obscura_render::{
|
||||
screenshot_png, screenshot_png_scrolled, CaptureError, CaptureRegion,
|
||||
screenshot_png, screenshot_png_scrolled, validate_capture_region, CaptureError, CaptureRegion,
|
||||
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||
};
|
||||
+635
-201
File diff suppressed because it is too large.
Load diff
+1801
-416
File diff suppressed because it is too large.
Load diff
+1299
-571
File diff suppressed because it is too large.
Load diff
+1665
-1224
File diff suppressed because it is too large.
Load diff
+347
-287
File diff suppressed because it is too large.
Load diff
@@ -14,7 +14,7 @@
|
||||
use taffy::prelude::*;
|
||||
|
||||
pub mod css;
|
||||
pub use css::Stylesheet;
|
||||
pub use css::{Stylesheet, StylesheetCache};
|
||||
|
||||
pub mod style;
|
||||
pub use style::compute_style;
|
||||
@@ -37,7 +37,10 @@ pub use dom::{
|
||||
/// are ASCII case-insensitive. Keep this list aligned with the `image` crate
|
||||
/// features in `Cargo.toml` plus the SVG path in `paint.rs`.
|
||||
pub(crate) fn source_type_supported(value: &str) -> bool {
|
||||
let essence = value.split_once(';').map_or(value, |(essence, _)| essence).trim();
|
||||
let essence = value
|
||||
.split_once(';')
|
||||
.map_or(value, |(essence, _)| essence)
|
||||
.trim();
|
||||
[
|
||||
"image/apng",
|
||||
"image/bmp",
|
||||
@@ -94,11 +97,12 @@ mod paint;
|
||||
#[cfg(feature = "paint")]
|
||||
pub use paint::{
|
||||
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
|
||||
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, screenshot_png,
|
||||
screenshot_png_scrolled, screenshot_prepared, screenshot_prepared_region_with_scroll,
|
||||
screenshot_prepared_with_scroll, CaptureError, CaptureRegion, DynamicFontFace,
|
||||
ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader,
|
||||
ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
|
||||
screenshot_prepared, screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll,
|
||||
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
|
||||
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
|
||||
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||
};
|
||||
|
||||
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
||||
@@ -122,7 +126,12 @@ pub mod inline {
|
||||
/// Layout-only builds have no shaper, so no container is ever treated
|
||||
/// as a cosmic-text inline formatting context: the word-split path
|
||||
/// handles text geometry for `getBoundingClientRect`.
|
||||
pub fn try_build(&mut self, _tree: &DomTree, _id: NodeId, _styles: &HashMap<NodeId, crate::LayoutStyle>) -> Option<usize> {
|
||||
pub fn try_build(
|
||||
&mut self,
|
||||
_tree: &DomTree,
|
||||
_id: NodeId,
|
||||
_styles: &HashMap<NodeId, crate::LayoutStyle>,
|
||||
) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
/// See `try_build`: inline runs likewise fall back to word-split
|
||||
@@ -224,16 +233,31 @@ impl Affine2 {
|
||||
}
|
||||
|
||||
pub fn translate(x: f32, y: f32) -> Self {
|
||||
Self { e: x, f: y, ..Self::IDENTITY }
|
||||
Self {
|
||||
e: x,
|
||||
f: y,
|
||||
..Self::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scale(x: f32, y: f32) -> Self {
|
||||
Self { a: x, d: y, ..Self::IDENTITY }
|
||||
Self {
|
||||
a: x,
|
||||
d: y,
|
||||
..Self::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rotate(degrees: f32) -> Self {
|
||||
let (sin, cos) = degrees.to_radians().sin_cos();
|
||||
Self { a: cos, b: sin, c: -sin, d: cos, e: 0.0, f: 0.0 }
|
||||
Self {
|
||||
a: cos,
|
||||
b: sin,
|
||||
c: -sin,
|
||||
d: cos,
|
||||
e: 0.0,
|
||||
f: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skew(x_degrees: f32, y_degrees: f32) -> Self {
|
||||
@@ -254,7 +278,10 @@ impl Affine2 {
|
||||
}
|
||||
|
||||
pub fn map_point(self, x: f32, y: f32) -> (f32, f32) {
|
||||
(self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f)
|
||||
(
|
||||
self.a * x + self.c * y + self.e,
|
||||
self.b * x + self.d * y + self.f,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn map_rect(self, rect: Rect) -> Rect {
|
||||
@@ -264,10 +291,22 @@ impl Affine2 {
|
||||
self.map_point(rect.x, rect.y + rect.height),
|
||||
self.map_point(rect.x + rect.width, rect.y + rect.height),
|
||||
];
|
||||
let left = points.iter().map(|point| point.0).fold(f32::INFINITY, f32::min);
|
||||
let top = points.iter().map(|point| point.1).fold(f32::INFINITY, f32::min);
|
||||
let right = points.iter().map(|point| point.0).fold(f32::NEG_INFINITY, f32::max);
|
||||
let bottom = points.iter().map(|point| point.1).fold(f32::NEG_INFINITY, f32::max);
|
||||
let left = points
|
||||
.iter()
|
||||
.map(|point| point.0)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let top = points
|
||||
.iter()
|
||||
.map(|point| point.1)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let right = points
|
||||
.iter()
|
||||
.map(|point| point.0)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let bottom = points
|
||||
.iter()
|
||||
.map(|point| point.1)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
Rect {
|
||||
x: left,
|
||||
y: top,
|
||||
@@ -317,7 +356,10 @@ pub struct TransformLength {
|
||||
|
||||
impl TransformLength {
|
||||
pub fn px(value: f32) -> Self {
|
||||
Self { value: Dimension::Px(value), expression: None }
|
||||
Self {
|
||||
value: Dimension::Px(value),
|
||||
expression: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +385,12 @@ impl Rect {
|
||||
let x1 = (self.x + self.width).min(other.x + other.width);
|
||||
let y1 = (self.y + self.height).min(other.y + other.height);
|
||||
if x1 > x0 && y1 > y0 {
|
||||
Some(Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
|
||||
Some(Rect {
|
||||
x: x0,
|
||||
y: y0,
|
||||
width: x1 - x0,
|
||||
height: y1 - y0,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -356,7 +403,12 @@ impl Rect {
|
||||
let y0 = self.y.min(other.y);
|
||||
let x1 = (self.x + self.width).max(other.x + other.width);
|
||||
let y1 = (self.y + self.height).max(other.y + other.height);
|
||||
Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
|
||||
Rect {
|
||||
x: x0,
|
||||
y: y0,
|
||||
width: x1 - x0,
|
||||
height: y1 - y0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,11 +432,7 @@ pub fn quantize_scroll_value(value: f32, device_scale_factor: f32) -> f32 {
|
||||
(value * scale).round() / scale
|
||||
}
|
||||
|
||||
pub(crate) fn quantized_scroll_range(
|
||||
content: f32,
|
||||
client: f32,
|
||||
device_scale_factor: f32,
|
||||
) -> f32 {
|
||||
pub(crate) fn quantized_scroll_range(content: f32, client: f32, device_scale_factor: f32) -> f32 {
|
||||
(quantize_scroll_value(content, device_scale_factor)
|
||||
- quantize_scroll_value(client, device_scale_factor))
|
||||
.max(0.0)
|
||||
@@ -532,8 +580,7 @@ impl BackgroundPositionAxis {
|
||||
pub(crate) fn interpolate(self, other: Self, position: f32) -> Self {
|
||||
Self {
|
||||
length: self.length + (other.length - self.length) * position,
|
||||
percentage: self.percentage
|
||||
+ (other.percentage - self.percentage) * position,
|
||||
percentage: self.percentage + (other.percentage - self.percentage) * position,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,8 +863,7 @@ pub struct LayoutStyle {
|
||||
/// First `radial-gradient(...)` layer: center in box-relative fractions
|
||||
/// and color stops. It is painted below the first linear layer, matching
|
||||
/// the common `linear-gradient(...), radial-gradient(...)` hero pattern.
|
||||
pub background_radial_gradient:
|
||||
Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||
pub background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||
/// `conic-gradient(...)` background. The angle is the CSS `from` angle,
|
||||
/// the center is a fraction of the border box, and stops are normalized
|
||||
/// during paint. Conic gradients commonly provide the color source for a
|
||||
@@ -1536,7 +1582,11 @@ pub struct LayoutNode {
|
||||
|
||||
impl LayoutNode {
|
||||
pub fn leaf(style: LayoutStyle) -> Self {
|
||||
LayoutNode { style, text: None, children: Vec::new() }
|
||||
LayoutNode {
|
||||
style,
|
||||
text: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1568,9 +1618,9 @@ fn build_node(tree: &mut TaffyTree, node: &LayoutNode) -> NodeId {
|
||||
if node.children.is_empty() {
|
||||
tree.new_leaf(style).expect("taffy new_leaf")
|
||||
} else {
|
||||
let child_ids: Vec<NodeId> =
|
||||
node.children.iter().map(|c| build_node(tree, c)).collect();
|
||||
tree.new_with_children(style, &child_ids).expect("taffy new_with_children")
|
||||
let child_ids: Vec<NodeId> = node.children.iter().map(|c| build_node(tree, c)).collect();
|
||||
tree.new_with_children(style, &child_ids)
|
||||
.expect("taffy new_with_children")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1609,7 +1659,10 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
||||
// `text_align` is separate from real flex/grid `align-items`, so a
|
||||
// text-align declaration never changes how flex children are sized.
|
||||
let promote_for_alignment = style.display == Display::Block
|
||||
&& matches!(style.text_align, Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END));
|
||||
&& matches!(
|
||||
style.text_align,
|
||||
Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END)
|
||||
);
|
||||
|
||||
s.display = match style.display {
|
||||
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
|
||||
@@ -1816,7 +1869,9 @@ fn dimension(v: Dimension) -> taffy::style::Dimension {
|
||||
// rather than panicking.
|
||||
Dimension::Em(v) | Dimension::Rem(v) => taffy::style::Dimension::length(v * 16.0),
|
||||
Dimension::Ex(v) => taffy::style::Dimension::length(v * 16.0 * 0.528_320_3),
|
||||
Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => taffy::style::Dimension::length(v),
|
||||
Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => {
|
||||
taffy::style::Dimension::length(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1866,7 +1921,12 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_box(display: Display, w: f32, h: f32) -> LayoutStyle {
|
||||
LayoutStyle { display, width: Dimension::Px(w), height: Dimension::Px(h), ..Default::default() }
|
||||
LayoutStyle {
|
||||
display,
|
||||
width: Dimension::Px(w),
|
||||
height: Dimension::Px(h),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1909,7 +1969,10 @@ mod tests {
|
||||
display: Display::Block,
|
||||
width: Dimension::Px(200.0),
|
||||
height: Dimension::Px(40.0),
|
||||
margin: Edges { right: 50.0, ..Default::default() },
|
||||
margin: Edges {
|
||||
right: 50.0,
|
||||
..Default::default()
|
||||
},
|
||||
margin_auto: [false, false, false, true],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -1957,7 +2020,12 @@ mod tests {
|
||||
#[test]
|
||||
fn flex_row_lays_out_horizontally() {
|
||||
let root = LayoutNode {
|
||||
style: LayoutStyle { display: Display::Flex, width: Dimension::Px(600.0), height: Dimension::Px(100.0), ..Default::default() },
|
||||
style: LayoutStyle {
|
||||
display: Display::Flex,
|
||||
width: Dimension::Px(600.0),
|
||||
height: Dimension::Px(100.0),
|
||||
..Default::default()
|
||||
},
|
||||
text: None,
|
||||
children: vec![
|
||||
LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)),
|
||||
@@ -1983,7 +2051,12 @@ mod tests {
|
||||
display: Display::Block,
|
||||
width: Dimension::Px(100.0),
|
||||
height: Dimension::Px(100.0),
|
||||
padding: Edges { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 },
|
||||
padding: Edges {
|
||||
top: 10.0,
|
||||
right: 10.0,
|
||||
bottom: 10.0,
|
||||
left: 10.0,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
text: None,
|
||||
|
||||
+2016
-756
File diff suppressed because it is too large.
Load diff
+984
-521
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user