diff --git a/moli-paint/README.md b/moli-paint/README.md index b559cc4b57..36db6b79c1 100644 --- a/moli-paint/README.md +++ b/moli-paint/README.md @@ -4,6 +4,10 @@ `pdf::pdf_snapshot` consumes the same owned snapshot and writes PDF through upstream [Krilla at `5eb4e1f`](https://github.com/LaurenzV/krilla/commit/5eb4e1fb24a24ce78b533c23b080969917530c32). CDP `Page.printToPDF` and CLI `--dump pdf` use the PDF backend. +CLI PDF exports include CSS backgrounds, preserving colored controls and white +text on colored backgrounds. CDP continues to use its `printBackground` option, +which defaults to false. Both paths apply print media styles unless explicitly +overridden. The renderer owns the print request, restores media after snapshot capture, and returns PDF bytes to both clients without publishing interactive geometry. Vector print capture is independent of the full-document bitmap dimension limit. @@ -103,10 +107,13 @@ clusters, repeated base letters, color glyphs, opacity, shadows, filters and a per-process timings/sampled peak RSS in `target/native-pdf-check/`. The Rust tests use lopdf for PDF objects and ordinary text; its extractor does not interpret ActualText and Type3 text correctly, so it is not the complex-text oracle. +The reader script also compares rendered solid and gradient button backgrounds +against CLI screenshots; extracting a white label alone does not establish that +the label is visible in the PDF. Validation on 2026-10-12 (Linux x86_64, Rust 1.96.1): `cargo fmt --all`, workspace Clippy with all targets/features and warnings denied, and the full -nextest run passed (20,011 tests; 17 skipped). The Python smoke unit suite passed +nextest run passed (20,012 tests; 17 skipped). The Python smoke unit suite passed 43 tests. The real PDF smoke group passed with both debug and release binaries. The visual comparison covered borders, rounded clips, an sRGB gradient, alpha image, SVG paths, transformed text, opacity, shadow and blur. Interior gradient diff --git a/moli/src/cli.rs b/moli/src/cli.rs index 4dc5b4e5ef..2591f60820 100644 --- a/moli/src/cli.rs +++ b/moli/src/cli.rs @@ -111,8 +111,8 @@ pub struct FetchArgs { /// Select the fetch output format. When omitted, rendered HTML documents /// are serialized and raw responses such as PDFs or videos are written /// verbatim. `screenshot` writes a viewport PNG, `screenshot_full` writes a - /// full-document PNG, and `pdf` writes a paginated PDF directly to stdout; - /// all three require layout. Dump output is quiet by default; pass + /// full-document PNG, and `pdf` writes a paginated PDF with CSS backgrounds + /// directly to stdout; all three require layout. Dump output is quiet by default; pass /// `--log-level` to opt into diagnostic logs. #[arg(short, long, value_enum)] pub dump: Option, diff --git a/moli/src/fetch_dump/raster.rs b/moli/src/fetch_dump/raster.rs index dcee7558d3..8f0122c2e9 100644 --- a/moli/src/fetch_dump/raster.rs +++ b/moli/src/fetch_dump/raster.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result, bail}; use moli_core::page::{ Page, RendererCaptureScreenshotReply, RendererCaptureScreenshotRequest, - RendererPrintToPdfReply, RendererScreenshotRegion, + RendererPrintToPdfReply, RendererPrintToPdfRequest, RendererScreenshotRegion, }; struct CapturedRaster { @@ -28,7 +28,11 @@ pub(super) async fn render_screenshot( pub(super) async fn render_pdf(page: &mut Page) -> Result> { let pending = page - .start_print_to_pdf(Default::default()) + .start_print_to_pdf(RendererPrintToPdfRequest { + // CLI exports preserve page artwork, including white-on-color controls. + print_background: true, + ..Default::default() + }) .context("failed to start --dump pdf capture")?; let completion = pending .wait() diff --git a/moli/tests/fetch_cli.rs b/moli/tests/fetch_cli.rs index 4b561708e9..46689d074f 100644 --- a/moli/tests/fetch_cli.rs +++ b/moli/tests/fetch_cli.rs @@ -1170,6 +1170,49 @@ fn cli_dump_pdf_writes_pdf_bytes() -> Result<()> { Ok(()) } +#[test] +fn cli_dump_pdf_preserves_button_backgrounds() -> Result<()> { + let html = concat!( + "", + ); + let url = format!("data:text/html;base64,{}", BASE64_STANDARD.encode(html)); + let output = run_fetch_cli_with_dump_and_args(&url, "pdf", &["--layout"])?; + assert!( + output.status.success(), + "moli fetch failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let document = lopdf::Document::load_mem(&output.stdout)?; + assert_eq!(document.extract_text(&[1])?.trim(), "Search"); + let page_id = document.get_pages()[&1]; + let content = lopdf::content::Content::decode(&document.get_page_content(page_id))?; + let background = content.operations.iter().position(|operation| { + operation.operator == "rg" + && operation.operands.len() == 3 + && operation + .operands + .iter() + .zip([50.0 / 255.0, 93.0 / 255.0, 1.0]) + .all(|(operand, expected)| { + operand + .as_float() + .is_ok_and(|value| (value - expected).abs() < 0.0001) + }) + }); + let background = background.expect("white button text needs its blue CSS background"); + assert!( + content.operations[background + 1..] + .iter() + .take_while(|operation| operation.operator != "rg") + .any(|operation| matches!(operation.operator.as_str(), "f" | "f*")), + "the button background must paint a filled path" + ); + Ok(()) +} + #[test] fn binary_dump_modes_require_layout() -> Result<()> { for dump in ["screenshot", "screenshot_full", "pdf"] { diff --git a/scripts/check-native-pdf.py b/scripts/check-native-pdf.py index 28de854b9c..e8f02eabde 100644 --- a/scripts/check-native-pdf.py +++ b/scripts/check-native-pdf.py @@ -65,6 +65,35 @@ def capture(binary, output, name, html): return text, info +def check_button_backgrounds(binary, output, html): + screenshot = output / "backgrounds-screenshot.png" + result = subprocess.run( + [str(binary), "fetch", "--font", "--layout", "--wait-until", "load", + "--log-level", "error", "--http-no-proxy", "*", "--dump", "screenshot", + data_url(html.encode(), "text/html")], + capture_output=True, check=True, timeout=120, + ) + screenshot.write_bytes(result.stdout) + reference = pymupdf.Pixmap(screenshot) + with pymupdf.open(output / "backgrounds.pdf") as document: + page = document[0] + # CLI PDF fits the viewport width inside the default 1 cm margins. + margin = 72 / 2.54 + scale = (page.rect.width - 2 * margin) / reference.width + clip = pymupdf.Rect(margin, margin, page.rect.width - margin, + margin + reference.height * scale) + rendered = page.get_pixmap(matrix=pymupdf.Matrix(1 / scale, 1 / scale), clip=clip) + rendered.save(output / "backgrounds-rendered.png") + for x, y in [(110, 112), (320, 112), (460, 112)]: + expected = reference.pixel(x, y)[:3] + actual = rendered.pixel(round(margin / scale + x - rendered.x), + round(margin / scale + y - rendered.y))[:3] + assert min(expected) < 180, ("missing screenshot background", x, y, expected) + assert max(abs(a - b) for a, b in zip(actual, expected)) <= 3, ( + "PDF button background differs from screenshot", x, y, actual, expected + ) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--moli", type=Path, default=ROOT / "target/debug/moli") @@ -85,6 +114,15 @@ body{{font:20px PDFTest,CJK;margin:24px}} p{{margin:16px 0}} assert results["minimal"]["image_count"] == 0 assert any("+MoliPDFTest" in font for font in results["minimal"]["fonts"]) + html = style + """""" + text, results["backgrounds"] = capture(args.moli.resolve(), args.output_dir, "backgrounds", html) + assert text == "Search Gradient", text + check_button_backgrounds(args.moli.resolve(), args.output_dir, html) + expected = ["Font Text 123 中文", "office fi ffi á", "Second page a", "abc אבג xyz", "Color ★", "Weight 650", "Italic"] html = style + """

Font Text 123 中文

office fi ffi á