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:[email protected]/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, |_| {
|
||||
|
||||
Reference in new issue
Block a user