fix(cli): retain CSS backgrounds in PDF exports

This commit is contained in:
ldm0
2026-10-13 00:34:09 +08:00
committed by Donough Liu
parent 778a35bd07
commit fbbafa49f7
5 changed files with 97 additions and 5 deletions
+8 -1
View File
@@ -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
+2 -2
View File
@@ -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<DumpFormat>,
+6 -2
View File
@@ -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<Vec<u8>> {
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()
+43
View File
@@ -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!(
"<!doctype html><style>html,body{margin:0}",
"button{position:absolute;left:100px;top:100px;width:180px;height:60px;",
"color:white;background:#325dff;border:0;border-radius:12px}",
"</style><button>Search</button>",
);
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"] {
+38
View File
@@ -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 + """<style>
button{position:absolute;left:100px;top:100px;width:180px;height:60px;
color:white;background:#325dff;border:0;border-radius:12px;font:20px PDFTest}
#gradient{left:300px;background:linear-gradient(90deg in srgb,#325dff,#9864ff)}
</style><button>Search</button><button id=gradient>Gradient</button>"""
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 + """<p>Font Text 123 中文</p><p>office fi ffi á</p>