collective

Browse / github.com/storytold/pdfcraft / patch

M7: Remove watermark also removes watermarks other editors marked as artifacts (#4957)

Watermark editor feature does not detect existing watermarks The watermark editor does not detect existing watermarks (created with adobe acrobat) actually marked as watermarks in the layers (and selectable/deselectable in the view). The watermark editor should be compatible with existing watermarks created with adobe acrobat and should be able to edit them, remove them, or recreate them while maintaining compatibility. In case this compatibility could be an optional tick to select in the watermark page (when creating a new one).

Status
open
Score
0.00
Adopters
0 other operators
Author
AdrianTM (imported from pull request #4984, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+771 −5 in 13 files
Review
pending
Shared
2026-10-28
Id
5de97bb94c5d12c6624b2129

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/automation/src/tools.rs crates/automation/tests/it/automation.rs crates/edit/src/foreign_marks.rs crates/edit/src/lib.rs crates/edit/src/tests.rs crates/engine/src/commands.rs crates/engine/src/document.rs crates/engine/src/edit/run.rs crates/engine/src/open.rs crates/engine/src/session/editing.rs crates/ui-egui/tests/it/marks.rs parity/acrobat-features.toml

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -745,3 +745,4 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.  - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.+- **2026-10-27 (M7, #4957):** Remove watermark also removes watermarks another editor wrote as artifacts (`/Artifact <</Subtype /Watermark>> BDC … EMC`, inline or through `/Properties`): the marked content is cut out byte for byte, anything unbalanced or unreadable is left. `doc_remove_marks` and the Edit menu command both go through it. A watermark whose drawing changes state that later content inherits, or a page that can't be read, stops the removal with the page and the reason, and the command is offered for a document that has only such a watermark. Update and reading a foreign watermark's settings into the dialog are not done.diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs--- a/crates/automation/src/tools.rs+++ b/crates/automation/src/tools.rs@@ -1002,7 +1002,7 @@ pub fn tools() -> Vec<ToolDef> {                 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" } }),                 &["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.")+        t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Removing a watermark also removes the ones another editor marked as watermark artifacts (Acrobat and others write them as /Artifact /Subtype /Watermark in the page content). Undoable.")             .destructive()             .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),         t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`<name>_page_<n>.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.")diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs--- a/crates/automation/tests/it/automation.rs+++ b/crates/automation/tests/it/automation.rs@@ -1122,6 +1122,83 @@ fn combine_takes_images_and_text_files() {     } } +/// A PDF with one page per entry of `contents`, the standard font as /F1 on each.+fn pages_with_content(contents: &[&str]) -> Vec<u8> {+    let n = contents.len();+    let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();+    let mut objs = vec![+        "<< /Type /Catalog /Pages 2 0 R >>".to_string(),+        format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 300] >>", kids.join(" ")),+    ];+    for i in 0..n {+        objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 {} 0 R >> >> >>", 3 + n + i, 3 + 2 * n));+    }+    for content in contents {+        objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));+    }+    objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string());+    let mut out = b"%PDF-1.7\n".to_vec();+    let mut offsets = Vec::new();+    for (i, o) in objs.iter().enumerate() {+        offsets.push(out.len());+        out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());+    }+    let xref = out.len();+    out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());+    for o in offsets {+        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{xref}\n%%EOF\n", objs.len() + 1).as_bytes());+    out+}++/// A one-page PDF whose content is `content`, with the standard font as /F1.+fn page_with_content(content: &str) -> Vec<u8> {+    pages_with_content(&[content])+}++/// #4957: Remove watermark takes out a watermark another editor marked as an artifact, leaves the+/// page's own text, and says so when there is none.+#[test]+fn remove_marks_takes_out_watermarks_other_editors_marked_as_artifacts() {+    let dir = workdir("foreign-watermark");+    let content = "BT /F1 18 Tf 20 250 Td (Body text) Tj ET\n/Artifact <</Subtype /Watermark /Type /Pagination>> BDC q BT /F1 40 Tf 20 100 Td (CONFIDENTIAL) Tj ET Q EMC\nBT /F1 12 Tf 20 20 Td (Footer) Tj ET";+    std::fs::write(dir.join("marked.pdf"), page_with_content(content)).unwrap();+    let mut a = auto(&dir);+    let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();+    assert!(page_text(&mut a, doc)[0].contains("CONFIDENTIAL"));+    ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "watermark" }));+    let text = page_text(&mut a, doc)[0].clone();+    assert!(!text.contains("CONFIDENTIAL") && text.contains("Body text") && text.contains("Footer"), "{text}");+    // Undo brings it back; a second removal after redo has nothing to take out.+    ok(&mut a, "edit_undo", json!({ "doc": doc }));+    assert!(page_text(&mut a, doc)[0].contains("CONFIDENTIAL"), "undo restores the watermark");+    ok(&mut a, "edit_redo", json!({ "doc": doc }));+    assert!(+        matches!(a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" })), Err(ToolError::Failed(e)) if e.contains("nothing to remove"))+    );+    // Other kinds are not touched by it: an artifact that is not a watermark stays.+    let header = "/Artifact <</Subtype /Header /Type /Pagination>> BDC BT /F1 10 Tf 20 280 Td (Running header) Tj ET EMC";+    std::fs::write(dir.join("header.pdf"), page_with_content(header)).unwrap();+    let doc = ok(&mut a, "doc_open", json!({ "path": "header.pdf" }))["doc"].as_u64().unwrap();+    assert!(matches!(a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" })), Err(ToolError::Failed(_))));+    assert!(page_text(&mut a, doc)[0].contains("Running header"));++    // A watermark that can't be cut out cleanly on one page stops the whole removal, naming the+    // page and why, and changes nothing: the page that could have been cleaned is left as it was.+    let clean = "BT /F1 12 Tf 20 20 Td (Page one body) Tj ET /Artifact <</Subtype /Watermark>> BDC q BT /F1 40 Tf 20 100 Td (STAMP) Tj ET Q EMC";+    let leaking = "0 g /Artifact <</Subtype /Watermark>> BDC 1 0 0 rg BT /F1 40 Tf 20 100 Td (LEAKY) Tj ET EMC BT /F1 12 Tf (Body two) Tj ET";+    std::fs::write(dir.join("mixed.pdf"), pages_with_content(&[clean, leaking])).unwrap();+    let doc = ok(&mut a, "doc_open", json!({ "path": "mixed.pdf" }))["doc"].as_u64().unwrap();+    let refused = a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" }));+    assert!(+        matches!(&refused, Err(ToolError::Failed(e)) if e.contains("page 2") && e.contains("nothing was changed") && !e.contains("page 1")),+        "{refused:?}"+    );+    let text = page_text(&mut a, doc);+    assert!(text[0].contains("STAMP") && text[1].contains("LEAKY"), "nothing changed: {text:?}");+}+ #[test] fn combine_extract_and_split() {     let dir = workdir("organize");diff --git a/crates/edit/src/foreign_marks.rs b/crates/edit/src/foreign_marks.rsnew file mode 100644--- /dev/null+++ b/crates/edit/src/foreign_marks.rs@@ -0,0 +1,321 @@+//! Watermarks other editors wrote (#4957): a page's content marked as an artifact of subtype+//! Watermark, `/Artifact <</Subtype /Watermark /Type /Pagination>> BDC … EMC` (ISO 32000-2+//! §14.8.2.2), which is how Acrobat and the other PDF editors mark theirs. The properties may be+//! inline or a name in the page's `/Properties`. Removing one cuts exactly those bytes out of the+//! content; everything else is copied as it was.+//!+//! Marked content does not save and restore the graphics state, so a watermark is cut out only+//! when nothing it does can reach what is drawn after it: every state change inside it sits+//! inside its own `q … Q`, and it opens and closes no text object or path the content around it+//! owns. One that does anything else is reported, not removed.++use std::fmt;+use std::ops::Range;++use super::*;+use pdfcraft_content::{Op, Pieces};++/// Marked-content scopes open at once beyond which a page is not read.+const MAX_SCOPES: usize = 1024;+/// The most decoded content bytes read from one document in one go, and operators in one page.+const MAX_CONTENT_BYTES: usize = 256 * 1024 * 1024;+const MAX_OPERATORS: usize = 5_000_000;+/// Pages looked at to tell whether a document has a watermark of this kind at all.+const MAX_DETECT_PAGES: usize = 5_000;+/// The deepest `q` nesting read (a page nested deeper than any real one is not read).+const MAX_DEPTH: usize = 100_000;++/// Why a page's watermark was not removed, or the page not checked.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Blocked {+    /// A content stream could not be read (damaged, too large).+    Unreadable,+    /// The content could not be read with certainty: bytes the parser skipped, an inline image+    /// whose end is a guess, marked-content scopes that do not pair up.+    Uncertain,+    /// A watermark is there, but cutting it out would change what is drawn after it.+    Unsafe,+}++impl fmt::Display for Blocked {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        f.write_str(match self {+            Blocked::Unreadable => "its content can't be read",+            Blocked::Uncertain => "its content can't be read with certainty",+            Blocked::Unsafe => "a watermark there changes what is drawn after it, so it can't be removed cleanly",+        })+    }+}++/// What [`remove_foreign_watermarks`] did.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct ForeignWatermarks {+    /// Watermark artifacts removed.+    pub removed: usize,+    /// Pages (0-based) a watermark could not be removed from, or that could not be checked.+    pub blocked: Vec<(usize, Blocked)>,+}++/// A marked-content scope that is open.+struct Scope {+    watermark: bool,+    /// The graphics-state depth (`q` minus `Q`) where it opened, and the lowest it reached since.+    depth: usize,+    floor: usize,+    start: usize,+    /// Which operator opened it (counted from 1), to tell whether a state change came after.+    opened: usize,+    /// A text object or a path was open where it started.+    text: bool,+    path: bool,+}++/// Operators that change the graphics state, the colour state or the text state, or the clip: they+/// outlast the marked-content scope they are in, until the `Q` of the `q` around them.+fn changes_state(op: &[u8]) -> bool {+    matches!(+        op,+        b"cm"+            | b"w"+            | b"J"+            | b"j"+            | b"M"+            | b"d"+            | b"ri"+            | b"i"+            | b"gs"+            | b"CS"+            | b"cs"+            | b"SC"+            | b"SCN"+            | b"sc"+            | b"scn"+            | b"G"+            | b"g"+            | b"RG"+            | b"rg"+            | b"K"+            | b"k"+            | b"Tc"+            | b"Tw"+            | b"Tz"+            | b"TL"+            // `"` sets the word and character spacing and then shows text; `TD` sets the leading.+            | b"\""+            | b"TD"+            | b"Tf"+            | b"Tr"+            | b"Ts"+            | b"W"+            | b"W*"+    )+}++/// The value of `/Subtype` in `d`, following a reference, is the name Watermark.+fn subtype_is_watermark(doc: &Document, d: &Dict) -> bool {+    d.get(b"Subtype").map(|o| doc.resolve(o)).is_some_and(|v| v.as_name() == Some(b"Watermark"))+}++/// The page's `/Properties` resource dictionary, if it has one.+fn page_properties(doc: &Document, page: &pdfcraft_model::Page) -> Option<Dict> {+    page.dict+        .get(b"Resources")+        .map(|r| doc.resolve(r))+        .and_then(|r| r.as_dict().and_then(|d| d.get(b"Properties").cloned()))+        .map(|p| doc.resolve(&p))+        .and_then(|p| p.as_dict().cloned())+}++/// Whether a `BDC` operator opens a watermark artifact.+fn opens_watermark(doc: &Document, properties: Option<&Dict>, op: &Op) -> bool {+    if op.name(0) != Some(b"Artifact") {+        return false;+    }+    match op.operands.get(1) {+        Some(Object::Dict(d)) => subtype_is_watermark(doc, d),+        Some(Object::Name(name)) => properties+            .and_then(|p| p.get(name))+            .map(|o| doc.resolve(o))+            .is_some_and(|resolved| resolved.as_dict().is_some_and(|d| subtype_is_watermark(doc, d))),+        _ => false,+    }+}++/// What a page's content holds.+enum Scan {+    /// No watermark artifact.+    Clean,+    /// Watermark artifacts: the byte ranges (in the joined content) that can be cut out, outermost+    /// ones only, and how many more cannot be.+    Found { spans: Vec<Range<usize>>, unsafe_left: usize },+    /// Not read with certainty.+    Uncertain,+}++fn scan_page(doc: &Document, page: &pdfcraft_model::Page, pieces: &Pieces) -> Scan {+    let properties = page_properties(doc, page);+    // Most pages have none: a watermark names itself in the content, or through a property whose+    // subtype says so. Neither: no need to read the content operator by operator. A name may be+    // spelled with `#` escapes (`/Water#6Dark` is `/Watermark`), so content with a `#` in it is+    // read in full: the absence of the plain spelling proves nothing there.+    let named = properties.as_ref().is_some_and(|p| p.iter().any(|(_, v)| doc.resolve(v).as_dict().is_some_and(|d| subtype_is_watermark(doc, d))));+    let data = pieces.data();+    if !named && !data.contains(&b'#') && !data.windows(9).any(|w| w == b"Watermark") {+        return Scan::Clean;+    }+    let parsed = pieces.parse();+    if parsed.skipped != 0 || parsed.ambiguous_images != 0 || parsed.ops.len() > MAX_OPERATORS {+        return Scan::Uncertain;+    }+    let (mut depth, mut stack, mut spans, mut unsafe_left) = (0usize, Vec::<Scope>::new(), Vec::<Range<usize>>::new(), 0usize);+    let (mut text, mut path) = (false, false);+    // For each `q` depth, the count of the operator that last changed state there. A scope is+    // affected when it was opened before the last change at its own depth: one lookup, however+    // many scopes are open.+    let mut last_change: Vec<usize> = vec![0];+    for (index, op) in parsed.ops.iter().enumerate() {+        let count = index.saturating_add(1);+        let name = op.op.as_slice();+        match name {+            b"q" => {+                depth = depth.saturating_add(1);+                if depth > MAX_DEPTH {+                    return Scan::Uncertain;+                }+                if depth >= last_change.len() {+                    last_change.resize(depth + 1, 0);+                }+            }+            b"Q" => {+                depth = depth.saturating_sub(1);+                if let Some(top) = stack.last_mut() {+                    top.floor = top.floor.min(depth);+                }+            }+            b"BT" => text = true,+            b"ET" => text = false,+            b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" => path = true,+            b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => path = false,+            b"BMC" | b"BDC" => {+                if stack.len() >= MAX_SCOPES {+                    return Scan::Uncertain;+                }+                let watermark = name == b"BDC" && opens_watermark(doc, properties.as_ref(), op);+                stack.push(Scope { watermark, depth, floor: depth, start: op.span.start, opened: count, text, path });+            }+            b"EMC" => {+                let Some(scope) = stack.pop() else { return Scan::Uncertain };+                if let Some(parent) = stack.last_mut() {+                    parent.floor = parent.floor.min(scope.floor);+                }+                if scope.watermark && !stack.iter().any(|s| s.watermark) {+                    // Where a text object or a path was open around it, it would change that+                    // object's matrix or that path (`q … Q` restores neither), so such a scope is+                    // never cut: nor one that closes or opens one, or changes state after it.+                    let effects = last_change.get(scope.depth).is_some_and(|last| *last > scope.opened);+                    let clean = depth == scope.depth && scope.floor >= scope.depth && !effects && !scope.text && !scope.path && !text && !path;+                    if clean {+                        spans.push(scope.start..op.span.end);+                    } else {+                        unsafe_left += 1;+                    }+                }+            }+            _ => {}+        }+        // A state change at a `q` depth reaches every scope open at that depth.+        if changes_state(name)+            && let Some(slot) = last_change.get_mut(depth)+        {+            *slot = count;+        }+    }+    if !stack.is_empty() {+        return Scan::Uncertain;+    }+    if spans.is_empty() && unsafe_left == 0 { Scan::Clean } else { Scan::Found { spans, unsafe_left } }+}++/// A page's content streams decoded, within what is left of `budget`.+fn page_streams(doc: &Document, page: &pdfcraft_model::Page, budget: &mut usize) -> Option<Vec<(Object, Vec<u8>)>> {+    let list: Vec<Object> = match page.dict.get(b"Contents") {+        None => Vec::new(),+        Some(c) => match &*doc.resolve(c) {+            Object::Array(a) => a.clone(),+            _ => vec![c.clone()],+        },+    };+    let mut out = Vec::with_capacity(list.len().min(1024));+    for object in list {+        let resolved = doc.resolve(&object);+        let Object::Stream(stream) = &*resolved else { return None };+        let bytes = stream.decoded_within(*budget).ok()?;+        *budget = budget.checked_sub(bytes.len())?;+        out.push((object, bytes));+    }+    Some(out)+}++/// Whether the document may have a watermark artifact another editor wrote (for enabling Remove+/// watermark). `None` when the document is too large to look through, or a page can't be read:+/// then it may.+pub fn foreign_watermarks_present(doc: &Document) -> Option<bool> {+    let pages = page_list(doc);+    if pages.len() > MAX_DETECT_PAGES {+        return None;+    }+    let mut budget = MAX_CONTENT_BYTES;+    let mut unknown = false;+    for page in &pages {+        let Some(streams) = page_streams(doc, page, &mut budget) else {+            unknown = true;+            continue;+        };+        let pieces = Pieces::join(&streams.iter().map(|(_, bytes)| bytes.as_slice()).collect::<Vec<_>>());+        match scan_page(doc, page, &pieces) {+            Scan::Found { .. } => return Some(true),+            Scan::Uncertain => unknown = true,+            Scan::Clean => {}+        }+    }+    if unknown { None } else { Some(false) }+}++/// Remove the watermark artifacts other editors wrote from `pages` (0-based). A page whose+/// watermark can't be cut out cleanly, or whose content can't be read with certainty, is left as+/// it is and listed in the result, so the caller can say so: nothing is skipped silently. The+/// fonts, forms and images a removed watermark drew with stay in the page's resources, unused.+pub fn remove_foreign_watermarks(doc: &mut Document, pages: &[usize]) -> Result<ForeignWatermarks, EditError> {+    let all = page_list(doc);+    check(pages, all.len())?;+    let mut result = ForeignWatermarks::default();+    let mut budget = MAX_CONTENT_BYTES;+    for &index in pages {+        let Some(page) = all.get(index) else { continue };+        let Some(streams) = page_streams(doc, page, &mut budget) else {+            result.blocked.push((index, Blocked::Unreadable));+            continue;+        };+        if streams.is_empty() {+            continue;+        }+        let pieces = Pieces::join(&streams.iter().map(|(_, bytes)| bytes.as_slice()).collect::<Vec<_>>());+        match scan_page(doc, page, &pieces) {+            Scan::Clean => {}+            Scan::Uncertain => result.blocked.push((index, Blocked::Uncertain)),+            Scan::Found { spans, unsafe_left } => {+                if unsafe_left > 0 {+                    result.blocked.push((index, Blocked::Unsafe));+                }+                if spans.is_empty() {+                    continue;+                }+                let data = pieces.splice(spans.iter().map(|r| (r.clone(), Vec::new())));+                let contents = crate::text::rewritten_contents(doc, &streams, data);+                doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;+                result.removed += spans.len();+            }+        }+    }+    Ok(result)+}diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs--- a/crates/edit/src/lib.rs+++ b/crates/edit/src/lib.rs@@ -57,10 +57,12 @@ pub use added::{ };  mod apply;+mod foreign_marks; mod marks; mod place;  pub use apply::{add_background, add_header_footer, add_watermark, marks_present, remove_marks};+pub use foreign_marks::{Blocked, ForeignWatermarks, foreign_watermarks_present, remove_foreign_watermarks}; #[cfg(test)] use marks::bates_start; pub use marks::{Background, Context, HeaderFooter, MarkKind, MarkSource, Watermark, expand};diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs--- a/crates/edit/src/tests.rs+++ b/crates/edit/src/tests.rs@@ -7457,3 +7457,280 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {     let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();     assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); }++/// A one-page document whose page content is `streams` (each its own stream, in order), with+/// `/Properties` `props` in its resources.+fn page_of_streams(streams: &[&str], props: &str) -> Document {+    page_of_streams_with(streams, props, &[])+}++/// The same, with `extra` objects numbered after the streams (the first is object `4 + streams`).+fn page_of_streams_with(streams: &[&str], props: &str, extra: &[&str]) -> Document {+    let mut objs: Vec<String> =+        vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into()];+    let refs: Vec<String> = (0..streams.len()).map(|i| format!("{} 0 R", 4 + i)).collect();+    objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents [{}] /Resources << /Properties << {props} >> >> >>", refs.join(" ")));+    for body in streams {+        objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));+    }+    objs.extend(extra.iter().map(|o| o.to_string()));+    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()+}++/// #4957: a watermark another editor marked as an artifact (inline or through `/Properties`) is+/// cut out of the content byte for byte, and the rest of the page is left exactly as it was.+#[test]+fn foreign_watermark_artifacts_are_cut_out_and_nothing_else_changes() {+    let wm = "/Artifact <</Subtype /Watermark /Type /Pagination>> BDC q 0.5 g /Fm0 Do Q EMC";+    let content = format!("BT (Before) Tj ET\n{wm}\nBT (After) Tj ET");+    let mut doc = page_of_streams(&[&content], "");+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 1);+    assert_eq!(streams(&doc, 0), ["BT (Before) Tj ET\n\nBT (After) Tj ET"], "only the artifact's bytes are gone");+    let doc = reopen(&doc);+    assert_eq!(streams(&doc, 0), ["BT (Before) Tj ET\n\nBT (After) Tj ET"], "and it is saved that way");++    // Named properties, as Acrobat writes them.+    let named = "BT (A) Tj ET /Artifact /MC0 BDC q /Fm0 Do Q EMC BT (B) Tj ET";+    let mut doc = page_of_streams(&[named], "/MC0 << /Subtype /Watermark /Type /Pagination >>");+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 1);+    assert_eq!(streams(&doc, 0), ["BT (A) Tj ET  BT (B) Tj ET"]);++    // Several, and one with marked content of its own inside.+    let many = format!("{wm} BT (x) Tj ET /Artifact <</Subtype /Watermark>> BDC /Span BMC q Q EMC EMC");+    let mut doc = page_of_streams(&[&many], "");+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 2);+    assert_eq!(streams(&doc, 0), [" BT (x) Tj ET "]);+}++/// #4957: only what is marked as a watermark goes, and only when cutting it out can't change what+/// follows it.+#[test]+fn foreign_watermark_removal_leaves_everything_it_should() {+    // Other artifacts and other marked content stay; a watermark inside other marked content goes+    // alone, and the content around it stays.+    let kept = "/Artifact <</Type /Pagination /Subtype /Header>> BDC (h) Tj EMC /Artifact BMC (b) Tj EMC /OC /MC0 BDC (o) Tj EMC /Artifact /MC1 BDC (n) Tj EMC";+    let mut doc = page_of_streams(&[kept], "/MC0 << /Subtype /Watermark >> /MC1 << /Subtype /Footer >>");+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap(), ForeignWatermarks::default());+    assert_eq!(streams(&doc, 0), [kept], "no watermark: the content is untouched");+    let inside = "/Span BMC /Artifact <</Subtype /Watermark>> BDC (w) Tj EMC (keep) Tj EMC";+    let mut doc = page_of_streams(&[inside], "");+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 1);+    assert_eq!(streams(&doc, 0), ["/Span BMC  (keep) Tj EMC"]);++    // A watermark whose q and Q don't balance inside it stays: cutting it would change the state.+    for unbalanced in ["/Artifact <</Subtype /Watermark>> BDC q 1 0 0 1 5 5 cm EMC (x) Tj Q", "q /Artifact <</Subtype /Watermark>> BDC Q q EMC Q"] {+        let mut doc = page_of_streams(&[unbalanced], "");+        assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 0, "{unbalanced}");+        assert_eq!(streams(&doc, 0), [unbalanced]);+    }++    // Content that can't be read with certainty is left alone: an unclosed scope, a stray EMC.+    for bad in [+        "/Artifact <</Subtype /Watermark>> BDC q Q (x) Tj",+        "(x) Tj EMC /Artifact <</Subtype /Watermark>> BDC EMC",+        // A whole watermark, then a scope that is never closed: not read with certainty, so left.+        "/Artifact <</Subtype /Watermark>> BDC q Q EMC /Span BMC (x) Tj",+    ] {+        let mut doc = page_of_streams(&[bad], "");+        assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 0, "{bad}");+        assert_eq!(streams(&doc, 0), [bad]);+    }+    // A page that isn't there is refused.+    let mut doc = page_of_streams(&["(x) Tj"], "");+    assert!(remove_foreign_watermarks(&mut doc, &[1]).is_err());+    assert!(remove_foreign_watermarks(&mut doc, &[usize::MAX]).is_err());+}++/// #4957: a watermark whose operators are split across content streams is cut out of both.+#[test]+fn a_foreign_watermark_across_content_streams_is_cut_out_of_each() {+    let mut doc = page_of_streams(&["(a) Tj /Artifact <</Subtype /Watermark>> BDC q 0.5 g", "/Fm0 Do Q EMC (b) Tj"], "");+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 1);+    assert_eq!(streams(&doc, 0), ["(a) Tj ", " (b) Tj"]);+}++/// #4957: marked content does not save and restore the graphics state, so a watermark that+/// changes state the rest of the page inherits is reported, not cut out (cutting it would repaint+/// what follows in another colour, place or clip). One that keeps its changes inside its own+/// `q … Q` is cut out whole.+#[test]+fn a_foreign_watermark_that_changes_state_for_what_follows_is_reported_not_removed() {+    use crate::{Blocked, ForeignWatermarks};+    let leaking = [+        // The colour: the square after it is red before removal, black after.+        "0 g /Artifact <</Subtype /Watermark>> BDC 1 0 0 rg 0 0 10 10 re f EMC 20 20 10 10 re f",+        // The matrix, the clip, the font and the line width.+        "/Artifact <</Subtype /Watermark>> BDC 1 0 0 1 50 50 cm EMC 0 0 5 5 re f",+        "/Artifact <</Subtype /Watermark>> BDC 0 0 50 50 re W n EMC 0 0 100 100 re f",+        "/Artifact <</Subtype /Watermark>> BDC BT /F1 40 Tf (x) Tj ET EMC BT (y) Tj ET",+        "/Artifact <</Subtype /Watermark>> BDC 8 w EMC 0 0 m 9 9 l S",+        // Operators that set state as a side effect: `"` sets the word and character spacing, `TD`+        // the leading, and both outlast the text object they are in.+        "BT /F1 12 Tf ET /Artifact <</Subtype /Watermark>> BDC BT 4 2 (WM) \" ET EMC BT 20 100 Td (A B) Tj ET",+        "BT /F1 12 Tf ET /Artifact <</Subtype /Watermark>> BDC BT 0 -14 TD (WM) Tj ET EMC BT (a) ' ET",+        // A text object or a path that begins or ends inside it.+        "BT /Artifact <</Subtype /Watermark>> BDC (x) Tj ET EMC",+        "0 0 m /Artifact <</Subtype /Watermark>> BDC 5 5 l S EMC",+    ];+    for content in leaking {+        let mut doc = page_of_streams(&[content], "");+        let result = remove_foreign_watermarks(&mut doc, &[0]).unwrap();+        assert_eq!(result, ForeignWatermarks { removed: 0, blocked: vec![(0, Blocked::Unsafe)] }, "{content}");+        assert_eq!(streams(&doc, 0), [content], "left exactly as it was: {content}");+    }+    // The same drawing with its changes inside its own q … Q goes, and what follows is untouched.+    let scoped = [+        "0 g /Artifact <</Subtype /Watermark>> BDC q 1 0 0 rg 1 0 0 1 5 5 cm 0 0 10 10 re f Q EMC 20 20 10 10 re f",+        "/Artifact <</Subtype /Watermark>> BDC q BT /F1 40 Tf 0.7 g (x) Tj ET Q EMC BT (y) Tj ET",+        "/Artifact <</Subtype /Watermark>> BDC q 0 0 50 50 re W n q 8 w 0 0 m 9 9 l S Q Q EMC",+        "BT /F1 12 Tf ET /Artifact <</Subtype /Watermark>> BDC q BT 4 2 (WM) \" 0 -14 TD (x) Tj ET Q EMC BT 20 100 Td (A B) Tj ET",+    ];+    for content in scoped {+        let mut doc = page_of_streams(&[content], "");+        let result = remove_foreign_watermarks(&mut doc, &[0]).unwrap();+        assert_eq!(result, ForeignWatermarks { removed: 1, blocked: vec![] }, "{content}");+        assert!(!streams(&doc, 0)[0].contains("Watermark"), "{content}");+    }+    let mut doc = page_of_streams(&[scoped[0]], "");+    remove_foreign_watermarks(&mut doc, &[0]).unwrap();+    assert_eq!(streams(&doc, 0), ["0 g  20 20 10 10 re f"], "what is left is the body, byte for byte");+}++/// #4957: the watermark's subtype and its properties may be indirect objects.+#[test]+fn a_foreign_watermark_named_through_indirect_objects_is_found() {+    use crate::ForeignWatermarks;+    // Object 5 is the name Watermark; object 6 is a properties dictionary whose Subtype is object 5.+    let content = "BT (A) Tj ET /Artifact /MC0 BDC q /Fm0 Do Q EMC BT (B) Tj ET";+    let mut doc = page_of_streams_with(&[content], "/MC0 6 0 R", &["/Watermark", "<< /Subtype 5 0 R /Type /Pagination >>"]);+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 1);+    assert_eq!(streams(&doc, 0), ["BT (A) Tj ET  BT (B) Tj ET"]);+    // A Subtype that is something else through a reference is not a watermark.+    let mut doc = page_of_streams_with(&[content], "/MC0 6 0 R", &["/Header", "<< /Subtype 5 0 R /Type /Pagination >>"]);+    assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap(), ForeignWatermarks::default());+}++/// #4957: a page that could not be checked or cleaned is reported with the reason, the others are+/// still cleaned, and a document with none says so.+#[test]+fn pages_a_foreign_watermark_could_not_be_removed_from_are_reported() {+    use crate::{Blocked, ForeignWatermarks, foreign_watermarks_present};+    let wm = "/Artifact <</Subtype /Watermark>> BDC q 0.5 g /Fm0 Do Q EMC";+    // Three pages: a watermark that goes, content that is not there, and a watermark in content that+    // can't be read with certainty.+    let uncertain = format!("{wm} /Span BMC (x) Tj");+    let mut objs: Vec<String> = vec![+        "<< /Type /Catalog /Pages 2 0 R >>".into(),+        "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 200 200] >>".into(),+        "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(),+        "<< /Type /Page /Parent 2 0 R /Contents 99 0 R >>".into(),+        "<< /Type /Page /Parent 2 0 R /Contents 8 0 R >>".into(),+    ];+    objs.push(format!("<< /Length {} >>\nstream\n{wm}\nendstream", wm.len()));+    // Object 7 is not used: page 2's content is a reference to an object that is not there.+    objs.push("<< >>".into());+    objs.push(format!("<< /Length {} >>\nstream\n{uncertain}\nendstream", uncertain.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());+    let mut doc = Document::open(Arc::new(out)).unwrap();+    // Detection: there is one, so Remove watermark is offered.+    assert_eq!(foreign_watermarks_present(&doc), Some(true));+    let result = remove_foreign_watermarks(&mut doc, &[0, 1, 2]).unwrap();+    assert_eq!(result, ForeignWatermarks { removed: 1, blocked: vec![(1, Blocked::Unreadable), (2, Blocked::Uncertain)] });+    assert!(!streams(&doc, 0)[0].contains("Watermark"), "the readable page was cleaned");+    assert_eq!(+        result.blocked.iter().map(|(_, why)| why.to_string()).collect::<Vec<_>>(),+        ["its content can't be read", "its content can't be read with certainty"]+    );++    // No watermark anywhere: nothing removed, nothing blocked, and detection says no.+    let mut plain = page_of_streams(&["BT (x) Tj ET", "q Q"], "");+    assert_eq!(foreign_watermarks_present(&plain), Some(false));+    assert_eq!(remove_foreign_watermarks(&mut plain, &[0]).unwrap(), ForeignWatermarks::default());+    // Detection by a property alone, and an unreadable page makes it "maybe".+    let named = page_of_streams(&["/Artifact /MC0 BDC EMC"], "/MC0 << /Subtype /Watermark >>");+    assert_eq!(foreign_watermarks_present(&named), Some(true));+}++/// #4957: a watermark inside a text object or a path that the surrounding content owns would move+/// that object's matrix or change that path when cut out, and `q … Q` restores neither: it is+/// reported, whether or not the object is still open where the watermark ends.+#[test]+fn a_foreign_watermark_inside_an_open_text_object_or_path_is_not_cut_out() {+    use crate::{Blocked, ForeignWatermarks};+    for content in [+        // The text matrix: the deleted Tj advanced it, so "Body" would shift left.+        "BT /F1 12 Tf 20 200 Td /Artifact <</Subtype /Watermark>> BDC (WM) Tj EMC (Body) Tj ET",+        "BT /F1 12 Tf /Artifact <</Subtype /Watermark>> BDC 30 0 Td EMC (Body) Tj ET",+        // The current path: cutting the segment changes the shape that is stroked.+        "0 0 m /Artifact <</Subtype /Watermark>> BDC 10 0 l EMC 10 10 l S",+        "0 0 m /Artifact <</Subtype /Watermark>> BDC q Q EMC 10 10 l S",+    ] {+        let mut doc = page_of_streams(&[content], "");+        let result = remove_foreign_watermarks(&mut doc, &[0]).unwrap();+        assert_eq!(result, ForeignWatermarks { removed: 0, blocked: vec![(0, Blocked::Unsafe)] }, "{content}");+        assert_eq!(streams(&doc, 0), [content], "{content}");+    }+}++/// #4957: a name may be spelled with `#` escapes, so a document whose only watermark is spelled+/// that way is still found, offered and cleaned.+#[test]+fn a_foreign_watermark_with_an_escaped_name_is_found() {+    use crate::foreign_watermarks_present;+    for content in [+        "(x) Tj /Artifact <</Subtype /Water#6Dark /Type /Pagination>> BDC q Q EMC (y) Tj",+        "(x) Tj /Arti#66act <</Subtype /Watermark>> BDC q Q EMC (y) Tj",+        "(x) Tj /Artifact <</Sub#74ype /Watermark>> BDC q Q EMC (y) Tj",+    ] {+        let mut doc = page_of_streams(&[content], "");+        assert_eq!(foreign_watermarks_present(&doc), Some(true), "{content}");+        assert_eq!(remove_foreign_watermarks(&mut doc, &[0]).unwrap().removed, 1, "{content}");+        assert_eq!(streams(&doc, 0), ["(x) Tj  (y) Tj"], "{content}");+    }+    // A `#` that is not a watermark: read in full, and nothing is found.+    let hash = "(a #1 b) Tj /Artifact <</Subtype /Header>> BDC q Q EMC";+    let doc = page_of_streams(&[hash], "");+    assert_eq!(foreign_watermarks_present(&doc), Some(false));+}++/// #4957: the scan costs the same however deeply marked content is nested: a watermark scope with+/// a thousand marked-content scopes inside it and a million state changes (some 4 MB) is read in+/// moments, not in a billion steps.+#[test]+fn reading_deeply_nested_marked_content_with_many_state_changes_is_quick() {+    use crate::{Blocked, ForeignWatermarks};+    let depth = 1000;+    let content =+        format!("/Artifact <</Subtype /Watermark>> BDC {}{}{}EMC", "/Span BMC ".repeat(depth), "0 g ".repeat(1_000_000), "EMC ".repeat(depth));+    let mut doc = page_of_streams(&[&content], "");+    let started = std::time::Instant::now();+    let result = remove_foreign_watermarks(&mut doc, &[0]).unwrap();+    let took = started.elapsed();+    // The state changes outlast the scope: reported, not cut.+    assert_eq!(result, ForeignWatermarks { removed: 0, blocked: vec![(0, Blocked::Unsafe)] });+    assert!(took < std::time::Duration::from_secs(5), "{took:?}");+    eprintln!("deep nesting scan took {took:?}");+}diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs--- a/crates/engine/src/commands.rs+++ b/crates/engine/src/commands.rs@@ -38,6 +38,8 @@ pub enum Needs {     HasRedactions,     /// The document has page marks of this kind and allows changes.     Marks(crate::MarkKind),+    /// Remove watermark: PdfCraft's own, or one another editor wrote as an artifact (#4957).+    WatermarkRemoval,     /// The document's security may be changed (owner, or nothing restricted), or would be     /// once its permissions password is entered.     Security,@@ -367,7 +369,7 @@ pub const COMMANDS: &[CommandSpec] = &[     c("edit.header_footer.remove", "Remove header & footer", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),     c("edit.watermark", "Add watermark…", None, None, Modification, "stamp"),     c("edit.watermark.update", "Update watermark…", None, None, Marks(crate::MarkKind::Watermark), "stamp"),-    c("edit.watermark.remove", "Remove watermark", None, None, Marks(crate::MarkKind::Watermark), "stamp"),+    c("edit.watermark.remove", "Remove watermark", None, None, WatermarkRemoval, "stamp"),     c("edit.background", "Add background…", None, None, Modification, "palette"),     c("edit.background.update", "Update background…", None, None, Marks(crate::MarkKind::Background), "palette"),     c("edit.background.remove", "Remove background", None, None, Marks(crate::MarkKind::Background), "palette"),@@ -423,6 +425,9 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)         Printing => doc.is_some_and(|d| d.allows_printing()),         FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()),         Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks().contains(&k)),+        WatermarkRemoval => {+            doc.is_some_and(|d| d.allows_modification() && (d.marks().contains(&crate::MarkKind::Watermark) || d.may_have_foreign_watermark()))+        }         HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()),         HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()),         HasRedactions => doc.is_some_and(|d| d.allows_modification() && d.redaction_marks() > 0),diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs--- a/crates/engine/src/document.rs+++ b/crates/engine/src/document.rs@@ -37,6 +37,9 @@ pub struct Document {     /// Interactive form fields of the current state (empty without a form).     pub form: Arc<Vec<pdfcraft_forms::Field>>,     pub(crate) marker_scan: OnceLock<pdfcraft_edit::MarkerScan>,+    /// Whether a watermark another editor wrote is there (`None`: could not tell), looked for the+    /// first time it is asked, and again after the content changes.+    pub(crate) foreign_watermark_scan: OnceLock<Option<bool>>,     pub(crate) links: OnceLock<Vec<pdfcraft_annot::links::LinkItem>>,     /// Signature fields, validated against the session's trust store (Signatures panel).     pub signatures: Arc<Vec<SignatureInfo>>,@@ -359,6 +362,14 @@ impl Document {         self.info.annotations.iter().filter(|a| a.subtype == "Redact").count()     } +    /// Whether the document may have a watermark another editor wrote as an artifact: it has one,+    /// or it could not be checked (a very large document, an unreadable page).+    pub fn may_have_foreign_watermark(&self) -> bool {+        self.foreign_watermark_scan+            .get_or_init(|| self.editor.as_ref().and_then(|e| pdfcraft_edit::foreign_watermarks_present(&e.cos)))+            .unwrap_or(true)+    }+     /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove.     pub fn marks(&self) -> &[MarkKind] {         &selfdiff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs--- a/crates/engine/src/edit/run.rs+++ b/crates/engine/src/edit/run.rs@@ -538,7 +538,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E         }         Edit::RemoveMarks { kind } => {             let n = pdfcraft_model::pages(doc).len();-            if pdfcraft_edit::remove_marks(doc, &(0..n).collect::<Vec<_>>(), *kind)? == 0 {+            let pages: Vec<usize> = (0..n).collect();+            let mut removed = pdfcraft_edit::remove_marks(doc, &pages, *kind)?;+            // Watermarks another editor marked as artifacts go with PdfCraft's own (#4957). A page+            // one could not be removed from, or checked on, stops the edit with the reason (nothing+            // changes): removing some and saying nothing about the rest would be wrong.+            if *kind == MarkKind::Watermark {+                let foreign = pdfcraft_edit::remove_foreign_watermarks(doc, &pages)?;+                if !foreign.blocked.is_empty() {+                    return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(blocked_watermarks(&foreign.blocked))));+                }+                removed += foreign.removed;+            }+            if removed == 0 {                 return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into())));             }         }@@ -694,3 +706,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E     }     Ok(()) }++/// "Remove watermark" refused: the pages a watermark could not be removed from, and why.+fn blocked_watermarks(blocked: &[(usize, pdfcraft_edit::Blocked)]) -> String {+    const SHOWN: usize = 5;+    let mut parts: Vec<String> = blocked.iter().take(SHOWN).map(|(page, why)| format!("page {}: {why}", page.saturating_add(1))).collect();+    if blocked.len() > SHOWN {+        parts.push(format!("and {} more pages", blocked.len() - SHOWN));+    }+    format!("a watermark another editor made could not be removed from every page ({}); nothing was changed", parts.join("; "))+}diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs--- a/crates/engine/src/open.rs+++ b/crates/engine/src/open.rs@@ -337,6 +337,7 @@ impl Session {             read_only_reason,             form: Arc::new(form),             marker_scan: OnceLock::new(),+            foreign_watermark_scan: OnceLock::new(),             links: OnceLock::new(),             signatures: Arc::new(signatures),             trust: trust.clone(),diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs--- a/crates/engine/src/session/editing.rs+++ b/crates/engine/src/session/editing.rs@@ -425,6 +425,7 @@ impl Session {         let (touches_added, touches_marks) = Self::content_refresh_lists(edit);         if touches_added || touches_marks {             doc.marker_scan = OnceLock::new();+            doc.foreign_watermark_scan = OnceLock::new();         }         doc.info.file_size = bytes.len();         let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };@@ -507,6 +508,7 @@ impl Session {         doc.form = Arc::new(form);         if invalidate_markers {             doc.marker_scan = OnceLock::new();+            doc.foreign_watermark_scan = OnceLock::new();         }         if invalidate_links {             doc.links = OnceLock::new();diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs--- a/crates/ui-egui/tests/it/marks.rs+++ b/crates/ui-egui/tests/it/marks.rs@@ -328,3 +328,50 @@ fn page_range_from_one_to_one_selects_only_the_first_page() {     assert_eq!(corner(&mut r, 0), vec![255, 255, 217, 255], "page 1 has the background");     assert_eq!(corner(&mut r, 1), vec![255, 255, 255, 255], "page 2 does not"); }++/// A one-page PDF with a watermark another editor marked as an artifact (and some body text).+fn foreign_watermark_pdf() -> Vec<u8> {+    let content = "BT /F1 18 Tf 20 250 Td (Body text) Tj ET\n/Artifact <</Subtype /Watermark /Type /Pagination>> BDC q BT /F1 40 Tf 20 100 Td (DRAFT) Tj ET Q EMC";+    format!(+        "%PDF-1.7+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj+3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj+4 0 obj << /Length {} >>+stream+{content}+endstream+endobj+5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj+trailer << /Root 1 0 R >>+%%EOF",+        content.len()+    )+    .into_bytes()+}++/// #4957: Remove watermark is offered for a document whose only watermark was made by another+/// editor, removes it, and is no longer offered; undo brings both back. A document without one+/// still has it disabled.+#[test]+fn remove_watermark_is_offered_for_a_watermark_another_editor_made() {+    let may = |h: &Harness<'static, PdfCraftApp>| {+        let s = h.state();+        s.session.get(s.views[0].id).unwrap().may_have_foreign_watermark()+    };+    let mut plain = harness();+    assert!(!may(&plain));+    assert!(!plain.state_mut().execute("edit.watermark.remove"), "nothing to remove in a document without one");++    let mut h = harness_with(foreign_watermark_pdf());+    assert!(marks(&h).is_empty(), "it is not one of PdfCraft's own marks");+    assert!(may(&h));+    assert!(h.state_mut().execute("edit.watermark.remove"), "Remove watermark is offered and runs");+    h.run_steps(3);+    assert!(!may(&h), "and it is gone");+    assert!(!h.state_mut().execute("edit.watermark.remove"), "so it is offered no more");+    h.state_mut().undo();+    h.run_steps(3);+    assert!(may(&h), "undo brings the watermark back, and the command with it");+    assert!(h.state_mut().execute("edit.watermark.remove"));+}diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -2507,8 +2507,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark.update", "edit.watermark.remove"] tools = ["doc_watermark", "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/tests.rs::foreign_watermark_artifacts_are_cut_out_and_nothing_else_changes", "crates/edit/src/tests.rs::foreign_watermark_removal_leaves_everything_it_should", "crates/edit/src/tests.rs::a_foreign_watermark_across_content_streams_is_cut_out_of_each", "crates/automation/tests/it/automation.rs::remove_marks_takes_out_watermarks_other_editors_marked_as_artifacts"]+notes = ". Remove watermark also takes out watermarks another editor wrote as artifacts (#4957): page content between /Artifact BDC and EMC whose properties, inline or a name in /Properties (the dictionary and its Subtype may be indirect), say /Subtype /Watermark (ISO 32000-2 14.8.2.2), cut out byte for byte, and the command is offered for a document that has only such a watermark. Marked content does not save the graphics state, so a watermark that changes the colour, matrix, clip, font or line state outside its own q and Q, or sits inside a text object or a path that the content around it owns (cutting it would move that text or change that path), or opens or closes one, is not cut out; names spelled with # escapes are read as their decoded names; neither is one in a page whose marked-content scopes do not pair up. A page where that happens, or whose content cannot be read, stops the whole removal with an error naming the page and the reason, and nothing is changed. Missing: Update (replace) does not replace such a watermark, the dialog does not read its text or settings back, and a watermark an editor wrote another way (a layer-only form, an annotation, a page-wide image with no artifact marking) is not found; the form, font or image a removed watermark drew with stays in the resources, unused; a very large document (over 5,000 pages) offers Remove watermark without looking"  [[feature]] id = "edit.watermark-appearance"