Files
pdfcraft/crates/annot/src/modify.rs
T
496e37d72b M5: text boxes and typed text take an oval or a circle for their border (#4543)
* Fill & Sign: a Border control for typed text, and a border colour of its own

Rebased onto main as one commit: main's Fill & Sign text colour (#2444) runs through the
same places as the border (the open box, typed(), the page input, the toolbar and the
preferences), so every step of the branch met it again. Text colour and border now travel
together as TypedLook (typed_with), which keeps typed() and the new function under Clippy's
argument limit; a border without its own colour takes the text's. Make Current Properties
Default on typed text also keeps its colour for new text now.

The branch's commits, in order:
- Typewriter text: a border change grows its box on every side, so the text keeps its place
- Fill & Sign: a Border control for typed text and dates
- Parity: the typewriter border's evidence
- i18n: the Border label in every catalog, recorded in ATTRIBUTION.toml
- Text boxes, typewriter text and callouts: a border colour of their own
- Fill & Sign: the toolbar follows the selected text; a Border colour button
- fill_sign_add and comment_edit take `border_color`
- Parity: the border colour and the toolbar following the selection
- i18n: Border colour and Same as text in every catalog, recorded in ATTRIBUTION.toml
- Typed text's border: /Border's width is read, a non-finite border colour refused, a saved Same as text restored
- Fill & Sign: the toolbar changes only the selected text; Make Current Properties Default keeps new text's border
- Review: Make Properties Default gives a text box's or callout's tool its border colour
- i18n: the new labels in the Persian catalog, recorded in ATTRIBUTION.toml
- Review: a test that dragging the Font size control resizes the selected text to where the drag ends
- Rebase: the typed text's border in main's annot and comments modules (Style's border colour, exports), and craft_ui's ComboBox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M5: text boxes and typed text take an oval or a circle for their border

Rebased onto #4146's new head as one commit (see there: main's Fill & Sign text colour runs
through the same places). The border shape goes into TypedLook's border with the width and
colour; a circle around typed text still takes the snug box.

The branch's commits, in order:
- Text boxes and typed text take an oval or a circle for their border
- A circle goes snug around its text
- Review: fill_sign_add takes an oval or a circle only with a border
- Review: a circle made on a quarter-turned page is sized from the text as shown
- Review: Border ▸ None takes an oval or a circle away with the line
- Review: the Border colour button shows the selected text box's own colour
- i18n: Border shape in the Persian catalog, recorded in ATTRIBUTION.toml
- Review: Border ▸ None on a circled text is one undo step
- Rebase: the border shape in main's annot, engine and comments modules; craft_ui's ComboBox; Fill & Sign's border controls in a function of their own (main's complexity limit)
- Review: Thickness 0 in Comment Properties takes an oval or a circle away, as Border ▸ None does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open the Font size popup first in the drag test: main moved the size into a popup

* Border shape per selected comment, and split the oval/circle border out of draw_free_text (clippy cognitive complexity)

* Re-run CI (the push of the main merge did not start it)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 17:32:01 +01:00

