Files
pdfcraft/crates/engine/src/edit/run.rs
T
FredAppsandCodex GPT-6.1 0046177f47 fix(engine): validate image transforms and promote arbitrary added-image transforms
Reject finite but singular or overflowing determinants at the engine boundary. Promote added images for arbitrary transforms, as RotateBy already does, while preserving editable quarter turns and reflections. Regressions verify transformed placement, promotion, undo and refusal without mutation.

Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
2026-11-01 14:58:20 +01:00

902 lines
46 KiB
Rust

use super::{
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
use crate::SourceProblem;
use crate::sources::{comment_source, import_pdf_comments};
use pdfcraft_content::Matrix;
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
match edit {
Edit::RotatePages { .. }
| Edit::DeletePages { .. }
| Edit::MovePages { .. }
| Edit::ReversePages { .. }
| Edit::InsertBlankPage { .. }
| Edit::DuplicatePages { .. }
| Edit::ReplacePages { .. }
| Edit::SetPageBox { .. }
| Edit::SetTransition { .. }
| Edit::InsertPagesFrom { .. }
// "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22).
| Edit::AddBookmark { .. }
| Edit::RenameBookmark { .. }
| Edit::DeleteBookmark { .. }
| Edit::MoveBookmark { .. }
| Edit::SetBookmarkPage { .. }
| Edit::SetBookmarkColor { .. }
| Edit::SetBookmarkStyle { .. }
| Edit::BookmarksFromStructure
| Edit::NumberPages { .. } => {
if p.assemble() {
Ok(())
} else {
Err(EditError::NotPermitted("page changes"))
}
}
// Creates bookmarks (assembly), and their titles are the document's text, copied out of it
// (Table 22, bit 5), as an export of it would be.
Edit::BookmarksFromTextStyles { .. } => {
if !p.assemble() {
Err(EditError::NotPermitted("page changes"))
} else if !p.copy() {
Err(EditError::NotPermitted("copying text and images"))
} else {
Ok(())
}
}
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
| Edit::SetAnnotationStatus { .. }
| Edit::MarkAnnotation { .. }
| Edit::ReplaceText { .. }
| Edit::EraseInk { .. }
| Edit::LockAnnotation { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
| Edit::FontSizeAnnotation { .. }
| Edit::SetAnnotationInfo { .. }
| Edit::SetMeasurementScale { .. } => {
if p.annotate() {
Ok(())
} else {
Err(EditError::NotPermitted("comments"))
}
}
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => {
if p.fill_forms() {
Ok(())
} else {
Err(EditError::NotPermitted("filling in form fields"))
}
}
Edit::Protect(_) | Edit::RemoveProtection => {
if unrestricted(p) {
Ok(())
} else {
Err(EditError::NotPermitted("changing security"))
}
}
// Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted
// document it is a change of security (#3921).
Edit::ConvertPdfA { .. } => {
if unrestricted(p) {
Ok(())
} else {
Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids"))
}
}
Edit::SetInfo { .. }
| Edit::AddHeaderFooter { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. }
| Edit::AddField { .. }
| Edit::ApplyRedactions { .. }
| Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::MoveObjects { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::ClearRedactions
| Edit::RemoveHidden { .. }
| Edit::ImportData { .. }
| Edit::AddLink { .. }
| Edit::SetLink { .. }
| Edit::DeleteLink { .. }
| Edit::RemoveLinks { .. }
| Edit::AddLinks { .. }
| Edit::Sanitize
| Edit::XfaToStatic
| Edit::SetFieldProps { .. }
| Edit::DeleteField { .. }
| Edit::DeleteWidget { .. }
| Edit::DuplicateField { .. }
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
| Edit::SetCalculationOrder { .. }
| Edit::SetInitialView(_)
| Edit::AddAttachment { .. }
| Edit::DeleteAttachment { .. }
| Edit::AutoTagText
| Edit::SetAltText { .. }
| Edit::AutoTagFormFields
| Edit::SetTagProperty { .. }
| Edit::MoveTag { .. }
| Edit::MarkDecorative { .. }
| Edit::MarkTagArtifact { .. }
| Edit::AddOcrText { .. }
| Edit::SetDocumentScript { .. }
| Edit::SetFieldScript { .. }
| Edit::RemoveStandardId { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::RegisterTextFont { .. }
| Edit::EditPageImage { .. }
| Edit::Flatten { .. }
| Edit::FlattenFillSign => {
if p.modify() {
Ok(())
} else {
Err(EditError::NotPermitted("changes to the document"))
}
}
Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)),
}
}
/// Dates and unique ids stamped onto what an edit creates.
pub(crate) struct EditCtx {
/// Field scripts run (`None`: JavaScript is off) and what they produced.
pub(crate) js: Option<js::JsRunner>,
/// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what
/// its scripts produced.
pub(crate) xfa: Option<Arc<pdfcraft_xfa::model::Template>>,
pub(crate) xfa_out: js::JsOutput,
/// The datasets stream this edit wrote (later writes in the edit replace it in place).
pub(crate) xfa_datasets: Option<pdfcraft_cos::ObjRef>,
/// A form script ran too long and was abandoned: the document's scripts go off.
pub(crate) xfa_ran_away: bool,
/// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]).
pub(crate) xfa_time_scale: u32,
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
pub(crate) xfa_removed: Vec<String>,
/// Pages in the document, for `xfa.layout.pageCount()`.
pub(crate) pages: usize,
pub(crate) date: Option<String>,
/// Today in local time, for date tokens.
pub(crate) today: (i64, u32, u32),
pub(crate) seed: u64,
pub(crate) count: u64,
/// Platform CSPRNG (`getrandom::fill`). Tests replace it so Protect can fail closed.
pub(crate) rng: fn(&mut [u8]) -> Result<(), getrandom::Error>,
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
/// log and the undo label.
pub(crate) renamed_fields: Vec<(String, String)>,
/// The overlay streams the edit's applied redactions added, which alone the renderer's check
/// leaves out.
pub(crate) redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
}
impl EditCtx {
pub(crate) fn new(now: Option<i64>, salt: u64) -> Self {
let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt;
if let Some(t) = now {
seed ^= (t as u64).rotate_left(17);
}
#[cfg(not(target_arch = "wasm32"))]
if now.is_some() {
// Real clock: mix in sub-second time so ids from two sessions don't collide.
seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32;
}
Self {
js: None,
xfa: None,
xfa_out: Default::default(),
xfa_datasets: None,
xfa_ran_away: false,
xfa_time_scale: 1,
xfa_removed: Vec::new(),
pages: 0,
date: now.map(pdfcraft_cos::pdf_date),
today: (1970, 1, 1),
seed,
count: 0,
rng: getrandom::fill,
renamed_fields: Vec::new(),
redaction_overlays: Vec::new(),
}
}
/// 32 bytes of entropy for new encryption keys, salts and generated owner passwords from the
/// platform CSPRNG. Fails closed when secure randomness is unavailable.
fn entropy(&mut self) -> Result<[u8; 32], EditError> {
let mut out = [0u8; 32];
(self.rng)(&mut out).map_err(|e| EditError::Protection(format!("no secure randomness available: {e}")))?;
if out == [0u8; 32] {
return Err(EditError::Protection("no secure randomness available".into()));
}
Ok(out)
}
/// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream.
fn meta(&mut self) -> pdfcraft_annot::Meta {
let mut next = || {
self.count += 1;
let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
let (a, b) = (next(), next());
let id = format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
a >> 32,
(a >> 16) & 0xffff,
a & 0xfff,
0x8000 | (b >> 48) & 0x3fff,
b & 0xffff_ffff_ffff
);
pdfcraft_annot::Meta { date: self.date.clone(), id }
}
}
fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> {
match change {
ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
}
ImageEdit::Transform(t) => {
let [a, b, c, d, _, _] = *t;
let determinant = a * d - b * c;
if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into())));
}
}
ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into())));
}
_ => {}
}
let img = pdfcraft_edit::page_images(doc, page)?
.into_iter()
.nth(index)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
let c = match change {
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
}
ImageEdit::RotateBy(degrees) => {
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
pdfcraft_edit::ImageChange::Transform(t)
}
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
img.visible_rect.unwrap_or(img.rect),
0,
*horizontal,
!*horizontal,
)),
ImageEdit::Replace { name, bytes } => {
if img.is_form {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into())));
}
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
}
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
};
let page_model = pdfcraft_model::pages(doc)
.into_iter()
.nth(page)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?;
let target_stream_obj = match page_model.dict.get(b"Contents") {
None => None,
Some(c) => match &*doc.resolve(c) {
pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()),
_ if img.stream == 0 => c.as_ref(),
_ => None,
},
};
let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect();
// Ownership belongs to the drawing stream, not the XObject: original and added images may
// show the same resource without sharing an editable item.
let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t));
let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a)));
let mut ai_to_update = None;
if let Some((added_idx, added_item)) = matched_added
&& let AddedContent::Image(ai) = added_item.content.clone()
{
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|| img.visible_rect.is_some_and(|v| {
(v[0] - img.rect[0]).abs() > 1e-4
|| (v[1] - img.rect[1]).abs() > 1e-4
|| (v[2] - img.rect[2]).abs() > 1e-4
|| (v[3] - img.rect[3]).abs() > 1e-4
});
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
let should_promote = has_active_crop
|| non_axis_transform
|| matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete);
if should_promote {
doc.update_dict(added_item.obj, |d| {
d.remove(b"PCAdded");
d.remove(b"PCMark");
})
.map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?;
} else {
ai_to_update = Some((*added_idx, ai));
}
}
if let Some((added_idx, mut ai)) = ai_to_update {
let view = Matrix(page_model.view_matrix(doc));
let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?;
match change {
ImageEdit::Move(to) => {
ai.rect = to_display.bbox(*to);
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Transform(_) | ImageEdit::Flip { .. } => {
let pdfcraft_edit::ImageChange::Transform(t) = c else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into())));
};
let t_display = view.then(&Matrix(t)).then(&to_display);
let [ta, tb, tc, td, _, _] = t_display.0;
let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5;
let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5;
let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel {
(0, ta < 0.0, td < 0.0)
} else if is_perpendicular {
match (tb > 0.0, tc > 0.0) {
(true, false) => (1, false, false),
(false, true) => (3, false, false),
(true, true) => (1, false, true),
(false, false) => (1, true, false),
}
} else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
"non-axis-aligned transforms are not supported for added images".into(),
)));
};
let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) };
ai.flip_h ^= toggle_h;
ai.flip_v ^= toggle_v;
ai.rotation = (ai.rotation + quarters_ccw) % 4;
ai.rect = t_display.bbox(ai.rect);
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Rotate(q) => {
let k = ((4 - q.rem_euclid(4)) % 4) as u8;
ai.rotation = (ai.rotation + k) % 4;
let r = ai.rect;
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
if k % 2 == 1 {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
}
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Replace { .. } => {
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
};
ai.image = new_image;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
}
} else {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
Ok(())
}
/// Perform an edit on a working copy (the caller discards it on error).
pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
match edit {
Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?,
Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?,
Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?,
Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?,
Edit::InsertBlankPage { at, width, height } => {
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
}
Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?,
Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?,
Edit::ReplacePages { pages, name, bytes, src_pages } => {
let src = open_source(name, bytes)?;
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
}
Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?,
Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?,
Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => {
let src = open_source(name, bytes)?;
let pages = match pages {
Some(p) => p.clone(),
None => (0..pdfcraft_organize::page_count(&src)?).collect(),
};
// The file's bookmarks come along under one named after it, as in Combine Files.
let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned());
let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?;
cx.renamed_fields = outcome.renamed_fields;
}
Edit::AddBookmark { parent, index, title, page, left, top } => {
pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?;
}
Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?,
Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?,
Edit::MoveBookmark { from, to_parent, index } => {
pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?;
}
Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?,
Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?,
Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?,
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None })
.collect();
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::BookmarksFromTextStyles { levels } => {
if levels.is_empty() {
return Err(EditError::Invalid("choose a text style for at least one bookmark level".into()));
}
// Each line goes to the first level whose style it is in: a style given again, or one
// within the size tolerance of an earlier level's, would leave its level empty.
for (i, a) in levels.iter().enumerate() {
if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) {
let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2));
return Err(EditError::Invalid(format!(
"bookmark levels {first} and {second} have the same text style: choose a different one for each level"
)));
}
}
let entries = crate::text_style_bookmarks::headings(doc, levels);
if entries.is_empty() {
return Err(EditError::Invalid("no text is set in the chosen styles".into()));
}
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
Edit::AddMeasurement(m) => {
measure::add(doc, m, &cx.meta())?;
}
Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?,
Edit::AddAnnotation(a) => {
pdfcraft_annot::add_annotation(doc, a, &cx.meta())?;
}
Edit::AddCustomStamp { page, rect, name, file, author } => {
let src = mark_source(doc, file)?;
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
let natural = rect[2] == rect[0] && rect[3] == rect[1];
let rotation = pdfcraft_organize::page_rotation(doc, *page)?;
// The picture as displayed: a quarter turn swaps its sides in user space.
let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) };
let rect = if natural {
let k = (200.0 / sw.max(sh)).min(1.0);
let (w, h) = (dw * k, dh * k);
[rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0]
} else {
fit_picture(*rect, dw, dh)
};
let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) };
let style = pdfcraft_annot::Style::default_for(&shape);
let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() };
let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?;
}
Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?,
Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?,
Edit::ReplyToAnnotation { page, index, text, author } => {
pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?;
}
Edit::SetAnnotationStatus { page, index, state, author } => {
pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?;
}
Edit::EraseInk { page, index, path, radius } => {
pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?;
}
Edit::AddAttachment { name, bytes, description, mime } => {
pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?;
}
Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?,
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
// the strikeout's `/NM` (#2666).
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
pdfcraft_annot::add_text_replacement(
doc,
pdfcraft_annot::TextReplacement {
page: *page,
quads,
replacement: text,
author,
strike,
caret,
strike_meta: &strike_meta,
caret_meta: &caret_meta,
},
)?;
}
Edit::MarkAnnotation { page, index, marked, author } => {
pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?;
}
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(
doc,
*page,
*index,
pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() },
&cx.meta(),
)?;
}
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
}
Edit::SetFieldValue { name, value } => {
let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() {
Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js),
None => pdfcraft_forms::set_value(doc, name, value),
};
match set(doc, value) {
Ok(()) => {}
// A date the field's format doesn't read may be typed by another of an XFA date
// field's patterns: it is kept in the first. Only a format refusal is tried
// again: it comes before any of the field's scripts run, so they run once.
Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) {
Some(typed) => set(doc, &typed)?,
None => return Err(refused.into()),
},
Err(refused) => return Err(refused.into()),
}
}
Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() {
Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?,
None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?,
},
Edit::XfaEvent { som, activity } => {
let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?;
let mut run =
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
let ran = xfa::on_event(doc, &tpl, som, activity, &mut run);
cx.xfa_datasets = run.datasets;
cx.xfa_ran_away |= run.ran_away;
ran.map_err(EditError::Invalid)?;
}
Edit::SetFieldImage { name, image } => {
let (img, _) = embed_image(doc, name, image)?;
let px = match &*doc.get(img) {
pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32),
_ => (1, 1),
};
// An XFA image field keeps the picture in the data too (as Designer saves it), and a
// laid-out form shows it from there: so only pictures the layout can show, and only
// for image fields (a laid-out one carries its SOM path; a static form's field name
// is its path).
let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some();
let image_field = is_xfa
&& pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon));
if image_field {
let Some(content_type) = image_content_type(image) else {
return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those")));
};
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone());
let path = match cx.xfa.as_deref() {
Some(tpl) => pdfcraft_xfa::data_path(tpl, &som),
None => pdfcraft_xfa::som_to_path(&som),
};
let r =
pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?;
if !r.written && r.warnings.is_empty() {
return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data")));
}
cx.xfa_datasets = r.stream.or(cx.xfa_datasets);
cx.xfa_out.errors.extend(r.warnings);
xfa_after_data_change(doc, cx, false)?;
} else {
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
}
}
Edit::ResetForm { names } => {
pdfcraft_forms::reset(doc, names.as_deref())?;
}
Edit::AddField { page, rect, kind, name } => {
pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?;
}
Edit::SetFieldProps { name, props } => {
pdfcraft_forms::set_props(doc, name, props)?;
}
Edit::DuplicateField { name, pages } => {
if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 {
return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages"))));
}
}
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?,
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
// As after a script edit: the values the new order gives show at once.
Edit::SetCalculationOrder { names } => {
pdfcraft_forms::set_calculation_order(doc, names)?;
match cx.js.as_mut() {
Some(js) => pdfcraft_forms::recalculate_with(doc, js)?,
None => pdfcraft_forms::recalculate(doc)?,
};
}
Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?,
Edit::AutoTagText => {
a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::SetAltText { figure, alt } => {
let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::AutoTagFormFields => {
a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?;
}
Edit::SetTagProperty { object, property, value } => {
a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::MoveTag { object, anchor, placement } => {
a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::EditTextLine { page, line, text } => {
pdfcraft_edit::replace_line(doc, *page, *line, text)?;
}
Edit::SetFieldScript { name, event, script } => {
pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?;
match cx.js.as_mut() {
Some(js) => pdfcraft_forms::recalculate_with(doc, js)?,
None => pdfcraft_forms::recalculate(doc)?,
};
}
Edit::ConvertPdfA { level } => {
pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?;
}
Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?,
Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?,
Edit::AddOcrText { page, words } => {
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
}
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
}
Edit::RegisterTextFont { page, bytes } => {
pdfcraft_edit::text::register_text_font(doc, *page, bytes)?;
}
Edit::MarkDecorative { figure } => {
let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::MarkTagArtifact { object } => {
a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::AddHeaderFooter { pages, settings, replace } => {
let date = cx.today;
pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?;
}
Edit::AddWatermark { pages, settings, replace, file } => {
let mut s = settings.clone();
if let Some(f) = file {
s.source = Some(mark_source(doc, f)?);
}
pdfcraft_edit::add_watermark(doc, pages, &s, *replace)?
}
Edit::AddBackground { pages, settings, replace, file } => {
let mut s = settings.clone();
if let Some(f) = file {
s.source = Some(mark_source(doc, f)?);
}
pdfcraft_edit::add_background(doc, pages, &s, *replace)?
}
Edit::RemoveMarks { kind } => {
let n = pdfcraft_model::pages(doc).len();
if pdfcraft_edit::remove_marks(doc, &(0..n).collect::<Vec<_>>(), *kind)? == 0 {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into())));
}
}
Edit::AddText { page, text } => {
pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?;
}
Edit::AddImage { page, rect, name, bytes } => {
let (image, natural) = embed_image(doc, name, bytes)?;
let rect = match rect {
Some(r) => *r,
None => {
let p = pdfcraft_model::pages(doc).swap_remove(*page);
let (pw, ph) = p.display_size(doc);
let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0);
let (w, h) = (natural.0 * k, natural.1 * k);
[(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0]
}
};
pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?;
}
Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?,
Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?,
Edit::ReplaceImage { page, index, name, bytes } => {
let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index);
let Some(AddedContent::Image(old)) = item.map(|a| a.content) else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into())));
};
let (image, _) = embed_image(doc, name, bytes)?;
pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?;
}
Edit::PasteContent { page, content, file } => {
let content = match content {
AddedContent::Text(_) => content.clone(),
AddedContent::Image(img) => {
let Some((name, bytes)) = file else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into())));
};
// The same picture in this document (copied here, pasted here before, or an
// identical file): drawn again from the one image object rather than stored twice.
let image = if copy_image::is_picture(doc, img.image, bytes) {
img.image
} else {
match copy_image::added_picture(doc, bytes) {
Some(image) => image,
None => embed_image(doc, name, bytes)?.0,
}
};
AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() })
}
};
pdfcraft_edit::add_content(doc, *page, &content)?;
}
Edit::ApplyRedactions { pages } => {
cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays;
}
Edit::ClearRedactions => {
pdfcraft_redact::clear_marks(doc, None)?;
}
Edit::AddLink { page, rect, action, style } => {
pdfcraft_annot::links::add(doc, *page, *rect, action, style)?;
}
Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?,
Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?,
Edit::RemoveLinks { pages } => {
if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into())));
}
}
Edit::AddLinks { links, style } => {
if pdfcraft_annot::links::add_many(doc, links, style)? == 0 {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into())));
}
}
Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => {
let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?;
import_pdf_comments(doc, &src)?;
}
Edit::ImportData { name, bytes } => {
// An XFA form takes XFA data files (XDP, datasets, or a data document) as its data;
// XFDF, FDF, Acrobat's XML and tables go the usual way.
let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten();
match text {
Some(Ok(text)) if is_xfa_data_file(&text) => {
let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone());
let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets)
.map_err(|e| EditError::Write(e.to_string()))?;
cx.xfa_datasets = r.stream.or(cx.xfa_datasets);
cx.xfa_out.errors.extend(r.warnings);
cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes));
xfa_after_data_change(doc, cx, true)?;
}
Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)),
_ => {
// A PDF with bytes before its header, within the 1,024 the reader allows, is read
// as a PDF when it is not a data file. A file the PDF reader cannot open keeps
// the data importer's error.
let mut data = doc.clone();
match pdfcraft_xfdf::import(&mut data, bytes) {
Ok(_) => *doc = data,
Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) {
Ok(src) => import_pdf_comments(doc, &src)?,
Err(SourceProblem::Unreadable(_)) => return Err(e.into()),
Err(p) => return Err(EditError::Source(format!("{name}: {p}"))),
},
Err(e) => return Err(e.into()),
}
}
}
}
Edit::RemoveHidden { which } => {
pdfcraft_redact::sanitize::remove_hidden(doc, which)?;
}
Edit::Sanitize => {
pdfcraft_redact::sanitize::sanitize(doc)?;
}
Edit::Flatten { comments, fields } => {
// An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers
// would lay the form out again from its template over the baked pages. A dynamic
// form that was never laid out keeps its XFA: without it only placeholders remain.
let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten();
let n = pdfcraft_model::pages(doc).len();
pdfcraft_edit::flatten(doc, &(0..n).collect::<Vec<_>>(), *comments, *fields)?;
match shape {
Some(shape) if shape.strippable() => strip_xfa(doc, cx)?,
Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()),
None => {}
}
}
Edit::XfaToStatic => {
let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else {
return Err(EditError::Invalid("this document is not an XFA form".into()));
};
if !shape.strippable() {
return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into()));
}
strip_xfa(doc, cx)?;
}
Edit::FlattenFillSign => {
let n = pdfcraft_model::pages(doc).len();
pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::<Vec<_>>())?;
}
Edit::Protect(p) => {
p.validate()?;
let seed = cx.entropy()?;
// Without a permissions password nothing is restricted, so the owner password is a
// random one nobody needs.
let random_owner = match p.permissions_password {
Some(_) => String::new(),
None => cx.entropy()?.iter().map(|b| format!("{b:02x}")).collect(),
};
let params = pdfcraft_cos::NewEncryption {
algorithm: p.algorithm,
user_password: p.open_password.as_deref().unwrap_or(""),
owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner),
permissions: p.permission_bits(),
encrypt_metadata: p.encrypt_metadata,
seed,
};
doc.set_encryption(&params).map_err(|e| EditError::Protection(e.to_string()))?;
}
Edit::RemoveProtection => {
if doc.output_handler().is_none() {
return Err(EditError::Protection("the document isn't protected".into()));
}
doc.remove_encryption();
}
Edit::Batch { edits, .. } => {
for e in edits {
run_edit(doc, e, cx)?;
}
}
}
Ok(())
}