From 21171866879f09615d5cb6c0e112e4285aec4269 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:34:52 +0800 Subject: [PATCH] M2: the Layers panel presents the configuration's /Order tree (ISO 32000-2 Table 99) The panel listed every group in /OCGs, flat. Table 99 says only the groups /Order lists are presented, nested arrays as a tree, and a leading text string in a nested array as a non-selectable label; in the default configuration /Order defaults to the empty array. - render: DocInfo::layer_order, rows of LayerRow { depth, item } where an item is a layer (index into DocInfo::layers) or a label. A nested array's entries are one level deeper than the layer or label before them. At most 4096 rows, 32 levels and 256 characters a label; an indirect array is read once, so cycles end. DocInfo::layers is unchanged: unlisted groups keep their state, draw, print and answer layer_set and set-layer actions. - ui-egui: the Layers panel draws the rows, indented 16 points a level, a label as a heading that can't be toggled; the count is the layers shown. A document whose /Order lists none says so. - automation: doc_info reports layer_order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/automation/src/json.rs | 6 + crates/automation/src/json.rs.orig | 391 ++++++++++++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 2 + crates/render/src/inspect.rs | 10 +- crates/render/src/inspect/core.rs | 1 + crates/render/src/inspect/model.rs | 17 + crates/render/src/inspect/resources.rs | 73 ++- crates/render/src/inspect/tests.rs | 54 ++ crates/render/src/lib.rs | 5 +- crates/ui-egui/src/panels/right.rs | 35 +- crates/ui-egui/src/panels/right.rs.orig | 731 +++++++++++++++++++++++ crates/ui-egui/tests/it/layer_actions.rs | 38 +- parity/acrobat-features.toml | 4 +- 14 files changed, 1352 insertions(+), 17 deletions(-) create mode 100644 crates/automation/src/json.rs.orig create mode 100644 crates/ui-egui/src/panels/right.rs.orig diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 9e93d4c347..5fcb710b34 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -357,6 +357,12 @@ pub(crate) fn info(d: &Document) -> Value { }, })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // The Layers panel's rows, the default configuration's /Order (ISO 32000-2 Table 99): a + // layer by its 1-based position in `layers`, or a label; `depth` 0 at the top. + "layer_order": i.layer_order.iter().map(|r| match &r.item { + pdfcraft_render::LayerItem::Layer(n) => json!({ "depth": r.depth, "layer": n.saturating_add(1), "name": i.layers.get(*n).map(|l| l.name.as_str()) }), + pdfcraft_render::LayerItem::Label(s) => json!({ "depth": r.depth, "label": s }), + }).collect::>(), // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. "transitions": i.transitions.iter().map(|t| json!({ diff --git a/crates/automation/src/json.rs.orig b/crates/automation/src/json.rs.orig new file mode 100644 index 0000000000..9e93d4c347 --- /dev/null +++ b/crates/automation/src/json.rs.orig @@ -0,0 +1,391 @@ +use super::error::{Result, ToolError}; +use super::{Document, Value, comments, json}; + +/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at +/// most 14 400 across). +const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; + +// ---- JSON helpers ---------------------------------------------------------------------------- + +/// The most bookmark levels `bookmark_from_text_styles` takes. +const MAX_STYLE_LEVELS: usize = 9; + +/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each +/// `{ font, size, bold, italic }` as text_styles lists them. +pub(crate) fn text_style_levels(a: &Args) -> Result> { + let levels = + a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?; + if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS { + return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles"))); + } + levels + .iter() + .enumerate() + .map(|(i, v)| { + let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}")); + let font = + v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?; + let size = + v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?; + let flag = |key: &str| match v.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(b)) => Ok(*b), + Some(_) => Err(bad(&format!("{key} must be true or false"))), + }; + Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? }) + }) + .collect() +} + +pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result> { + let Some(value) = value else { return Ok(Vec::new()) }; + let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; + values + .iter() + .map(|v| { + let a = Args(v); + let bounds = a + .get("range") + .and_then(Value::as_array) + .filter(|v| v.len() == 2) + .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; + let start = bounds + .first() + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let end = bounds + .get(1) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let family = a + .opt_str("font")? + .map(|f| match f { + "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), + "times" => Ok(pdfcraft_engine::FontFamily::Times), + "courier" => Ok(pdfcraft_engine::FontFamily::Courier), + _ => Err(Args::wrong("font", "helvetica, times or courier")), + }) + .transpose()? + .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) + .transpose()?; + Ok(pdfcraft_engine::RangeStyle { + range: start..end, + family, + resource: a.opt_str("font_resource")?.map(str::to_owned), + size: a.opt_num("size")?, + color: a.opt_str("color")?.map(comments::parse_color).transpose()?, + underline: a.opt_bool("underline")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + }) + }) + .collect() +} + +/// Typed access to validated arguments. +pub(crate) struct Args<'a>(pub(crate) &'a Value); + +impl Args<'_> { + pub(crate) fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + pub(crate) fn missing(key: &str) -> ToolError { + ToolError::InvalidArgs(format!("missing argument {key}")) + } + pub(crate) fn wrong(key: &str, what: &str) -> ToolError { + ToolError::InvalidArgs(format!("{key} must be {what}")) + } + pub(crate) fn str(&self, key: &str) -> Result<&str> { + self.opt_str(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_str(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() + } + pub(crate) fn int(&self, key: &str) -> Result { + self.opt_int(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_int(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() + } + pub(crate) fn opt_num(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() + } + pub(crate) fn opt_bool(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() + } + pub(crate) fn ints(&self, key: &str) -> Result> { + self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_ints(&self, key: &str) -> Result>> { + let Some(v) = self.get(key) else { return Ok(None) }; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; + arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) + } + /// A 1-based bookmark path → 0-based indices. + pub(crate) fn path(&self, key: &str) -> Result> { + let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; + if p.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); + } + Ok(p) + } + pub(crate) fn opt_path(&self, key: &str) -> Result>> { + let Some(v) = self.opt_ints(key)? else { return Ok(None) }; + v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) + } + /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error + /// when it is not a number or lies absurdly far off the page. + pub(crate) fn bookmark_coordinate(&self, key: &str) -> Result> { + match self.opt_num(key)? { + None => Ok(None), + Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), + Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), + } + } + pub(crate) fn strs(&self, key: &str) -> Result> { + let v = self.get(key).ok_or_else(|| Self::missing(key))?; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; + arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() + } +} + +/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. +pub(crate) fn document_attachment_keys(doc: &Document) -> Vec { + doc.info + .attachments + .iter() + .filter_map(|x| match &x.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect() +} + +/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the +/// next level down in `children`, and paths and pages are 1-based. +pub(crate) fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { + // The open rows, outermost first, with their depth and the children gathered so far. + let mut open: Vec<(usize, Value, Vec)> = Vec::new(); + let mut roots = Vec::new(); + for row in rows { + let depth = row.path.len().saturating_sub(1); + while open.last().is_some_and(|(d, _, _)| *d >= depth) { + close_row(&mut open, &mut roots); + } + let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); + let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + // Only what differs from the defaults (black, regular), as the PDF itself stores it. + if let Some(color) = row.color { + fields["color"] = json!(color); + } + if row.bold { + fields["bold"] = json!(true); + } + if row.italic { + fields["italic"] = json!(true); + } + if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { + fields["destination"] = destination; + } + open.push((depth, fields, Vec::new())); + } + while !open.is_empty() { + close_row(&mut open, &mut roots); + } + roots +} + +/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page +/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its +/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the +/// reader's position and zoom). `None` for a destination without a type. +pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + let [x, y] = page.dest_fraction(left, top, 0); + let round = |v: f32| f64::from((v * 100.0).round() / 100.0); + (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) + }; + Some(match view { + DestView::Top => return None, + DestView::Xyz { left, top, zoom } => { + let (x, y) = points(*left, *top); + json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) + } + DestView::Fit => json!({ "fit": "Fit" }), + DestView::FitH { top } => { + let (x, y) = points(None, *top); + json!({ "fit": "FitH", "left": x, "top": y }) + } + DestView::FitV { left } => { + let (x, y) = points(*left, None); + json!({ "fit": "FitV", "left": x, "top": y }) + } + DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), + }) +} + +/// Closes the innermost open row: it joins the row above it as a child, or the top level. +fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { + if let Some((_, mut fields, children)) = open.pop() { + fields["children"] = Value::Array(children); + match open.last_mut() { + Some((_, _, siblings)) => siblings.push(fields), + None => roots.push(fields), + } + } +} + +pub(crate) fn one_based(pages: &[i64]) -> Result> { + pages + .iter() + .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) + .collect() +} + +/// The document's security as the next save writes it (never includes passwords). +pub(crate) fn security(d: &Document) -> Value { + match d.security_summary() { + None => json!({ "protected": false }), + Some(s) => { + let p = s.permissions; + json!({ + "protected": true, + "method": s.method, + "pending": s.pending, + "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, + "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), + }) + } + } +} + +/// What every tool that changes a document returns. +pub(crate) fn summary(d: &Document) -> Value { + json!({ + "doc": d.id.0, + "name": d.name, + "path": d.path, + "pages": d.info.pages.len(), + "dirty": d.dirty, + "editable": d.read_only_reason.is_none(), + "read_only_reason": d.read_only_reason, + "encrypted": d.info.encrypted, + "undo": d.can_undo(), + "redo": d.can_redo(), + }) +} + +pub(crate) fn info(d: &Document) -> Value { + let i = &d.info; + let page1 = |p: usize| p + 1; + let mut security = security(d); + if let Some(s) = d.security_summary() { + security["opened_as_owner"] = json!(s.owner); + } + json!({ + "document": summary(d), + "pdf_version": i.pdf_version, + "file_size": i.file_size, + "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), + "creator": i.creator, "producer": i.producer, + "tagged": i.tagged, + "has_javascript": i.has_javascript, + // Where the document opens (/OpenAction, named destinations resolved): 1-based page and + // destination, null without one. + "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), + // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" + // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). + "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), + // A script of the form ran too long and was abandoned: its scripts no longer run in this + // document (calculations, buttons) until it is opened again. + "xfa_scripts_off": d.xfa_scripts_off(), + // What was rewritten from, or could not be written to, the XFA data. + "xfa_warnings": d.xfa_warnings, + "security": security, + "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ + "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, + })).collect::>(), + "outline": outline(&i.outline), + "outline_more": i.outline_more, + // Rectangles use the tools' convention (top-left of the displayed page, like + // comment_list and link_list), not raw PDF user space, so they can be fed back to + // geometry-taking tools (#129). + "annotations": i.annotations.iter().map(|a| json!({ + "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), + })).collect::>(), + "fields": i.fields.iter().map(|f| json!({ + "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), + "tooltip": f.tooltip, "has_actions": f.has_actions, + })).collect::>(), + "links": i.links.iter().map(|l| json!({ + "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), + "target": match &l.target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + }, + })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // `substitute` names the face standing in for a program the document does not embed, and + // `substitution` says why. Both are null for an embedded font: nothing stands in. A null + // `substitute` with a non-null `substitution` means no face was available at all, so + // nothing is drawn for that font -- which is the answer to "why is my text missing?". + "fonts": i.fonts.iter().map(|f| json!({ + "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, + "substitute": f.substitute, "substitution": f.substitution, + })).collect::>(), + "warnings": i.warnings, + "repairs": d.repair_log(), + }) +} + +/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the +/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports +/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). +/// Public so `pdfcraft-cli` reports the same rectangles as the tools. +pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { + i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) +} + +fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..8a56f16f73 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -142,7 +142,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..c066a97a6e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8182,6 +8182,8 @@ fn doc_info_describes_set_layer_links() { }) ); assert_eq!(info["layers"], json!([{ "name": "Red", "visible": true, "initial": true }, { "name": "Green", "visible": false, "initial": false }])); + // No /Order: the Layers panel presents none (ISO 32000-2 Table 99), though both are layers. + assert_eq!(info["layer_order"], json!([])); } mod close_argument_tests { diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..63b40f7fdc 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -36,14 +36,14 @@ pub use core::{check_pages, inspect, pretty_date}; #[cfg(test)] use core::{inspect_structure, spend}; pub use model::{ - Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, + Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, + OutlineItem, PageInfo, PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; pub use resources::attachment_data; #[cfg(test)] -use resources::{xfa_config_dynamic_render, xfa_packet_text}; +use resources::{MAX_LAYER_LABEL_CHARS, MAX_LAYER_ORDER_ROWS, xfa_config_dynamic_render, xfa_packet_text}; #[derive(Clone, Debug, Default)] pub struct DocInfo { @@ -74,6 +74,10 @@ pub struct DocInfo { /// Radio-button layer groups (`/OCProperties /D /RBGroups`): turning one layer of a group on /// turns the others off. pub layer_groups: Vec>, + /// The Layers panel's rows: the default configuration's `/Order` (ISO 32000-2 Table 99), a + /// tree of layers (indices into `layers`) and labels. A layer it doesn't list isn't presented, + /// though it still draws in its state; no `/Order` presents none. + pub layer_order: Vec, pub fonts: Vec, pub attachments: Vec, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c190dc565b..92606cb3fd 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -248,6 +248,7 @@ impl<'a> Inspector<'a> { } self.layers(catalog, &mut info.layers); info.layer_groups = self.layer_groups(catalog); + info.layer_order = self.layer_order(catalog, &info.layers); self.fonts(&mut info.fonts); if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..45f591efff 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -502,6 +502,23 @@ pub struct Layer { pub initial: bool, } +/// One row of the Layers panel ([`DocInfo::layer_order`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LayerRow { + /// Nesting level, 0 at the top: a nested array's entries are one deeper than the layer or + /// label before them. + pub depth: usize, + pub item: LayerItem, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LayerItem { + /// A layer, by its index in [`DocInfo::layers`]. + Layer(usize), + /// A text label heading a nested array: not a layer, so it can't be selected or toggled. + Label(String), +} + #[derive(Clone, Debug)] pub struct Attachment { pub name: String, diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 5344b20855..7085ccb85a 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -1,8 +1,8 @@ #[cfg(test)] use super::core::inspect; use super::core::{Inspector, load_options}; -use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle}; -use super::navigation::NameTreeSeen; +use super::model::{Attachment, AttachmentSource, FontInfo, Layer, LayerItem, LayerRow, PageTransition, TransitionStyle}; +use super::navigation::{NameTreeSeen, text_string}; use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashMap, HashSet, Object, ObjectId, catch_unwind}; /// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to @@ -19,6 +19,12 @@ const MAX_AUTO_STATE_APPLICATIONS: usize = 256; const MAX_AUTO_STATE_GROUPS: usize = 4096; const MAX_AUTO_STATE_CATEGORIES: usize = 16; +/// The Layers panel's rows from `/Order`: at most this many rows, nested at most this deep, and +/// a label keeps at most this many characters. +pub(super) const MAX_LAYER_ORDER_ROWS: usize = 4096; +const MAX_LAYER_ORDER_DEPTH: usize = 32; +pub(super) const MAX_LAYER_LABEL_CHARS: usize = 256; + impl<'a> Inspector<'a> { // ── object helpers ────────────────────────────────────────────────────────────────────── @@ -172,6 +178,69 @@ impl<'a> Inspector<'a> { out } + /// The Layers panel's rows: the default configuration's `/Order` (ISO 32000-2 Table 99). Its + /// entries are groups, shown by name, and nested arrays, shown as a tree one level deeper; + /// a nested array may start with a text string, a label that is not a group. Groups not + /// listed aren't presented, and `/Order` defaults to the empty array in the default + /// configuration, so a document without one presents no layers. An entry that is neither a + /// group in `/OCGs` nor an array is skipped. Bounded by `MAX_LAYER_ORDER_*`, and an indirect + /// array is read once, so a cycle ends. + pub(super) fn layer_order(&self, catalog: &Dictionary, layers: &[Layer]) -> Vec { + let mut out = Vec::new(); + let Some(config) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)).and_then(|p| p.get(b"D").ok()).and_then(|o| self.dict(o)) + else { + return out; + }; + let Some(order) = config.get(b"Order").ok() else { return out }; + let index: HashMap = layers.iter().enumerate().map(|(i, l)| ((l.id.0, l.id.1), i)).collect(); + let mut seen = HashSet::new(); + if let Object::Reference(id) = order { + seen.insert(*id); + } + if let Ok(items) = self.resolve(order).as_array() { + self.layer_order_rows(items, 0, &index, &mut seen, &mut out); + } + out + } + + fn layer_order_rows( + &self, + items: &'a [Object], + depth: usize, + index: &HashMap, + seen: &mut HashSet, + out: &mut Vec, + ) { + if depth > MAX_LAYER_ORDER_DEPTH { + return; + } + for item in items { + if out.len() >= MAX_LAYER_ORDER_ROWS { + return; + } + if let Object::Reference(id) = item { + if let Some(&layer) = index.get(id) { + out.push(LayerRow { depth, item: LayerItem::Layer(layer) }); + continue; + } + // An indirect nested array, read once. + if !seen.insert(*id) { + continue; + } + } + let Ok(nested) = self.resolve(item).as_array() else { continue }; + let rest = match nested.split_first() { + Some((first, rest)) if let Object::String(bytes, _) = self.resolve(first) => { + let label: String = text_string(bytes).chars().take(MAX_LAYER_LABEL_CHARS).collect(); + out.push(LayerRow { depth, item: LayerItem::Label(label) }); + rest + } + _ => nested.as_slice(), + }; + self.layer_order_rows(rest, depth.saturating_add(1), index, seen, out); + } + } + /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. pub(super) fn layer_groups(&self, catalog: &Dictionary) -> Vec> { let groups = catalog diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 01f2f20049..1fc93170df 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -769,6 +769,60 @@ trailer << /Root 1 0 R >> assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); } +/// ISO 32000-2 Table 99 `/Order`: the Layers panel presents the groups it lists, as a tree, +/// with nested arrays one level deeper and a leading text string as a label. Groups it +/// doesn't list are not presented (they stay in `layers` and keep their state), and without +/// `/Order` none are. Malformed entries are skipped; cycles and depth are bounded. +#[test] +fn the_layers_panel_presents_the_order_tree() { + let rows = |order: &str, extra: &str| { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R 8 0 R] /D << /OFF [8 0 R] {order} >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +5 0 obj << /Type /OCG /Name (A) >> endobj +6 0 obj << /Type /OCG /Name (B) >> endobj +7 0 obj << /Type /OCG /Name (C) >> endobj +8 0 obj << /Type /OCG /Name (D) >> endobj +9 0 obj << /Type /OCG /Name (Unlisted) >> endobj +{extra} +trailer << /Root 1 0 R >> +%%EOF" + ); + let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); + assert_eq!(info.layers.len(), 4, "every group in /OCGs keeps its state: {order}"); + info.layer_order + .iter() + .map(|r| match &r.item { + LayerItem::Layer(i) => format!("{}{}", " ".repeat(r.depth), info.layers.get(*i).map_or("?", |l| l.name.as_str())), + LayerItem::Label(s) => format!("{}[{s}]", " ".repeat(r.depth)), + }) + .collect::>() + }; + // A layer with sublayers, and a group not listed (D) that isn't presented. + assert_eq!(rows("/Order [5 0 R 6 0 R [7 0 R]]", ""), ["A", "B", " C"]); + // A labelled collection heads its own level, with a nested array under B. + assert_eq!(rows("/Order [5 0 R [(Set) 6 0 R [7 0 R]] 8 0 R]", ""), ["A", "[Set]", " B", " C", "D"]); + // Without /Order, or with an empty one, nothing is presented. + assert!(rows("", "").is_empty()); + assert!(rows("/Order []", "").is_empty()); + // An indirect /Order and nested array; a group outside /OCGs, a missing object and junk + // are skipped. + assert_eq!(rows("/Order 10 0 R", "10 0 obj [5 0 R 11 0 R 9 0 R 12 0 R 1 (x) /N] endobj\n11 0 obj [6 0 R] endobj"), ["A", " B"]); + // Cycles: an array holding itself, and an /Order holding itself. + assert_eq!(rows("/Order 10 0 R", "10 0 obj [5 0 R 10 0 R 11 0 R] endobj\n11 0 obj [6 0 R 11 0 R 10 0 R] endobj"), ["A", " B"]); + // Nesting past the limit is cut off; a long label is cut short. + let deep = format!("/Order [5 0 R {}6 0 R{}]", "[".repeat(40), "]".repeat(40)); + assert_eq!(rows(&deep, ""), ["A"]); + let long = format!("/Order [[({}) 5 0 R]]", "x".repeat(1000)); + let got = rows(&long, ""); + assert_eq!(got.first().map(|s| s.len()), Some(MAX_LAYER_LABEL_CHARS + 2)); + // Many rows stop at the cap. + let many = format!("/Order [{}]", "5 0 R ".repeat(MAX_LAYER_ORDER_ROWS + 10)); + assert_eq!(rows(&many, "").len(), MAX_LAYER_ORDER_ROWS); +} + /// ISO 32000-2 §8.11.4.4: the View auto state (`/AS`) decides the initial state of the groups /// it manages, over `/ON` and `/OFF`; the others keep the configuration's. Malformed entries /// are ignored. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6f9a3ac319..95a66300c2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -18,8 +18,9 @@ pub mod text; pub mod transition; pub use inspect::{ - Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, + OutlineItem, PageInfo, PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, + thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 79769867d3..b556086ff8 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -48,7 +48,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Bookmarks => ("Bookmarks", None), RightPanel::Pages => ("Pages", Some(info.pages.len())), RightPanel::Fields => ("Fields", Some(info.fields.len())), - RightPanel::Layers => ("Layers", Some(info.layers.len())), + RightPanel::Layers => { + ("Layers", Some(info.layer_order.iter().filter(|r| matches!(r.item, pdfcraft_render::LayerItem::Layer(_))).count())) + } RightPanel::Attachments => ("Attachments", Some(info.attachments.len())), RightPanel::Signatures => ("Signatures", Some(doc.signatures.iter().filter(|s| s.signed).count())), RightPanel::Accessibility => ("Accessibility Checker", None), @@ -228,8 +230,31 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Layers => { if info.layers.is_empty() { empty(ui, &t, "layers", "This document has no layers."); + } else if info.layer_order.is_empty() { + // ISO 32000-2 Table 99: only the groups /Order lists are presented. + empty(ui, &t, "layers", "This document lists no layers to show."); } - for (li, l) in info.layers.iter().enumerate() { + // The default configuration's /Order, as a tree: a level is indented + // 16 points, up to eight levels; a label is a heading, not a layer. + for row in &info.layer_order { + let indent = 16.0 * row.depth.min(8) as f32; + let li = match &row.item { + pdfcraft_render::LayerItem::Layer(li) => *li, + pdfcraft_render::LayerItem::Label(label) => { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, label)); + crate::widgets::paint_left( + ui, + rect.left_center() + vec2(8.0 + indent, 0.0), + label, + theme::medium(12.0), + t.text_faint, + (rect.width() - 12.0 - indent).max(0.0), + ); + continue; + } + }; + let Some(l) = info.layers.get(li) else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, l.visible, &l.name)); let press = theme::Press::track(ui, &resp); @@ -237,7 +262,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let body = rect.translate(press.offset()); icons::paint( ui, - Rect::from_min_size(body.min + vec2(4.0, 7.0), vec2(18.0, 18.0)), + Rect::from_min_size(body.min + vec2(4.0 + indent, 7.0), vec2(18.0, 18.0)), if l.visible { "eye" } else { "eye-off" }, 16.0, t.icon, @@ -245,11 +270,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let fg = if l.visible { t.text } else { t.text_faint }; crate::widgets::paint_left( ui, - body.left_center() + vec2(32.0, 0.0), + body.left_center() + vec2(32.0 + indent, 0.0), &l.name, theme::regular(13.0), fg, - (rect.width() - 40.0).max(0.0), + (rect.width() - 40.0 - indent).max(0.0), ); if theme::hand(resp).on_hover_text(if l.visible { tl!("Hide layer") } else { tl!("Show layer") }).clicked() { toggle_layer = Some((li, !l.visible)); diff --git a/crates/ui-egui/src/panels/right.rs.orig b/crates/ui-egui/src/panels/right.rs.orig new file mode 100644 index 0000000000..79769867d3 --- /dev/null +++ b/crates/ui-egui/src/panels/right.rs.orig @@ -0,0 +1,731 @@ +use super::bookmarks::{BmAction, OutlineCtx, outline_item, outline_matches}; +use super::pages::{page_tools, pages, thumbnail_size}; +use super::{ + Align, Align2, Color32, DocInfo, FieldKind, Layout, Nav, PdfCraftApp, Rect, RightPanel, Sense, Stroke, Tokens, human_size, icons, theme, vec2, +}; + +/// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. +pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Some((index, id)) = app.active_ids() else { return }; + let Some(panel) = app.right else { return }; + let mut nav: Option