M3: layer_set shows or hides a layer, and page_render draws with the document's view settings (#2986)

The Layers panel had no automation tool: doc_info listed the layers, but
nothing could show or hide one, so layer visibility could not be checked
headlessly. layer_set does what the panel's eye icon does, naming a layer
by name (every group with that name, as CAD exporters repeat names) or by
its 1-based position in doc_info's list.

page_render built its own RenderConfig with only the password and the
reading colours, so it ignored layer visibility and comments_hide as
well: hiding comments through the tool changed nothing it drew. The
automation renderer now takes the document's own RenderConfig
(Session::render_config), and both tools drop the cached renderer when
they change a setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410
2026-10-17 21:09:57 +08:00
co-authored by Claude Opus 5.5
parent 924c4da4ac
commit 92990b2e2c
7 changed files with 157 additions and 9 deletions
+4 -1
View File
@@ -589,7 +589,10 @@ impl Automation {
pub(crate) fn comments_hide(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let hidden = a.opt_bool("hidden")?.unwrap_or(true);
self.session.set_hide_comments(id, hidden);
if self.session.set_hide_comments(id, hidden) {
// The cached renderer drew with the old setting; page_render must read it again.
self.renderers.remove(&id);
}
Ok(json!({ "hidden": hidden }))
}
+47
View File
@@ -0,0 +1,47 @@
//! Layers (optional content groups) as a tool: the Layers panel's eye icon (#2986, AGENTS.md §3).
//!
//! A per-document view setting, like hidden comments: `page_render` draws with it, and the file
//! itself never changes.
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
impl Automation {
/// Show or hide a layer, named by `layer`: its name as `doc_info` lists it, or its 1-based
/// position in that list. A name picks every layer that has it, since CAD exporters often
/// write one group per page or per drawing under the same name.
pub(crate) fn layer_set(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let id = doc.id;
let visible = a.opt_bool("visible")?.ok_or_else(|| ToolError::InvalidArgs("missing argument visible".into()))?;
let layers = &doc.info.layers;
let targets: Vec<usize> = match a.get("layer") {
Some(Value::String(name)) => layers.iter().enumerate().filter(|(_, l)| l.name == *name).map(|(i, _)| i).collect(),
Some(v) => match v.as_u64() {
Some(n) if n >= 1 && usize::try_from(n - 1).is_ok_and(|i| i < layers.len()) => vec![(n - 1) as usize],
Some(_) => Vec::new(),
None => return Err(ToolError::InvalidArgs("layer must be a layer name or its 1-based position in doc_info's layers".into())),
},
None => return Err(ToolError::InvalidArgs("missing argument layer".into())),
};
if targets.is_empty() {
let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
return Err(ToolError::InvalidArgs(if names.is_empty() {
"the document has no layers".into()
} else {
format!("no such layer; the document's layers are: {}", names.join(", "))
}));
}
let mut changed = false;
for i in targets {
changed |= self.session.set_layer_visible(id, i, visible);
}
if changed {
// The cached renderer drew with the old visibility; page_render must read it again.
self.renderers.remove(&id);
}
let layers = &self.doc(a)?.info.layers;
Ok(json!({ "layers": layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>() }))
}
}
+7 -5
View File
@@ -21,6 +21,7 @@ mod content;
mod conventions;
pub mod file_access;
mod forms;
mod layers;
mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
@@ -838,6 +839,7 @@ impl Automation {
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comments_hide" => self.comments_hide(&a)?,
"layer_set" => self.layer_set(&a)?,
"view_reading_colors" => self.view_reading_colors(&a)?,
"comments_summarize" => self.comments_summarize(&a)?,
"comment_edit" => self.comment_edit(&a)?,
@@ -1791,11 +1793,11 @@ impl Automation {
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let colors = self.session.reading_colors(id).unwrap_or_default();
let fresh = || {
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() };
(doc.display.clone(), PageRenderer::new(doc.display.clone(), config))
};
// The document's own view settings: layer visibility, hidden comments, reading colours
// (tools that change one drop this cache so the next render reads it again).
let config =
self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() });
let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone()));
let entry = self.renderers.entry(id).or_insert_with(fresh);
if !Arc::ptr_eq(&entry.0, &doc.display) {
*entry = fresh();
+5
View File
@@ -757,6 +757,11 @@ pub fn tools() -> Vec<ToolDef> {
comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })),
&["doc"],
)),
t("layer_set", "Show or hide a layer", "Show or hide a layer (optional content group), as the Layers panel's eye icon does. layer is its name as doc_info lists it (every layer with that name) or its 1-based position in that list. A view setting: page_render draws with it, and the file doesn't change. Returns every layer with its visibility.")
.with(schema(
json!({ "doc": doc(), "layer": { "type": ["string", "integer"], "description": "A layer name, or a 1-based position in doc_info's layers." }, "visible": { "type": "boolean" } }),
&["doc", "layer", "visible"],
)),
t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.")
.cmd("comment.hide_all")
.with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])),
+85
View File
@@ -6262,6 +6262,91 @@ fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] {
pixels[at..at + 4].try_into().unwrap()
}
/// A 300 × 100 page: red in layer "Walls" (left), green in "Doors" (middle), blue in a second
/// group also named "Walls" (right), and a black Square comment along the bottom.
fn layered_pdf() -> Vec<u8> {
let content = b"/OC /W1 BDC 1 0 0 rg 10 30 80 60 re f EMC /OC /D BDC 0 1 0 rg 110 30 80 60 re f EMC /OC /W2 BDC 0 0 1 rg 210 30 80 60 re f EMC";
let ap = b"0 g 0 0 280 10 re f";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R /Annots [8 0 R] /Resources << /Properties << /W1 5 0 R /D 6 0 R /W2 7 0 R >> >> >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
b"<< /Type /OCG /Name (Walls) >>".to_vec(),
b"<< /Type /OCG /Name (Doors) >>".to_vec(),
b"<< /Type /OCG /Name (Walls) >>".to_vec(),
b"<< /Type /Annot /Subtype /Square /Rect [10 2 290 12] /C [0 0 0] /AP << /N 9 0 R >> >>".to_vec(),
[format!("<< /Type /XObject /Subtype /Form /BBox [0 0 280 10] /Length {} >>\nstream\n", ap.len()).as_bytes(), ap, b"\nendstream"].concat(),
];
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", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
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
}
/// #2986: `layer_set` hides and shows layers in what `page_render` draws, by name (every group
/// with that name) or by position; `comments_hide` now changes `page_render` too. Neither
/// changes the file.
#[test]
fn layers_and_hidden_comments_change_what_page_render_draws() {
let dir = workdir("layer-set");
std::fs::write(dir.join("layers.pdf"), layered_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap();
let at = |a: &mut Automation, x: u32, y: u32| {
let (w, _, px) = a.render_pixels(doc, 1, 1.0).unwrap();
let i = ((y * w + x) * 4) as usize;
[px.as_ref()[i], px.as_ref()[i + 1], px.as_ref()[i + 2]]
};
// Points (50, 40) red, (150, 40) green, (250, 40) blue, (150, 93) the comment; y down.
assert_eq!(
[at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40), at(&mut a, 150, 93)],
[[255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 0, 0]]
);
let r = ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Walls", "visible": false }));
assert_eq!(
r["layers"],
json!([{ "name": "Walls", "visible": false }, { "name": "Doors", "visible": true }, { "name": "Walls", "visible": false }])
);
assert_eq!(
[at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40)],
[[255, 255, 255], [0, 255, 0], [255, 255, 255]],
"both groups named Walls hide"
);
ok(&mut a, "layer_set", json!({ "doc": doc, "layer": 3, "visible": true }));
assert_eq!([at(&mut a, 50, 40), at(&mut a, 250, 40)], [[255, 255, 255], [0, 0, 255]], "position 3 is the second Walls only");
ok(&mut a, "comments_hide", json!({ "doc": doc }));
assert_eq!(at(&mut a, 150, 93), [255, 255, 255], "hidden comments are not drawn");
ok(&mut a, "comments_hide", json!({ "doc": doc, "hidden": false }));
assert_eq!(at(&mut a, 150, 93), [0, 0, 0]);
for (args, what) in [
(json!({ "doc": doc, "layer": "Roof", "visible": false }), "an unknown name"),
(json!({ "doc": doc, "layer": 0, "visible": false }), "position 0"),
(json!({ "doc": doc, "layer": 4, "visible": false }), "a position past the end"),
(json!({ "doc": doc, "layer": true, "visible": false }), "a layer that is neither"),
(json!({ "doc": doc, "layer": "Doors" }), "no visible"),
] {
assert!(matches!(a.call("layer_set", &args), Err(ToolError::InvalidArgs(_))), "{what} must be refused");
}
let list = ok(&mut a, "doc_list", json!({}));
assert_eq!(list["documents"][0]["dirty"], json!(false), "a view setting leaves the document clean: {list}");
}
/// The `view_reading_colors` tool changes what `page_render` draws — text and background remap,
/// images never do — without touching the file, and its errors name what's wrong (#897).
#[test]
+6
View File
@@ -4760,6 +4760,12 @@ impl Session {
Some(self.get(id)?.config.reading_colors)
}
/// How the document's pages are drawn for viewing: its password and view settings (layer
/// visibility, hidden comments, reading colours). `None` when no such document is open.
pub fn render_config(&self, id: DocId) -> Option<RenderConfig> {
Some(self.get(id)?.config.clone())
}
/// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment
/// with its number, author, type, date, text and replies, grouped by page.
pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result<Arc<Vec<u8>>, EditError> {
+3 -3
View File
@@ -1321,9 +1321,9 @@ tier = "P0"
milestone = "M3"
status = "shipped"
commands = []
tools = ["doc_info"]
evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits"]
notes = ""
tools = ["doc_info", "layer_set"]
evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"]
notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176)."
[[feature]]
id = "view.layers-reset"