Honor print media in PDF export

This commit is contained in:
SGavrl committed 2026-08-04 13:34:28 +00:00
1 parent c1dbec078b
commit 703d334df7
12 files changed
+856 -181

No files matched your search

+362 -81
View File
@@ -276,10 +276,22 @@ const MAX_STYLESHEET_RESOURCES: usize = 128;
#[derive(Clone)]
struct LoadedStylesheet {
response_url: Url,
imports: Vec<String>,
imports: Vec<StylesheetImport>,
rules: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct StylesheetImport {
url: String,
media: Option<String>,
}
#[derive(Clone, Copy)]
enum AuthorStylesheetTarget {
Linked(usize),
InlineImport(usize),
}
fn canonical_stylesheet_url(mut url: Url) -> (String, Url) {
url.set_fragment(None);
(url.to_string(), url)
@@ -305,13 +317,21 @@ fn materialize_stylesheet_graph(
let mut output = String::new();
for import in &sheet.imports {
let Ok(import_url) = sheet.response_url.join(import) else {
let Ok(import_url) = sheet.response_url.join(&import.url) else {
continue;
};
let (import_key, _) = canonical_stylesheet_url(import_url);
if let Some(imported) = materialize_stylesheet_graph(&import_key, sheets, aliases, active) {
output.push_str(&imported);
output.push('\n');
if let Some(media) = import.media.as_deref() {
output.push_str("@media ");
output.push_str(media);
output.push_str(" {\n");
output.push_str(&imported);
output.push_str("\n}\n");
} else {
output.push_str(&imported);
output.push('\n');
}
}
}
output.push_str(&rebase_css_urls(&sheet.rules, &sheet.response_url));
@@ -537,12 +557,13 @@ fn render_resource_type(url: &url::Url) -> ResourceType {
}
/// Pull leading `@import` rules out of a stylesheet. Returns each import target
/// URL (skipping print-only and `prefers-color-scheme: dark` conditional
/// imports, which must not apply in our light desktop context) plus the CSS
/// with those `@import` statements removed. Handles `@import "x.css";`,
/// URL with its optional media condition plus the CSS with those `@import`
/// statements removed. Browsers fetch media-gated imports even when they do
/// not match the current screen; preserving the condition lets the same bytes
/// participate in a later PDF print cascade. Handles `@import "x.css";`,
/// `@import url("x.css");`, `@import url(x.css);` and an optional trailing
/// media query.
fn split_css_imports(css: &str) -> (Vec<String>, String) {
fn split_css_imports(css: &str) -> (Vec<StylesheetImport>, String) {
let mut urls = Vec::new();
let mut stripped = String::with_capacity(css.len());
let mut rest = css;
@@ -574,10 +595,9 @@ fn split_css_imports(css: &str) -> (Vec<String>, String) {
(urls, stripped)
}
/// Extract the URL from an `@import` statement body (the text between `@import`
/// and `;`), or `None` when the import is media-gated to print / dark and must
/// be skipped.
fn parse_import_url(stmt: &str) -> Option<String> {
/// Extract the URL and optional trailing media query from an `@import`
/// statement body (the text between `@import` and `;`).
fn parse_import_url(stmt: &str) -> Option<StylesheetImport> {
let s = stmt.trim();
let is_url_fn = s.len() >= 4 && s[..4].eq_ignore_ascii_case("url(");
let (url, media) = if is_url_fn {
@@ -591,22 +611,13 @@ fn parse_import_url(stmt: &str) -> Option<String> {
let end = rest.find(quote)?;
(rest[..end].to_string(), rest[end + 1..].trim())
};
if !media.is_empty() {
let compact: String = media
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(|c| c.to_lowercase())
.collect();
let print_only =
compact.contains("print") && !compact.contains("screen") && !compact.contains("all");
if print_only || compact.contains("prefers-color-scheme:dark") {
return None;
}
}
if url.is_empty() {
return None;
}
Some(url)
Some(StylesheetImport {
url,
media: (!media.is_empty()).then(|| media.to_string()),
})
}
/// Materialize a fetched linked sheet immediately after its source `<link>`.
@@ -615,6 +626,8 @@ fn parse_import_url(stmt: &str) -> Option<String> {
/// author sheets are interleaved. Appending one aggregate `<style>` to `<head>`
/// makes every external rule later than every inline rule, which changes the
/// CSS cascade even when the external fetches themselves complete in order.
/// The synthetic style retains the link's effective media query so the same
/// fetched bytes can enter print layout without leaking into screen layout.
fn materialize_linked_stylesheet_script(link_index: usize, css: &str) -> String {
let escaped_css = escape_for_js_template_literal(css);
format!(
@@ -632,17 +645,10 @@ fn materialize_linked_stylesheet_script(link_index: usize, css: &str) -> String
}}
return link.getAttribute('media') || '';
}}
function applies() {{
var media = effectiveMedia().trim();
if (!media) return true;
try {{ return window.matchMedia(media).matches; }}
catch (_) {{ return false; }}
}}
function syncSheet() {{
var enabled = link.parentNode
&& !link.disabled
&& !link.hasAttribute('disabled')
&& applies();
&& !link.hasAttribute('disabled');
if (!enabled) {{
if (style && style.parentNode) style.parentNode.removeChild(style);
return;
@@ -652,6 +658,9 @@ fn materialize_linked_stylesheet_script(link_index: usize, css: &str) -> String
style.setAttribute('data-obscura-external-stylesheets', '');
style.textContent = `{escaped_css}`;
}}
var media = effectiveMedia().trim();
if (media) style.setAttribute('media', media);
else style.removeAttribute('media');
if (!style.parentNode) {{
link.parentNode.insertBefore(style, link.nextSibling);
}}
@@ -667,6 +676,35 @@ fn materialize_linked_stylesheet_script(link_index: usize, css: &str) -> String
)
}
/// Materialize one fetched `@import` immediately before its source inline
/// `<style>`. Imported rules precede the importing sheet in the author cascade,
/// and inherit the source sheet's own media condition in addition to the
/// import rule's media wrapper.
fn materialize_inline_import_script(style_index: usize, css: &str) -> String {
let escaped_css = escape_for_js_template_literal(css);
format!(
r#"(function() {{
var styles = document.querySelectorAll('style');
var source = null;
var authorIndex = -1;
for (var i = 0; i < styles.length; i++) {{
var candidate = styles[i];
if (candidate.hasAttribute('data-obscura-external-stylesheets')
|| candidate.hasAttribute('data-obscura-inline-import')) continue;
authorIndex++;
if (authorIndex === {style_index}) {{ source = candidate; break; }}
}}
if (!source || !source.parentNode) return;
var imported = document.createElement('style');
imported.setAttribute('data-obscura-inline-import', '');
var media = source.getAttribute('media') || '';
if (media.trim()) imported.setAttribute('media', media);
imported.textContent = `{escaped_css}`;
source.parentNode.insertBefore(imported, source);
}})()"#
)
}
/// Discover linked author sheets in document order.
///
/// Media queries control whether a loaded sheet participates in the cascade;
@@ -692,6 +730,35 @@ fn linked_stylesheet_requests(dom: &DomTree) -> Vec<(usize, String)> {
links
}
/// Discover fetchable `@import` rules in inline author sheets. The source
/// index excludes Obscura's own materialized sheets so it remains stable while
/// imports are inserted before their source nodes.
fn inline_stylesheet_import_requests(dom: &DomTree) -> Vec<(usize, StylesheetImport)> {
let style_ids = dom.query_selector_all("style").unwrap_or_default();
let mut imports = Vec::new();
let mut author_index = 0usize;
for style_id in style_ids {
let Some(node) = dom.get_node(style_id) else {
continue;
};
if node
.get_attribute("data-obscura-external-stylesheets")
.is_some()
|| node.get_attribute("data-obscura-inline-import").is_some()
{
continue;
}
let (style_imports, _) = split_css_imports(&dom.text_content(style_id));
imports.extend(
style_imports
.into_iter()
.map(|import| (author_index, import)),
);
author_index += 1;
}
imports
}
impl Page {
pub fn new(id: String, context: Arc<BrowserContext>) -> Self {
let http_client = context.http_client.clone();
@@ -939,9 +1006,16 @@ impl Page {
}
}
async fn fetch_stylesheets(&mut self) -> Vec<(usize, String)> {
let all_links = match &self.js {
Some(js) => js.with_dom(linked_stylesheet_requests).unwrap_or_default(),
async fn fetch_stylesheets(&mut self) -> Vec<(AuthorStylesheetTarget, String)> {
let (all_links, inline_imports) = match &self.js {
Some(js) => js
.with_dom(|dom| {
(
linked_stylesheet_requests(dom),
inline_stylesheet_import_requests(dom),
)
})
.unwrap_or_default(),
None => {
tracing::info!("fetch_stylesheets: no js runtime");
return Vec::new();
@@ -949,8 +1023,9 @@ impl Page {
};
tracing::info!(
"fetch_stylesheets: found {} stylesheet links",
all_links.len()
"fetch_stylesheets: found {} stylesheet links and {} inline imports",
all_links.len(),
inline_imports.len()
);
let Some(document_url) = self.url.clone() else {
@@ -979,13 +1054,33 @@ impl Page {
tracing::info!("Blocked stylesheet by interception: {}", resolved);
continue;
}
roots.push((link_index, key.clone()));
roots.push((AuthorStylesheetTarget::Linked(link_index), key.clone(), None));
if scheduled.insert(key.clone()) {
if scheduled.len() <= MAX_STYLESHEET_RESOURCES {
pending.push((key, resolved, 0u8));
}
}
}
for (style_index, import) in inline_imports {
let Ok(resolved) = document_base.join(&import.url) else {
continue;
};
let (key, resolved) = canonical_stylesheet_url(resolved);
if !subresource_allowed(Some(&document_url), resolved.as_str())
|| self.should_block_url(resolved.as_str())
{
tracing::info!("Blocked inline stylesheet import: {}", resolved);
continue;
}
roots.push((
AuthorStylesheetTarget::InlineImport(style_index),
key.clone(),
import.media,
));
if scheduled.insert(key.clone()) && scheduled.len() <= MAX_STYLESHEET_RESOURCES {
pending.push((key, resolved, 1u8));
}
}
let mut sheets = std::collections::HashMap::new();
let mut aliases = std::collections::HashMap::new();
@@ -1086,7 +1181,7 @@ impl Page {
continue;
}
for import in imports {
let Ok(import_url) = response_url.join(&import) else {
let Ok(import_url) = response_url.join(&import.url) else {
continue;
};
let (import_key, import_url) = canonical_stylesheet_url(import_url);
@@ -1114,14 +1209,20 @@ impl Page {
roots
.into_iter()
.filter_map(|(link_index, key)| {
.filter_map(|(target, key, media)| {
materialize_stylesheet_graph(
&key,
&sheets,
&aliases,
&mut std::collections::HashSet::new(),
)
.map(|css| (link_index, css))
.map(|css| {
let css = match media {
Some(media) => format!("@media {media} {{\n{css}\n}}\n"),
None => css,
};
(target, css)
})
})
.collect()
}
@@ -2280,14 +2381,14 @@ impl Page {
self.dom = Some(dom);
self.init_js();
let linked_stylesheets = self.fetch_stylesheets().await;
let author_stylesheets = self.fetch_stylesheets().await;
// Inject CSS as a global so getComputedStyle and any CSS-aware shim
// can read it. Has to happen before scripts run, regardless of
// waitUntil, so handlers that read window.__obscura_css see it.
if !linked_stylesheets.is_empty() {
if !author_stylesheets.is_empty() {
if let Some(js) = &mut self.js {
let combined_css = linked_stylesheets
let combined_css = author_stylesheets
.iter()
.map(|(_, css)| css.as_str())
.collect::<Vec<_>>()
@@ -2301,8 +2402,15 @@ impl Page {
let escaped = escape_for_js_template_literal(&combined_css);
let code = format!("globalThis.__obscura_css = `{}`;", escaped);
let _ = js.execute_script("<css>", &code);
for (link_index, css) in &linked_stylesheets {
let code = materialize_linked_stylesheet_script(*link_index, css);
for (target, css) in &author_stylesheets {
let code = match target {
AuthorStylesheetTarget::Linked(link_index) => {
materialize_linked_stylesheet_script(*link_index, css)
}
AuthorStylesheetTarget::InlineImport(style_index) => {
materialize_inline_import_script(*style_index, css)
}
};
let _ = js.execute_script("<fetch_stylesheets>", &code);
}
}
@@ -3387,8 +3495,9 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
mod tests {
use super::{
css_resource_urls, linked_stylesheet_requests, materialize_linked_stylesheet_script,
navigation_referrer, parse_import_url, rebase_css_urls, script_response_is_executable,
split_css_imports, truncate_on_char_boundary, url_matches_cdp_pattern,
materialize_stylesheet_graph, navigation_referrer, parse_import_url, rebase_css_urls,
script_response_is_executable, split_css_imports, truncate_on_char_boundary,
url_matches_cdp_pattern, LoadedStylesheet, StylesheetImport,
};
#[cfg(feature = "render")]
use super::remaining_settle_resource_warmup_ms;
@@ -3477,6 +3586,49 @@ mod tests {
(origin, request_rx)
}
fn spawn_inline_import_server() -> (String, std::sync::mpsc::Receiver<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let origin = format!("http://{address}");
let (request_tx, request_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
for _ in 0..3 {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 4096];
let length = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..length]);
let path = request
.lines()
.next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/")
.to_string();
request_tx.send(path.clone()).unwrap();
let (content_type, body) = match path.as_str() {
"/" => (
"text/html",
r#"<!doctype html><style media="screen, print">
@import url('/a.css') print;
@import '/b.css' print;
.local { color: white }
</style><div class="local imported-a imported-b">marker</div>"#,
),
"/a.css" => ("text/css", ".imported-a{background:#9020d0}"),
"/b.css" => ("text/css", ".imported-b{border-color:#f0d020}"),
_ => ("text/plain", "unexpected"),
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
}
});
(origin, request_rx)
}
#[test]
fn default_navigation_referrer_matches_strict_origin_when_cross_origin() {
let source = url::Url::parse("https://user:pass@source.example/path?q=1#fragment").unwrap();
@@ -3652,6 +3804,74 @@ mod tests {
assert!(sheets[2].contains(&format!("url(\"{origin}/theme/img/second.png\")")));
}
#[tokio::test(flavor = "current_thread")]
async fn inline_imports_fetch_in_order_and_materialize_before_source_style() {
let (origin, requests) = spawn_inline_import_server();
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"inline-imports".to_string(),
None,
false,
None,
None,
true,
));
let mut page = super::Page::new("inline-imports".to_string(), context);
page.set_viewport((100.0, 80.0));
page.navigate(&format!("{origin}/")).await.unwrap();
let mut paths = (0..3)
.map(|_| {
requests
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap()
})
.collect::<Vec<_>>();
paths.sort();
assert_eq!(paths, vec!["/", "/a.css", "/b.css"]);
let styles = page
.js
.as_ref()
.unwrap()
.with_dom(|dom| {
dom.query_selector_all("style")
.unwrap()
.into_iter()
.map(|nid| {
let node = dom.get_node(nid).unwrap();
(
node.get_attribute("data-obscura-inline-import").is_some(),
node.get_attribute("media").map(str::to_string),
dom.text_content(nid),
)
})
.collect::<Vec<_>>()
})
.unwrap();
assert_eq!(styles.len(), 3);
assert!(styles[0].0 && styles[0].2.contains(".imported-a"));
assert!(styles[1].0 && styles[1].2.contains(".imported-b"));
assert!(!styles[2].0 && styles[2].2.contains(".local"));
assert_eq!(styles[0].1.as_deref(), Some("screen, print"));
assert_eq!(styles[1].1.as_deref(), Some("screen, print"));
assert!(styles[0].2.starts_with("@media print {\n"));
assert!(styles[1].2.starts_with("@media print {\n"));
let pdf = page
.raster_pdf(crate::RasterPdfOptions {
print_background: true,
paper_width_in: 100.0 / 72.0,
paper_height_in: 80.0 / 72.0,
margin_top_in: 0.0,
margin_bottom_in: 0.0,
margin_left_in: 0.0,
margin_right_in: 0.0,
..crate::RasterPdfOptions::default()
})
.expect("inline-import print PDF");
assert!(pdf.starts_with(b"%PDF-1.4"));
}
fn client_replacement_page(name: &str, deferred: bool) -> super::Page {
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
name.to_string(),
@@ -5004,40 +5224,45 @@ mod tests {
#[test]
fn parse_import_url_extracts_url_forms() {
assert_eq!(
parse_import_url(" url(\"basic.css\")").as_deref(),
Some("basic.css")
);
assert_eq!(
parse_import_url(" url(basic.css)").as_deref(),
Some("basic.css")
);
assert_eq!(
parse_import_url(" \"basic.css\"").as_deref(),
Some("basic.css")
);
assert_eq!(
parse_import_url(" 'theme.css'").as_deref(),
Some("theme.css")
);
assert_eq!(parse_import_url(" URL('x.css')").as_deref(), Some("x.css"));
for (source, expected_url) in [
(" url(\"basic.css\")", "basic.css"),
(" url(basic.css)", "basic.css"),
(" \"basic.css\"", "basic.css"),
(" 'theme.css'", "theme.css"),
(" URL('x.css')", "x.css"),
] {
assert_eq!(
parse_import_url(source),
Some(StylesheetImport {
url: expected_url.to_string(),
media: None,
})
);
}
}
#[test]
fn parse_import_url_skips_print_and_dark_media() {
assert_eq!(parse_import_url("url(\"p.css\") print"), None);
fn parse_import_url_preserves_print_and_color_scheme_media() {
assert_eq!(
parse_import_url("url(\"p.css\") print"),
Some(StylesheetImport {
url: "p.css".to_string(),
media: Some("print".to_string()),
})
);
assert_eq!(
parse_import_url("url(\"d.css\") (prefers-color-scheme: dark)"),
None
);
// screen / all and light preference still apply.
assert_eq!(
parse_import_url("url(\"s.css\") screen").as_deref(),
Some("s.css")
Some(StylesheetImport {
url: "d.css".to_string(),
media: Some("(prefers-color-scheme: dark)".to_string()),
})
);
assert_eq!(
parse_import_url("url(\"a.css\") print, screen").as_deref(),
Some("a.css")
parse_import_url("url(\"a.css\") print, screen"),
Some(StylesheetImport {
url: "a.css".to_string(),
media: Some("print, screen".to_string()),
})
);
}
@@ -5045,7 +5270,13 @@ mod tests {
fn split_css_imports_pulls_imports_and_strips_them() {
let css = "@import url(\"basic.css\");\nbody { color: red; }";
let (imports, stripped) = split_css_imports(css);
assert_eq!(imports, vec!["basic.css".to_string()]);
assert_eq!(
imports,
vec![StylesheetImport {
url: "basic.css".to_string(),
media: None,
}]
);
assert!(!stripped.contains("@import"));
assert!(stripped.contains("body { color: red; }"));
}
@@ -5058,6 +5289,49 @@ mod tests {
assert_eq!(stripped, css);
}
#[test]
fn materialized_import_graph_retains_print_condition_and_import_base() {
let root_url = url::Url::parse("https://example.test/css/root.css").unwrap();
let print_url = root_url.join("print/print.css").unwrap();
let mut sheets = std::collections::HashMap::new();
sheets.insert(
root_url.to_string(),
LoadedStylesheet {
response_url: root_url.clone(),
imports: vec![StylesheetImport {
url: "print/print.css".to_string(),
media: Some("print".to_string()),
}],
rules: ".root{color:red}".to_string(),
},
);
sheets.insert(
print_url.to_string(),
LoadedStylesheet {
response_url: print_url.clone(),
imports: Vec::new(),
rules: ".print{background:url(../mark.svg)}".to_string(),
},
);
let aliases = std::collections::HashMap::from([
(root_url.to_string(), root_url.to_string()),
(print_url.to_string(), print_url.to_string()),
]);
let materialized = materialize_stylesheet_graph(
root_url.as_str(),
&sheets,
&aliases,
&mut std::collections::HashSet::new(),
)
.expect("materialized graph");
assert!(materialized.starts_with("@media print {\n"));
assert!(materialized.contains(
r#".print{background:url("https://example.test/css/mark.svg")}"#
));
assert!(materialized.ends_with(".root{color:red}"));
}
#[test]
fn stylesheet_asset_urls_keep_the_importing_sheets_base() {
let base = url::Url::parse("https://example.com/css/theme/app.css").unwrap();
@@ -5165,7 +5439,7 @@ mod tests {
}
#[test]
fn true_print_stylesheet_loads_without_entering_screen_cascade() {
fn true_print_stylesheet_loads_and_remains_media_gated() {
let dom = parse_html(
r#"<html><head>
<link id="print" rel="stylesheet" href="print.css" media="print"
@@ -5191,9 +5465,12 @@ mod tests {
(
dom.get_node(link)
.and_then(|node| node.get_attribute("data-loaded").map(str::to_owned)),
dom.query_selector_all("style[data-obscura-external-stylesheets]")
dom.query_selector("style[data-obscura-external-stylesheets]")
.expect("valid selector")
.len(),
.and_then(|style| {
dom.get_node(style)
.and_then(|node| node.get_attribute("media").map(str::to_owned))
}),
)
})
.expect("live DOM");
@@ -5203,7 +5480,11 @@ mod tests {
Some("yes"),
"print link still fires load"
);
assert_eq!(state.1, 0, "print-only CSS must stay out of screen cascade");
assert_eq!(
state.1.as_deref(),
Some("print"),
"the fetched sheet must remain available for PDF print selection"
);
}
#[test]
+149 -59
View File
@@ -1,8 +1,8 @@
//! 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.
//! print-media layout, fits the full document width into the printable area,
//! and slices that immutable layout vertically across PDF pages.
use std::io;
@@ -272,11 +272,12 @@ fn selected_page_indices(
}
impl Page {
/// Export the current screen layout as a paginated raster PDF.
/// Export the current print-media 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, or footers.
/// vertical slices become pages. Print media rules participate in normal
/// cascade and layout, but CSS paged media, headers, and footers remain
/// outside this raster-backed exporter.
pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result<Vec<u8>, RasterPdfError> {
self.raster_pdf_with_animation_sample(options, self.live_animation_sample())
}
@@ -309,62 +310,68 @@ impl Page {
if !js.set_animation_sample(animation_sample) {
return Err(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 previous_media = js.set_render_media(obscura_js::CssMediaType::Print);
let result = (|| {
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,
options.scale,
)?;
let selected_pages = selected_page_indices(plan.page_count, &options.page_ranges)?;
validate_selected_raster_work(content_width, content_height, plan, &selected_pages)?;
let plan = pagination_plan(
content_width,
content_height,
printable_width,
printable_height,
options.scale,
)?;
let selected_pages = selected_page_indices(plan.page_count, &options.page_ranges)?;
validate_selected_raster_work(content_width, content_height, plan, &selected_pages)?;
encode_pdf_pages(
selected_pages.len(),
page_width,
page_height,
left,
bottom,
printable_height,
|output_page_index| {
let page_index = selected_pages[output_page_index];
let y = page_index as f32 * plan.css_page_height;
let slice_height = (content_height - y).min(plan.css_page_height);
let png = js
.screenshot_prepared_region_at_scroll_with_backgrounds(
CaptureRegion::new(0.0, y, content_width, slice_height, 1.0),
(0.0, y),
options.print_background,
)
.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: content_width * plan.points_per_css_pixel,
draw_height_pt: slice_height * plan.points_per_css_pixel,
#[cfg(test)]
_lifetime_probe: None,
})
},
)
encode_pdf_pages(
selected_pages.len(),
page_width,
page_height,
left,
bottom,
printable_height,
|output_page_index| {
let page_index = selected_pages[output_page_index];
let y = page_index as f32 * plan.css_page_height;
let slice_height = (content_height - y).min(plan.css_page_height);
let png = js
.screenshot_prepared_region_at_scroll_with_backgrounds(
CaptureRegion::new(0.0, y, content_width, slice_height, 1.0),
(0.0, y),
options.print_background,
)
.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: content_width * plan.points_per_css_pixel,
draw_height_pt: slice_height * plan.points_per_css_pixel,
#[cfg(test)]
_lifetime_probe: None,
})
},
)
})();
js.set_render_media(previous_media);
result
}
}
@@ -845,6 +852,89 @@ mod tests {
assert!(channel_near(*selected[1].get_pixel(50, 20), [32, 80, 224]));
}
#[test]
fn raster_pdf_selects_print_media_and_restores_screen_render_state() {
let context = std::sync::Arc::new(crate::BrowserContext::new("pdf-media".to_string()));
let mut page = crate::Page::new("pdf-media-page".to_string(), context);
page.set_viewport((100.0, 80.0));
let dom = obscura_dom::parse_html(
r#"<!doctype html><html><head>
<style>
html,body{margin:0;width:100px;height:80px;background:#101010}
#print-marker,#screen-marker{display:none}
@media print {
body{background:#2050e0}
}
@media screen {
body{background:#e02020}
}
</style>
<style media="print">
#print-marker{display:block;position:absolute;left:60px;top:10px;
width:30px;height:30px;background:#f0d020}
</style>
<style media="screen">
#screen-marker{display:block;position:absolute;left:5px;top:5px;
width:10px;height:10px;background:#20c040}
</style>
</head><body><div id="print-marker"></div><div id="screen-marker"></div></body></html>"#,
);
let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new();
runtime.set_dom(dom);
runtime.set_url("https://example.test/pdf-media");
runtime.set_viewport(100.0, 80.0);
runtime.run_page_init();
page.js = Some(runtime);
let screen_before = page.screenshot((100.0, 80.0)).expect("screen before PDF");
let screen_before_pixels =
image::load_from_memory_with_format(&screen_before, image::ImageFormat::Png)
.expect("screen PNG")
.into_rgb8();
assert_eq!(screen_before_pixels.get_pixel(50, 60).0, [224, 32, 32]);
assert_eq!(screen_before_pixels.get_pixel(8, 8).0, [32, 192, 64]);
assert_eq!(
screen_before_pixels.get_pixel(70, 20).0,
[224, 32, 32],
"media=print marker must stay out of the screen cascade"
);
let options = RasterPdfOptions {
print_background: true,
paper_width_in: 100.0 / POINTS_PER_INCH,
paper_height_in: 80.0 / POINTS_PER_INCH,
margin_top_in: 0.0,
margin_bottom_in: 0.0,
margin_left_in: 0.0,
margin_right_in: 0.0,
..RasterPdfOptions::default()
};
let pages = pdf_page_rasters(&page.raster_pdf(options).expect("print-media PDF"));
assert_eq!(pages.len(), 1);
let printed = &pages[0];
assert!(
channel_near(*printed.get_pixel(50, 60), [32, 80, 224]),
"@media print body color missing: {:?}",
printed.get_pixel(50, 60)
);
assert!(
channel_near(*printed.get_pixel(70, 20), [240, 208, 32]),
"media=print stylesheet marker missing: {:?}",
printed.get_pixel(70, 20)
);
assert!(
channel_near(*printed.get_pixel(8, 8), [32, 80, 224]),
"media=screen marker leaked into print: {:?}",
printed.get_pixel(8, 8)
);
let screen_after = page.screenshot((100.0, 80.0)).expect("screen after PDF");
assert_eq!(
screen_after, screen_before,
"temporary print cascade must not poison retained screen geometry or stylesheet cache"
);
}
#[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, |_| {
+4 -2
View File
@@ -231,13 +231,14 @@ pub async fn print_to_pdf(
.raster_pdf_with_animation_sample(options.raster, animation_sample)
.map_err(|error| error.to_string())?;
let mut response = json!({
"obscuraPrintMode": "screen-raster",
"obscuraPrintMode": "print-media-raster",
"obscuraPrintBackground": options.requested_print_background,
"obscuraRequestedPrintBackground": options.requested_print_background,
"obscuraTaggedPDF": false,
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
"obscuraCapabilities": {
"cssPagedMedia": false,
"honorsPrintMedia": true,
"honorsPrintBackground": true,
"honorsScale": true,
"pageRanges": true,
@@ -423,7 +424,7 @@ mod tests {
)
.await
.expect("raster PDF");
assert_eq!(response["obscuraPrintMode"], "screen-raster");
assert_eq!(response["obscuraPrintMode"], "print-media-raster");
assert_eq!(response["obscuraPrintBackground"], true);
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
@@ -472,6 +473,7 @@ mod tests {
true
);
assert_eq!(streamed["obscuraCapabilities"]["honorsScale"], true);
assert_eq!(streamed["obscuraCapabilities"]["honorsPrintMedia"], true);
assert_eq!(streamed["obscuraCapabilities"]["pageRanges"], true);
assert_eq!(streamed["obscuraTaggedPDF"], false);
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
+1 -1
View File
@@ -16,6 +16,6 @@ pub use obscura_render::{
screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time,
screenshot_png_scrolled_at_animation_time_with_surface_color,
validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime,
CaptureError, CaptureRegion, ImageRequestProfile,
CaptureError, CaptureRegion, CssMediaType, ImageRequestProfile,
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
};
+32 -11
View File
@@ -141,6 +141,11 @@ pub struct ObscuraState {
/// DOM/style/viewport changes clear this value but retain resource bytes.
#[cfg(feature = "render")]
pub prepared_render: Option<obscura_render::PreparedRender>,
/// CSS media type selected for the next retained layout. Live pages use
/// screen; PDF export switches to print for one synchronous capture and
/// restores screen before returning.
#[cfg(feature = "render")]
pub render_media: obscura_render::CssMediaType,
/// Explicit document-timeline sample used by the next style/layout flush.
/// Captures set this to either deterministic T=0 or live document time.
#[cfg(feature = "render")]
@@ -235,6 +240,8 @@ impl ObscuraState {
#[cfg(feature = "render")]
prepared_render: None,
#[cfg(feature = "render")]
render_media: obscura_render::CssMediaType::Screen,
#[cfg(feature = "render")]
animation_sample: obscura_render::AnimationSample::default(),
#[cfg(feature = "render")]
animation_timeline: obscura_render::AnimationTimelineState::default(),
@@ -3426,6 +3433,7 @@ pub(crate) fn ensure_prepared_render(
) -> Option<&obscura_render::PreparedRender> {
let base_url = document_base_url(state);
let viewport = state.viewport;
let render_media = state.render_media;
let animation_sample = state.animation_sample;
let incompatible = state.prepared_render.as_ref().is_some_and(|prepared| {
prepared.viewport() != viewport
@@ -3440,7 +3448,7 @@ pub(crate) fn ensure_prepared_render(
.animation_timeline
.materialize_start_candidates(dom);
}
let previous = (!incompatible)
let previous = (!incompatible && render_media == obscura_render::CssMediaType::Screen)
.then(|| state.prepared_render.take())
.flatten();
let mutations = std::mem::take(&mut state.pending_style_mutations);
@@ -3471,16 +3479,29 @@ pub(crate) fn ensure_prepared_render(
&mut state.animation_timeline,
)
})?,
None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
animation_sample,
&mut state.animation_timeline,
)?,
None => match render_media {
obscura_render::CssMediaType::Screen => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
animation_sample,
&mut state.animation_timeline,
)?,
obscura_render::CssMediaType::Print => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_for_media_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
render_media,
animation_sample,
&mut state.animation_timeline,
)?,
},
}
};
if animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime {
+18
View File
@@ -493,6 +493,24 @@ impl ObscuraJsRuntime {
true
}
/// Select the CSS media type for the next synchronous render flush.
/// Changing media invalidates geometry and the compiled stylesheet key but
/// leaves the live DOM, scroll offsets, and resource bytes untouched.
#[cfg(feature = "render")]
pub fn set_render_media(
&self,
media: obscura_render::CssMediaType,
) -> obscura_render::CssMediaType {
let mut state = self.state.borrow_mut();
let previous = state.render_media;
if previous != media {
state.render_media = media;
state.prepared_render = None;
state.resolved_scroll = None;
}
previous
}
#[cfg(feature = "render")]
pub fn animation_sample_time(&self) -> obscura_render::AnimationSampleTime {
self.state.borrow().animation_sample.time
+154 -20
View File
@@ -15,6 +15,18 @@ use std::sync::Arc;
use crate::LayoutStyle;
/// CSS media type used while selecting conditional author rules.
///
/// Screen is the normal live-page mode. PDF export temporarily selects Print
/// so `@media print` and media-gated stylesheet blocks can participate without
/// mutating the document or changing JavaScript's live screen environment.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CssMediaType {
#[default]
Screen,
Print,
}
/// The part of the tree whose selector match may change when a dependency on
/// one element changes. Multiple bits can be present for selectors which use
/// the same key in more than one compound.
@@ -2320,7 +2332,8 @@ const MAX_STYLESHEET_CACHE_RULES: usize = 100_000;
///
/// DOM mutations still run the complete cascade and layout against the live
/// tree. This cache retains only source parsing, selector compilation, and
/// candidate indexing, whose inputs are the ordered CSS text and viewport.
/// candidate indexing, whose inputs are the ordered CSS text, viewport, and
/// selected CSS media type.
/// Keeping a single exact-key entry prevents cross-document growth and avoids
/// hash-collision correctness risks. Pathological source sets above the byte
/// bound are parsed normally but never retained.
@@ -2334,6 +2347,7 @@ pub struct StylesheetCache {
struct CachedStylesheet {
sources: Vec<String>,
viewport_bits: (u32, u32),
media_type: CssMediaType,
source_bytes: usize,
sheet: Arc<Stylesheet>,
}
@@ -2344,17 +2358,26 @@ impl StylesheetCache {
tree: &DomTree,
sources: &[String],
viewport: (f32, f32),
media_type: CssMediaType,
) -> (Arc<Stylesheet>, bool) {
let viewport_bits = (viewport.0.to_bits(), viewport.1.to_bits());
if let Some(entry) = self.entry.as_ref() {
if entry.viewport_bits == viewport_bits && entry.sources == sources {
if entry.viewport_bits == viewport_bits
&& entry.media_type == media_type
&& entry.sources == sources
{
self.hits = self.hits.saturating_add(1);
return (Arc::clone(&entry.sheet), true);
}
}
self.misses = self.misses.saturating_add(1);
let sheet = Arc::new(Stylesheet::parse_for_viewport(tree, sources, viewport));
let sheet = Arc::new(Stylesheet::parse_for_viewport_and_media(
tree,
sources,
viewport,
media_type,
));
let source_bytes = sources
.iter()
.try_fold(0usize, |total, source| total.checked_add(source.len()));
@@ -2369,6 +2392,7 @@ impl StylesheetCache {
self.entry = Some(CachedStylesheet {
sources: sources.to_vec(),
viewport_bits,
media_type,
source_bytes,
sheet: Arc::clone(&sheet),
});
@@ -2556,10 +2580,25 @@ impl Stylesheet {
/// fixed desktop width made responsive frameworks build one DOM while the
/// renderer applied another breakpoint.
pub fn parse_for_viewport(tree: &DomTree, sources: &[String], viewport: (f32, f32)) -> Self {
Self::parse_for_viewport_at_animation_time(
Self::parse_for_viewport_and_media(
tree,
sources,
viewport,
CssMediaType::Screen,
)
}
pub fn parse_for_viewport_and_media(
tree: &DomTree,
sources: &[String],
viewport: (f32, f32),
media_type: CssMediaType,
) -> Self {
Self::parse_for_viewport_and_media_at_animation_time(
tree,
sources,
viewport,
media_type,
crate::AnimationSampleTime::default(),
)
}
@@ -2569,6 +2608,22 @@ impl Stylesheet {
sources: &[String],
viewport: (f32, f32),
animation_sample_time: crate::AnimationSampleTime,
) -> Self {
Self::parse_for_viewport_and_media_at_animation_time(
tree,
sources,
viewport,
CssMediaType::Screen,
animation_sample_time,
)
}
pub fn parse_for_viewport_and_media_at_animation_time(
tree: &DomTree,
sources: &[String],
viewport: (f32, f32),
media_type: CssMediaType,
animation_sample_time: crate::AnimationSampleTime,
) -> Self {
let mut sheet = Stylesheet {
rules: Vec::new(),
@@ -2598,6 +2653,7 @@ impl Stylesheet {
let parsed = parse_stylesheet_for_viewport_preserving_containers_in_layer(
src,
viewport,
media_type,
&mut sheet.container_conditions,
ContainerConditionId::NONE,
&mut layers,
@@ -3919,6 +3975,7 @@ fn extract_keyframes(css: &str) -> Vec<(String, Keyframes)> {
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
);
@@ -5413,6 +5470,7 @@ fn parse_stylesheet_for_viewport(css: &str, viewport: (f32, f32)) -> Vec<(String
parse_stylesheet_for_viewport_preserving_containers(
css,
viewport,
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
)
@@ -5432,6 +5490,7 @@ fn parse_stylesheet_for_viewport(css: &str, viewport: (f32, f32)) -> Vec<(String
fn parse_stylesheet_for_viewport_preserving_containers(
css: &str,
viewport: (f32, f32),
media_type: CssMediaType,
container_conditions: &mut Vec<ContainerConditionNode>,
container_condition_id: ContainerConditionId,
) -> Vec<ParsedRule> {
@@ -5439,6 +5498,7 @@ fn parse_stylesheet_for_viewport_preserving_containers(
parse_stylesheet_for_viewport_preserving_containers_in_layer(
css,
viewport,
media_type,
container_conditions,
container_condition_id,
&mut layers,
@@ -5449,6 +5509,7 @@ fn parse_stylesheet_for_viewport_preserving_containers(
fn parse_stylesheet_for_viewport_preserving_containers_in_layer(
css: &str,
viewport: (f32, f32),
media_type: CssMediaType,
container_conditions: &mut Vec<ContainerConditionNode>,
container_condition_id: ContainerConditionId,
layers: &mut LayerRegistry,
@@ -5498,6 +5559,7 @@ fn parse_stylesheet_for_viewport_preserving_containers_in_layer(
decls,
&mut rules,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -5513,6 +5575,7 @@ fn parse_stylesheet_for_viewport_preserving_containers_in_layer(
decls,
&mut rules,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -5551,17 +5614,19 @@ fn flush_at_rule(
inner: &str,
rules: &mut Vec<ParsedRule>,
viewport: (f32, f32),
media_type: CssMediaType,
container_conditions: &mut Vec<ContainerConditionNode>,
container_condition_id: ContainerConditionId,
layers: &mut LayerRegistry,
current_layer: Option<&LayerOrder>,
) {
if let Some(prelude) = at_rule_prelude(at, "media") {
if media_query_applies_for_viewport(prelude, viewport) {
if media_query_applies_for_viewport_and_type(prelude, viewport, media_type) {
rules.extend(
parse_stylesheet_for_viewport_preserving_containers_in_layer(
inner,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -5575,6 +5640,7 @@ fn flush_at_rule(
parse_stylesheet_for_viewport_preserving_containers_in_layer(
inner,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -5596,6 +5662,7 @@ fn flush_at_rule(
parse_stylesheet_for_viewport_preserving_containers_in_layer(
inner,
viewport,
media_type,
container_conditions,
id,
layers,
@@ -5647,6 +5714,7 @@ fn flush_at_rule(
parse_stylesheet_for_viewport_preserving_containers_in_layer(
inner,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -6276,16 +6344,28 @@ fn split_supports_operator<'a>(condition: &'a str, operator: &str) -> Option<Vec
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
/// than special-case every formatting variant a site might use.
pub(crate) fn media_query_applies_for_viewport(query: &str, viewport: (f32, f32)) -> bool {
media_query_applies_for_viewport_and_type(query, viewport, CssMediaType::Screen)
}
pub(crate) fn media_query_applies_for_viewport_and_type(
query: &str,
viewport: (f32, f32),
media_type: CssMediaType,
) -> bool {
// A media-query list is an OR, not an AND. Evaluate each top-level comma
// arm independently (commas inside functions such as rgb() / calc() are
// not list separators). This also keeps an inapplicable `print` arm from
// suppressing a later screen/feature arm.
split_media_query_list(query)
.into_iter()
.any(|query| single_media_query_applies_for_viewport(query, viewport))
.any(|query| single_media_query_applies_for_viewport(query, viewport, media_type))
}
fn single_media_query_applies_for_viewport(query: &str, viewport: (f32, f32)) -> bool {
fn single_media_query_applies_for_viewport(
query: &str,
viewport: (f32, f32),
media_type: CssMediaType,
) -> bool {
let viewport_w = viewport.0;
let viewport_h = viewport.1;
let query = query.trim().strip_prefix("@media").unwrap_or(query).trim();
@@ -6295,19 +6375,26 @@ fn single_media_query_applies_for_viewport(query: &str, viewport: (f32, f32)) ->
.flat_map(|c| c.to_lowercase())
.collect();
// Tailwind expresses every `max-*` breakpoint as the negation of a
// min-width query (`not all and (min-width:40rem)`). Evaluate the inner
// condition and invert it; treating all `not all` queries as permanently
// false makes mutually exclusive responsive utilities apply together at
// desktop widths. Unknown browser-targeting features still conservatively
// evaluate true inside and therefore false after negation.
if let Some(inner) = compact.strip_prefix("notalland") {
return !single_media_query_applies_for_viewport(inner, viewport);
// A leading `not` negates the complete media query. This covers both
// Tailwind's `not all and (...)` breakpoints and ordinary `not print` /
// `not screen` selection without treating a word inside a feature as a
// media type.
if let Some(inner) = compact.strip_prefix("not") {
return !single_media_query_applies_for_viewport(inner, viewport, media_type);
}
if compact == "notall" {
return false;
}
if compact.contains("print") {
let compact = compact.strip_prefix("only").unwrap_or(&compact);
let medium = compact.split_once("and").map_or(compact, |(medium, _)| medium);
let medium_matches = match medium {
"all" => true,
"screen" => media_type == CssMediaType::Screen,
"print" => media_type == CssMediaType::Print,
medium if medium.starts_with('(') => true,
// Unknown named media such as `speech` do not match either visual
// rendering mode.
_ => false,
};
if !medium_matches {
return false;
}
@@ -6478,6 +6565,7 @@ fn denest(
body: &str,
rules: &mut Vec<ParsedRule>,
viewport: (f32, f32),
media_type: CssMediaType,
container_conditions: &mut Vec<ContainerConditionNode>,
container_condition_id: ContainerConditionId,
layers: &mut LayerRegistry,
@@ -6556,12 +6644,17 @@ fn denest(
if let Some(at) = pre.strip_prefix('@') {
// A nested at-rule keeps the enclosing selector for its body.
if let Some(prelude) = at_rule_prelude(at, "media") {
if media_query_applies_for_viewport(prelude, viewport) {
if media_query_applies_for_viewport_and_type(
prelude,
viewport,
media_type,
) {
denest(
sel,
&inner,
rules,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -6575,6 +6668,7 @@ fn denest(
&inner,
rules,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -6594,6 +6688,7 @@ fn denest(
&inner,
rules,
viewport,
media_type,
container_conditions,
id,
layers,
@@ -6613,6 +6708,7 @@ fn denest(
&inner,
rules,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -6627,6 +6723,7 @@ fn denest(
&inner,
rules,
viewport,
media_type,
container_conditions,
container_condition_id,
layers,
@@ -8365,6 +8462,7 @@ mod tests {
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
);
@@ -8420,6 +8518,7 @@ mod tests {
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
);
@@ -8453,6 +8552,7 @@ mod tests {
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
);
@@ -8894,6 +8994,7 @@ mod tests {
let parsed = parse_stylesheet_for_viewport_preserving_containers(
"@container (future(foo)), main (min-width:1px) {.card{display:grid}}",
(1280.0, 720.0),
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
);
@@ -9320,6 +9421,7 @@ mod tests {
let parsed = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
);
@@ -9386,6 +9488,7 @@ mod tests {
let rich = parse_stylesheet_for_viewport_preserving_containers(
css,
(1280.0, 720.0),
CssMediaType::Screen,
&mut conditions,
ContainerConditionId::NONE,
);
@@ -9518,6 +9621,37 @@ mod tests {
));
}
#[test]
fn media_type_selects_screen_print_negation_and_query_lists() {
let viewport = (800.0, 600.0);
let applies = |query, media| {
media_query_applies_for_viewport_and_type(query, viewport, media)
};
assert!(applies("screen", CssMediaType::Screen));
assert!(!applies("screen", CssMediaType::Print));
assert!(applies("print", CssMediaType::Print));
assert!(!applies("print", CssMediaType::Screen));
assert!(applies("not print", CssMediaType::Screen));
assert!(!applies("not print", CssMediaType::Print));
assert!(applies("not screen", CssMediaType::Print));
assert!(!applies("not screen", CssMediaType::Screen));
assert!(applies("speech, print", CssMediaType::Print));
assert!(!applies("speech, print", CssMediaType::Screen));
assert!(applies(
"print and (min-width: 700px)",
CssMediaType::Print
));
assert!(!applies(
"print and (min-width: 900px)",
CssMediaType::Print
));
assert!(applies(
"not all and (min-width: 900px)",
CssMediaType::Screen
));
}
#[test]
fn negated_min_width_queries_form_max_breakpoints() {
assert!(media_query_applies_for_viewport(
+42 -3
View File
@@ -3435,6 +3435,28 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_stat
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> DomLayout {
layout_dom_with_web_fonts_and_stylesheet_cache_for_media_with_animation_state(
tree,
viewport,
intrinsic,
fonts,
stylesheet_cache,
crate::CssMediaType::Screen,
animation_sample,
animation_timeline,
)
}
pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_for_media_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
stylesheet_cache: &mut crate::css::StylesheetCache,
media_type: crate::CssMediaType,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> DomLayout {
layout_dom_with_web_fonts_pass_limit_at_animation_time(
tree,
@@ -3445,6 +3467,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_stat
Some(stylesheet_cache),
None,
&[],
media_type,
animation_sample,
animation_timeline,
)
@@ -3520,6 +3543,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles_with_animation_state
Some(stylesheet_cache),
Some(retained),
mutations,
crate::CssMediaType::Screen,
animation_sample,
animation_timeline,
)
@@ -3555,6 +3579,7 @@ fn layout_dom_with_web_fonts_pass_limit(
stylesheet_cache,
retained,
mutations,
crate::CssMediaType::Screen,
crate::AnimationSample::default(),
&mut animation_timeline,
)
@@ -3569,6 +3594,7 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time(
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
retained: Option<RetainedStyleMaps>,
mutations: &[RetainedStyleMutation],
media_type: crate::CssMediaType,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> (DomLayout, ContainerLayoutTelemetry) {
@@ -3579,7 +3605,16 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time(
for nid in tree.descendants(tree.document()) {
if let Some(node) = tree.get_node(nid) {
if let Some(elem) = node.as_element() {
if elem.local.as_ref() == "style" {
if elem.local.as_ref() == "style"
&& node.get_attribute("media").is_none_or(|media| {
media.trim().is_empty()
|| crate::css::media_query_applies_for_viewport_and_type(
media,
viewport,
media_type,
)
})
{
css_sources.push(tree.text_content(nid));
}
}
@@ -3588,12 +3623,13 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time(
let t0 = std::time::Instant::now();
let (sheet, stylesheet_cache_hit) = match stylesheet_cache {
Some(cache) => cache.get_or_parse(tree, &css_sources, viewport),
Some(cache) => cache.get_or_parse(tree, &css_sources, viewport, media_type),
None => (
std::sync::Arc::new(crate::css::Stylesheet::parse_for_viewport(
std::sync::Arc::new(crate::css::Stylesheet::parse_for_viewport_and_media(
tree,
&css_sources,
viewport,
media_type,
)),
false,
),
@@ -14866,6 +14902,7 @@ mod tests {
Some(&mut cache),
None,
&[],
crate::CssMediaType::Screen,
crate::AnimationSample::document(0.0),
&mut timeline,
);
@@ -14883,6 +14920,7 @@ mod tests {
Some(&mut cache),
Some(retained),
&[RetainedStyleMutation::Animation { node: animated }],
crate::CssMediaType::Screen,
crate::AnimationSample::document(500.0),
&mut timeline,
);
@@ -14896,6 +14934,7 @@ mod tests {
Some(&mut cache),
None,
&[],
crate::CssMediaType::Screen,
crate::AnimationSample::document(500.0),
&mut full_timeline,
);
+2 -1
View File
@@ -14,7 +14,7 @@
use taffy::prelude::*;
pub mod css;
pub use css::{Stylesheet, StylesheetCache};
pub use css::{CssMediaType, Stylesheet, StylesheetCache};
pub mod style;
pub use style::compute_style;
@@ -106,6 +106,7 @@ pub use paint::{
prepare_dom_with_dynamic_fonts, prepare_dom_with_dynamic_fonts_at_animation_time,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_for_media_with_animation_state,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state,
screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time,
screenshot_png_scrolled_at_animation_time_with_surface_color,
+33 -3
View File
@@ -24,7 +24,8 @@ static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-san
use crate::dom::{
layout_dom_with_web_fonts_and_retained_styles_with_animation_state,
layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state, RetainedStyleMaps,
layout_dom_with_web_fonts_and_stylesheet_cache_for_media_with_animation_state,
RetainedStyleMaps,
};
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
@@ -2006,6 +2007,30 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_for_media_with_animation_state(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
crate::CssMediaType::Screen,
animation_sample,
animation_timeline,
)
}
pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_for_media_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
media_type: crate::CssMediaType,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
tree,
@@ -2015,6 +2040,7 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
dynamic_fonts,
stylesheet_cache,
None,
media_type,
animation_sample,
animation_timeline,
)
@@ -2166,6 +2192,7 @@ pub fn prepare_dom_with_retained_styles_with_animation_state(
dynamic_fonts,
stylesheet_cache,
Some((retained, mutations)),
crate::CssMediaType::Screen,
animation_sample,
animation_timeline,
)
@@ -2199,6 +2226,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
retained: Option<(RetainedStyleMaps, &[crate::dom::RetainedStyleMutation])>,
media_type: crate::CssMediaType,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
@@ -2253,12 +2281,13 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
animation_sample,
animation_timeline,
),
None => layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
None => layout_dom_with_web_fonts_and_stylesheet_cache_for_media_with_animation_state(
tree,
viewport,
&intrinsic,
&fonts,
stylesheet_cache,
media_type,
animation_sample,
animation_timeline,
),
@@ -2282,12 +2311,13 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
{
resources.content_image_layout_retries += 1;
}
laid = layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
laid = layout_dom_with_web_fonts_and_stylesheet_cache_for_media_with_animation_state(
tree,
viewport,
&intrinsic,
&fonts,
stylesheet_cache,
media_type,
animation_sample,
animation_timeline,
);
+9
View File
@@ -0,0 +1,9 @@
.print-import-marker {
display: block !important;
width: 320px;
height: 120px;
margin-top: 40px;
padding: 32px;
background: #9020d0;
color: white;
}
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<meta charset="utf-8">
<title>PDF print media oracle</title>
<style>
@import url("./pdf-print-import.css") print;
* { box-sizing: border-box }
html, body { margin: 0; width: 800px; min-height: 1000px }
body { background: #e02020; color: white; font: 32px/1.2 sans-serif }
.page { width: 800px; min-height: 1000px; padding: 80px }
.screen-only { display: block }
.print-only { display: none }
.screen-marker { display: none }
.print-marker { display: none }
.print-import-marker { display: none }
@media screen {
.screen-marker { display: block }
}
@media print {
body { background: #2050e0 }
.screen-only { display: none }
.print-only { display: block }
}
</style>
<style media="print">
.print-marker {
display: block;
width: 320px;
height: 180px;
margin-top: 80px;
padding: 40px;
background: #f0d020;
color: #101010;
}
</style>
<style media="screen">
.screen-marker {
width: 320px;
height: 180px;
margin-top: 80px;
padding: 40px;
background: #20c040;
}
</style>
<main class="page">
<div class="screen-only">SCREEN ONLY</div>
<div class="print-only">PRINT ONLY</div>
<div class="screen-marker">SCREEN STYLESHEET</div>
<div class="print-marker">PRINT STYLESHEET</div>
<div class="print-import-marker">PRINT IMPORT</div>
</main>