From 703d334df780ce40af0bf07354fa2a84fed94de0 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:34:28 +0000 Subject: [PATCH] Honor print media in PDF export --- crates/obscura-browser/src/page.rs | 443 +++++++++++++++++++++----- crates/obscura-browser/src/pdf.rs | 208 ++++++++---- crates/obscura-cdp/src/domains/pdf.rs | 6 +- crates/obscura-js/src/lib.rs | 2 +- crates/obscura-js/src/ops.rs | 43 ++- crates/obscura-js/src/runtime.rs | 18 ++ crates/obscura-render/src/css.rs | 174 ++++++++-- crates/obscura-render/src/dom.rs | 45 ++- crates/obscura-render/src/lib.rs | 3 +- crates/obscura-render/src/paint.rs | 36 ++- render-repros/pdf-print-import.css | 9 + render-repros/pdf-print-media.html | 50 +++ 12 files changed, 856 insertions(+), 181 deletions(-) create mode 100644 render-repros/pdf-print-import.css create mode 100644 render-repros/pdf-print-media.html diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 3a7ad22..8fc8578 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -276,10 +276,22 @@ const MAX_STYLESHEET_RESOURCES: usize = 128; #[derive(Clone)] struct LoadedStylesheet { response_url: Url, - imports: Vec, + imports: Vec, rules: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct StylesheetImport { + url: String, + media: Option, +} + +#[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) { +fn split_css_imports(css: &str) -> (Vec, 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) { (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 { +/// 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 { 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 { 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 ``. @@ -615,6 +626,8 @@ fn parse_import_url(stmt: &str) -> Option { /// author sheets are interleaved. Appending one aggregate `
marker
"#, + ), + "/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::>(); + 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::>() + }) + .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#" Result, 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#" + + + +
"#, + ); + 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, |_| { diff --git a/crates/obscura-cdp/src/domains/pdf.rs b/crates/obscura-cdp/src/domains/pdf.rs index 121d273..4193319 100644 --- a/crates/obscura-cdp/src/domains/pdf.rs +++ b/crates/obscura-cdp/src/domains/pdf.rs @@ -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); diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index 25dd435..8194492 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -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, }; diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index c941108..ed8ffd5 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -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, + /// 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 { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 81fcdf0..ee7ffc0 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -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 diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index a15dd84..f1c4569 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -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, viewport_bits: (u32, u32), + media_type: CssMediaType, source_bytes: usize, sheet: Arc, } @@ -2344,17 +2358,26 @@ impl StylesheetCache { tree: &DomTree, sources: &[String], viewport: (f32, f32), + media_type: CssMediaType, ) -> (Arc, 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, container_condition_id: ContainerConditionId, ) -> Vec { @@ -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, 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, viewport: (f32, f32), + media_type: CssMediaType, container_conditions: &mut Vec, 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 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, viewport: (f32, f32), + media_type: CssMediaType, container_conditions: &mut Vec, 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( diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index e96fa02..55d45b4 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -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, 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, ); diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 1134d85..8b39445 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -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, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 3226444..8bd6b41 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -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 { + 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 { 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 { @@ -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, ); diff --git a/render-repros/pdf-print-import.css b/render-repros/pdf-print-import.css new file mode 100644 index 0000000..6a7f664 --- /dev/null +++ b/render-repros/pdf-print-import.css @@ -0,0 +1,9 @@ +.print-import-marker { + display: block !important; + width: 320px; + height: 120px; + margin-top: 40px; + padding: 32px; + background: #9020d0; + color: white; +} diff --git a/render-repros/pdf-print-media.html b/render-repros/pdf-print-media.html new file mode 100644 index 0000000..a29329b --- /dev/null +++ b/render-repros/pdf-print-media.html @@ -0,0 +1,50 @@ + + +PDF print media oracle + + + +
+
SCREEN ONLY
+ +
SCREEN STYLESHEET
+ + +