2202 lines
88 KiB
Rust
2202 lines
88 KiB
Rust
//! 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)
|
||
}
|
||
}
|
||
|
||
/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum LineEnding {
|
||
None,
|
||
Square,
|
||
Circle,
|
||
Diamond,
|
||
OpenArrow,
|
||
ClosedArrow,
|
||
Butt,
|
||
ROpenArrow,
|
||
RClosedArrow,
|
||
Slash,
|
||
}
|
||
|
||
impl LineEnding {
|
||
pub const ALL: [Self; 10] = [
|
||
Self::None,
|
||
Self::Square,
|
||
Self::Circle,
|
||
Self::Diamond,
|
||
Self::OpenArrow,
|
||
Self::ClosedArrow,
|
||
Self::Butt,
|
||
Self::ROpenArrow,
|
||
Self::RClosedArrow,
|
||
Self::Slash,
|
||
];
|
||
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Self::None => "None",
|
||
Self::Square => "Square",
|
||
Self::Circle => "Circle",
|
||
Self::Diamond => "Diamond",
|
||
Self::OpenArrow => "OpenArrow",
|
||
Self::ClosedArrow => "ClosedArrow",
|
||
Self::Butt => "Butt",
|
||
Self::ROpenArrow => "ROpenArrow",
|
||
Self::RClosedArrow => "RClosedArrow",
|
||
Self::Slash => "Slash",
|
||
}
|
||
}
|
||
|
||
pub fn parse(name: &str) -> Option<Self> {
|
||
Self::ALL.into_iter().find(|ending| ending.name() == name)
|
||
}
|
||
}
|
||
|
||
/// 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. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool).
|
||
Line {
|
||
from: [f64; 2],
|
||
to: [f64; 2],
|
||
start: LineEnding,
|
||
end: LineEnding,
|
||
},
|
||
/// 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). `start` is the first vertex, `end` the last.
|
||
PolyLine {
|
||
vertices: Vec<[f64; 2]>,
|
||
start: LineEnding,
|
||
end: LineEnding,
|
||
},
|
||
/// 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,
|
||
ending: LineEnding,
|
||
},
|
||
/// 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()
|
||
}
|
||
|
||
/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature.
|
||
///
|
||
/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft
|
||
/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is
|
||
/// "Signature").
|
||
pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
|
||
if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
|
||
return true;
|
||
}
|
||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||
if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") {
|
||
return true;
|
||
}
|
||
if subtype == b"Stamp" {
|
||
if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) {
|
||
return true;
|
||
}
|
||
if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials"))
|
||
&& matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
|
||
}
|
||
|
||
fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
|
||
d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0)
|
||
}
|
||
|
||
/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden).
|
||
pub fn has_visible_fill_sign(doc: &Document) -> bool {
|
||
let Ok(pages) = page_refs(doc) else { return false };
|
||
for page in pages {
|
||
for entry in annots(doc, page) {
|
||
let obj = doc.resolve(&entry);
|
||
let Some(d) = obj.as_dict() else { continue };
|
||
let flags = annotation_flags(doc, d);
|
||
if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 {
|
||
continue;
|
||
}
|
||
if is_fill_sign(doc, d) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
fn fill_sign_shape(shape: &Shape) -> bool {
|
||
match shape {
|
||
Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true,
|
||
Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials",
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// The embedded image of a Fill & Sign image signature or initials (0-based target).
|
||
/// Other stamps have appearances that can't be represented by this image alone.
|
||
pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result<Option<ObjRef>, AnnotError> {
|
||
let p = page_ref(doc, page)?;
|
||
let list = annots(doc, p);
|
||
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||
let obj = doc.resolve(entry);
|
||
let Some(d) = obj.as_dict() else { return Ok(None) };
|
||
Ok((d.name(b"Subtype") == Some(b"Stamp")
|
||
&& matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials"))
|
||
&& matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))))
|
||
.then(|| d.reference(b"PCPicture"))
|
||
.flatten())
|
||
}
|
||
|
||
/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back
|
||
/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by
|
||
/// another app. The picture appears turned by the page's current rotation less this.
|
||
pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64, AnnotError> {
|
||
let p = page_ref(doc, page)?;
|
||
let list = annots(doc, p);
|
||
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
|
||
}
|
||
|
||
// ── 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_NO_VIEW: i64 = 32;
|
||
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]
|
||
}
|
||
|
||
fn ending_draws(ending: LineEnding) -> bool {
|
||
ending != LineEnding::None
|
||
}
|
||
|
||
fn ending_pair(start: LineEnding, end: LineEnding) -> Object {
|
||
Object::Array(vec![Object::name(start.name()), Object::name(end.name())])
|
||
}
|
||
|
||
/// 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::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::CustomStamp { rect, .. } => {
|
||
let r = normalize(*rect);
|
||
// Image signatures may be very thin; a positive PDF appearance box still works.
|
||
if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] {
|
||
return Err(bad("rectangle (empty)"));
|
||
}
|
||
r
|
||
}
|
||
Shape::Line { from, to, start, end } => {
|
||
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 ending_draws(*start) || ending_draws(*end) { 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)"));
|
||
}
|
||
// Strokes of three or more points are drawn as curves, which stay within their
|
||
// points and control points.
|
||
let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| {
|
||
let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect();
|
||
appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]])
|
||
});
|
||
grow(bounds(strokes.iter().flatten().copied().chain(controls)).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, start, end } => {
|
||
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);
|
||
let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra)
|
||
}
|
||
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 { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "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, start, end } => {
|
||
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 ending_draws(*start) || ending_draws(*end) {
|
||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||
}
|
||
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]));
|
||
// An image signature's rectangle is in user space; on a turned page its appearance is
|
||
// turned back (see `appearance::build`), so it reads upright as displayed.
|
||
if *image && matches!(clean.as_str(), "Signature" | "Initials") {
|
||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||
if turn != 0 {
|
||
d.set(b"PCPictureRotate".to_vec(), Object::Int(turn));
|
||
}
|
||
}
|
||
}
|
||
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, start, end } => {
|
||
d.set(b"C".to_vec(), rgb(style.color));
|
||
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
|
||
if ending_draws(*start) || ending_draws(*end) {
|
||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||
}
|
||
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, ending, .. } => {
|
||
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(ending.name()));
|
||
// 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);
|
||
}
|
||
_ => {}
|
||
}
|
||
if fill_sign_shape(&new.shape) {
|
||
// Survives a later subject edit, so flatten-on-save still finds the mark.
|
||
d.set(b"PCFillSign".to_vec(), Object::Bool(true));
|
||
}
|
||
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)
|
||
}
|
||
|
||
/// Counterrotate a newly placed image stamp's appearance into displayed-page axes.
|
||
/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers.
|
||
pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> {
|
||
let matrix = match rotation {
|
||
90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0],
|
||
180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0],
|
||
270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0],
|
||
_ => return Ok(()),
|
||
};
|
||
let (_, r) = annot_ref(doc, page, index)?;
|
||
let d = annot_dict(doc, r);
|
||
if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
|
||
return Err(AnnotError::Invalid("only an image stamp can be oriented".into()));
|
||
}
|
||
let normal = d
|
||
.get(b"AP")
|
||
.map(|ap| doc.resolve(ap))
|
||
.and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N")))
|
||
.ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?;
|
||
let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else {
|
||
return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into()));
|
||
};
|
||
stream.dict.set(b"Matrix".to_vec(), num_array(&matrix));
|
||
doc.set(normal, Object::Stream(stream));
|
||
Ok(())
|
||
}
|
||
|
||
/// (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(mut stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
|
||
// Image stamp restyling must retain the placement's page-axis correction.
|
||
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
|
||
let matrix = d.get(b"AP").and_then(|ap| {
|
||
let ap = doc.resolve(ap);
|
||
let normal = doc.resolve(ap.as_dict()?.get(b"N")?);
|
||
let Object::Stream(normal) = normal.as_ref() else { return None };
|
||
normal.dict.get(b"Matrix").cloned()
|
||
});
|
||
if let Some(matrix) = matrix {
|
||
stream.dict.set(b"Matrix".to_vec(), matrix);
|
||
}
|
||
}
|
||
let ap = doc.add(Object::Stream(stream));
|
||
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
|
||
// appearances would show the previous look on press or hover, so they go with the old /N.
|
||
let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||
apd.remove(b"D");
|
||
apd.remove(b"R");
|
||
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(())
|
||
}
|
||
|
||
/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one
|
||
/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no
|
||
/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes,
|
||
/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file
|
||
/// as it was. Links, form fields and pop-ups keep their own handling.
|
||
pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
|
||
let mut copy: Option<Document> = None;
|
||
for page in page_refs(doc).ok()? {
|
||
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
|
||
let d = annot_dict(doc, r);
|
||
if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) {
|
||
continue;
|
||
}
|
||
let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
|
||
if has_normal || appearance::build(&d).is_none() {
|
||
continue;
|
||
}
|
||
// Fails only when `r` isn't a dictionary, which `build` above has just ruled out.
|
||
set_appearance(copy.get_or_insert_with(|| doc.clone()), r).ok();
|
||
}
|
||
}
|
||
copy
|
||
}
|
||
|
||
/// 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """);
|
||
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().map(|d| annotation_flags(doc, d)).unwrap_or(0)
|
||
}
|
||
|
||
/// 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, text box, stamp or drawing to `rect`. Stamps keep their appearance,
|
||
/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their
|
||
/// strokes and get a new one.
|
||
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 subtype == "Stamp" {
|
||
let rect = normalize(rect);
|
||
if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] {
|
||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||
}
|
||
doc.update_dict(r, |d| {
|
||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||
touch(d, meta);
|
||
})?;
|
||
return Ok(());
|
||
}
|
||
if subtype == "Ink" {
|
||
return resize_ink(doc, r, normalize(rect), meta);
|
||
}
|
||
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)
|
||
}
|
||
|
||
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
|
||
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
|
||
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||
let d = annot_dict(doc, r);
|
||
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();
|
||
let Some(inner) = bounds(strokes.iter().flatten().copied()) else {
|
||
return Err(AnnotError::Invalid("the drawing has no strokes".into()));
|
||
};
|
||
let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||
let old: Vec<f64> = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect();
|
||
let old = match old[..] {
|
||
[a, b, c, e] => normalize([a, b, c, e]),
|
||
_ => inner,
|
||
};
|
||
let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0));
|
||
let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]];
|
||
if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||
}
|
||
// An axis with no extent (a straight stroke) is centred rather than scaled.
|
||
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 };
|
||
let scaled: Vec<Vec<[f64; 2]>> = strokes
|
||
.iter()
|
||
.map(|s| {
|
||
s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect()
|
||
})
|
||
.collect();
|
||
doc.update_dict(r, |d| {
|
||
d.set(b"InkList".to_vec(), Object::Array(scaled.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(())
|
||
}
|
||
|
||
/// 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, line width and/or line endings, and redraw it.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn set_style(
|
||
doc: &mut Document,
|
||
page: usize,
|
||
index: usize,
|
||
color: Option<Rgb>,
|
||
opacity: Option<f64>,
|
||
width: Option<f64>,
|
||
endings: Option<&[LineEnding]>,
|
||
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.clone()));
|
||
}
|
||
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()));
|
||
}
|
||
if let Some(ends) = endings {
|
||
let callout = subtype == "FreeText" && d.contains(b"CL");
|
||
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
|
||
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
|
||
return Err(AnnotError::Invalid("this comment has no line endings to change".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");
|
||
}
|
||
if let Some(ends) = endings {
|
||
if ends.len() == 1 {
|
||
if let Some(ending) = ends.first() {
|
||
d.set(b"LE".to_vec(), Object::name(ending.name()));
|
||
}
|
||
} else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) {
|
||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||
}
|
||
}
|
||
ensure_ending_room(d);
|
||
touch(d, meta);
|
||
})?;
|
||
set_appearance(doc, r)
|
||
}
|
||
|
||
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
|
||
/// ovals, polygons and clouds, §12.5.6.8–9).
|
||
pub fn fillable(subtype: &str) -> bool {
|
||
matches!(subtype, "Square" | "Circle" | "Polygon")
|
||
}
|
||
|
||
/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
|
||
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
|
||
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>, 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 !fillable(&subtype) {
|
||
return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
|
||
}
|
||
// Check before changing anything: a stale appearance would contradict the new fill.
|
||
if appearance::build(&d).is_none() {
|
||
return Err(AnnotError::Unsupported(subtype));
|
||
}
|
||
if fill.is_some_and(|c| !finite(&c)) {
|
||
return Err(AnnotError::Invalid("invalid fill colour".into()));
|
||
}
|
||
doc.update_dict(r, |d| {
|
||
match fill {
|
||
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
|
||
None => {
|
||
d.remove(b"IC");
|
||
}
|
||
}
|
||
touch(d, meta);
|
||
})?;
|
||
set_appearance(doc, r)
|
||
}
|
||
|
||
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
|
||
fn ensure_ending_room(d: &mut Dict) {
|
||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||
if !matches!(subtype, b"Line" | b"PolyLine") {
|
||
return;
|
||
}
|
||
if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) {
|
||
return;
|
||
}
|
||
let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" };
|
||
let Some(pts) = pair_points(d, key) else { return };
|
||
let Some(bounds) = bounds(pts.iter().copied()) else { return };
|
||
let w = border_width_of(d);
|
||
let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0);
|
||
let vals: Vec<f64> = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||
let Some(rect) = four(&vals) else { return };
|
||
let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])];
|
||
if union != rect {
|
||
d.set(b"Rect".to_vec(), num_array(&union));
|
||
}
|
||
}
|
||
|
||
fn four(v: &[f64]) -> Option<[f64; 4]> {
|
||
Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?])
|
||
}
|
||
|
||
fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
|
||
let v: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect();
|
||
if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) {
|
||
return None;
|
||
}
|
||
Some(v.as_chunks::<2>().0.to_vec())
|
||
}
|
||
|
||
// ── 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>,
|
||
/// A Fill & Sign mark ([`is_fill_sign`]).
|
||
pub fill_sign: bool,
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup
|
||
/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia,
|
||
/// 3D, PrinterMark, TrapNet, Watermark) are not.
|
||
pub fn is_comment_subtype(subtype: &str) -> bool {
|
||
!matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark")
|
||
}
|
||
|
||
/// Every comment (see [`is_comment_subtype`]), 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 !is_comment_subtype(&subtype) {
|
||
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"),
|
||
// Acrobat ends lines with CR, which the interface would draw as nothing (keep in
|
||
// step with `pdfcraft_render::inspect`).
|
||
contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
|
||
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: annotation_flags(doc, d) & 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())),
|
||
fill_sign: is_fill_sign(doc, d),
|
||
});
|
||
}
|
||
}
|
||
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,
|
||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||
pub endings: Option<Vec<LineEnding>>,
|
||
/// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
|
||
pub fillable: bool,
|
||
/// `/IC`, the interior colour (`None`: no fill).
|
||
pub fill: Option<Rgb>,
|
||
}
|
||
|
||
/// 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: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||
fillable: fillable(&subtype),
|
||
fill: fill_of(d),
|
||
subtype,
|
||
endings: line_endings_of(d),
|
||
})
|
||
}
|
||
|
||
/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
|
||
fn fill_of(d: &Dict) -> Option<Rgb> {
|
||
let c: Vec<f64> = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||
let rgb = match c.as_slice() {
|
||
[g] => [*g; 3],
|
||
[r, g, b] => [*r, *g, *b],
|
||
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
|
||
_ => return None,
|
||
};
|
||
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
|
||
}
|
||
|
||
/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden.
|
||
fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
|
||
let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse);
|
||
match d.name(b"Subtype")? {
|
||
b"Line" | b"PolyLine" => match d.get(b"LE") {
|
||
None => Some(vec![LineEnding::None, LineEnding::None]),
|
||
Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(),
|
||
},
|
||
b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|