mirror of
https://github.com/aphrody-labs/obscura.git
synced 2026-10-14 05:09:16 +00:00
Honor print media in PDF export
This commit is contained in:
1 parent
c1dbec078b
commit
703d334df7
12 files changed
+856
-181
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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, |_| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
.print-import-marker {
|
||||
display: block !important;
|
||||
width: 320px;
|
||||
height: 120px;
|
||||
margin-top: 40px;
|
||||
padding: 32px;
|
||||
background: #9020d0;
|
||||
color: white;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user