mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 01:41:18 +00:00
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:
1 parent
c17638e050
commit
6174ac97b9
8 files changed
+555
-21
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user