Edit a PDF: copy objects selected together and paste them as page content that draws the same (#5989)

copy_objects replays the page's state operators up to the last selected object and paints only the selection; paste_objects draws the copy on top through Ungroup's resource merge, under names that don't clash.
This commit is contained in:
bflatastic committed 2026-11-09 00:49:46 +07:00
1 parent c17638e050
commit 6174ac97b9
8 files changed
+555 -21

No files matched your search

+27 -2
View File
@@ -1670,8 +1670,15 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op);
let space = crate::ungroup::page_blend_space(doc, &p.dict);
let (inline, mut merged, patterns) =
crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix))?;
let (inline, mut merged, patterns) = crate::ungroup::ungroup_ops(
doc,
&res,
img.name.as_bytes(),
translucent,
space.as_ref(),
Matrix(img.matrix),
"this artwork can't be ungrouped",
)?;
if !patterns.is_empty() {
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (name, copy) in patterns {
@@ -1883,6 +1890,24 @@ fn crop_image_impl(
Ok(())
}
/// The `Do` of each image `indexes` (from [`page_images`]) on `page`, as an index among the
/// page's joined operators: what Copy takes of them.
pub(crate) fn image_ops(doc: &Document, page: usize, indexes: &[usize]) -> Result<Vec<usize>, EditError> {
if indexes.is_empty() {
return Ok(Vec::new());
}
let images = page_images(doc, page)?;
indexes
.iter()
.map(|i| {
images
.get(*i)
.map(|image| image.op)
.ok_or_else(|| EditError::Invalid("a selected image no longer exists; select the objects again".into()))
})
.collect()
}
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
+3 -1
View File
@@ -46,7 +46,9 @@ pub use background::page_background;
mod rules;
pub use rules::page_rules;
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
pub use objects::{
CopiedObjects, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, copy_objects, editable_objects, move_objects, paste_objects,
};
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
+3
View File
@@ -9,6 +9,9 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::text::ContentStream;
use crate::{Content, EditError};
mod clip;
pub use clip::{CopiedObjects, copy_objects, paste_objects};
pub const MAX_MOVE_OBJECTS: usize = 1_000;
const MAX_PAGE_OBJECTS: usize = 10_000;
const MAX_COORDINATE: f64 = 1e9;
+282
View File
@@ -0,0 +1,282 @@
//! Edit a PDF ▸ Copy, Cut and Paste of objects selected together (#5989): paragraphs, images,
//! Form artwork and added content, pasted as page content that draws the same.
//!
//! A copy is a content stream of its own that draws the objects and nothing else, in the page's
//! user space, with the resources it names. It replays every state operator of the page up to
//! the last object (transformations, clips, colours, ExtGStates, text state), so each object is
//! drawn in the state it had, and leaves out what paints anything else: other text, images,
//! shadings and paths (a path that clips is kept, without its paint). Each copied text-showing
//! operator is placed by its own text matrix, which is where its glyphs start, so the text
//! positioning between operators is left out. Marked content is left out too: a paste is
//! untagged and in no layer.
//!
//! A paste draws that stream on top of the page through Ungroup's merge ([`ungroup_ops`]): the
//! copy is read as Form artwork whose resources join the page's under names that don't clash,
//! moved by the offset, with the patterns it paints moved along. Its resources must already be
//! the target document's: the engine copies them over first when the copy came from another
//! document.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use pdfcraft_content::{Matrix, Op, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use super::{EditableObject, MAX_COORDINATE, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, checked_streams, editable_objects, valid_rect};
use crate::EditError;
use crate::ungroup::{CATEGORIES, DEFAULT_SPACES, page_blend_space, resource_operand, ungroup_ops};
/// How deep the copied graphics state may nest (`q`), as the page's own may.
const MAX_NESTING: usize = 1024;
/// Objects copied from one page: what [`paste_objects`] draws again.
#[derive(Clone, Debug, PartialEq)]
pub struct CopiedObjects {
/// A content stream that draws the objects alone, in the source page's user space: its `q`
/// and `Q` balance and its text objects close.
pub content: Vec<u8>,
/// The resources `content` names, as the source page has them: references are into the
/// document the copy came from.
pub resources: Dict,
/// The union of the objects' boxes (user space of the source page).
pub rect: [f64; 4],
/// The objects as [`editable_objects`] listed them, in inventory order.
pub objects: Vec<EditableObject>,
}
impl CopiedObjects {
/// The text of the copied paragraphs and text boxes, one per line: what the system clipboard
/// gets for other applications.
pub fn text(&self) -> String {
self.objects.iter().filter_map(|o| o.text.as_deref()).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n")
}
}
/// Copy `targets` (references from one [`editable_objects`] inventory of `page`). The document
/// is not changed.
pub fn copy_objects(doc: &Document, page: usize, targets: &[ObjectTarget]) -> Result<CopiedObjects, EditError> {
if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS {
return Err(EditError::Invalid("select between 1 and 1000 objects to copy".into()));
}
let mut inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect();
let mut objects = Vec::with_capacity(targets.len());
for target in targets.iter().collect::<BTreeSet<_>>() {
objects
.push(inventory.remove(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?);
}
if objects.len() != targets.len() {
return Err(EditError::Invalid("an object was selected more than once".into()));
}
let indexes = |kind| objects.iter().filter(|o| o.target.kind == kind).map(|o| o.target.index).collect::<Vec<_>>();
let streams = checked_streams(doc, page)?;
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut keep = crate::text::paragraph_ops(doc, page, &indexes(ObjectKind::Text), false)?;
keep.extend(crate::images::image_ops(doc, page, &indexes(ObjectKind::Image))?);
// Added content owns its stream: everything that stream paints is the item.
let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect();
let owned = indexes(ObjectKind::Added)
.into_iter()
.map(|i| {
added.get(i).map(|a| a.obj).ok_or_else(|| EditError::Invalid("a selected added item no longer exists; select the objects again".into()))
})
.collect::<Result<BTreeSet<ObjRef>, _>>()?;
if !owned.is_empty() {
for (i, op) in ops.iter().enumerate() {
let stream = streams.get(pieces.pieces_of(op).0).and_then(|s| s.entry.as_ref());
if paints(op) && stream.is_some_and(|r| owned.contains(&r)) {
keep.insert(i);
}
}
}
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let shows = crate::text::show_starts(doc, &ops, &res)?;
let drawn = replay(&ops, &keep, &shows)?;
let rect = objects.iter().map(|o| o.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap_or_default();
Ok(CopiedObjects { resources: used_resources(doc, &res, &drawn), content: serialize_ops(&drawn), rect, objects })
}
/// Whether `op` paints (rather than sets state or builds a path).
fn paints(op: &Op) -> bool {
matches!(op.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI")
}
/// The page's operators up to the last of `keep`, painting only `keep` (see the module docs).
/// `shows`: where each text-showing operator starts ([`crate::text::show_starts`]).
fn replay(ops: &[Op], keep: &BTreeSet<usize>, shows: &HashMap<usize, (Matrix, i64)>) -> Result<Vec<Op>, EditError> {
let Some(&last) = keep.last() else { return Err(EditError::Invalid("the selected objects draw nothing to copy".into())) };
let mut out = Vec::new();
let (mut depth, mut in_text) = (0usize, false);
let (mut path, mut clips) = (Vec::new(), false);
for (i, op) in ops.iter().enumerate().take(last.saturating_add(1)) {
let kept = keep.contains(&i);
match op.op.as_slice() {
b"q" => {
if depth >= MAX_NESTING {
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
depth += 1;
out.push(op.clone());
}
// An unmatched `Q` restores nothing; the renderer ignores it too.
b"Q" if depth > 0 => {
depth -= 1;
out.push(op.clone());
}
b"BT" if !in_text => {
in_text = true;
out.push(op.clone());
}
b"ET" if in_text => {
in_text = false;
out.push(op.clone());
}
b"Q" | b"BT" | b"ET" => {}
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path.push(op.clone()),
b"W" | b"W*" => {
clips = true;
path.push(op.clone());
}
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
if kept || clips {
out.append(&mut path);
out.push(if kept { op.clone() } else { Op::new("n", Vec::new()) });
}
path.clear();
clips = false;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
// `"` sets the spacing for what follows, shown or not.
if op.is("\"") {
for (name, value) in ["Tw", "Tc"].into_iter().zip(op.operands.iter().take(2)) {
if value.as_f64().is_some() {
out.push(Op::new(name, vec![value.clone()]));
}
}
}
match shows.get(&i) {
Some((tm, _)) if kept => {
out.push(Op::new("Tm", tm.0.map(Object::Real).to_vec()));
out.push(if op.is("TJ") { op.clone() } else { Op::new("Tj", op.operands.last().cloned().into_iter().collect()) });
}
// Its glyphs clip what follows, which leaving them out would not.
Some((_, mode)) if *mode >= 4 => {
return Err(EditError::Invalid(
"text used as a clipping path is drawn before the selected objects, so they can't be copied".into(),
));
}
_ => {}
}
}
// Each copied show sets its own text matrix; `TD` also sets the leading.
b"TD" => {
if let Some([_, y]) = op.nums::<2>() {
out.push(Op::new("TL", vec![Object::Real(-y)]));
}
}
b"Td" | b"Tm" | b"T*" | b"BDC" | b"BMC" | b"EMC" | b"MP" | b"DP" => {}
b"Do" | b"sh" | b"BI" => {
if kept {
out.push(op.clone());
}
}
_ => out.push(op.clone()),
}
}
if in_text {
out.push(Op::new("ET", Vec::new()));
}
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
Ok(out)
}
/// The entries of the page's resources `res` that `ops` name, and the default colour spaces,
/// which remap the device colours they paint (ISO 32000-2 §8.6.5.6).
fn used_resources(doc: &Document, res: &Dict, ops: &[Op]) -> Dict {
let mut used: BTreeMap<&[u8], BTreeSet<&[u8]>> = BTreeMap::new();
for op in ops {
if let Ok(Some((cat, i))) = resource_operand(op)
&& let Some(Object::Name(name)) = op.operands.get(i)
{
used.entry(cat).or_default().insert(name.as_slice());
}
}
let mut out = Dict::new();
for cat in CATEGORIES {
let Some(all) = res.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) else { continue };
let mut mine = Dict::new();
for (key, value) in all.iter() {
if used.get(cat).is_some_and(|u| u.contains(key.as_slice())) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&key.as_slice())) {
mine.set(key.clone(), value.clone());
}
}
if !mine.is_empty() {
out.set(cat.to_vec(), Object::Dict(mine));
}
}
out
}
/// Paste `copied` on top of `page`, moved by `offset` (user space), as one change: its content
/// and the page's resources merged with its own. Its resources must be this document's.
pub fn paste_objects(doc: &mut Document, page: usize, copied: &CopiedObjects, offset: [f64; 2]) -> Result<(), EditError> {
let r = copied.rect;
if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE)
|| !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]])
{
return Err(EditError::Invalid("the paste position is out of range".into()));
}
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
crate::check_on_top(doc, &p)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let name = (1..=xobjects.len().saturating_add(1))
.map(|n| format!("PCPaste{n}").into_bytes())
.find(|n| xobjects.get(n).is_none())
.ok_or_else(|| EditError::Invalid("the page has too many resources".into()))?;
// The copy as Form artwork, to draw inline as Ungroup does: its resources merge into the
// page's, and patterns move with it. The form itself is never added to the document.
let number = |v: f64| Object::Real(v);
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
// Nothing of the copy is clipped: its glyphs and strokes may reach past its objects' boxes.
form.set(b"BBox".to_vec(), Object::Array([-MAX_COORDINATE, -MAX_COORDINATE, MAX_COORDINATE, MAX_COORDINATE].map(number).to_vec()));
form.set(b"Matrix".to_vec(), Object::Array(Matrix::translate(offset[0], offset[1]).0.map(number).to_vec()));
form.set(b"Resources".to_vec(), Object::Dict(copied.resources.clone()));
xobjects.set(name.clone(), Object::Stream(Stream::from_raw(form, copied.content.clone())));
let mut with_copy = res;
with_copy.set(b"XObject".to_vec(), Object::Dict(xobjects));
let space = page_blend_space(doc, &p.dict);
let (ops, mut merged, patterns) = ungroup_ops(
doc,
&with_copy,
&name,
false,
space.as_ref(),
Matrix::translate(offset[0], offset[1]),
"the copied objects can't be pasted here",
)?;
let mut work = doc.clone();
if let Some(mut x) = merged.get(b"XObject").and_then(Object::as_dict).cloned() {
x.remove(&name);
merged.set(b"XObject".to_vec(), Object::Dict(x));
}
if !patterns.is_empty() {
let mut table = merged.get(b"Pattern").map(|x| work.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (key, pattern) in patterns {
table.set(key, Object::Ref(work.add(pattern)));
}
merged.set(b"Pattern".to_vec(), Object::Dict(table));
}
work.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(merged)))?;
crate::place_tagged(&mut work, &p, "Pasted", serialize_ops(&ops), false)?;
// Keep the page's objects selectable together, as a move does.
editable_objects(&work, page)?;
*doc = work;
Ok(())
}
#[cfg(test)]
#[path = "clip_tests.rs"]
mod tests;
+201
View File
@@ -0,0 +1,201 @@
use std::sync::Arc;
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
use super::*;
/// A document of `objs` (object 1 the catalog), numbered in order.
fn pdf(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn stream(content: &str) -> String {
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())
}
/// Two 300 × 300 pages. The first draws `source` with Helvetica as /F1, a 4 × 2 RGB image /Im0,
/// a half-opaque ExtGState /GS0 and Courier as /F2; the second draws `target` with Courier
/// under the same name /F1, so a paste from the first must rename the font it brings.
fn two_pages(source: &str, target: &str) -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R /F2 8 0 R >> /XObject << /Im0 9 0 R >> /ExtGState << /GS0 << /ca 0.5 /CA 0.5 >> >> >> >>"
.into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
stream(source),
stream(target),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 24 >>\nstream\n\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{7f}@@@\u{0}\u{0}\u{0}\nendstream".into(),
])
}
/// What the page state, the copied paragraph and picture and the objects left out are drawn with.
const SOURCE: &str = "q 1 0 0 1 10 10 cm 0 0 270 270 re W n 0 0 1 rg /GS0 gs \
BT /F2 10 Tf 200 20 Td (Skip) Tj ET \
BT /F1 24 Tf 1.5 Tc 20 170 Td (Copied) Tj 0 -30 Td (Too) Tj ET \
q 1 0 0 rg 150 230 100 40 re f Q \
q 80 0 0 40 20 60 cm /Im0 Do Q Q \
BT /F1 12 Tf 200 250 Td (After) Tj ET";
fn target(doc: &Document, page: usize, want: impl Fn(&EditableObject) -> bool) -> ObjectTarget {
editable_objects(doc, page).unwrap().into_iter().find(|o| want(o)).unwrap().target
}
fn render(doc: &Document, page: usize) -> pdfcraft_render::RenderedPage {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
renderer.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() })
}
/// The pixels of `page` in user-space box `r` (a page 300 high at scale 1), shifted by `by`.
fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4], by: [f64; 2]) -> Vec<u8> {
let (x0, x1) = ((r[0] + by[0]) as u32, (r[2] + by[0]) as u32);
let (y0, y1) = ((300.0 - r[3] - by[1]) as u32, (300.0 - r[1] - by[1]) as u32);
let mut out = Vec::new();
for y in y0..y1 {
for x in x0..x1 {
let i = ((y * page.width + x) * 4) as usize;
out.extend_from_slice(&page.rgba[i..i + 4]);
}
}
out
}
/// The paragraph "Copied" / "Too" (one or two of them, as the lines group) and the picture.
fn copied_paragraph_and_picture(doc: &Document) -> CopiedObjects {
let targets: Vec<_> = editable_objects(doc, 0)
.unwrap()
.into_iter()
.filter(|o| o.target.kind == ObjectKind::Image || o.text.as_deref().is_some_and(|t| t.contains("Copied") || t.contains("Too")))
.map(|o| o.target)
.collect();
copy_objects(doc, 0, &targets).unwrap()
}
#[test]
fn a_copy_takes_the_selected_objects_their_state_and_only_the_resources_they_use() {
let doc = two_pages(SOURCE, "");
let copied = copied_paragraph_and_picture(&doc);
let content = String::from_utf8_lossy(&copied.content).into_owned();
assert!(content.contains("(Copied)") && content.contains("(Too)") && content.contains("/Im0 Do"), "{content}");
// The page's clip and its colour and transparency still apply; nothing else is painted.
assert!(content.contains("W\nn") && content.contains("/GS0 gs") && content.contains("rg"), "{content}");
assert!(!content.contains("Skip") && !content.contains("After") && !content.contains("\nf\n"), "{content}");
let names = |cat: &[u8]| copied.resources.get(cat).and_then(Object::as_dict).map(|d| d.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>());
assert_eq!(names(b"Font"), Some(vec![b"F1".to_vec(), b"F2".to_vec()]), "the font of the skipped text stays selected for what follows");
assert_eq!(names(b"XObject"), Some(vec![b"Im0".to_vec()]));
assert_eq!(names(b"ExtGState"), Some(vec![b"GS0".to_vec()]));
assert!(copied.text().contains("Copied") && copied.text().contains("Too") && !copied.text().contains("Skip"), "{}", copied.text());
assert_eq!(pdfcraft_content::parse(&copied.content).ops.iter().filter(|o| o.is("q")).count(), 3, "balanced: {content}");
}
#[test]
fn pasted_objects_draw_the_same_on_another_page_under_renamed_resources() {
let mut doc = two_pages(SOURCE, "BT /F1 10 Tf 5 5 Td (x) Tj ET");
let copied = copied_paragraph_and_picture(&doc);
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
let (source, pasted) = (render(&doc, 0), render(&doc, 1));
let area = [copied.rect[0] - 4.0, copied.rect[1] - 4.0, copied.rect[2] + 4.0, copied.rect[3] + 4.0];
let (before, after) = (pixels(&source, area, [0.0, 0.0]), pixels(&pasted, area, [0.0, 0.0]));
assert!(before.chunks(4).any(|p| p != [255, 255, 255, 255]), "the copied objects are drawn");
assert_eq!(before, after, "the paste draws the same pixels");
let red = pixels(&pasted, [160.0, 240.0, 260.0, 280.0], [0.0, 0.0]);
assert!(red.chunks(4).all(|p| p == [255, 255, 255, 255]), "what wasn't selected isn't pasted");
// Its own /F1 (Courier) is left alone; the copy's Helvetica comes under another name.
let fonts =
pdfcraft_model::pages(&doc)[1].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap().as_dict().unwrap().get(b"Font").cloned().unwrap();
let fonts = doc.resolve(&fonts).as_dict().cloned().unwrap();
assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))));
assert!(fonts.iter().any(|(k, v)| k != b"F1" && *v == Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))), "{fonts:?}");
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
assert!(texts.iter().any(|t| t.contains("Copied")) && !texts.iter().any(|t| t.contains("Skip")), "{texts:?}");
assert_eq!(crate::images::page_images(&doc, 1).unwrap().len(), 1);
}
#[test]
fn a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same() {
let mut doc = two_pages(SOURCE, "");
let copied = copied_paragraph_and_picture(&doc);
let before = render(&doc, 0);
paste_objects(&mut doc, 0, &copied, [150.0, 0.0]).unwrap();
let after = render(&doc, 0);
let area = [copied.rect[0] - 2.0, copied.rect[1] - 2.0, copied.rect[2] + 2.0, copied.rect[3] + 2.0];
assert!(area[2] + 150.0 < 300.0, "{area:?}");
// The copy (and the clip it is drawn in) lands 150 pt to the right, the original stays.
// Antialiasing elsewhere on the page may round an edge pixel one step differently.
let (moved, original) = (pixels(&after, area, [150.0, 0.0]), pixels(&before, area, [0.0, 0.0]));
assert_eq!(moved.len(), original.len());
assert!(original.iter().any(|v| *v != 255), "the copied objects are drawn");
assert!(moved.iter().zip(&original).all(|(a, b)| a.abs_diff(*b) <= 2), "the paste draws the same pixels, moved");
assert_eq!(pixels(&after, area, [0.0, 0.0]), original);
// Within the document and the page, the resources are the page's own, under their names.
let res = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap();
let fonts = res.as_dict().unwrap().get(b"Font").and_then(Object::as_dict).cloned().unwrap();
assert_eq!(fonts.len(), 2, "{fonts:?}");
assert_eq!(editable_objects(&doc, 0).unwrap().iter().filter(|o| o.target.kind == ObjectKind::Image).count(), 2);
}
#[test]
fn added_content_is_copied_with_everything_its_stream_paints() {
let mut doc = two_pages(SOURCE, "");
let item = crate::Content::Text(crate::AddedText {
rect: [20.0, 20.0, 200.0, 60.0],
text: "Added box".into(),
size: 12.0,
..crate::AddedText::default()
});
crate::add_content(&mut doc, 0, &item).unwrap();
let added = target(&doc, 0, |o| o.target.kind == ObjectKind::Added);
let copied = copy_objects(&doc, 0, &[added]).unwrap();
assert_eq!(copied.text(), "Added box");
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
assert_eq!(texts, ["Added box"], "pasted as the page's own text");
assert!(crate::list_added(&doc).iter().all(|a| a.page == 0));
}
#[test]
fn copying_and_pasting_refuse_what_they_cannot_do_and_change_nothing() {
let doc = two_pages(SOURCE, "");
let text = target(&doc, 0, |o| o.text.as_deref().is_some_and(|t| t.contains("Copied")));
assert!(copy_objects(&doc, 0, &[]).is_err());
assert!(copy_objects(&doc, 0, &[text, text]).is_err(), "selected twice");
assert!(copy_objects(&doc, 0, &[ObjectTarget { kind: ObjectKind::Image, index: 9 }]).is_err(), "stale");
assert!(copy_objects(&doc, 5, &[text]).is_err(), "no such page");
let copied = copy_objects(&doc, 0, &[text]).unwrap();
for offset in [[f64::NAN, 0.0], [f64::INFINITY, 0.0], [2e9, 0.0]] {
let mut work = doc.clone();
assert!(paste_objects(&mut work, 1, &copied, offset).is_err(), "{offset:?}");
assert!(!work.is_modified(), "a refused paste changes nothing");
}
assert!(paste_objects(&mut doc.clone(), 7, &copied, [0.0, 0.0]).is_err());
// Text drawn as a clipping path before the selection would clip it: refused, not dropped.
let clipped = two_pages("BT /F1 20 Tf 7 Tr 20 20 Td (Mask) Tj ET 0 Tr BT /F1 20 Tf 20 200 Td (Shown) Tj ET", "");
let shown = target(&clipped, 0, |o| o.text.as_deref() == Some("Shown"));
assert!(copy_objects(&clipped, 0, &[shown]).is_err());
// Stray `Q`, `ET` and an unfinished path or text object: the copy is still balanced.
let stray = two_pages("Q Q ET 10 10 m 20 20 l BT /F1 20 Tf 20 200 Td (Stray) Tj", "");
let shown = target(&stray, 0, |o| o.text.as_deref() == Some("Stray"));
let copied = copy_objects(&stray, 0, &[shown]).unwrap();
let ops = pdfcraft_content::parse(&copied.content).ops;
assert_eq!(ops.iter().filter(|o| o.is("q")).count(), ops.iter().filter(|o| o.is("Q")).count());
assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), ops.iter().filter(|o| o.is("ET")).count());
let mut work = stray.clone();
paste_objects(&mut work, 1, &copied, [0.0, 0.0]).unwrap();
}
+1 -1
View File
@@ -62,9 +62,9 @@ pub(crate) use lines::{
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
pub use rewrite::{LineEdit, replace_line, rewrite_block};
use rewrite::{coincident_ops, rewrite_block_from};
pub(crate) use rewrite::{paragraph_ops, reveal_ops, selectable_blocks, show_starts, translate_blocks};
#[cfg(test)]
pub(crate) use rewrite::{replace_line_with_limit, rewrite_block_with_limit};
pub(crate) use rewrite::{reveal_ops, selectable_blocks, translate_blocks};
/// One line of existing text.
#[derive(Clone, Debug, PartialEq)]
+31 -11
View File
@@ -1117,6 +1117,36 @@ fn vertical_encoding(doc: &Document, font: &Dict) -> Result<bool, EditError> {
Err(EditError::Invalid("the text encoding is nested too deeply".into()))
}
/// The text-showing operators of paragraphs `indexes` (from [`group_blocks`] of the page's
/// lines), as indexes among the page's joined operators. `editable`: refuse a line that can't be
/// edited (moving rewrites its operators; copying takes them as they are).
pub(crate) fn paragraph_ops(doc: &Document, page: usize, indexes: &[usize], editable: bool) -> Result<std::collections::BTreeSet<usize>, EditError> {
let mut selected = std::collections::BTreeSet::new();
if indexes.is_empty() {
return Ok(selected);
}
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
for index in indexes {
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
for line in &b.lines {
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
if editable {
l.ensure_editable()?;
}
selected.extend(l.ops.iter().copied());
}
}
Ok(selected)
}
/// Where each text-showing operator of `ops` (a page's joined operators, drawn with `resources`)
/// starts its glyphs: the text matrix there, and its rendering mode (`Tr`).
pub(crate) fn show_starts(doc: &Document, ops: &[Op], resources: &Dict) -> Result<HashMap<usize, (Matrix, i64)>, EditError> {
let shown = interpret(doc, ops, resources, &mut HashMap::new(), &mut Carry::new(), None, None)?;
Ok(shown.into_iter().map(|s| (s.op, (s.tm, s.state.render_mode))).collect())
}
/// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing.
/// Numeric TJ offsets and text rise temporarily move each selected show operation, then
/// restore both advances and rise. The PDF reader advances the original glyph bytes itself:
@@ -1127,17 +1157,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
}
let p = page_dict(doc, page)?;
let streams = content_streams(doc, &p.dict)?;
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let mut selected = std::collections::BTreeSet::new();
for index in indexes {
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
for line in &b.lines {
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
l.ensure_editable()?;
selected.extend(l.ops.iter().copied());
}
}
let selected = paragraph_ops(doc, page, indexes, true)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = joined(&streams);
+7 -6
View File
@@ -36,11 +36,11 @@ use crate::EditError;
const MAX_FORM_BYTES: usize = 64 << 20;
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
pub(crate) const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
/// The `/ColorSpace` entries that remap device colours painted with the resources they are in
/// (ISO 32000-2 §8.6.5.6).
const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"];
pub(crate) const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"];
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
const MAX_MOVED_PATTERNS: usize = 256;
@@ -57,7 +57,7 @@ pub(crate) type Ungrouped = (Vec<Op>, Dict, Vec<(Vec<u8>, Object)>);
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]).
/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`,
/// then the CTM there).
/// then the CTM there). A refusal starts with `refusal` (what can't be done).
pub(crate) fn ungroup_ops(
doc: &Document,
resources: &Dict,
@@ -65,8 +65,9 @@ pub(crate) fn ungroup_ops(
translucent: bool,
page_space: Option<&Object>,
placement: Matrix,
refusal: &str,
) -> Result<Ungrouped, EditError> {
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
let refuse = |why: &str| EditError::Invalid(format!("{refusal}: {why}"));
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
@@ -371,7 +372,7 @@ struct BlendWalk {
}
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
struct Unreadable;
pub(crate) struct Unreadable;
/// Which operand of `op` names a resource, and from which category, read as the renderer reads
/// it: operands are taken from the end, so a surplus operand before them is ignored. `Do`, `gs`,
@@ -379,7 +380,7 @@ struct Unreadable;
/// a name or an inline dictionary; `scn` and `SCN` numbers and the last name among them.
/// `Ok(None)` when `op` names no resource; [`Unreadable`] when the renderer would skip it, which
/// [`ungroup_ops`] refuses rather than guess at.
fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
pub(crate) fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
let operands = &op.operands;
let name_at = |i: Option<usize>| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_))));
let last = operands.len().checked_sub(1);