From a9b0db12c62f81beb8d2b57851d650d10bdb37e8 Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 16 Oct 2026 10:46:02 +0200 Subject: [PATCH] fix(edit): show backgrounds on pages that paint their own white backdrop Add Background draws behind the page content, so on a page that first fills itself with opaque white (browsers' Save as PDF, print drivers) the background was hidden completely and the tool looked broken. - pdfcraft-edit: new `backdrop` module. A strictly detected backdrop (one axis-aligned `re`, opaque white in a device colour space, painted before anything else and covering the crop box) is replaced by turning that one fill into `n` in a per-page copy of its stream; the original stream is left untouched for other pages. Remove background restores it, unless the copy was edited since. Page-sized pictures (scans) are reported only. - Background gains `replace_backdrop` (default on); the default colour is a visible light yellow instead of near-white. - Dialog: offers to replace the backdrop, warns about scanned pages, checks pages a few per frame, preview draws text over the colour. - Automation: `doc_background` takes `replace_backdrop`; new read-only `doc_backdrops` lists pages with a backdrop. Acrobat's behaviour on such pages is unobserved; recorded as our choice in parity/. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/automation/src/lib.rs | 23 ++ crates/automation/src/tools.rs | 8 +- crates/automation/tests/backdrop.rs | 109 +++++++++ crates/edit/src/backdrop.rs | 323 +++++++++++++++++++++++++ crates/edit/src/backdrop_tests.rs | 233 ++++++++++++++++++ crates/edit/src/lib.rs | 22 +- crates/engine/src/lib.rs | 7 +- crates/ui-egui/src/marks_ui.rs | 101 +++++++- crates/ui-egui/tests/ui_group/marks.rs | 67 +++++ parity/acrobat-features.toml | 10 +- 11 files changed, 892 insertions(+), 12 deletions(-) create mode 100644 crates/automation/tests/backdrop.rs create mode 100644 crates/edit/src/backdrop.rs create mode 100644 crates/edit/src/backdrop_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..b90b9f5679 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background puts the original back unless the copy was edited since. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b2..564dd8e882 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -704,6 +704,7 @@ impl Automation { } "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_backdrops" => self.backdrops(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; out["security"] = security(self.doc(&a)?); @@ -1172,6 +1173,27 @@ impl Automation { Ok(out) } + /// Pages whose own backdrop hides a background (see `pdfcraft_edit::backdrop`). + fn backdrops(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let doc = self.doc(a)?; + let found: Vec = pages + .into_iter() + .filter_map(|p| { + let kind = match doc.page_backdrop(p)? { + pdfcraft_engine::Backdrop::Fill => "fill", + pdfcraft_engine::Backdrop::Image => "image", + }; + Some(json!({ "page": p + 1, "backdrop": kind })) + }) + .collect(); + Ok(json!({ "pages": found })) + } + fn marks(&mut self, tool: &str, a: &Args) -> Result { use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; let n = self.doc(a)?.info.pages.len(); @@ -1230,6 +1252,7 @@ impl Automation { color: color("color", [1.0; 3])?, opacity: a.opt_num("opacity")?.unwrap_or(1.0), scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + replace_backdrop: a.opt_bool("replace_backdrop")?.unwrap_or(true), ..Background::default() }, replace, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..e520b7bb63 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -902,12 +902,16 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. Undoable.") + t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. A page that paints its own opaque white backdrop would hide it, so that backdrop is replaced unless replace_backdrop is false (removing the background puts it back); a scanned page's picture still hides it (doc_backdrops lists both). Undoable.") .cmd("edit.background") .with(schema( - json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }), + json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") + .ro() + .cmd("edit.background") + .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), diff --git a/crates/automation/tests/backdrop.rs b/crates/automation/tests/backdrop.rs new file mode 100644 index 0000000000..1a8a71eb8e --- /dev/null +++ b/crates/automation/tests/backdrop.rs @@ -0,0 +1,109 @@ +//! Add Background on pages that paint their own backdrop, end to end through the tools: what +//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write), +/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture). +fn fixture() -> Vec { + let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET"; + let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")]; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream" + .into(), + ]; + for (i, c) in contents.iter().enumerate() { + objs.push(format!( + "<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>", + 5 + 2 * i + )); + objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn workdir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.pdf"), fixture()).unwrap(); + dir +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi. +fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] { + let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap(); + let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.color_type.samples(); + let at = (10 * *width as usize + 180) * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +const RED: [u8; 3] = [255, 0, 0]; +const WHITE: [u8; 3] = [255, 255, 255]; + +#[test] +fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() { + let dir = workdir(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }]) + ); + + // Not replacing: the page's own white hides the background, as it always did. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false })); + assert_eq!(blank_spot(&mut a, doc, 1), WHITE); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + + // The default replaces it: red on both pages; the scan still hides it. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + assert_eq!(blank_spot(&mut a, doc, 1), RED); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + assert_ne!(blank_spot(&mut a, doc, 3), RED); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }])); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen"); + ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" })); + assert_eq!(blank_spot(&mut a, saved, 1), WHITE); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }]), + "the backdrop is back" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs new file mode 100644 index 0000000000..d15652f82b --- /dev/null +++ b/crates/edit/src/backdrop.rs @@ -0,0 +1,323 @@ +//! A page's own backdrop: an opaque white fill (or a picture) covering the whole visible page +//! before anything else is drawn. Many writers paint one (browsers' Save as PDF, print drivers; +//! scans are a page-sized picture), and it hides anything placed behind the content, so a +//! background added behind such a page never shows. +//! +//! A white fill can be replaced: the one fill operator becomes `n` (end the path without +//! painting), which looks the same on white paper. The page then gets a copy of the content +//! stream that held it, tagged `/PCBackdrop << /Original ref /At n /Len n >>`, and the stream +//! itself is left alone (other pages may share it). [`restore`] puts the original back when the +//! background is removed, unless the copy has been edited since. A picture can't be replaced +//! without losing it, so it is only reported. +//! +//! Detection is deliberately strict: only one rectangle (`re`), in DeviceGray, DeviceRGB or +//! DeviceCMYK, opaque in the graphics state, with an axis-aligned transform, filled before any +//! other painting, and covering the crop box. Anything else is real content and stays. + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::EditError; + +/// How much of a page's content is read looking for a backdrop: it has to come first. +const SCAN_BYTES: usize = 64 * 1024; +/// How many operators are walked before giving up. +const SCAN_OPS: usize = 4096; +/// How far (in points) a backdrop may fall short of the crop box and still count as covering it. +const COVER_EPS: f64 = 1.0; +/// Components at least this close to white (per channel) count as white. +const WHITE: f64 = 0.98; + +/// What paints a page's backdrop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backdrop { + /// An opaque white fill: replaceable. + Fill, + /// A picture covering the page (a scan): a background behind it stays hidden. + Image, +} + +/// Where a replaceable fill is: the content streams (references in `/Contents` order, untagged +/// ones only), their joined bytes, and the fill operator's joined byte range. +struct Found { + refs: Vec<(usize, ObjRef)>, + pieces: Pieces, + op: std::ops::Range, +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + /// Clip in default user space, when it is a known rectangle; `None` = unknown shape. + clip: Option<[f64; 4]>, + /// The fill colour is white. + white: bool, + /// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read. + space: usize, + opaque: bool, +} + +/// The backdrop `page` (0-based) paints, if any. +pub fn page_backdrop(doc: &Document, page: usize) -> Result, EditError> { + Ok(scan(doc, page)?.map(|(b, _)| b)) +} + +/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it +/// did. A picture backdrop is left alone. +pub(crate) fn replace(doc: &mut Document, page: usize) -> Result { + let Some((Backdrop::Fill, Some(found))) = scan(doc, page)? else { return Ok(false) }; + let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]); + let piece = found.pieces.piece_of(found.op.start); + let (Some(&(slot, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) }; + // Where the operator sits in that piece: the joined offset less the pieces before it. + let before: usize = (0..piece).filter_map(|i| edited.get(i)).map(|p| p.len() + 1).sum(); + let at = found.op.start.saturating_sub(before); + let len = found.op.end.saturating_sub(found.op.start); + let mut note = Dict::new(); + note.set(b"Original".to_vec(), Object::Ref(original)); + note.set(b"At".to_vec(), Object::Int(i64::try_from(at).unwrap_or(i64::MAX))); + note.set(b"Len".to_vec(), Object::Int(i64::try_from(len).unwrap_or(i64::MAX))); + let mut dict = Dict::new(); + dict.set(b"PCBackdrop".to_vec(), Object::Dict(note)); + let copy = doc.add(Object::Stream(Stream::flate(dict, bytes))); + let p = page_of(doc, page)?; + let mut list = crate::contents(doc, &p)?; + let Some(entry) = list.get_mut(slot) else { return Ok(false) }; + *entry = Object::Ref(copy); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + Ok(true) +} + +/// Put back the original content streams [`replace`] copied on `page`, where the copy is still +/// exactly what it wrote. Returns how many it restored. +pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result { + let mut list = crate::contents(doc, page)?; + let mut restored = 0; + for entry in list.iter_mut() { + if let Some(original) = unchanged_copy_of(doc, entry) { + *entry = Object::Ref(original); + restored += 1; + } + } + if restored > 0 { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + } + Ok(restored) +} + +/// The original of a backdrop copy whose bytes are still the original's with the fill turned to +/// `n`; `None` for anything else (including a copy edited since, which is kept). +fn unchanged_copy_of(doc: &Document, entry: &Object) -> Option { + let copy = doc.resolve(entry); + let Object::Stream(copy) = &*copy else { return None }; + let note = copy.dict.get(b"PCBackdrop").map(|n| doc.resolve(n))?; + let note = note.as_dict()?; + let Some(Object::Ref(original)) = note.get(b"Original") else { return None }; + let at = usize::try_from(note.get(b"At")?.as_int()?).ok()?; + let len = usize::try_from(note.get(b"Len")?.as_int()?).ok()?; + let orig = doc.get(*original); + let Object::Stream(orig) = &*orig else { return None }; + let source = orig.decoded().ok()?; + let mut expect = source.get(..at)?.to_vec(); + expect.push(b'n'); + expect.extend_from_slice(source.get(at.checked_add(len)?..)?); + (copy.decoded().ok()? == expect).then_some(*original) +} + +fn page_of(doc: &Document, page: usize) -> Result { + pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page)) +} + +/// Find the backdrop, and for a fill, where it is. +fn scan(doc: &Document, page: usize) -> Result)>, EditError> { + let p = page_of(doc, page)?; + let crop = p.crop(doc); + let list: Vec = match p.dict.get(b"Contents") { + None => return Ok(None), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + // The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read + // only as far as a backdrop could be. + let mut refs = Vec::new(); + let mut data = Vec::new(); + let mut total = 0usize; + for (slot, o) in list.iter().enumerate() { + if total >= SCAN_BYTES { + break; + } + let Object::Ref(r) = o else { return Ok(None) }; + let obj = doc.get(*r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.get(b"PCMark").is_some() { + continue; + } + let bytes = s.decoded().unwrap_or_default(); + total = total.saturating_add(bytes.len()).saturating_add(1); + refs.push((slot, *r)); + data.push(bytes); + } + let pieces = Pieces::join(&data); + let head = pieces.data().get(..pieces.data().len().min(SCAN_BYTES)).unwrap_or_default(); + let parsed = pdfcraft_content::parse(head); + let res = resources(doc, &p.dict); + let found = walk(doc, &res, crop, &parsed.ops); + Ok(found.map(|(b, op)| match (b, op) { + (Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, op })), + _ => (b, None), + })) +} + +fn resources(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default() +} + +fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option> { + let d = res.get(kind).map(|d| doc.resolve(d))?; + let o = d.as_dict()?.get(name)?.clone(); + Some(doc.resolve(&o)) +} + +/// Walk the operators until the first one that paints; a backdrop is that one, or nothing. +fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backdrop, Option>)> { + let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, opaque: true }; + let mut saved: Vec = Vec::new(); + // Current path: its rectangles in user space, or `None` once it is anything else. + let mut path: Option> = Some(Vec::new()); + let mut clip_next = false; + for op in ops.iter().take(SCAN_OPS) { + match op.op.as_slice() { + b"q" => { + if saved.len() >= 64 { + return None; + } + saved.push(st.clone()); + } + b"Q" => st = saved.pop()?, + b"cm" => { + let m = Matrix::from_operands(op.operands.get(op.operands.len().checked_sub(6)?..)?)?; + if !m.0.iter().all(|v| v.is_finite()) { + return None; + } + st.ctm = m.then(&st.ctm); + } + b"gs" => st.opaque = opaque_state(doc, res, op.name(0)?)?, + b"g" => set_colour(&mut st, 1, op), + b"rg" => set_colour(&mut st, 3, op), + b"k" => set_colour(&mut st, 4, op), + b"cs" => { + st.space = match op.name(0)? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + b"DeviceCMYK" => 4, + _ => 0, + }; + // The initial colour of these spaces is black. + st.white = false; + } + b"sc" | b"scn" => { + let n = st.space; + set_colour(&mut st, n, op) + } + b"re" => { + let [x, y, w, h] = op.nums::<4>()?; + let finite = [x, y, w, h].iter().all(|v| v.is_finite()); + match (&mut path, finite && axis_aligned(&st.ctm)) { + (Some(rects), true) => rects.push(st.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), + _ => path = None, + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => path = None, + b"W" | b"W*" => clip_next = true, + b"n" => { + if clip_next { + st.clip = clipped(st.clip, path.as_deref()); + } + path = Some(Vec::new()); + clip_next = false; + } + b"f" | b"F" | b"f*" => { + // One rectangle: with two, a fill rule can cut a hole where content shows. + let covers = match path.as_deref() { + Some([r]) => st.clip.and_then(|c| intersect(*r, c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)), + _ => false, + }; + return (covers && st.white && st.opaque && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone()))); + } + b"Do" => { + let xo = sub(doc, res, b"XObject", op.name(0)?)?; + let Object::Stream(s) = &*xo else { return None }; + // A stencil or masked picture lets what is behind it show. + let masked = [&b"SMask"[..], b"Mask"].iter().any(|k| s.dict.get(k).is_some()) + || s.dict.get(b"ImageMask").is_some_and(|m| matches!(*doc.resolve(m), Object::Bool(true))); + let image = s.dict.name(b"Subtype") == Some(b"Image") && !masked; + return covers_unit_square(&st, crop, image).then_some((Backdrop::Image, None)); + } + b"BI" => { + let masked = op + .inline + .as_ref() + .is_some_and(|(d, _)| [&b"IM"[..], b"ImageMask"].iter().any(|k| d.get(k).is_some_and(|m| matches!(m, Object::Bool(true))))); + return covers_unit_square(&st, crop, !masked).then_some((Backdrop::Image, None)); + } + // Anything else that paints comes first: no backdrop. + b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" | b"sh" | b"Tj" | b"TJ" | b"'" | b"\"" | b"EI" | b"d0" | b"d1" => return None, + _ => {} + } + } + None +} + +/// A picture placed by the CTM (onto the unit square) covering the crop box, opaque, unclipped. +fn covers_unit_square(st: &State, crop: [f64; 4], image: bool) -> bool { + image + && st.opaque + && axis_aligned(&st.ctm) + && st.clip.and_then(|c| intersect(st.ctm.bbox([0.0, 0.0, 1.0, 1.0]), c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)) +} + +fn set_colour(st: &mut State, components: usize, op: &Op) { + st.space = components; + let vals: Vec = op.operands.iter().filter_map(Object::as_f64).collect(); + st.white = vals.len() == components + && match components { + 1 | 3 => vals.iter().all(|v| *v >= WHITE), + 4 => vals.iter().all(|v| *v <= 1.0 - WHITE), + _ => false, + }; +} + +/// Does the named ExtGState keep painting opaque and normal? `None` when it can't be read. +fn opaque_state(doc: &Document, res: &Dict, name: &[u8]) -> Option { + let gs = sub(doc, res, b"ExtGState", name)?; + let gs = gs.as_dict()?; + let alpha = gs.get(b"ca").map(|a| doc.resolve(a).as_f64()).unwrap_or(Some(1.0))?; + let soft = gs.get(b"SMask").is_some_and(|m| doc.resolve(m).as_name() != Some(b"None")); + let blend = gs.get(b"BM").is_some_and(|b| !matches!(doc.resolve(b).as_name(), Some(b"Normal" | b"Compatible"))); + Some(alpha >= 0.999 && !soft && !blend) +} + +/// Rotations by multiples of 90° and scales only: rectangles stay rectangles. +fn axis_aligned(m: &Matrix) -> bool { + let [a, b, c, d, ..] = m.0; + (b.abs() < 1e-9 && c.abs() < 1e-9) || (a.abs() < 1e-9 && d.abs() < 1e-9) +} + +fn intersect(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + (r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r) +} + +/// The clip after intersecting with a path: known only when the path is one rectangle. +fn clipped(clip: Option<[f64; 4]>, path: Option<&[[f64; 4]]>) -> Option<[f64; 4]> { + match path { + Some([r]) => intersect(clip?, *r).or(Some([0.0, 0.0, 0.0, 0.0])), + _ => None, + } +} + +#[cfg(test)] +#[path = "backdrop_tests.rs"] +mod tests; diff --git a/crates/edit/src/backdrop_tests.rs b/crates/edit/src/backdrop_tests.rs new file mode 100644 index 0000000000..88f058b4f5 --- /dev/null +++ b/crates/edit/src/backdrop_tests.rs @@ -0,0 +1,233 @@ +use std::sync::Arc; + +use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, Stream, write_incremental}; + +use super::*; +use crate::{Background, MarkKind, add_background, remove_marks}; + +const TEXT: &str = "BT /F1 24 Tf 40 90 Td (Hello) Tj ET"; + +/// A document from its objects (object 1 is the catalog, 2 the page tree). +fn build(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// One 300 × 200 page drawing `pieces` (objects 5…), with a font, a page-sized image `/Im0` +/// (object 4) and two graphics states, `/Half` (ca 0.5) and `/Full` (ca 1). +fn page(pieces: &[&str]) -> Document { + let refs: Vec = (0..pieces.len()).map(|i| format!("{} 0 R", i + 5)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << /Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> >> >> >>", + refs.join(" ") + ), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{80}"), + ]; + objs.extend(pieces.iter().map(|p| stream("", p))); + build(&objs) +} + +/// The decoded content of a page, stream by stream. +fn streams(doc: &Document) -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let list = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() { + Some(Object::Array(a)) => a.clone(), + _ => vec![], + }; + list.iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + _ => String::new(), + }) + .collect() +} + +fn reopen(doc: &Document) -> Document { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + Document::open(Arc::new(bytes)).unwrap() +} + +fn red() -> Background { + Background { color: [1.0, 0.0, 0.0], ..Background::default() } +} + +#[test] +fn a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes() { + let content = format!("q 1 1 1 rg 0 0 300 200 re f Q {TEXT}"); + let mut doc = page(&[&content]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&reopen(&doc)); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[0].contains("/PCMark /Background")); + assert_eq!(s[1], format!("q 1 1 1 rg 0 0 300 200 re n Q {TEXT}"), "only the fill operator changed"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "nothing hides the background now"); + // The original stream object is untouched; the page points at a copy. + let Object::Stream(orig) = &*doc.get(ObjRef { num: 5, generation: 0 }) else { panic!("object 5 is the content") }; + assert_eq!(String::from_utf8_lossy(&orig.decoded().unwrap()), content); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1); + assert_eq!(streams(&reopen(&doc)), original, "exactly the original content"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); +} + +#[test] +fn updating_the_background_replaces_the_backdrop_once_and_removing_still_restores() { + let mut doc = page(&[&format!("1 1 1 rg 0 0 300 200 re f 0 g {TEXT}")]); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], ..Background::default() }, true).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 2, "one background, one copy: {s:?}"); + assert!(s[1].contains("re n")); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn a_backdrop_split_across_content_streams_is_replaced_in_the_piece_holding_the_operator() { + let mut doc = page(&["1 1 1 rg 0 0 300 200 re", &format!("f* {TEXT}")]); + let original = streams(&doc); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&doc); + assert_eq!(s[1..], ["1 1 1 rg 0 0 300 200 re".to_string(), format!("n {TEXT}")], "{s:?}"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn backdrops_writers_paint_are_recognised() { + for content in [ + // Browsers: a flipped, scaled coordinate system and a page clip first. + "q 0.5 0 0 -0.5 0 200 cm 0 0 600 400 re W n 1 1 1 rg 0 0 600 400 re f Q", + // Larger than the page, in CMYK, through an opaque graphics state. + "/Full gs 0 0 0 0 k -10 -10 320 220 re f", + "/DeviceRGB cs 1 1 1 sc 0 0 300 200 re F", + "0.99 g 0 0 300 200 re f", + // Turned a quarter: still a rectangle. + "0 1 -1 0 300 0 cm 1 g 0 0 200 300 re f", + ] { + let doc = page(&[&format!("{content} 0 g {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{content}"); + } +} + +#[test] +fn content_that_is_not_a_page_backdrop_is_left_alone() { + for content in [ + // Not the whole page. + "1 g 0 0 300 150 re f", + "q 0 0 100 100 re W n 1 g 0 0 300 200 re f Q", + // Not white, or not opaque. + "0.5 g 0 0 300 200 re f", + "1 0 0 rg 0 0 300 200 re f", + "0 0 0 0.5 k 0 0 300 200 re f", + "/Half gs 1 g 0 0 300 200 re f", + "/Missing gs 1 g 0 0 300 200 re f", + "/Pattern cs /P0 scn 0 0 300 200 re f", + // The initial colour is black. + "0 0 300 200 re f", + // Something else is painted first. + "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re f", + "0 g 0 0 1 1 re f 1 g 0 0 300 200 re f", + "1 g 0 0 300 200 re S 1 g 0 0 300 200 re f", + // Not a single rectangle: a hole, a turned square, a fill-and-stroke. + "1 g 0 0 300 200 re 50 50 10 10 re f*", + "1 g 0 0 m 300 0 l 300 200 l 0 200 l h f", + "0.7 0.7 -0.7 0.7 150 -100 cm 1 g 0 0 400 400 re f", + "1 g 0 0 300 200 re B", + // Malformed. + "Q 1 g 0 0 300 200 re f", + "1 g 0 0 300 re f", + "1 g 0 0 1e400 200 re f", + ] { + let mut doc = page(&[&format!("{content} {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{content}"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..], "{content}: the page's content is unchanged"); + } +} + +#[test] +fn a_scanned_page_is_reported_and_its_picture_kept() { + let mut doc = page(&[&format!("q 300 0 0 200 0 0 cm /Im0 Do Q {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Image)); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); + // A small picture is content, not a backdrop. + let doc = page(&["q 30 0 0 20 0 0 cm /Im0 Do Q"]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); +} + +#[test] +fn replacing_can_be_turned_off() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + let before = streams(&doc); + add_background(&mut doc, &[0], &Background { replace_backdrop: false, ..red() }, false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); +} + +#[test] +fn a_backdrop_copy_edited_since_is_kept_when_the_background_goes() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + // Something else rewrites the copy (as Edit text does), keeping its dictionary. + let p = &pdfcraft_model::pages(&doc)[0]; + let Some(Object::Array(list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") }; + let Object::Ref(copy) = list[1] else { panic!("a reference") }; + let Object::Stream(s) = (*doc.get(copy)).clone() else { panic!("a stream") }; + let edited = "1 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET"; + doc.set(copy, Object::Stream(Stream::flate(s.dict.clone(), edited.as_bytes()))); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), [edited.to_string()], "the edit survives"); +} + +#[test] +fn a_content_stream_shared_by_two_pages_is_copied_for_the_one_page() { + let content = format!("1 g 0 0 300 200 re f {TEXT}"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + stream("", &content), + ]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), Some(Backdrop::Fill), "the other page keeps its backdrop"); +} + +#[test] +fn pages_without_content_or_with_odd_contents_have_no_backdrop() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [5 0 R 99 0 R] >>".into(), + "<< /Not /AStream >>".into(), + ]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), None); + assert!(matches!(page_backdrop(&doc, 7), Err(EditError::NoSuchPage(7)))); +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 515f17d8da..a8ab1d51b5 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -150,11 +150,16 @@ pub struct Background { pub source: Option, /// Pictures: size relative to the page (1.0 fits the page). pub scale: f64, + /// Replace a white backdrop the page paints itself, which would hide the background + /// ([`backdrop`]). Removing the background puts it back. + pub replace_backdrop: bool, } impl Default for Background { + /// A light yellow that still reads as a colour on screen (a near-white default looked as if + /// nothing had happened). fn default() -> Self { - Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 } + Self { color: [1.0, 0.95, 0.7], opacity: 1.0, source: None, scale: 1.0, replace_backdrop: true } } } @@ -583,7 +588,9 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac Ok(()) } -/// Add a solid-colour background behind the content of `pages`. +/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a +/// white backdrop a page paints itself is replaced first, so the background shows (see +/// [`backdrop`]); a picture backdrop (a scan) still hides it. pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; @@ -592,6 +599,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl } let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 }; for &i in pages { + if bg.replace_backdrop { + backdrop::replace(doc, i)?; + } let page = page_list(doc)[i].clone(); let (w, h) = page.display_size(doc); let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc)); @@ -614,13 +624,17 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl } /// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on -/// top of a page, the q/Q wrapper goes too, restoring the original content list. +/// top of a page, the q/Q wrapper goes too, restoring the original content list. Removing +/// backgrounds also puts back a backdrop [`add_background`] replaced, unless it was edited since. pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { let all = page_list(doc); check(pages, all.len())?; let mut removed = 0; for &i in pages { let page = &all[i]; + if kind == MarkKind::Background { + backdrop::restore(doc, page)?; + } let list = contents(doc, page)?; let before = list.len(); let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); @@ -644,6 +658,8 @@ pub fn marks_present(doc: &Document) -> Vec { added::scan_markers(doc).marks } +pub mod backdrop; +pub use backdrop::{Backdrop, page_backdrop}; pub mod objects; pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868e..0f81fb22de 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -63,7 +63,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable}; +pub use pdfcraft_edit::{Backdrop, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; @@ -499,6 +499,11 @@ impl Document { self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() } + /// The backdrop `page` (0-based) paints itself, which hides a background (Add Background). + pub fn page_backdrop(&self, page: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten()) + } + /// Edit a PDF: the images `page` draws (0-based). pub fn page_images(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 4a60c40a6e..ed2490737c 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -3,7 +3,7 @@ //! preview of the current page. "Update" opens the same dialog in replace mode. use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; -use pdfcraft_engine::{Background, Edit, HeaderFooter, MarkKind, Watermark}; +use pdfcraft_engine::{Backdrop, Background, DocId, Edit, HeaderFooter, MarkKind, Watermark}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -72,6 +72,63 @@ impl PageRange { } } +/// How many pages a frame the Background dialog checks for a backdrop of their own, so a long +/// document never stalls the dialog. +const BACKDROP_PAGES_PER_FRAME: usize = 24; + +/// Which pages paint their own backdrop (it hides a background), for the document and edit +/// generation they were checked in. Filled a few pages a frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BackdropCheck { + key: Option<(DocId, u64)>, + /// Per page: `None` until checked. + pages: Vec>>, +} + +/// What the check found among the pages a background goes on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BackdropCount { + /// Pages with a white backdrop that can be replaced. + pub fills: usize, + /// Pages covered by a picture (scans): the background stays hidden there. + pub images: usize, + /// Pages not checked yet. + pub pending: usize, +} + +impl BackdropCheck { + /// Check up to [`BACKDROP_PAGES_PER_FRAME`] more of `pages` (0-based) and count them. + pub fn update(&mut self, doc: &pdfcraft_engine::Document, pages: &[usize]) -> BackdropCount { + let key = (doc.id, doc.edit_generation()); + let count = doc.info.pages.len(); + if self.key != Some(key) || self.pages.len() != count { + self.key = Some(key); + self.pages = vec![None; count]; + } + let mut budget = BACKDROP_PAGES_PER_FRAME; + let mut n = BackdropCount::default(); + for &p in pages { + let Some(slot) = self.pages.get_mut(p) else { continue }; + if slot.is_none() && budget > 0 { + *slot = Some(doc.page_backdrop(p)); + budget -= 1; + } + match slot { + None => n.pending += 1, + Some(Some(Backdrop::Fill)) => n.fills += 1, + Some(Some(Backdrop::Image)) => n.images += 1, + Some(None) => {} + } + } + n + } +} + +/// "1 page" or "{n} pages", in a message. +fn pages_phrase(n: usize, one: &str, many: &str) -> String { + if n == 1 { one.to_string() } else { crate::i18n::fmt(many, &[("n", &n.to_string())]) } +} + #[derive(Clone, Debug, PartialEq)] pub struct MarksDraft { pub hf: HeaderFooter, @@ -90,6 +147,8 @@ pub struct MarksDraft { pub use_file: bool, pub file: Option<(String, std::sync::Arc>)>, pub file_page: usize, + /// Background: pages that paint their own backdrop. + pub backdrops: BackdropCheck, } impl Default for MarksDraft { @@ -107,6 +166,7 @@ impl Default for MarksDraft { use_file: false, file: None, file_page: 1, + backdrops: BackdropCheck::default(), } } } @@ -299,6 +359,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M d.bg.opacity = pct / 100.0; } }); + if let Some(doc) = app.session.get(id) { + backdrop_options(ui, t, d, doc, count); + } } } ui.add_space(8.0); @@ -364,6 +427,13 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } MarkKind::Background => { p.rect_filled(r, CornerRadius::ZERO, rgb32(bg.color).gamma_multiply(bg.opacity as f32)); + // Schematic lines of text on top: the colour goes behind the content. + let (gap, inset) = (r.height() / 9.0, r.width() * 0.12); + for k in 1..8 { + let y = r.top() + gap * k as f32; + let end = if k % 3 == 0 { r.center().x } else { r.right() - inset }; + p.line_segment([pos2(r.left() + inset, y), pos2(end, y)], Stroke::new((gap * 0.3).clamp(1.0, 3.0), Color32::from_gray(90))); + } } }); ui.vertical(|ui| { @@ -477,6 +547,35 @@ fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) -> bool { browse } +/// Pages that paint their own backdrop would hide the background: offer to replace a white one, +/// and say where a picture (a scan) will still hide it. +fn backdrop_options(ui: &mut egui::Ui, t: &Tokens, d: &mut MarksDraft, doc: &pdfcraft_engine::Document, count: usize) { + let n = d.backdrops.update(doc, &d.range.pages(count)); + if n.pending > 0 { + ui.label(egui::RichText::new(tl!("Checking pages…")).small().color(t.text_faint)); + ui.ctx().request_repaint(); + } + if n.fills > 0 { + ui.add_space(4.0); + let label = pages_phrase( + n.fills, + tl!("This page paints its own white backdrop, which would hide the background. Replace it"), + tl!("{n} pages paint their own white backdrop, which would hide the background. Replace it"), + ); + ui.add(widgets::checkbox(&mut d.bg.replace_backdrop, &label)); + ui.label(egui::RichText::new(tl!("Removing the background puts the page's own backdrop back.")).small().color(t.text_faint)); + } + if n.images > 0 { + ui.add_space(4.0); + let text = pages_phrase( + n.images, + tl!("A picture covers this page (a scan?), so the background won't show there."), + tl!("A picture covers {n} pages (scans?), so the background won't show there."), + ); + ui.label(egui::RichText::new(text).small().color(crate::content_ui::UNDRAWABLE)); + } +} + /// The edit the dialog's OK makes. pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { let pages = d.range.pages(count); diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs index 0a0e45b152..3a9864ea47 100644 --- a/crates/ui-egui/tests/ui_group/marks.rs +++ b/crates/ui-egui/tests/ui_group/marks.rs @@ -96,3 +96,70 @@ fn backgrounds_and_watermarks_from_a_file() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background")); } + +/// Three pages: a white backdrop of its own (as browsers write), a page-sized picture (a scan) +/// and bare paper. +fn backdrop_fixture() -> Vec { + let stream = |data: &str| format!("<< /Length {} >> stream\n{data}\nendstream", data.len()); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 9 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 8 0 R >>".into(), + stream("q 1 1 1 rg 0 0 300 400 re f Q 0 g 20 20 50 50 re f"), + stream("q 300 0 0 400 0 0 cm /Im0 Do Q"), + stream("0 g 20 20 50 50 re f"), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >> stream\n\u{80}\nendstream" + .into(), + ]; + let mut out = String::from("%PDF-1.7\n"); + for (i, o) in objs.iter().enumerate() { + out.push_str(&format!("{} 0 obj {o} endobj\n", i + 1)); + } + out.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + out.into_bytes() +} + +#[test] +fn the_background_dialog_offers_to_replace_a_white_backdrop_and_warns_about_scans() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, backdrop_fixture()).unwrap(); + app + }); + h.run_steps(4); + let backdrop = |h: &Harness<'static, PdfCraftApp>, page| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().page_backdrop(page) + }; + assert_eq!(backdrop(&h, 0), Some(pdfcraft_engine::Backdrop::Fill)); + h.state_mut().execute("edit.background"); + h.run_steps(3); + let replace = "This page paints its own white backdrop, which would hide the background. Replace it"; + h.get_by_label("A picture covers this page (a scan?), so the background won't show there."); + assert!(h.state().marks_draft.bg.replace_backdrop, "on by default"); + // Left unticked, the backdrop stays and still hides the background. + h.get_by_label(replace).click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(backdrop(&h, 0), Some(pdfcraft_engine::Backdrop::Fill)); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(2); + // Ticked, it is replaced; the scan is reported, not touched. + h.state_mut().execute("edit.background"); + h.run_steps(3); + h.get_by_label(replace).click(); + h.run_steps(2); + assert!(h.state().marks_draft.bg.replace_backdrop); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(backdrop(&h, 0), None); + assert_eq!(backdrop(&h, 1), Some(pdfcraft_engine::Backdrop::Image)); + // Nothing left to replace: the offer is gone. + h.state_mut().execute("edit.background.update"); + h.run_steps(3); + assert!(h.query_by_label(replace).is_none()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..e6b3dee939 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2495,9 +2495,9 @@ tier = "P0" milestone = "M7" status = "shipped" commands = ["edit.background"] -tools = ["doc_background"] -evidence = ["crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/ui_group/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"] -notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page" +tools = ["doc_background", "doc_backdrops"] +evidence = ["crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/ui_group/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/automation/tests/backdrop.rs::a_background_shows_on_a_page_that_paints_its_own_white_backdrop", "crates/ui-egui/tests/ui_group/marks.rs::the_background_dialog_offers_to_replace_a_white_backdrop_and_warns_about_scans", "crates/edit/src/backdrop_tests.rs::content_that_is_not_a_page_backdrop_is_left_alone"] +notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page. A page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) used to hide the background entirely; the dialog now offers to replace that fill (on by default; Remove puts it back unless the content was edited since). Our own choice: Acrobat's behaviour on such pages has not been observed. Still hidden: scanned pages (warned), backdrops drawn inside a form XObject, by a non-rectangular path, or in a named colour space; the dialog preview is a schematic, not the page" [[feature]] id = "edit.background-update-remove" @@ -2508,8 +2508,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background.update", "edit.background.remove"] tools = ["doc_background", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::a_backdrop_copy_edited_since_is_kept_when_the_background_goes"] +notes = "Remove also restores a page backdrop Add replaced" [[feature]] id = "edit.decoration-profiles"