mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
* 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>
495 lines
23 KiB
Rust
495 lines
23 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::{free_text_da, n, rich_text, set_part_keys, shape_text_box};
|
|
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]
|
|
}
|
|
|
|
/// Tight bounding box of ink strokes, including Bezier control points for strokes with three or
|
|
/// more points (matching [`appearance::smooth_segments`]).
|
|
pub(crate) fn ink_bounds(strokes: &[Vec<[f64; 2]>]) -> Option<[f64; 4]> {
|
|
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]])
|
|
});
|
|
bounds(strokes.iter().flatten().copied().chain(controls))
|
|
}
|
|
|
|
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::FreehandHighlight { strokes } | Shape::Signature { strokes } => {
|
|
if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) {
|
|
return Err(bad("drawing (no points)"));
|
|
}
|
|
grow(ink_bounds(strokes).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::FreehandHighlight { .. } => "Freehand Highlight",
|
|
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)));
|
|
if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) {
|
|
return Err(AnnotError::Invalid("invalid opacity".into()));
|
|
}
|
|
let line = style.opacity.clamp(0.0, 1.0);
|
|
let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0));
|
|
let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0));
|
|
set_part_keys(&mut d, appearance::PartOpacity { line, fill, text });
|
|
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, .. } => {
|
|
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]));
|
|
// 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::FreehandHighlight { 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));
|
|
if matches!(&new.shape, Shape::FreehandHighlight { .. }) {
|
|
d.set(b"IT".to_vec(), Object::name("FreehandHighlight"));
|
|
}
|
|
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 };
|
|
d.set(b"DA".to_vec(), free_text_da(style.color, style.border_color, size));
|
|
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 !matches!(new.shape, Shape::Callout { .. }) {
|
|
// The text is laid out for the page as displayed, like a stamp's label.
|
|
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));
|
|
}
|
|
// An oval or a circle goes around the text box, its text wrapped as displayed:
|
|
// after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page.
|
|
shape_text_box(&mut d, rect, style.border_shape, style.width);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
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.border_color.is_some_and(|c| !finite(&c)) || !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 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(())
|
|
}
|
|
|
|
/// 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(())
|
|
}
|