pdfcraft/crates/annot/src/lib.rs

1766 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! pdfcraft-annot — comments (annotations), ISO 32000-2 §12.5, execution plan M5.1–M5.3.
//!
//! Builders for the comment types Acrobat's commenting tools create (sticky note, highlight,
//! underline, strikethrough, squiggly, rectangle, oval, line/arrow, freehand ink, text box),
//! appearance streams for them ([`appearance`]), and the edits a comment goes through: reply,
//! change its text, recolour, move, resize and delete.
//!
//! Addressing: a comment is `(page, index)`, its position in the page's `/Annots` array, which
//! is what `pdfcraft_render::Annotation::index` reports. Inline annotation dictionaries are
//! promoted to indirect objects when they are edited (replies need a reference to point at).
//!
//! Every edit mutates a `pdfcraft_cos::Document` (copy-on-write); callers snapshot it first
//! for undo. Keys we do not understand are left alone.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
pub mod appearance;
pub mod links;
#[cfg(test)]
mod tests;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum AnnotError {
#[error("the document has no page tree")]
NoPageTree,
#[error("page {0} does not exist")]
NoSuchPage(usize),
#[error("there is no comment {index} on page {page}")]
NoSuchAnnotation { page: usize, index: usize },
#[error("{0}")]
Invalid(String),
#[error("{0} comments can't be restyled yet (their appearance can't be regenerated)")]
Unsupported(String),
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
pub type Rgb = [f64; 3];
/// Text markup kinds (§12.5.6.10).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Markup {
Highlight,
Underline,
StrikeOut,
Squiggly,
}
impl Markup {
fn subtype(self) -> &'static str {
match self {
Markup::Highlight => "Highlight",
Markup::Underline => "Underline",
Markup::StrikeOut => "StrikeOut",
Markup::Squiggly => "Squiggly",
}
}
}
/// The standard text-annotation icon names (§12.5.6.4, Table 175).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum NoteIcon {
#[default]
Comment,
Note,
Help,
Insert,
Key,
NewParagraph,
Paragraph,
}
impl NoteIcon {
pub fn name(self) -> &'static str {
match self {
NoteIcon::Comment => "Comment",
NoteIcon::Note => "Note",
NoteIcon::Help => "Help",
NoteIcon::Insert => "Insert",
NoteIcon::Key => "Key",
NoteIcon::NewParagraph => "NewParagraph",
NoteIcon::Paragraph => "Paragraph",
}
}
pub fn from_name(n: &str) -> Option<Self> {
[Self::Comment, Self::Note, Self::Help, Self::Insert, Self::Key, Self::NewParagraph, Self::Paragraph].into_iter().find(|i| i.name() == n)
}
}
/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FillMark {
Check,
Cross,
Dot,
Line,
}
impl FillMark {
/// The `/Name` of the stamp PdfCraft draws for it.
pub fn name(self) -> &'static str {
match self {
FillMark::Check => "PCCheck",
FillMark::Cross => "PCCross",
FillMark::Dot => "PCDot",
FillMark::Line => "PCLine",
}
}
pub fn label(self) -> &'static str {
match self {
FillMark::Check => "Checkmark",
FillMark::Cross => "Cross",
FillMark::Dot => "Dot",
FillMark::Line => "Line",
}
}
}
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum StampKind {
// Standard business.
Approved,
Completed,
Confidential,
Draft,
Final,
ForComment,
ForPublicRelease,
InformationOnly,
NotApproved,
NotForPublicRelease,
PreliminaryResults,
Void,
// Sign here.
Accepted,
InitialHere,
Rejected,
SignHere,
Witness,
// Dynamic (with a "By … at …" line).
DynApproved,
DynConfidential,
DynReceived,
DynReviewed,
DynRevised,
}
/// The palette's sections.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StampGroup {
Dynamic,
SignHere,
StandardBusiness,
}
impl StampKind {
pub const ALL: [StampKind; 22] = [
StampKind::DynApproved,
StampKind::DynConfidential,
StampKind::DynReceived,
StampKind::DynReviewed,
StampKind::DynRevised,
StampKind::Accepted,
StampKind::InitialHere,
StampKind::Rejected,
StampKind::SignHere,
StampKind::Witness,
StampKind::Approved,
StampKind::Completed,
StampKind::Confidential,
StampKind::Draft,
StampKind::Final,
StampKind::ForComment,
StampKind::ForPublicRelease,
StampKind::InformationOnly,
StampKind::NotApproved,
StampKind::NotForPublicRelease,
StampKind::PreliminaryResults,
StampKind::Void,
];
pub fn group(self) -> StampGroup {
use StampKind::*;
match self {
DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic,
Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere,
_ => StampGroup::StandardBusiness,
}
}
/// The text on the stamp.
pub fn label(self) -> &'static str {
use StampKind::*;
match self {
Approved | DynApproved => "APPROVED",
Completed => "COMPLETED",
Confidential | DynConfidential => "CONFIDENTIAL",
Draft => "DRAFT",
Final => "FINAL",
ForComment => "FOR COMMENT",
ForPublicRelease => "FOR PUBLIC RELEASE",
InformationOnly => "INFORMATION ONLY",
NotApproved => "NOT APPROVED",
NotForPublicRelease => "NOT FOR PUBLIC RELEASE",
PreliminaryResults => "PRELIMINARY RESULTS",
Void => "VOID",
Accepted => "ACCEPTED",
InitialHere => "INITIAL HERE",
Rejected => "REJECTED",
SignHere => "SIGN HERE",
Witness => "WITNESS",
DynReceived => "RECEIVED",
DynReviewed => "REVIEWED",
DynRevised => "REVISED",
}
}
/// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists.
pub fn name(self) -> &'static str {
use StampKind::*;
match self {
Approved => "Approved",
Confidential => "Confidential",
Draft => "Draft",
Final => "Final",
ForComment => "ForComment",
ForPublicRelease => "ForPublicRelease",
NotApproved => "NotApproved",
NotForPublicRelease => "NotForPublicRelease",
Completed => "PCCompleted",
InformationOnly => "PCInformationOnly",
PreliminaryResults => "PCPreliminaryResults",
Void => "PCVoid",
Accepted => "PCAccepted",
InitialHere => "PCInitialHere",
Rejected => "PCRejected",
SignHere => "PCSignHere",
Witness => "PCWitness",
DynApproved => "PCDynApproved",
DynConfidential => "PCDynConfidential",
DynReceived => "PCDynReceived",
DynReviewed => "PCDynReviewed",
DynRevised => "PCDynRevised",
}
}
pub fn from_name(n: &[u8]) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.name().as_bytes() == n)
}
/// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise.
pub fn color(self) -> Rgb {
use StampKind::*;
match self {
Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13],
NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10],
SignHere | InitialHere | Witness => [0.85, 0.35, 0.05],
_ => [0.10, 0.30, 0.70],
}
}
/// The stamp's size (points) for its label, as placed with a click.
pub fn size(self) -> (f64, f64) {
let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0;
let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 };
let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w };
(w.max(80.0), h)
}
}
/// Geometry of a new comment, in PDF user space of its page.
#[derive(Clone, Debug, PartialEq)]
pub enum Shape {
/// A sticky note whose icon's top-left corner is at `at`.
Note {
at: [f64; 2],
icon: NoteIcon,
},
/// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right,
/// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10).
TextMarkup {
kind: Markup,
quads: Vec<[f64; 8]>,
},
Rectangle {
rect: [f64; 4],
},
Oval {
rect: [f64; 4],
},
/// A line, with an open arrowhead at `to` when `arrow` is set.
Line {
from: [f64; 2],
to: [f64; 2],
arrow: bool,
},
/// Freehand strokes (Draw tool).
Ink {
strokes: Vec<Vec<[f64; 2]>>,
},
/// A text box (FreeText) showing the comment's contents.
TextBox {
rect: [f64; 4],
font_size: f64,
},
/// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border).
Typewriter {
rect: [f64; 4],
font_size: f64,
},
/// A Fill & Sign mark in `rect`.
Mark {
rect: [f64; 4],
mark: FillMark,
},
/// A drawn signature (ink strokes).
Signature {
strokes: Vec<Vec<[f64; 2]>>,
},
/// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line.
Stamp {
rect: [f64; 4],
stamp: StampKind,
by: Option<String>,
},
/// A typed signature or initials: filled outlines (each contour as points normalised to
/// `rect`, 0–1, y up), drawn with the even-odd rule.
TypedSignature {
rect: [f64; 4],
contours: Vec<Vec<[f64; 2]>>,
},
/// A custom stamp: a picture already in the document (an image XObject, or a form XObject
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`.
CustomStamp {
rect: [f64; 4],
name: String,
picture: ObjRef,
image: bool,
size: (f64, f64),
},
/// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad
/// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`.
Redact {
quads: Vec<[f64; 8]>,
overlay: String,
look: OverlayLook,
},
/// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon
/// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`).
Polygon {
vertices: Vec<[f64; 2]>,
cloud: bool,
},
/// Connected lines (Polygonal Line tool).
PolyLine {
vertices: Vec<[f64; 2]>,
},
/// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via
/// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`).
Callout {
rect: [f64; 4],
knee: [f64; 2],
point: [f64; 2],
font_size: f64,
},
/// Insert text: a caret in `rect` (its point at the top centre).
Caret {
rect: [f64; 4],
},
/// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data`
/// (embedded) under the name `file`.
Attachment {
at: [f64; 2],
icon: AttachIcon,
file: String,
data: Vec<u8>,
},
}
/// The standard file attachment icons (§12.5.6.15).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AttachIcon {
#[default]
PushPin,
Paperclip,
Graph,
Tag,
}
impl AttachIcon {
pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag];
pub fn name(self) -> &'static str {
match self {
AttachIcon::PushPin => "PushPin",
AttachIcon::Paperclip => "Paperclip",
AttachIcon::Graph => "Graph",
AttachIcon::Tag => "Tag",
}
}
pub fn from_name(n: &str) -> Option<AttachIcon> {
Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n))
}
}
/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left,
/// bottom-right).
pub fn rect_quad(r: [f64; 4]) -> [f64; 8] {
let [x0, y0, x1, y1] = normalize(r);
[x0, y1, x1, y1, x0, y0, x1, y0]
}
impl Shape {
pub fn subtype(&self) -> &'static str {
match self {
Shape::Note { .. } => "Text",
Shape::TextMarkup { kind, .. } => kind.subtype(),
Shape::Rectangle { .. } => "Square",
Shape::Oval { .. } => "Circle",
Shape::Line { .. } => "Line",
Shape::Ink { .. } | Shape::Signature { .. } => "Ink",
Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText",
Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp",
Shape::Redact { .. } => "Redact",
Shape::Polygon { .. } => "Polygon",
Shape::PolyLine { .. } => "PolyLine",
Shape::Caret { .. } => "Caret",
Shape::Attachment { .. } => "FileAttachment",
}
}
}
/// The standard fonts of redaction overlay text.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OverlayFont {
#[default]
Helvetica,
Times,
Courier,
}
impl OverlayFont {
pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier];
pub fn name(self) -> &'static str {
match self {
OverlayFont::Helvetica => "Helvetica",
OverlayFont::Times => "Times Roman",
OverlayFont::Courier => "Courier",
}
}
/// The `/DA` font resource name (Acrobat's form font names).
pub fn resource(self) -> &'static str {
match self {
OverlayFont::Helvetica => "Helv",
OverlayFont::Times => "TiRo",
OverlayFont::Courier => "Cour",
}
}
pub fn from_resource(n: &str) -> OverlayFont {
match n {
"TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times,
"Cour" | "Courier" => OverlayFont::Courier,
_ => OverlayFont::Helvetica,
}
}
}
/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OverlayLook {
pub font: OverlayFont,
/// Points; 0 = auto-size to fit the area.
pub size: f64,
pub color: Rgb,
/// 0 left, 1 centre, 2 right.
pub align: u8,
/// Repeat the text to fill the area.
pub repeat: bool,
}
impl Default for OverlayLook {
fn default() -> Self {
Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false }
}
}
/// How a comment looks.
#[derive(Clone, Debug, PartialEq)]
pub struct Style {
/// Stroke colour (text colour for text boxes, icon colour for notes).
pub color: Rgb,
/// 0–1 (`/CA`).
pub opacity: f64,
/// Border / stroke width in points (0 = none).
pub width: f64,
/// Interior fill for rectangles and ovals (`/IC`), background for text boxes.
pub fill: Option<Rgb>,
}
impl Default for Style {
fn default() -> Self {
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
}
}
impl Style {
/// The default look of each commenting tool.
pub fn default_for(shape: &Shape) -> Self {
let (color, width) = match shape {
Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0),
Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0),
Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0),
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0),
Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0),
Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => {
([0.89, 0.13, 0.13], 2.0)
}
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5),
Shape::Stamp { stamp, .. } => (stamp.color(), 2.0),
Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0),
Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0),
// Red outline while marked; a black box once applied.
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) },
};
Self { color, opacity: 1.0, width, fill: None }
}
}
/// A comment to add.
#[derive(Clone, Debug, PartialEq)]
pub struct NewAnnotation {
/// 0-based page index.
pub page: usize,
pub shape: Shape,
pub style: Style,
/// The comment text (`/Contents`); the text shown by a text box.
pub contents: String,
/// `/T` (shown as the comment's author).
pub author: String,
}
/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay
/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Meta {
pub date: Option<String>,
pub id: String,
}
/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status").
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReviewState {
None,
Accepted,
Rejected,
Cancelled,
Completed,
}
impl ReviewState {
pub fn name(self) -> &'static str {
match self {
ReviewState::None => "None",
ReviewState::Accepted => "Accepted",
ReviewState::Rejected => "Rejected",
ReviewState::Cancelled => "Cancelled",
ReviewState::Completed => "Completed",
}
}
pub fn from_name(n: &str) -> Option<Self> {
[Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n))
}
}
// ── pages and /Annots ───────────────────────────────────────────────────────────────────────
/// The page object of each leaf page, in order.
pub fn page_refs(doc: &Document) -> Result<Vec<ObjRef>, AnnotError> {
let root = doc.root().ok_or(AnnotError::NoPageTree)?;
let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut stack = vec![pages];
while let Some(node) = stack.pop() {
if !seen.insert(node) || seen.len() > 1_000_000 {
continue;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { continue };
match d.get(b"Kids").map(|k| doc.resolve(k)) {
Some(kids) if d.name(b"Type") != Some(b"Page") => {
if let Some(a) = kids.as_array() {
stack.extend(a.iter().rev().filter_map(|k| k.as_ref()));
}
}
_ => out.push(node),
}
}
Ok(out)
}
fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
page_refs(doc)?.get(page).copied().ok_or(AnnotError::NoSuchPage(page))
}
/// The entries of a page's `/Annots` (references or inline dictionaries).
fn annots(doc: &Document, page: ObjRef) -> Vec<Object> {
let obj = doc.get(page);
let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() };
doc.resolve(a).as_array().cloned().unwrap_or_default()
}
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect.
fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result<(), AnnotError> {
let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots"));
match existing {
Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)),
_ => doc.update_dict(page, |d| {
if list.is_empty() {
d.remove(b"Annots");
} else {
d.set(b"Annots".to_vec(), Object::Array(list));
}
})?,
}
Ok(())
}
/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
let p = page_ref(doc, page)?;
let mut list = annots(doc, p);
let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let r = match entry {
Object::Ref(r) if doc.get(r).as_dict().is_some() => r,
Object::Dict(d) => {
let r = doc.add(Object::Dict(d));
list[index] = Object::Ref(r);
set_annots(doc, p, list)?;
r
}
_ => return Err(AnnotError::NoSuchAnnotation { page, index }),
};
Ok((p, r))
}
fn annot_dict(doc: &Document, r: ObjRef) -> Dict {
doc.get(r).as_dict().cloned().unwrap_or_default()
}
// ── building ────────────────────────────────────────────────────────────────────────────────
/// Annotation flags (§12.5.3).
const FLAG_PRINT: i64 = 4;
const FLAG_NO_ZOOM: i64 = 8;
const FLAG_NO_ROTATE: i64 = 16;
const FLAG_HIDDEN: i64 = 2;
const FLAG_LOCKED: i64 = 128;
/// Size of a note icon (points, unscaled by zoom).
pub const NOTE_SIZE: f64 = 20.0;
fn num_array(v: &[f64]) -> Object {
Object::Array(v.iter().map(|x| Object::Real(*x)).collect())
}
fn rgb(c: Rgb) -> Object {
num_array(&c.map(|x| x.clamp(0.0, 1.0)))
}
fn finite(v: &[f64]) -> bool {
v.iter().all(|x| x.is_finite() && x.abs() < 1e7)
}
fn normalize(r: [f64; 4]) -> [f64; 4] {
[r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]
}
fn bounds(points: impl Iterator<Item = [f64; 2]>) -> Option<[f64; 4]> {
points.fold(None, |acc, [x, y]| match acc {
None => Some([x, y, x, y]),
Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]),
})
}
fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
}
/// Validate and compute `/Rect` for a new comment.
fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}"));
let half = style.width.max(0.0) / 2.0;
let r = match shape {
Shape::Note { at, .. } | Shape::Attachment { at, .. } => {
if !finite(at) {
return Err(bad("position"));
}
[at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]]
}
Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => {
if quads.is_empty() || !quads.iter().all(|q| finite(q)) {
return Err(bad("text area (no quadrilaterals)"));
}
bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::<Vec<_>>()))
.ok_or_else(|| bad("text area"))?
}
Shape::Rectangle { rect }
| Shape::Oval { rect }
| Shape::TextBox { rect, .. }
| Shape::Typewriter { rect, .. }
| Shape::Stamp { rect, .. }
| Shape::CustomStamp { rect, .. }
| Shape::TypedSignature { rect, .. }
| Shape::Mark { rect, .. } => {
let r = normalize(*rect);
if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
return Err(bad("rectangle (too small)"));
}
r
}
Shape::Line { from, to, arrow } => {
if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 {
return Err(bad("line (too short)"));
}
let pad = half + if *arrow { appearance::arrow_size(style.width) } else { 0.0 };
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
}
Shape::Ink { strokes } | Shape::Signature { strokes } => {
if strokes.iter().all(|s| s.is_empty()) || !strokes.iter().flatten().all(|p| finite(p)) {
return Err(bad("drawing (no points)"));
}
grow(bounds(strokes.iter().flatten().copied()).unwrap_or_default(), half + 1.0)
}
Shape::Polygon { vertices, cloud } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?;
grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 })
}
Shape::PolyLine { vertices } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0);
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0)
}
Shape::Callout { rect, knee, point, .. } => {
let r = normalize(*rect);
if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
return Err(bad("callout"));
}
let pad = half + appearance::arrow_size(style.width) + 1.0;
let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad);
[r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])]
}
Shape::Caret { rect } => {
let r = normalize(*rect);
if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
return Err(bad("caret"));
}
r
}
};
Ok(r)
}
fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name(subtype));
d.set(b"Rect".to_vec(), num_array(&rect));
d.set(b"Contents".to_vec(), PdfString::text(contents));
if !author.is_empty() {
d.set(b"T".to_vec(), PdfString::text(author));
}
if let Some(date) = &meta.date {
d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
}
if !meta.id.is_empty() {
d.set(b"NM".to_vec(), PdfString::text(&meta.id));
}
d.set(b"P".to_vec(), Object::Ref(page));
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT));
d
}
/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers).
fn subject(shape: &Shape) -> &'static str {
match shape {
Shape::Note { .. } => "Sticky Note",
Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight",
Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline",
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough",
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly",
Shape::Rectangle { .. } => "Rectangle",
Shape::Oval { .. } => "Oval",
Shape::Line { arrow: true, .. } => "Arrow",
Shape::Line { .. } => "Line",
Shape::Ink { .. } => "Pencil",
Shape::TextBox { .. } => "Text Box",
Shape::Typewriter { .. } => "Typewriter",
Shape::Mark { mark, .. } => mark.label(),
Shape::Signature { .. } => "Signature",
Shape::Redact { .. } => "Redact",
Shape::Stamp { stamp, .. } => match stamp.group() {
StampGroup::Dynamic => "Dynamic stamp",
StampGroup::SignHere => "Sign Here",
StampGroup::StandardBusiness => "Stamp",
},
Shape::CustomStamp { .. } => "Stamp",
Shape::TypedSignature { .. } => "Signature",
Shape::Polygon { cloud: true, .. } => "Cloud",
Shape::Polygon { .. } => "Polygon",
Shape::PolyLine { .. } => "Polygonal Line",
Shape::Callout { .. } => "Callout",
Shape::Caret { .. } => "Inserted Text",
Shape::Attachment { .. } => "File Attachment",
}
}
/// Where a callout's leader line meets its text box: the middle of the side facing `knee`.
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
if knee[0] < x0 {
[x0, cy]
} else if knee[0] > x1 {
[x1, cy]
} else if knee[1] > y1 {
[cx, y1]
} else {
[cx, y0]
}
}
/// Add a comment; returns its index in the page's `/Annots`.
pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result<usize, AnnotError> {
let page = page_ref(doc, new.page)?;
let style = &new.style;
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
return Err(AnnotError::Invalid("invalid style".into()));
}
let rect = rect_for(&new.shape, style)?;
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
let opacity = style.opacity.clamp(0.0, 1.0);
if opacity < 1.0 {
d.set(b"CA".to_vec(), Object::Real(opacity));
}
let border = |d: &mut Dict| {
let mut bs = Dict::new();
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
bs.set(b"S".to_vec(), Object::name("S"));
d.set(b"BS".to_vec(), Object::Dict(bs));
};
let mut popup = None;
match &new.shape {
Shape::Note { icon, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Name".to_vec(), Object::name(icon.name()));
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
d.set(b"Open".to_vec(), Object::Bool(false));
popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]);
}
Shape::TextMarkup { quads, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
}
Shape::Redact { quads, overlay, look } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
if !overlay.is_empty() {
d.set(b"OverlayText".to_vec(), PdfString::text(overlay));
let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
d.set(
b"DA".to_vec(),
PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()),
);
d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
if look.repeat {
d.set(b"Repeat".to_vec(), Object::Bool(true));
}
}
}
Shape::Rectangle { .. } | Shape::Oval { .. } => {
d.set(b"C".to_vec(), rgb(style.color));
if let Some(f) = style.fill {
d.set(b"IC".to_vec(), rgb(f));
}
border(&mut d);
}
Shape::Line { from, to, arrow } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]]));
if *arrow {
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name("OpenArrow")]));
}
border(&mut d);
}
Shape::Mark { mark, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Name".to_vec(), Object::name(mark.name()));
border(&mut d);
}
Shape::Stamp { stamp, by, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Name".to_vec(), Object::name(stamp.name()));
// Marks the stamp as drawn by PdfCraft: other stamps with standard names keep
// their own artwork.
d.set(b"PCStamp".to_vec(), Object::Bool(true));
if let Some(b) = by {
d.set(b"PCByLine".to_vec(), PdfString::text(b));
}
}
Shape::TypedSignature { contours, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Name".to_vec(), Object::name("PCTypedSignature"));
let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::<Vec<_>>())).collect();
d.set(b"PCOutline".to_vec(), Object::Array(list));
}
Shape::CustomStamp { name, picture, image, size, .. } => {
let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1]));
}
Shape::Ink { strokes } | Shape::Signature { strokes } => {
d.set(b"C".to_vec(), rgb(style.color));
let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect();
d.set(b"InkList".to_vec(), Object::Array(list));
border(&mut d);
}
Shape::Polygon { vertices, cloud } => {
d.set(b"C".to_vec(), rgb(style.color));
if let Some(f) = style.fill {
d.set(b"IC".to_vec(), rgb(f));
}
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
if *cloud {
d.set(b"IT".to_vec(), Object::name("PolygonCloud"));
let mut be = Dict::new();
be.set(b"S".to_vec(), Object::name("C"));
be.set(b"I".to_vec(), Object::Int(1));
d.set(b"BE".to_vec(), Object::Dict(be));
}
border(&mut d);
}
Shape::PolyLine { vertices } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
border(&mut d);
}
Shape::Caret { .. } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Sy".to_vec(), Object::name("None"));
}
Shape::Attachment { icon, file, data, .. } => {
if file.trim().is_empty() {
return Err(AnnotError::Invalid("the attached file needs a name".into()));
}
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Name".to_vec(), Object::name(icon.name()));
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
// The file: an embedded file stream inside a file specification (§7.11.4).
let mut params = Dict::new();
params.set(b"Size".to_vec(), Object::Int(data.len() as i64));
if let Some(date) = &meta.date {
params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
}
let mut ef = Dict::new();
ef.set(b"Type".to_vec(), Object::name("EmbeddedFile"));
ef.set(b"Params".to_vec(), Object::Dict(params));
let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data)));
let mut efd = Dict::new();
efd.set(b"F".to_vec(), Object::Ref(ef));
efd.set(b"UF".to_vec(), Object::Ref(ef));
let mut fs = Dict::new();
fs.set(b"Type".to_vec(), Object::name("Filespec"));
fs.set(b"F".to_vec(), PdfString::text(file.trim()));
fs.set(b"UF".to_vec(), PdfString::text(file.trim()));
fs.set(b"EF".to_vec(), Object::Dict(efd));
d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs))));
if new.contents.is_empty() {
d.set(b"Contents".to_vec(), PdfString::text(file.trim()));
}
}
Shape::Callout { rect: tb, knee, point, .. } => {
let tb = normalize(*tb);
let attach = callout_attach(tb, *knee);
d.set(b"IT".to_vec(), Object::name("FreeTextCallout"));
d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]]));
d.set(b"LE".to_vec(), Object::name("OpenArrow"));
// The text box inside /Rect (§12.5.6.6 /RD).
d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]]));
}
_ => {}
}
match &new.shape {
Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => {
if matches!(new.shape, Shape::Typewriter { .. }) {
d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter"));
}
let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 };
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
d.set(b"Q".to_vec(), Object::Int(0));
if let Some(f) = style.fill {
d.set(b"C".to_vec(), rgb(f));
}
border(&mut d);
rich_text(&mut d);
}
_ => {}
}
let r = doc.add(Object::Dict(d.clone()));
set_appearance(doc, r)?;
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
if let Some(pr) = popup {
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Annot"));
p.set(b"Subtype".to_vec(), Object::name("Popup"));
p.set(b"Rect".to_vec(), num_array(&pr));
p.set(b"Parent".to_vec(), Object::Ref(r));
p.set(b"Open".to_vec(), Object::Bool(false));
p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
let pref = doc.add(Object::Dict(p));
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
list.push(Object::Ref(pref));
}
set_annots(doc, page, list)?;
Ok(index)
}
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary.
/// Regenerate an annotation's normal appearance from its dictionary.
pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let Some(stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
let ap = doc.add(Object::Stream(stream));
let mut apd = Dict::new();
apd.set(b"N".to_vec(), Object::Ref(ap));
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
d.remove(b"AS");
})?;
Ok(())
}
/// Format a number for content streams and DA strings.
pub(crate) fn n(v: f64) -> String {
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
// ── edits ───────────────────────────────────────────────────────────────────────────────────
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing.
fn rich_text(d: &mut Dict) {
let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default();
let (col, size) = appearance::parse_da(d);
let hex = format!(
"#{:02X}{:02X}{:02X}",
(col[0].clamp(0.0, 1.0) * 255.0).round() as u8,
(col[1].clamp(0.0, 1.0) * 255.0).round() as u8,
(col[2].clamp(0.0, 1.0) * 255.0).round() as u8
);
let align = match d.get(b"Q").and_then(Object::as_int) {
Some(1) => "center",
Some(2) => "right",
_ => "left",
};
let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;");
let body: String = text.split('\n').map(|line| format!("<p dir=\"ltr\">{}</p>", esc(line.trim_end_matches('\r')))).collect();
let style = format!(
"font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal",
n(size)
);
let rc = format!(
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:spec=\"2.0.2\" style=\"{style}\">{body}</body>"
);
d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size))));
d.set(b"RC".to_vec(), PdfString::text(&rc));
}
fn touch(d: &mut Dict, meta: &Meta) {
if let Some(date) = &meta.date {
d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
}
}
/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does.
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
if let Some(r) = target.as_ref() {
unlocked(doc, r)?;
}
let mut doomed: Vec<ObjRef> = target.as_ref().into_iter().collect();
// Replies (`/IRT`) and pop-ups (`/Parent`) of anything doomed, to a fixpoint.
loop {
let before = doomed.len();
for e in &list {
let Some(r) = e.as_ref() else { continue };
if doomed.contains(&r) {
continue;
}
let obj = doc.get(r);
let Some(d) = obj.as_dict() else { continue };
let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| doomed.contains(&t));
if points_at(b"IRT") || points_at(b"Parent") {
doomed.push(r);
}
}
for r in doomed.clone() {
if let Some(pop) = doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))
&& !doomed.contains(&pop)
{
doomed.push(pop);
}
}
if doomed.len() == before {
break;
}
}
let kept: Vec<Object> =
list.into_iter().enumerate().filter(|(i, e)| *i != index && !e.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(_, e)| e).collect();
set_annots(doc, p, kept)
}
/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the
/// new text: the wrap width and top edge stay, the height fits the wrapped lines.
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText");
doc.update_dict(r, |d| {
d.set(b"Contents".to_vec(), PdfString::text(text));
// The rich-text version follows the new plain text (text boxes), or would contradict it.
if free_text {
rich_text(d);
} else {
d.remove(b"RC");
}
touch(d, meta);
})?;
if free_text {
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
// text editable (as in Acrobat), so the re-fit ignores the lock.
match fitted_box(doc, r, text) {
Some(rect) => apply_text_box(doc, r, rect, meta)?,
None => set_appearance(doc, r)?,
}
}
Ok(())
}
/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and
/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old
/// rectangle (the box can't be measured, so only the appearance is redrawn).
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
let d = annot_dict(doc, r);
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
let (_, size) = appearance::parse_da(&d);
let pad = 2.0 + border_width_of(&d);
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
let rect = [rect[0], rect[1], rect[2], rect[3]];
// A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place.
let tb = if d.contains(b"CL") {
let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?;
[rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]
} else {
rect
};
let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3]));
let w = tb[2] - tb[0];
let w = if w.is_finite() && w >= 1.0 {
w
} else {
// No usable old width: fall back to the creation-time width (at most 300 pt).
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
};
let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64;
Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top])
}
/// Reply to a comment; returns the reply's index in the page's `/Annots`.
///
/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the
/// parent's rectangle and an empty appearance, so it shows in comment lists but never paints a
/// second icon on the page.
pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
reply(doc, page, index, text, author, meta, None)
}
/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a
/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins.
pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author });
reply(doc, page, index, &text, author, meta, Some(state))
}
fn reply(
doc: &mut Document,
page: usize,
index: usize,
text: &str,
author: &str,
meta: &Meta,
state: Option<ReviewState>,
) -> Result<usize, AnnotError> {
let (p, parent) = annot_ref(doc, page, index)?;
let pd = annot_dict(doc, parent);
if pd.name(b"Subtype") == Some(b"Popup") {
return Err(AnnotError::Invalid("pop-ups can't be replied to".into()));
}
let rect = pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0, 0.0, 0.0, 0.0]));
let mut d = base_dict("Text", [0.0; 4], p, text, author, meta);
d.set(b"Rect".to_vec(), rect);
d.set(b"IRT".to_vec(), Object::Ref(parent));
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
d.set(b"Name".to_vec(), Object::name("Comment"));
if let Some(c) = pd.get(b"C") {
d.set(b"C".to_vec(), c.clone());
}
if let Some(s) = state {
d.set(b"State".to_vec(), PdfString::text(s.name()));
d.set(b"StateModel".to_vec(), PdfString::text("Review"));
d.set(b"Subj".to_vec(), PdfString::text("Status"));
}
// An empty form: nothing is drawn for the reply itself.
let mut fd = Dict::new();
fd.set(b"Type".to_vec(), Object::name("XObject"));
fd.set(b"Subtype".to_vec(), Object::name("Form"));
fd.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 0.0, 0.0]));
let ap = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(fd, Vec::new())));
let mut apd = Dict::new();
apd.set(b"N".to_vec(), Object::Ref(ap));
d.set(b"AP".to_vec(), Object::Dict(apd));
let r = doc.add(Object::Dict(d));
let mut list = annots(doc, p);
list.push(Object::Ref(r));
let i = list.len() - 1;
set_annots(doc, p, list)?;
Ok(i)
}
fn flags(doc: &Document, r: ObjRef) -> i64 {
doc.get(r).as_dict().and_then(|d| d.get(b"F").and_then(|f| doc.resolve(f).as_f64())).unwrap_or(0.0) as i64
}
/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked).
fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> {
if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) }
}
/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized,
/// restyled or deleted; its text and replies stay editable, as in Acrobat.
pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED };
doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?;
Ok(())
}
/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with
/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status.
pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
let (p, parent) = annot_ref(doc, page, index)?;
let pd = annot_dict(doc, parent);
if pd.name(b"Subtype") == Some(b"Popup") {
return Err(AnnotError::Invalid("pop-ups can't be marked".into()));
}
let state = if marked { "Marked" } else { "Unmarked" };
let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author });
let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta);
d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4])));
d.set(b"IRT".to_vec(), Object::Ref(parent));
d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
d.set(b"State".to_vec(), PdfString::text(state));
d.set(b"StateModel".to_vec(), PdfString::text("Marked"));
let r = doc.add(Object::Dict(d));
let mut list = annots(doc, p);
list.push(Object::Ref(r));
let i = list.len() - 1;
set_annots(doc, p, list)?;
Ok(i)
}
/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`.
pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> {
if !finite(&[dx, dy]) {
return Err(AnnotError::Invalid("invalid offset".into()));
}
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let shift = |o: &Object, every: bool| -> Option<Object> {
let a = o.as_array()?;
Some(Object::Array(
a.iter()
.enumerate()
.map(|(i, v)| match v.as_f64() {
Some(x) if every || i < 4 => Object::Real(x + if i % 2 == 0 { dx } else { dy }),
_ => v.clone(),
})
.collect(),
))
};
let popup = annot_dict(doc, r).reference(b"Popup");
doc.update_dict(r, |d| {
for (k, every) in [(&b"Rect"[..], false), (b"QuadPoints", true), (b"L", false), (b"CL", true), (b"Vertices", true)] {
if let Some(v) = d.get(k).and_then(|o| shift(o, every)) {
d.set(k.to_vec(), v);
}
}
if let Some(Object::Array(list)) = d.get(b"InkList").cloned() {
let moved = list.iter().map(|s| shift(s, true).unwrap_or_else(|| s.clone())).collect();
d.set(b"InkList".to_vec(), Object::Array(moved));
}
touch(d, meta);
})?;
if let Some(p) = popup
&& doc.get(p).as_dict().is_some()
{
doc.update_dict(p, |d| {
if let Some(v) = d.get(b"Rect").and_then(|o| shift(o, false)) {
d.set(b"Rect".to_vec(), v);
}
})?;
}
Ok(())
}
/// Resize a rectangle, oval or text box to `rect`; its appearance is redrawn.
pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
apply_text_box(doc, r, rect, meta)
}
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
let d = annot_dict(doc, r);
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
Some(mut l) => {
let attach = callout_attach(rect, [l[2], l[3]]);
(l[4], l[5]) = (attach[0], attach[1]);
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad);
([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l))
}
None => (rect, None),
};
doc.update_dict(r, |d| {
d.set(b"Rect".to_vec(), num_array(&outer));
match &cl {
Some(l) => {
d.set(b"CL".to_vec(), num_array(l));
d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]]));
}
None => {
d.remove(b"RD");
}
}
touch(d, meta);
})?;
set_appearance(doc, r)
}
fn border_width_of(d: &Dict) -> f64 {
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0)
}
/// Change a comment's colour, opacity and/or line width, and redraw it.
pub fn set_style(
doc: &mut Document,
page: usize,
index: usize,
color: Option<Rgb>,
opacity: Option<f64>,
width: Option<f64>,
meta: &Meta,
) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
// Check before changing anything: a stale appearance would contradict the new style.
if appearance::build(&d).is_none() {
return Err(AnnotError::Unsupported(subtype));
}
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
return Err(AnnotError::Invalid("invalid style".into()));
}
let free_text = subtype == "FreeText";
doc.update_dict(r, |d| {
if let Some(c) = color {
if free_text {
let (_, size) = appearance::parse_da(d);
let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0));
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
rich_text(d);
} else {
d.set(b"C".to_vec(), rgb(c));
}
}
if let Some(o) = opacity {
let o = o.clamp(0.0, 1.0);
if o < 1.0 {
d.set(b"CA".to_vec(), Object::Real(o));
} else {
d.remove(b"CA");
}
}
if let Some(w) = width {
let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default();
bs.set(b"W".to_vec(), Object::Real(w.max(0.0)));
d.set(b"BS".to_vec(), Object::Dict(bs));
d.remove(b"Border");
}
touch(d, meta);
})?;
set_appearance(doc, r)
}
// ── reading ─────────────────────────────────────────────────────────────────────────────────
/// One comment as the viewer lists it (the same fields and rules as
/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to
/// refresh the comment list after a comment edit without re-inspecting the whole document.
#[derive(Clone, Debug, PartialEq)]
pub struct Summary {
pub page: usize,
pub index: usize,
pub subtype: String,
pub author: Option<String>,
pub contents: Option<String>,
/// `/M` as written (a PDF date string).
pub modified: Option<String>,
pub name: Option<String>,
pub in_reply_to: Option<String>,
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
pub state: Option<String>,
pub quads: Vec<[f32; 8]>,
pub locked: bool,
pub intent: Option<String>,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
let o = doc.resolve(d.get(key)?);
let s = match &*o {
Object::String(s) => s.to_text(),
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
let s = s.trim_matches('\0').trim().to_string();
(!s.is_empty()).then_some(s)
}
/// Every comment (not links, form widgets or pop-ups), ordered by page and then top edge.
pub fn summaries(doc: &Document) -> Vec<Summary> {
let mut out = Vec::new();
let Ok(pages) = page_refs(doc) else { return out };
for (page, p) in pages.iter().enumerate() {
for (index, entry) in annots(doc, *p).iter().enumerate() {
let obj = doc.resolve(entry);
let Some(d) = obj.as_dict() else { continue };
let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue };
if matches!(subtype.as_str(), "Link" | "Widget" | "Popup") {
continue;
}
let nums = |k: &[u8]| -> Vec<f32> {
d.get(k)
.map(|o| doc.resolve(o))
.and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect()))
.unwrap_or_default()
};
let r = nums(b"Rect");
let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] };
let c = nums(b"C");
let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
if subtype == "FreeText"
&& let Some(da) = text_value(doc, d, b"DA")
{
let t: Vec<&str> = da.split_whitespace().collect();
if let Some(i) = t.iter().position(|x| *x == "rg")
&& i >= 3
{
let f = |k: usize| t[k].parse::<f32>().unwrap_or(0.0);
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
}
}
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
out.push(Summary {
page,
index,
subtype,
author: text_value(doc, d, b"T"),
contents: text_value(doc, d, b"Contents"),
modified: text_value(doc, d, b"M"),
name: text_value(doc, d, b"NM"),
in_reply_to,
rect,
color,
state: text_value(doc, d, b"State"),
quads,
locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
});
}
}
out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
out
}
/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn).
/// `None` leaves a value as it is.
pub fn set_info(
doc: &mut Document,
page: usize,
index: usize,
author: Option<&str>,
subject: Option<&str>,
icon: Option<NoteIcon>,
meta: &Meta,
) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text");
if icon.is_some() && !is_note {
return Err(AnnotError::Invalid("only sticky notes have an icon".into()));
}
doc.update_dict(r, |d| {
if let Some(a) = author {
d.set(b"T".to_vec(), PdfString::text(a));
}
if let Some(s) = subject {
d.set(b"Subj".to_vec(), PdfString::text(s));
}
if let Some(i) = icon {
d.set(b"Name".to_vec(), Object::name(i.name()));
}
touch(d, meta);
})?;
if icon.is_some() {
set_appearance(doc, r)?;
}
Ok(())
}
/// What Comment properties shows for one comment.
#[derive(Clone, Debug, PartialEq)]
pub struct Props {
pub subtype: String,
pub author: String,
pub subject: String,
pub color: Option<Rgb>,
pub opacity: f64,
/// Border / line width, for shapes, lines and drawings.
pub width: Option<f64>,
/// Notes only.
pub icon: Option<NoteIcon>,
/// `/M`, as written.
pub modified: Option<String>,
/// The appearance can be redrawn (so colour, opacity and width can change).
pub restylable: bool,
/// The Locked flag.
pub locked: bool,
}
/// The current properties of the comment at `(page, index)`.
pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
let p = page_ref(doc, page).ok()?;
let entry = annots(doc, p).get(index).cloned()?;
let obj = doc.resolve(&entry);
let d = obj.as_dict()?;
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine")
.then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0));
Some(Props {
author: text_value(doc, d, b"T").unwrap_or_default(),
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
color,
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
width,
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
modified: text_value(doc, d, b"M"),
restylable: appearance::build(d).is_some(),
locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0,
subtype,
})
}
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they
/// move, list and delete as one. Returns the strikeout's index.
#[allow(clippy::too_many_arguments)]
pub fn add_text_replacement(
doc: &mut Document,
page: usize,
quads: &[[f64; 8]],
replacement: &str,
author: &str,
strike: &Style,
caret: &Style,
meta: &Meta,
) -> Result<usize, AnnotError> {
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
let s = add_annotation(
doc,
&NewAnnotation {
page,
shape: Shape::TextMarkup { kind: Markup::StrikeOut, quads: quads.to_vec() },
style: strike.clone(),
contents: String::new(),
author: author.into(),
},
meta,
)?;
let (_, sr) = annot_ref(doc, page, s)?;
doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?;
// The caret sits on the baseline at the end of the last line, its point at the text.
let xs = [last[0], last[2], last[4], last[6]];
let ys = [last[1], last[3], last[5], last[7]];
let x = xs.iter().copied().fold(f64::MIN, f64::max);
let (y0, y1) = (ys.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MIN, f64::max));
let h = ((y1 - y0) * 0.6).clamp(4.0, 24.0);
let rect = [x - h / 2.0, y0 - h * 0.6, x + h / 2.0, y0 + h * 0.4];
let c = add_annotation(
doc,
&NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() },
meta,
)?;
let (_, cr) = annot_ref(doc, page, c)?;
doc.update_dict(cr, |d| {
d.set(b"IRT".to_vec(), Object::Ref(sr));
d.set(b"RT".to_vec(), Object::name("Group"));
d.set(b"Subj".to_vec(), PdfString::text("Replace Text"));
})?;
Ok(s)
}
/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points
/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted.
/// Returns whether anything was erased.
pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result<bool, AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"Ink") {
return Err(AnnotError::Invalid("only drawings can be erased".into()));
}
if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 {
return Err(AnnotError::Invalid("invalid eraser path".into()));
}
let strokes: Vec<Vec<[f64; 2]>> = d
.get(b"InkList")
.map(|l| doc.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|s| {
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
})
.collect();
// Distance from a point to the eraser's path (segments, or a single point).
let near = |p: [f64; 2]| -> bool {
if path.len() == 1 {
return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius;
}
path.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
(p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius
})
};
let mut kept: Vec<Vec<[f64; 2]>> = Vec::new();
let mut erased = false;
for s in &strokes {
// Densify so long segments are cut where the eraser crosses them.
let mut pts: Vec<[f64; 2]> = Vec::new();
for w in s.windows(2) {
let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize;
for k in 0..n {
let t = k as f64 / n as f64;
pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]);
}
}
if let Some(last) = s.last() {
pts.push(*last);
}
let mut run: Vec<[f64; 2]> = Vec::new();
for p in pts {
if near(p) {
erased = true;
if run.len() >= 2 {
kept.push(std::mem::take(&mut run));
}
run.clear();
} else {
run.push(p);
}
}
if run.len() >= 2 {
kept.push(run);
}
}
if !erased {
return Ok(false);
}
if kept.is_empty() {
delete_annotation(doc, page, index)?;
return Ok(true);
}
let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0);
let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0);
doc.update_dict(r, |d| {
d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
set_appearance(doc, r)?;
Ok(true)
}