mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 17:31:35 +00:00
Conflicts:
- crates/edit/src/images.rs, crates/engine/src/doc_state.rs, crates/engine/src/edit/labels.rs,
crates/automation/src/dispatch.rs: main added Transform, Arrange and RemoveCrop next to this
branch's Ungroup; kept all.
- crates/edit/src/lib.rs: kept `mod ungroup;` with main's export list (ArrangeOrder).
- crates/engine/src/edit/run.rs: main moved EditPageImage into run_edit_page_image; carried
the Ungroup arm there, and Ungroup goes through change_image on the added-image path too.
- crates/automation/src/tools.rs: image_edit takes main's description and action list, with
this branch's ungroup added to both.
- crates/ui-egui/src/tools/edit_text_ui/image.rs: main rebuilt the image context menu; took
it and carried the Ungroup item (Form artwork only) before Replace image.
- crates/automation/tests/it/automation.rs, crates/edit/src/tests.rs,
crates/ui-egui/tests/it/editing.rs: both sides appended tests; kept both.
- crates/ui-egui/src/i18n/{ca,es,fi,hi,id,ko,ro,sq}.tsv: both sides appended; kept both,
main's lines first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
2037 lines
90 KiB
Rust
2037 lines
90 KiB
Rust
//! Edit a PDF ▸ edit existing images and grouped artwork (Image/Form XObjects painted by `Do`
|
|
//! in its own content streams), and changing one: move/resize/rotate/flip (the `Do` is wrapped in
|
|
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
|
|
//! delete, or ungroup grouped artwork (its content drawn in place, see `ungroup`). Only the stream that draws it is rewritten, as a new object.
|
|
|
|
use kurbo::{BezPath, Point, Shape};
|
|
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
|
|
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
|
use std::sync::Arc;
|
|
|
|
use crate::EditError;
|
|
use crate::text::content_streams;
|
|
|
|
/// The identity of a soft mask (`/SMask`) in the PDF graphics state.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct MaskIdentity {
|
|
// Share the owning ExtGState instead of copying an arbitrarily large inline mask on q/Q.
|
|
// Its complete SMask value retains S, TR, BC and every other mask parameter, not just G.
|
|
definition: Arc<Object>,
|
|
ctm: Matrix,
|
|
}
|
|
|
|
impl PartialEq for MaskIdentity {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.ctm == other.ctm && self.definition.as_dict().and_then(|d| d.get(b"SMask")) == other.definition.as_dict().and_then(|d| d.get(b"SMask"))
|
|
}
|
|
}
|
|
|
|
/// An image drawn on a page.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct PageImage {
|
|
/// Its box in user space.
|
|
pub rect: [f64; 4],
|
|
/// Visible frame after page and analytic path bounds. Hit testing also respects curve
|
|
/// interiors, winding rules and holes, rather than intercepting their empty areas.
|
|
pub visible_rect: Option<[f64; 4]>,
|
|
/// Current page/path clip in user space. A private crop transforms with its image.
|
|
pub clip_rect: Option<[f64; 4]>,
|
|
/// The placement: image unit square or Form coordinates → user space.
|
|
pub matrix: [f64; 6],
|
|
/// The XObject resource name and object.
|
|
pub name: String,
|
|
pub object: Option<ObjRef>,
|
|
/// Whether this is grouped Form artwork, edited as a whole (including its nested content).
|
|
pub is_form: bool,
|
|
/// Pixel size; zero for vector/grouped Form artwork.
|
|
pub width: u32,
|
|
pub height: u32,
|
|
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
|
|
/// operators of the page's streams joined ([`Pieces`]).
|
|
pub stream: usize,
|
|
op: usize,
|
|
/// CTM before `Do`, excluding a Form's own Matrix.
|
|
draw_matrix: [f64; 6],
|
|
clips: Vec<ClipPath>,
|
|
}
|
|
|
|
impl PageImage {
|
|
/// Whether an active clip was written by PdfCraft's image crop tool.
|
|
pub fn has_editor_crop(&self) -> bool {
|
|
self.clips.iter().any(|clip| clip.app_crop)
|
|
}
|
|
|
|
/// Whether a user-space point belongs to the placed artwork and every active clip.
|
|
pub fn contains(&self, point: [f64; 2]) -> bool {
|
|
if !point.iter().all(|v| v.is_finite()) {
|
|
return false;
|
|
}
|
|
let Some(bounds) = self.visible_rect else {
|
|
return false;
|
|
};
|
|
if point[0] < bounds[0] || point[0] > bounds[2] || point[1] < bounds[1] || point[1] > bounds[3] {
|
|
return false;
|
|
}
|
|
if !self.is_form {
|
|
let Some(inverse) = Matrix(self.matrix).invert() else {
|
|
return false;
|
|
};
|
|
let (x, y) = inverse.apply(point[0], point[1]);
|
|
if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
|
|
return false;
|
|
}
|
|
}
|
|
self.clips.iter().all(|clip| {
|
|
let winding = clip.path.winding(Point::new(point[0], point[1]));
|
|
if clip.even_odd { winding % 2 != 0 } else { winding != 0 }
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct ClipPath {
|
|
path: Arc<BezPath>,
|
|
even_odd: bool,
|
|
app_crop: bool,
|
|
}
|
|
|
|
/// Image draws one page's inventory lists at most: a form drawn many times from other forms
|
|
/// would otherwise multiply a few hundred bytes into millions of records.
|
|
pub(crate) const MAX_PAGE_IMAGES: usize = 100_000;
|
|
|
|
fn too_many_images() -> EditError {
|
|
EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images"))
|
|
}
|
|
|
|
/// The page's streams joined into the one stream they are: an operator may span two of them.
|
|
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
|
|
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
|
|
}
|
|
|
|
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
|
|
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
|
|
}
|
|
|
|
fn xobjects(doc: &Document, page: &Dict) -> Dict {
|
|
page.get(b"Resources")
|
|
.map(|r| doc.resolve(r))
|
|
.and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
|
|
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
|
|
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Placement {
|
|
matrix: Matrix,
|
|
clip: [f64; 4],
|
|
clips: Vec<ClipPath>,
|
|
}
|
|
|
|
/// Path coordinates are transformed when constructed; q/Q save clips, not the current path.
|
|
struct ImageState {
|
|
placement: Placement,
|
|
stack: Vec<Placement>,
|
|
path: BezPath,
|
|
subpath_start: Option<Point>,
|
|
pending_clip: Option<bool>,
|
|
app_crop: bool,
|
|
}
|
|
|
|
impl ImageState {
|
|
fn new(matrix: Matrix, clip: [f64; 4], clips: Vec<ClipPath>) -> Self {
|
|
Self {
|
|
placement: Placement { matrix, clip, clips },
|
|
stack: Vec::new(),
|
|
path: BezPath::new(),
|
|
subpath_start: None,
|
|
pending_clip: None,
|
|
app_crop: false,
|
|
}
|
|
}
|
|
|
|
fn current_point(&self) -> Option<Point> {
|
|
match self.path.elements().last() {
|
|
Some(kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p)) => Some(*p),
|
|
Some(kurbo::PathEl::QuadTo(_, p2) | kurbo::PathEl::CurveTo(_, _, p2)) => Some(*p2),
|
|
Some(kurbo::PathEl::ClosePath) => self.subpath_start,
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
fn has_current_point(&self) -> bool {
|
|
self.current_point().is_some()
|
|
}
|
|
|
|
fn point(&self, x: f64, y: f64) -> Option<Point> {
|
|
let (x, y) = self.placement.matrix.apply(x, y);
|
|
if x.is_finite() && y.is_finite() { Some(Point::new(x, y)) } else { None }
|
|
}
|
|
|
|
fn apply(&mut self, op: &Op) -> Result<(), EditError> {
|
|
if matches!(op.op.as_slice(), b"cm" | b"m" | b"l" | b"c" | b"v" | b"y" | b"re")
|
|
&& op.operands.iter().any(|v| v.as_f64().is_some_and(|v| !v.is_finite() || v.abs() > 1e12))
|
|
{
|
|
return Err(EditError::Invalid("the image path contains invalid coordinates".into()));
|
|
}
|
|
if self.path.elements().len() > 16384 || self.stack.len() > 4096 || self.placement.clips.len() > 256 {
|
|
return Err(EditError::Invalid("the image clip or graphics state exceeds its complexity budget".into()));
|
|
}
|
|
match op.op.as_slice() {
|
|
b"q" => self.stack.push(self.placement.clone()),
|
|
b"Q" => {
|
|
if let Some(saved) = self.stack.pop() {
|
|
self.placement = saved;
|
|
}
|
|
}
|
|
b"cm" => {
|
|
if let Some(m) = op.nums::<6>() {
|
|
let next_mat = Matrix(m).then(&self.placement.matrix);
|
|
if next_mat.0.iter().all(|v| v.is_finite()) {
|
|
self.placement.matrix = next_mat;
|
|
}
|
|
}
|
|
}
|
|
b"m" => {
|
|
if let Some([x, y]) = op.nums::<2>()
|
|
&& let Some(p) = self.point(x, y)
|
|
{
|
|
self.subpath_start = Some(p);
|
|
self.path.move_to(p);
|
|
}
|
|
}
|
|
b"l" => {
|
|
// A segment before any m (or re) has no subpath to extend: BezPath's line_to and
|
|
// friends panic on an empty path by contract, so the segment is skipped, as the
|
|
// renderer, MuPDF and pdftotext do (#3581).
|
|
if let Some([x, y]) = op.nums::<2>()
|
|
&& self.has_current_point()
|
|
&& let Some(p) = self.point(x, y)
|
|
{
|
|
self.path.line_to(p);
|
|
}
|
|
}
|
|
b"c" => {
|
|
if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>()
|
|
&& self.has_current_point()
|
|
&& let (Some(p1), Some(p2), Some(p3)) = (self.point(x1, y1), self.point(x2, y2), self.point(x3, y3))
|
|
{
|
|
self.path.curve_to(p1, p2, p3);
|
|
}
|
|
}
|
|
b"v" => {
|
|
if let Some([x1, y1, x2, y2]) = op.nums::<4>()
|
|
&& self.has_current_point()
|
|
&& let (Some(p1), Some(p2)) = (self.point(x1, y1), self.point(x2, y2))
|
|
&& let Some(current) = self.current_point()
|
|
{
|
|
self.path.curve_to(current, p1, p2);
|
|
}
|
|
}
|
|
b"y" => {
|
|
if let Some([x1, y1, x2, y2]) = op.nums::<4>()
|
|
&& self.has_current_point()
|
|
&& let (Some(p1), Some(end)) = (self.point(x1, y1), self.point(x2, y2))
|
|
{
|
|
self.path.curve_to(p1, end, end);
|
|
}
|
|
}
|
|
b"h" => {
|
|
// ClosePath with no subpath panics by the same contract (#3581).
|
|
if self.has_current_point() {
|
|
self.path.close_path();
|
|
}
|
|
}
|
|
b"re" => {
|
|
if let Some([x, y, w, h]) = op.nums::<4>()
|
|
&& let (Some(p0), Some(p1), Some(p2), Some(p3)) =
|
|
(self.point(x, y), self.point(x + w, y), self.point(x + w, y + h), self.point(x, y + h))
|
|
{
|
|
self.subpath_start = Some(p0);
|
|
self.path.move_to(p0);
|
|
self.path.line_to(p1);
|
|
self.path.line_to(p2);
|
|
self.path.line_to(p3);
|
|
self.path.close_path();
|
|
}
|
|
}
|
|
b"MP" if op.name(0) == Some(b"PdfCraftImageCrop") => self.app_crop = true,
|
|
b"W" => self.pending_clip = Some(false),
|
|
b"W*" => self.pending_clip = Some(true),
|
|
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
|
if let Some(even_odd) = self.pending_clip.take() {
|
|
// Filling/clipping implicitly closes open subpaths. BezPath's winding
|
|
// follows the same rule, and its bounds use cubic extrema, not controls.
|
|
let bounds = self.path.bounding_box();
|
|
self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]);
|
|
self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd, app_crop: self.app_crop });
|
|
}
|
|
self.path = BezPath::new();
|
|
self.subpath_start = None;
|
|
self.app_crop = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// The images and Form artwork page `page` (0-based) draws, in drawing order.
|
|
/// Forms stay intact: no traversal or rewriting of their internal objects is needed.
|
|
pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
|
|
let p = page_of(doc, page)?;
|
|
let xo = xobjects(doc, &p.dict);
|
|
let mut out = Vec::new();
|
|
// The streams are one stream in pieces, so the graphics state and operators carry over.
|
|
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
|
let pieces = joined(&content_streams(doc, &p.dict)?);
|
|
for (i, op) in pieces.parse().ops.iter().enumerate() {
|
|
state.apply(op)?;
|
|
let ctm = state.placement.matrix;
|
|
if op.is("Do") {
|
|
let Some(name) = op.name(0) else { continue };
|
|
let Some(resource) = xo.get(name) else { continue };
|
|
let obj = doc.resolve(resource);
|
|
let Object::Stream(s) = &*obj else { continue };
|
|
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
|
|
let (matrix, bounds) = if is_form {
|
|
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
|
|
v.as_array().and_then(|a| {
|
|
if a.len() != 4 {
|
|
return None;
|
|
}
|
|
let mut b = [0.0; 4];
|
|
for (n, v) in b.iter_mut().zip(a) {
|
|
*n = v.as_f64()?;
|
|
}
|
|
Some(b)
|
|
})
|
|
}) else {
|
|
continue;
|
|
};
|
|
let matrix = match s.dict.get(b"Matrix") {
|
|
Some(v) => {
|
|
let v = doc.resolve(v);
|
|
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
|
|
m
|
|
}
|
|
None => Matrix::IDENTITY,
|
|
};
|
|
(matrix.then(&ctm), bounds)
|
|
} else if s.dict.name(b"Subtype") == Some(b"Image") {
|
|
(ctm, [0.0, 0.0, 1.0, 1.0])
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
let rect = matrix.bbox(bounds);
|
|
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|
|
|| bounds[2] <= bounds[0]
|
|
|| bounds[3] <= bounds[1]
|
|
|| rect[2] <= rect[0]
|
|
|| rect[3] <= rect[1]
|
|
{
|
|
continue;
|
|
}
|
|
if out.len() >= MAX_PAGE_IMAGES {
|
|
return Err(too_many_images());
|
|
}
|
|
out.push(PageImage {
|
|
rect,
|
|
visible_rect: intersection(rect, state.placement.clip),
|
|
clip_rect: Some(state.placement.clip),
|
|
matrix: matrix.0,
|
|
name: String::from_utf8_lossy(name).into_owned(),
|
|
object: resource.as_ref(),
|
|
is_form,
|
|
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
|
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
|
stream: pieces.piece_of(op.span.start),
|
|
op: i,
|
|
draw_matrix: ctm.0,
|
|
clips: state.placement.clips.clone(),
|
|
});
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The images page `page` (0-based) shows, in drawing order, including those drawn inside form
|
|
/// XObjects (Export to Word, HTML and RTF). Read-only: indexes into this list are not
|
|
/// [`page_images`] indexes, so they can't be passed to [`change_image`].
|
|
pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
|
|
/// The CTM and `q` stack carry from one page stream to the next (§7.8.2); a form starts
|
|
/// its own.
|
|
/// The forms being read (cycle guard) and how many were entered (the page's budget).
|
|
struct Forms {
|
|
path: Vec<ObjRef>,
|
|
budget: crate::text::FormBudget,
|
|
}
|
|
fn walk(
|
|
doc: &Document,
|
|
ops: &[Op],
|
|
resources: &Dict,
|
|
st: &mut ImageState,
|
|
stream: &dyn Fn(&Op) -> usize,
|
|
forms: &mut Forms,
|
|
out: &mut Vec<PageImage>,
|
|
) -> Result<(), EditError> {
|
|
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
|
for (i, op) in ops.iter().enumerate() {
|
|
st.apply(op)?;
|
|
let ctm = st.placement.matrix;
|
|
if op.is("Do") {
|
|
let Some(name) = op.name(0) else { continue };
|
|
if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) {
|
|
if let Some(r) = form.obj {
|
|
forms.path.push(r);
|
|
}
|
|
let bounds =
|
|
xo.get(name).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then(
|
|
|b| {
|
|
let a = b.as_array()?;
|
|
let [x0, y0, x1, y1] = a.as_slice() else { return None };
|
|
Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?]))
|
|
},
|
|
);
|
|
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
|
|
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
|
|
let piece = stream(op);
|
|
walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?;
|
|
if form.obj.is_some() {
|
|
forms.path.pop();
|
|
}
|
|
continue;
|
|
}
|
|
let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue };
|
|
let obj = doc.get(r);
|
|
let Object::Stream(s) = &*obj else { continue };
|
|
if s.dict.name(b"Subtype") != Some(b"Image") {
|
|
continue;
|
|
}
|
|
if out.len() >= MAX_PAGE_IMAGES {
|
|
return Err(too_many_images());
|
|
}
|
|
out.push(PageImage {
|
|
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
|
|
visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip),
|
|
clip_rect: Some(st.placement.clip),
|
|
matrix: ctm.0,
|
|
name: String::from_utf8_lossy(name).into_owned(),
|
|
object: Some(r),
|
|
is_form: false,
|
|
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
|
|
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
|
|
stream: stream(op),
|
|
op: i,
|
|
draw_matrix: ctm.0,
|
|
clips: st.placement.clips.clone(),
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
let p = page_of(doc, page)?;
|
|
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
|
let mut out = Vec::new();
|
|
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
|
let mut decode = pdfcraft_cos::DecodeBudget::page();
|
|
let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?);
|
|
let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) };
|
|
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
|
|
Ok(out)
|
|
}
|
|
|
|
/// The four stacking order arrangements.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum ArrangeOrder {
|
|
BringToFront,
|
|
SendToBack,
|
|
BringForward,
|
|
SendBackward,
|
|
}
|
|
|
|
/// What to do with an image.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum ImageChange {
|
|
/// Apply a user-space transform after its placement (move, resize, rotate, flip).
|
|
Transform([f64; 6]),
|
|
/// Show only this part of it (a box in user space): a clipping path around the drawing,
|
|
/// inside its own scope so a later move or resize carries it along. The pixels stay where
|
|
/// they are; nothing is scaled.
|
|
Crop([f64; 4]),
|
|
/// Draw this image XObject instead, in the same place.
|
|
Replace(ObjRef),
|
|
Delete,
|
|
/// Change the stacking order.
|
|
Arrange(ArrangeOrder),
|
|
/// Remove any private crop on the image.
|
|
RemoveCrop,
|
|
/// Grouped Form artwork only: draw the form's content in place of its `Do`, so its text and
|
|
/// pictures become the page's own and can be edited one by one (#2964).
|
|
Ungroup,
|
|
}
|
|
|
|
/// The user-space transform that maps box `from` onto box `to` (move and resize).
|
|
pub fn rect_to_rect(from: [f64; 4], to: [f64; 4]) -> [f64; 6] {
|
|
let (fw, fh) = ((from[2] - from[0]).max(1e-6), (from[3] - from[1]).max(1e-6));
|
|
let (sx, sy) = ((to[2] - to[0]) / fw, (to[3] - to[1]) / fh);
|
|
[sx, 0.0, 0.0, sy, to[0] - from[0] * sx, to[1] - from[1] * sy]
|
|
}
|
|
|
|
/// Turn the image a quarter turn clockwise (`quarters` = 1, 2, 3) about its centre, or flip it.
|
|
pub fn turn_about_centre(rect: [f64; 4], quarters: i32, flip_h: bool, flip_v: bool) -> [f64; 6] {
|
|
let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0);
|
|
let (a, b, c, d) = match quarters.rem_euclid(4) {
|
|
1 => (0.0, -1.0, 1.0, 0.0),
|
|
2 => (-1.0, 0.0, 0.0, -1.0),
|
|
3 => (0.0, 1.0, -1.0, 0.0),
|
|
_ => (1.0, 0.0, 0.0, 1.0),
|
|
};
|
|
let (fx, fy) = (if flip_h { -1.0 } else { 1.0 }, if flip_v { -1.0 } else { 1.0 });
|
|
let (a, b, c, d) = (a * fx, b * fy, c * fx, d * fy);
|
|
// Translate the centre to the origin, turn, translate back.
|
|
[a, b, c, d, cx - a * cx - c * cy, cy - b * cx - d * cy]
|
|
}
|
|
|
|
/// Turn the image `degrees` clockwise (as drawn, whatever the page's `/Rotate`) about the centre
|
|
/// of `rect`. `None` for a non-finite angle; whole turns of 360 are the identity.
|
|
pub fn turn_by_degrees(rect: [f64; 4], degrees: f64) -> Option<[f64; 6]> {
|
|
if !degrees.is_finite() {
|
|
return None;
|
|
}
|
|
let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0);
|
|
let t = (degrees % 360.0).to_radians();
|
|
let (s, c) = t.sin_cos();
|
|
// Clockwise in a y-up space: (x, y) -> (x cos t + y sin t, -x sin t + y cos t).
|
|
let (a, b, cc, d) = (c, -s, s, c);
|
|
Some([a, b, cc, d, cx - a * cx - cc * cy, cy - b * cx - d * cy])
|
|
}
|
|
|
|
/// For a `Do` at `op` that sits alone in `q re W n cm Do Q`: the index of the `re` and the `cm` matrix.
|
|
/// That clip belongs to this image only, so it may move with it. A `cm` this move put before the
|
|
/// `re` earlier (`q cm re W n cm Do Q`) keeps the pattern, so the image can be moved again.
|
|
fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> {
|
|
let at = op.checked_sub(4)?;
|
|
let mut q = at.checked_sub(1)?;
|
|
while ops.get(q)?.is("cm") {
|
|
q = q.checked_sub(1)?;
|
|
}
|
|
let ok = ops.get(q)?.is("q")
|
|
&& ops.get(at)?.is("re")
|
|
&& (ops.get(at + 1)?.is("W") || ops.get(at + 1)?.is("W*"))
|
|
&& ops.get(at + 2)?.is("n")
|
|
&& ops.get(at + 3)?.is("cm")
|
|
&& ops.get(op + 1)?.is("Q");
|
|
if !ok {
|
|
return None;
|
|
}
|
|
Some((at, Matrix(ops.get(at + 3)?.nums::<6>()?)))
|
|
}
|
|
|
|
/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced
|
|
/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same
|
|
/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each.
|
|
fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> {
|
|
let at = start.checked_sub(1)?;
|
|
let cm = ops.get(at)?;
|
|
if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 {
|
|
return None;
|
|
}
|
|
let m = Matrix(cm.nums::<6>()?);
|
|
m.0.iter().all(|v| v.is_finite()).then_some((at, m))
|
|
}
|
|
|
|
/// The `cm` to put before the clip so that user-space transform `t` also moves the clip:
|
|
/// the clip is drawn in the CTM before the image's own `cm` (`cm` · that = `draw`).
|
|
fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option<Matrix> {
|
|
let outer = cm.invert()?.then(&draw);
|
|
Some(outer.then(&t).then(&outer.invert()?))
|
|
}
|
|
|
|
pub(crate) fn is_entire_page_wrapped(ops: &[Op]) -> bool {
|
|
if ops.len() < 2 || !ops.first().is_some_and(|o| o.is("q")) || !ops.last().is_some_and(|o| o.is("Q")) {
|
|
return false;
|
|
}
|
|
let mut depth: usize = 0;
|
|
for (i, op) in ops.iter().enumerate() {
|
|
if op.is("q") {
|
|
depth = depth.saturating_add(1);
|
|
} else if op.is("Q") {
|
|
if depth == 1 && i < ops.len().saturating_sub(1) {
|
|
return false;
|
|
}
|
|
depth = depth.saturating_sub(1);
|
|
}
|
|
}
|
|
depth == 0
|
|
}
|
|
|
|
/// The end (exclusive) of the sequence opened at `i`: just past the `close` that matches it,
|
|
/// counting the nested `opens`, or `end` when it never closes.
|
|
fn sequence_end(ops: &[Op], i: usize, end: usize, opens: &[&str], close: &str) -> usize {
|
|
let mut depth: usize = 1;
|
|
for (j, op) in ops.iter().enumerate().take(end).skip(i.saturating_add(1)) {
|
|
if opens.iter().any(|o| op.is(o)) {
|
|
depth = depth.saturating_add(1);
|
|
} else if op.is(close) {
|
|
depth = depth.saturating_sub(1);
|
|
if depth == 0 {
|
|
return j.saturating_add(1);
|
|
}
|
|
}
|
|
}
|
|
end
|
|
}
|
|
|
|
pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec<std::ops::Range<usize>> {
|
|
let mut units = Vec::new();
|
|
let mut i = scan_start;
|
|
while i < scan_end {
|
|
let Some(op) = ops.get(i) else { break };
|
|
let j = if op.is("BDC") || op.is("BMC") {
|
|
// A marked-content sequence is one unit up to its matching EMC (#3145), so Arrange moves
|
|
// it whole and never inserts into another one: its content belongs to its tag (ISO 32000-2
|
|
// § 14.6). An unbalanced sequence runs to the end of the scan.
|
|
sequence_end(ops, i, scan_end, &["BDC", "BMC"], "EMC")
|
|
} else if op.is("q") {
|
|
sequence_end(ops, i, scan_end, &["q"], "Q")
|
|
} else if op.is("BT") {
|
|
sequence_end(ops, i, scan_end, &[], "ET")
|
|
} else if op.is("BI") {
|
|
sequence_end(ops, i, scan_end, &[], "EI")
|
|
} else if op.is("Do") || op.is("sh") {
|
|
i.saturating_add(1)
|
|
} else {
|
|
let mut j = i;
|
|
let mut found_end = false;
|
|
while j < scan_end {
|
|
let Some(next) = ops.get(j) else { break };
|
|
if matches!(next.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") {
|
|
j = j.saturating_add(1);
|
|
found_end = true;
|
|
break;
|
|
} else if j > i
|
|
&& matches!(
|
|
next.op.as_slice(),
|
|
b"q" | b"Q" | b"BT" | b"BI" | b"Do" | b"sh" | b"BDC" | b"BMC" | b"EMC" | b"Tj" | b"TJ" | b"'" | b"\""
|
|
)
|
|
{
|
|
found_end = true;
|
|
break;
|
|
}
|
|
j = j.saturating_add(1);
|
|
}
|
|
if found_end { j } else { scan_end.max(i.saturating_add(1)) }
|
|
};
|
|
units.push(i..j);
|
|
i = j;
|
|
}
|
|
units.retain(|u| {
|
|
ops.get(u.clone()).is_some_and(|slice| {
|
|
slice.iter().any(|op| {
|
|
matches!(
|
|
op.op.as_slice(),
|
|
b"Do" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\""
|
|
)
|
|
})
|
|
})
|
|
});
|
|
units
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct ExtractedScopeState {
|
|
pub clip_ops: Vec<Op>,
|
|
pub state_ops: Vec<Op>,
|
|
pub do_op: Op,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(crate) struct EffectiveGState {
|
|
pub(crate) ca: Option<f64>,
|
|
pub(crate) ca_stroke: Option<f64>,
|
|
pub(crate) bm: Option<Vec<u8>>,
|
|
pub(crate) smask: Option<MaskIdentity>,
|
|
// Printing parameters cannot generally be reset by a content-stream operator. Retain their
|
|
// exact values so Arrange refuses a destination whose state it cannot reconstruct.
|
|
print_params: std::collections::BTreeMap<Vec<u8>, std::rc::Rc<Object>>,
|
|
rendering_intent: Option<Vec<u8>>,
|
|
}
|
|
|
|
impl EffectiveGState {
|
|
pub(crate) fn visually_differs(&self, other: &EffectiveGState) -> bool {
|
|
(self.ca.unwrap_or(1.0) - other.ca.unwrap_or(1.0)).abs() > 1e-4
|
|
|| (self.ca_stroke.unwrap_or(1.0) - other.ca_stroke.unwrap_or(1.0)).abs() > 1e-4
|
|
|| self.bm.as_deref().unwrap_or(b"Normal") != other.bm.as_deref().unwrap_or(b"Normal")
|
|
|| self.smask != other.smask
|
|
|| self.print_params != other.print_params
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, ctm: Matrix) {
|
|
self.merge_shared(&Arc::new(Object::Dict(dict.clone())), doc, ctm);
|
|
}
|
|
|
|
fn merge_shared(&mut self, definition: &Arc<Object>, doc: &Document, ctm: Matrix) {
|
|
let Some(dict) = definition.as_dict() else { return };
|
|
for key in [b"TR".as_slice(), b"TR2", b"OP", b"op", b"OPM", b"BG", b"BG2", b"UCR", b"UCR2", b"HT"] {
|
|
if let Some(value) = dict.get(key) {
|
|
self.print_params.insert(key.to_vec(), std::rc::Rc::new(value.clone()));
|
|
}
|
|
}
|
|
if let Some(intent) = dict.get(b"RI").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)) {
|
|
self.rendering_intent = Some(intent);
|
|
}
|
|
if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) {
|
|
self.ca = Some(val);
|
|
}
|
|
if let Some(val) = dict.get(b"CA").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) {
|
|
self.ca_stroke = Some(val);
|
|
}
|
|
if let Some(bm_obj) = dict.get(b"BM").map(|v| doc.resolve(v)) {
|
|
if let Some(name) = bm_obj.as_name() {
|
|
self.bm = Some(name.to_vec());
|
|
} else if let Some(arr) = bm_obj.as_array()
|
|
&& let Some(first_name) = arr.first().map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(|n| n.to_vec()))
|
|
{
|
|
self.bm = Some(first_name);
|
|
}
|
|
}
|
|
if let Some(raw_smask) = dict.get(b"SMask") {
|
|
let disabled = raw_smask.as_name() == Some(b"None") || raw_smask.as_ref().is_some_and(|r| doc.get(r).as_name() == Some(b"None"));
|
|
self.smask = (!disabled).then(|| MaskIdentity { definition: Arc::clone(definition), ctm });
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_ACTIVE_GS_OPS: usize = 128;
|
|
|
|
type ActiveGsOp = (Vec<u8>, Op, Matrix);
|
|
type ActiveGsOps = std::rc::Rc<Vec<ActiveGsOp>>;
|
|
|
|
#[derive(Clone, Default)]
|
|
struct ExtendedGStateTracker {
|
|
stack: Vec<(EffectiveGState, ActiveGsOps)>,
|
|
current_state: EffectiveGState,
|
|
active_gs_ops: ActiveGsOps,
|
|
definitions: std::collections::HashMap<Vec<u8>, Arc<Object>>,
|
|
}
|
|
|
|
impl ExtendedGStateTracker {
|
|
fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document, ctm: Matrix) -> Result<(), EditError> {
|
|
match op.op.as_slice() {
|
|
b"ri" => self.current_state.rendering_intent = op.name(0).map(<[u8]>::to_vec),
|
|
b"q" => {
|
|
if self.stack.len() >= 4096 {
|
|
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
|
|
}
|
|
self.stack.push((self.current_state.clone(), std::rc::Rc::clone(&self.active_gs_ops)));
|
|
}
|
|
b"Q" => {
|
|
if let Some((prev_state, prev_ops)) = self.stack.pop() {
|
|
self.current_state = prev_state;
|
|
self.active_gs_ops = prev_ops;
|
|
}
|
|
}
|
|
b"gs" => {
|
|
let valid_gs = op.name(0).and_then(|name| {
|
|
let definition = self.definitions.get(name).cloned().or_else(|| {
|
|
let definition = doc.resolve(ext_gstates.get(name)?);
|
|
definition.as_dict()?;
|
|
self.definitions.insert(name.to_vec(), Arc::clone(&definition));
|
|
Some(definition)
|
|
})?;
|
|
Some((name, definition))
|
|
});
|
|
if let Some((name, definition)) = valid_gs {
|
|
self.current_state.merge_shared(&definition, doc, ctm);
|
|
let mut next = (*self.active_gs_ops).clone();
|
|
if let Some(pos) = next.iter().position(|(k, _, _)| k.as_slice() == name) {
|
|
next.remove(pos);
|
|
}
|
|
if next.len() < MAX_ACTIVE_GS_OPS {
|
|
next.push((name.to_vec(), op.clone(), ctm));
|
|
}
|
|
self.active_gs_ops = std::rc::Rc::new(next);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// The drawing-state operators besides colour that a moved unit restores, in the order it writes them.
|
|
const DRAWING_PARAMS: [&str; 14] = ["w", "J", "j", "M", "d", "ri", "i", "Tc", "Tw", "Tz", "TL", "Tr", "Ts", "Tf"];
|
|
|
|
#[derive(Clone, Default, Debug, PartialEq)]
|
|
struct DrawingState {
|
|
non_stroking_color: Option<SharedColorOps>,
|
|
stroking_color: Option<SharedColorOps>,
|
|
/// The last operator of each of [`DRAWING_PARAMS`].
|
|
params: [Option<std::rc::Rc<Op>>; DRAWING_PARAMS.len()],
|
|
}
|
|
|
|
// Save/restore shares operand storage, including when only the current colour changes under q.
|
|
type SharedColorOps = std::rc::Rc<Vec<std::rc::Rc<Op>>>;
|
|
|
|
/// Keep the explicit or implicit colour-space selector before `sc`/`scn` (or `SC`/`SCN`).
|
|
fn set_color(slot: &mut Option<SharedColorOps>, op: &Op, space: &str) {
|
|
match slot {
|
|
Some(ops) if ops.first().is_some_and(|o| o.is(space) || matches!(o.op.as_slice(), b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K")) => {
|
|
let ops = std::rc::Rc::make_mut(ops);
|
|
ops.truncate(1);
|
|
ops.push(std::rc::Rc::new(op.clone()));
|
|
}
|
|
_ => *slot = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default, Debug)]
|
|
struct DrawingStateTracker {
|
|
stack: Vec<DrawingState>,
|
|
current: DrawingState,
|
|
}
|
|
|
|
impl DrawingStateTracker {
|
|
fn step(&mut self, op: &Op) -> Result<(), EditError> {
|
|
match op.op.as_slice() {
|
|
b"q" => {
|
|
if self.stack.len() >= 4096 {
|
|
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
|
|
}
|
|
self.stack.push(self.current.clone());
|
|
}
|
|
b"Q" => {
|
|
if let Some(prev) = self.stack.pop() {
|
|
self.current = prev;
|
|
}
|
|
}
|
|
b"g" | b"rg" | b"k" | b"cs" => self.current.non_stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])),
|
|
b"sc" | b"scn" => set_color(&mut self.current.non_stroking_color, op, "cs"),
|
|
b"G" | b"RG" | b"K" | b"CS" => self.current.stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])),
|
|
b"SC" | b"SCN" => set_color(&mut self.current.stroking_color, op, "CS"),
|
|
_ => {
|
|
if let Some(slot) = DRAWING_PARAMS.iter().position(|p| op.is(p)).and_then(|k| self.current.params.get_mut(k)) {
|
|
*slot = Some(std::rc::Rc::new(op.clone()));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Initial line style, rendering intent and text-state parameters.
|
|
fn initial_param(name: &str) -> Option<Op> {
|
|
use pdfcraft_content::num;
|
|
let operands = match name {
|
|
"w" => vec![num(1.0)],
|
|
"J" | "j" => vec![num(0.0)],
|
|
"M" => vec![num(10.0)],
|
|
"d" => vec![Object::Array(vec![]), num(0.0)],
|
|
"ri" => vec![Object::name("RelativeColorimetric")],
|
|
"Tc" | "Tw" | "TL" | "Tr" | "Ts" => vec![num(0.0)],
|
|
"Tz" => vec![num(100.0)],
|
|
_ => return None,
|
|
};
|
|
Some(Op::new(name, operands))
|
|
}
|
|
|
|
/// The drawing state the image had at `source` that differs at `dest`, unless the moved unit's own
|
|
/// state operators (`extracted_state_ops`) set it.
|
|
fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, extracted_state_ops: &[Op]) -> Vec<Op> {
|
|
let sets = |names: &[&str]| extracted_state_ops.iter().any(|o| names.iter().any(|n| o.is(n)));
|
|
let mut ops = Vec::new();
|
|
for (own, source, dest, black) in [
|
|
(sets(&["g", "rg", "k", "cs"]), &source.non_stroking_color, &dest.non_stroking_color, "g"),
|
|
(sets(&["G", "RG", "K", "CS"]), &source.stroking_color, &dest.stroking_color, "G"),
|
|
] {
|
|
match (own, source, dest) {
|
|
(true, _, _) | (false, None, None) => {}
|
|
(false, Some(color), _) => ops.extend(color.iter().map(|op| (**op).clone())),
|
|
(false, None, Some(_)) => ops.push(Op::new(black, vec![pdfcraft_content::num(0.0)])),
|
|
}
|
|
}
|
|
let mut text_ops = Vec::new();
|
|
for ((name, source), dest) in DRAWING_PARAMS.iter().zip(&source.params).zip(&dest.params) {
|
|
let output = if matches!(*name, "Tc" | "Tw" | "Tz" | "TL" | "Tr" | "Ts" | "Tf") { &mut text_ops } else { &mut ops };
|
|
match (sets(&[name]), source, dest) {
|
|
(true, _, _) | (false, None, None) => {}
|
|
(false, Some(op), _) => output.push((**op).clone()),
|
|
(false, None, Some(_)) => output.extend(initial_param(name)),
|
|
}
|
|
}
|
|
if !text_ops.is_empty() {
|
|
// Forms inherit text state, even though each BT resets its text matrices.
|
|
ops.push(Op::new("BT", vec![]));
|
|
ops.extend(text_ops);
|
|
ops.push(Op::new("ET", vec![]));
|
|
}
|
|
ops
|
|
}
|
|
|
|
/// The operators that set `clip` again, in user space. [`ImageState`] builds clips only from
|
|
/// finite points with `m`, `l`, `c` and `h`, so `None` (a quadratic segment) does not happen.
|
|
fn clip_path_to_ops(clip: &ClipPath) -> Option<Vec<Op>> {
|
|
let mut ops = Vec::new();
|
|
if clip.app_crop {
|
|
ops.push(crop_marker());
|
|
}
|
|
for el in clip.path.elements() {
|
|
let (name, points) = match el {
|
|
kurbo::PathEl::MoveTo(p) => ("m", vec![*p]),
|
|
kurbo::PathEl::LineTo(p) => ("l", vec![*p]),
|
|
kurbo::PathEl::CurveTo(p1, p2, p3) => ("c", vec![*p1, *p2, *p3]),
|
|
kurbo::PathEl::ClosePath => ("h", Vec::new()),
|
|
kurbo::PathEl::QuadTo(..) => return None,
|
|
};
|
|
ops.push(Op::new(name, points.iter().flat_map(|p| [pdfcraft_content::num(p.x), pdfcraft_content::num(p.y)]).collect()));
|
|
}
|
|
if !ops.is_empty() {
|
|
ops.push(Op::new(if clip.even_odd { "W*" } else { "W" }, vec![]));
|
|
ops.push(Op::new("n", vec![]));
|
|
}
|
|
Some(ops)
|
|
}
|
|
|
|
pub(crate) fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Matrix) -> Result<Option<ExtractedScopeState>, EditError> {
|
|
if target >= ops.len() || start > target {
|
|
return Ok(None);
|
|
}
|
|
let target_op = ops.get(target).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
|
if start == target {
|
|
return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new(), do_op: target_op }));
|
|
}
|
|
let mut clip_ops = Vec::new();
|
|
let mut state_ops = Vec::new();
|
|
let mut pending_path: Vec<Op> = Vec::new();
|
|
let mut pending_clip = false;
|
|
let mut active_path = false;
|
|
let mut depth: usize = 0;
|
|
let mut matrix = initial_ctm;
|
|
let mut matrix_at_clip = None;
|
|
|
|
let scan_start = if ops.get(start).is_some_and(|o| o.is("q")) { start.saturating_add(1) } else { start };
|
|
|
|
for i in scan_start..target {
|
|
let Some(op) = ops.get(i) else {
|
|
return Ok(None);
|
|
};
|
|
match op.op.as_slice() {
|
|
b"q" => {
|
|
if depth >= 4096 {
|
|
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
|
|
}
|
|
depth = depth.saturating_add(1);
|
|
}
|
|
b"Q" => {
|
|
if depth == 0 {
|
|
return Ok(None);
|
|
}
|
|
depth = depth.saturating_sub(1);
|
|
}
|
|
_ if depth > 0 => {}
|
|
b"cm" => {
|
|
if active_path {
|
|
return Ok(None);
|
|
}
|
|
if let Some(m) = op.nums::<6>() {
|
|
matrix = Matrix(m).then(&matrix);
|
|
}
|
|
}
|
|
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => {
|
|
active_path = true;
|
|
pending_path.push(op.clone());
|
|
}
|
|
b"W" | b"W*" => {
|
|
if !active_path {
|
|
return Ok(None);
|
|
}
|
|
pending_clip = true;
|
|
pending_path.push(op.clone());
|
|
}
|
|
b"n" => {
|
|
if pending_clip {
|
|
if matrix_at_clip.is_none() {
|
|
matrix_at_clip = Some(matrix);
|
|
} else if matrix_at_clip != Some(matrix) {
|
|
return Ok(None);
|
|
}
|
|
clip_ops.extend(std::mem::take(&mut pending_path));
|
|
clip_ops.push(op.clone());
|
|
pending_clip = false;
|
|
active_path = false;
|
|
} else {
|
|
pending_path.clear();
|
|
active_path = false;
|
|
}
|
|
}
|
|
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
|
if pending_clip {
|
|
return Ok(None);
|
|
}
|
|
pending_path.clear();
|
|
active_path = false;
|
|
}
|
|
b"gs" | b"CS" | b"cs" | b"SC" | b"sc" | b"SCN" | b"scn" | b"G" | b"g" | b"RG" | b"rg" | b"K" | b"k" | b"ri" | b"i" => {
|
|
state_ops.push(op.clone());
|
|
}
|
|
b"BT" => {
|
|
return Ok(None);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if depth != 0 || active_path || pending_clip {
|
|
return Ok(None);
|
|
}
|
|
|
|
if !clip_ops.is_empty() && matrix_at_clip != Some(Matrix::IDENTITY) {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(ExtractedScopeState { clip_ops, state_ops, do_op: target_op }))
|
|
}
|
|
|
|
/// The unit `order` puts the image's unit `target_idx` next to, and whether after it. An image that
|
|
/// shares its unit with other paint at the limit moves within that unit, past or behind the paint.
|
|
fn target_destination_unit<'a>(
|
|
units: &'a [std::ops::Range<usize>],
|
|
target_idx: usize,
|
|
target_unit: &'a std::ops::Range<usize>,
|
|
order: ArrangeOrder,
|
|
has_other_paint: bool,
|
|
) -> Result<(&'a std::ops::Range<usize>, bool), EditError> {
|
|
let forward = matches!(order, ArrangeOrder::BringToFront | ArrangeOrder::BringForward);
|
|
let refused = || EditError::Invalid(if forward { "the image is already in front" } else { "the image is already at the back" }.into());
|
|
let at_limit = if forward { target_idx.saturating_add(1) >= units.len() } else { target_idx == 0 };
|
|
if at_limit {
|
|
return if has_other_paint { Ok((target_unit, forward)) } else { Err(refused()) };
|
|
}
|
|
let dest = match order {
|
|
ArrangeOrder::BringToFront => units.last(),
|
|
ArrangeOrder::SendToBack => units.first(),
|
|
ArrangeOrder::BringForward => units.get(target_idx.saturating_add(1)),
|
|
ArrangeOrder::SendBackward => target_idx.checked_sub(1).and_then(|i| units.get(i)),
|
|
};
|
|
dest.map(|u| (u, forward)).ok_or_else(refused)
|
|
}
|
|
|
|
/// A moved unit must not borrow a graphics or marked-content boundary from its neighbours.
|
|
fn balanced_arrange_span(ops: &[Op], range: std::ops::Range<usize>) -> bool {
|
|
let Some(slice) = ops.get(range) else { return false };
|
|
let (mut graphics, mut marked) = (0usize, 0usize);
|
|
for op in slice {
|
|
let depth = match op.op.as_slice() {
|
|
b"q" => {
|
|
graphics = graphics.saturating_add(1);
|
|
continue;
|
|
}
|
|
b"BDC" | b"BMC" => {
|
|
marked = marked.saturating_add(1);
|
|
continue;
|
|
}
|
|
b"Q" => &mut graphics,
|
|
b"EMC" => &mut marked,
|
|
_ => continue,
|
|
};
|
|
let Some(next) = depth.checked_sub(1) else { return false };
|
|
*depth = next;
|
|
}
|
|
graphics == 0 && marked == 0
|
|
}
|
|
|
|
/// What Arrange takes out: the byte span, the op where the image's own scope state starts, and the
|
|
/// ops `start..end` removed. That is the image's own `q … Q` scope inside its unit, else the `Do`
|
|
/// alone when the unit paints something else too, else the whole unit.
|
|
fn determine_arrange_spans(
|
|
ops: &[Op],
|
|
target: usize,
|
|
target_unit: &std::ops::Range<usize>,
|
|
has_other_paint: bool,
|
|
marked_unit: bool,
|
|
) -> Result<(std::ops::Range<usize>, usize, usize, usize), EditError> {
|
|
let scope = image_scopes(ops, target, "arranged")
|
|
.ok()
|
|
.and_then(|s| s.isolated)
|
|
.filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end);
|
|
let unit_last = target_unit.end.saturating_sub(1);
|
|
let (first, scope_start, last) = match scope {
|
|
Some((start, end, _)) if has_other_paint || !marked_unit => (start, start, end),
|
|
_ if has_other_paint => (target, target, target),
|
|
// A marked-content unit holding only this image moves whole, with its BDC…EMC.
|
|
// The whole sequence is removed; the image's state is rebuilt from the ops inside it.
|
|
_ if marked_unit => (target_unit.start, target_unit.start.saturating_add(1).min(target), unit_last),
|
|
_ => (target_unit.start, target_unit.start, unit_last),
|
|
};
|
|
if !balanced_arrange_span(ops, first..last.saturating_add(1)) {
|
|
return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into()));
|
|
}
|
|
let missing = || EditError::Invalid("the image content unit could not be located".into());
|
|
let start = ops.get(first).ok_or_else(missing)?.span.start;
|
|
let end = ops.get(last).ok_or_else(missing)?.span.end;
|
|
Ok((start..end, scope_start, first, last.saturating_add(1)))
|
|
}
|
|
|
|
fn scan_leaking_ops(ops: &[Op], rem_start: usize, rem_end: usize) -> (Vec<Op>, bool) {
|
|
let mut q_depth = 0usize;
|
|
let mut leaking_ops = Vec::new();
|
|
let mut pending_clip_path: Vec<Op> = Vec::new();
|
|
let mut pending_clip = false;
|
|
let mut has_leaking_clip = false;
|
|
for op in ops.get(rem_start..rem_end).unwrap_or_default() {
|
|
match op.op.as_slice() {
|
|
b"q" => q_depth = q_depth.saturating_add(1),
|
|
b"Q" => q_depth = q_depth.saturating_sub(1),
|
|
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" if q_depth == 0 => {
|
|
pending_clip_path.push(op.clone());
|
|
}
|
|
b"W" | b"W*" if q_depth == 0 => {
|
|
if !pending_clip_path.is_empty() {
|
|
pending_clip = true;
|
|
pending_clip_path.push(op.clone());
|
|
}
|
|
}
|
|
b"n" if q_depth == 0 => {
|
|
if pending_clip {
|
|
pending_clip_path.push(op.clone());
|
|
leaking_ops.extend(std::mem::take(&mut pending_clip_path));
|
|
has_leaking_clip = true;
|
|
pending_clip = false;
|
|
} else {
|
|
pending_clip_path.clear();
|
|
}
|
|
}
|
|
b"rg" | b"RG" | b"g" | b"G" | b"k" | b"K" | b"cs" | b"CS" | b"sc" | b"scn" | b"SC" | b"SCN" | b"w" | b"J" | b"j" | b"M" | b"d"
|
|
| b"ri" | b"i" | b"gs" | b"Tf" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Tr" | b"Ts"
|
|
if q_depth == 0 =>
|
|
{
|
|
leaking_ops.push(op.clone());
|
|
}
|
|
b"cm" if q_depth == 0 => {
|
|
leaking_ops.push(op.clone());
|
|
}
|
|
_ => {
|
|
if q_depth == 0 && !op.is("BDC") && !op.is("BMC") && !op.is("EMC") && !pending_clip {
|
|
pending_clip_path.clear();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(leaking_ops, has_leaking_clip)
|
|
}
|
|
|
|
fn build_relocated_unit_ops(
|
|
marked_open: Option<&Op>,
|
|
source_gs_ops: &[(Op, Matrix)],
|
|
inherited_drawing_ops: &[Op],
|
|
extracted: ExtractedScopeState,
|
|
dest_ctm: Matrix,
|
|
draw_matrix: [f64; 6],
|
|
has_closing_emc: bool,
|
|
) -> Result<Vec<Op>, EditError> {
|
|
let mut unit_ops = Vec::new();
|
|
if let Some(open) = marked_open {
|
|
unit_ops.push(open.clone());
|
|
}
|
|
unit_ops.push(Op::new("q", vec![]));
|
|
let mut current_ctm = dest_ctm;
|
|
|
|
for (gs_op, target_ctm) in source_gs_ops {
|
|
if *target_ctm != current_ctm {
|
|
let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?;
|
|
let delta = target_ctm.then(¤t_inv);
|
|
if !delta.0.iter().all(|v| v.is_finite()) {
|
|
return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into()));
|
|
}
|
|
if delta != Matrix::IDENTITY {
|
|
unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec()));
|
|
current_ctm = *target_ctm;
|
|
}
|
|
}
|
|
unit_ops.push(gs_op.clone());
|
|
}
|
|
|
|
unit_ops.extend(inherited_drawing_ops.iter().cloned());
|
|
|
|
let non_gs_ops = extracted.state_ops.into_iter().filter(|o| !o.is("gs"));
|
|
unit_ops.extend(non_gs_ops);
|
|
|
|
if !extracted.clip_ops.is_empty() {
|
|
if current_ctm != Matrix::IDENTITY {
|
|
let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?;
|
|
unit_ops.push(Op::new("cm", current_inv.0.map(pdfcraft_content::num).to_vec()));
|
|
}
|
|
unit_ops.extend(extracted.clip_ops);
|
|
if Matrix(draw_matrix) != Matrix::IDENTITY {
|
|
unit_ops.push(Op::new("cm", draw_matrix.map(pdfcraft_content::num).to_vec()));
|
|
}
|
|
} else if Matrix(draw_matrix) != current_ctm {
|
|
let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?;
|
|
let delta = Matrix(draw_matrix).then(¤t_inv);
|
|
if !delta.0.iter().all(|v| v.is_finite()) {
|
|
return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into()));
|
|
}
|
|
if delta != Matrix::IDENTITY {
|
|
unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec()));
|
|
}
|
|
}
|
|
unit_ops.push(extracted.do_op);
|
|
unit_ops.push(Op::new("Q", vec![]));
|
|
if has_closing_emc {
|
|
unit_ops.push(Op::new("EMC", vec![]));
|
|
}
|
|
Ok(unit_ops)
|
|
}
|
|
|
|
/// Change the stacking order of `img`, drawn on page `p` by its content streams `all`.
|
|
fn arrange_image(
|
|
doc: &mut Document,
|
|
p: &pdfcraft_model::Page,
|
|
all: &[(Object, Vec<u8>)],
|
|
img: &PageImage,
|
|
order: ArrangeOrder,
|
|
) -> Result<(), EditError> {
|
|
let pieces = joined(all);
|
|
let ops = pieces.parse().ops;
|
|
let target = img.op;
|
|
let target_op = ops.get(target).filter(|op| op.is("Do")).ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
|
|
|
|
let is_wrapped = is_entire_page_wrapped(&ops);
|
|
let (scan_start, scan_end) = if is_wrapped { (1, ops.len().saturating_sub(1)) } else { (0, ops.len()) };
|
|
let units = content_units(&ops, scan_start, scan_end);
|
|
let Some((target_idx, target_unit)) = units.iter().enumerate().find(|(_, u)| u.contains(&target)) else {
|
|
return Err(EditError::Invalid("the image content unit could not be located".into()));
|
|
};
|
|
let has_other_paint = ops.iter().enumerate().any(|(i, op)| {
|
|
i >= target_unit.start
|
|
&& i < target_unit.end
|
|
&& i != target
|
|
&& matches!(
|
|
op.op.as_slice(),
|
|
b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*"
|
|
)
|
|
});
|
|
if units.len() <= 1 && !has_other_paint {
|
|
return Err(EditError::Invalid("the page has only one content unit to arrange".into()));
|
|
}
|
|
|
|
let (dest_unit, insert_after) = target_destination_unit(&units, target_idx, target_unit, order, has_other_paint)?;
|
|
if !balanced_arrange_span(&ops, target_unit.clone()) || !balanced_arrange_span(&ops, dest_unit.clone()) {
|
|
return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into()));
|
|
}
|
|
|
|
// A marked-content sequence that holds other content cannot be split without generating
|
|
// a new structure element and MCID, so we refuse to rearrange it to avoid leaving the image untagged.
|
|
// If the sequence holds only this image, the whole marked unit is moved intact.
|
|
let marked_unit = ops.get(target_unit.start).is_some_and(|o| o.is("BDC") || o.is("BMC"));
|
|
let marked_depth = ops.iter().take(target).fold(0usize, |depth, op| {
|
|
if op.is("BDC") || op.is("BMC") {
|
|
depth.saturating_add(1)
|
|
} else if op.is("EMC") {
|
|
depth.saturating_sub(1)
|
|
} else {
|
|
depth
|
|
}
|
|
});
|
|
if marked_depth > usize::from(marked_unit) {
|
|
return Err(EditError::Invalid("cannot rearrange an image out of a nested marked-content sequence".into()));
|
|
}
|
|
if marked_unit && has_other_paint {
|
|
return Err(EditError::Invalid("cannot rearrange an image that shares a marked-content sequence with other content".into()));
|
|
}
|
|
|
|
let (mut target_span, scope_start, rem_start, rem_end) = determine_arrange_spans(&ops, target, target_unit, has_other_paint, marked_unit)?;
|
|
|
|
let missing = || EditError::Invalid("the destination content unit could not be located".into());
|
|
let (dest_pos, scan_target) = if insert_after {
|
|
(dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).ok_or_else(missing)?.span.end, dest_unit.end)
|
|
} else {
|
|
(ops.get(dest_unit.start).ok_or_else(missing)?.span.start, dest_unit.start)
|
|
};
|
|
|
|
// An added item owns its entire stream: editing or deleting it replaces that stream.
|
|
// Insert ordinary artwork next to the complete owned unit in a separate stream instead.
|
|
let destination_piece = if insert_after {
|
|
dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).map(|op| pieces.piece_of(op.span.end.saturating_sub(1)))
|
|
} else {
|
|
ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start))
|
|
};
|
|
let owned_destination = destination_piece
|
|
.filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some())));
|
|
if let Some(piece) = owned_destination {
|
|
let whole_unit = ops.iter().enumerate().all(|(index, op)| {
|
|
let (first, last) = pieces.pieces_of(op);
|
|
if dest_unit.contains(&index) { first == piece && last == piece } else { piece < first || piece > last }
|
|
});
|
|
if !whole_unit || img.stream == piece {
|
|
return Err(EditError::Invalid("cannot arrange an image inside an added item's content stream".into()));
|
|
}
|
|
}
|
|
|
|
let (leaking_ops, has_leaking_clip) = scan_leaking_ops(&ops, rem_start, rem_end);
|
|
|
|
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
|
let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
|
|
|
|
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
|
let mut tracker = ExtendedGStateTracker::default();
|
|
let mut drawing_tracker = DrawingStateTracker::default();
|
|
let mut source_effective_state = EffectiveGState::default();
|
|
let mut source_gs_ops = Vec::new();
|
|
let mut source_drawing_state = DrawingState::default();
|
|
let mut dest_effective_state = EffectiveGState::default();
|
|
let mut dest_drawing_state = DrawingState::default();
|
|
let mut dest_ctm = Matrix::IDENTITY;
|
|
let mut scope_start_ctm = Matrix::IDENTITY;
|
|
let mut outer_clips = Vec::new();
|
|
let mut dest_clips = Vec::new();
|
|
let mut ctm_before_removal = Matrix::IDENTITY;
|
|
let mut ctm_after_removal = Matrix::IDENTITY;
|
|
|
|
let max_scan = scan_target.max(target.saturating_add(1)).max(scope_start.saturating_add(1)).max(rem_end);
|
|
for (i, op) in ops.iter().enumerate() {
|
|
if i == rem_start {
|
|
ctm_before_removal = state.placement.matrix;
|
|
}
|
|
if i == scope_start {
|
|
scope_start_ctm = state.placement.matrix;
|
|
outer_clips = state.placement.clips.clone();
|
|
}
|
|
if i == target {
|
|
source_effective_state = tracker.current_state.clone();
|
|
source_gs_ops = tracker.active_gs_ops.iter().map(|(_, op, ctm)| (op.clone(), *ctm)).collect();
|
|
source_drawing_state = drawing_tracker.current.clone();
|
|
}
|
|
if i == scan_target {
|
|
dest_effective_state = tracker.current_state.clone();
|
|
dest_drawing_state = drawing_tracker.current.clone();
|
|
dest_ctm = state.placement.matrix;
|
|
dest_clips = state.placement.clips.clone();
|
|
}
|
|
if i >= max_scan {
|
|
break;
|
|
}
|
|
state.apply(op)?;
|
|
tracker.step(op, &ext_gstates, doc, state.placement.matrix)?;
|
|
drawing_tracker.step(op)?;
|
|
if op.is("gs")
|
|
&& op.name(0).and_then(|name| tracker.definitions.get(name)).and_then(|gs| gs.as_dict()).is_some_and(|gs| gs.get(b"Font").is_some())
|
|
&& let Some(font) = DRAWING_PARAMS.iter().position(|name| *name == "Tf").and_then(|index| drawing_tracker.current.params.get_mut(index))
|
|
{
|
|
// The replayed gs selects its font; do not overwrite it with an older direct Tf.
|
|
*font = None;
|
|
}
|
|
if op.is("gs")
|
|
&& let Some(intent) = &tracker.current_state.rendering_intent
|
|
{
|
|
drawing_tracker.step(&Op::new("ri", vec![Object::Name(intent.clone())]))?;
|
|
}
|
|
if i == rem_end.saturating_sub(1) {
|
|
ctm_after_removal = state.placement.matrix;
|
|
}
|
|
}
|
|
if scan_target >= ops.len() {
|
|
dest_effective_state = tracker.current_state.clone();
|
|
dest_drawing_state = drawing_tracker.current.clone();
|
|
dest_ctm = state.placement.matrix;
|
|
dest_clips = state.placement.clips.clone();
|
|
}
|
|
if dest_ctm.invert().is_none() {
|
|
return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into()));
|
|
}
|
|
|
|
let owned_crop = ops.get(scope_start..target).unwrap_or_default().iter().any(|op| op.is("MP") && op.name(0) == Some(b"PdfCraftImageCrop"));
|
|
let extracted_scope = if owned_crop { None } else { extract_scope_state(&ops, scope_start, target, scope_start_ctm)? };
|
|
let mut extracted = match extracted_scope {
|
|
Some(ext) => ext,
|
|
None if scope_start != target => {
|
|
if !marked_unit || has_other_paint {
|
|
target_span = target_op.span.clone();
|
|
}
|
|
extract_scope_state(&ops, target, target, Matrix(img.draw_matrix))?
|
|
.ok_or_else(|| EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()))?
|
|
}
|
|
None => {
|
|
return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()));
|
|
}
|
|
};
|
|
|
|
// The clips the moved unit draws itself: all of the image's when they are rebuilt from
|
|
// `img.clips`, else those its own scope set after the clips around it.
|
|
let all_clips = extracted.clip_ops.is_empty();
|
|
let reemitted = if all_clips {
|
|
img.clips.as_slice()
|
|
} else if img.clips.starts_with(&outer_clips) {
|
|
img.clips.get(outer_clips.len()..).unwrap_or_default()
|
|
} else {
|
|
return Err(EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into()));
|
|
};
|
|
// At the destination it is clipped by the clips there and the ones it draws. Moving past a
|
|
// clip the image isn't under (or out from under one it is) would change what part shows.
|
|
if !dest_clips.iter().all(|c| img.clips.contains(c)) || !img.clips.iter().all(|c| dest_clips.contains(c) || reemitted.contains(c)) {
|
|
return Err(EditError::Invalid(
|
|
"unsupported graphics state: moving the image past a clipping path would change what part of it shows".into(),
|
|
));
|
|
}
|
|
|
|
if all_clips {
|
|
for clip in &img.clips {
|
|
let ops = clip_path_to_ops(clip)
|
|
.ok_or_else(|| EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into()))?;
|
|
extracted.clip_ops.extend(ops);
|
|
}
|
|
}
|
|
|
|
let has_inherited_clip = !img.clips.is_empty() && extracted.clip_ops.is_empty();
|
|
if has_inherited_clip {
|
|
return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into()));
|
|
}
|
|
|
|
let mut reconstructed_state = dest_effective_state.clone();
|
|
for (op, ctm) in &source_gs_ops {
|
|
if let Some(name) = op.name(0)
|
|
&& let Some(definition) = tracker.definitions.get(name)
|
|
{
|
|
reconstructed_state.merge_shared(definition, doc, *ctm);
|
|
}
|
|
}
|
|
|
|
if reconstructed_state.visually_differs(&source_effective_state) {
|
|
return Err(EditError::Invalid(
|
|
"unsupported graphics state: moving the image would alter opacity, blend mode, soft mask, or printing parameters".into(),
|
|
));
|
|
}
|
|
|
|
let marked_open = if marked_unit && !has_other_paint { ops.get(target_unit.start) } else { None };
|
|
let has_closing_emc = marked_open.is_some() && target_unit.end.checked_sub(1).and_then(|i| ops.get(i)).is_some_and(|o| o.is("EMC"));
|
|
let inherited_drawing_ops = compute_inherited_drawing_ops(&source_drawing_state, &dest_drawing_state, &extracted.state_ops);
|
|
let unit_ops =
|
|
build_relocated_unit_ops(marked_open, &source_gs_ops, &inherited_drawing_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?;
|
|
|
|
let mut serialized = Vec::new();
|
|
serialized.push(b'\n');
|
|
serialized.extend_from_slice(&serialize_ops(&unit_ops));
|
|
serialized.push(b'\n');
|
|
|
|
let leaking = if let Some(inv) = ctm_before_removal.invert() { ctm_after_removal.then(&inv) } else { Matrix::IDENTITY };
|
|
if leaking != Matrix::IDENTITY && has_leaking_clip {
|
|
return Err(EditError::Invalid("unsupported graphics state: moving the image would alter clipping path transform".into()));
|
|
}
|
|
let mut leak_ops = leaking_ops;
|
|
if leaking != Matrix::IDENTITY && leak_ops.iter().all(|o| !o.is("cm")) {
|
|
leak_ops.insert(0, Op::new("cm", leaking.0.map(pdfcraft_content::num).to_vec()));
|
|
}
|
|
let removal_replacement = if !leak_ops.is_empty() {
|
|
let mut s = Vec::new();
|
|
s.push(b'\n');
|
|
s.extend_from_slice(&serialize_ops(&leak_ops));
|
|
s.push(b'\n');
|
|
s
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
if let Some(piece) = owned_destination {
|
|
let changed = pieces.splice([(target_span, removal_replacement)]);
|
|
let contents = crate::text::rewritten_contents(doc, all, changed);
|
|
let mut list = match contents {
|
|
Object::Array(list) => list,
|
|
one => vec![one],
|
|
};
|
|
let insertion = piece.saturating_add(usize::from(insert_after));
|
|
if insertion > list.len() {
|
|
return Err(EditError::Invalid("the destination content stream is missing".into()));
|
|
}
|
|
let relocated = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &serialized)));
|
|
list.insert(insertion, Object::Ref(relocated));
|
|
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
|
|
return Ok(());
|
|
}
|
|
|
|
let mut edits = Vec::new();
|
|
if dest_pos >= target_span.end {
|
|
edits.push((target_span, removal_replacement));
|
|
edits.push((dest_pos..dest_pos, serialized));
|
|
} else if dest_pos <= target_span.start {
|
|
edits.push((dest_pos..dest_pos, serialized));
|
|
edits.push((target_span, removal_replacement));
|
|
} else {
|
|
return Err(EditError::Invalid("cannot arrange image into overlapping content range".into()));
|
|
}
|
|
|
|
let changed = pieces.splice(edits);
|
|
let contents = crate::text::rewritten_contents(doc, all, changed);
|
|
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The saved graphics-state scopes (`q … Q`) around the `Do` at `target` that paint nothing else,
|
|
/// each as `(q, Q, CTM before the q)`.
|
|
struct ImageScopes {
|
|
/// The innermost one inside which every clip on the image is set.
|
|
isolated: Option<(usize, usize, Matrix)>,
|
|
/// The outermost one. It holds all of the image's private clips, but not a clip set outside it.
|
|
outermost: Option<(usize, usize, Matrix)>,
|
|
/// The CTM at the `Do`, and whether a clip applies there.
|
|
placement: Matrix,
|
|
clipped: bool,
|
|
}
|
|
|
|
impl ImageScopes {
|
|
/// The saved graphics-state scope containing just this image and its crop, or the `Do` alone
|
|
/// when nothing clips it. Applying a transform before that scope moves the actual clipping path
|
|
/// as well as the pixels. A shared clip must never be moved along with unrelated artwork or text.
|
|
///
|
|
/// Under a clip set outside every scope that holds only this image (a report or table drawing
|
|
/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active
|
|
/// clip. The outermost scope holding only the image is used then: it contains all of the
|
|
/// image's private clips, which move with it, while the outer clip is shared and stays.
|
|
fn transform_scope(&self, target: usize) -> Option<(usize, usize, Matrix)> {
|
|
if !self.clipped {
|
|
return Some((target, target, self.placement));
|
|
}
|
|
self.isolated.or(self.outermost)
|
|
}
|
|
}
|
|
|
|
/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`.
|
|
fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, EditError> {
|
|
struct Scope {
|
|
start: usize,
|
|
matrix: Matrix,
|
|
inherited_clip: bool,
|
|
inherited_path: bool,
|
|
selected: bool,
|
|
other_paint: bool,
|
|
}
|
|
let mut stack: Vec<Scope> = Vec::new();
|
|
let mut matrix = Matrix::IDENTITY;
|
|
let mut clipped = false;
|
|
let mut path = false;
|
|
let mut pending_clip = false;
|
|
let mut text = false;
|
|
let mut at_image = None;
|
|
let mut isolated = None;
|
|
let mut outermost = None;
|
|
for (index, op) in ops.iter().enumerate() {
|
|
match op.op.as_slice() {
|
|
b"q" => {
|
|
if stack.len() >= 256 {
|
|
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
|
|
}
|
|
stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false });
|
|
}
|
|
b"Q" => {
|
|
if let Some(scope) = stack.pop() {
|
|
if scope.selected && !scope.other_paint && !scope.inherited_path {
|
|
let found = (scope.start, index, scope.matrix);
|
|
if !scope.inherited_clip {
|
|
isolated.get_or_insert(found);
|
|
}
|
|
// Scopes close inner first, so the last one recorded is the outermost.
|
|
outermost = Some(found);
|
|
}
|
|
matrix = scope.matrix;
|
|
clipped = scope.inherited_clip;
|
|
path = scope.inherited_path;
|
|
pending_clip = false;
|
|
}
|
|
}
|
|
b"cm" => {
|
|
if let Some(m) = op.nums::<6>() {
|
|
matrix = Matrix(m).then(&matrix);
|
|
}
|
|
}
|
|
b"BT" => text = true,
|
|
b"ET" => text = false,
|
|
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path = true,
|
|
b"W" | b"W*" => pending_clip = true,
|
|
_ => {}
|
|
}
|
|
if index == target {
|
|
if text {
|
|
return Err(EditError::Invalid(format!("an image inside a text object cannot be {done}")));
|
|
}
|
|
at_image = Some((matrix, clipped));
|
|
for scope in &mut stack {
|
|
scope.selected = true;
|
|
}
|
|
} else if matches!(
|
|
op.op.as_slice(),
|
|
b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*"
|
|
) {
|
|
for scope in &mut stack {
|
|
scope.other_paint = true;
|
|
}
|
|
}
|
|
if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") {
|
|
clipped |= pending_clip;
|
|
pending_clip = false;
|
|
path = false;
|
|
}
|
|
}
|
|
let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
|
Ok(ImageScopes { isolated, outermost, placement, clipped })
|
|
}
|
|
|
|
/// A marked-content point identifies only the clipping path written by this editor.
|
|
/// It does not open a marked-content sequence or change the document's structure tags.
|
|
fn crop_marker() -> Op {
|
|
Op::new("MP", vec![Object::Name(b"PdfCraftImageCrop".to_vec())])
|
|
}
|
|
|
|
fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec<std::ops::Range<usize>> {
|
|
let mut ranges = Vec::new();
|
|
for (index, op) in ops.iter().enumerate().take(target).skip(start) {
|
|
if !op.is("MP") || op.name(0) != Some(b"PdfCraftImageCrop") {
|
|
continue;
|
|
}
|
|
let path_start = index.saturating_add(1);
|
|
let mut end = path_start;
|
|
while end < target && ops.get(end).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) {
|
|
end = end.saturating_add(1);
|
|
}
|
|
if end > path_start && ops.get(end).is_some_and(|o| o.is("W") || o.is("W*")) && ops.get(end.saturating_add(1)).is_some_and(|o| o.is("n")) {
|
|
ranges.push(index..end.saturating_add(2));
|
|
}
|
|
}
|
|
ranges
|
|
}
|
|
|
|
/// Remove only crops written by PdfCraft, preserving the document author's clipping paths.
|
|
fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec<u8>)], img: &PageImage) -> Result<(), EditError> {
|
|
let pieces = joined(all);
|
|
let ops = pieces.parse().ops;
|
|
let target = img.op;
|
|
let scope = image_scopes(&ops, target, "cropped")?;
|
|
let Some((start, _, _)) = scope.transform_scope(target) else {
|
|
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
|
|
};
|
|
let crops = app_crop_ranges(&ops, start, target);
|
|
if crops.is_empty() {
|
|
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
|
|
}
|
|
let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index))));
|
|
let contents = crate::text::rewritten_contents(doc, all, changed);
|
|
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Change image `index` (from [`page_images`]) on `page`.
|
|
pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> {
|
|
let images = page_images(doc, page)?;
|
|
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
|
|
let p = page_of(doc, page)?;
|
|
let all = content_streams(doc, &p.dict)?;
|
|
let pieces = joined(&all);
|
|
let ops = pieces.parse().ops;
|
|
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
|
|
return Err(EditError::Invalid("the image operation is missing".into()));
|
|
}
|
|
let mut resources = None;
|
|
let replacement: Vec<Op> = match change {
|
|
ImageChange::Delete => Vec::new(),
|
|
ImageChange::Transform(t) => {
|
|
return transform_image(doc, &p, &all, &img, *t);
|
|
}
|
|
ImageChange::Arrange(order) => {
|
|
return arrange_image(doc, &p, &all, &img, *order);
|
|
}
|
|
ImageChange::RemoveCrop => {
|
|
return remove_image_crop(doc, &p, &all, &img);
|
|
}
|
|
ImageChange::Crop(keep) => {
|
|
return crop_image_impl(doc, &p, &all, &img, *keep);
|
|
}
|
|
ImageChange::Ungroup => {
|
|
if !img.is_form {
|
|
return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into()));
|
|
}
|
|
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))?;
|
|
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 {
|
|
table.set(name, Object::Ref(doc.add(copy)));
|
|
}
|
|
merged.set(b"Pattern".to_vec(), Object::Dict(table));
|
|
}
|
|
resources = Some(merged);
|
|
inline
|
|
}
|
|
ImageChange::Replace(r) => {
|
|
if img.is_form {
|
|
return Err(EditError::Invalid(
|
|
"grouped Form artwork can be moved, resized, rotated, flipped or deleted; replacing it with a bitmap is not supported".into(),
|
|
));
|
|
}
|
|
let name = format!("PCImg{}", r.num);
|
|
let mut res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
|
let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
|
xo.set(name.clone().into_bytes(), Object::Ref(*r));
|
|
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
|
resources = Some(res);
|
|
vec![Op::new("Do", vec![Object::name(&name)])]
|
|
}
|
|
};
|
|
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
|
|
let contents = crate::text::rewritten_contents(doc, &all, changed);
|
|
doc.update_dict(p.obj, |d| {
|
|
d.set(b"Contents".to_vec(), contents);
|
|
if let Some(res) = resources {
|
|
d.set(b"Resources".to_vec(), Object::Dict(res));
|
|
}
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Inventory indexes exclude the streams that belong to added content.
|
|
pub(crate) fn selectable_images(
|
|
doc: &Document,
|
|
page: usize,
|
|
excluded: &std::collections::BTreeSet<ObjRef>,
|
|
) -> Result<Vec<(usize, PageImage)>, EditError> {
|
|
let p = page_of(doc, page)?;
|
|
let all = content_streams(doc, &p.dict)?;
|
|
Ok(page_images(doc, page)?
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter(|(_, image)| !all.get(image.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
|
|
.collect())
|
|
}
|
|
|
|
fn transform_image(
|
|
doc: &mut Document,
|
|
page: &pdfcraft_model::Page,
|
|
streams: &[(Object, Vec<u8>)],
|
|
image: &PageImage,
|
|
transform: [f64; 6],
|
|
) -> Result<(), EditError> {
|
|
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
|
|
}
|
|
let pieces = joined(streams);
|
|
let ops = pieces.parse().ops;
|
|
let target = image.op;
|
|
if !ops.get(target).is_some_and(|op| op.is("Do")) {
|
|
return Err(EditError::Invalid("the image operation could not be located".into()));
|
|
}
|
|
// A private path crop moves with its image regardless of intervening state operators.
|
|
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
|
|
let (start, end, placement) =
|
|
image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
|
|
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
|
|
let local = placement.then(&Matrix(transform)).then(&inverse);
|
|
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the image transform is too large".into()));
|
|
}
|
|
|
|
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
|
|
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
|
|
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
|
|
let folded = local.then(&cm);
|
|
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the image transform is too large".into()));
|
|
}
|
|
let changed = crate::text::splice(&pieces, &ops, |index| {
|
|
if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) }
|
|
});
|
|
let contents = crate::text::rewritten_contents(doc, streams, changed);
|
|
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
|
return Ok(());
|
|
}
|
|
let changed = crate::text::splice(&pieces, &ops, |index| {
|
|
if index == start {
|
|
let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())];
|
|
if start == end {
|
|
out.extend(ops.get(index).cloned());
|
|
out.push(Op::new("Q", vec![]));
|
|
return (out, false);
|
|
}
|
|
(out, true)
|
|
} else if index == end {
|
|
let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect();
|
|
out.push(Op::new("Q", vec![]));
|
|
(out, false)
|
|
} else {
|
|
(Vec::new(), true)
|
|
}
|
|
});
|
|
let contents = crate::text::rewritten_contents(doc, streams, changed);
|
|
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Crop: clip the image's drawing to `keep` (user space). The clip goes just inside the scope
|
|
/// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in
|
|
/// that scope's coordinates as the box's four corners, so a turned image crops to an upright
|
|
/// box too and a later transform of the scope moves the crop with the pixels.
|
|
fn crop_image_impl(
|
|
doc: &mut Document,
|
|
page: &pdfcraft_model::Page,
|
|
streams: &[(Object, Vec<u8>)],
|
|
image: &PageImage,
|
|
keep: [f64; 4],
|
|
) -> Result<(), EditError> {
|
|
if image.is_form {
|
|
return Err(EditError::Invalid("cropping grouped Form artwork is not supported".into()));
|
|
}
|
|
if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into()));
|
|
}
|
|
let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])];
|
|
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
|
|
return Err(EditError::Invalid("the crop box has no area".into()));
|
|
}
|
|
if intersection(image.rect, [x0, y0, x1, y1]).is_none() {
|
|
return Err(EditError::Invalid("the crop box does not intersect the image".into()));
|
|
}
|
|
|
|
let pieces = joined(streams);
|
|
let ops = pieces.parse().ops;
|
|
let target = image.op;
|
|
if !ops.get(target).is_some_and(|op| op.is("Do")) {
|
|
return Err(EditError::Invalid("the image operation could not be located".into()));
|
|
}
|
|
let (start, end, placement) =
|
|
image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
|
|
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
|
|
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
|
|
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the crop box is too large for the image's placement".into()));
|
|
}
|
|
let is_axis_aligned = ((corners[0].1 - corners[1].1).abs() < 1e-4
|
|
&& (corners[1].0 - corners[2].0).abs() < 1e-4
|
|
&& (corners[2].1 - corners[3].1).abs() < 1e-4
|
|
&& (corners[3].0 - corners[0].0).abs() < 1e-4)
|
|
|| ((corners[0].0 - corners[1].0).abs() < 1e-4
|
|
&& (corners[1].1 - corners[2].1).abs() < 1e-4
|
|
&& (corners[2].0 - corners[3].0).abs() < 1e-4
|
|
&& (corners[3].1 - corners[0].1).abs() < 1e-4);
|
|
let mut clip: Vec<Op> = if is_axis_aligned {
|
|
let min_x = corners.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
|
|
let max_x = corners.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max);
|
|
let min_y = corners.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
|
|
let max_y = corners.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max);
|
|
vec![
|
|
Op::new("re", vec![Object::Real(min_x), Object::Real(min_y), Object::Real(max_x - min_x), Object::Real(max_y - min_y)]),
|
|
Op::new("W", vec![]),
|
|
Op::new("n", vec![]),
|
|
]
|
|
} else {
|
|
corners
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)]))
|
|
.chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])])
|
|
.collect()
|
|
};
|
|
clip.insert(0, crop_marker());
|
|
// Replace only our identified crop; an author's path must remain intact.
|
|
let old_crops = app_crop_ranges(&ops, start, target);
|
|
let insert_at = start.saturating_add(1);
|
|
let changed = crate::text::splice(&pieces, &ops, |index| {
|
|
if start == end && index == target {
|
|
// No scope of its own yet: one around the drawing, with the crop inside.
|
|
let mut out = vec![Op::new("q", vec![])];
|
|
out.extend(clip.iter().cloned());
|
|
out.extend(ops.get(index).cloned());
|
|
out.push(Op::new("Q", vec![]));
|
|
(out, false)
|
|
} else if start != end && index == insert_at {
|
|
// Inside the scope's `q`, before whatever it draws (in place of its old crop).
|
|
(clip.clone(), !old_crops.iter().any(|r| r.contains(&index)))
|
|
} else if old_crops.iter().any(|r| r.contains(&index)) {
|
|
(Vec::new(), false)
|
|
} else {
|
|
(Vec::new(), true)
|
|
}
|
|
});
|
|
let contents = crate::text::rewritten_contents(doc, streams, changed);
|
|
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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> {
|
|
if indexes.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let images = page_images(doc, page)?;
|
|
let p = page_of(doc, page)?;
|
|
let all = content_streams(doc, &p.dict)?;
|
|
let mut plans = std::collections::BTreeMap::new();
|
|
for index in indexes {
|
|
let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?;
|
|
let placement = Matrix(image.draw_matrix);
|
|
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?;
|
|
let [a, b, c, d, _, _] = inverse.0;
|
|
let movement = Matrix([1.0, 0.0, 0.0, 1.0, a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]]);
|
|
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the image move is too large".into()));
|
|
}
|
|
plans.insert(image.op, (movement, placement, offset));
|
|
}
|
|
let pieces = joined(&all);
|
|
let ops = pieces.parse().ops;
|
|
let mut inside_text = false;
|
|
for (index, op) in ops.iter().enumerate() {
|
|
if op.is("BT") {
|
|
inside_text = true;
|
|
} else if op.is("ET") {
|
|
inside_text = false;
|
|
} else if inside_text && plans.contains_key(&index) {
|
|
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
|
|
}
|
|
}
|
|
// Images alone in their own `q re W n cm Do Q` take the clip with them.
|
|
let mut clips = std::collections::BTreeMap::new();
|
|
let mut crop_starts = std::collections::BTreeMap::new();
|
|
let mut crop_ends = std::collections::BTreeSet::new();
|
|
let mut folds = std::collections::BTreeMap::new();
|
|
for image in images.iter().filter(|image| image.has_editor_crop()) {
|
|
let Some(&(_, _, offset)) = plans.get(&image.op) else { continue };
|
|
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else {
|
|
return Err(EditError::Invalid("the image crop cannot be moved independently".into()));
|
|
};
|
|
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?;
|
|
let local = placement.then(&Matrix::translate(offset[0], offset[1])).then(&inverse);
|
|
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the image move is too large".into()));
|
|
}
|
|
let leading_transform = || {
|
|
let at = start.checked_add(1)?;
|
|
if start >= end || !ops.get(start)?.is("q") || !ops.get(at)?.is("cm") {
|
|
return None;
|
|
}
|
|
ops.get(at)?.nums::<6>().map(|m| (at, Matrix(m)))
|
|
};
|
|
// An enclosing cm is before the captured placement; an inside-scope cm is after it.
|
|
// Their multiplication orders differ when that cm scales or shears a translation.
|
|
let folded = enclosing_transform(&ops, start, end)
|
|
.map(|(at, cm)| (at, local.then(&cm)))
|
|
.or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local))));
|
|
if let Some((at, folded)) = folded {
|
|
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the image move is too large".into()));
|
|
}
|
|
folds.insert(at, folded);
|
|
} else {
|
|
crop_starts.insert(start, local);
|
|
crop_ends.insert(end);
|
|
}
|
|
plans.remove(&image.op);
|
|
}
|
|
for index in 0..ops.len() {
|
|
let Some(&(_, placement, offset)) = plans.get(&index) else { continue };
|
|
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
|
|
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
|
|
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
|
|
return Err(EditError::Invalid("the image move is too large".into()));
|
|
};
|
|
plans.remove(&index);
|
|
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
|
|
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
|
|
let folded = at
|
|
.checked_sub(1)
|
|
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
|
|
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
|
|
match folded {
|
|
Some((i, m)) => clips.insert(i, (m, false)),
|
|
None => clips.insert(at, (c, true)),
|
|
};
|
|
}
|
|
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
|
|
for index in 0..ops.len() {
|
|
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
|
|
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
|
|
let folded = movement.then(&cm);
|
|
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
|
return Err(EditError::Invalid("the image move is too large".into()));
|
|
}
|
|
plans.remove(&index);
|
|
folds.insert(at, folded);
|
|
}
|
|
if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) {
|
|
return Err(EditError::Invalid("a selected image operation is missing".into()));
|
|
}
|
|
let data = crate::text::splice(&pieces, &ops, |index| {
|
|
if let Some(matrix) = crop_starts.remove(&index) {
|
|
return (vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())], true);
|
|
}
|
|
if crop_ends.remove(&index) {
|
|
let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect();
|
|
out.push(Op::new("Q", vec![]));
|
|
return (out, false);
|
|
}
|
|
if let Some((c, keep)) = clips.remove(&index) {
|
|
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
|
|
}
|
|
if let Some(m) = folds.remove(&index) {
|
|
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
|
|
}
|
|
let Some((matrix, _, _)) = plans.remove(&index) else { return (Vec::new(), true) };
|
|
let original = ops.get(index).cloned().into_iter();
|
|
// Local translations can be tiny under a large CTM. Preserve their precision
|
|
// rather than rounding them to zero or amplifying an error on the page.
|
|
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
|
|
out.extend(original);
|
|
out.push(Op::new("Q", vec![]));
|
|
(out, false)
|
|
});
|
|
let contents = crate::text::rewritten_contents(doc, &all, data);
|
|
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod allocation_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn saved_drawing_states_share_large_operand_storage() {
|
|
let mut tracker = DrawingStateTracker::default();
|
|
let parsed = parse(b"[1 2 3 4 5 6 7 8] 0 d /Pattern cs 1 2 3 4 5 6 /P scn q q q");
|
|
for op in &parsed.ops {
|
|
tracker.step(op).unwrap();
|
|
}
|
|
for state in &tracker.stack {
|
|
let dash = state.params[4].as_ref().unwrap();
|
|
let current_dash = tracker.current.params[4].as_ref().unwrap();
|
|
assert!(std::ptr::eq(dash.operands.as_ptr(), current_dash.operands.as_ptr()), "q must share the dash operands instead of cloning them");
|
|
let color = state.non_stroking_color.as_ref().unwrap().last().unwrap();
|
|
let current_color = tracker.current.non_stroking_color.as_ref().unwrap().last().unwrap();
|
|
assert!(std::ptr::eq(color.operands.as_ptr(), current_color.operands.as_ptr()), "q must share scn operands instead of cloning them");
|
|
}
|
|
let saved = tracker.current.clone();
|
|
for op in &parse(b"[9 10] 0 d 4 5 6 /Other scn Q").ops {
|
|
tracker.step(op).unwrap();
|
|
}
|
|
assert_eq!(tracker.current, saved, "changing shared operands must preserve the saved state");
|
|
}
|
|
|
|
#[test]
|
|
fn review_soft_mask_identity_includes_establishment_matrix() {
|
|
let doc = Document::new_empty();
|
|
let mask = Dict::from_iter([(b"S".to_vec(), Object::name("Alpha")), (b"G".to_vec(), Object::Ref(ObjRef::new(8, 0)))]);
|
|
let definition = Dict::from_iter([(b"SMask".to_vec(), Object::Dict(mask))]);
|
|
let states = Dict::from_iter([(b"A".to_vec(), Object::Dict(definition))]);
|
|
let mut tracker = ExtendedGStateTracker::default();
|
|
let op = Op::new("gs", vec![Object::name("A")]);
|
|
tracker.step(&op, &states, &doc, Matrix::IDENTITY).unwrap();
|
|
let before = tracker.current_state.clone();
|
|
tracker.step(&op, &states, &doc, Matrix::translate(50.0, 0.0)).unwrap();
|
|
assert!(before.visually_differs(&tracker.current_state), "the same mask established under a different CTM is a different effective mask");
|
|
}
|
|
}
|