mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 14:41:51 +00:00
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e5ce0afd02
commit
2117186687
@@ -357,6 +357,12 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
|
||||
// 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::<Vec<_>>(),
|
||||
// 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!({
|
||||
|
||||
@@ -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<Vec<pdfcraft_engine::text_style_bookmarks::TextStyle>> {
|
||||
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<Vec<pdfcraft_engine::RangeStyle>> {
|
||||
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<Option<&str>> {
|
||||
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<i64> {
|
||||
self.opt_int(key)?.ok_or_else(|| Self::missing(key))
|
||||
}
|
||||
pub(crate) fn opt_int(&self, key: &str) -> Result<Option<i64>> {
|
||||
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<Option<f64>> {
|
||||
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<Option<bool>> {
|
||||
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<Vec<i64>> {
|
||||
self.opt_ints(key)?.ok_or_else(|| Self::missing(key))
|
||||
}
|
||||
pub(crate) fn opt_ints(&self, key: &str) -> Result<Option<Vec<i64>>> {
|
||||
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::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
/// A 1-based bookmark path → 0-based indices.
|
||||
pub(crate) fn path(&self, key: &str) -> Result<Vec<usize>> {
|
||||
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<Option<Vec<usize>>> {
|
||||
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::<Result<Vec<_>>>().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<Option<f32>> {
|
||||
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<Vec<&str>> {
|
||||
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<String> {
|
||||
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<Value> {
|
||||
// The open rows, outermost first, with their depth and the children gathered so far.
|
||||
let mut open: Vec<(usize, Value, Vec<Value>)> = 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<usize> = 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<Value> {
|
||||
use pdfcraft_render::DestView;
|
||||
let points = |left: Option<f32>, top: Option<f32>| {
|
||||
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<Value>)>, roots: &mut Vec<Value>) {
|
||||
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<Vec<usize>> {
|
||||
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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
|
||||
// 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::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
// `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::<Vec<_>>(),
|
||||
"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())
|
||||
}
|
||||
@@ -142,7 +142,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.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"])),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Vec<(u32, u16)>>,
|
||||
/// 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<LayerRow>,
|
||||
pub fonts: Vec<FontInfo>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
/// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LayerRow> {
|
||||
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<ObjectId, usize> = 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<ObjectId, usize>,
|
||||
seen: &mut HashSet<ObjectId>,
|
||||
out: &mut Vec<LayerRow>,
|
||||
) {
|
||||
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<Vec<ObjectId>> {
|
||||
let groups = catalog
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
};
|
||||
// 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.
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<Nav> = None;
|
||||
let mut close = false;
|
||||
let mut toggle_layer: Option<(usize, bool)> = None;
|
||||
let mut reset_layers = false;
|
||||
let mut attachment_action: Option<(usize, bool)> = None; // (index, open instead of save)
|
||||
let mut bm_action: Option<BmAction> = None;
|
||||
let mut bm_expand: Option<usize> = None;
|
||||
let mut panel_edit: Option<pdfcraft_engine::Edit> = None;
|
||||
let mut field_properties = None;
|
||||
let mut panel_command: Option<&'static str> = None;
|
||||
let mut sig_action: Option<crate::sign_ui::PanelAction> = None;
|
||||
let mut a11y_action: Option<crate::a11y_ui::PanelAction> = None;
|
||||
let mut compare_action: Option<crate::compare_ui::PanelAction> = None;
|
||||
let mut bm_rename = app.bookmark_rename.clone();
|
||||
let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none());
|
||||
// Prepare a form is open: the Fields panel orders tabs.
|
||||
let preparing = app.is_preparing();
|
||||
{
|
||||
let Some(doc) = app.session.get(id) else { return };
|
||||
let info = &doc.info;
|
||||
let view = &mut app.views[index];
|
||||
let prefs = &app.comment_prefs;
|
||||
let sig_expanded = &mut app.sig_expanded;
|
||||
let a11y = &mut app.a11y;
|
||||
let tags_state = &mut app.tags_state;
|
||||
let compare = &app.compare;
|
||||
let comment_allowed = doc.allows_annotation();
|
||||
// A dialog or the palette owns the keyboard: the panel leaves Escape to it.
|
||||
let modal = app.dialog.is_some() || app.palette_open;
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.inner_margin(egui::Margin { left: 14, right: 12, top: 12, bottom: 8 })
|
||||
.stroke(Stroke::new(1.0, t.divider))
|
||||
.show(ui, |ui| {
|
||||
let (title, count) = match panel {
|
||||
RightPanel::Comments => ("Comments", Some(crate::comments_panel::count(info))),
|
||||
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::Attachments => ("Attachments", Some(info.attachments.len())),
|
||||
RightPanel::Signatures => ("Signatures", Some(doc.signatures.iter().filter(|s| s.signed).count())),
|
||||
RightPanel::Accessibility => ("Accessibility Checker", None),
|
||||
RightPanel::Tags => ("Tags", None),
|
||||
RightPanel::Search => ("Search", None),
|
||||
RightPanel::Compare => ("Compare", compare.as_ref().filter(|c| c.new == id).map(|c| c.result.changes.len())),
|
||||
};
|
||||
// Comments count like Acrobat (no replies); the replies go in small text below.
|
||||
let replies = if panel == RightPanel::Comments { crate::comments_panel::reply_count(info) } else { 0 };
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5)));
|
||||
if let Some(c) = count {
|
||||
ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint));
|
||||
}
|
||||
});
|
||||
if replies > 0 {
|
||||
let text = crate::i18n::trn(crate::i18n::current(), replies as u64, "{n} reply", "{n} replies");
|
||||
ui.label(egui::RichText::new(text).font(theme::regular(11.0)).color(t.text_faint));
|
||||
}
|
||||
});
|
||||
if panel == RightPanel::Pages && view.selected.len() > 1 {
|
||||
let n = view.selected.len().to_string();
|
||||
let picked = crate::i18n::fmt(tl!("{n} pages selected"), &[("n", &n)]);
|
||||
ui.label(egui::RichText::new(picked).font(theme::medium(12.0)).color(t.accent_text));
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if icons::button(ui, "x", 26.0, false, tl!("Close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
if panel == RightPanel::Pages
|
||||
&& let Some(c) = page_tools(ui, !modal && view.panel_drag.is_none() && bm_editable)
|
||||
{
|
||||
panel_command = Some(c);
|
||||
}
|
||||
// Right to left: close, "…", filter, search (Acrobat's order left to right).
|
||||
if panel == RightPanel::Comments {
|
||||
panel_command = crate::comments_panel::header_controls(ui, info, view, doc.comments_hidden());
|
||||
}
|
||||
if panel == RightPanel::Comments
|
||||
&& icons::button(ui, "search", 26.0, view.comments.search.is_some(), tl!("Search comments")).clicked()
|
||||
{
|
||||
view.comments.search = match view.comments.search {
|
||||
Some(_) => None,
|
||||
None => Some(String::new()),
|
||||
};
|
||||
view.comments.search_focus = true;
|
||||
}
|
||||
if panel == RightPanel::Bookmarks && (bm_editable || !info.outline.is_empty()) {
|
||||
let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Bookmark options"));
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
if bm_editable && ui.button(tl!("New bookmarks from structure")).clicked() {
|
||||
bm_action = Some(BmAction::FromStructure);
|
||||
ui.close();
|
||||
}
|
||||
if bm_editable && ui.button(tl!("New bookmarks from text styles…")).clicked() {
|
||||
bm_action = Some(BmAction::FromTextStyles);
|
||||
ui.close();
|
||||
}
|
||||
if !info.outline.is_empty() {
|
||||
for (levels, label) in [
|
||||
(usize::MAX, tl!("Expand all bookmarks")),
|
||||
(1, tl!("Expand top-level bookmarks")),
|
||||
(0, tl!("Collapse all bookmarks")),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
bm_expand = Some(levels);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if panel == RightPanel::Layers && !info.layers.is_empty() {
|
||||
let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Layer options"));
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
if ui.button(tl!("Reset to Initial Visibility")).clicked() {
|
||||
reset_layers = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
let new_tip = crate::commands::command_tip(ui.ctx(), tl!("New bookmark ({key})"), "bookmark.add");
|
||||
if panel == RightPanel::Bookmarks && bm_editable && icons::button(ui, "bookmark-plus", 26.0, false, &new_tip).clicked() {
|
||||
bm_action = Some(BmAction::New);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let search_id = egui::Id::unique(("bookmark_search", id.0));
|
||||
let mut bookmark_query = ui.data(|d| d.get_temp::<String>(search_id)).unwrap_or_default();
|
||||
if panel == RightPanel::Bookmarks && !info.outline.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
const CLEAR: f32 = 26.0;
|
||||
let label = ui.label(tl!("Search"));
|
||||
let previous = bookmark_query.clone();
|
||||
// The panel keeps whatever width its content used, so this row must fit
|
||||
// exactly: any overflow would widen the panel again on every repaint.
|
||||
let width = ui.available_width() - CLEAR - ui.spacing().item_spacing.x;
|
||||
let response = ui.add(egui::TextEdit::singleline(&mut bookmark_query).desired_width(width)).labelled_by(label.id);
|
||||
response.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::text_edit(ui.is_enabled(), &previous, &bookmark_query, "");
|
||||
info.label = Some(tl!("Search").to_string());
|
||||
info
|
||||
});
|
||||
if icons::button(ui, "x", CLEAR, false, tl!("Clear")).clicked() {
|
||||
bookmark_query.clear();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(search_id, bookmark_query.clone()));
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let bookmark_query = pdfcraft_render::text::fold_case(bookmark_query.trim());
|
||||
let mut bookmark_matches =
|
||||
if panel == RightPanel::Bookmarks { outline_matches(&info.outline, &bookmark_query) } else { std::collections::HashSet::new() };
|
||||
// A new bookmark must stay nameable even when its initial title doesn't match.
|
||||
if let Some((path, _)) = &bm_rename {
|
||||
let mut ancestor = path.clone();
|
||||
while !ancestor.is_empty() {
|
||||
bookmark_matches.insert(ancestor.clone());
|
||||
ancestor.pop();
|
||||
}
|
||||
}
|
||||
if panel == RightPanel::Pages {
|
||||
let enabled = !modal && view.panel_drag.is_none();
|
||||
thumbnail_size(ui, &t, view, enabled);
|
||||
}
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel {
|
||||
RightPanel::Comments => {
|
||||
if let Some(e) = crate::comments_panel::show(ui, &t, doc, view, prefs, comment_allowed, &mut nav) {
|
||||
panel_edit = Some(e);
|
||||
}
|
||||
}
|
||||
RightPanel::Bookmarks => {
|
||||
if info.outline.is_empty() {
|
||||
empty(ui, &t, "bookmark", "This document has no bookmarks.");
|
||||
}
|
||||
let mut ctx = OutlineCtx {
|
||||
nav: &mut nav,
|
||||
action: &mut bm_action,
|
||||
rename: &mut bm_rename,
|
||||
editable: bm_editable,
|
||||
current: view.current,
|
||||
expand: bm_expand,
|
||||
matches: &bookmark_matches,
|
||||
searching: !bookmark_query.is_empty(),
|
||||
};
|
||||
if ctx.searching && bookmark_matches.is_empty() && !info.outline.is_empty() {
|
||||
ui.label(tl!("No matches."));
|
||||
}
|
||||
for (i, item) in info.outline.iter().enumerate() {
|
||||
outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx);
|
||||
}
|
||||
if info.outline_more {
|
||||
ui.label(tl!("Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth."));
|
||||
}
|
||||
}
|
||||
RightPanel::Pages => pages(ui, &t, info, view, modal, bm_editable, &mut nav),
|
||||
RightPanel::Fields => {
|
||||
field_properties = fields(
|
||||
ui,
|
||||
FieldsPanel {
|
||||
theme: &t,
|
||||
info,
|
||||
form: doc.form.as_slice(),
|
||||
preparing,
|
||||
selection: &mut view.prepare,
|
||||
nav: &mut nav,
|
||||
edit: &mut panel_edit,
|
||||
},
|
||||
);
|
||||
}
|
||||
RightPanel::Layers => {
|
||||
if info.layers.is_empty() {
|
||||
empty(ui, &t, "layers", "This document has no layers.");
|
||||
}
|
||||
for (li, l) in info.layers.iter().enumerate() {
|
||||
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);
|
||||
press.wash(ui, rect, 6, false);
|
||||
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)),
|
||||
if l.visible { "eye" } else { "eye-off" },
|
||||
16.0,
|
||||
t.icon,
|
||||
);
|
||||
let fg = if l.visible { t.text } else { t.text_faint };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(32.0, 0.0),
|
||||
&l.name,
|
||||
theme::regular(13.0),
|
||||
fg,
|
||||
(rect.width() - 40.0).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));
|
||||
}
|
||||
}
|
||||
}
|
||||
RightPanel::Signatures => {
|
||||
sig_action = crate::sign_ui::panel(ui, &t, &doc.signatures, sig_expanded);
|
||||
}
|
||||
RightPanel::Accessibility => {
|
||||
a11y_action = crate::a11y_ui::panel(ui, &t, a11y, id);
|
||||
}
|
||||
RightPanel::Tags => {
|
||||
let tags = tags_state.tags(doc);
|
||||
let content = tags_state.content(doc);
|
||||
let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, &content, tags_state, doc.allows_modification());
|
||||
if let Some(page) = page {
|
||||
nav = Some(Nav::Page(page));
|
||||
}
|
||||
panel_edit = edit;
|
||||
}
|
||||
RightPanel::Search => crate::search_ui::panel(ui, &t, view, info.pages.len()),
|
||||
RightPanel::Compare => compare_action = crate::compare_ui::panel(ui, &t, compare, id),
|
||||
RightPanel::Attachments => {
|
||||
let attachable = doc.allows_modification() && doc.read_only_reason.is_none();
|
||||
if attachable {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| {
|
||||
if ui.add(egui::Button::new(tl!("Attach a file…"))).clicked() {
|
||||
panel_command = Some("edit.attach");
|
||||
}
|
||||
});
|
||||
}
|
||||
if info.attachments.is_empty() {
|
||||
empty(ui, &t, "paperclip", "This document has no attachments.");
|
||||
}
|
||||
for (ai, a) in info.attachments.iter().enumerate() {
|
||||
let is_pdf = a.name.to_lowercase().ends_with(".pdf");
|
||||
// Only an attachment of the document itself can be deleted here.
|
||||
let deletable = match &a.source {
|
||||
pdfcraft_render::AttachmentSource::Document { key } if attachable => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
// The whole row answers a double-click and a right-click (#3466), below
|
||||
// its buttons, which still take their own clicks.
|
||||
let mut buttons: Vec<egui::Response> = Vec::new();
|
||||
let row = ui.scope_builder(egui::UiBuilder::new().sense(Sense::click()), |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("paperclip", 16.0, t.icon));
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(ui.available_width() - 70.0);
|
||||
// Not selectable, so a click on the name or the details reaches the row.
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate().selectable(false),
|
||||
);
|
||||
let mut meta = a.size.map(human_size).unwrap_or_default();
|
||||
if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source {
|
||||
let on_page = crate::i18n::fmt(
|
||||
tl!("on page {label}"),
|
||||
&[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))],
|
||||
);
|
||||
meta = format!("{meta} · {on_page}");
|
||||
}
|
||||
if let Some(d) = &a.description {
|
||||
meta = format!("{meta} · {d}");
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate().selectable(false),
|
||||
);
|
||||
});
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let save = icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…"));
|
||||
if save.clicked() {
|
||||
attachment_action = Some((ai, false));
|
||||
}
|
||||
buttons.push(save);
|
||||
if is_pdf {
|
||||
let open = icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab"));
|
||||
if open.clicked() {
|
||||
attachment_action = Some((ai, true));
|
||||
}
|
||||
buttons.push(open);
|
||||
}
|
||||
if let Some(key) = deletable {
|
||||
let delete = icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment"));
|
||||
if delete.clicked() {
|
||||
panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() });
|
||||
}
|
||||
buttons.push(delete);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
// A double-click opens a PDF in a new tab, as Acrobat opens an attachment;
|
||||
// any other file is saved, since PdfCraft opens only PDFs.
|
||||
if row.response.double_clicked() {
|
||||
attachment_action = Some((ai, is_pdf));
|
||||
}
|
||||
// A right-click on one of the row's buttons is a right-click on the row:
|
||||
// the union keeps the row's id, so it is one menu either way.
|
||||
let menu = buttons.into_iter().fold(row.response.clone(), |row, button| row | button);
|
||||
menu.context_menu(|ui| {
|
||||
if is_pdf && ui.button(tl!("Open in a new tab")).clicked() {
|
||||
attachment_action = Some((ai, true));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Save attachment…")).clicked() {
|
||||
attachment_action = Some((ai, false));
|
||||
ui.close();
|
||||
}
|
||||
if let Some(key) = deletable
|
||||
&& ui.button(tl!("Delete attachment")).clicked()
|
||||
{
|
||||
panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() });
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if close {
|
||||
app.choose_right_panel(None);
|
||||
}
|
||||
if let Some(e) = panel_edit {
|
||||
app.apply_edit(e);
|
||||
}
|
||||
if let Some((name, widget)) = field_properties {
|
||||
app.open_field_props(&name, widget);
|
||||
}
|
||||
if let Some(c) = panel_command {
|
||||
app.run_command(c);
|
||||
}
|
||||
if let Some(a) = a11y_action {
|
||||
app.a11y_action(index, a);
|
||||
}
|
||||
if let Some(a) = compare_action {
|
||||
app.compare_action(index, a);
|
||||
}
|
||||
match sig_action {
|
||||
Some(crate::sign_ui::PanelAction::Validate) => app.run_command("sign.validate"),
|
||||
Some(crate::sign_ui::PanelAction::GoTo(p)) => app.views[index].go_to_page(p),
|
||||
Some(crate::sign_ui::PanelAction::Trust(c)) => app.trust_certificate(*c),
|
||||
Some(crate::sign_ui::PanelAction::ViewCertificate(chain)) => {
|
||||
app.cert_viewer = Some(crate::sign_ui::CertViewer { chain, selected: 0, tab: crate::sign_ui::CertTab::Summary });
|
||||
app.dialog = Some(crate::Dialog::CertificateViewer);
|
||||
}
|
||||
Some(crate::sign_ui::PanelAction::ViewSigned { len, revision }) => app.view_signed_version(len, revision),
|
||||
Some(crate::sign_ui::PanelAction::Sign(field)) => {
|
||||
let page = app.views[index].current;
|
||||
app.start_signing(page, None, Some(field), None);
|
||||
}
|
||||
Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => {
|
||||
app.export_certificate(&c);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) {
|
||||
app.make_comment_default(p, i);
|
||||
}
|
||||
if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) {
|
||||
app.comment_attachment_action(id, p, i, open);
|
||||
}
|
||||
if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) {
|
||||
app.open_comment_props(p, i);
|
||||
}
|
||||
app.bookmark_rename = bm_rename;
|
||||
if let Some(a) = bm_action {
|
||||
app.bookmark_action(a);
|
||||
}
|
||||
if let Some((ai, open)) = attachment_action {
|
||||
app.attachment_action(id, ai, open);
|
||||
}
|
||||
if let Some((layer, visible)) = toggle_layer
|
||||
&& app.session.set_layer_visible(id, layer, visible)
|
||||
{
|
||||
app.views[index].invalidate_content();
|
||||
}
|
||||
if reset_layers && app.session.reset_layers(id) {
|
||||
app.views[index].invalidate_content();
|
||||
}
|
||||
match nav {
|
||||
Some(Nav::Page(p)) => app.views[index].go_to_page(p),
|
||||
Some(Nav::Dest(p, dest)) => match app.session.get(id) {
|
||||
Some(doc) => app.views[index].go_to_dest(p, dest, &doc.info),
|
||||
None => app.views[index].go_to_page(p),
|
||||
},
|
||||
Some(Nav::Flash(p, r)) => {
|
||||
let v = &mut app.views[index];
|
||||
if let Some(doc) = app.session.get(id) {
|
||||
v.reveal_rect(p, r, &doc.info);
|
||||
} else {
|
||||
v.go_to_page(p);
|
||||
}
|
||||
v.flash = Some((p, r, 0.0));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn empty(ui: &mut egui::Ui, t: &Tokens, icon: &str, text: &str) {
|
||||
ui.add_space(40.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add(icons::image(icon, 36.0, t.text_faint));
|
||||
ui.add_space(8.0);
|
||||
// Every empty-panel message goes through here.
|
||||
ui.label(egui::RichText::new(tl!(text)).color(t.text_muted));
|
||||
});
|
||||
}
|
||||
|
||||
/// The Fields panel: fields by page, in tab order. While preparing a form, each field can move
|
||||
/// earlier or later in its page's tab order (Acrobat: Order Tabs Manually).
|
||||
struct FieldsPanel<'a> {
|
||||
theme: &'a Tokens,
|
||||
info: &'a DocInfo,
|
||||
form: &'a [pdfcraft_engine::FormField],
|
||||
preparing: bool,
|
||||
selection: &'a mut crate::prepare::PrepareView,
|
||||
nav: &'a mut Option<Nav>,
|
||||
edit: &'a mut Option<pdfcraft_engine::Edit>,
|
||||
}
|
||||
|
||||
fn fields(ui: &mut egui::Ui, panel: FieldsPanel<'_>) -> Option<(String, usize)> {
|
||||
let FieldsPanel { theme: t, info, form, preparing, selection, nav, edit } = panel;
|
||||
let mut properties = None;
|
||||
if info.fields.is_empty() {
|
||||
empty(ui, t, "text-cursor-input", "This document has no form fields.");
|
||||
return None;
|
||||
}
|
||||
let rows = field_rows(&info.fields, form);
|
||||
if preparing {
|
||||
ui.label(egui::RichText::new(tl!("Tab order: move a field with its arrows.")).small().color(t.text_muted));
|
||||
ui.label(egui::RichText::new(tl!("Shift- or Ctrl/Command-click to select several fields.")).small().color(t.text_muted));
|
||||
let names = selection.names();
|
||||
// Keep the field rows in place when the first click selects a field, so the
|
||||
// second click of a double-click still lands on the same row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::i18n::fmt(tl!("{n} selected"), &[("n", &names.len().to_string())]));
|
||||
if ui.add_enabled(!names.is_empty(), egui::Button::new(tl!("Properties…"))).clicked() {
|
||||
properties = selection.selected.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
for (p, page_rows) in rows {
|
||||
// A field's /P can name a page the document doesn't have (#1307); it reads as unplaced.
|
||||
let label = p
|
||||
.and_then(|p| info.pages.get(p))
|
||||
.map(|page| crate::i18n::fmt(tl!("Page {label}"), &[("label", &page.label)]))
|
||||
.unwrap_or_else(|| tl!("Unplaced").to_string());
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.5)).color(t.text_muted));
|
||||
for (f, key) in page_rows {
|
||||
let selected = key.as_ref().is_some_and(|key| selection.selected.as_ref() == Some(key) || selection.also.contains(key));
|
||||
let icon = match f.kind {
|
||||
FieldKind::Text => "text-cursor-input",
|
||||
FieldKind::CheckBox => "check-circle-2",
|
||||
FieldKind::Radio => "circle",
|
||||
FieldKind::PushButton => "square",
|
||||
FieldKind::Combo => "chevron-down",
|
||||
FieldKind::List => "list",
|
||||
FieldKind::Signature => "signature",
|
||||
FieldKind::Unknown => "square",
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &f.name));
|
||||
// A row scrolled out of view still takes clicks and keyboard focus, but isn't drawn:
|
||||
// laying out every row of a large form each frame took a tenth of a second a frame.
|
||||
let visible = ui.is_rect_visible(rect);
|
||||
if visible {
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, selected);
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, Color32::from_rgb(0x8E, 0x4E, 0xE6));
|
||||
let value =
|
||||
f.value.as_ref().map(|v| if v.chars().count() > 18 { format!("{}…", v.chars().take(17).collect::<String>()) } else { v.clone() });
|
||||
let value_font = theme::regular(11.5);
|
||||
let value_w = value.as_ref().map(|v| crate::widgets::text_width(ui, v, &value_font)).unwrap_or(0.0);
|
||||
let arrows = if preparing && f.page.is_some() { 56.0 } else { 0.0 };
|
||||
let max_w = (rect.width() - 32.0 - value_w - arrows - 16.0).max(0.0);
|
||||
crate::widgets::paint_left(ui, body.left_center() + vec2(32.0, 0.0), &f.name, theme::regular(13.0), t.text, max_w);
|
||||
if let Some(v) = value {
|
||||
let x = if arrows > 0.0 { 64.0 } else { 8.0 };
|
||||
ui.painter().text(body.right_center() - vec2(x, 0.0), Align2::RIGHT_CENTER, v, value_font, t.text_faint);
|
||||
}
|
||||
}
|
||||
if preparing && f.page.is_some() {
|
||||
let up = Rect::from_center_size(rect.right_center() - vec2(44.0, 0.0), vec2(22.0, 22.0));
|
||||
let down = Rect::from_center_size(rect.right_center() - vec2(20.0, 0.0), vec2(22.0, 22.0));
|
||||
for (r, icon, earlier, tip) in
|
||||
[(up, "chevron-up", true, tl!("Earlier in tab order")), (down, "chevron-down", false, tl!("Later in tab order"))]
|
||||
{
|
||||
let b = ui.interact(r, ui.scope_id().with((&f.name, earlier)), Sense::click());
|
||||
b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{tip}: {}", f.name)));
|
||||
if visible {
|
||||
let bpress = theme::Press::track(ui, &b);
|
||||
bpress.wash(ui, r, 4, false);
|
||||
icons::paint(ui, r.shrink(3.0).translate(bpress.offset()), icon, 15.0, t.icon);
|
||||
}
|
||||
if theme::hand(b).on_hover_text(tip).clicked() {
|
||||
*edit = Some(pdfcraft_engine::Edit::MoveInTabOrder { name: f.name.clone(), earlier });
|
||||
}
|
||||
}
|
||||
}
|
||||
if preparing && resp.double_clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
|
||||
if let Some(key) = key {
|
||||
if !selected {
|
||||
selection.select(key.clone(), false);
|
||||
}
|
||||
properties = Some(key);
|
||||
}
|
||||
} else if preparing
|
||||
&& resp.clicked()
|
||||
&& let Some(key) = key
|
||||
{
|
||||
selection.select(key, ui.input(|i| i.modifiers.shift || i.modifiers.command));
|
||||
}
|
||||
let resp = theme::hand(resp);
|
||||
// Only a row in view can be hovered: its tip isn't built for the others.
|
||||
let resp = if visible { resp.on_hover_text(field_tip(f)) } else { resp };
|
||||
if resp.clicked()
|
||||
&& let (Some(p), Some(r)) = (f.page, f.rect)
|
||||
{
|
||||
*nav = Some(Nav::Flash(p, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
properties
|
||||
}
|
||||
|
||||
/// A Fields panel row's tooltip: the field's kind, its own tooltip and whether it has scripts.
|
||||
fn field_tip(f: &pdfcraft_render::Field) -> String {
|
||||
let kind = format!("{:?}", f.kind);
|
||||
let mut tip = crate::i18n::fmt(tl!("{kind} field"), &[("kind", tl!(&kind))]);
|
||||
if let Some(tt) = &f.tooltip {
|
||||
tip.push_str(&format!(" — {tt}"));
|
||||
}
|
||||
if f.has_actions {
|
||||
tip.push_str(&format!("\n{}", tl!("Has JavaScript actions (run in M6)")));
|
||||
}
|
||||
tip
|
||||
}
|
||||
|
||||
/// One page's rows of the Fields panel: each field, with the form field and widget a click on
|
||||
/// it selects (`None` when the form has no widget of it on that page).
|
||||
type FieldRows<'a> = Vec<(&'a pdfcraft_render::Field, Option<(String, usize)>)>;
|
||||
|
||||
/// The Fields panel's rows: by page ("Unplaced" first), each page's in tab order. A row is paired
|
||||
/// with the first form field of its name, as the engine pairs them: one map, built once a frame.
|
||||
/// Searching the form for each row, and again inside the sort, was O(fields²) every frame.
|
||||
fn field_rows<'a>(fields: &'a [pdfcraft_render::Field], form: &[pdfcraft_engine::FormField]) -> Vec<(Option<usize>, FieldRows<'a>)> {
|
||||
let mut by_name: std::collections::HashMap<&str, &pdfcraft_engine::FormField> = std::collections::HashMap::with_capacity(form.len());
|
||||
for field in form {
|
||||
by_name.entry(field.name.as_str()).or_insert(field);
|
||||
}
|
||||
let rank = |name: &str| by_name.get(name).and_then(|f| f.widgets.iter().map(|w| w.tab).min()).unwrap_or(usize::MAX);
|
||||
let mut ordered: Vec<&pdfcraft_render::Field> = fields.iter().collect();
|
||||
ordered.sort_by_cached_key(|f| (f.page, rank(&f.name)));
|
||||
let mut rows: Vec<(Option<usize>, FieldRows<'a>)> = Vec::new();
|
||||
for f in ordered {
|
||||
let key =
|
||||
by_name.get(f.name.as_str()).and_then(|field| field.widgets.iter().position(|w| w.page == f.page).map(|wi| (field.name.clone(), wi)));
|
||||
match rows.last_mut() {
|
||||
Some((page, page_rows)) if *page == f.page => page_rows.push((f, key)),
|
||||
_ => rows.push((f.page, vec![(f, key)])),
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod field_rows {
|
||||
use super::field_rows;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A PDF of `objects` (numbered from 1; object 1 the catalog) with a valid xref table.
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
|
||||
for o in offsets {
|
||||
out.extend(format!("{o:010} 00000 n \n").bytes());
|
||||
}
|
||||
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// The fields the engine reads from `bytes`: the inspector's list and the form.
|
||||
fn open(bytes: Vec<u8>) -> (Vec<pdfcraft_render::Field>, Arc<Vec<pdfcraft_engine::FormField>>) {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open("form.pdf", None, Arc::new(bytes), None).expect("opens");
|
||||
let doc = session.get(id).expect("open");
|
||||
(doc.info.fields.clone(), doc.form.clone())
|
||||
}
|
||||
|
||||
/// Rows go by page, the unplaced first, each page's in tab order (here its annotation order,
|
||||
/// not the `/Fields` order), and a row selects the first form field of its name.
|
||||
#[test]
|
||||
fn rows_follow_pages_and_tab_order() {
|
||||
let widget = |name: &str, page: Option<usize>| match page {
|
||||
Some(p) => format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [10 10 90 30] /P {} 0 R >>", 3 + p),
|
||||
None => format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [10 10 90 30] >>"),
|
||||
};
|
||||
let bytes = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 6 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R 5 0 R] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [7 0 R 8 0 R 9 0 R] >>".into(),
|
||||
widget("a1", Some(0)),
|
||||
widget("a2", Some(0)),
|
||||
widget("b", Some(1)),
|
||||
widget("twin", Some(1)),
|
||||
widget("twin", Some(1)),
|
||||
widget("loose", None),
|
||||
]);
|
||||
let (fields, form) = open(bytes);
|
||||
// A page's rows as (field name, the form field and widget a click selects).
|
||||
type Named<'a> = Vec<(&'a str, Option<(String, usize)>)>;
|
||||
let rows: Vec<(Option<usize>, Named)> = field_rows(&fields, &form)
|
||||
.into_iter()
|
||||
.map(|(page, rows)| (page, rows.into_iter().map(|(f, key)| (f.name.as_str(), key)).collect()))
|
||||
.collect();
|
||||
let key = |name: &str| Some((name.to_string(), 0));
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
(None, vec![("loose", key("loose"))]),
|
||||
(Some(0), vec![("a2", key("a2")), ("a1", key("a1"))]),
|
||||
(Some(1), vec![("b", key("b")), ("twin", key("twin")), ("twin", key("twin"))]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The panel's rows for a large form take milliseconds: looking each row's field up in the
|
||||
/// form, there and inside the sort, was O(fields²) every frame.
|
||||
#[test]
|
||||
fn rows_for_a_large_form_are_quick() {
|
||||
const PAGES: usize = 150;
|
||||
const PER_PAGE: usize = 100;
|
||||
let mut objects = vec![String::new(), String::new()];
|
||||
let page = |p: usize| 3 + p;
|
||||
let field = |p: usize, k: usize| 3 + PAGES + p * PER_PAGE + k;
|
||||
let mut all = Vec::new();
|
||||
for p in 0..PAGES {
|
||||
let annots: Vec<String> = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect();
|
||||
all.extend(annots.iter().cloned());
|
||||
objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >>", annots.join(" ")));
|
||||
}
|
||||
for p in 0..PAGES {
|
||||
for k in 0..PER_PAGE {
|
||||
objects.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (field{p}_{k}) /Rect [10 10 90 30] /P {} 0 R >>", page(p)));
|
||||
}
|
||||
}
|
||||
// `/Fields` in no particular order, so the rows must be sorted.
|
||||
let mut seed = 12345u64;
|
||||
for i in (1..all.len()).rev() {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
all.swap(i, (seed >> 33) as usize % (i + 1));
|
||||
}
|
||||
objects[0] = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{}] >> >>", all.join(" "));
|
||||
let kids: Vec<String> = (0..PAGES).map(|p| format!("{} 0 R", page(p))).collect();
|
||||
objects[1] = format!("<< /Type /Pages /Kids [{}] /Count {PAGES} >>", kids.join(" "));
|
||||
let (fields, form) = open(pdf(&objects));
|
||||
assert_eq!(fields.len(), PAGES * PER_PAGE);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let rows = field_rows(&fields, &form);
|
||||
let _ = tx.send((rows.len(), rows.iter().map(|(_, r)| r.len()).sum::<usize>()));
|
||||
});
|
||||
let counted = rx.recv_timeout(std::time::Duration::from_secs(3)).expect("the Fields panel's rows must not take seconds a frame");
|
||||
assert_eq!(counted, (PAGES, PAGES * PER_PAGE));
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,14 @@ use pdfcraft_ui_egui::PdfCraftApp;
|
||||
/// One page with layers Red (on) and Green (off), a radio-button group, each drawing a band;
|
||||
/// a "show-red" push button turns Red on and a link toggles Green.
|
||||
fn fixture() -> Vec<u8> {
|
||||
fixture_with("")
|
||||
}
|
||||
|
||||
/// [`fixture`] with `order` added to the default configuration.
|
||||
fn fixture_with(order: &str) -> Vec<u8> {
|
||||
let content = "/OC /R BDC 1 0 0 rg 20 300 260 40 re f EMC /OC /G BDC 0 1 0 rg 20 240 260 40 re f EMC";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>".to_string(),
|
||||
format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] {order} >> >> >>"),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> /Annots [7 0 R 8 0 R] >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
@@ -36,10 +41,14 @@ fn fixture() -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
|
||||
harness_with(fixture(), setup)
|
||||
}
|
||||
|
||||
fn harness_with(pdf: Vec<u8>, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("layers.pdf", None, fixture()).unwrap();
|
||||
app.open_bytes("layers.pdf", None, pdf).unwrap();
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
setup(&mut app);
|
||||
app
|
||||
@@ -124,3 +133,28 @@ fn layer_options_reset_to_initial_visibility() {
|
||||
assert!(!doc.dirty, "showing and hiding layers doesn't change the document");
|
||||
assert_eq!(doc.can_undo(), None, "nor is it an edit to undo");
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 99 `/Order`: the Layers panel shows the tree it gives, a leading text
|
||||
/// string as a label, and not the groups it leaves out, which still draw in their state. Without
|
||||
/// `/Order` it presents none.
|
||||
#[test]
|
||||
fn the_layers_panel_shows_the_order_tree() {
|
||||
let mut h = harness_with(fixture_with("/Order [[(Colours) 5 0 R]]"), |app| app.set_option("panel", "layers").unwrap());
|
||||
h.run_steps(2);
|
||||
let label = h.get_by_label("Colours").rect();
|
||||
let red = h.get_by_label("Red").rect();
|
||||
assert!(red.min.y > label.max.y - 1.0, "Red is listed under its label");
|
||||
assert!(h.query_by_label("Green").is_none(), "Green isn't in /Order, so it isn't presented");
|
||||
assert_eq!(shown(&h), layers(true, false), "but it is still a layer, in its state");
|
||||
// Clicking the label changes nothing; clicking Red hides it.
|
||||
h.get_by_label("Colours").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(shown(&h), layers(true, false));
|
||||
h.get_by_label("Red").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(shown(&h), layers(false, false));
|
||||
|
||||
let h = harness(|app| app.set_option("panel", "layers").unwrap());
|
||||
assert!(h.query_by_label("Red").is_none() && h.query_by_label("Green").is_none(), "no /Order: none presented");
|
||||
h.get_by_label("This document lists no layers to show.");
|
||||
}
|
||||
|
||||
@@ -1334,8 +1334,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_info", "layer_set"]
|
||||
evidence = ["crates/render/src/raster/tests.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws", "crates/render/src/raster/tests.rs::the_view_auto_state_sets_a_groups_state_on_screen", "crates/render/src/raster/tests.rs::layer_toggles_and_print_renders_are_not_the_view_auto_state", "crates/render/src/raster/tests.rs::malformed_view_auto_state_is_ignored"]
|
||||
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). The default configuration's auto state for the View event (/AS, ISO 32000-2 8.11.4.4) sets the groups it manages from their /Usage /View /ViewState as the document opens, on screen and in the panel, and toggles still override it; renders for printing don't apply it. Not yet: the /Zoom, /User and /Language categories, and the Print event's auto state when printing."
|
||||
evidence = ["crates/render/src/raster/tests.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws", "crates/render/src/raster/tests.rs::the_view_auto_state_sets_a_groups_state_on_screen", "crates/render/src/raster/tests.rs::layer_toggles_and_print_renders_are_not_the_view_auto_state", "crates/render/src/raster/tests.rs::malformed_view_auto_state_is_ignored", "crates/render/src/inspect/tests.rs::the_layers_panel_presents_the_order_tree", "crates/ui-egui/tests/it/layer_actions.rs::the_layers_panel_shows_the_order_tree"]
|
||||
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). The default configuration's auto state for the View event (/AS, ISO 32000-2 8.11.4.4) sets the groups it manages from their /Usage /View /ViewState as the document opens, on screen and in the panel, and toggles still override it; renders for printing don't apply it. Not yet: the /Zoom, /User and /Language categories, and the Print event's auto state when printing. The panel presents the default configuration's /Order (ISO 32000-2 Table 99) as a tree: a nested array one level deeper, a leading text string as a label that can't be toggled. Groups /Order leaves out aren't listed but still draw in their state, and a document without /Order lists none, the default Table 99 gives; doc_info reports the rows as layer_order. Not yet: /ListMode /VisiblePages."
|
||||
|
||||
[[feature]]
|
||||
id = "view.layers-reset"
|
||||
|
||||
Reference in New Issue
Block a user