mirror of
https://github.com/lexmount/moli.git
synced 2026-10-30 08:07:38 +00:00
fix(cli): retain CSS backgrounds in PDF exports
This commit is contained in:
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user