Improve modern rendering and output parity

This commit is contained in:
SGavrl committed 2026-08-03 19:46:24 +00:00
1 parent f428aa37fa
commit c8a5477296
25 files changed
+12878 -5046

No files matched your search

Generated
+17
View File
@@ -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"
+2 -1
View File
@@ -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 }
+8 -2
View File
@@ -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};
File diff suppressed because it is too large. Load diff
+556
View File
@@ -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);
}
}
+103 -52
View File
@@ -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]
+122 -23
View File
@@ -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", &params, &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);
}
}
+17 -7
View File
@@ -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)),
+150 -50
View File
@@ -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", &params, &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);
}
}
+11 -10
View File
@@ -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
+361
View File
@@ -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(&params).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
View File
@@ -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\""));
}
}
+463 -62
View File
@@ -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");
}
+4
View File
@@ -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 }
+400 -50
View File
@@ -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') {
+5 -5
View File
@@ -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,
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+111 -38
View File
@@ -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,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff