Files
pdfcraft/crates/edit/src/objects.rs
T
6d843a8e8b Preserve source fonts in text editing and resize clipped artwork (#1116)
* M0: remove upstream trademark artwork from contributor fork

Use the existing document icon and a plain project label. Remove trademark
asset entries and regenerate attribution as required for modified forks.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: group compatible PDF words and hide source text during editing

Join compatible words across text objects and subset-font spaces, recognize
component colours, and reuse subset fonts with numeric space positioning.
Render a read-only background with the selected glyphs removed while the
inline editor is open. Keep one frame and source line breaks, and reset
the layout width when switching paragraphs. Expose the same preview through page_render.

Add synthetic extraction, save/reopen, preview and UI regressions. Record
the remaining mixed-style paragraph and installed-font limitations.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: preserve rich PDF regions and preview text edits in place

Group mixed source runs into complete visual regions, retain run styles and source encodings, and use validated embedded source faces in the inline editor. Preserve source baselines and show a single live frame as text grows or shrinks, backed by a read-only draft geometry tool.

Validation: strict workspace Clippy, workspace tests, all 30 WASM crates, asset and parity gates, licensed synthetic edit/save/reopen and desktop checks, and an opt-in four-page private-document regression.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: keep cropped artwork from blocking text editing

Track page/path clipping separately from full image placement, use visible selection bounds, and prioritize text hit testing over overlapping artwork. Preserve fixed clips during image transformations and expose visible bounds through the headless tools.

Validation: strict workspace Clippy, workspace tests, 30 WASM crates, asset/parity gates, synthetic engine/tool/UI and native desktop checks, both private-document UI regressions, and all-page local edit/save/reopen/undo on a four-page cropped-artwork PDF.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: resize cropped images with their clipping paths

Keep the visible frame and image pixels under the same transform. Preserve the
opposite corner and respond to diagonal, horizontal-only and vertical-only
corner drags without jumping from an off-centre grip. Refuse shared clipping
scopes that would move unrelated content.

Add synthetic engine/tool/UI regressions and opt-in private-file coverage
for 80 corner gestures and 10 grow/shrink save/reopen/undo render checks.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: complete source font subsets and preserve clipped editing

Add explicit document fonts, CFF embedding, range formatting and bounded contextual shaping; isolate shared image clips and verify private Chinese edits on all pages.

Co-authored-by: OpenAI Codex <codex@openai.com>

* Revert "M0: remove upstream trademark artwork from contributor fork"

This reverts commit 3aa539b5b6.

* Test: give the text layer up to 4 s before copying (it flaked on a loaded machine)

---------

Co-authored-by: Kamoliddin Fatkhullaev <komoliddinfatxullayev@MacBook-Pro-Komoliddin.local>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:27:55 -04:00

340 lines
15 KiB
Rust

//! Mixed-object inventory and atomic translation in Edit a PDF. Source indexes are resolved
//! together before mutation. Added content appears once, not again as a paragraph/image.
use std::collections::{BTreeMap, BTreeSet};
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document, Object};
use crate::{Content, EditError};
pub const MAX_MOVE_OBJECTS: usize = 1_000;
const MAX_PAGE_OBJECTS: usize = 10_000;
const MAX_COORDINATE: f64 = 1e9;
const MAX_CONTENT_BYTES: usize = 128 * 1024 * 1024;
/// The source inventory of an editable item. Indexes are zero-based within that inventory.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ObjectKind {
Added,
Text,
Image,
}
impl ObjectKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Added => "added",
Self::Text => "text",
Self::Image => "image",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectTarget {
pub kind: ObjectKind,
pub index: usize,
}
/// An item recognised by Edit a PDF. Geometry is in the page's user space; a Form is one
/// grouped artwork item. Its nested content is kept intact, not selected again separately.
#[derive(Clone, Debug, PartialEq)]
pub struct EditableObject {
pub target: ObjectTarget,
pub rect: [f64; 4],
pub content_type: &'static str,
pub text: Option<String>,
}
fn valid_rect(rect: [f64; 4]) -> bool {
rect.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) && rect[2] > rect[0] && rect[3] > rect[1]
}
pub fn editable_objects(doc: &Document, page: usize) -> Result<Vec<EditableObject>, EditError> {
checked_streams(doc, page)?;
let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect();
if added.len() > MAX_PAGE_OBJECTS {
return Err(EditError::Invalid("this page has too many objects to select together".into()));
}
let geometry = added_geometry(doc, page)?.1;
let excluded = added.iter().map(|a| a.obj).collect();
let mut objects = Vec::new();
for (index, a) in added.into_iter().enumerate() {
let (content_type, text) = match &a.content {
Content::Text(t) => ("text", Some(t.text.clone())),
Content::Image(_) => ("image", None),
};
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Added, index },
rect: geometry.get(index).ok_or_else(|| EditError::Invalid("an added item has no placement".into()))?.rect,
content_type,
text,
});
}
for (index, b) in crate::text::selectable_blocks(doc, page, &excluded)? {
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Text, index },
rect: b.rect,
content_type: "text",
text: Some(b.text),
});
}
for (index, image) in crate::images::selectable_images(doc, page, &excluded)? {
let Some(rect) = image.visible_rect else { continue };
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Image, index },
rect,
content_type: if image.is_form { "form" } else { "image" },
text: None,
});
}
if objects.len() > MAX_PAGE_OBJECTS {
return Err(EditError::Invalid("this page has too many objects to select together".into()));
}
if objects.iter().any(|o| !valid_rect(o.rect)) {
return Err(EditError::Invalid("an object has invalid geometry".into()));
}
Ok(objects)
}
/// Move selected mixed objects by a user-space vector as one atomic operation. All targets
/// refer to the original inventories. A refusal leaves the graph, including overlay, untouched.
pub fn move_objects(doc: &mut Document, page: usize, targets: &[ObjectTarget], offset: [f64; 2]) -> Result<(), EditError> {
if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS {
return Err(EditError::Invalid("select between 1 and 1000 objects to move".into()));
}
if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) || offset == [0.0, 0.0] {
return Err(EditError::Invalid("the move must be finite and non-zero".into()));
}
let inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect();
let mut unique = BTreeSet::new();
for target in targets {
if !unique.insert(*target) {
return Err(EditError::Invalid("an object was selected more than once".into()));
}
let object =
inventory.get(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?;
let r = object.rect;
if !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]) {
return Err(EditError::Invalid("the object move is too large".into()));
}
}
let indexes = |kind| targets.iter().filter(|t| t.kind == kind).map(|t| t.index).collect::<Vec<_>>();
let mut work = doc.clone();
crate::text::translate_blocks(&mut work, page, &indexes(ObjectKind::Text), offset)?;
crate::images::translate_images(&mut work, page, &indexes(ObjectKind::Image), offset)?;
translate_added(&mut work, page, &indexes(ObjectKind::Added), offset)?;
// Placement operators add to size/depth/work budgets, and moving paragraphs can change
// their grouping. Keep the resulting inventory usable; refuse before swapping otherwise.
editable_objects(&work, page)?;
*doc = work;
Ok(())
}
struct AddedGeometry {
rect: [f64; 4],
local_rect: [f64; 4],
origin: Matrix,
outside: Matrix,
current: Matrix,
stream: usize,
op: usize,
params: Dict,
}
/// An added item's metadata is in the coordinate space it was written in, which can differ
/// from today's page rotation/crop/UserUnit. Read its original placement, including graphics
/// state from preceding streams. MoveOrigin retains that basis after a byte-preserving move.
fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec<AddedGeometry>), EditError> {
let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect();
if added.is_empty() {
return Ok((Pieces::default(), Vec::new()));
}
let streams = checked_streams(doc, page)?;
let pieces = Pieces::join(&streams.iter().map(|(_, bytes)| bytes.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let owned: BTreeSet<_> = added.iter().map(|a| a.obj).collect();
let mut geometry = BTreeMap::new();
let mut first = BTreeMap::new();
let mut matrix = Matrix::IDENTITY;
let mut stack = Vec::new();
for (index, op) in ops.iter().enumerate() {
let (stream, end) = pieces.pieces_of(op);
let object = streams.get(stream).and_then(|(r, _)| r.as_ref());
if object.is_some_and(|r| owned.contains(&r)) {
if let std::collections::btree_map::Entry::Vacant(entry) = first.entry(stream) {
if stream != end || !op.is("q") {
return Err(EditError::Invalid("an added item has an unsupported placement".into()));
}
entry.insert((index, matrix));
} else if first.get(&stream).is_some_and(|(i, _)| i.checked_add(1) == Some(index)) {
if stream != end || !op.is("cm") {
return Err(EditError::Invalid("an added item has an unsupported placement".into()));
}
let current = op.nums::<6>().map(Matrix).ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?;
let outside = first.get(&stream).map_or(Matrix::IDENTITY, |(_, m)| *m);
let params = object
.and_then(|r| doc.get(r).as_dict().and_then(|d| d.get(b"PCAdded")).map(|p| doc.resolve(p)))
.and_then(|p| p.as_dict().cloned())
.ok_or_else(|| EditError::Invalid("an added item has no parameters".into()))?;
let origin = match params.get(b"MoveOrigin") {
None => current,
Some(o) => doc
.resolve(o)
.as_array()
.and_then(|a| Matrix::from_operands(a))
.ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?,
};
let raw = params
.get(b"Rect")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_array().cloned())
.ok_or_else(|| EditError::Invalid("an added item has no rectangle".into()))?;
let local_rect = raw
.iter()
.map(Object::as_f64)
.collect::<Option<Vec<_>>>()
.and_then(|r| <[f64; 4]>::try_from(r).ok())
.ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?;
if !current.0.iter().chain(origin.0.iter()).chain(outside.0.iter()).all(|v| v.is_finite() && v.abs() <= 1e12)
|| !valid_rect(local_rect)
{
return Err(EditError::Invalid("an added item has invalid geometry".into()));
}
geometry.insert(
stream,
AddedGeometry { rect: origin.then(&outside).bbox(local_rect), local_rect, origin, outside, current, stream, op: index, params },
);
}
}
match op.op.as_slice() {
b"q" => {
if stack.len() >= 1024 {
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
stack.push(matrix);
}
b"Q" => matrix = stack.pop().unwrap_or(matrix),
b"cm" => {
if let Some(m) = op.nums::<6>() {
matrix = Matrix(m).then(&matrix);
}
}
_ => {}
}
}
let geometry: Vec<_> = geometry.into_values().collect();
if geometry.len() != added.len() {
return Err(EditError::Invalid("an added item has no placement".into()));
}
Ok((pieces, geometry))
}
fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
if indexes.is_empty() {
return Ok(());
}
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let streams = checked_streams(doc, page)?;
let (pieces, geometry) = added_geometry(doc, page)?;
let ops = pieces.parse().ops;
let mut replacements = BTreeMap::new();
let mut parameters = Vec::new();
let linear = |inverse: Matrix| {
let [a, b, c, d, _, _] = inverse.0;
[a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]]
};
for index in indexes {
let g = geometry.get(*index).ok_or_else(|| EditError::Invalid("a selected added item no longer exists".into()))?;
let delta = linear(g.origin.then(&g.outside).invert().ok_or_else(|| EditError::Invalid("an added item has no area".into()))?);
let outer_delta = linear(g.outside.invert().ok_or_else(|| EditError::Invalid("an added item has no area".into()))?);
let current = g.current.then(&Matrix([1.0, 0.0, 0.0, 1.0, outer_delta[0], outer_delta[1]]));
let r = g.local_rect;
let rect = [r[0] + delta[0], r[1] + delta[1], r[2] + delta[0], r[3] + delta[1]];
if !valid_rect(rect) || !current.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the added item move is too large".into()));
}
let mut params = g.params.clone();
params.set(b"MoveOrigin".to_vec(), Object::Array(g.origin.0.map(Object::Real).to_vec()));
params.set(b"Rect".to_vec(), Object::Array(rect.map(Object::Real).to_vec()));
parameters.push((g.stream, params));
replacements.insert(g.op, Op::new("cm", current.0.map(Object::Real).to_vec()));
}
// A placement replaces exactly one operator. Keep its existing following separator,
// rather than adding a blank line every time the item is moved.
let edits = replacements
.into_iter()
.map(|(i, op)| {
let original = ops.get(i).ok_or_else(|| EditError::Invalid("an added item placement is missing".into()))?;
let mut bytes = pdfcraft_content::serialize_ops(&[op]);
if bytes.last() == Some(&b'\n') {
bytes.pop();
}
Ok((original.span.clone(), bytes))
})
.collect::<Result<Vec<_>, EditError>>()?;
let data = pieces.splice(edits);
let contents = crate::text::rewritten_contents(doc, &streams, data);
let refs = match &contents {
Object::Array(a) => a.clone(),
one => vec![one.clone()],
};
for (stream, params) in parameters {
let r = refs.get(stream).and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("the added item stream is missing".into()))?;
doc.update_dict(r, |d| d.set(b"PCAdded".to_vec(), Object::Dict(params)))?;
}
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
/// A missing or undecodable preceding stream can hide graphics state that affects every
/// later item. Refuse group editing rather than inventing an inventory from partial data.
fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let contents = match p.dict.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => {
if a.len() > MAX_PAGE_OBJECTS {
return Err(EditError::Invalid("this page has too many content streams to select objects together".into()));
}
a.clone()
}
_ => vec![c.clone()],
},
};
let mut remaining = MAX_CONTENT_BYTES;
let mut depth = 0usize;
let mut operations = 0usize;
let mut streams = Vec::with_capacity(contents.len());
for object in contents {
let resolved = doc.resolve(&object);
let Object::Stream(stream) = &*resolved else {
return Err(EditError::Invalid("a page content stream is missing".into()));
};
let bytes = stream
.decoded_within(remaining.min(64 * 1024 * 1024))
.map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?;
remaining =
remaining.checked_sub(bytes.len()).ok_or_else(|| EditError::Invalid("the page content is too large for group selection".into()))?;
let parsed = pdfcraft_content::parse(&bytes);
operations = operations.saturating_add(parsed.ops.len());
if operations > 100_000 {
return Err(EditError::Invalid("this page has too many content operations for group selection".into()));
}
for op in parsed.ops {
if op.is("q") {
depth = depth.saturating_add(1);
} else if op.is("Q") {
depth = depth.saturating_sub(1);
}
if depth > 1024 {
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
}
streams.push((object, bytes));
}
Ok(streams)
}