mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
924c4da4ac
commit
92990b2e2c
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user