1116 lines
53 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.
use super::create::{
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb,
};
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{line_endings_of, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
use super::types::{
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
Shape, Style, StyleChange, TextReplacement,
};
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> {
let drawn_elsewhere =
d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none();
if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) {
return Err(AnnotError::Undrawable(format!(
"PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)",
c.escape_debug(),
u32::from(c)
)));
}
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.
pub(crate) 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));
}
pub(crate) 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.
/// Refused while it, or a reply that would go with it, is locked.
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
deletable(doc, page, index)?;
lift_annotation(doc, page, index)
}
/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without
/// changing anything: what a Delete command asks before it is offered. It is refused while the
/// annotation is locked, or a reply that would go with it is.
pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> {
let list = annots(doc, page_ref(doc, page)?);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
if let Some(r) = target.as_ref() {
unlocked(doc, r)?;
}
let doomed = with_replies_and_popups(doc, &list, seed(target));
// A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
// answers either. Pop-ups follow their comment.
for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
let obj = doc.resolve(e);
if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice()))
&& annotation_flags(doc, d) & FLAG_LOCKED != 0
{
return Err(AnnotError::Invalid("a reply to it is locked".into()));
}
}
Ok(())
}
/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as
/// [`delete_annotation`] does but whatever their locks: for drawing the page beneath it on a
/// copy of the document (a stamp or signature being dragged), which changes nothing.
pub fn lift_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).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let doomed = with_replies_and_popups(doc, &list, seed(target));
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
Ok(set_annots(doc, p, kept)?)
}
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
/// comment (which has none to collect) the pop-up it names, which still goes.
fn seed(target: &Object) -> Option<ObjRef> {
target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")))
}
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
/// one of them, or it is written inline (a file can, though the standard asks for references) and
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
/// for the walk to collect, so it is matched here.
pub fn goes_with(entry: &Object, going: &HashSet<ObjRef>) -> bool {
match entry.as_ref() {
Some(r) => going.contains(&r),
None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))),
}
}
/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or
/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their
/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no
/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or
/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also
/// matches inline entries.
pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator<Item = ObjRef>) -> HashSet<ObjRef> {
let mut hanging: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
for e in list {
let obj = doc.resolve(e);
let Some(d) = obj.as_dict() else { continue };
// What goes with the annotation this entry hangs off: the entry, or for an inline one the
// pop-up it names.
let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue };
for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() {
hanging.entry(on).or_default().push(goes);
}
}
let mut going = HashSet::new();
let mut todo: Vec<ObjRef> = doomed.into_iter().collect();
while let Some(r) = todo.pop() {
if !going.insert(r) {
continue;
}
todo.extend(hanging.get(&r).into_iter().flatten());
todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup")));
}
going
}
/// 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. A circle snug
/// around its text stays snug around the new text, centred where it was.
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 d = annot_dict(doc, r);
let free_text = d.name(b"Subtype") == Some(b"FreeText");
let snug = free_text && is_snug(&d);
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. New text is drawn even
// where the standard fonts can't draw some of it (marked and reported, see
// `draw_appearance`). A circle snug around its text stays snug around the new text; one
// made larger by hand keeps its size, only growing for more lines.
let fit = if snug {
snug_refit(doc, r)
} else {
let d = annot_dict(doc, r);
let circle = text_box_of(&d).filter(|_| BorderShape::of(&d) == BorderShape::Circle);
fitted_box(doc, r, text).map(|fit| circle.map_or(fit, |old| [fit[0], fit[1].min(old[1]), fit[2], fit[3]]))
};
match fit {
Some(rect) => apply_text_box(doc, r, rect, meta, true)?,
None => draw_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.
// So does a text box inside an oval or a circle.
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 {
text_box_of(&d).unwrap_or(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).
pub(crate) 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")));
}
// An oval's or a circle's frame is resized: its text box follows inside.
let shape = if subtype == "FreeText" { BorderShape::of(&d) } else { BorderShape::Rectangle };
let rect = match (shape, text_box_of(&d)) {
(BorderShape::Rectangle, _) | (_, None) => rect,
// A circle's text box keeps its proportions and reaches the circle with its longer sides;
// the circle then goes around it again, never closer than its text allows.
(BorderShape::Circle, Some(was)) => {
let (o, w) = (normalize(rect), normalize(was));
let (cx, cy, r) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]).min(o[3] - o[1]) / 2.0);
let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0);
let k = r / wx.max(wy).max(1e-6);
let (ix, iy) = ((wx * k).max(0.5), (wy * k).max(0.5));
[cx - ix, cy - iy, cx + ix, cy + iy]
}
(shape, Some(was)) => shape.inside(rect, border_width_of(&d), was),
};
apply_text_box(doc, r, rect, meta, false)
}
/// A FreeText's text box: `/Rect` inset by `/RD` for a callout or a text box inside an oval or a
/// circle, else `/Rect`. `None` when they can't be read.
pub(crate) fn text_box_of(d: &Dict) -> Option<[f64; 4]> {
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
let rect = normalize(four(&nums(b"Rect")?)?);
if !d.contains(b"CL") && BorderShape::of(d) == BorderShape::Rectangle {
return Some(rect);
}
let rd = appearance::inset(d)?;
let tb = [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]];
(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb)
}
/// Give text box or typewriter text `d`, whose text box is `tb`, the border `shape` (`width`
/// wide): `/Rect` around it, `/RD` and `/PCBorderShape`, or for a rectangle `/Rect` the text
/// box itself again.
pub(crate) fn shape_text_box(d: &mut Dict, tb: [f64; 4], shape: BorderShape, width: f64) {
// A circle goes around the text as the box lays it out.
let text = (shape == BorderShape::Circle).then(|| text_extent(d, displayed_sides(d, tb).0 - 2.0 * (2.0 + width.max(0.0))));
let outer = shape.around(tb, width, text);
d.set(b"Rect".to_vec(), num_array(&outer));
if shape == BorderShape::Rectangle {
d.remove(b"PCBorderShape");
d.remove(b"RD");
} else {
d.set(b"PCBorderShape".to_vec(), Object::name(shape.name()));
// A circle touching the text box's sides would put them a rounding error outside it.
let tb = normalize(tb);
let rd = [tb[0] - outer[0], tb[1] - outer[1], outer[2] - tb[2], outer[3] - tb[3]].map(|x| x.max(0.0));
d.set(b"RD".to_vec(), num_array(&rd));
}
}
/// The width of the widest line and the height of the lines of FreeText `d`'s text, as its
/// appearance lays the text out `wrap` wide.
fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] {
let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
let (_, size) = appearance::parse_da(d);
let lines = appearance::wrap(&text, size, wrap.max(1.0));
let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
[widest, lines.len().max(1) as f64 * size * 1.2]
}
/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by
/// a quarter, where its text is laid out upright.
fn displayed_sides(d: &Dict, tb: [f64; 4]) -> (f64, f64) {
let tb = normalize(tb);
let (w, h) = (tb[2] - tb[0], tb[3] - tb[1]);
if matches!(appearance::text_turn(d), 90 | 270) { (h, w) } else { (w, h) }
}
/// FreeText `d`'s text box `tb`, with a border `width` wide, made as large as its text wrapped
/// `wrap` wide needs (its widest line and its lines, inside the padding a box sized to its text
/// has) and centred where it was: a circle goes around this, not around a box wider than its text.
fn snug_text_box(d: &Dict, tb: [f64; 4], width: f64, wrap: f64) -> [f64; 4] {
let tb = normalize(tb);
let pad = 2.0 + width.max(0.0);
let [w, h] = text_extent(d, wrap);
let (hx, hy) = (w / 2.0 + pad + 2.0, h / 2.0 + pad + 1.0);
let (hx, hy) = if matches!(appearance::text_turn(d), 90 | 270) { (hy, hx) } else { (hx, hy) };
let (cx, cy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0);
[cx - hx, cy - hy, cx + hx, cy + hy]
}
/// Whether FreeText `d` is a circle whose text box is snug around its text ([`snug_text_box`]):
/// it then follows its text as that changes, where one made larger by hand keeps its size.
fn is_snug(d: &Dict) -> bool {
let Some(tb) = text_box_of(d).filter(|_| BorderShape::of(d) == BorderShape::Circle) else { return false };
let width = border_width_of(d);
let s = snug_text_box(d, tb, width, displayed_sides(d, tb).0 - 2.0 * (2.0 + width));
let tb = normalize(tb);
((s[2] - s[0]) - (tb[2] - tb[0])).abs() < 0.5 && ((s[3] - s[1]) - (tb[3] - tb[1])).abs() < 0.5
}
/// The text box of a circle snug around its text for the text and size it has now: its lines up to
/// 300 pt wide, centred where it was.
fn snug_refit(doc: &Document, r: ObjRef) -> Option<[f64; 4]> {
let d = annot_dict(doc, r);
let width = border_width_of(&d);
Some(snug_text_box(&d, text_box_of(&d)?, width, 300.0 - 2.0 * (2.0 + width)))
}
/// Give a text box or typewriter text an oval, a circle or a rectangle for its border, and redraw
/// it. Its text box keeps its place; the oval or circle goes around it. For a circle the text box
/// first shrinks to its text, its lines wrapped as they are.
pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: BorderShape, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"FreeText") || d.contains(b"CL") {
return Err(AnnotError::Invalid("only text boxes and typewriter text have a border shape".into()));
}
let tb = text_box_of(&d).ok_or_else(|| AnnotError::Invalid("the text box can't be read".into()))?;
let tb = match shape {
BorderShape::Circle => {
let width = border_width_of(&d);
snug_text_box(&d, tb, width, displayed_sides(&d, tb).0 - 2.0 * (2.0 + width))
}
_ => tb,
};
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
shape_text_box(d, tb, shape, border_width_of(d));
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// At most this many points reshape a comment (a drawn polygon has a handful).
const MAX_POINTS: usize = 100_000;
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
/// points with room for the line's width and endings, as when the comment was drawn, and the
/// appearance is redrawn. A measurement keeps the points it measured.
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.contains(b"Measure") {
return Err(AnnotError::Invalid("a measurement's points are where it measured".into()));
}
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
let style = Style { width: border_width_of(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
("Polygon", _) => {
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
}
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
};
// Checks the points too: finite, and far enough apart to draw.
let rect = rect_for(&shape, &style)?;
let before = d;
doc.update_dict(r, |d| {
d.set(key.to_vec(), num_array(&points.concat()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// 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();
let before = annot_dict(doc, r);
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);
})?;
redraw_or_restore(doc, r, before)
}
/// 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
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> 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);
if !new_text {
refuse_foreign_text(doc, &d)?;
}
// 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),
};
let (shape, width) = (BorderShape::of(&d), border_width_of(&d));
let free_text = d.name(b"Subtype") == Some(b"FreeText");
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]]));
}
// An oval or a circle goes around the text box again.
None if free_text => shape_text_box(d, rect, shape, width),
None => {
d.remove(b"RD");
}
}
touch(d, meta);
})?;
let before = d;
if new_text {
if let Err(e) = draw_appearance(doc, r) {
doc.set(r, Object::Dict(before));
return Err(e);
}
Ok(())
} else {
redraw_or_restore(doc, r, before)
}
}
/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border`
/// (another app's), else 1.
fn border_width_of(d: &Dict) -> f64 {
appearance::border_width(d)
}
/// Recompute `/Rect` for an `Ink` annotation when its stroke width changes from `old_w` to `w`.
/// Preserves any baseline margin beyond `old_w / 2.0` (at least `1.0` pt) without accumulating
/// padding across repeated width increases and decreases.
fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
let list = d.get(b"InkList")?.as_array()?;
let mut strokes: Vec<Vec<[f64; 2]>> = Vec::with_capacity(list.len());
for s in list {
let pts: Vec<f64> = s.as_array()?.iter().map(Object::as_f64).collect::<Option<_>>()?;
strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect());
}
let inner = ink_bounds(&strokes)?;
let vals: Vec<f64> = d.get(b"Rect").and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
let Some(old_rect) = four(&vals).map(normalize) else {
return Some(grow(inner, w / 2.0 + 1.0));
};
let old_half = old_w / 2.0;
let new_half = w / 2.0;
let base = [
(inner[0] - old_rect[0] - old_half).max(1.0),
(inner[1] - old_rect[1] - old_half).max(1.0),
(old_rect[2] - inner[2] - old_half).max(1.0),
(old_rect[3] - inner[3] - old_half).max(1.0),
];
Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half])
}
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
/// redraw it.
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
let StyleChange { color, opacity, width, endings } = change;
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()));
}
}
refuse_foreign_text(doc, &d)?;
let free_text = subtype == "FreeText";
// Typewriter text has no box of its own: a border goes around its text, so the box grows
// (or shrinks) by the change on every side and the text keeps its place and its lines.
// So does a text box inside an oval or a circle, which goes around it again.
let shape = if free_text { BorderShape::of(&d) } else { BorderShape::Rectangle };
let typewriter = free_text && (d.name(b"IT") == Some(b"FreeTextTypeWriter") || shape != BorderShape::Rectangle) && !d.contains(b"CL");
let regrow = width.filter(|_| typewriter).and_then(|w| {
let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 };
let rect = grow(text_box_of(&d)?, by);
(by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some((rect, w.max(0.0)))
});
let before = d;
doc.update_dict(r, |d| {
if let Some((rect, w)) = regrow {
shape_text_box(d, rect, shape, w);
}
if let Some(c) = color {
if free_text {
let (_, size) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size));
rich_text(d);
} else {
d.set(b"C".to_vec(), rgb(c));
}
}
// The whole comment: its fill and text drop opacities of their own.
if let Some(o) = opacity {
let o = o.clamp(0.0, 1.0);
set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o });
}
if let Some(w) = width {
let old_w = border_width_of(d);
let w = w.max(0.0);
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));
d.set(b"BS".to_vec(), Object::Dict(bs));
d.remove(b"Border");
if d.name(b"Subtype") == Some(b"Ink")
&& let Some(rect) = ink_rect_for_width(d, old_w, w)
{
d.set(b"Rect".to_vec(), num_array(&rect));
}
}
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);
})?;
redraw_or_restore(doc, r, before)
}
/// A FreeText's `/DA`: the text colour, the border's own colour when it has one (the stroking
/// colour, `RG`), Helvetica at `size`.
pub(crate) fn free_text_da(text: Rgb, border: Option<Rgb>, size: f64) -> PdfString {
let [r, g, b] = text.map(|x| x.clamp(0.0, 1.0));
let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default();
PdfString::literal(format!("{} {} {} rg {stroke}/Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())
}
/// Give a text box, typewriter text or callout a border colour of its own (`None`: the text's
/// again), and redraw it.
pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"FreeText") {
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a border colour".into()));
}
if color.is_some_and(|c| !finite(&c)) {
return Err(AnnotError::Invalid("invalid colour".into()));
}
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
let (text, size) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(text, color, size));
rich_text(d);
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the
/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new
/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt.
pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"FreeText") {
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into()));
}
if !size.is_finite() || !(1.0..=400.0).contains(&size) {
return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into()));
}
refuse_foreign_text(doc, &d)?;
let snug = is_snug(&d);
let before = d;
doc.update_dict(r, |d| {
let (col, _) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size));
rich_text(d);
touch(d, meta);
})?;
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
let result = match (snug, if snug { snug_refit(doc, r) } else { fitted_box(doc, r, &text) }) {
// A circle snug around its text stays snug around it at the new size.
(true, Some(fit)) => apply_text_box(doc, r, fit, meta, false),
(false, Some(fit)) => {
let cur = annot_dict(doc, r);
// Keep the top and sides; only the bottom edge moves, and only downwards.
match text_box_of(&cur) {
Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false),
_ => apply_text_box(doc, r, fit, meta, false),
}
}
(_, None) => redraw_or_restore(doc, r, before.clone()),
};
if result.is_err() {
doc.set(r, Object::Dict(before));
}
result
}
/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6).
pub fn fillable(subtype: &str) -> bool {
matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText")
}
/// Fill a shape or FreeText 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, polygons and text boxes 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()));
}
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" };
match fill {
Some(c) => d.set(key.to_vec(), rgb(c)),
None => {
d.remove(key);
}
}
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an
/// opacity of its own (0–1), its line or border keeping the comment's (`/CA`), and redraw it.
/// `None` leaves that part as it is drawn now. The fill goes in `/ca` (PDF 2.0's opacity for
/// what is filled), the text in `/PCTextOpacity`, each only where it differs from the one it
/// would otherwise follow.
pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Option<f64>, text: 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();
if fill.is_some() && !fillable(&subtype) {
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes have a fill of their own".into()));
}
if text.is_some() && subtype != "FreeText" {
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have text of their own".into()));
}
if fill.into_iter().chain(text).any(|o| !o.is_finite()) {
return Err(AnnotError::Invalid("invalid opacity".into()));
}
// Check before changing anything: a stale appearance would contradict the new opacity.
if appearance::build(&d).is_none() {
return Err(AnnotError::Unsupported(subtype));
}
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
let mut parts = appearance::part_opacity(d);
if let Some(f) = fill {
parts.fill = f.clamp(0.0, 1.0);
}
if let Some(t) = text {
parts.text = t.clamp(0.0, 1.0);
}
set_part_keys(d, parts);
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// Write a comment's parts' opacities (see [`appearance::PartOpacity`]): the line's as `/CA`, the
/// fill's as `/ca` and the text's as `/PCTextOpacity`, each only where it differs from the one
/// it would otherwise follow (the fill the line, the text the fill), so a comment drawn at one
/// opacity keeps just `/CA`.
pub(crate) fn set_part_keys(d: &mut Dict, parts: appearance::PartOpacity) {
let appearance::PartOpacity { line, fill, text } = parts;
let mut put = |key: &[u8], value: f64, unless: f64| {
if value == unless {
d.remove(key);
} else {
d.set(key.to_vec(), Object::Real(value));
}
};
put(b"CA", line, 1.0);
put(b"ca", fill, line);
put(b"PCTextOpacity", text, fill);
}
/// 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())
}
/// 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.
///
/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
/// from the caller, so the edit stays deterministic.
pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result<usize, AnnotError> {
let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement;
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() },
caret_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)
}