mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
On a page turned 90 or 270 degrees the stamp's rectangle has its sides swapped in user space, and the appearance is counter-rotated so viewers fit its turned bounding box to /Rect (ISO 32000-2 12.5.5). The lined layout was computed in user-space axes, so that fit scaled the two displayed axes differently: the picture came out squashed and the band stretched. The layout is now made in the displayed box (the rectangle with its sides swapped) and the stamp records the page's turn in /PCPictureRotate, so the appearance carries the counter-rotation itself and a later restyle rebuilds it the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
507 lines
24 KiB
Rust
507 lines
24 KiB
Rust
use super::draw::draw_appearance;
|
|
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
|
use super::modify::{n, rich_text};
|
|
use super::types::{
|
|
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
|
};
|
|
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
|
|
|
|
pub(crate) fn num_array(v: &[f64]) -> Object {
|
|
Object::Array(v.iter().map(|x| Object::Real(*x)).collect())
|
|
}
|
|
|
|
pub(crate) fn rgb(c: Rgb) -> Object {
|
|
num_array(&c.map(|x| x.clamp(0.0, 1.0)))
|
|
}
|
|
|
|
pub(crate) fn finite(v: &[f64]) -> bool {
|
|
v.iter().all(|x| x.is_finite() && x.abs() < 1e7)
|
|
}
|
|
|
|
pub(crate) 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])]
|
|
}
|
|
|
|
pub(crate) 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)]),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
|
|
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
|
|
}
|
|
|
|
pub(crate) fn ending_draws(ending: LineEnding) -> bool {
|
|
ending != LineEnding::None
|
|
}
|
|
|
|
pub(crate) 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.
|
|
pub(crate) 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(Vec::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)
|
|
}
|
|
|
|
pub(crate) 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;
|
|
validate_style(style)?;
|
|
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);
|
|
set_non_opaque_style(&mut d, 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 } => set_redaction_properties(&mut d, style, quads, overlay, look),
|
|
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);
|
|
// Drawn upright for the page as displayed, as a stamp is (#2392).
|
|
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
|
if matches!(turn, 90 | 180 | 270) {
|
|
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
// The label is drawn for the page as displayed. A later page rotation is a new edit.
|
|
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
|
if matches!(turn, 90 | 180 | 270) {
|
|
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
|
|
}
|
|
}
|
|
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, line, .. } => {
|
|
let clean: String = name.chars().filter(char::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]));
|
|
let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty());
|
|
if let Some(l) = lined {
|
|
d.set(b"PCByLine".to_vec(), PdfString::text(l));
|
|
}
|
|
// 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. A stamp
|
|
// with a line is laid out in the displayed box, so it needs the turn too.
|
|
if lined.is_some() || (*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);
|
|
// The text is laid out for the page as displayed, like a stamp's label.
|
|
if !matches!(new.shape, Shape::Callout { .. }) {
|
|
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
|
if matches!(turn, 90 | 180 | 270) {
|
|
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
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));
|
|
}
|
|
// A redaction's overlay text becomes page content when it is applied, where `?` would be there
|
|
// for good: refused before anything is added. A comment's own text is stored as typed and
|
|
// drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`).
|
|
if d.name(b"Subtype") == Some(b"Redact")
|
|
&& let Some((what, c)) = appearance::undrawable_text(&d)
|
|
{
|
|
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
|
|
}
|
|
let r = doc.add(Object::Dict(d.clone()));
|
|
draw_appearance(doc, r)?;
|
|
let mut list = annots(doc, page);
|
|
let index = list.len();
|
|
list.push(Object::Ref(r));
|
|
add_popup(doc, r, popup, &mut list)?;
|
|
set_annots(doc, page, list)?;
|
|
Ok(index)
|
|
}
|
|
|
|
fn validate_style(style: &Style) -> Result<(), AnnotError> {
|
|
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
|
|
return Err(AnnotError::Invalid("invalid style".into()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) {
|
|
dict.set(b"C".to_vec(), rgb(style.color));
|
|
dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
|
|
dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
|
|
if !overlay.is_empty() {
|
|
dict.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 };
|
|
dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()));
|
|
dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
|
|
if look.repeat {
|
|
dict.set(b"Repeat".to_vec(), Object::Bool(true));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
|
|
if opacity < 1.0 {
|
|
dict.set(b"CA".to_vec(), Object::Real(opacity));
|
|
}
|
|
}
|
|
|
|
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
|
|
let Some(rect) = rect else { return Ok(()) };
|
|
let mut popup = Dict::new();
|
|
popup.set(b"Type".to_vec(), Object::name("Annot"));
|
|
popup.set(b"Subtype".to_vec(), Object::name("Popup"));
|
|
popup.set(b"Rect".to_vec(), num_array(&rect));
|
|
popup.set(b"Parent".to_vec(), Object::Ref(parent));
|
|
popup.set(b"Open".to_vec(), Object::Bool(false));
|
|
popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
|
let popup_ref = doc.add(Object::Dict(popup));
|
|
doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?;
|
|
annots.push(Object::Ref(popup_ref));
|
|
Ok(())
|
|
}
|
|
|
|
/// The share of a custom stamp's height its text line takes (the rest is the picture).
|
|
pub const CUSTOM_LINE_BAND: f64 = 0.22;
|
|
|
|
/// A custom stamp line's text height as a share of its band.
|
|
pub const CUSTOM_LINE_TEXT: f64 = 0.62;
|
|
|
|
/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`,
|
|
/// the band under the picture is added, and the stamp is as wide as the line at its usual size
|
|
/// when that is wider than the picture.
|
|
pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) {
|
|
let Some(line) = line else { return picture };
|
|
let h = picture.1 / (1.0 - CUSTOM_LINE_BAND);
|
|
let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0;
|
|
(picture.0.max(text), h)
|
|
}
|
|
|
|
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
|
|
/// The picture may be an image or a PDF page. 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") || d.get(b"PCPicture").is_none() {
|
|
return Err(AnnotError::Invalid("only a picture 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(())
|
|
}
|