Improve modern rendering and output parity
This commit is contained in:
1 parent
f428aa37fa
commit
c8a5477296
25 files changed
+11732
-3900
No files matched your search
Generated
+17
@@ -1870,6 +1870,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"futures",
|
"futures",
|
||||||
|
"image",
|
||||||
"obscura-dom",
|
"obscura-dom",
|
||||||
"obscura-js",
|
"obscura-js",
|
||||||
"obscura-net",
|
"obscura-net",
|
||||||
@@ -1957,6 +1958,7 @@ dependencies = [
|
|||||||
"obscura-net",
|
"obscura-net",
|
||||||
"obscura-render",
|
"obscura-render",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
|
"psl",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2358,6 +2360,21 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ edition.workspace = true
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
stealth = ["obscura-net/stealth", "obscura-js/stealth"]
|
stealth = ["obscura-net/stealth", "obscura-js/stealth"]
|
||||||
render = ["obscura-js/render"]
|
render = ["obscura-js/render", "dep:image"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
obscura-dom = { workspace = true }
|
obscura-dom = { workspace = true }
|
||||||
@@ -20,3 +20,4 @@ thiserror = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"], optional = true }
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
pub mod page;
|
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
|
pub mod page;
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
pub mod pdf;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
|
|
||||||
pub use page::{NetworkEvent, Page, PageError};
|
|
||||||
pub use context::BrowserContext;
|
pub use context::BrowserContext;
|
||||||
pub use lifecycle::{LifecycleState, WaitUntil};
|
pub use lifecycle::{LifecycleState, WaitUntil};
|
||||||
pub use obscura_js::HTML_TO_MARKDOWN_JS;
|
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,
|
// Re-exported so the embeddable `obscura` crate (which depends on obscura-browser,
|
||||||
// not obscura-js) can surface the interception channel types.
|
// not obscura-js) can surface the interception channel types.
|
||||||
pub use obscura_js::ops::{InterceptResolution, InterceptedRequest};
|
pub use obscura_js::ops::{InterceptResolution, InterceptedRequest};
|
||||||
+753
-244
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,556 @@
|
|||||||
|
//! Bounded raster-backed PDF export over the retained document-space painter.
|
||||||
|
//!
|
||||||
|
//! This deliberately does not claim CSS paged-media support. It preserves the
|
||||||
|
//! screen layout, fits the full document width into the printable area, and
|
||||||
|
//! slices that immutable layout vertically across PDF pages.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use image::ImageEncoder as _;
|
||||||
|
use obscura_js::CaptureRegion;
|
||||||
|
|
||||||
|
use crate::Page;
|
||||||
|
|
||||||
|
const POINTS_PER_INCH: f32 = 72.0;
|
||||||
|
const MAX_PAPER_INCHES: f32 = 200.0;
|
||||||
|
const MAX_PDF_PAGES: usize = 250;
|
||||||
|
const MAX_PDF_PAGE_PIXELS: u64 = 16 * 1024 * 1024;
|
||||||
|
const MAX_PDF_TOTAL_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
|
||||||
|
const MAX_PDF_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct RasterPdfOptions {
|
||||||
|
pub landscape: bool,
|
||||||
|
pub paper_width_in: f32,
|
||||||
|
pub paper_height_in: f32,
|
||||||
|
pub margin_top_in: f32,
|
||||||
|
pub margin_bottom_in: f32,
|
||||||
|
pub margin_left_in: f32,
|
||||||
|
pub margin_right_in: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RasterPdfOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
landscape: false,
|
||||||
|
paper_width_in: 8.5,
|
||||||
|
paper_height_in: 11.0,
|
||||||
|
// CDP's defaults are one centimetre.
|
||||||
|
margin_top_in: 0.3937,
|
||||||
|
margin_bottom_in: 0.3937,
|
||||||
|
margin_left_in: 0.3937,
|
||||||
|
margin_right_in: 0.3937,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
pub enum RasterPdfError {
|
||||||
|
#[error("PDF paper dimensions must be finite and between 0 and 200 inches")]
|
||||||
|
InvalidPaperSize,
|
||||||
|
#[error("PDF margins must be finite, non-negative, and leave a printable area")]
|
||||||
|
InvalidMargins,
|
||||||
|
#[error("the page has no retained renderable document")]
|
||||||
|
NoRenderableDocument,
|
||||||
|
#[error("PDF pagination would exceed the {0}-page safety limit")]
|
||||||
|
TooManyPages(usize),
|
||||||
|
#[error("PDF raster work would exceed the bounded page or document pixel budget")]
|
||||||
|
RasterWorkLimitExceeded,
|
||||||
|
#[error("document-space PDF capture failed: {0}")]
|
||||||
|
CaptureFailed(String),
|
||||||
|
#[error("PDF raster image decoding failed: {0}")]
|
||||||
|
ImageDecode(String),
|
||||||
|
#[error("PDF JPEG encoding failed: {0}")]
|
||||||
|
ImageEncode(String),
|
||||||
|
#[error("encoded PDF would exceed the 64 MiB safety limit")]
|
||||||
|
OutputLimitExceeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RasterPage {
|
||||||
|
rgb: image::RgbImage,
|
||||||
|
draw_width_pt: f32,
|
||||||
|
draw_height_pt: f32,
|
||||||
|
#[cfg(test)]
|
||||||
|
_lifetime_probe: Option<std::rc::Rc<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct PaginationPlan {
|
||||||
|
points_per_css_pixel: f32,
|
||||||
|
css_page_height: f32,
|
||||||
|
page_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RasterPdfOptions {
|
||||||
|
fn page_geometry(self) -> Result<(f32, f32, f32, f32, f32, f32), RasterPdfError> {
|
||||||
|
let values = [self.paper_width_in, self.paper_height_in];
|
||||||
|
if values
|
||||||
|
.iter()
|
||||||
|
.any(|value| !value.is_finite() || *value <= 0.0 || *value > MAX_PAPER_INCHES)
|
||||||
|
{
|
||||||
|
return Err(RasterPdfError::InvalidPaperSize);
|
||||||
|
}
|
||||||
|
let margins = [
|
||||||
|
self.margin_top_in,
|
||||||
|
self.margin_bottom_in,
|
||||||
|
self.margin_left_in,
|
||||||
|
self.margin_right_in,
|
||||||
|
];
|
||||||
|
if margins
|
||||||
|
.iter()
|
||||||
|
.any(|value| !value.is_finite() || *value < 0.0)
|
||||||
|
{
|
||||||
|
return Err(RasterPdfError::InvalidMargins);
|
||||||
|
}
|
||||||
|
let (paper_width_in, paper_height_in) = if self.landscape {
|
||||||
|
(self.paper_height_in, self.paper_width_in)
|
||||||
|
} else {
|
||||||
|
(self.paper_width_in, self.paper_height_in)
|
||||||
|
};
|
||||||
|
let page_width = paper_width_in * POINTS_PER_INCH;
|
||||||
|
let page_height = paper_height_in * POINTS_PER_INCH;
|
||||||
|
let left = self.margin_left_in * POINTS_PER_INCH;
|
||||||
|
let bottom = self.margin_bottom_in * POINTS_PER_INCH;
|
||||||
|
let printable_width =
|
||||||
|
page_width - (self.margin_left_in + self.margin_right_in) * POINTS_PER_INCH;
|
||||||
|
let printable_height =
|
||||||
|
page_height - (self.margin_top_in + self.margin_bottom_in) * POINTS_PER_INCH;
|
||||||
|
if printable_width <= 0.0 || printable_height <= 0.0 {
|
||||||
|
return Err(RasterPdfError::InvalidMargins);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
page_width,
|
||||||
|
page_height,
|
||||||
|
printable_width,
|
||||||
|
printable_height,
|
||||||
|
left,
|
||||||
|
bottom,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pagination_plan(
|
||||||
|
content_width: f32,
|
||||||
|
content_height: f32,
|
||||||
|
printable_width: f32,
|
||||||
|
printable_height: f32,
|
||||||
|
) -> Result<PaginationPlan, RasterPdfError> {
|
||||||
|
let points_per_css_pixel = printable_width / content_width;
|
||||||
|
let css_page_height = printable_height / points_per_css_pixel;
|
||||||
|
if !points_per_css_pixel.is_finite()
|
||||||
|
|| points_per_css_pixel <= 0.0
|
||||||
|
|| !css_page_height.is_finite()
|
||||||
|
|| css_page_height <= 0.0
|
||||||
|
{
|
||||||
|
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_count_value = (content_height / css_page_height).ceil().max(1.0);
|
||||||
|
if !page_count_value.is_finite() || page_count_value > MAX_PDF_PAGES as f32 {
|
||||||
|
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
|
||||||
|
}
|
||||||
|
let page_count = page_count_value as usize;
|
||||||
|
let pixel_width = content_width.ceil();
|
||||||
|
if !pixel_width.is_finite()
|
||||||
|
|| pixel_width <= 0.0
|
||||||
|
|| pixel_width > obscura_js::MAX_CAPTURE_DIMENSION as f32
|
||||||
|
{
|
||||||
|
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||||
|
}
|
||||||
|
let pixel_width = pixel_width as u64;
|
||||||
|
let mut total_pixels = 0u64;
|
||||||
|
for page_index in 0..page_count {
|
||||||
|
let y = page_index as f32 * css_page_height;
|
||||||
|
let slice_height = (content_height - y).min(css_page_height).ceil();
|
||||||
|
if !slice_height.is_finite()
|
||||||
|
|| slice_height <= 0.0
|
||||||
|
|| slice_height > obscura_js::MAX_CAPTURE_DIMENSION as f32
|
||||||
|
{
|
||||||
|
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||||
|
}
|
||||||
|
let page_pixels = pixel_width
|
||||||
|
.checked_mul(slice_height as u64)
|
||||||
|
.ok_or(RasterPdfError::RasterWorkLimitExceeded)?;
|
||||||
|
if page_pixels > MAX_PDF_PAGE_PIXELS {
|
||||||
|
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||||
|
}
|
||||||
|
total_pixels = total_pixels
|
||||||
|
.checked_add(page_pixels)
|
||||||
|
.ok_or(RasterPdfError::RasterWorkLimitExceeded)?;
|
||||||
|
if total_pixels > MAX_PDF_TOTAL_RASTER_PIXELS {
|
||||||
|
return Err(RasterPdfError::RasterWorkLimitExceeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PaginationPlan {
|
||||||
|
points_per_css_pixel,
|
||||||
|
css_page_height,
|
||||||
|
page_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Page {
|
||||||
|
/// Export the current screen layout as a paginated raster PDF.
|
||||||
|
///
|
||||||
|
/// The full document width is scaled uniformly into the printable width;
|
||||||
|
/// vertical slices become pages. This does not reflow into `@media print`
|
||||||
|
/// or implement CSS paged media, headers, footers, or page ranges.
|
||||||
|
pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result<Vec<u8>, RasterPdfError> {
|
||||||
|
let (page_width, page_height, printable_width, printable_height, left, bottom) =
|
||||||
|
options.page_geometry()?;
|
||||||
|
let js = self
|
||||||
|
.js
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(RasterPdfError::NoRenderableDocument)?;
|
||||||
|
let (content_width, content_height) = js
|
||||||
|
.prepared_content_size()
|
||||||
|
.ok_or(RasterPdfError::NoRenderableDocument)?;
|
||||||
|
if !content_width.is_finite()
|
||||||
|
|| !content_height.is_finite()
|
||||||
|
|| content_width <= 0.0
|
||||||
|
|| content_height <= 0.0
|
||||||
|
{
|
||||||
|
return Err(RasterPdfError::NoRenderableDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = pagination_plan(
|
||||||
|
content_width,
|
||||||
|
content_height,
|
||||||
|
printable_width,
|
||||||
|
printable_height,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
encode_pdf_pages(
|
||||||
|
plan.page_count,
|
||||||
|
page_width,
|
||||||
|
page_height,
|
||||||
|
left,
|
||||||
|
bottom,
|
||||||
|
printable_height,
|
||||||
|
|page_index| {
|
||||||
|
let y = page_index as f32 * plan.css_page_height;
|
||||||
|
let slice_height = (content_height - y).min(plan.css_page_height);
|
||||||
|
let png = js
|
||||||
|
.screenshot_prepared_region(CaptureRegion::new(
|
||||||
|
0.0,
|
||||||
|
y,
|
||||||
|
content_width,
|
||||||
|
slice_height,
|
||||||
|
1.0,
|
||||||
|
))
|
||||||
|
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
|
||||||
|
let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
|
||||||
|
.map_err(|error| RasterPdfError::ImageDecode(error.to_string()))?;
|
||||||
|
// The document capture is already a complete PNG allocation. Drop
|
||||||
|
// it before converting the decoded pixels and, below, encoding the
|
||||||
|
// JPEG directly into the final PDF buffer. At no point do we retain
|
||||||
|
// PNGs or JPEGs for earlier pages.
|
||||||
|
drop(png);
|
||||||
|
let rgb = decoded.into_rgb8();
|
||||||
|
Ok(RasterPage {
|
||||||
|
rgb,
|
||||||
|
draw_width_pt: printable_width,
|
||||||
|
draw_height_pt: slice_height * plan.points_per_css_pixel,
|
||||||
|
#[cfg(test)]
|
||||||
|
_lifetime_probe: None,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_pdf_pages(
|
||||||
|
page_count: usize,
|
||||||
|
page_width: f32,
|
||||||
|
page_height: f32,
|
||||||
|
left: f32,
|
||||||
|
bottom: f32,
|
||||||
|
printable_height: f32,
|
||||||
|
mut page_source: impl FnMut(usize) -> Result<RasterPage, RasterPdfError>,
|
||||||
|
) -> Result<Vec<u8>, RasterPdfError> {
|
||||||
|
let object_count = 2usize
|
||||||
|
.checked_add(
|
||||||
|
page_count
|
||||||
|
.checked_mul(3)
|
||||||
|
.ok_or(RasterPdfError::OutputLimitExceeded)?,
|
||||||
|
)
|
||||||
|
.ok_or(RasterPdfError::OutputLimitExceeded)?;
|
||||||
|
let mut writer = PdfWriter::new(object_count, MAX_PDF_OUTPUT_BYTES)?;
|
||||||
|
writer.write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>")?;
|
||||||
|
|
||||||
|
let kids = (0..page_count)
|
||||||
|
.map(|index| format!("{} 0 R", 3 + index * 3))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let pages_dictionary = format!("<< /Type /Pages /Count {page_count} /Kids [{kids}] >>");
|
||||||
|
writer.write_object(2, pages_dictionary.as_bytes())?;
|
||||||
|
|
||||||
|
for index in 0..page_count {
|
||||||
|
let page = page_source(index)?;
|
||||||
|
let page_id = 3 + index * 3;
|
||||||
|
let content_id = page_id + 1;
|
||||||
|
let image_id = page_id + 2;
|
||||||
|
let page_dictionary = format!(
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_width:.3} {page_height:.3}] /Resources << /XObject << /Im0 {image_id} 0 R >> >> /Contents {content_id} 0 R >>"
|
||||||
|
);
|
||||||
|
writer.write_object(page_id, page_dictionary.as_bytes())?;
|
||||||
|
|
||||||
|
let draw_y = bottom + printable_height - page.draw_height_pt;
|
||||||
|
let commands = format!(
|
||||||
|
"q\n{:.3} 0 0 {:.3} {:.3} {:.3} cm\n/Im0 Do\nQ\n",
|
||||||
|
page.draw_width_pt, page.draw_height_pt, left, draw_y,
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
"<< /Length {} >>\nstream\n{}endstream",
|
||||||
|
commands.len(),
|
||||||
|
commands
|
||||||
|
);
|
||||||
|
writer.write_object(content_id, content.as_bytes())?;
|
||||||
|
writer.write_rgb_image(image_id, &page.rgb)?;
|
||||||
|
// `page`, including its decoded RGB raster, is dropped here before
|
||||||
|
// the next page is captured. Only the bounded final PDF survives.
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PdfWriter {
|
||||||
|
output: Vec<u8>,
|
||||||
|
offsets: Vec<usize>,
|
||||||
|
limit: usize,
|
||||||
|
limit_exceeded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfWriter {
|
||||||
|
fn new(object_count: usize, limit: usize) -> Result<Self, RasterPdfError> {
|
||||||
|
let mut writer = Self {
|
||||||
|
output: Vec::new(),
|
||||||
|
offsets: vec![0usize; object_count + 1],
|
||||||
|
limit,
|
||||||
|
limit_exceeded: false,
|
||||||
|
};
|
||||||
|
writer.append(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")?;
|
||||||
|
Ok(writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append(&mut self, bytes: &[u8]) -> Result<(), RasterPdfError> {
|
||||||
|
let new_len = self
|
||||||
|
.output
|
||||||
|
.len()
|
||||||
|
.checked_add(bytes.len())
|
||||||
|
.ok_or(RasterPdfError::OutputLimitExceeded)?;
|
||||||
|
if new_len > self.limit {
|
||||||
|
self.limit_exceeded = true;
|
||||||
|
return Err(RasterPdfError::OutputLimitExceeded);
|
||||||
|
}
|
||||||
|
self.output.extend_from_slice(bytes);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_object(&mut self, id: usize, body: &[u8]) -> Result<(), RasterPdfError> {
|
||||||
|
self.offsets[id] = self.output.len();
|
||||||
|
self.append(format!("{id} 0 obj\n").as_bytes())?;
|
||||||
|
self.append(body)?;
|
||||||
|
self.append(b"\nendobj\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_rgb_image(&mut self, id: usize, rgb: &image::RgbImage) -> Result<(), RasterPdfError> {
|
||||||
|
self.offsets[id] = self.output.len();
|
||||||
|
self.append(format!(
|
||||||
|
"{id} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ",
|
||||||
|
rgb.width(), rgb.height(),
|
||||||
|
).as_bytes())?;
|
||||||
|
// Encode into the final PDF rather than building a second JPEG Vec.
|
||||||
|
// A fixed-width decimal token lets us patch /Length after encoding.
|
||||||
|
const LENGTH_DIGITS: usize = 20;
|
||||||
|
let length_offset = self.output.len();
|
||||||
|
self.append(b"00000000000000000000 >>\nstream\n")?;
|
||||||
|
let stream_offset = self.output.len();
|
||||||
|
let encode_result = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut *self, 90)
|
||||||
|
.write_image(
|
||||||
|
rgb.as_raw(),
|
||||||
|
rgb.width(),
|
||||||
|
rgb.height(),
|
||||||
|
image::ExtendedColorType::Rgb8,
|
||||||
|
);
|
||||||
|
if let Err(error) = encode_result {
|
||||||
|
return if self.limit_exceeded {
|
||||||
|
Err(RasterPdfError::OutputLimitExceeded)
|
||||||
|
} else {
|
||||||
|
Err(RasterPdfError::ImageEncode(error.to_string()))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let stream_len = self.output.len() - stream_offset;
|
||||||
|
let length = format!("{stream_len:0LENGTH_DIGITS$}");
|
||||||
|
if length.len() != LENGTH_DIGITS {
|
||||||
|
return Err(RasterPdfError::OutputLimitExceeded);
|
||||||
|
}
|
||||||
|
self.output[length_offset..length_offset + LENGTH_DIGITS]
|
||||||
|
.copy_from_slice(length.as_bytes());
|
||||||
|
self.append(b"\nendstream\nendobj\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(mut self) -> Result<Vec<u8>, RasterPdfError> {
|
||||||
|
let object_count = self.offsets.len() - 1;
|
||||||
|
let xref_offset = self.output.len();
|
||||||
|
self.append(format!("xref\n0 {}\n0000000000 65535 f \n", object_count + 1).as_bytes())?;
|
||||||
|
for index in 1..self.offsets.len() {
|
||||||
|
let offset = self.offsets[index];
|
||||||
|
self.append(format!("{offset:010} 00000 n \n").as_bytes())?;
|
||||||
|
}
|
||||||
|
self.append(
|
||||||
|
format!(
|
||||||
|
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n",
|
||||||
|
object_count + 1,
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)?;
|
||||||
|
Ok(self.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl io::Write for PdfWriter {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
|
self.append(bytes)
|
||||||
|
.map(|()| bytes.len())
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn options_reject_impossible_media_boxes() {
|
||||||
|
let mut options = RasterPdfOptions::default();
|
||||||
|
options.paper_width_in = 0.0;
|
||||||
|
assert_eq!(
|
||||||
|
options.page_geometry(),
|
||||||
|
Err(RasterPdfError::InvalidPaperSize)
|
||||||
|
);
|
||||||
|
let mut options = RasterPdfOptions::default();
|
||||||
|
options.margin_left_in = 5.0;
|
||||||
|
options.margin_right_in = 5.0;
|
||||||
|
assert_eq!(options.page_geometry(), Err(RasterPdfError::InvalidMargins));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pagination_preflight_bounds_pages_and_raster_work() {
|
||||||
|
let (_, _, printable_width, printable_height, _, _) =
|
||||||
|
RasterPdfOptions::default().page_geometry().unwrap();
|
||||||
|
let ordinary = pagination_plan(1280.0, 10_000.0, printable_width, printable_height)
|
||||||
|
.expect("an ordinary multi-page document stays inside the budget");
|
||||||
|
assert!(ordinary.page_count > 1);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pagination_plan(5_000.0, 5_000.0, printable_width, printable_height).unwrap_err(),
|
||||||
|
RasterPdfError::RasterWorkLimitExceeded,
|
||||||
|
"one excessively large raster page must fail before capture"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pagination_plan(1_000.0, 70_000.0, printable_width, printable_height).unwrap_err(),
|
||||||
|
RasterPdfError::RasterWorkLimitExceeded,
|
||||||
|
"many individually valid pages must still respect a total work budget"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height).unwrap_err(),
|
||||||
|
RasterPdfError::TooManyPages(MAX_PDF_PAGES),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writer_emits_xref_and_one_image_per_page() {
|
||||||
|
let pdf = encode_pdf_pages(1, 612.0, 792.0, 36.0, 36.0, 720.0, |_| {
|
||||||
|
Ok(RasterPage {
|
||||||
|
rgb: image::RgbImage::from_pixel(2, 3, image::Rgb([10, 20, 30])),
|
||||||
|
draw_width_pt: 100.0,
|
||||||
|
draw_height_pt: 150.0,
|
||||||
|
_lifetime_probe: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(pdf.starts_with(b"%PDF-1.4"));
|
||||||
|
assert!(pdf.ends_with(b"%%EOF\n"));
|
||||||
|
let text = String::from_utf8_lossy(&pdf);
|
||||||
|
assert!(text.contains("/Count 1"));
|
||||||
|
assert!(text.contains("/Subtype /Image"));
|
||||||
|
assert!(text.contains("xref\n0 6"));
|
||||||
|
let startxref = text
|
||||||
|
.rsplit_once("startxref\n")
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap();
|
||||||
|
assert!(pdf[startxref..].starts_with(b"xref\n"));
|
||||||
|
let object_one_offset = text
|
||||||
|
.split("xref\n0 6\n")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap()[..10]
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap();
|
||||||
|
assert!(pdf[object_one_offset..].starts_with(b"1 0 obj\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_rasters_are_released_before_capturing_the_next_page() {
|
||||||
|
let previous = std::cell::RefCell::new(None::<std::rc::Weak<()>>);
|
||||||
|
let pdf = encode_pdf_pages(4, 612.0, 792.0, 36.0, 36.0, 720.0, |index| {
|
||||||
|
if let Some(previous) = previous.borrow().as_ref() {
|
||||||
|
assert!(
|
||||||
|
previous.upgrade().is_none(),
|
||||||
|
"page {index} was requested while the prior raster was still retained"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let probe = std::rc::Rc::new(());
|
||||||
|
*previous.borrow_mut() = Some(std::rc::Rc::downgrade(&probe));
|
||||||
|
Ok(RasterPage {
|
||||||
|
rgb: image::RgbImage::from_pixel(8, 8, image::Rgb([index as u8, 0, 0])),
|
||||||
|
draw_width_pt: 100.0,
|
||||||
|
draw_height_pt: 100.0,
|
||||||
|
_lifetime_probe: Some(probe),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&pdf)
|
||||||
|
.matches("/Subtype /Image")
|
||||||
|
.count(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writer_enforces_the_output_limit_while_encoding_the_image_stream() {
|
||||||
|
let mut writer = PdfWriter::new(5, 600).unwrap();
|
||||||
|
writer
|
||||||
|
.write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>")
|
||||||
|
.unwrap();
|
||||||
|
writer
|
||||||
|
.write_object(2, b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>")
|
||||||
|
.unwrap();
|
||||||
|
let noisy = image::RgbImage::from_fn(128, 128, |x, y| {
|
||||||
|
image::Rgb([
|
||||||
|
x.wrapping_mul(37) as u8,
|
||||||
|
y.wrapping_mul(53) as u8,
|
||||||
|
x.wrapping_add(y).wrapping_mul(71) as u8,
|
||||||
|
])
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
writer.write_rgb_image(5, &noisy),
|
||||||
|
Err(RasterPdfError::OutputLimitExceeded)
|
||||||
|
);
|
||||||
|
assert!(writer.output.len() <= 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ use crate::types::{CdpEvent, CdpRequest, CdpResponse};
|
|||||||
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) enum ScreencastFormat { Png, Jpeg }
|
pub(crate) enum ScreencastFormat {
|
||||||
|
Png,
|
||||||
|
Jpeg,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -257,10 +260,15 @@ impl CdpContext {
|
|||||||
self.pages.retain(|p| p.id != id);
|
self.pages.retain(|p| p.id != id);
|
||||||
#[cfg(feature = "render")]
|
#[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)
|
.filter(|(_, page_id)| page_id.as_str() == id)
|
||||||
.map(|(session_id, _)| session_id.clone()).collect();
|
.map(|(session_id, _)| session_id.clone())
|
||||||
for session_id in removed { self.screencasts.remove(&session_id); }
|
.collect();
|
||||||
|
for session_id in removed {
|
||||||
|
self.screencasts.remove(&session_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.sessions.retain(|_, v| v != 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> {
|
pub fn get_session_page(&self, session_id: &Option<String>) -> Option<&Page> {
|
||||||
let page_id = session_id
|
let page_id = session_id.as_ref().and_then(|sid| self.sessions.get(sid))?;
|
||||||
.as_ref()
|
|
||||||
.and_then(|sid| self.sessions.get(sid))?;
|
|
||||||
self.get_page(page_id)
|
self.get_page(page_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,38 +317,67 @@ impl CdpContext {
|
|||||||
/// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and
|
/// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and
|
||||||
/// must stay behind the lock.
|
/// must stay behind the lock.
|
||||||
fn is_v8_free_method(method: &str) -> bool {
|
fn is_v8_free_method(method: &str) -> bool {
|
||||||
matches!(method,
|
matches!(
|
||||||
"Target.getTargets" | "Target.setDiscoverTargets"
|
method,
|
||||||
| "Target.attachToTarget" | "Target.attachToBrowserTarget"
|
"Target.getTargets"
|
||||||
|
| "Target.setDiscoverTargets"
|
||||||
|
| "Target.attachToTarget"
|
||||||
|
| "Target.attachToBrowserTarget"
|
||||||
| "Target.setAutoAttach"
|
| "Target.setAutoAttach"
|
||||||
| "Target.getBrowserContexts" | "Target.createBrowserContext"
|
| "Target.getBrowserContexts"
|
||||||
| "Target.disposeBrowserContext" | "Target.getTargetInfo"
|
| "Target.createBrowserContext"
|
||||||
| "Target.detachFromTarget" | "Target.activateTarget"
|
| "Target.disposeBrowserContext"
|
||||||
| "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget"
|
| "Target.getTargetInfo"
|
||||||
| "Browser.setDownloadBehavior" | "Browser.getWindowBounds" | "Browser.setWindowBounds"
|
| "Target.detachFromTarget"
|
||||||
| "Page.enable" | "Page.disable" | "Page.getFrameTree"
|
| "Target.activateTarget"
|
||||||
|
| "Browser.getVersion"
|
||||||
|
| "Browser.close"
|
||||||
|
| "Browser.getWindowForTarget"
|
||||||
|
| "Browser.setDownloadBehavior"
|
||||||
|
| "Browser.getWindowBounds"
|
||||||
|
| "Browser.setWindowBounds"
|
||||||
|
| "Page.enable"
|
||||||
|
| "Page.disable"
|
||||||
|
| "Page.getFrameTree"
|
||||||
| "Page.setDownloadBehavior"
|
| "Page.setDownloadBehavior"
|
||||||
| "Page.setLifecycleEventsEnabled"
|
| "Page.setLifecycleEventsEnabled"
|
||||||
| "Page.addScriptToEvaluateOnNewDocument" | "Page.removeScriptToEvaluateOnNewDocument"
|
| "Page.addScriptToEvaluateOnNewDocument"
|
||||||
| "Page.setInterceptFileChooserDialog" | "Page.getNavigationHistory"
|
| "Page.removeScriptToEvaluateOnNewDocument"
|
||||||
| "Page.resetNavigationHistory" | "Page.printToPDF"
|
| "Page.setInterceptFileChooserDialog"
|
||||||
|
| "Page.getNavigationHistory"
|
||||||
|
| "Page.resetNavigationHistory"
|
||||||
| "Page.captureSnapshot"
|
| "Page.captureSnapshot"
|
||||||
| "Page.stopScreencast" | "Page.screencastFrameAck"
|
| "Page.stopScreencast"
|
||||||
|
| "Page.screencastFrameAck"
|
||||||
| "Page.createIsolatedWorld"
|
| "Page.createIsolatedWorld"
|
||||||
| "Runtime.enable" | "Runtime.disable"
|
| "Runtime.enable"
|
||||||
| "Runtime.runIfWaitingForDebugger" | "Runtime.getExceptionDetails"
|
| "Runtime.disable"
|
||||||
|
| "Runtime.runIfWaitingForDebugger"
|
||||||
|
| "Runtime.getExceptionDetails"
|
||||||
| "Runtime.discardConsoleEntries"
|
| "Runtime.discardConsoleEntries"
|
||||||
| "Network.enable" | "Network.disable" | "Network.setCacheDisabled"
|
| "Network.enable"
|
||||||
| "Network.setRequestInterception" | "Network.setBlockedURLs"
|
| "Network.disable"
|
||||||
|
| "Network.setCacheDisabled"
|
||||||
|
| "Network.setRequestInterception"
|
||||||
|
| "Network.setBlockedURLs"
|
||||||
| "Network.setExtraHTTPHeaders"
|
| "Network.setExtraHTTPHeaders"
|
||||||
| "Network.setUserAgentOverride"
|
| "Network.setUserAgentOverride"
|
||||||
| "Network.getCookies" | "Network.getAllCookies"
|
| "Network.getCookies"
|
||||||
| "Network.setCookie" | "Network.setCookies"
|
| "Network.getAllCookies"
|
||||||
| "Network.deleteCookies" | "Network.clearBrowserCookies"
|
| "Network.setCookie"
|
||||||
|
| "Network.setCookies"
|
||||||
|
| "Network.deleteCookies"
|
||||||
|
| "Network.clearBrowserCookies"
|
||||||
| "Network.getResponseBody"
|
| "Network.getResponseBody"
|
||||||
| "Fetch.continueRequest" | "Fetch.fulfillRequest"
|
| "Fetch.continueRequest"
|
||||||
| "Fetch.failRequest" | "Fetch.getResponseBody"
|
| "Fetch.fulfillRequest"
|
||||||
| "Storage.getCookies" | "Storage.setCookies" | "Storage.deleteCookies"
|
| "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 {
|
} else {
|
||||||
ctx.get_session_page(&req.session_id)
|
ctx.get_session_page(&req.session_id)
|
||||||
.and_then(|p| p.isolate_handle())
|
.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('.') {
|
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,
|
"Browser" => domains::browser::handle(method, &req.params).await,
|
||||||
"Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).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,
|
"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,
|
"Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await,
|
||||||
"Network" => domains::network::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,
|
"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,
|
"Emulation" => domains::emulation::handle(method, &req.params, ctx, &req.session_id).await,
|
||||||
"Storage" => domains::storage::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,
|
"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
|
// Accepted but no-op. Puppeteer's FrameManager.initialize calls
|
||||||
// Audits.enable on connect — refusing it breaks puppeteer.connect()
|
// Audits.enable on connect — refusing it breaks puppeteer.connect()
|
||||||
// before any user code runs.
|
// before any user code runs.
|
||||||
"Log" | "Performance" | "Security" | "CSS"
|
"Log" | "Performance" | "Security" | "CSS" | "ServiceWorker" | "Inspector" | "Debugger"
|
||||||
| "ServiceWorker" | "Inspector"
|
| "Profiler" | "HeapProfiler" | "Overlay" | "Audits" => Ok(json!({})),
|
||||||
| "Debugger" | "Profiler" | "HeapProfiler" | "Overlay"
|
|
||||||
| "Audits" => {
|
|
||||||
Ok(json!({}))
|
|
||||||
}
|
|
||||||
_ => Err(format!("Unknown domain: {}", domain)),
|
_ => Err(format!("Unknown domain: {}", domain)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -599,7 +636,11 @@ mod tests {
|
|||||||
async fn audits_enable_returns_empty_success() {
|
async fn audits_enable_returns_empty_success() {
|
||||||
let mut ctx = CdpContext::new();
|
let mut ctx = CdpContext::new();
|
||||||
let resp = dispatch(&req("Audits.enable"), &mut ctx).await;
|
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!({})));
|
assert_eq!(resp.result, Some(json!({})));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,7 +672,11 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let resp = dispatch(&outer, &mut ctx).await;
|
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.id, 99);
|
||||||
assert_eq!(resp.result, Some(json!({})));
|
assert_eq!(resp.result, Some(json!({})));
|
||||||
|
|
||||||
@@ -647,8 +692,14 @@ mod tests {
|
|||||||
let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap();
|
||||||
assert_eq!(parsed["id"], 42);
|
assert_eq!(parsed["id"], 42);
|
||||||
// Browser.getVersion returns a populated result object, not an error.
|
// Browser.getVersion returns a populated result object, not an error.
|
||||||
assert!(parsed.get("result").is_some(), "inner response carries result");
|
assert!(
|
||||||
assert!(parsed.get("error").is_none(), "inner response is not an error");
|
parsed.get("result").is_some(),
|
||||||
|
"inner response carries result"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed.get("error").is_none(),
|
||||||
|
"inner response is not an error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ use serde_json::{json, Value};
|
|||||||
|
|
||||||
use crate::dispatch::CdpContext;
|
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(
|
pub async fn handle(
|
||||||
method: &str,
|
method: &str,
|
||||||
params: &Value,
|
params: &Value,
|
||||||
@@ -10,31 +26,42 @@ pub async fn handle(
|
|||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
match method {
|
match method {
|
||||||
"setDeviceMetricsOverride" => {
|
"setDeviceMetricsOverride" => {
|
||||||
let width = params
|
let width = metric_dimension(params, "width")?;
|
||||||
.get("width")
|
let height = metric_dimension(params, "height")?;
|
||||||
|
let device_scale_factor = params
|
||||||
|
.get("deviceScaleFactor")
|
||||||
.and_then(Value::as_f64)
|
.and_then(Value::as_f64)
|
||||||
.ok_or("Emulation.setDeviceMetricsOverride requires width")?;
|
.ok_or("Emulation.setDeviceMetricsOverride requires deviceScaleFactor")?;
|
||||||
let height = params
|
if !device_scale_factor.is_finite() || device_scale_factor < 0.0 {
|
||||||
.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 {
|
|
||||||
return Err(
|
return Err(
|
||||||
"Emulation.setDeviceMetricsOverride requires positive finite dimensions"
|
"Emulation.setDeviceMetricsOverride requires a non-negative finite deviceScaleFactor"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let page = ctx
|
let page = ctx
|
||||||
.get_session_page_mut(session_id)
|
.get_session_page_mut(session_id)
|
||||||
.ok_or("No page for session")?;
|
.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!({}))
|
Ok(json!({}))
|
||||||
}
|
}
|
||||||
"clearDeviceMetricsOverride" => {
|
"clearDeviceMetricsOverride" => {
|
||||||
let page = ctx
|
let page = ctx
|
||||||
.get_session_page_mut(session_id)
|
.get_session_page_mut(session_id)
|
||||||
.ok_or("No page for session")?;
|
.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!({}))
|
Ok(json!({}))
|
||||||
}
|
}
|
||||||
// Touch emulation does not affect layout yet, but acknowledging it is
|
// Touch emulation does not affect layout yet, but acknowledging it is
|
||||||
@@ -53,12 +80,11 @@ mod tests {
|
|||||||
let mut ctx = CdpContext::new();
|
let mut ctx = CdpContext::new();
|
||||||
let page_id = ctx.create_page();
|
let page_id = ctx.create_page();
|
||||||
let session_id = Some("viewport-session".to_string());
|
let session_id = Some("viewport-session".to_string());
|
||||||
ctx.sessions
|
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||||
.insert(session_id.clone().unwrap(), page_id);
|
|
||||||
|
|
||||||
handle(
|
handle(
|
||||||
"setDeviceMetricsOverride",
|
"setDeviceMetricsOverride",
|
||||||
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 1}),
|
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 2}),
|
||||||
&mut ctx,
|
&mut ctx,
|
||||||
&session_id,
|
&session_id,
|
||||||
)
|
)
|
||||||
@@ -69,30 +95,103 @@ mod tests {
|
|||||||
.get_session_page_mut(&session_id)
|
.get_session_page_mut(&session_id)
|
||||||
.expect("page for session");
|
.expect("page for session");
|
||||||
assert_eq!(page.viewport, (1024.0, 768.0));
|
assert_eq!(page.viewport, (1024.0, 768.0));
|
||||||
|
assert_eq!(page.device_scale_factor, 2.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"return [innerWidth, innerHeight, visualViewport.width,\
|
"return [innerWidth, innerHeight, visualViewport.width,\
|
||||||
visualViewport.height];"
|
visualViewport.height, devicePixelRatio];"
|
||||||
),
|
),
|
||||||
json!([1024, 768, 1024, 768])
|
json!([1024, 768, 1024, 768, 2])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 mut ctx = CdpContext::new();
|
||||||
let page_id = ctx.create_page();
|
let page_id = ctx.create_page();
|
||||||
let session_id = Some("viewport-session".to_string());
|
let session_id = Some("viewport-session".to_string());
|
||||||
ctx.sessions
|
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||||
.insert(session_id.clone().unwrap(), page_id);
|
for params in [
|
||||||
let error = handle(
|
json!({"width": 0.5, "height": 768, "deviceScaleFactor": 1}),
|
||||||
|
json!({"width": 10_000_001, "height": 768, "deviceScaleFactor": 1}),
|
||||||
|
json!({"width": -1, "height": 768, "deviceScaleFactor": 1}),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
handle("setDeviceMetricsOverride", ¶ms, &mut ctx, &session_id)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"must reject {params}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn zero_dimensions_disable_size_override() {
|
||||||
|
let mut ctx = CdpContext::new();
|
||||||
|
let page_id = ctx.create_page();
|
||||||
|
let session_id = Some("zero-size-session".to_string());
|
||||||
|
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||||
|
handle(
|
||||||
"setDeviceMetricsOverride",
|
"setDeviceMetricsOverride",
|
||||||
&json!({"width": 0, "height": 768}),
|
&json!({"width": 0, "height": 0, "deviceScaleFactor": 0}),
|
||||||
&mut ctx,
|
&mut ctx,
|
||||||
&session_id,
|
&session_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("zero width must fail");
|
.expect("zero disables the overrides");
|
||||||
assert!(error.contains("positive finite dimensions"));
|
|
||||||
|
let page = ctx.get_session_page_mut(&session_id).unwrap();
|
||||||
|
assert_eq!(page.viewport, DEFAULT_VIEWPORT);
|
||||||
|
assert_eq!(page.device_scale_factor, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn device_scale_factor_zero_disables_override_and_clear_restores_defaults() {
|
||||||
|
let mut ctx = CdpContext::new();
|
||||||
|
let page_id = ctx.create_page();
|
||||||
|
let session_id = Some("scale-session".to_string());
|
||||||
|
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
|
||||||
|
|
||||||
|
handle(
|
||||||
|
"setDeviceMetricsOverride",
|
||||||
|
&json!({"width": 640, "height": 480, "deviceScaleFactor": 3}),
|
||||||
|
&mut ctx,
|
||||||
|
&session_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ctx.get_session_page(&session_id)
|
||||||
|
.unwrap()
|
||||||
|
.device_scale_factor,
|
||||||
|
3.0
|
||||||
|
);
|
||||||
|
|
||||||
|
handle(
|
||||||
|
"setDeviceMetricsOverride",
|
||||||
|
&json!({"width": 640, "height": 480, "deviceScaleFactor": 0}),
|
||||||
|
&mut ctx,
|
||||||
|
&session_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("zero disables the scale override");
|
||||||
|
assert_eq!(
|
||||||
|
ctx.get_session_page(&session_id)
|
||||||
|
.unwrap()
|
||||||
|
.device_scale_factor,
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
|
||||||
|
handle(
|
||||||
|
"clearDeviceMetricsOverride",
|
||||||
|
&json!({}),
|
||||||
|
&mut ctx,
|
||||||
|
&session_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let page = ctx.get_session_page(&session_id).unwrap();
|
||||||
|
assert_eq!(page.viewport, (1280.0, 720.0));
|
||||||
|
assert_eq!(page.device_scale_factor, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,10 +115,19 @@ pub async fn handle(
|
|||||||
|
|
||||||
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
|
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
|
||||||
let _ = paused.resolver.send(FetchResolution::Continue {
|
let _ = paused.resolver.send(FetchResolution::Continue {
|
||||||
url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
url: params
|
||||||
method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
.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,
|
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!({}))
|
Ok(json!({}))
|
||||||
@@ -178,9 +187,7 @@ pub async fn handle(
|
|||||||
}
|
}
|
||||||
Ok(json!({}))
|
Ok(json!({}))
|
||||||
}
|
}
|
||||||
"getResponseBody" => {
|
"getResponseBody" => Ok(json!({ "body": "", "base64Encoded": false })),
|
||||||
Ok(json!({ "body": "", "base64Encoded": false }))
|
|
||||||
}
|
|
||||||
"takeResponseBodyAsStream" => {
|
"takeResponseBodyAsStream" => {
|
||||||
// Hand the client a streaming handle for a large response body so it
|
// 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,
|
// 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}")
|
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 }))
|
Ok(json!({ "stream": handle }))
|
||||||
}
|
}
|
||||||
_ => Err(format!("Unknown Fetch method: {}", method)),
|
_ => Err(format!("Unknown Fetch method: {}", method)),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::dispatch::CdpContext;
|
|||||||
// order of magnitude; keeping chunks bounded is the point of streaming (issue
|
// order of magnitude; keeping chunks bounded is the point of streaming (issue
|
||||||
// #360), so we never return the whole body in one IO.read.
|
// #360), so we never return the whole body in one IO.read.
|
||||||
const DEFAULT_CHUNK: usize = 1 << 20; // 1 MiB
|
const DEFAULT_CHUNK: usize = 1 << 20; // 1 MiB
|
||||||
|
const MAX_READ_CHUNK: usize = 4 << 20; // 4 MiB
|
||||||
|
|
||||||
fn io_stream_max_entries() -> usize {
|
fn io_stream_max_entries() -> usize {
|
||||||
std::env::var("OBSCURA_IO_STREAM_MAX_ENTRIES")
|
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
|
/// memory stays bounded regardless of client behavior. Reading an evicted
|
||||||
/// handle fails cleanly (the client re-takes or gives up), which is the right
|
/// handle fails cleanly (the client re-takes or gives up), which is the right
|
||||||
/// trade against an OOM.
|
/// trade against an OOM.
|
||||||
#[derive(Default)]
|
|
||||||
pub struct IoStreamStore {
|
pub struct IoStreamStore {
|
||||||
streams: HashMap<String, (Vec<u8>, usize)>,
|
streams: HashMap<String, (Vec<u8>, usize)>,
|
||||||
order: VecDeque<String>,
|
order: VecDeque<String>,
|
||||||
total_bytes: usize,
|
total_bytes: usize,
|
||||||
counter: u64,
|
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 {
|
impl IoStreamStore {
|
||||||
/// Store a body and return its handle, evicting the oldest streams if this
|
fn with_limits(max_entries: usize, max_bytes: usize) -> Self {
|
||||||
/// pushes the store past its entry or byte cap.
|
Self {
|
||||||
pub fn insert(&mut self, bytes: Vec<u8>) -> String {
|
streams: HashMap::new(),
|
||||||
let handle = format!("stream-{}", self.counter);
|
order: VecDeque::new(),
|
||||||
self.counter += 1;
|
total_bytes: 0,
|
||||||
self.total_bytes += bytes.len();
|
counter: 0,
|
||||||
self.streams.insert(handle.clone(), (bytes, 0));
|
max_entries: max_entries.max(1),
|
||||||
self.order.push_back(handle.clone());
|
max_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let max_entries = io_stream_max_entries().max(1);
|
/// Store a body and return its handle, evicting the oldest streams if this
|
||||||
let max_bytes = io_stream_max_bytes();
|
/// would push the store past its entry or byte cap. A single body larger
|
||||||
// Evict oldest streams while over either cap. Never evict the stream we
|
/// than the byte cap is rejected rather than becoming an unbounded
|
||||||
// just inserted (it sits at the back of `order`); a lone oversized body
|
/// exception to the store's memory contract.
|
||||||
// is kept, since the client explicitly asked to stream it.
|
pub fn insert(&mut self, bytes: Vec<u8>) -> Result<String, String> {
|
||||||
while self.order.len() > 1
|
if bytes.len() > self.max_bytes {
|
||||||
&& (self.order.len() > max_entries || self.total_bytes > 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(oldest) = self.order.pop_front() {
|
||||||
if let Some((b, _)) = self.streams.remove(&oldest) {
|
if let Some((body, _)) = self.streams.remove(&oldest) {
|
||||||
self.total_bytes -= b.len();
|
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
|
/// 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
|
/// the base64 chunk and whether EOF was reached, or None for an unknown or
|
||||||
/// already-freed handle.
|
/// 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)?;
|
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 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]);
|
let data = base64::engine::general_purpose::STANDARD.encode(&bytes[start..end]);
|
||||||
*cursor = end;
|
*cursor = end;
|
||||||
Some((data, end >= bytes.len()))
|
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")?;
|
.ok_or("IO.read requires handle")?;
|
||||||
let size = params
|
let size = params
|
||||||
.get("size")
|
.get("size")
|
||||||
.and_then(|v| v.as_u64())
|
.map(|value| {
|
||||||
.map(|s| s as usize)
|
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);
|
.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
|
let (data, eof) = ctx
|
||||||
.io_streams
|
.io_streams
|
||||||
.read(handle, size)
|
.read(handle, offset, size)
|
||||||
.ok_or_else(|| format!("IO.read: unknown handle {handle}"))?;
|
.ok_or_else(|| format!("IO.read: unknown handle {handle}"))?;
|
||||||
|
|
||||||
Ok(json!({ "data": data, "eof": eof, "base64Encoded": true }))
|
Ok(json!({ "data": data, "eof": eof, "base64Encoded": true }))
|
||||||
@@ -136,48 +195,89 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reads_chunks_then_frees() {
|
fn reads_chunks_then_frees() {
|
||||||
let mut store = IoStreamStore::default();
|
let mut store = IoStreamStore::with_limits(4, 1024);
|
||||||
let h = store.insert(b"hello".to_vec());
|
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_eq!(decode(&d1), b"hel");
|
||||||
assert!(!eof1);
|
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_eq!(decode(&d2), b"lo");
|
||||||
assert!(eof2);
|
assert!(eof2);
|
||||||
|
|
||||||
store.remove(&h);
|
store.remove(&h);
|
||||||
assert!(store.read(&h, 3).is_none());
|
assert!(store.read(&h, None, 3).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_offset_seeks_and_zero_size_does_not_advance() {
|
||||||
|
let mut store = IoStreamStore::with_limits(2, 1024);
|
||||||
|
let handle = store.insert(b"abcdef".to_vec()).unwrap();
|
||||||
|
|
||||||
|
let (empty, eof) = store.read(&handle, Some(1), 0).unwrap();
|
||||||
|
assert_eq!(decode(&empty), b"");
|
||||||
|
assert!(!eof);
|
||||||
|
let (middle, eof) = store.read(&handle, None, 2).unwrap();
|
||||||
|
assert_eq!(decode(&middle), b"bc");
|
||||||
|
assert!(!eof);
|
||||||
|
let (tail, eof) = store.read(&handle, Some(4), 10).unwrap();
|
||||||
|
assert_eq!(decode(&tail), b"ef");
|
||||||
|
assert!(eof);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_rejects_negative_or_non_integer_ranges() {
|
||||||
|
let mut ctx = CdpContext::new();
|
||||||
|
let handle_id = ctx.io_streams.insert(b"data".to_vec()).unwrap();
|
||||||
|
for params in [
|
||||||
|
json!({"handle": handle_id.clone(), "offset": -1}),
|
||||||
|
json!({"handle": handle_id.clone(), "size": -1}),
|
||||||
|
json!({"handle": handle_id, "offset": 1.5}),
|
||||||
|
] {
|
||||||
|
assert!(handle("read", ¶ms, &mut ctx).await.is_err(), "{params}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evicts_oldest_over_entry_cap() {
|
fn evicts_oldest_over_entry_cap() {
|
||||||
std::env::set_var("OBSCURA_IO_STREAM_MAX_ENTRIES", "3");
|
let mut store = IoStreamStore::with_limits(3, 1024);
|
||||||
let mut store = IoStreamStore::default();
|
let h0 = store.insert(vec![0]).unwrap();
|
||||||
let h0 = store.insert(vec![0]);
|
let h1 = store.insert(vec![1]).unwrap();
|
||||||
let h1 = store.insert(vec![1]);
|
let _h2 = store.insert(vec![2]).unwrap();
|
||||||
let _h2 = store.insert(vec![2]);
|
let h3 = store.insert(vec![3]).unwrap(); // 4th entry, cap 3 -> h0 evicted
|
||||||
let h3 = store.insert(vec![3]); // 4th entry, cap 3 -> h0 evicted
|
|
||||||
std::env::remove_var("OBSCURA_IO_STREAM_MAX_ENTRIES");
|
|
||||||
|
|
||||||
assert!(store.read(&h0, 10).is_none(), "oldest stream should be evicted");
|
assert!(
|
||||||
assert!(store.read(&h1, 10).is_some());
|
store.read(&h0, None, 10).is_none(),
|
||||||
assert!(store.read(&h3, 10).is_some());
|
"oldest stream should be evicted"
|
||||||
|
);
|
||||||
|
assert!(store.read(&h1, None, 10).is_some());
|
||||||
|
assert!(store.read(&h3, None, 10).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evicts_over_byte_cap_but_keeps_newest() {
|
fn evicts_over_byte_cap_and_rejects_oversized_body() {
|
||||||
std::env::set_var("OBSCURA_IO_STREAM_MAX_BYTES", "10");
|
let mut store = IoStreamStore::with_limits(4, 10);
|
||||||
let mut store = IoStreamStore::default();
|
let h0 = store.insert(vec![0u8; 8]).unwrap();
|
||||||
let h0 = store.insert(vec![0u8; 8]);
|
let h1 = store.insert(vec![1u8; 8]).unwrap(); // 16 > 10 -> h0 evicted
|
||||||
let h1 = store.insert(vec![1u8; 8]); // 16 > 10 -> h0 evicted
|
let error = store
|
||||||
// A single body larger than the cap is still kept (client asked for it).
|
.insert(vec![2u8; 100])
|
||||||
let big = store.insert(vec![2u8; 100]);
|
.expect_err("a single body cannot bypass the context byte cap");
|
||||||
std::env::remove_var("OBSCURA_IO_STREAM_MAX_BYTES");
|
|
||||||
|
|
||||||
assert!(store.read(&h0, 100).is_none());
|
assert!(store.read(&h0, None, 100).is_none());
|
||||||
assert!(store.read(&big, 200).is_some(), "the just-inserted body is never evicted");
|
assert!(store.read(&h1, None, 100).is_some());
|
||||||
let _ = h1;
|
assert!(error.contains("exceeding"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requested_read_size_is_capped() {
|
||||||
|
let mut store = IoStreamStore::with_limits(2, MAX_READ_CHUNK * 2);
|
||||||
|
let handle = store.insert(vec![7u8; MAX_READ_CHUNK + 17]).unwrap();
|
||||||
|
let (first, eof) = store.read(&handle, None, usize::MAX).unwrap();
|
||||||
|
assert_eq!(decode(&first).len(), MAX_READ_CHUNK);
|
||||||
|
assert!(!eof);
|
||||||
|
let (second, eof) = store.read(&handle, None, usize::MAX).unwrap();
|
||||||
|
assert_eq!(decode(&second).len(), 17);
|
||||||
|
assert!(eof);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
pub mod target;
|
pub mod accessibility;
|
||||||
pub mod browser;
|
pub mod browser;
|
||||||
pub mod page;
|
|
||||||
pub mod dom;
|
pub mod dom;
|
||||||
pub mod domsnapshot;
|
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 emulation;
|
||||||
|
pub mod fetch;
|
||||||
|
pub mod input;
|
||||||
|
pub mod io;
|
||||||
|
pub mod lp;
|
||||||
|
pub mod network;
|
||||||
|
pub mod page;
|
||||||
|
pub mod pdf;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod target;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
|||||||
|
#[cfg(feature = "render")]
|
||||||
|
use serde_json::json;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::dispatch::CdpContext;
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
const MAX_BASE64_PDF_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn base64_encoded_len(raw_len: usize) -> Option<usize> {
|
||||||
|
raw_len.checked_add(2)?.checked_div(3)?.checked_mul(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum PdfTransferMode {
|
||||||
|
ReturnAsBase64,
|
||||||
|
ReturnAsStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct ParsedPdfOptions {
|
||||||
|
raster: obscura_browser::RasterPdfOptions,
|
||||||
|
transfer_mode: PdfTransferMode,
|
||||||
|
requested_print_background: bool,
|
||||||
|
requested_tagged_pdf: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn number(params: &Value, name: &str, default: f32) -> Result<f32, String> {
|
||||||
|
match params.get(name) {
|
||||||
|
None => Ok(default),
|
||||||
|
Some(value) => value
|
||||||
|
.as_f64()
|
||||||
|
.filter(|value| value.is_finite())
|
||||||
|
.map(|value| value as f32)
|
||||||
|
.ok_or_else(|| format!("Invalid parameters: {name} must be a finite number")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn boolean(params: &Value, name: &str, default: bool) -> Result<bool, String> {
|
||||||
|
match params.get(name) {
|
||||||
|
None => Ok(default),
|
||||||
|
Some(Value::Bool(value)) => Ok(*value),
|
||||||
|
Some(_) => Err(format!("Invalid parameters: {name} must be a boolean")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
|
||||||
|
if !params.is_object() {
|
||||||
|
return Err("Invalid parameters: expected an object".to_string());
|
||||||
|
}
|
||||||
|
let unsupported_true = [
|
||||||
|
("displayHeaderFooter", "headers and footers"),
|
||||||
|
("preferCSSPageSize", "CSS @page sizing"),
|
||||||
|
("generateDocumentOutline", "document outlines"),
|
||||||
|
];
|
||||||
|
for (name, capability) in unsupported_true {
|
||||||
|
if boolean(params, name, false)? {
|
||||||
|
return Err(format!(
|
||||||
|
"Page.printToPDF does not yet support {capability} ({name}=true)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Current Puppeteer and Playwright always send their false default. The
|
||||||
|
// raster paginator cannot suppress backgrounds without a second paint
|
||||||
|
// mode, so accept the protocol shape and disclose the limitation in the
|
||||||
|
// extension capability fields returned with the result.
|
||||||
|
let requested_print_background = boolean(params, "printBackground", false)?;
|
||||||
|
// Puppeteer currently sends true by default. Raster PDFs have no semantic
|
||||||
|
// structure to tag, but rejecting the request makes the standard client
|
||||||
|
// unusable. Accept and truthfully report `taggedPdf:false` in the response.
|
||||||
|
let requested_tagged_pdf = boolean(params, "generateTaggedPDF", false)?;
|
||||||
|
let scale = number(params, "scale", 1.0)?;
|
||||||
|
if (scale - 1.0).abs() > f32::EPSILON {
|
||||||
|
return Err(
|
||||||
|
"Page.printToPDF scale is not supported by the raster paginator; only scale=1 is accepted"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(value) = params.get("pageRanges") {
|
||||||
|
let ranges = value
|
||||||
|
.as_str()
|
||||||
|
.ok_or("Invalid parameters: pageRanges must be a string")?;
|
||||||
|
if !ranges.trim().is_empty() {
|
||||||
|
return Err("Page.printToPDF pageRanges are not yet supported".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name in ["headerTemplate", "footerTemplate"] {
|
||||||
|
if let Some(value) = params.get(name) {
|
||||||
|
let template = value
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| format!("Invalid parameters: {name} must be a string"))?;
|
||||||
|
if !template.is_empty() {
|
||||||
|
return Err(format!("Page.printToPDF {name} is not yet supported"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let transfer_mode = match params.get("transferMode") {
|
||||||
|
None => PdfTransferMode::ReturnAsBase64,
|
||||||
|
Some(Value::String(value)) => match value.as_str() {
|
||||||
|
"ReturnAsBase64" => PdfTransferMode::ReturnAsBase64,
|
||||||
|
"ReturnAsStream" => PdfTransferMode::ReturnAsStream,
|
||||||
|
_ => return Err("Invalid parameters: unknown transferMode".into()),
|
||||||
|
},
|
||||||
|
Some(_) => return Err("Invalid parameters: transferMode must be a string".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let defaults = obscura_browser::RasterPdfOptions::default();
|
||||||
|
Ok(ParsedPdfOptions {
|
||||||
|
raster: obscura_browser::RasterPdfOptions {
|
||||||
|
landscape: boolean(params, "landscape", defaults.landscape)?,
|
||||||
|
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
|
||||||
|
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
|
||||||
|
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
|
||||||
|
margin_bottom_in: number(params, "marginBottom", defaults.margin_bottom_in)?,
|
||||||
|
margin_left_in: number(params, "marginLeft", defaults.margin_left_in)?,
|
||||||
|
margin_right_in: number(params, "marginRight", defaults.margin_right_in)?,
|
||||||
|
},
|
||||||
|
transfer_mode,
|
||||||
|
requested_print_background,
|
||||||
|
requested_tagged_pdf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn print_to_pdf(
|
||||||
|
params: &Value,
|
||||||
|
ctx: &mut CdpContext,
|
||||||
|
session_id: &Option<String>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
{
|
||||||
|
let options = parse_options(params)?;
|
||||||
|
let deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.unwrap_or(3_000);
|
||||||
|
let page = ctx
|
||||||
|
.get_session_page_mut(session_id)
|
||||||
|
.ok_or("No page for session")?;
|
||||||
|
let _ = page.prepare_screenshot_resources(deadline_ms).await;
|
||||||
|
let pdf = page
|
||||||
|
.raster_pdf(options.raster)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut response = json!({
|
||||||
|
"obscuraPrintMode": "screen-raster",
|
||||||
|
"obscuraPrintBackground": true,
|
||||||
|
"obscuraRequestedPrintBackground": options.requested_print_background,
|
||||||
|
"obscuraTaggedPDF": false,
|
||||||
|
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
|
||||||
|
"obscuraCapabilities": {
|
||||||
|
"cssPagedMedia": false,
|
||||||
|
"honorsPrintBackground": false,
|
||||||
|
"taggedPdf": false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let mut ignored_options = Vec::new();
|
||||||
|
if !options.requested_print_background {
|
||||||
|
ignored_options.push("printBackground");
|
||||||
|
}
|
||||||
|
if options.requested_tagged_pdf {
|
||||||
|
ignored_options.push("generateTaggedPDF");
|
||||||
|
}
|
||||||
|
response["obscuraIgnoredOptions"] = json!(ignored_options);
|
||||||
|
match options.transfer_mode {
|
||||||
|
PdfTransferMode::ReturnAsBase64 => {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let encoded_len = base64_encoded_len(pdf.len())
|
||||||
|
.ok_or("Page.printToPDF base64 response size overflow")?;
|
||||||
|
if encoded_len > MAX_BASE64_PDF_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"Page.printToPDF base64 response would be {encoded_len} bytes, exceeding the {MAX_BASE64_PDF_BYTES}-byte response limit; use transferMode=ReturnAsStream"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
response["data"] =
|
||||||
|
Value::String(base64::engine::general_purpose::STANDARD.encode(pdf));
|
||||||
|
}
|
||||||
|
PdfTransferMode::ReturnAsStream => {
|
||||||
|
let handle = ctx
|
||||||
|
.io_streams
|
||||||
|
.insert(pdf)
|
||||||
|
.map_err(|error| format!("Page.printToPDF could not open stream: {error}"))?;
|
||||||
|
response["data"] = Value::String(String::new());
|
||||||
|
response["stream"] = Value::String(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "render"))]
|
||||||
|
{
|
||||||
|
let _ = (params, ctx, session_id);
|
||||||
|
Err("Page.printToPDF requires a build with the render feature".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "render"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domains::page;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_accepts_standard_client_defaults_and_rejects_unrepresented_features() {
|
||||||
|
let standard = parse_options(&json!({
|
||||||
|
"transferMode": "ReturnAsStream",
|
||||||
|
"displayHeaderFooter": false,
|
||||||
|
"headerTemplate": "",
|
||||||
|
"footerTemplate": "",
|
||||||
|
"printBackground": false,
|
||||||
|
"scale": 1,
|
||||||
|
"pageRanges": "",
|
||||||
|
"preferCSSPageSize": false,
|
||||||
|
"generateTaggedPDF": true,
|
||||||
|
"generateDocumentOutline": false,
|
||||||
|
}))
|
||||||
|
.expect("current Puppeteer defaults must be accepted");
|
||||||
|
assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream);
|
||||||
|
assert!(!standard.requested_print_background);
|
||||||
|
assert!(standard.requested_tagged_pdf);
|
||||||
|
|
||||||
|
for params in [
|
||||||
|
json!({"displayHeaderFooter": true}),
|
||||||
|
json!({"preferCSSPageSize": true}),
|
||||||
|
json!({"scale": 0.5}),
|
||||||
|
json!({"pageRanges": "2-3"}),
|
||||||
|
json!({"headerTemplate": "<span>title</span>"}),
|
||||||
|
json!({"generateDocumentOutline": true}),
|
||||||
|
] {
|
||||||
|
assert!(parse_options(¶ms).is_err(), "must reject {params}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_size_preflight_is_exact_and_overflow_safe() {
|
||||||
|
assert_eq!(base64_encoded_len(0), Some(0));
|
||||||
|
assert_eq!(base64_encoded_len(1), Some(4));
|
||||||
|
assert_eq!(base64_encoded_len(2), Some(4));
|
||||||
|
assert_eq!(base64_encoded_len(3), Some(4));
|
||||||
|
assert_eq!(base64_encoded_len(4), Some(8));
|
||||||
|
assert_eq!(base64_encoded_len(usize::MAX), None);
|
||||||
|
let largest_raw = (MAX_BASE64_PDF_BYTES / 4) * 3;
|
||||||
|
assert_eq!(base64_encoded_len(largest_raw), Some(MAX_BASE64_PDF_BYTES));
|
||||||
|
assert!(base64_encoded_len(largest_raw + 1).unwrap() > MAX_BASE64_PDF_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn print_to_pdf_returns_paginated_pdf_with_requested_media_box() {
|
||||||
|
let mut ctx = CdpContext::new();
|
||||||
|
let page_id = ctx.create_page();
|
||||||
|
let session_id = format!("{page_id}-session");
|
||||||
|
ctx.sessions.insert(session_id.clone(), page_id);
|
||||||
|
let session = Some(session_id);
|
||||||
|
ctx.get_session_page_mut(&session)
|
||||||
|
.unwrap()
|
||||||
|
.set_viewport((100.0, 80.0));
|
||||||
|
page::handle(
|
||||||
|
"navigate",
|
||||||
|
&json!({
|
||||||
|
"url": "data:text/html,<html style='margin:0'><body style='margin:0;height:400px;background:linear-gradient(red,blue)'></body></html>",
|
||||||
|
"waitUntil": "load",
|
||||||
|
}),
|
||||||
|
&mut ctx,
|
||||||
|
&session,
|
||||||
|
).await.expect("navigate fixture");
|
||||||
|
let response = print_to_pdf(
|
||||||
|
&json!({
|
||||||
|
"paperWidth": 4.0, "paperHeight": 6.0,
|
||||||
|
"marginTop": 0.5, "marginBottom": 0.5,
|
||||||
|
"marginLeft": 0.5, "marginRight": 0.5,
|
||||||
|
"printBackground": true,
|
||||||
|
}),
|
||||||
|
&mut ctx,
|
||||||
|
&session,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("raster PDF");
|
||||||
|
assert_eq!(response["obscuraPrintMode"], "screen-raster");
|
||||||
|
assert_eq!(response["obscuraPrintBackground"], true);
|
||||||
|
use base64::Engine as _;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(response["data"].as_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert!(bytes.starts_with(b"%PDF-1.4"));
|
||||||
|
assert!(bytes.ends_with(b"%%EOF\n"));
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(text.contains("/MediaBox [0 0 288.000 432.000]"));
|
||||||
|
assert!(
|
||||||
|
text.matches("/Subtype /Image").count() >= 2,
|
||||||
|
"400px tall fixture should paginate at this printable aspect ratio"
|
||||||
|
);
|
||||||
|
|
||||||
|
let streamed = page::handle(
|
||||||
|
"printToPDF",
|
||||||
|
&json!({
|
||||||
|
"transferMode": "ReturnAsStream",
|
||||||
|
"landscape": false,
|
||||||
|
"displayHeaderFooter": false,
|
||||||
|
"headerTemplate": "",
|
||||||
|
"footerTemplate": "",
|
||||||
|
"printBackground": false,
|
||||||
|
"scale": 1,
|
||||||
|
"paperWidth": 8.5,
|
||||||
|
"paperHeight": 11,
|
||||||
|
"marginTop": 0,
|
||||||
|
"marginBottom": 0,
|
||||||
|
"marginLeft": 0,
|
||||||
|
"marginRight": 0,
|
||||||
|
"pageRanges": "",
|
||||||
|
"preferCSSPageSize": false,
|
||||||
|
"generateTaggedPDF": true,
|
||||||
|
"generateDocumentOutline": false,
|
||||||
|
}),
|
||||||
|
&mut ctx,
|
||||||
|
&session,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Puppeteer/Playwright-shaped stream request");
|
||||||
|
assert_eq!(streamed["data"], "");
|
||||||
|
assert_eq!(streamed["obscuraPrintBackground"], true);
|
||||||
|
assert_eq!(streamed["obscuraRequestedPrintBackground"], false);
|
||||||
|
assert_eq!(streamed["obscuraTaggedPDF"], false);
|
||||||
|
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
|
||||||
|
assert_eq!(
|
||||||
|
streamed["obscuraIgnoredOptions"],
|
||||||
|
json!(["printBackground", "generateTaggedPDF"])
|
||||||
|
);
|
||||||
|
let handle = streamed["stream"].as_str().expect("protocol stream handle");
|
||||||
|
let mut streamed_bytes = Vec::new();
|
||||||
|
loop {
|
||||||
|
let chunk = crate::domains::io::handle(
|
||||||
|
"read",
|
||||||
|
&json!({"handle": handle, "size": 1024}),
|
||||||
|
&mut ctx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("read PDF stream");
|
||||||
|
streamed_bytes.extend(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(chunk["data"].as_str().unwrap())
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
if chunk["eof"] == true {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(streamed_bytes.starts_with(b"%PDF-1.4"));
|
||||||
|
assert!(streamed_bytes.ends_with(b"%%EOF\n"));
|
||||||
|
crate::domains::io::handle("close", &json!({"handle": handle}), &mut ctx)
|
||||||
|
.await
|
||||||
|
.expect("close PDF stream");
|
||||||
|
assert!(
|
||||||
|
crate::domains::io::handle("read", &json!({"handle": handle}), &mut ctx,)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"closed PDF handles must release their buffer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+317
-124
@@ -129,8 +129,11 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
selector: Option<String>,
|
selector: Option<String>,
|
||||||
|
|
||||||
#[arg(long, default_value_t = 5)]
|
/// Maximum adaptive post-load settle time in seconds. When supplied
|
||||||
wait: u64,
|
/// 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..))]
|
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))]
|
||||||
timeout: u64,
|
timeout: u64,
|
||||||
@@ -194,10 +197,8 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)]
|
#[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)]
|
||||||
enum DumpFormat {
|
enum DumpFormat {
|
||||||
Html,
|
Html,
|
||||||
@@ -221,7 +222,8 @@ enum DumpFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_banner(port: u16) {
|
fn print_banner(port: u16) {
|
||||||
println!(r#"
|
println!(
|
||||||
|
r#"
|
||||||
____ _
|
____ _
|
||||||
/ __ \| |
|
/ __ \| |
|
||||||
| | | | |__ ___ ___ _ _ _ __ __ _
|
| | | | |__ ___ ___ _ _ _ __ __ _
|
||||||
@@ -231,7 +233,10 @@ fn print_banner(port: u16) {
|
|||||||
|
|
||||||
Headless Browser v{}
|
Headless Browser v{}
|
||||||
CDP server: ws://127.0.0.1:{}/devtools/browser
|
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 {
|
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
|
/// 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).
|
/// 173 MB -> 140 MB) at no measurable speed cost (V8 still JITs hot paths).
|
||||||
#[cfg(target_pointer_width = "64")]
|
#[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"))]
|
#[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 {
|
fn effective_v8_flags(user: Option<&str>) -> String {
|
||||||
match normalize_v8_flags(user) {
|
match normalize_v8_flags(user) {
|
||||||
@@ -305,10 +312,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// TZ from the host is respected.
|
// TZ from the host is respected.
|
||||||
// SAFETY: runs before any V8 isolate or worker thread starts, so the env is
|
// SAFETY: runs before any V8 isolate or worker thread starts, so the env is
|
||||||
// effectively single threaded here.
|
// effectively single threaded here.
|
||||||
if let Some(tz) = std::env::var("OBSCURA_TIMEZONE").ok().filter(|s| !s.trim().is_empty()) {
|
if let Some(tz) = std::env::var("OBSCURA_TIMEZONE")
|
||||||
unsafe { std::env::set_var("TZ", tz); }
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
{
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("TZ", tz);
|
||||||
|
}
|
||||||
} else if std::env::var_os("TZ").is_none() {
|
} 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);
|
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
|
// SAFETY: set_var is unsafe in newer rustc; this runs before any
|
||||||
// spawned thread inspects the env, so it's effectively single
|
// spawned thread inspects the env, so it's effectively single
|
||||||
// threaded at this point.
|
// 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 global_proxy = args.proxy.clone();
|
||||||
let stealth = args.stealth;
|
let stealth = args.stealth;
|
||||||
|
|
||||||
match args.command {
|
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
|
// Fall back to OBSCURA_PROXY so a proxy can be supplied without
|
||||||
// putting credentials on the command line. The multi-worker load
|
// putting credentials on the command line. The multi-worker load
|
||||||
// balancer passes the proxy to each worker this way (issue #366).
|
// balancer passes the proxy to each worker this way (issue #366).
|
||||||
let proxy = merge_proxy(global_proxy.clone(), proxy)
|
let proxy = merge_proxy(global_proxy.clone(), proxy).or_else(|| {
|
||||||
.or_else(|| std::env::var("OBSCURA_PROXY").ok().filter(|s| !s.is_empty()));
|
std::env::var("OBSCURA_PROXY")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
});
|
||||||
print_banner(port);
|
print_banner(port);
|
||||||
if let Some(ref dir) = storage_dir {
|
if let Some(ref dir) = storage_dir {
|
||||||
tracing::info!("Storage dir: {}", dir.display());
|
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?;
|
run_multi_worker_serve(port, host, workers, proxy, stealth, user_agent).await?;
|
||||||
} else {
|
} else {
|
||||||
obscura_cdp::start_with_serve_options_and_limit(
|
obscura_cdp::start_with_serve_options_and_limit(
|
||||||
port, &host, proxy, stealth, user_agent, allow_file_access, storage_dir,
|
port,
|
||||||
args.allow_private_network, max_connections,
|
&host,
|
||||||
).await?;
|
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 let Some(file) = file {
|
||||||
if url.is_some() {
|
if url.is_some() {
|
||||||
anyhow::bail!("Pass URLs via a positional argument or --file, not both.");
|
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)?;
|
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 {
|
} else {
|
||||||
let url = url.ok_or_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 }) => {
|
Some(Command::Scrape {
|
||||||
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy, stealth).await?;
|
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);
|
let mcp_proxy = merge_proxy(global_proxy.clone(), proxy);
|
||||||
if http {
|
if http {
|
||||||
obscura_mcp::http::run(host, port, mcp_proxy, user_agent, stealth).await?;
|
obscura_mcp::http::run(host, port, mcp_proxy, user_agent, stealth).await?;
|
||||||
@@ -431,8 +536,8 @@ async fn run_multi_worker_serve(
|
|||||||
stealth: bool,
|
stealth: bool,
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::io::AsyncWriteExt as _;
|
use tokio::io::AsyncWriteExt as _;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
let exe = std::env::current_exe()?;
|
let exe = std::env::current_exe()?;
|
||||||
let mut children = Vec::new();
|
let mut children = Vec::new();
|
||||||
@@ -514,10 +619,7 @@ async fn run_multi_worker_serve(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = tokio::io::copy_bidirectional(
|
let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream)
|
||||||
&mut client,
|
|
||||||
&mut worker_stream,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -526,9 +628,7 @@ async fn run_multi_worker_serve(
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut s = client_stream;
|
let mut s = client_stream;
|
||||||
let _ = s
|
let _ = s
|
||||||
.write_all(
|
.write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n")
|
||||||
b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n",
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
let _ = s.shutdown().await;
|
let _ = s.shutdown().await;
|
||||||
});
|
});
|
||||||
@@ -543,8 +643,7 @@ async fn run_multi_worker_serve(
|
|||||||
match tokio::net::TcpStream::connect(&worker_addr).await {
|
match tokio::net::TcpStream::connect(&worker_addr).await {
|
||||||
Ok(mut worker_stream) => {
|
Ok(mut worker_stream) => {
|
||||||
let mut client = client_stream;
|
let mut client = client_stream;
|
||||||
let _ =
|
let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await;
|
||||||
tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("worker {} unreachable: {}", worker_addr, 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(
|
async fn run_fetch(
|
||||||
url_str: &str,
|
url_str: &str,
|
||||||
dump: Option<DumpFormat>,
|
dump: Option<DumpFormat>,
|
||||||
selector: Option<String>,
|
selector: Option<String>,
|
||||||
wait_secs: u64,
|
wait_secs: u64,
|
||||||
|
wait_is_fixed: bool,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
wait_until: &str,
|
wait_until: &str,
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
@@ -587,13 +696,7 @@ async fn run_fetch(
|
|||||||
// payloads (images, fonts, …) and any non-HTML resource where parsing the
|
// payloads (images, fonts, …) and any non-HTML resource where parsing the
|
||||||
// body through the DOM/JS layer would corrupt or discard data.
|
// body through the DOM/JS layer would corrupt or discard data.
|
||||||
if dump == DumpFormat::Original {
|
if dump == DumpFormat::Original {
|
||||||
let bytes = fetch_original_bytes(
|
let bytes = fetch_original_bytes(url_str, proxy, user_agent.clone(), timeout_secs).await?;
|
||||||
url_str,
|
|
||||||
proxy,
|
|
||||||
user_agent.clone(),
|
|
||||||
timeout_secs,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
write_or_print_bytes(&bytes, output.as_ref()).await?;
|
write_or_print_bytes(&bytes, output.as_ref()).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -643,8 +746,7 @@ async fn run_fetch(
|
|||||||
// absent.
|
// absent.
|
||||||
let eval_at_capture_boundary = screenshot.is_some()
|
let eval_at_capture_boundary = screenshot.is_some()
|
||||||
&& eval.is_some()
|
&& eval.is_some()
|
||||||
&& std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE")
|
&& std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE").is_ok_and(|value| value == "1");
|
||||||
.is_ok_and(|value| value == "1");
|
|
||||||
let controlled_scroll_request = screenshot.as_ref().and_then(|_| {
|
let controlled_scroll_request = screenshot.as_ref().and_then(|_| {
|
||||||
let raw_y = std::env::var("OBSCURA_SHOT_SCROLL_Y").ok()?;
|
let raw_y = std::env::var("OBSCURA_SHOT_SCROLL_Y").ok()?;
|
||||||
let x = match std::env::var("OBSCURA_SHOT_SCROLL_X") {
|
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 {
|
let settle_passes = if eval_at_capture_boundary {
|
||||||
1 + u64::from(controlled_scroll_request.is_some())
|
1 + u64::from(controlled_scroll_request.is_some())
|
||||||
} else if eval.is_some()
|
} else if eval.is_some() && (screenshot.is_some() || selector.is_some() || dump_specified) {
|
||||||
&& (screenshot.is_some() || selector.is_some() || dump_specified)
|
|
||||||
{
|
|
||||||
2
|
2
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
@@ -687,13 +787,23 @@ async fn run_fetch(
|
|||||||
);
|
);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(hard);
|
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);
|
std::process::exit(124);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match timeout(Duration::from_secs(timeout_secs), page.navigate_with_wait(url_str, wait_condition)).await {
|
match timeout(
|
||||||
Ok(result) => result.map_err(|e| anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e))?,
|
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!(
|
Err(_) => anyhow::bail!(
|
||||||
"Timed out navigating to {} after {}s",
|
"Timed out navigating to {} after {}s",
|
||||||
url_str,
|
url_str,
|
||||||
@@ -709,7 +819,7 @@ async fn run_fetch(
|
|||||||
// and completion callbacks (e.g. testharness's add_completion_callback) run
|
// 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
|
// before we read the page. Returns early once the loop is idle, so static
|
||||||
// pages stay fast.
|
// 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 mut deferred_eval_output = None;
|
||||||
let initial_controlled_scroll = if eval_at_capture_boundary {
|
let initial_controlled_scroll = if eval_at_capture_boundary {
|
||||||
@@ -731,7 +841,7 @@ async fn run_fetch(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
if initial_controlled_scroll.is_some() {
|
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 {
|
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
|
// listener) that writes the DOM. Drive the event loop again so that
|
||||||
// work completes, then fall through to selector/capture/dump instead
|
// work completes, then fall through to selector/capture/dump instead
|
||||||
// of returning the still-pending eval value (issue #248).
|
// 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()
|
.ok()
|
||||||
.and_then(|value| value.parse::<u64>().ok())
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
.unwrap_or(3_000);
|
.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.
|
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
|
||||||
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
||||||
// capture below-the-fold content in one shot).
|
// 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
|
// render resources during prepare/paint, so sampling first would
|
||||||
// compare pre-paint Obscura state with post-load Chromium state.
|
// compare pre-paint Obscura state with post-load Chromium state.
|
||||||
// Keep this private opt-in out of ordinary CLI screenshots.
|
// Keep this private opt-in out of ordinary CLI screenshots.
|
||||||
let warmup_capture = std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP")
|
let warmup_capture =
|
||||||
.is_ok_and(|value| value == "1");
|
std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP").is_ok_and(|value| value == "1");
|
||||||
if warmup_capture {
|
if warmup_capture {
|
||||||
if page.screenshot(viewport).is_none() {
|
if page.screenshot(viewport).is_none() {
|
||||||
anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render");
|
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 eval_at_capture_boundary {
|
||||||
if let Some(ref expr) = eval {
|
if let Some(ref expr) = eval {
|
||||||
deferred_eval_output = Some(
|
deferred_eval_output =
|
||||||
page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs))
|
Some(page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let capture_state = deferred_eval_output.as_ref().map(|_| {
|
let capture_state = deferred_eval_output.as_ref().map(|_| {
|
||||||
@@ -896,7 +1007,11 @@ async fn run_fetch(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !quiet {
|
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();
|
context.save_cookies();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -958,7 +1073,11 @@ async fn fetch_original_bytes(
|
|||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
) -> anyhow::Result<Vec<u8>> {
|
) -> 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 `-`).
|
/// Read newline-delimited URLs from `path` (or stdin when `path` is `-`).
|
||||||
@@ -1024,8 +1143,12 @@ async fn run_batch_fetch(
|
|||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = sem.acquire().await.unwrap();
|
let _permit = sem.acquire().await.unwrap();
|
||||||
let task_start = Instant::now();
|
let task_start = Instant::now();
|
||||||
let result =
|
let result = fetch_original_response(
|
||||||
fetch_original_response(&url, (*proxy).clone(), (*user_agent).clone(), timeout_secs)
|
&url,
|
||||||
|
(*proxy).clone(),
|
||||||
|
(*user_agent).clone(),
|
||||||
|
timeout_secs,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
let elapsed_ms = task_start.elapsed().as_millis();
|
let elapsed_ms = task_start.elapsed().as_millis();
|
||||||
|
|
||||||
@@ -1097,7 +1220,10 @@ async fn run_batch_fetch(
|
|||||||
Ok(())
|
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 {
|
if let Some(path) = output {
|
||||||
tokio::fs::write(path, content)
|
tokio::fs::write(path, content)
|
||||||
.await
|
.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 {
|
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);
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
|
||||||
loop {
|
loop {
|
||||||
let found = page.with_dom(|dom| {
|
let found = page
|
||||||
dom.query_selector(selector).ok().flatten().is_some()
|
.with_dom(|dom| dom.query_selector(selector).ok().flatten().is_some())
|
||||||
}).unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if found {
|
if found {
|
||||||
return true;
|
return true;
|
||||||
@@ -1176,7 +1302,8 @@ fn dump_html(page: &Page) -> String {
|
|||||||
let doc = dom.document();
|
let doc = dom.document();
|
||||||
dom.inner_html(doc)
|
dom.inner_html(doc)
|
||||||
}
|
}
|
||||||
}).unwrap_or_default()
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dump_text(page: &mut Page) -> String {
|
fn dump_text(page: &mut Page) -> String {
|
||||||
@@ -1187,7 +1314,8 @@ fn dump_text(page: &mut Page) -> String {
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
}).unwrap_or_default()
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dump_markdown(page: &mut Page) -> String {
|
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
|
// Boilerplate elements rarely contain content the user wants to
|
||||||
// scrape — strip them so `--dump text` returns the article body
|
// scrape — strip them so `--dump text` returns the article body
|
||||||
// instead of menus, footers, and cookie banners.
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_block = matches!(
|
let is_block = matches!(
|
||||||
tag,
|
tag,
|
||||||
"div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
"div"
|
||||||
| "li" | "tr" | "br" | "hr" | "blockquote" | "pre"
|
| "p"
|
||||||
| "section" | "article" | "header" | "footer" | "nav"
|
| "h1"
|
||||||
| "main" | "aside" | "figure" | "figcaption" | "table"
|
| "h2"
|
||||||
| "thead" | "tbody" | "tfoot" | "dl" | "dt" | "dd"
|
| "h3"
|
||||||
| "ul" | "ol"
|
| "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 {
|
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()
|
let worker_path = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parent().map(|d| d.join(worker_name)))
|
.and_then(|p| p.parent().map(|d| d.join(worker_name)))
|
||||||
@@ -1382,7 +1543,8 @@ async fn run_parallel_scrape(
|
|||||||
};
|
};
|
||||||
let mut reader = BufReader::new(stdout);
|
let mut reader = BufReader::new(stdout);
|
||||||
|
|
||||||
let worker_result: Result<serde_json::Value, String> = match timeout(worker_timeout, async {
|
let worker_result: Result<serde_json::Value, String> =
|
||||||
|
match timeout(worker_timeout, async {
|
||||||
let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url});
|
let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url});
|
||||||
let mut line = serde_json::to_string(&nav_cmd).unwrap();
|
let mut line = serde_json::to_string(&nav_cmd).unwrap();
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
@@ -1400,16 +1562,14 @@ async fn run_parallel_scrape(
|
|||||||
Err(_) => return Err("timeout".to_string()),
|
Err(_) => return Err("timeout".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let nav_resp: serde_json::Value =
|
let nav_resp: serde_json::Value = serde_json::from_str(resp_line.trim())
|
||||||
serde_json::from_str(resp_line.trim()).unwrap_or(serde_json::json!({"ok": false}));
|
.unwrap_or(serde_json::json!({"ok": false}));
|
||||||
|
|
||||||
if !nav_resp["ok"].as_bool().unwrap_or(false) {
|
if !nav_resp["ok"].as_bool().unwrap_or(false) {
|
||||||
return Err(
|
return Err(nav_resp["error"]
|
||||||
nav_resp["error"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("navigate failed")
|
.unwrap_or("navigate failed")
|
||||||
.to_string(),
|
.to_string());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = nav_resp["result"]["title"]
|
let title = nav_resp["result"]["title"]
|
||||||
@@ -1431,7 +1591,8 @@ async fn run_parallel_scrape(
|
|||||||
let mut resp_line = String::new();
|
let mut resp_line = String::new();
|
||||||
match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
|
match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
|
||||||
Ok(Ok(bytes)) if bytes > 0 => {
|
Ok(Ok(bytes)) if bytes > 0 => {
|
||||||
let resp: serde_json::Value = serde_json::from_str(resp_line.trim())
|
let resp: serde_json::Value =
|
||||||
|
serde_json::from_str(resp_line.trim())
|
||||||
.unwrap_or(serde_json::json!({"ok": false}));
|
.unwrap_or(serde_json::json!({"ok": false}));
|
||||||
resp["result"].clone()
|
resp["result"].clone()
|
||||||
}
|
}
|
||||||
@@ -1537,7 +1698,9 @@ fn dump_links(page: &Page) -> String {
|
|||||||
let full_url = if href.starts_with("http://") || href.starts_with("https://") {
|
let full_url = if href.starts_with("http://") || href.starts_with("https://") {
|
||||||
href.clone()
|
href.clone()
|
||||||
} else if let Some(ref base) = base_url {
|
} 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 {
|
} else {
|
||||||
href.clone()
|
href.clone()
|
||||||
};
|
};
|
||||||
@@ -1552,7 +1715,8 @@ fn dump_links(page: &Page) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rendered.join("\n")
|
rendered.join("\n")
|
||||||
}).unwrap_or_default()
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selectors paired with the attribute whose URL we extract and the
|
/// 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
|
/// Unknown / missing `rel` falls back to a generic "link" so the
|
||||||
/// caller still sees the URL.
|
/// caller still sees the URL.
|
||||||
fn link_kind_from_rel(rel: &str) -> &'static str {
|
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",
|
"stylesheet" => "stylesheet",
|
||||||
"icon" | "shortcut" => "icon",
|
"icon" | "shortcut" => "icon",
|
||||||
"manifest" => "manifest",
|
"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 {
|
for (selector, attr, default_kind) in ASSET_SELECTORS {
|
||||||
let nodes = dom.query_selector_all(selector).unwrap_or_default();
|
let nodes = dom.query_selector_all(selector).unwrap_or_default();
|
||||||
for node_id in nodes {
|
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 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 kind = if *default_kind == "link" {
|
||||||
let rel = node.get_attribute("rel").unwrap_or_default().to_string();
|
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()))
|
.with_dom(|dom| extract_assets(dom, base_url.as_ref()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut lines: Vec<String> =
|
let mut lines: Vec<String> = dom_ndjson
|
||||||
dom_ndjson.lines().filter(|l| !l.is_empty()).map(|l| l.to_string()).collect();
|
.lines()
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
// URLs already listed from static DOM attributes, so a resource the script
|
// URLs already listed from static DOM attributes, so a resource the script
|
||||||
// fetches that the markup also references is not emitted twice.
|
// fetches that the markup also references is not emitted twice.
|
||||||
@@ -1669,9 +1846,9 @@ fn dump_assets(page: &Page) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes,
|
effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes,
|
||||||
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags,
|
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags, read_urls_from_file,
|
||||||
read_urls_from_file, resolve_asset_url, select_log_filter, write_or_print,
|
resolve_asset_url, select_log_filter, write_or_print, write_or_print_bytes, Args, Command,
|
||||||
write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS,
|
DumpFormat, DEFAULT_V8_FLAGS,
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use obscura_dom::parse_html;
|
use obscura_dom::parse_html;
|
||||||
@@ -1720,7 +1897,13 @@ mod tests {
|
|||||||
])
|
])
|
||||||
.expect("clap should accept --file with --concurrency and no positional URL");
|
.expect("clap should accept --file with --concurrency and no positional URL");
|
||||||
match args.command {
|
match args.command {
|
||||||
Some(Command::Fetch { url, file, concurrency, dump, .. }) => {
|
Some(Command::Fetch {
|
||||||
|
url,
|
||||||
|
file,
|
||||||
|
concurrency,
|
||||||
|
dump,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
assert!(url.is_none());
|
assert!(url.is_none());
|
||||||
assert_eq!(file, Some(std::path::PathBuf::from("urls.txt")));
|
assert_eq!(file, Some(std::path::PathBuf::from("urls.txt")));
|
||||||
assert_eq!(concurrency.get(), 25);
|
assert_eq!(concurrency.get(), 25);
|
||||||
@@ -1734,7 +1917,8 @@ mod tests {
|
|||||||
fn concurrency_rejects_zero() {
|
fn concurrency_rejects_zero() {
|
||||||
// NonZeroUsize means --concurrency 0 is a parse error, not a silent hang
|
// NonZeroUsize means --concurrency 0 is a parse error, not a silent hang
|
||||||
// on a zero-permit semaphore.
|
// 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());
|
assert!(err.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1788,7 +1972,10 @@ mod tests {
|
|||||||
|
|
||||||
let _ = tokio::fs::remove_file(&path).await;
|
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")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
@@ -1811,7 +1998,10 @@ mod tests {
|
|||||||
let read_back = tokio::fs::read(&path).await.expect("read back");
|
let read_back = tokio::fs::read(&path).await.expect("read back");
|
||||||
let _ = tokio::fs::remove_file(&path).await;
|
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")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
@@ -1856,12 +2046,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parsed_fetch_with_quiet_flag_is_detected() {
|
fn parsed_fetch_with_quiet_flag_is_detected() {
|
||||||
let args = Args::try_parse_from([
|
let args = Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"])
|
||||||
"obscura",
|
|
||||||
"fetch",
|
|
||||||
"--quiet",
|
|
||||||
"https://example.com",
|
|
||||||
])
|
|
||||||
.expect("clap should accept --quiet on fetch");
|
.expect("clap should accept --quiet on fetch");
|
||||||
assert!(is_quiet_command(&args.command));
|
assert!(is_quiet_command(&args.command));
|
||||||
}
|
}
|
||||||
@@ -1875,8 +2060,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parsed_serve_command_is_not_quiet() {
|
fn parsed_serve_command_is_not_quiet() {
|
||||||
let args = Args::try_parse_from(["obscura", "serve"])
|
let args = Args::try_parse_from(["obscura", "serve"]).expect("clap should accept serve");
|
||||||
.expect("clap should accept serve");
|
|
||||||
assert!(!is_quiet_command(&args.command));
|
assert!(!is_quiet_command(&args.command));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1938,13 +2122,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parsed_v8_flags_empty_string_is_accepted() {
|
fn parsed_v8_flags_empty_string_is_accepted() {
|
||||||
let args = Args::try_parse_from([
|
let args =
|
||||||
"obscura",
|
Args::try_parse_from(["obscura", "--v8-flags", "", "fetch", "https://example.com"])
|
||||||
"--v8-flags",
|
|
||||||
"",
|
|
||||||
"fetch",
|
|
||||||
"https://example.com",
|
|
||||||
])
|
|
||||||
.expect("clap should accept empty --v8-flags value");
|
.expect("clap should accept empty --v8-flags value");
|
||||||
assert_eq!(args.v8_flags.as_deref(), Some(""));
|
assert_eq!(args.v8_flags.as_deref(), Some(""));
|
||||||
}
|
}
|
||||||
@@ -2001,17 +2180,29 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parsed_fetch_quiet_resolves_to_off_filter() {
|
fn parsed_fetch_quiet_resolves_to_off_filter() {
|
||||||
let args = Args::try_parse_from([
|
let args =
|
||||||
"obscura",
|
Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"]).unwrap();
|
||||||
"fetch",
|
|
||||||
"--quiet",
|
|
||||||
"https://example.com",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
let filter = select_log_filter(args.verbose, is_quiet_command(&args.command));
|
let filter = select_log_filter(args.verbose, is_quiet_command(&args.command));
|
||||||
assert_eq!(filter, "off");
|
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]
|
#[test]
|
||||||
fn matcher_still_uses_fetch_variant() {
|
fn matcher_still_uses_fetch_variant() {
|
||||||
let cmd = Some(Command::Fetch {
|
let cmd = Some(Command::Fetch {
|
||||||
@@ -2020,7 +2211,7 @@ mod tests {
|
|||||||
selector: None,
|
selector: None,
|
||||||
file: None,
|
file: None,
|
||||||
concurrency: std::num::NonZeroUsize::new(1).unwrap(),
|
concurrency: std::num::NonZeroUsize::new(1).unwrap(),
|
||||||
wait: 5,
|
wait: Some(5),
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
wait_until: "load".to_string(),
|
wait_until: "load".to_string(),
|
||||||
user_agent: None,
|
user_agent: None,
|
||||||
@@ -2040,7 +2231,10 @@ mod tests {
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.expect("body must exist");
|
.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]
|
#[test]
|
||||||
@@ -2267,5 +2461,4 @@ mod tests {
|
|||||||
assert!(lines[0].contains("\"https://example.test/ok.html\""));
|
assert!(lines[0].contains("\"https://example.test/ok.html\""));
|
||||||
assert!(lines[0].contains("\"iframe\""));
|
assert!(lines[0].contains("\"iframe\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,8 @@ use selectors::matching::{
|
|||||||
NeedsSelectorFlags,
|
NeedsSelectorFlags,
|
||||||
};
|
};
|
||||||
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
|
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
|
||||||
use selectors::{Element, OpaqueElement, SelectorList};
|
|
||||||
use selectors::visitor::SelectorVisitor;
|
use selectors::visitor::SelectorVisitor;
|
||||||
|
use selectors::{Element, OpaqueElement, SelectorList};
|
||||||
|
|
||||||
use crate::tree::{DomTree, NodeData, NodeId};
|
use crate::tree::{DomTree, NodeData, NodeId};
|
||||||
|
|
||||||
@@ -251,7 +251,13 @@ impl<'a> DomElement<'a> {
|
|||||||
.map(|name| {
|
.map(|name| {
|
||||||
matches!(
|
matches!(
|
||||||
name.local.as_ref(),
|
name.local.as_ref(),
|
||||||
"input" | "button" | "select" | "textarea" | "optgroup" | "option" | "fieldset"
|
"input"
|
||||||
|
| "button"
|
||||||
|
| "select"
|
||||||
|
| "textarea"
|
||||||
|
| "optgroup"
|
||||||
|
| "option"
|
||||||
|
| "fieldset"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
@@ -390,20 +396,26 @@ impl<'a> Element for DomElement<'a> {
|
|||||||
fn has_namespace(&self, ns: &CssNamespace) -> bool {
|
fn has_namespace(&self, ns: &CssNamespace) -> bool {
|
||||||
self.tree
|
self.tree
|
||||||
.with_node(self.node_id, |n| {
|
.with_node(self.node_id, |n| {
|
||||||
n.as_element()
|
n.as_element().map(|name| name.ns == ns.0).unwrap_or(false)
|
||||||
.map(|name| name.ns == ns.0)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_same_type(&self, other: &Self) -> bool {
|
fn is_same_type(&self, other: &Self) -> bool {
|
||||||
let self_name = self.tree.with_node(self.node_id, |n| {
|
let self_name = self
|
||||||
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
.tree
|
||||||
}).flatten();
|
.with_node(self.node_id, |n| {
|
||||||
let other_name = self.tree.with_node(other.node_id, |n| {
|
n.as_element()
|
||||||
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
.map(|name| (name.local.clone(), name.ns.clone()))
|
||||||
}).flatten();
|
})
|
||||||
|
.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) {
|
match (self_name, other_name) {
|
||||||
(Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
|
(Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -441,10 +453,9 @@ impl<'a> Element for DomElement<'a> {
|
|||||||
.with_node(self.node_id, |node| {
|
.with_node(self.node_id, |node| {
|
||||||
node.attrs()
|
node.attrs()
|
||||||
.map(|attrs| {
|
.map(|attrs| {
|
||||||
attrs.iter().any(|a| {
|
attrs
|
||||||
a.name.ns == html5ever::ns!()
|
.iter()
|
||||||
&& a.name.local == local_name.0
|
.any(|a| a.name.ns == html5ever::ns!() && a.name.local == local_name.0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
})
|
})
|
||||||
@@ -469,7 +480,9 @@ impl<'a> Element for DomElement<'a> {
|
|||||||
// correctly are) made every such base rule silently inert.
|
// correctly are) made every such base rule silently inert.
|
||||||
PseudoClass::Enabled => self.is_form_control() && !self.has_boolean_attr("disabled"),
|
PseudoClass::Enabled => self.is_form_control() && !self.has_boolean_attr("disabled"),
|
||||||
PseudoClass::Disabled => 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
|
// Dynamic user-interaction pseudo-classes have no meaning against
|
||||||
// a static DOM snapshot with no live user input.
|
// a static DOM snapshot with no live user input.
|
||||||
PseudoClass::Hover
|
PseudoClass::Hover
|
||||||
@@ -640,7 +653,10 @@ fn simple_id_selector(selector: &str) -> Option<&str> {
|
|||||||
if !(first.is_ascii_alphabetic() || first == b'_') {
|
if !(first.is_ascii_alphabetic() || first == b'_') {
|
||||||
return None;
|
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)
|
Some(id)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -656,7 +672,11 @@ impl DomTree {
|
|||||||
self.query_selector_all_from(self.document(), selector)
|
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)
|
// 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
|
// 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.
|
// 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 selector_list = parse_selector(selector)?;
|
||||||
let mut caches = selectors::context::SelectorCaches::default();
|
let mut caches = selectors::context::SelectorCaches::default();
|
||||||
let mut context = MatchingContext::new(
|
let mut context = MatchingContext::new(
|
||||||
@@ -746,7 +770,10 @@ impl DomTree {
|
|||||||
/// Test one element as the selector subject, including when it is
|
/// Test one element as the selector subject, including when it is
|
||||||
/// detached or is a direct child of a shadow-tree compatibility root.
|
/// detached or is a direct child of a shadow-tree compatibility root.
|
||||||
pub fn matches_selector(&self, nid: NodeId, selector: &str) -> Result<bool, String> {
|
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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let selector_list = parse_selector(selector)?;
|
let selector_list = parse_selector(selector)?;
|
||||||
@@ -768,7 +795,7 @@ impl DomTree {
|
|||||||
|
|
||||||
/// Parse a single selector once for repeated single-element matching, and
|
/// Parse a single selector once for repeated single-element matching, and
|
||||||
/// precompute its specificity, ancestor hashes, and "subject key" (the
|
/// 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.
|
/// lookup). Returns `None` if the selector fails to parse.
|
||||||
///
|
///
|
||||||
/// This is the primitive a stylesheet cascade builds on: compile every rule
|
/// 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 list = parse_selector(selector).ok()?;
|
||||||
let sel = list.slice().first()?.clone();
|
let sel = list.slice().first()?.clone();
|
||||||
let specificity = sel.specificity();
|
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);
|
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
|
/// Does a single element match a precompiled selector? Allocates a fresh
|
||||||
@@ -798,6 +830,8 @@ impl DomTree {
|
|||||||
Matcher {
|
Matcher {
|
||||||
caches: selectors::context::SelectorCaches::default(),
|
caches: selectors::context::SelectorCaches::default(),
|
||||||
ancestors: AncestorFilter::new(),
|
ancestors: AncestorFilter::new(),
|
||||||
|
candidate_generation: 0,
|
||||||
|
candidate_seen: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,9 +843,41 @@ impl DomTree {
|
|||||||
pub struct Matcher {
|
pub struct Matcher {
|
||||||
caches: selectors::context::SelectorCaches,
|
caches: selectors::context::SelectorCaches,
|
||||||
ancestors: AncestorFilter,
|
ancestors: AncestorFilter,
|
||||||
|
candidate_generation: u32,
|
||||||
|
candidate_seen: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Matcher {
|
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
|
/// Match `nid` (matched as if it were the subject element; the ancestor
|
||||||
/// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`.
|
/// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`.
|
||||||
pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool {
|
pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool {
|
||||||
@@ -827,7 +893,13 @@ impl Matcher {
|
|||||||
MatchingForInvalidation::No,
|
MatchingForInvalidation::No,
|
||||||
);
|
);
|
||||||
let element = DomElement::new(tree, nid);
|
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
|
/// Push `nid`'s hashes onto the ancestor filter before descending into its
|
||||||
@@ -854,7 +926,10 @@ struct AncestorFilter {
|
|||||||
|
|
||||||
impl AncestorFilter {
|
impl AncestorFilter {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
AncestorFilter { filter: Box::default(), levels: Vec::new() }
|
AncestorFilter {
|
||||||
|
filter: Box::default(),
|
||||||
|
levels: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filter(&self) -> &selectors::bloom::BloomFilter {
|
fn filter(&self) -> &selectors::bloom::BloomFilter {
|
||||||
@@ -865,13 +940,25 @@ impl AncestorFilter {
|
|||||||
let mut hashes = Vec::new();
|
let mut hashes = Vec::new();
|
||||||
tree.with_node(nid, |node| {
|
tree.with_node(nid, |node| {
|
||||||
if let Some(elem) = node.as_element() {
|
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") {
|
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") {
|
if let Some(class) = node.get_attribute("class") {
|
||||||
for c in class.split_whitespace() {
|
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.
|
/// only tested against elements that carry its key.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum SelectorKey {
|
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),
|
Id(String),
|
||||||
Class(String),
|
Class(String),
|
||||||
|
Attribute(String),
|
||||||
Local(String),
|
Local(String),
|
||||||
Universal,
|
Universal,
|
||||||
}
|
}
|
||||||
@@ -908,51 +1000,359 @@ pub enum SelectorKey {
|
|||||||
pub struct CompiledSelector {
|
pub struct CompiledSelector {
|
||||||
sel: parser::Selector<ObscuraSelector>,
|
sel: parser::Selector<ObscuraSelector>,
|
||||||
specificity: u32,
|
specificity: u32,
|
||||||
key: SelectorKey,
|
keys: SubjectKeys,
|
||||||
hashes: parser::AncestorHashes,
|
hashes: parser::AncestorHashes,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompiledSelector {
|
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 {
|
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 {
|
pub fn specificity(&self) -> u32 {
|
||||||
self.specificity
|
self.specificity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick the index key from a selector's rightmost compound: prefer id, then
|
enum SubjectKeys {
|
||||||
/// class, then local name; fall back to Universal for `*`, attribute-only, or
|
One(SelectorKey),
|
||||||
/// pseudo-only subjects.
|
Many(Box<[SelectorKey]>),
|
||||||
fn subject_key(sel: &parser::Selector<ObscuraSelector>) -> 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;
|
use parser::Component;
|
||||||
let mut local: Option<String> = None;
|
let mut local: Option<String> = None;
|
||||||
|
let mut attribute: Option<String> = None;
|
||||||
let mut class: Option<String> = None;
|
let mut class: Option<String> = None;
|
||||||
let mut id: 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() {
|
for comp in sel.iter_raw_match_order() {
|
||||||
match comp {
|
match comp {
|
||||||
Component::Combinator(_) => break,
|
Component::Combinator(_) => break,
|
||||||
Component::ID(v) => id = Some(v.0.clone()),
|
Component::ID(v) => id = Some(v.0.clone()),
|
||||||
|
Component::Root => root = true,
|
||||||
Component::Class(v) => class = Some(v.0.clone()),
|
Component::Class(v) => class = Some(v.0.clone()),
|
||||||
Component::LocalName(l) => local = Some(l.lower_name.0.to_string()),
|
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)
|
SelectorKey::Id(i)
|
||||||
} else if let Some(c) = class {
|
} else if let Some(c) = class {
|
||||||
SelectorKey::Class(c)
|
SelectorKey::Class(c)
|
||||||
|
} else if let Some(a) = attribute {
|
||||||
|
SelectorKey::Attribute(a)
|
||||||
} else if let Some(l) = local {
|
} else if let Some(l) = local {
|
||||||
SelectorKey::Local(l)
|
SelectorKey::Local(l)
|
||||||
} else {
|
} else {
|
||||||
SelectorKey::Universal
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::tree_sink::parse_html;
|
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]
|
#[test]
|
||||||
fn test_query_selector_tag() {
|
fn test_query_selector_tag() {
|
||||||
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
|
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
|
||||||
@@ -964,8 +1364,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_query_selector_class() {
|
fn test_query_selector_class() {
|
||||||
let tree =
|
let tree = parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
|
||||||
parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
|
|
||||||
let result = tree.query_selector(".foo").unwrap();
|
let result = tree.query_selector(".foo").unwrap();
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let node = tree.get_node(result.unwrap()).unwrap();
|
let node = tree.get_node(result.unwrap()).unwrap();
|
||||||
@@ -989,7 +1388,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_has_parses() {
|
fn test_has_parses() {
|
||||||
// Isolate parse from match: :has must parse, not error.
|
// 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]
|
#[test]
|
||||||
@@ -1005,9 +1407,17 @@ mod tests {
|
|||||||
fn test_enabled_matches_form_control_without_disabled_attr() {
|
fn test_enabled_matches_form_control_without_disabled_attr() {
|
||||||
let tree = parse_html(r#"<button>Click</button><button disabled>Nope</button>"#);
|
let tree = parse_html(r#"<button>Click</button><button disabled>Nope</button>"#);
|
||||||
let enabled = tree.query_selector_all("button:enabled").unwrap();
|
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();
|
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]
|
#[test]
|
||||||
@@ -1021,33 +1431,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_checked_matches_checked_attribute() {
|
fn test_checked_matches_checked_attribute() {
|
||||||
let tree = parse_html(
|
let tree = parse_html(r#"<input type="checkbox" checked><input type="checkbox">"#);
|
||||||
r#"<input type="checkbox" checked><input type="checkbox">"#,
|
|
||||||
);
|
|
||||||
assert_eq!(tree.query_selector_all("input:checked").unwrap().len(), 1);
|
assert_eq!(tree.query_selector_all("input:checked").unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unfocused_snapshot_matches_focus_negation_selectors() {
|
fn unfocused_snapshot_matches_focus_negation_selectors() {
|
||||||
let tree = parse_html(
|
let tree = parse_html(r#"<div class="visually-hidden-focusable">Skip</div>"#);
|
||||||
r#"<div class="visually-hidden-focusable">Skip</div>"#,
|
|
||||||
);
|
|
||||||
let hidden = tree
|
let hidden = tree
|
||||||
.query_selector_all(
|
.query_selector_all(".visually-hidden-focusable:not(:focus):not(:focus-within)")
|
||||||
".visually-hidden-focusable:not(:focus):not(:focus-within)",
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(hidden.len(), 1);
|
assert_eq!(hidden.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(tree.query_selector_all(":focus-visible").unwrap().len(), 0);
|
||||||
tree.query_selector_all(":focus-visible").unwrap().len(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_query_selector_descendant() {
|
fn test_query_selector_descendant() {
|
||||||
let tree =
|
let tree = parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
|
||||||
parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
|
|
||||||
let result = tree.query_selector("#outer span").unwrap();
|
let result = tree.query_selector("#outer span").unwrap();
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let node = tree.get_node(result.unwrap()).unwrap();
|
let node = tree.get_node(result.unwrap()).unwrap();
|
||||||
@@ -1072,9 +1472,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_query_selector_attribute() {
|
fn test_query_selector_attribute() {
|
||||||
let tree = parse_html(
|
let tree =
|
||||||
r#"<input type="text" name="user"><input type="password" name="pass">"#,
|
parse_html(r#"<input type="text" name="user"><input type="password" name="pass">"#);
|
||||||
);
|
|
||||||
let result = tree.query_selector(r#"input[type="password"]"#).unwrap();
|
let result = tree.query_selector(r#"input[type="password"]"#).unwrap();
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let node = tree.get_node(result.unwrap()).unwrap();
|
let node = tree.get_node(result.unwrap()).unwrap();
|
||||||
@@ -1151,13 +1550,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_query_selector_from_returns_first_in_subtree_only() {
|
fn test_query_selector_from_returns_first_in_subtree_only() {
|
||||||
let tree = parse_html(
|
let tree =
|
||||||
r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#,
|
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");
|
let s = tree.get_element_by_id("s").expect("section#s");
|
||||||
|
|
||||||
// Scoped to #s: skip the outside paragraph; return the first inside.
|
// 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");
|
assert_eq!(tree.text_content(first_in_s), "first");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ tokio = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
url = { 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 }
|
tracing = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
Vendored
+400
-50
@@ -87,8 +87,6 @@ const _DOM_MUTATION_COMMANDS = new Set([
|
|||||||
"set_attribute", "remove_attribute",
|
"set_attribute", "remove_attribute",
|
||||||
"set_text_content", "set_inner_html", "set_inner_html_context",
|
"set_text_content", "set_inner_html", "set_inner_html_context",
|
||||||
"set_fragment_html_executable",
|
"set_fragment_html_executable",
|
||||||
"create_element", "create_text_node", "create_comment_node",
|
|
||||||
"create_document_fragment", "clone_node",
|
|
||||||
]);
|
]);
|
||||||
const _dom = (cmd, a1, a2) => {
|
const _dom = (cmd, a1, a2) => {
|
||||||
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
|
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
|
||||||
@@ -622,11 +620,27 @@ globalThis.console = {
|
|||||||
let _tid = 0;
|
let _tid = 0;
|
||||||
const _clearedTimers = new Set();
|
const _clearedTimers = new Set();
|
||||||
const _intervals = new Set();
|
const _intervals = new Set();
|
||||||
|
const _nativeTimerIds = new Map();
|
||||||
|
const __obscuraPendingTimeoutDeadlines = new Map();
|
||||||
|
|
||||||
const _scheduleAfter = (delay, fn) => {
|
const _scheduleAfter = (delay, fn) => {
|
||||||
const d = Math.max(0, Number(delay) || 0);
|
const d = Math.max(0, Number(delay) || 0);
|
||||||
if (d === 0) Promise.resolve().then(fn);
|
// HTML timers queue tasks even when their delay is zero. Treating a
|
||||||
else Deno.core.ops.op_sleep(d).then(fn);
|
// 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
|
// 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);
|
const f = _coerceTimerFn(fn);
|
||||||
if (f === null) return ++_tid;
|
if (f === null) return ++_tid;
|
||||||
const id = ++_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;
|
if (_clearedTimers.has(id)) return;
|
||||||
try { f(...args); } catch(e) { console.error("Timer error:", e); }
|
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;
|
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) => {
|
globalThis.setInterval = (fn, delay = 0, ...args) => {
|
||||||
const f = _coerceTimerFn(fn);
|
const f = _coerceTimerFn(fn);
|
||||||
@@ -670,13 +699,18 @@ globalThis.setInterval = (fn, delay = 0, ...args) => {
|
|||||||
if (!_intervals.has(id)) return;
|
if (!_intervals.has(id)) return;
|
||||||
try { f(...args); } catch(e) { console.error("Interval error:", e); }
|
try { f(...args); } catch(e) { console.error("Interval error:", e); }
|
||||||
if (!_intervals.has(id)) return;
|
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;
|
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
|
// Animation callbacks are a rendering-phase batch, not zero-delay
|
||||||
// microtasks. In particular, a callback which queues itself must yield to
|
// 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));
|
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 {
|
class MessageChannel {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.port1 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} };
|
this.port1 = new MessagePort(_messagePortConstructionKey);
|
||||||
this.port2 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} };
|
this.port2 = new MessagePort(_messagePortConstructionKey);
|
||||||
this.port1.postMessage = (data) => {
|
_messagePortStateFor(this.port1).entangled = this.port2;
|
||||||
Promise.resolve().then(() => { if (this.port2.onmessage) this.port2.onmessage({ data }); });
|
_messagePortStateFor(this.port2).entangled = this.port1;
|
||||||
};
|
|
||||||
this.port2.postMessage = (data) => {
|
|
||||||
Promise.resolve().then(() => { if (this.port1.onmessage) this.port1.onmessage({ data }); });
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
globalThis.MessageChannel = MessageChannel;
|
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 _cssCamelToKebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||||
const _cssKebabToCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
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 {
|
class Document extends Node {
|
||||||
get documentElement() { return _wrapEl(+_dom("document_element")); }
|
get documentElement() { return _wrapEl(+_dom("document_element")); }
|
||||||
get children() {
|
get children() {
|
||||||
@@ -3569,6 +3758,28 @@ class Document extends Node {
|
|||||||
}
|
}
|
||||||
get URL() { return _domParse("document_url") ?? ""; }
|
get URL() { return _domParse("document_url") ?? ""; }
|
||||||
get documentURI() { return this.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 referrer() { return _domParse("document_referrer") ?? ""; }
|
||||||
get location() { return globalThis.location; }
|
get location() { return globalThis.location; }
|
||||||
set location(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); }
|
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;}
|
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) => {
|
globalThis.getComputedStyle = (el) => {
|
||||||
if (!el) el = document.body || {};
|
if (!el) el = document.body || {};
|
||||||
const style = el?.style || el?._style || new CSSStyleDeclaration();
|
const style = el?.style || el?._style || new CSSStyleDeclaration();
|
||||||
// Render builds expose one immutable snapshot from the retained final
|
// Render builds expose one immutable snapshot from the retained final
|
||||||
// cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a
|
// cascade/layout. The native snapshot is shared per element and epoch while
|
||||||
// dozen properties must not trigger a dozen layout/native crossings.
|
// each call still returns a distinct, live CSSStyleDeclaration proxy.
|
||||||
let rendered = null;
|
const cacheable = (typeof el === 'object' && el !== null) || typeof el === 'function';
|
||||||
let renderedEpoch = -1;
|
let snapshot = cacheable ? _computedStyleSnapshotCache.get(el) : null;
|
||||||
let computedNames = [];
|
if (!snapshot) {
|
||||||
|
snapshot = { rendered: null, epoch: -1, names: [] };
|
||||||
|
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
|
||||||
|
}
|
||||||
const refreshRendered = () => {
|
const refreshRendered = () => {
|
||||||
if (renderedEpoch === _domMutationEpoch) return;
|
if (snapshot.epoch === _domMutationEpoch) return;
|
||||||
renderedEpoch = _domMutationEpoch;
|
snapshot.epoch = _domMutationEpoch;
|
||||||
rendered = null;
|
snapshot.rendered = null;
|
||||||
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) {
|
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) {
|
||||||
try {
|
try {
|
||||||
const raw = Deno.core.ops.op_computed_style(String(el._nid | 0));
|
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) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
computedNames = rendered ? Object.keys(rendered) : [];
|
snapshot.names = snapshot.rendered ? Object.keys(snapshot.rendered) : [];
|
||||||
};
|
};
|
||||||
refreshRendered();
|
|
||||||
// React virtualization libraries (react-window, tanstack-virtual,
|
// React virtualization libraries (react-window, tanstack-virtual,
|
||||||
// react-virtuoso) all compute container dimensions via getComputedStyle.
|
// react-virtuoso) all compute container dimensions via getComputedStyle.
|
||||||
// The defaults table previously returned `auto` for width/height and
|
// 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
|
// (`-webkit-line-clamp`). Normalize the prefix once for every WebKit
|
||||||
// property instead of adding per-property aliases to the native snapshot.
|
// property instead of adding per-property aliases to the native snapshot.
|
||||||
if (kebab.startsWith('webkit-')) kebab = '-' + kebab;
|
if (kebab.startsWith('webkit-')) kebab = '-' + kebab;
|
||||||
if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab))
|
if (snapshot.rendered && Object.prototype.hasOwnProperty.call(snapshot.rendered, kebab))
|
||||||
return rendered[kebab];
|
return snapshot.rendered[kebab];
|
||||||
// Non-render builds and properties outside the renderer snapshot retain
|
// Non-render builds and properties outside the renderer snapshot retain
|
||||||
// the lightweight inline CSSOM behavior.
|
// the lightweight inline CSSOM behavior.
|
||||||
const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : '';
|
const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : '';
|
||||||
@@ -6160,11 +6379,11 @@ globalThis.getComputedStyle = (el) => {
|
|||||||
if (prop === 'getPropertyPriority') return () => '';
|
if (prop === 'getPropertyPriority') return () => '';
|
||||||
if (prop === 'item') return (i) => {
|
if (prop === 'item') return (i) => {
|
||||||
refreshRendered();
|
refreshRendered();
|
||||||
return computedNames[i | 0] || '';
|
return snapshot.names[i | 0] || '';
|
||||||
};
|
};
|
||||||
if (prop === 'length') {
|
if (prop === 'length') {
|
||||||
refreshRendered();
|
refreshRendered();
|
||||||
return computedNames.length;
|
return snapshot.names.length;
|
||||||
}
|
}
|
||||||
if (prop === 'cssText') return '';
|
if (prop === 'cssText') return '';
|
||||||
if (prop === 'parentRule') return null;
|
if (prop === 'parentRule') return null;
|
||||||
@@ -6530,6 +6749,23 @@ globalThis.NodeFilter = {
|
|||||||
// target boxes through getBoundingClientRect. Element scroll-container roots
|
// target boxes through getBoundingClientRect. Element scroll-container roots
|
||||||
// remain intentionally unsupported until their offsets participate in layout.
|
// remain intentionally unsupported until their offsets participate in layout.
|
||||||
globalThis.__intersectionObservers = [];
|
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) {
|
function _ioRect(x, y, width, height) {
|
||||||
return {
|
return {
|
||||||
x, y, width, height,
|
x, y, width, height,
|
||||||
@@ -6664,7 +6900,7 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
|||||||
}
|
}
|
||||||
this._targets.add(el);
|
this._targets.add(el);
|
||||||
this._previous.delete(el);
|
this._previous.delete(el);
|
||||||
this._check([el], true);
|
_scheduleIntersectionRenderCheckpoint();
|
||||||
}
|
}
|
||||||
unobserve(el) {
|
unobserve(el) {
|
||||||
this._targets.delete(el);
|
this._targets.delete(el);
|
||||||
@@ -6686,18 +6922,8 @@ globalThis.IntersectionObserver = class IntersectionObserver {
|
|||||||
get thresholds() { return this._thresholds.slice(); }
|
get thresholds() { return this._thresholds.slice(); }
|
||||||
};
|
};
|
||||||
(function() {
|
(function() {
|
||||||
let pending = false;
|
|
||||||
const recompute = () => {
|
const recompute = () => {
|
||||||
if (pending) return;
|
_scheduleIntersectionRenderCheckpoint();
|
||||||
pending = true;
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
pending = false;
|
|
||||||
for (const observer of globalThis.__intersectionObservers) {
|
|
||||||
if (observer._connected && observer._targets.size) {
|
|
||||||
observer._check([...observer._targets], false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
globalThis.__obscura_recompute_intersections = recompute;
|
globalThis.__obscura_recompute_intersections = recompute;
|
||||||
globalThis.addEventListener("resize", recompute);
|
globalThis.addEventListener("resize", recompute);
|
||||||
@@ -6920,7 +7146,16 @@ globalThis.PopStateEvent = class extends Event {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
globalThis.HashChangeEvent = 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 {
|
globalThis.ProgressEvent = class ProgressEvent extends Event {
|
||||||
constructor(type, init) {
|
constructor(type, init) {
|
||||||
super(type, init || {});
|
super(type, init || {});
|
||||||
@@ -7315,6 +7550,8 @@ globalThis.DOMParser = class DOMParser {
|
|||||||
// URL about:blank, are already fully parsed, and carry no stylesheets.
|
// URL about:blank, are already fully parsed, and carry no stylesheets.
|
||||||
get URL() { return "about:blank"; },
|
get URL() { return "about:blank"; },
|
||||||
get documentURI() { return "about:blank"; },
|
get documentURI() { return "about:blank"; },
|
||||||
|
get domain() { return _incumbentDocumentDomain(); },
|
||||||
|
set domain(value) { String(value); _throwDocumentDomainSecurityError(); },
|
||||||
get referrer() { return ""; },
|
get referrer() { return ""; },
|
||||||
get baseURI() { return "about:blank"; },
|
get baseURI() { return "about:blank"; },
|
||||||
get compatMode() { return "CSS1Compat"; },
|
get compatMode() { return "CSS1Compat"; },
|
||||||
@@ -10813,14 +11050,127 @@ if (typeof WebSocket === 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof BroadcastChannel === '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 {
|
globalThis.BroadcastChannel = class BroadcastChannel {
|
||||||
constructor(name) {
|
constructor(name) {
|
||||||
this.name = name; this.onmessage = null; this.onmessageerror = null;
|
if (arguments.length < 1) {
|
||||||
_makeListenerBox(this);
|
throw new TypeError("Failed to construct 'BroadcastChannel': 1 argument required.");
|
||||||
}
|
}
|
||||||
postMessage(msg) {}
|
const normalizedName = String(name);
|
||||||
close() {}
|
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);
|
||||||
|
}
|
||||||
|
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') {
|
if (typeof MediaQueryList === 'undefined') {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
pub mod cdp_watchdog;
|
pub mod cdp_watchdog;
|
||||||
mod import_map;
|
mod import_map;
|
||||||
pub mod module_loader;
|
|
||||||
pub mod runtime;
|
|
||||||
pub mod ops;
|
|
||||||
pub mod v8_flags;
|
|
||||||
pub mod markdown;
|
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 markdown::HTML_TO_MARKDOWN_JS;
|
||||||
pub use v8_flags::set_v8_flags;
|
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.
|
// render feature (which enables obscura-render/paint) is compiled in.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub use obscura_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,
|
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||||
};
|
};
|
||||||
+621
-187
File diff suppressed because it is too large.
Load diff
+1765
-380
File diff suppressed because it is too large.
Load diff
+1078
-350
File diff suppressed because it is too large.
Load diff
+1265
-824
File diff suppressed because it is too large.
Load diff
+292
-232
@@ -15,9 +15,9 @@
|
|||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use cosmic_text::{
|
use cosmic_text::{
|
||||||
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, CssLineBreak, CssOverflowWrap, Cursor,
|
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, CssLineBreak, CssOverflowWrap,
|
||||||
CssWordBreak, Family, FeatureTag, FontFeatures, FontSystem, FontVariations, Metrics, Shaping,
|
CssWordBreak, Cursor, Family, FeatureTag, FontFeatures, FontSystem, FontVariations, Metrics,
|
||||||
Style, SwashCache, SwashImage, VariationTag, Weight, Wrap,
|
Shaping, Style, SwashCache, SwashImage, VariationTag, Weight, Wrap,
|
||||||
};
|
};
|
||||||
use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
|
use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
|
||||||
use swash::zeno::{Angle, Format, Transform, Vector};
|
use swash::zeno::{Angle, Format, Transform, Vector};
|
||||||
@@ -247,7 +247,13 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 {
|
|||||||
.copied()
|
.copied()
|
||||||
.filter(|weight| *weight >= requested && *weight <= 500)
|
.filter(|weight| *weight >= requested && *weight <= 500)
|
||||||
.min()
|
.min()
|
||||||
.or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max())
|
.or_else(|| {
|
||||||
|
weights
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|weight| *weight < requested)
|
||||||
|
.max()
|
||||||
|
})
|
||||||
.or_else(|| weights.iter().copied().filter(|weight| *weight > 500).min())
|
.or_else(|| weights.iter().copied().filter(|weight| *weight > 500).min())
|
||||||
.unwrap_or(requested)
|
.unwrap_or(requested)
|
||||||
} else if requested < 400 {
|
} else if requested < 400 {
|
||||||
@@ -256,7 +262,13 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 {
|
|||||||
.copied()
|
.copied()
|
||||||
.filter(|weight| *weight <= requested)
|
.filter(|weight| *weight <= requested)
|
||||||
.max()
|
.max()
|
||||||
.or_else(|| weights.iter().copied().filter(|weight| *weight > requested).min())
|
.or_else(|| {
|
||||||
|
weights
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|weight| *weight > requested)
|
||||||
|
.min()
|
||||||
|
})
|
||||||
.unwrap_or(requested)
|
.unwrap_or(requested)
|
||||||
} else {
|
} else {
|
||||||
weights
|
weights
|
||||||
@@ -264,7 +276,13 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 {
|
|||||||
.copied()
|
.copied()
|
||||||
.filter(|weight| *weight >= requested)
|
.filter(|weight| *weight >= requested)
|
||||||
.min()
|
.min()
|
||||||
.or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max())
|
.or_else(|| {
|
||||||
|
weights
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|weight| *weight < requested)
|
||||||
|
.max()
|
||||||
|
})
|
||||||
.unwrap_or(requested)
|
.unwrap_or(requested)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +327,10 @@ fn normal_line_height(font_size: f32, metrics: FaceMetrics) -> f32 {
|
|||||||
/// border. Chromium and Gecko both fit ascent and descent independently.
|
/// border. Chromium and Gecko both fit ascent and descent independently.
|
||||||
fn fitted_font_box_metrics(font_size: f32, metrics: FaceMetrics) -> (f32, f32) {
|
fn fitted_font_box_metrics(font_size: f32, metrics: FaceMetrics) -> (f32, f32) {
|
||||||
let scale = font_size / metrics.units_per_em.max(1.0);
|
let scale = font_size / metrics.units_per_em.max(1.0);
|
||||||
((metrics.ascent * scale).round(), (metrics.descent * scale).round())
|
(
|
||||||
|
(metrics.ascent * scale).round(),
|
||||||
|
(metrics.descent * scale).round(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn font_metrics(
|
fn font_metrics(
|
||||||
@@ -365,8 +386,7 @@ const META_UNDERLINE: usize = 1;
|
|||||||
const META_FILL_SHIFT: usize = 1;
|
const META_FILL_SHIFT: usize = 1;
|
||||||
const META_VARIATION_BITS: usize = usize::BITS as usize / 2;
|
const META_VARIATION_BITS: usize = usize::BITS as usize / 2;
|
||||||
const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS;
|
const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS;
|
||||||
const META_VARIATION_MASK: usize =
|
const META_VARIATION_MASK: usize = ((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT;
|
||||||
((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT;
|
|
||||||
const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE;
|
const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE;
|
||||||
|
|
||||||
fn metadata_fill(metadata: usize) -> Option<usize> {
|
fn metadata_fill(metadata: usize) -> Option<usize> {
|
||||||
@@ -604,9 +624,7 @@ impl VariableSwashCache {
|
|||||||
let explicit_value = key.explicit.as_ref().and_then(|settings| {
|
let explicit_value = key.explicit.as_ref().and_then(|settings| {
|
||||||
settings
|
settings
|
||||||
.iter()
|
.iter()
|
||||||
.find(|setting| {
|
.find(|setting| swash::tag_from_bytes(setting.tag.as_bytes()) == tag)
|
||||||
swash::tag_from_bytes(setting.tag.as_bytes()) == tag
|
|
||||||
})
|
|
||||||
.map(|setting| setting.value.0)
|
.map(|setting| setting.value.0)
|
||||||
});
|
});
|
||||||
let automatic_value = if tag == swash::tag_from_bytes(b"wght") {
|
let automatic_value = if tag == swash::tag_from_bytes(b"wght") {
|
||||||
@@ -675,12 +693,7 @@ impl VariableSwashCache {
|
|||||||
cache_key
|
cache_key
|
||||||
.flags
|
.flags
|
||||||
.contains(CacheKeyFlags::FAKE_ITALIC)
|
.contains(CacheKeyFlags::FAKE_ITALIC)
|
||||||
.then(|| {
|
.then(|| Transform::skew(Angle::from_degrees(14.0), Angle::from_degrees(0.0))),
|
||||||
Transform::skew(
|
|
||||||
Angle::from_degrees(14.0),
|
|
||||||
Angle::from_degrees(0.0),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.render(&mut scaler, cache_key.glyph_id)
|
.render(&mut scaler, cache_key.glyph_id)
|
||||||
});
|
});
|
||||||
@@ -827,14 +840,8 @@ impl ReplacedItem {
|
|||||||
fn size(self, known: taffy::Size<Option<f32>>) -> taffy::Size<f32> {
|
fn size(self, known: taffy::Size<Option<f32>>) -> taffy::Size<f32> {
|
||||||
let (width, height) = match (known.width, known.height) {
|
let (width, height) = match (known.width, known.height) {
|
||||||
(Some(width), Some(height)) => (width, height),
|
(Some(width), Some(height)) => (width, height),
|
||||||
(Some(width), None) => (
|
(Some(width), None) => (width, width / self.preferred_ratio),
|
||||||
width,
|
(None, Some(height)) => (height * self.preferred_ratio, height),
|
||||||
width / self.preferred_ratio,
|
|
||||||
),
|
|
||||||
(None, Some(height)) => (
|
|
||||||
height * self.preferred_ratio,
|
|
||||||
height,
|
|
||||||
),
|
|
||||||
(None, None) => match (self.preferred_width, self.preferred_height) {
|
(None, None) => match (self.preferred_width, self.preferred_height) {
|
||||||
(Some(width), Some(height)) => (width, height),
|
(Some(width), Some(height)) => (width, height),
|
||||||
(Some(width), None) => (width, width / self.preferred_ratio),
|
(Some(width), None) => (width, width / self.preferred_ratio),
|
||||||
@@ -913,12 +920,7 @@ impl TextEngine {
|
|||||||
for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(
|
for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(
|
||||||
font.data.clone(),
|
font.data.clone(),
|
||||||
))) {
|
))) {
|
||||||
declarations.push((
|
declarations.push((id, font.family.clone(), font.weight, font.italic));
|
||||||
id,
|
|
||||||
font.family.clone(),
|
|
||||||
font.weight,
|
|
||||||
font.italic,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut loaded_families = HashMap::new();
|
let mut loaded_families = HashMap::new();
|
||||||
@@ -1018,7 +1020,12 @@ impl TextEngine {
|
|||||||
/// item index to store as the leaf's taffy context. `None` means the
|
/// item index to store as the leaf's taffy context. `None` means the
|
||||||
/// container is not a pure-text IFC and should build through the normal
|
/// container is not a pure-text IFC and should build through the normal
|
||||||
/// (block / flex / word-split) path.
|
/// (block / flex / word-split) path.
|
||||||
pub fn try_build(&mut self, tree: &DomTree, id: NodeId, styles: &std::collections::HashMap<NodeId, LayoutStyle>) -> Option<usize> {
|
pub fn try_build(
|
||||||
|
&mut self,
|
||||||
|
tree: &DomTree,
|
||||||
|
id: NodeId,
|
||||||
|
styles: &std::collections::HashMap<NodeId, LayoutStyle>,
|
||||||
|
) -> Option<usize> {
|
||||||
if !is_pure_text_ifc(tree, id, styles) {
|
if !is_pure_text_ifc(tree, id, styles) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1116,11 +1123,7 @@ impl TextEngine {
|
|||||||
/// weight selection, transformations, and glyph rasterizer as an ordinary
|
/// weight selection, transformations, and glyph rasterizer as an ordinary
|
||||||
/// inline formatting context. The caller measures/finalizes/paints the
|
/// inline formatting context. The caller measures/finalizes/paints the
|
||||||
/// returned item immediately against the pseudo's resolved content box.
|
/// returned item immediately against the pseudo's resolved content box.
|
||||||
pub(crate) fn push_generated_text(
|
pub(crate) fn push_generated_text(&mut self, text: &str, style: &LayoutStyle) -> Option<usize> {
|
||||||
&mut self,
|
|
||||||
text: &str,
|
|
||||||
style: &LayoutStyle,
|
|
||||||
) -> Option<usize> {
|
|
||||||
let mut collector = Collector::new();
|
let mut collector = Collector::new();
|
||||||
let font = resolve_loaded_font(
|
let font = resolve_loaded_font(
|
||||||
style.font_family.as_deref(),
|
style.font_family.as_deref(),
|
||||||
@@ -1183,7 +1186,10 @@ impl TextEngine {
|
|||||||
mut boundary_events: Vec<InlineBoundaryEvent>,
|
mut boundary_events: Vec<InlineBoundaryEvent>,
|
||||||
) -> Option<usize> {
|
) -> Option<usize> {
|
||||||
let white_space = base.white_space.unwrap_or_default();
|
let white_space = base.white_space.unwrap_or_default();
|
||||||
let layout_wrap = if spans.iter().any(|(_, attrs)| attrs.has_layout_emergency_breaks()) {
|
let layout_wrap = if spans
|
||||||
|
.iter()
|
||||||
|
.any(|(_, attrs)| attrs.has_layout_emergency_breaks())
|
||||||
|
{
|
||||||
Wrap::WordOrGlyph
|
Wrap::WordOrGlyph
|
||||||
} else {
|
} else {
|
||||||
Wrap::Word
|
Wrap::Word
|
||||||
@@ -1199,9 +1205,7 @@ impl TextEngine {
|
|||||||
// Collapsible trailing whitespace does not widen the last line.
|
// Collapsible trailing whitespace does not widen the last line.
|
||||||
if matches!(
|
if matches!(
|
||||||
white_space,
|
white_space,
|
||||||
crate::WhiteSpace::Normal
|
crate::WhiteSpace::Normal | crate::WhiteSpace::NoWrap | crate::WhiteSpace::PreLine
|
||||||
| crate::WhiteSpace::NoWrap
|
|
||||||
| crate::WhiteSpace::PreLine
|
|
||||||
) {
|
) {
|
||||||
if let Some((text, _)) = spans.last_mut() {
|
if let Some((text, _)) = spans.last_mut() {
|
||||||
if text.ends_with(' ') {
|
if text.ends_with(' ') {
|
||||||
@@ -1209,7 +1213,8 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if owner_boxes.is_empty() && spans.iter().all(|(text, _)| {
|
if owner_boxes.is_empty()
|
||||||
|
&& spans.iter().all(|(text, _)| {
|
||||||
text.is_empty()
|
text.is_empty()
|
||||||
|| (matches!(
|
|| (matches!(
|
||||||
white_space,
|
white_space,
|
||||||
@@ -1218,7 +1223,8 @@ impl TextEngine {
|
|||||||
| crate::WhiteSpace::PreLine
|
| crate::WhiteSpace::PreLine
|
||||||
) && text.trim().is_empty()
|
) && text.trim().is_empty()
|
||||||
&& !text.contains('\n'))
|
&& !text.contains('\n'))
|
||||||
}) {
|
})
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let text_len = spans.iter().map(|(text, _)| text.len()).sum::<usize>();
|
let text_len = spans.iter().map(|(text, _)| text.len()).sum::<usize>();
|
||||||
@@ -1294,13 +1300,12 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let line_clamp = (base.webkit_box_display.is_some()
|
let line_clamp = (base.webkit_box_display.is_some() && base.webkit_box_orient_vertical)
|
||||||
&& base.webkit_box_orient_vertical)
|
|
||||||
.then_some(base.webkit_line_clamp)
|
.then_some(base.webkit_line_clamp)
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|lines| lines as usize);
|
.map(|lines| lines as usize);
|
||||||
let ellipsis_overflow = base.text_overflow == crate::TextOverflow::Ellipsis
|
let ellipsis_overflow =
|
||||||
&& base.clips_overflow_x();
|
base.text_overflow == crate::TextOverflow::Ellipsis && base.clips_overflow_x();
|
||||||
let marker_attrs = (line_clamp.is_some() || ellipsis_overflow)
|
let marker_attrs = (line_clamp.is_some() || ellipsis_overflow)
|
||||||
.then(|| spans.last().map(|(_, attrs)| attrs.clone()))
|
.then(|| spans.last().map(|(_, attrs)| attrs.clone()))
|
||||||
.flatten();
|
.flatten();
|
||||||
@@ -1400,21 +1405,20 @@ impl TextEngine {
|
|||||||
/// taffy's measure function during layout.
|
/// taffy's measure function during layout.
|
||||||
pub fn measure(&mut self, idx: usize, width: Option<f32>) -> (f32, f32) {
|
pub fn measure(&mut self, idx: usize, width: Option<f32>) -> (f32, f32) {
|
||||||
if idx & REPLACED_CONTEXT_BIT != 0 {
|
if idx & REPLACED_CONTEXT_BIT != 0 {
|
||||||
let size = self.replaced[idx & !REPLACED_CONTEXT_BIT]
|
let size = self.replaced[idx & !REPLACED_CONTEXT_BIT].size(taffy::Size {
|
||||||
.size(taffy::Size { width, height: None });
|
width,
|
||||||
|
height: None,
|
||||||
|
});
|
||||||
return (size.width, size.height);
|
return (size.width, size.height);
|
||||||
}
|
}
|
||||||
let wrap = self.items[idx].layout_wrap;
|
let wrap = self.items[idx].layout_wrap;
|
||||||
self.measure_text_with_wrap(idx, width, wrap)
|
self.measure_text_with_wrap(idx, width, wrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn measure_text_with_wrap(
|
fn measure_text_with_wrap(&mut self, idx: usize, width: Option<f32>, wrap: Wrap) -> (f32, f32) {
|
||||||
&mut self,
|
let TextEngine {
|
||||||
idx: usize,
|
font_system, items, ..
|
||||||
width: Option<f32>,
|
} = self;
|
||||||
wrap: Wrap,
|
|
||||||
) -> (f32, f32) {
|
|
||||||
let TextEngine { font_system, items, .. } = self;
|
|
||||||
let item = &mut items[idx];
|
let item = &mut items[idx];
|
||||||
shape_with_text_indent(font_system, item, width, wrap);
|
shape_with_text_indent(font_system, item, width, wrap);
|
||||||
let (width, height, clamped) = buffer_size(item);
|
let (width, height, clamped) = buffer_size(item);
|
||||||
@@ -1467,12 +1471,7 @@ impl TextEngine {
|
|||||||
/// Register a replaced element's intrinsic size as a taffy measure
|
/// Register a replaced element's intrinsic size as a taffy measure
|
||||||
/// context. Percentage-sized image leaves still need their intrinsic
|
/// context. Percentage-sized image leaves still need their intrinsic
|
||||||
/// max-content contribution while an auto-sized ancestor is measured.
|
/// max-content contribution while an auto-sized ancestor is measured.
|
||||||
pub fn register_replaced(
|
pub fn register_replaced(&mut self, width: f32, height: f32, style: &LayoutStyle) -> usize {
|
||||||
&mut self,
|
|
||||||
width: f32,
|
|
||||||
height: f32,
|
|
||||||
style: &LayoutStyle,
|
|
||||||
) -> usize {
|
|
||||||
let index = self.replaced.len();
|
let index = self.replaced.len();
|
||||||
self.replaced
|
self.replaced
|
||||||
.push(ReplacedItem::from_style(width, height, style));
|
.push(ReplacedItem::from_style(width, height, style));
|
||||||
@@ -1625,10 +1624,7 @@ impl TextEngine {
|
|||||||
/// DOM resolves percentages against the real containing block. Selecting
|
/// DOM resolves percentages against the real containing block. Selecting
|
||||||
/// the deepest matching range at paint time is therefore sufficient even
|
/// the deepest matching range at paint time is therefore sufficient even
|
||||||
/// for nested relative inlines: its offset already includes its ancestors.
|
/// for nested relative inlines: its offset already includes its ancestors.
|
||||||
pub(crate) fn set_inline_owner_offsets(
|
pub(crate) fn set_inline_owner_offsets(&mut self, offsets: &HashMap<NodeId, (f32, f32)>) {
|
||||||
&mut self,
|
|
||||||
offsets: &HashMap<NodeId, (f32, f32)>,
|
|
||||||
) {
|
|
||||||
for item in &mut self.items {
|
for item in &mut self.items {
|
||||||
item.relative_owner_ranges = item
|
item.relative_owner_ranges = item
|
||||||
.owner_ranges
|
.owner_ranges
|
||||||
@@ -1694,12 +1690,7 @@ impl TextEngine {
|
|||||||
};
|
};
|
||||||
let raw_right = if last {
|
let raw_right = if last {
|
||||||
run_cursor_x(&run, owner.end.saturating_sub(line_start))
|
run_cursor_x(&run, owner.end.saturating_sub(line_start))
|
||||||
+ line_advance_before_event(
|
+ line_advance_before_event(item, owner.end_event, line_start, line_end)
|
||||||
item,
|
|
||||||
owner.end_event,
|
|
||||||
line_start,
|
|
||||||
line_end,
|
|
||||||
)
|
|
||||||
+ owner.end_edge.border_padding()
|
+ owner.end_edge.border_padding()
|
||||||
} else {
|
} else {
|
||||||
run.line_w + line_edge_advance(item, line_start, line_end)
|
run.line_w + line_edge_advance(item, line_start, line_end)
|
||||||
@@ -1817,7 +1808,11 @@ impl SpanAttrs {
|
|||||||
}
|
}
|
||||||
// Clip-text glyphs must be shaped with an opaque fill so their coverage
|
// Clip-text glyphs must be shaped with an opaque fill so their coverage
|
||||||
// reaches paint; the real gradient is selected through metadata.
|
// reaches paint; the real gradient is selected through metadata.
|
||||||
let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
|
let color = if self.clip_fill.is_some() {
|
||||||
|
[255, 255, 255, 255]
|
||||||
|
} else {
|
||||||
|
self.color
|
||||||
|
};
|
||||||
a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
|
a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
|
||||||
let fill = self
|
let fill = self
|
||||||
.clip_fill
|
.clip_fill
|
||||||
@@ -1986,11 +1981,7 @@ fn resolved_font_variations(
|
|||||||
(!variations.is_empty()).then(|| Arc::new(variations))
|
(!variations.is_empty()).then(|| Arc::new(variations))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base_span_ctx(
|
fn base_span_ctx(base: &LayoutStyle, font: ResolvedFont, collector: &mut Collector) -> SpanCtx {
|
||||||
base: &LayoutStyle,
|
|
||||||
font: ResolvedFont,
|
|
||||||
collector: &mut Collector,
|
|
||||||
) -> SpanCtx {
|
|
||||||
let clip_fill = clip_text_fill(base).map(|fill| {
|
let clip_fill = clip_text_fill(base).map(|fill| {
|
||||||
let index = collector.clip_fills.len();
|
let index = collector.clip_fills.len();
|
||||||
collector.clip_fills.push(fill);
|
collector.clip_fills.push(fill);
|
||||||
@@ -2031,15 +2022,7 @@ fn collect_spans(
|
|||||||
loaded_families: &HashMap<String, LoadedFamily>,
|
loaded_families: &HashMap<String, LoadedFamily>,
|
||||||
) {
|
) {
|
||||||
for cid in crate::dom::rendered_children(tree, id) {
|
for cid in crate::dom::rendered_children(tree, id) {
|
||||||
collect_node_spans(
|
collect_node_spans(tree, cid, styles, ctx.clone(), out, c, loaded_families);
|
||||||
tree,
|
|
||||||
cid,
|
|
||||||
styles,
|
|
||||||
ctx.clone(),
|
|
||||||
out,
|
|
||||||
c,
|
|
||||||
loaded_families,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2056,7 +2039,9 @@ fn collect_node_spans(
|
|||||||
c: &mut Collector,
|
c: &mut Collector,
|
||||||
loaded_families: &HashMap<String, LoadedFamily>,
|
loaded_families: &HashMap<String, LoadedFamily>,
|
||||||
) {
|
) {
|
||||||
let Some(node) = tree.get_node(cid) else { return };
|
let Some(node) = tree.get_node(cid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
match &node.data {
|
match &node.data {
|
||||||
obscura_dom::tree::NodeData::Text { contents } => {
|
obscura_dom::tree::NodeData::Text { contents } => {
|
||||||
let attrs = SpanAttrs {
|
let attrs = SpanAttrs {
|
||||||
@@ -2078,17 +2063,12 @@ fn collect_node_spans(
|
|||||||
overflow_wrap: ctx.overflow_wrap,
|
overflow_wrap: ctx.overflow_wrap,
|
||||||
word_break: ctx.word_break,
|
word_break: ctx.word_break,
|
||||||
};
|
};
|
||||||
push_text(
|
push_text(contents, ctx.transform, ctx.white_space, &attrs, out, c);
|
||||||
contents,
|
|
||||||
ctx.transform,
|
|
||||||
ctx.white_space,
|
|
||||||
&attrs,
|
|
||||||
out,
|
|
||||||
c,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let Some(elem) = node.as_element() else { return };
|
let Some(elem) = node.as_element() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let style = styles.get(&cid);
|
let style = styles.get(&cid);
|
||||||
if style.map(|s| s.display == Display::None).unwrap_or(false) {
|
if style.map(|s| s.display == Display::None).unwrap_or(false) {
|
||||||
return;
|
return;
|
||||||
@@ -2120,9 +2100,8 @@ fn collect_node_spans(
|
|||||||
c.last_was_space = true;
|
c.last_was_space = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let owns_inline_fragment = style.is_some_and(|style| {
|
let owns_inline_fragment = style
|
||||||
style.ignores_used_box_sizes() && !style.display_contents
|
.is_some_and(|style| style.ignores_used_box_sizes() && !style.display_contents);
|
||||||
});
|
|
||||||
if owns_inline_fragment {
|
if owns_inline_fragment {
|
||||||
c.begin_owner(cid, style.expect("inline owner style"));
|
c.begin_owner(cid, style.expect("inline owner style"));
|
||||||
}
|
}
|
||||||
@@ -2135,9 +2114,8 @@ fn collect_node_spans(
|
|||||||
// A descendant with its own clip-text background replaces the
|
// A descendant with its own clip-text background replaces the
|
||||||
// inherited fill. Transparent descendants otherwise continue an
|
// inherited fill. Transparent descendants otherwise continue an
|
||||||
// ancestor's fill; an opaque text color paints normally.
|
// ancestor's fill; an opaque text color paints normally.
|
||||||
let clip_fill = own_clip_fill.or_else(|| {
|
let clip_fill =
|
||||||
if color[3] == 0 { ctx.clip_fill } else { None }
|
own_clip_fill.or_else(|| if color[3] == 0 { ctx.clip_fill } else { None });
|
||||||
});
|
|
||||||
let requested_weight = style
|
let requested_weight = style
|
||||||
.map(crate::style::used_font_weight)
|
.map(crate::style::used_font_weight)
|
||||||
.unwrap_or(ctx.weight);
|
.unwrap_or(ctx.weight);
|
||||||
@@ -2188,10 +2166,10 @@ fn collect_node_spans(
|
|||||||
// Underline propagates in: an ancestor's underline covers
|
// Underline propagates in: an ancestor's underline covers
|
||||||
// descendant text; an element only sets its own via CSS.
|
// descendant text; an element only sets its own via CSS.
|
||||||
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
|
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
|
||||||
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
|
transform: style
|
||||||
white_space: style
|
.and_then(|s| s.text_transform)
|
||||||
.and_then(|s| s.white_space)
|
.unwrap_or(ctx.transform),
|
||||||
.unwrap_or(ctx.white_space),
|
white_space: style.and_then(|s| s.white_space).unwrap_or(ctx.white_space),
|
||||||
overflow_wrap: style
|
overflow_wrap: style
|
||||||
.and_then(|s| s.overflow_wrap)
|
.and_then(|s| s.overflow_wrap)
|
||||||
.unwrap_or(ctx.overflow_wrap),
|
.unwrap_or(ctx.overflow_wrap),
|
||||||
@@ -2199,15 +2177,7 @@ fn collect_node_spans(
|
|||||||
family: font.family,
|
family: font.family,
|
||||||
clip_fill,
|
clip_fill,
|
||||||
};
|
};
|
||||||
collect_spans(
|
collect_spans(tree, cid, styles, child, out, c, loaded_families);
|
||||||
tree,
|
|
||||||
cid,
|
|
||||||
styles,
|
|
||||||
child,
|
|
||||||
out,
|
|
||||||
c,
|
|
||||||
loaded_families,
|
|
||||||
);
|
|
||||||
if owns_inline_fragment {
|
if owns_inline_fragment {
|
||||||
c.end_owner(cid);
|
c.end_owner(cid);
|
||||||
}
|
}
|
||||||
@@ -2366,7 +2336,11 @@ fn buffer_size(item: &InlineItem) -> (f32, f32, bool) {
|
|||||||
let clamped = item.line_clamp.is_some_and(|limit| nonempty_lines > limit);
|
let clamped = item.line_clamp.is_some_and(|limit| nonempty_lines > limit);
|
||||||
(
|
(
|
||||||
w.ceil(),
|
w.ceil(),
|
||||||
if clamped { clamp_height.unwrap_or(h) } else { h },
|
if clamped {
|
||||||
|
clamp_height.unwrap_or(h)
|
||||||
|
} else {
|
||||||
|
h
|
||||||
|
},
|
||||||
clamped,
|
clamped,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2437,7 +2411,11 @@ fn used_text_indent(value: Dimension, width: Option<f32>) -> f32 {
|
|||||||
Dimension::Percent(fraction) => width.unwrap_or(0.0) * fraction,
|
Dimension::Percent(fraction) => width.unwrap_or(0.0) * fraction,
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
};
|
};
|
||||||
if indent.is_finite() { indent } else { 0.0 }
|
if indent.is_finite() {
|
||||||
|
indent
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shape one IFC with first-line indent and ordinary-inline boundary advances.
|
/// Shape one IFC with first-line indent and ordinary-inline boundary advances.
|
||||||
@@ -2490,9 +2468,7 @@ fn shape_with_text_indent(
|
|||||||
let negative_edges = item
|
let negative_edges = item
|
||||||
.boundary_events
|
.boundary_events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| {
|
.filter(|event| event.position >= global_start && event.position <= line_source_end)
|
||||||
event.position >= global_start && event.position <= line_source_end
|
|
||||||
})
|
|
||||||
.map(|event| event.edge.advance().min(0.0))
|
.map(|event| event.edge.advance().min(0.0))
|
||||||
.sum::<f32>();
|
.sum::<f32>();
|
||||||
let mut available = (base_available - negative_edges).max(0.0);
|
let mut available = (base_available - negative_edges).max(0.0);
|
||||||
@@ -2512,10 +2488,7 @@ fn shape_with_text_indent(
|
|||||||
let Some(first) = layouts.first() else {
|
let Some(first) = layouts.first() else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
(
|
(first.glyphs.iter().map(|glyph| glyph.end).max(), first.w)
|
||||||
first.glyphs.iter().map(|glyph| glyph.end).max(),
|
|
||||||
first.w,
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
let Some(candidate) = candidate else {
|
let Some(candidate) = candidate else {
|
||||||
break;
|
break;
|
||||||
@@ -2689,8 +2662,19 @@ fn is_pure_text_ifc(
|
|||||||
pub(crate) fn is_replaced(local: &str) -> bool {
|
pub(crate) fn is_replaced(local: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
local,
|
local,
|
||||||
"img" | "svg" | "canvas" | "video" | "audio" | "iframe" | "embed"
|
"img"
|
||||||
| "object" | "input" | "textarea" | "select" | "button" | "progress"
|
| "svg"
|
||||||
|
| "canvas"
|
||||||
|
| "video"
|
||||||
|
| "audio"
|
||||||
|
| "iframe"
|
||||||
|
| "embed"
|
||||||
|
| "object"
|
||||||
|
| "input"
|
||||||
|
| "textarea"
|
||||||
|
| "select"
|
||||||
|
| "button"
|
||||||
|
| "progress"
|
||||||
| "meter"
|
| "meter"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2702,8 +2686,7 @@ pub(crate) fn is_replaced(local: &str) -> bool {
|
|||||||
pub(crate) fn has_replaced_sizing(local: &str) -> bool {
|
pub(crate) fn has_replaced_sizing(local: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
local,
|
local,
|
||||||
"img" | "canvas" | "video" | "audio" | "iframe" | "embed"
|
"img" | "canvas" | "video" | "audio" | "iframe" | "embed" | "object" | "progress" | "meter"
|
||||||
| "object" | "progress" | "meter"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2737,8 +2720,15 @@ pub(crate) fn default_replaced_intrinsic_size(
|
|||||||
/// genuinely cannot fold are rejected: replaced/atomic elements, block-level
|
/// genuinely cannot fold are rejected: replaced/atomic elements, block-level
|
||||||
/// children, floats, out-of-flow positioned boxes, and elements with generated
|
/// children, floats, out-of-flow positioned boxes, and elements with generated
|
||||||
/// content (which would be lost).
|
/// content (which would be lost).
|
||||||
fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashMap<NodeId, LayoutStyle>, has_text: &mut bool) -> bool {
|
fn inline_child_ok(
|
||||||
let Some(node) = tree.get_node(cid) else { return true };
|
tree: &DomTree,
|
||||||
|
cid: NodeId,
|
||||||
|
styles: &std::collections::HashMap<NodeId, LayoutStyle>,
|
||||||
|
has_text: &mut bool,
|
||||||
|
) -> bool {
|
||||||
|
let Some(node) = tree.get_node(cid) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
match &node.data {
|
match &node.data {
|
||||||
obscura_dom::tree::NodeData::Text { contents } => {
|
obscura_dom::tree::NodeData::Text { contents } => {
|
||||||
if !contents.trim().is_empty() {
|
if !contents.trim().is_empty() {
|
||||||
@@ -2747,8 +2737,12 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
obscura_dom::tree::NodeData::Element { .. } => {
|
obscura_dom::tree::NodeData::Element { .. } => {
|
||||||
let Some(elem) = node.as_element() else { return true };
|
let Some(elem) = node.as_element() else {
|
||||||
let Some(style) = styles.get(&cid) else { return false };
|
return true;
|
||||||
|
};
|
||||||
|
let Some(style) = styles.get(&cid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
if style.display == Display::None {
|
if style.display == Display::None {
|
||||||
return true; // removed from flow; ignore its subtree
|
return true; // removed from flow; ignore its subtree
|
||||||
}
|
}
|
||||||
@@ -2794,11 +2788,15 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM
|
|||||||
/// Content-box origin for a container whose border box is `rect`, i.e. inside
|
/// Content-box origin for a container whose border box is `rect`, i.e. inside
|
||||||
/// its border and padding: where inline text actually starts.
|
/// its border and padding: where inline text actually starts.
|
||||||
pub fn content_origin(rect: &Rect, style: &LayoutStyle) -> (f32, f32) {
|
pub fn content_origin(rect: &Rect, style: &LayoutStyle) -> (f32, f32) {
|
||||||
(rect.x + style.border.left + style.padding.left, rect.y + style.border.top + style.padding.top)
|
(
|
||||||
|
rect.x + style.border.left + style.padding.left,
|
||||||
|
rect.y + style.border.top + style.padding.top,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 {
|
pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 {
|
||||||
(rect.width - style.border.left - style.border.right - style.padding.left - style.padding.right).max(0.0)
|
(rect.width - style.border.left - style.border.right - style.padding.left - style.padding.right)
|
||||||
|
.max(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The background to paint through the glyphs for `-webkit-background-clip: text`
|
/// The background to paint through the glyphs for `-webkit-background-clip: text`
|
||||||
@@ -2829,7 +2827,13 @@ fn clip_text_fill(style: &LayoutStyle) -> Option<(f32, Vec<([u8; 4], Option<f32>
|
|||||||
/// returning an rgba color. `angle` is CSS degrees clockwise from 12 o'clock
|
/// returning an rgba color. `angle` is CSS degrees clockwise from 12 o'clock
|
||||||
/// (0 = to top, 90 = to right, 180 = to bottom), matching `parse_linear_gradient`
|
/// (0 = to top, 90 = to right, 180 = to bottom), matching `parse_linear_gradient`
|
||||||
/// and `paint::paint_linear_gradient`. Positionless stops are spread evenly.
|
/// and `paint::paint_linear_gradient`. Positionless stops are spread evenly.
|
||||||
fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option<f32>)>), x: f32, y: f32, w: f32, h: f32) -> [u8; 4] {
|
fn sample_gradient(
|
||||||
|
fill: &(f32, Vec<([u8; 4], Option<f32>)>),
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
h: f32,
|
||||||
|
) -> [u8; 4] {
|
||||||
let (angle, stops) = fill;
|
let (angle, stops) = fill;
|
||||||
match stops.len() {
|
match stops.len() {
|
||||||
0 => return [0, 0, 0, 255],
|
0 => return [0, 0, 0, 255],
|
||||||
@@ -2848,7 +2852,12 @@ fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option<f32>)>), x: f32, y: f32, w:
|
|||||||
(((x - w / 2.0) * dx + (y - h / 2.0) * dy) / len + 0.5).clamp(0.0, 1.0)
|
(((x - w / 2.0) * dx + (y - h / 2.0) * dy) / len + 0.5).clamp(0.0, 1.0)
|
||||||
};
|
};
|
||||||
let n = stops.len();
|
let n = stops.len();
|
||||||
let pos = |i: usize| stops[i].1.unwrap_or(i as f32 / (n as f32 - 1.0)).clamp(0.0, 1.0);
|
let pos = |i: usize| {
|
||||||
|
stops[i]
|
||||||
|
.1
|
||||||
|
.unwrap_or(i as f32 / (n as f32 - 1.0))
|
||||||
|
.clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
// Walk to the pair of stops surrounding t, then interpolate between them.
|
// Walk to the pair of stops surrounding t, then interpolate between them.
|
||||||
let mut lo = 0usize;
|
let mut lo = 0usize;
|
||||||
while lo + 1 < n && pos(lo + 1) < t {
|
while lo + 1 < n && pos(lo + 1) < t {
|
||||||
@@ -2856,10 +2865,23 @@ fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option<f32>)>), x: f32, y: f32, w:
|
|||||||
}
|
}
|
||||||
let hi = (lo + 1).min(n - 1);
|
let hi = (lo + 1).min(n - 1);
|
||||||
let (p0, p1) = (pos(lo), pos(hi));
|
let (p0, p1) = (pos(lo), pos(hi));
|
||||||
let f = if (p1 - p0).abs() < 1e-6 { 0.0 } else { ((t - p0) / (p1 - p0)).clamp(0.0, 1.0) };
|
let f = if (p1 - p0).abs() < 1e-6 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
((t - p0) / (p1 - p0)).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
let (c0, c1) = (stops[lo].0, stops[hi].0);
|
let (c0, c1) = (stops[lo].0, stops[hi].0);
|
||||||
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8;
|
let lerp = |a: u8, b: u8| {
|
||||||
[lerp(c0[0], c1[0]), lerp(c0[1], c1[1]), lerp(c0[2], c1[2]), lerp(c0[3], c1[3])]
|
(a as f32 + (b as f32 - a as f32) * f)
|
||||||
|
.round()
|
||||||
|
.clamp(0.0, 255.0) as u8
|
||||||
|
};
|
||||||
|
[
|
||||||
|
lerp(c0[0], c1[0]),
|
||||||
|
lerp(c0[1], c1[1]),
|
||||||
|
lerp(c0[2], c1[2]),
|
||||||
|
lerp(c0[3], c1[3]),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextEngine {
|
impl TextEngine {
|
||||||
@@ -2880,6 +2902,36 @@ impl TextEngine {
|
|||||||
pixmap: &mut tiny_skia::Pixmap,
|
pixmap: &mut tiny_skia::Pixmap,
|
||||||
offset: (f32, f32),
|
offset: (f32, f32),
|
||||||
clip_override: Option<Rect>,
|
clip_override: Option<Rect>,
|
||||||
|
) {
|
||||||
|
self.paint_item_with_clip_mask(idx, pixmap, offset, clip_override, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterize shaped text against the rectangular culling envelope and an
|
||||||
|
/// optional full descendant clip-chain mask. The latter preserves rounded
|
||||||
|
/// overflow corners without moving glyph coordinates into the clip
|
||||||
|
/// owner's space.
|
||||||
|
pub fn paint_item_with_clip_mask(
|
||||||
|
&mut self,
|
||||||
|
idx: usize,
|
||||||
|
pixmap: &mut tiny_skia::Pixmap,
|
||||||
|
offset: (f32, f32),
|
||||||
|
clip_override: Option<Rect>,
|
||||||
|
clip_mask: Option<&tiny_skia::Mask>,
|
||||||
|
) {
|
||||||
|
self.paint_item_with_clip_mask_scaled(idx, pixmap, offset, clip_override, clip_mask, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterize already-shaped CSS-pixel glyph positions directly into a
|
||||||
|
/// device-pixel surface. Shaping and line breaking stay immutable; only
|
||||||
|
/// glyph outline sampling, clipping, and compositing use `raster_scale`.
|
||||||
|
pub fn paint_item_with_clip_mask_scaled(
|
||||||
|
&mut self,
|
||||||
|
idx: usize,
|
||||||
|
pixmap: &mut tiny_skia::Pixmap,
|
||||||
|
offset: (f32, f32),
|
||||||
|
clip_override: Option<Rect>,
|
||||||
|
clip_mask: Option<&tiny_skia::Mask>,
|
||||||
|
raster_scale: f32,
|
||||||
) {
|
) {
|
||||||
let TextEngine {
|
let TextEngine {
|
||||||
font_system,
|
font_system,
|
||||||
@@ -2888,8 +2940,13 @@ impl TextEngine {
|
|||||||
items,
|
items,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
let Some(item) = items.get_mut(idx) else { return };
|
let Some(item) = items.get_mut(idx) else {
|
||||||
let (ox, oy) = (item.origin.0 + offset.0, item.origin.1 + offset.1);
|
return;
|
||||||
|
};
|
||||||
|
let (ox, oy) = (
|
||||||
|
(item.origin.0 + offset.0) * raster_scale,
|
||||||
|
(item.origin.1 + offset.1) * raster_scale,
|
||||||
|
);
|
||||||
// The glyph origin shifts by the container's accumulated translate,
|
// The glyph origin shifts by the container's accumulated translate,
|
||||||
// but the clip is already in screen space (owner-shifted at
|
// but the clip is already in screen space (owner-shifted at
|
||||||
// `resolve_clip_rects`) and must not move with the container, or a
|
// `resolve_clip_rects`) and must not move with the container, or a
|
||||||
@@ -2897,13 +2954,18 @@ impl TextEngine {
|
|||||||
let clip = clip_override.or(item.clip);
|
let clip = clip_override.or(item.clip);
|
||||||
let pw = pixmap.width() as i32;
|
let pw = pixmap.width() as i32;
|
||||||
let ph = pixmap.height() as i32;
|
let ph = pixmap.height() as i32;
|
||||||
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));
|
let clip_bounds = clip.map(|c| {
|
||||||
|
(
|
||||||
|
c.x * raster_scale,
|
||||||
|
c.y * raster_scale,
|
||||||
|
(c.x + c.width) * raster_scale,
|
||||||
|
(c.y + c.height) * raster_scale,
|
||||||
|
)
|
||||||
|
});
|
||||||
let line_source_starts = item
|
let line_source_starts = item
|
||||||
.owner_text
|
.owner_text
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|_| {
|
.filter(|_| !item.relative_owner_ranges.is_empty() || !item.boundary_events.is_empty())
|
||||||
!item.relative_owner_ranges.is_empty() || !item.boundary_events.is_empty()
|
|
||||||
})
|
|
||||||
.map(|source| source_line_starts(&item.buffer, source))
|
.map(|source| source_line_starts(&item.buffer, source))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@@ -2912,8 +2974,7 @@ impl TextEngine {
|
|||||||
// consecutive underlined glyphs on a line into one stroke below the
|
// consecutive underlined glyphs on a line into one stroke below the
|
||||||
// baseline. Done first so the draw() mutable borrow does not overlap.
|
// baseline. Done first so the draw() mutable borrow does not overlap.
|
||||||
let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color
|
let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color
|
||||||
let mut fill_bounds: Vec<Option<(f32, f32, f32, f32)>> =
|
let mut fill_bounds: Vec<Option<(f32, f32, f32, f32)>> = vec![None; item.clip_fills.len()];
|
||||||
vec![None; item.clip_fills.len()];
|
|
||||||
for (line_index, run) in item.buffer.layout_runs().enumerate() {
|
for (line_index, run) in item.buffer.layout_runs().enumerate() {
|
||||||
let line_offset = if line_index == 0 {
|
let line_offset = if line_index == 0 {
|
||||||
item.first_line_offset
|
item.first_line_offset
|
||||||
@@ -2932,8 +2993,7 @@ impl TextEngine {
|
|||||||
// underline tail or expand a gradient's sampling bounds past
|
// underline tail or expand a gradient's sampling bounds past
|
||||||
// the separately painted ellipsis marker.
|
// the separately painted ellipsis marker.
|
||||||
if item.marker.is_some_and(|marker| {
|
if item.marker.is_some_and(|marker| {
|
||||||
marker.line_index == line_index
|
marker.line_index == line_index && g.x + line_offset + g.w > marker.content_end
|
||||||
&& g.x + line_offset + g.w > marker.content_end
|
|
||||||
}) {
|
}) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2970,7 +3030,10 @@ impl TextEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let col = g.color_opt.map(|c| [c.r(), c.g(), c.b(), c.a()]).unwrap_or([0, 0, 0, 255]);
|
let col = g
|
||||||
|
.color_opt
|
||||||
|
.map(|c| [c.r(), c.g(), c.b(), c.a()])
|
||||||
|
.unwrap_or([0, 0, 0, 255]);
|
||||||
if underlined {
|
if underlined {
|
||||||
match &mut seg {
|
match &mut seg {
|
||||||
Some((_, x1, fs, c, prior_relative))
|
Some((_, x1, fs, c, prior_relative))
|
||||||
@@ -2981,7 +3044,13 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some((x0, x1, fs, c, prior_relative)) = seg.take() {
|
if let Some((x0, x1, fs, c, prior_relative)) = seg.take() {
|
||||||
underlines.push((x0, x1, base_y + prior_relative.1 + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c));
|
underlines.push((
|
||||||
|
x0,
|
||||||
|
x1,
|
||||||
|
base_y + prior_relative.1 + (fs * 0.12).max(1.0),
|
||||||
|
(fs / 14.0).max(1.0),
|
||||||
|
c,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
seg = Some((
|
seg = Some((
|
||||||
g.x + line_offset + relative.0,
|
g.x + line_offset + relative.0,
|
||||||
@@ -2993,11 +3062,23 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some((x0, x1, fs, c, relative)) = seg.take() {
|
} else if let Some((x0, x1, fs, c, relative)) = seg.take() {
|
||||||
underlines.push((x0, x1, base_y + relative.1 + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c));
|
underlines.push((
|
||||||
|
x0,
|
||||||
|
x1,
|
||||||
|
base_y + relative.1 + (fs * 0.12).max(1.0),
|
||||||
|
(fs / 14.0).max(1.0),
|
||||||
|
c,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some((x0, x1, fs, c, relative)) = seg.take() {
|
if let Some((x0, x1, fs, c, relative)) = seg.take() {
|
||||||
underlines.push((x0, x1, base_y + relative.1 + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c));
|
underlines.push((
|
||||||
|
x0,
|
||||||
|
x1,
|
||||||
|
base_y + relative.1 + (fs * 0.12).max(1.0),
|
||||||
|
(fs / 14.0).max(1.0),
|
||||||
|
c,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3041,10 +3122,7 @@ impl TextEngine {
|
|||||||
line_source_start,
|
line_source_start,
|
||||||
line_source_end,
|
line_source_end,
|
||||||
);
|
);
|
||||||
let physical = glyph.physical(
|
let physical = glyph.physical((line_offset + relative.0, relative.1), raster_scale);
|
||||||
(line_offset + relative.0, relative.1),
|
|
||||||
1.0,
|
|
||||||
);
|
|
||||||
let glyph_color = glyph.color_opt.unwrap_or(default);
|
let glyph_color = glyph.color_opt.unwrap_or(default);
|
||||||
let fill_index = metadata_fill(glyph.metadata);
|
let fill_index = metadata_fill(glyph.metadata);
|
||||||
let mut draw_pixel = |x, y, color: Color| {
|
let mut draw_pixel = |x, y, color: Color| {
|
||||||
@@ -3057,15 +3135,15 @@ impl TextEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let gx = physical.x + x;
|
let gx = physical.x + x;
|
||||||
let gy = run.line_y as i32 + physical.y + y;
|
let gy = (run.line_y * raster_scale) as i32 + physical.y + y;
|
||||||
let (r, g, b) = fill_index
|
let (r, g, b) = fill_index
|
||||||
.and_then(|index| {
|
.and_then(|index| {
|
||||||
let fill = clip_fills.get(index)?;
|
let fill = clip_fills.get(index)?;
|
||||||
let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?;
|
let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?;
|
||||||
let sampled = sample_gradient(
|
let sampled = sample_gradient(
|
||||||
fill,
|
fill,
|
||||||
gx as f32 + 0.5 - x0,
|
(gx as f32 + 0.5) / raster_scale - x0,
|
||||||
gy as f32 + 0.5 - y0,
|
(gy as f32 + 0.5) / raster_scale - y0,
|
||||||
x1 - x0,
|
x1 - x0,
|
||||||
y1 - y0,
|
y1 - y0,
|
||||||
);
|
);
|
||||||
@@ -3075,7 +3153,11 @@ impl TextEngine {
|
|||||||
let px = ox as i32 + gx;
|
let px = ox as i32 + gx;
|
||||||
let py = oy as i32 + gy;
|
let py = oy as i32 + gy;
|
||||||
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
||||||
if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 {
|
if (px as f32) < cx0
|
||||||
|
|| (px as f32) >= cx1
|
||||||
|
|| (py as f32) < cy0
|
||||||
|
|| (py as f32) >= cy1
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3083,6 +3165,14 @@ impl TextEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let idx = (py * pw + px) as usize;
|
let idx = (py * pw + px) as usize;
|
||||||
|
let mask_alpha = clip_mask
|
||||||
|
.and_then(|mask| mask.data().get(idx))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(255) as u32;
|
||||||
|
let a = a * mask_alpha / 255;
|
||||||
|
if a == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dst = pixels[idx];
|
let dst = pixels[idx];
|
||||||
let sa = a;
|
let sa = a;
|
||||||
let sr = (r as u32 * sa) / 255;
|
let sr = (r as u32 * sa) / 255;
|
||||||
@@ -3167,12 +3257,9 @@ impl TextEngine {
|
|||||||
let dst = pixels[index];
|
let dst = pixels[index];
|
||||||
let inverse = 255 - alpha;
|
let inverse = 255 - alpha;
|
||||||
let out_alpha = alpha + dst.alpha() as u32 * inverse / 255;
|
let out_alpha = alpha + dst.alpha() as u32 * inverse / 255;
|
||||||
let out_red = color.r() as u32 * alpha / 255
|
let out_red = color.r() as u32 * alpha / 255 + dst.red() as u32 * inverse / 255;
|
||||||
+ dst.red() as u32 * inverse / 255;
|
let out_green = color.g() as u32 * alpha / 255 + dst.green() as u32 * inverse / 255;
|
||||||
let out_green = color.g() as u32 * alpha / 255
|
let out_blue = color.b() as u32 * alpha / 255 + dst.blue() as u32 * inverse / 255;
|
||||||
+ dst.green() as u32 * inverse / 255;
|
|
||||||
let out_blue = color.b() as u32 * alpha / 255
|
|
||||||
+ dst.blue() as u32 * inverse / 255;
|
|
||||||
pixels[index] = tiny_skia::PremultipliedColorU8::from_rgba(
|
pixels[index] = tiny_skia::PremultipliedColorU8::from_rgba(
|
||||||
out_red as u8,
|
out_red as u8,
|
||||||
out_green as u8,
|
out_green as u8,
|
||||||
@@ -3189,9 +3276,9 @@ impl TextEngine {
|
|||||||
if col[3] == 0 {
|
if col[3] == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let t = thick.max(1.0).round() as i32;
|
let t = (thick.max(1.0) * raster_scale).round().max(1.0) as i32;
|
||||||
for dt in 0..t {
|
for dt in 0..t {
|
||||||
let py = oy as i32 + y as i32 + dt;
|
let py = oy as i32 + (y * raster_scale) as i32 + dt;
|
||||||
if py < 0 || py >= ph {
|
if py < 0 || py >= ph {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3200,7 +3287,7 @@ impl TextEngine {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for px in (ox + x0) as i32..(ox + x1) as i32 {
|
for px in (ox + x0 * raster_scale) as i32..(ox + x1 * raster_scale) as i32 {
|
||||||
if px < 0 || px >= pw {
|
if px < 0 || px >= pw {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3214,12 +3301,9 @@ impl TextEngine {
|
|||||||
let sa = col[3] as u32;
|
let sa = col[3] as u32;
|
||||||
let inv = 255 - sa;
|
let inv = 255 - sa;
|
||||||
let out_a = sa + dst.alpha() as u32 * inv / 255;
|
let out_a = sa + dst.alpha() as u32 * inv / 255;
|
||||||
let out_r = col[0] as u32 * sa / 255
|
let out_r = col[0] as u32 * sa / 255 + dst.red() as u32 * inv / 255;
|
||||||
+ dst.red() as u32 * inv / 255;
|
let out_g = col[1] as u32 * sa / 255 + dst.green() as u32 * inv / 255;
|
||||||
let out_g = col[1] as u32 * sa / 255
|
let out_b = col[2] as u32 * sa / 255 + dst.blue() as u32 * inv / 255;
|
||||||
+ dst.green() as u32 * inv / 255;
|
|
||||||
let out_b = col[2] as u32 * sa / 255
|
|
||||||
+ dst.blue() as u32 * inv / 255;
|
|
||||||
pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(
|
pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(
|
||||||
out_r as u8,
|
out_r as u8,
|
||||||
out_g as u8,
|
out_g as u8,
|
||||||
@@ -3336,10 +3420,7 @@ mod tests {
|
|||||||
normal_line_height(9.3333, bundled_face_metrics(FAMILY)),
|
normal_line_height(9.3333, bundled_face_metrics(FAMILY)),
|
||||||
10.0
|
10.0
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(normal_line_height(12.0, bundled_face_metrics(FAMILY)), 14.0);
|
||||||
normal_line_height(12.0, bundled_face_metrics(FAMILY)),
|
|
||||||
14.0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
normal_line_height(13.0, bundled_face_metrics(SERIF_FAMILY)),
|
normal_line_height(13.0, bundled_face_metrics(SERIF_FAMILY)),
|
||||||
16.0
|
16.0
|
||||||
@@ -3373,8 +3454,7 @@ mod tests {
|
|||||||
weight: Some((400, 400)),
|
weight: Some((400, 400)),
|
||||||
italic: Some(false),
|
italic: Some(false),
|
||||||
}]);
|
}]);
|
||||||
let resolved =
|
let resolved = resolve_loaded_font(Some("Page Face"), 400, false, &engine.loaded_families);
|
||||||
resolve_loaded_font(Some("Page Face"), 400, false, &engine.loaded_families);
|
|
||||||
let expected = font_metrics(&engine.font_system.db(), resolved.font_id.unwrap()).unwrap();
|
let expected = font_metrics(&engine.font_system.db(), resolved.font_id.unwrap()).unwrap();
|
||||||
assert_eq!(resolved.metrics, expected);
|
assert_eq!(resolved.metrics, expected);
|
||||||
|
|
||||||
@@ -3395,8 +3475,7 @@ mod tests {
|
|||||||
for expression_index in [0, 4] {
|
for expression_index in [0, 4] {
|
||||||
let mut engine = TextEngine::new();
|
let mut engine = TextEngine::new();
|
||||||
let mut style = LayoutStyle::default();
|
let mut style = LayoutStyle::default();
|
||||||
style.size_expressions[expression_index] =
|
style.size_expressions[expression_index] = Some("calc(100% - 1px)".to_string());
|
||||||
Some("calc(100% - 1px)".to_string());
|
|
||||||
let item = engine.register_replaced(800.0, 400.0, &style);
|
let item = engine.register_replaced(800.0, 400.0, &style);
|
||||||
let unknown = taffy::Size {
|
let unknown = taffy::Size {
|
||||||
width: None,
|
width: None,
|
||||||
@@ -3444,9 +3523,8 @@ mod tests {
|
|||||||
width: None,
|
width: None,
|
||||||
height: None,
|
height: None,
|
||||||
};
|
};
|
||||||
let measure = |style: &LayoutStyle| {
|
let measure =
|
||||||
ReplacedItem::from_style(512.0, 323.0, style).size(unknown)
|
|style: &LayoutStyle| ReplacedItem::from_style(512.0, 323.0, style).size(unknown);
|
||||||
};
|
|
||||||
|
|
||||||
let max_height = LayoutStyle {
|
let max_height = LayoutStyle {
|
||||||
max_height: Dimension::Px(128.0),
|
max_height: Dimension::Px(128.0),
|
||||||
@@ -3612,12 +3690,8 @@ mod tests {
|
|||||||
let loaded = &engine.loaded_families["pinned variable"];
|
let loaded = &engine.loaded_families["pinned variable"];
|
||||||
assert_eq!(loaded.faces.len(), 2);
|
assert_eq!(loaded.faces.len(), 2);
|
||||||
let first_id = loaded.faces[0].font_id.unwrap();
|
let first_id = loaded.faces[0].font_id.unwrap();
|
||||||
let selected = resolve_loaded_font(
|
let selected =
|
||||||
Some("Pinned Variable"),
|
resolve_loaded_font(Some("Pinned Variable"), 700, false, &engine.loaded_families);
|
||||||
700,
|
|
||||||
false,
|
|
||||||
&engine.loaded_families,
|
|
||||||
);
|
|
||||||
let selected_id = selected.font_id.unwrap();
|
let selected_id = selected.font_id.unwrap();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
first_id, selected_id,
|
first_id, selected_id,
|
||||||
@@ -3849,11 +3923,8 @@ mod tests {
|
|||||||
wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture")
|
wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn isolated_fallback_engine() -> (
|
fn isolated_fallback_engine() -> (TextEngine, cosmic_text::fontdb::ID, cosmic_text::fontdb::ID)
|
||||||
TextEngine,
|
{
|
||||||
cosmic_text::fontdb::ID,
|
|
||||||
cosmic_text::fontdb::ID,
|
|
||||||
) {
|
|
||||||
let mut engine = TextEngine::new_with_web_fonts(&[
|
let mut engine = TextEngine::new_with_web_fonts(&[
|
||||||
WebFont {
|
WebFont {
|
||||||
data: include_bytes!("../../../vendor/cosmic-text/fonts/NotoSansArabic.ttf")
|
data: include_bytes!("../../../vendor/cosmic-text/fonts/NotoSansArabic.ttf")
|
||||||
@@ -3920,10 +3991,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut pixmap = tiny_skia::Pixmap::new(100, 80).unwrap();
|
let mut pixmap = tiny_skia::Pixmap::new(100, 80).unwrap();
|
||||||
engine.paint_item(item, &mut pixmap, (0.0, 0.0));
|
engine.paint_item(item, &mut pixmap, (0.0, 0.0));
|
||||||
let mut axes = std::collections::HashMap::<
|
let mut axes = std::collections::HashMap::<[u8; 4], std::collections::HashSet<u32>>::new();
|
||||||
[u8; 4],
|
|
||||||
std::collections::HashSet<u32>,
|
|
||||||
>::new();
|
|
||||||
for key in engine.variable_swash.images.keys() {
|
for key in engine.variable_swash.images.keys() {
|
||||||
for variation in key.variations.iter() {
|
for variation in key.variations.iter() {
|
||||||
axes.entry(*variation.tag.as_bytes())
|
axes.entry(*variation.tag.as_bytes())
|
||||||
@@ -4126,11 +4194,7 @@ mod tests {
|
|||||||
regular_style.font_weight = Some("400".to_string());
|
regular_style.font_weight = Some("400".to_string());
|
||||||
let mut black_style = regular_style.clone();
|
let mut black_style = regular_style.clone();
|
||||||
black_style.font_weight = Some("900".to_string());
|
black_style.font_weight = Some("900".to_string());
|
||||||
let styles = HashMap::from([
|
let styles = HashMap::from([(copy, base), (regular, regular_style), (black, black_style)]);
|
||||||
(copy, base),
|
|
||||||
(regular, regular_style),
|
|
||||||
(black, black_style),
|
|
||||||
]);
|
|
||||||
let item = engine.try_build(&tree, copy, &styles).unwrap();
|
let item = engine.try_build(&tree, copy, &styles).unwrap();
|
||||||
engine.measure(item, Some(200.0));
|
engine.measure(item, Some(200.0));
|
||||||
engine.finalize(item, (0.0, 0.0), 200.0, None);
|
engine.finalize(item, (0.0, 0.0), 200.0, None);
|
||||||
@@ -4207,22 +4271,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn optical_sizing_changes_tailwind_heading_shape_and_raster_axes() {
|
fn optical_sizing_changes_tailwind_heading_shape_and_raster_axes() {
|
||||||
let (auto_geometry, auto_lines, auto_axes) = render_tailwind_optical_heading(
|
let (auto_geometry, auto_lines, auto_axes) =
|
||||||
crate::FontOpticalSizing::Auto,
|
render_tailwind_optical_heading(crate::FontOpticalSizing::Auto, None, 496.0);
|
||||||
None,
|
let (none_geometry, none_lines, none_axes) =
|
||||||
496.0,
|
render_tailwind_optical_heading(crate::FontOpticalSizing::None, None, 496.0);
|
||||||
);
|
|
||||||
let (none_geometry, none_lines, none_axes) = render_tailwind_optical_heading(
|
|
||||||
crate::FontOpticalSizing::None,
|
|
||||||
None,
|
|
||||||
496.0,
|
|
||||||
);
|
|
||||||
let (explicit_geometry, explicit_lines, explicit_axes) =
|
let (explicit_geometry, explicit_lines, explicit_axes) =
|
||||||
render_tailwind_optical_heading(
|
render_tailwind_optical_heading(crate::FontOpticalSizing::Auto, Some(14.0), 496.0);
|
||||||
crate::FontOpticalSizing::Auto,
|
|
||||||
Some(14.0),
|
|
||||||
496.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
auto_geometry, none_geometry,
|
auto_geometry, none_geometry,
|
||||||
@@ -4234,7 +4288,10 @@ mod tests {
|
|||||||
explicit_geometry, auto_geometry,
|
explicit_geometry, auto_geometry,
|
||||||
"an explicit low-level opsz coordinate must override automatic sizing"
|
"an explicit low-level opsz coordinate must override automatic sizing"
|
||||||
);
|
);
|
||||||
assert_eq!(auto_axes, std::collections::HashSet::from([32.0f32.to_bits()]));
|
assert_eq!(
|
||||||
|
auto_axes,
|
||||||
|
std::collections::HashSet::from([32.0f32.to_bits()])
|
||||||
|
);
|
||||||
assert!(none_axes.is_empty());
|
assert!(none_axes.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
explicit_axes,
|
explicit_axes,
|
||||||
@@ -4244,9 +4301,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn text_only_inline_block_keeps_an_internal_shaping_context() {
|
fn text_only_inline_block_keeps_an_internal_shaping_context() {
|
||||||
let tree = obscura_dom::parse_html(
|
let tree = obscura_dom::parse_html("<span id='icon'>ligature_name</span>");
|
||||||
"<span id='icon'>ligature_name</span>",
|
|
||||||
);
|
|
||||||
let icon = tree.get_element_by_id("icon").unwrap();
|
let icon = tree.get_element_by_id("icon").unwrap();
|
||||||
let mut style = LayoutStyle::default();
|
let mut style = LayoutStyle::default();
|
||||||
style.display = Display::Inline;
|
style.display = Display::Inline;
|
||||||
@@ -4311,8 +4366,14 @@ gamma</div>"#,
|
|||||||
let laid = crate::dom::layout_dom(&tree, (400.0, 300.0));
|
let laid = crate::dom::layout_dom(&tree, (400.0, 300.0));
|
||||||
let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()];
|
let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()];
|
||||||
|
|
||||||
assert_eq!(laid.styles[&tree.get_element_by_id("ua").unwrap()].white_space, Some(crate::WhiteSpace::Pre));
|
assert_eq!(
|
||||||
assert_eq!(laid.styles[&tree.get_element_by_id("explicit").unwrap()].white_space, Some(crate::WhiteSpace::PreWrap));
|
laid.styles[&tree.get_element_by_id("ua").unwrap()].white_space,
|
||||||
|
Some(crate::WhiteSpace::Pre)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
laid.styles[&tree.get_element_by_id("explicit").unwrap()].white_space,
|
||||||
|
Some(crate::WhiteSpace::PreWrap)
|
||||||
|
);
|
||||||
assert!((rect("ua").height - 72.0).abs() < 0.01, "{:?}", rect("ua"));
|
assert!((rect("ua").height - 72.0).abs() < 0.01, "{:?}", rect("ua"));
|
||||||
assert!(
|
assert!(
|
||||||
(rect("explicit").height - 72.0).abs() < 0.01,
|
(rect("explicit").height - 72.0).abs() < 0.01,
|
||||||
@@ -4370,8 +4431,7 @@ gamma</div>"#,
|
|||||||
assert!(lines.len() >= 2, "fixture must wrap: {lines:?}");
|
assert!(lines.len() >= 2, "fixture must wrap: {lines:?}");
|
||||||
assert_eq!(engine.items[item].first_line_offset, 40.0);
|
assert_eq!(engine.items[item].first_line_offset, 40.0);
|
||||||
let runs: Vec<_> = engine.items[item].buffer.layout_runs().collect();
|
let runs: Vec<_> = engine.items[item].buffer.layout_runs().collect();
|
||||||
let first_x = runs[0].glyphs.first().unwrap().x
|
let first_x = runs[0].glyphs.first().unwrap().x + engine.items[item].first_line_offset;
|
||||||
+ engine.items[item].first_line_offset;
|
|
||||||
let second_x = runs[1].glyphs.first().unwrap().x;
|
let second_x = runs[1].glyphs.first().unwrap().x;
|
||||||
assert!(
|
assert!(
|
||||||
(first_x - 40.0).abs() < 0.01,
|
(first_x - 40.0).abs() < 0.01,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
use taffy::prelude::*;
|
use taffy::prelude::*;
|
||||||
|
|
||||||
pub mod css;
|
pub mod css;
|
||||||
pub use css::Stylesheet;
|
pub use css::{Stylesheet, StylesheetCache};
|
||||||
|
|
||||||
pub mod style;
|
pub mod style;
|
||||||
pub use style::compute_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
|
/// are ASCII case-insensitive. Keep this list aligned with the `image` crate
|
||||||
/// features in `Cargo.toml` plus the SVG path in `paint.rs`.
|
/// features in `Cargo.toml` plus the SVG path in `paint.rs`.
|
||||||
pub(crate) fn source_type_supported(value: &str) -> bool {
|
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/apng",
|
||||||
"image/bmp",
|
"image/bmp",
|
||||||
@@ -94,11 +97,12 @@ mod paint;
|
|||||||
#[cfg(feature = "paint")]
|
#[cfg(feature = "paint")]
|
||||||
pub use paint::{
|
pub use paint::{
|
||||||
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
|
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
|
||||||
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, screenshot_png,
|
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
|
||||||
screenshot_png_scrolled, screenshot_prepared, screenshot_prepared_region_with_scroll,
|
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
|
||||||
screenshot_prepared_with_scroll, CaptureError, CaptureRegion, DynamicFontFace,
|
screenshot_prepared, screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll,
|
||||||
ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader,
|
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
|
||||||
ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
|
||||||
|
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
// 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
|
/// Layout-only builds have no shaper, so no container is ever treated
|
||||||
/// as a cosmic-text inline formatting context: the word-split path
|
/// as a cosmic-text inline formatting context: the word-split path
|
||||||
/// handles text geometry for `getBoundingClientRect`.
|
/// 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
|
None
|
||||||
}
|
}
|
||||||
/// See `try_build`: inline runs likewise fall back to word-split
|
/// 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 {
|
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 {
|
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 {
|
pub fn rotate(degrees: f32) -> Self {
|
||||||
let (sin, cos) = degrees.to_radians().sin_cos();
|
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 {
|
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) {
|
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 {
|
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.y + rect.height),
|
||||||
self.map_point(rect.x + rect.width, 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 left = points
|
||||||
let top = points.iter().map(|point| point.1).fold(f32::INFINITY, f32::min);
|
.iter()
|
||||||
let right = points.iter().map(|point| point.0).fold(f32::NEG_INFINITY, f32::max);
|
.map(|point| point.0)
|
||||||
let bottom = points.iter().map(|point| point.1).fold(f32::NEG_INFINITY, f32::max);
|
.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 {
|
Rect {
|
||||||
x: left,
|
x: left,
|
||||||
y: top,
|
y: top,
|
||||||
@@ -317,7 +356,10 @@ pub struct TransformLength {
|
|||||||
|
|
||||||
impl TransformLength {
|
impl TransformLength {
|
||||||
pub fn px(value: f32) -> Self {
|
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 x1 = (self.x + self.width).min(other.x + other.width);
|
||||||
let y1 = (self.y + self.height).min(other.y + other.height);
|
let y1 = (self.y + self.height).min(other.y + other.height);
|
||||||
if x1 > x0 && y1 > y0 {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -356,7 +403,12 @@ impl Rect {
|
|||||||
let y0 = self.y.min(other.y);
|
let y0 = self.y.min(other.y);
|
||||||
let x1 = (self.x + self.width).max(other.x + other.width);
|
let x1 = (self.x + self.width).max(other.x + other.width);
|
||||||
let y1 = (self.y + self.height).max(other.y + other.height);
|
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
|
(value * scale).round() / scale
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn quantized_scroll_range(
|
pub(crate) fn quantized_scroll_range(content: f32, client: f32, device_scale_factor: f32) -> f32 {
|
||||||
content: f32,
|
|
||||||
client: f32,
|
|
||||||
device_scale_factor: f32,
|
|
||||||
) -> f32 {
|
|
||||||
(quantize_scroll_value(content, device_scale_factor)
|
(quantize_scroll_value(content, device_scale_factor)
|
||||||
- quantize_scroll_value(client, device_scale_factor))
|
- quantize_scroll_value(client, device_scale_factor))
|
||||||
.max(0.0)
|
.max(0.0)
|
||||||
@@ -532,8 +580,7 @@ impl BackgroundPositionAxis {
|
|||||||
pub(crate) fn interpolate(self, other: Self, position: f32) -> Self {
|
pub(crate) fn interpolate(self, other: Self, position: f32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
length: self.length + (other.length - self.length) * position,
|
length: self.length + (other.length - self.length) * position,
|
||||||
percentage: self.percentage
|
percentage: self.percentage + (other.percentage - self.percentage) * position,
|
||||||
+ (other.percentage - self.percentage) * position,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -816,8 +863,7 @@ pub struct LayoutStyle {
|
|||||||
/// First `radial-gradient(...)` layer: center in box-relative fractions
|
/// First `radial-gradient(...)` layer: center in box-relative fractions
|
||||||
/// and color stops. It is painted below the first linear layer, matching
|
/// and color stops. It is painted below the first linear layer, matching
|
||||||
/// the common `linear-gradient(...), radial-gradient(...)` hero pattern.
|
/// the common `linear-gradient(...), radial-gradient(...)` hero pattern.
|
||||||
pub background_radial_gradient:
|
pub background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||||
Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
|
||||||
/// `conic-gradient(...)` background. The angle is the CSS `from` angle,
|
/// `conic-gradient(...)` background. The angle is the CSS `from` angle,
|
||||||
/// the center is a fraction of the border box, and stops are normalized
|
/// the center is a fraction of the border box, and stops are normalized
|
||||||
/// during paint. Conic gradients commonly provide the color source for a
|
/// during paint. Conic gradients commonly provide the color source for a
|
||||||
@@ -1536,7 +1582,11 @@ pub struct LayoutNode {
|
|||||||
|
|
||||||
impl LayoutNode {
|
impl LayoutNode {
|
||||||
pub fn leaf(style: LayoutStyle) -> Self {
|
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() {
|
if node.children.is_empty() {
|
||||||
tree.new_leaf(style).expect("taffy new_leaf")
|
tree.new_leaf(style).expect("taffy new_leaf")
|
||||||
} else {
|
} else {
|
||||||
let child_ids: Vec<NodeId> =
|
let child_ids: Vec<NodeId> = node.children.iter().map(|c| build_node(tree, c)).collect();
|
||||||
node.children.iter().map(|c| build_node(tree, c)).collect();
|
tree.new_with_children(style, &child_ids)
|
||||||
tree.new_with_children(style, &child_ids).expect("taffy new_with_children")
|
.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` is separate from real flex/grid `align-items`, so a
|
||||||
// text-align declaration never changes how flex children are sized.
|
// text-align declaration never changes how flex children are sized.
|
||||||
let promote_for_alignment = style.display == Display::Block
|
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 {
|
s.display = match style.display {
|
||||||
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
|
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
|
||||||
@@ -1816,7 +1869,9 @@ fn dimension(v: Dimension) -> taffy::style::Dimension {
|
|||||||
// rather than panicking.
|
// rather than panicking.
|
||||||
Dimension::Em(v) | Dimension::Rem(v) => taffy::style::Dimension::length(v * 16.0),
|
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::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::*;
|
use super::*;
|
||||||
|
|
||||||
fn make_box(display: Display, w: f32, h: f32) -> LayoutStyle {
|
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]
|
#[test]
|
||||||
@@ -1909,7 +1969,10 @@ mod tests {
|
|||||||
display: Display::Block,
|
display: Display::Block,
|
||||||
width: Dimension::Px(200.0),
|
width: Dimension::Px(200.0),
|
||||||
height: Dimension::Px(40.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],
|
margin_auto: [false, false, false, true],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1957,7 +2020,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn flex_row_lays_out_horizontally() {
|
fn flex_row_lays_out_horizontally() {
|
||||||
let root = LayoutNode {
|
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,
|
text: None,
|
||||||
children: vec![
|
children: vec![
|
||||||
LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)),
|
LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)),
|
||||||
@@ -1983,7 +2051,12 @@ mod tests {
|
|||||||
display: Display::Block,
|
display: Display::Block,
|
||||||
width: Dimension::Px(100.0),
|
width: Dimension::Px(100.0),
|
||||||
height: 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()
|
..Default::default()
|
||||||
},
|
},
|
||||||
text: None,
|
text: None,
|
||||||
|
|||||||
+1835
-575
File diff suppressed because it is too large.
Load diff
+914
-451
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user