Merge upstream/main into fix/update-without-prev-is-refused

This commit is contained in:
mtlcl410
2026-11-08 23:27:12 +08:00
16 changed files with 2197 additions and 12 deletions
+1
View File
@@ -467,6 +467,7 @@ impl Automation {
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
"remove_crop" => ImageEdit::RemoveCrop,
"ungroup" => ImageEdit::Ungroup,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
+2 -2
View File
@@ -1562,14 +1562,14 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, ungroup (Form artwork only: its content is drawn in place, so its text becomes editable with text_paragraphs/text_edit and its pictures separate; refused for knockout groups and for transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's, which would composite differently, and for forms whose /DefaultGray, /DefaultRGB or /DefaultCMYK the page doesn't share, which would stop remapping their colours), or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"image": { "type": "integer", "minimum": 1 },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop", "ungroup"] },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"quarters": { "type": "integer" },
"degrees": { "type": "number" },
+148
View File
@@ -11168,6 +11168,154 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how
/// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece
/// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its
/// text becomes paragraphs that `text_edit` changes, and undo brings the form back.
fn table_in_a_form_pdf() -> Vec<u8> {
let table = b"BT /F1 12 Tf 1 0 0 1 20 120 Tm (Revenue) Tj 1 0 0 1 120 120 Tm (153,300.00) Tj 1 0 0 1 20 100 Tm (Costs) Tj 1 0 0 1 120 100 Tm (382,110.00) Tj ET 0.6 G 0.5 w 15 115 m 220 115 l S";
// Two pictures with soft masks overlapping each other, the second also over the table's
// last cell: a translucent wash, and a picture whose corner pixels are see-through.
let inner = [&table[..], b" q 40 0 0 20 30 40 cm /Sig Do Q q 60 0 0 40 50 30 cm /Wash Do Q q 100 0 0 30 110 90 cm /Sig Do Q"].concat();
let rgb: Vec<u8> = std::iter::repeat_n([20u8, 60, 220], 16).flatten().collect();
let alpha: Vec<u8> = (0..16).map(|i| if i % 3 == 0 { 255 } else { 0 }).collect();
let page = b"q /Fm1 Do Q";
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 160] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
stream("<<", page),
stream(
"<< /Type /XObject /Subtype /Form /BBox [0 0 240 160] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /Font << /F1 6 0 R >> /XObject << /Sig 7 0 R /Wash 9 0 R >> >>",
&inner,
),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 8 0 R", &rgb),
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceGray /BitsPerComponent 8", &alpha),
stream(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 10 0 R",
&[230, 40, 40].repeat(4),
),
stream("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[128, 96, 160, 64]),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn ungrouping_a_table_drawn_in_a_form_makes_its_text_editable() {
let dir = workdir("ungroup");
std::fs::write(dir.join("table.pdf"), table_in_a_form_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
let paragraphs = |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"].as_array().unwrap().len();
let objects = |a: &mut Automation| ok(a, "object_list", json!({ "doc": doc, "page": 1 }))["objects"].as_array().unwrap().len();
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "one piece of artwork, no text");
let (w, h, before) = a.render_pixels(doc, 1, 2.0).unwrap();
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" }));
let (w2, h2, after) = a.render_pixels(doc, 1, 2.0).unwrap();
assert_eq!((w, h), (w2, h2));
let differing = before.as_ref().as_chunks::<4>().0.iter().zip(after.as_ref().as_chunks::<4>().0).filter(|(p, q)| p != q).count();
assert_eq!(differing, 0, "ungrouping draws the same pixels");
assert!(paragraphs(&mut a) >= 4, "the table's text is page text now");
let images = ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }));
let kinds: Vec<_> = images["images"].as_array().unwrap().iter().map(|i| i["kind"].clone()).collect();
assert_eq!(kinds, vec![json!("image"); 3], "the pictures are separate pictures now: {images}");
let list = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
let target =
list["paragraphs"].as_array().unwrap().iter().find(|p| p["text"].as_str().is_some_and(|t| t.contains("382,110.00"))).unwrap()["paragraph"]
.clone();
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": target, "text": "400,000.00" }));
let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
let s = text.to_string();
assert!(s.contains("400,000.00") && !s.contains("382,110.00"), "{s}");
ok(&mut a, "edit_undo", json!({ "doc": doc }));
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "undo brings the form back");
}
/// One 300 × 300 page that draws form `Fm1` with `page_do`; the form (with `matrix`) fills with
/// pattern `/P1` (`pattern`) in `content`, and a red outline on the page marks where it lands.
fn pattern_in_a_form_pdf(page_do: &str, matrix: &str, content: &str, pattern: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_string(),
stream_object("<<", &format!("{page_do} 1 0 0 RG 1 w 60 60 180 180 re S")),
stream_object(&format!("<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] {matrix} /Resources << /Pattern << /P1 6 0 R >> >>"), content),
pattern.to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// #3067 review: a pattern's matrix maps pattern space to the default space of the stream that
/// uses it (ISO 32000-2 §8.7.2), so a pattern filled inside a form moved by its `/Matrix` or by a
/// `cm` before the `Do` must stay where it was when the form is drawn inline. Each case draws
/// differently in Acrobat when the placement is lost (tiles out of phase or half size, the
/// shading outside its box); ungrouped, every one paints the same pixels.
#[test]
fn ungrouping_keeps_patterns_where_the_forms_placement_put_them() {
let tiling = stream_object(
"<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >>",
"0 0 0 rg 0 0 10 10 re f 10 10 10 10 re f",
);
let shading = "<< /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [false false] >> >>";
let cases = [
("no placement", "q /Fm1 Do Q", "", "/Pattern cs /P1 scn 60 60 180 180 re f", tiling.as_str()),
("form Matrix, half a tile", "q /Fm1 Do Q", "/Matrix [1 0 0 1 10 0]", "/Pattern cs /P1 scn 50 60 180 180 re f", tiling.as_str()),
("cm doubling the form", "q 2 0 0 2 0 0 cm /Fm1 Do Q", "", "/Pattern cs /P1 scn 30 30 90 90 re f", tiling.as_str()),
("shading moved by the form Matrix", "q /Fm1 Do Q", "/Matrix [1 0 0 1 100 100]", "/Pattern cs /P1 scn 0 0 100 100 re f", shading),
];
for (what, page_do, matrix, content, pattern) in cases {
let dir = workdir("ungroup_patterns");
std::fs::write(dir.join("p.pdf"), pattern_in_a_form_pdf(page_do, matrix, content, pattern)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "p.pdf" }))["doc"].as_u64().unwrap();
let (w, h, before) = a.render_pixels(doc, 1, 1.0).unwrap();
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
let (w2, h2, after) = a.render_pixels(saved, 1, 1.0).unwrap();
assert_eq!((w, h), (w2, h2), "{what}");
let differing = before.as_ref().as_chunks::<4>().0.iter().zip(after.as_ref().as_chunks::<4>().0).filter(|(p, q)| p != q).count();
assert_eq!(differing, 0, "{what}: ungrouping draws the same pixels, saved and reopened");
}
}
/// An indirect stream object: `dict` (without its closing `>>`) and `data`.
fn stream_object(dict: &str, data: &str) -> String {
format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
}
/// Reviewing headlessly: corrections keep the boxes, a stale or foreign review is refused, and
/// applying consumes the handle (the pages already accepted were applied once).
#[test]
+7 -1
View File
@@ -342,8 +342,14 @@ impl Stream {
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
self.decoded_strict_within(MAX_DECODED)
}
/// Like [`Stream::decoded_strict`], bounded by `max` (and [`MAX_DECODED`]): for data that
/// will be copied elsewhere, where a damaged stream must not pass for a shorter one.
pub fn decoded_strict_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0));
self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] bounded by `max` and counted like
+23 -1
View File
@@ -1,7 +1,7 @@
//! 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),
//! or delete. Only the stream that draws it is rewritten, as a new object.
//! 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, parse, serialize_ops};
@@ -465,6 +465,9 @@ pub enum ImageChange {
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).
@@ -1660,6 +1663,25 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
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(
+1
View File
@@ -51,6 +51,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
mod ungroup;
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
+2 -2
View File
@@ -1,7 +1,7 @@
//! A page's ruled lines: the horizontal and vertical strokes (and thin filled bars) its content
//! draws, so exports can tell a ruled table from text that merely lines up (#5149).
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_content::Matrix;
use pdfcraft_cos::Document;
use crate::EditError;
@@ -26,7 +26,7 @@ pub fn page_rules(doc: &Document, page: usize) -> Result<Vec<[f64; 4]>, EditErro
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut out: Vec<[f64; 4]> = Vec::new();
File diff suppressed because it is too large Load Diff
+590
View File
@@ -0,0 +1,590 @@
//! Edit a PDF ▸ Ungroup: draw a Form XObject's content in place of its `Do` (#2964, #3005).
//!
//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own
//! content streams. Ungrouping copies the form's content into the page at the same place,
//! wrapped as `q <Matrix> cm <BBox> re W n … Q`, with the form's resources merged into a copy of
//! the page's under names that don't clash. Its text then edits like any other. The form object
//! itself is left alone, so another page or placement that draws it is unchanged. Each resource
//! name is read from the operand the renderer uses ([`resource_operand`]), so a surplus operand
//! before it changes neither the renaming nor the checks below.
//!
//! Drawing a form inline paints the same pixels unless the form is a transparency group whose
//! compositing differs from painting its parts one by one: a knockout group, a group that uses
//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft
//! mask, or a group with a blending colour space (`/CS`) other than the page's: its contents
//! composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they would
//! composite in the page's. Those are refused rather than changed, as is a form whose
//! `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` the page doesn't share (it remaps the form's
//! device colours, §8.6.5.6, and inline the page's resources apply), tagged form content (its
//! structure link would be lost) and content whose marked content or text objects don't balance
//! (inlined, they would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
//!
//! A pattern's matrix maps pattern space to the default space of the content stream that uses it
//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is
//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose
//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused.
use std::collections::HashMap;
use pdfcraft_content::{Matrix, Op, parse};
use pdfcraft_cos::{Dict, Document, Object};
use crate::EditError;
/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above
/// any real form, and a bomb must not be copied into the page.
const MAX_FORM_BYTES: usize = 64 << 20;
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
/// The `/ColorSpace` entries that remap device colours painted with the resources they are in
/// (ISO 32000-2 §8.6.5.6).
const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"];
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
const MAX_MOVED_PATTERNS: usize = 256;
/// A name map for one resource category: form name → name in the page's resources.
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with
/// the form's merged in, and the pattern copies to add to the document, each under the name in
/// the merged `/Pattern` dictionary that the ops already use.
pub(crate) type Ungrouped = (Vec<Op>, Dict, Vec<(Vec<u8>, Object)>);
/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]).
/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`,
/// then the CTM there).
pub(crate) fn ungroup_ops(
doc: &Document,
resources: &Dict,
name: &[u8],
translucent: bool,
page_space: Option<&Object>,
placement: Matrix,
) -> Result<Ungrouped, EditError> {
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
if !is_name(doc, &s.dict, b"Subtype", b"Form") {
return Err(refuse("it is not a Form XObject"));
}
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
};
let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?;
let matrix = match s.dict.get(b"Matrix") {
None => None,
Some(_) => Some(Matrix(
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?)
.map_err(|_| refuse("its matrix is unreadable"))?,
)),
};
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
let form_res = own.clone().unwrap_or_else(|| resources.clone());
// A tagged form's content is a structure element's: drawn inline it would lose that link.
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags"));
}
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"));
if let Some(group) = &group {
if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) {
return Err(refuse("it is a knockout transparency group"));
}
if translucent {
return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask"));
}
// Its contents composite in its own colour space (ISO 32000-2 §11.6.6, Table 145 /CS);
// inline they would composite in the page's.
if let Some(cs) = group.get(b"CS")
&& !same_blend_space(doc, cs, page_space)
{
return Err(refuse("it is a transparency group that blends in a colour space of its own"));
}
}
// Strictly: damaged data must not be copied in as a shorter form.
let data = s.decoded_strict_within(MAX_FORM_BYTES).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
let parsed = parse(&data);
if parsed.skipped > 0 {
return Err(refuse("its content has parts that can't be read"));
}
let mut ops = parsed.ops;
if group.is_some() {
// The form itself is being read: drawing itself again counts as blending.
let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0 };
if blends_inside(doc, &form_res, &ops, 0, &mut walk) {
return Err(refuse("it is a transparency group with blend modes inside"));
}
}
// A form's default colour space remaps its device colours (ISO 32000-2 §8.6.5.6); inline,
// the page's resources are the current ones (§7.8.3).
if own.is_some()
&& let Some(key) = unshared_default_space(doc, &form_res, resources)
{
let key = String::from_utf8_lossy(key);
return Err(refuse(&format!("it remaps its device colours with a /{key} the page doesn't share")));
}
// Merge the form's resources into a copy of the page's, renaming what clashes.
let mut merged = resources.clone();
let mut renames: Vec<(&[u8], Renames)> = Vec::new();
if own.is_some() {
for cat in CATEGORIES {
let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let mut map = Renames::new();
let mut n = 0u32;
for (key, value) in theirs.iter() {
// A default colour space checked above is the page's, which applies inline;
// renamed, it would be an entry nothing uses.
if mine.get(key) == Some(value) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&&key[..])) {
continue;
}
let fresh = loop {
n = n.saturating_add(1);
let candidate = format!("PCUg{n}").into_bytes();
if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() {
break candidate;
}
if n == u32::MAX {
return Err(refuse("too many resources"));
}
};
mine.set(fresh.clone(), value.clone());
map.push((key.to_vec(), fresh));
}
merged.set(cat.to_vec(), Object::Dict(mine));
if !map.is_empty() {
renames.push((cat, map));
}
}
}
let renamed = |cat: &[u8], name: &[u8]| -> Option<Vec<u8>> {
renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone())
};
// A nested form, Type 3 font or tiling pattern without resources of its own looks its names
// up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it
// would find the page's, which differ wherever a name was renamed (every form resource that
// isn't already the page's, the nested item's own entry included).
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) {
return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources"));
}
let mut depth = 0usize;
// Marked content and text objects must balance within the form's own stream: an extra EMC
// would close one the page opened, and one left open would take in the page's later content.
let mut marked = 0usize;
let mut in_text = false;
for op in &mut ops {
match op.op.as_slice() {
b"BDC" | b"BMC" => marked = marked.saturating_add(1),
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?,
b"BT" if in_text => return Err(refuse("its content begins a text object inside another")),
b"BT" => in_text = true,
b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")),
b"ET" => in_text = false,
_ => {}
}
match op.op.as_slice() {
b"q" => depth = depth.saturating_add(1),
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?,
_ => {}
}
let slot = resource_operand(op).map_err(|Unreadable| refuse("its content selects a resource with operands that can't be read"))?;
if let Some((cat, i)) = slot
&& let Some(operand) = op.operands.get_mut(i)
&& let Object::Name(n) = operand
&& let Some(to) = renamed(cat, n)
{
*operand = Object::Name(to);
}
if let Some((dict, _)) = &mut op.inline {
for key in [&b"CS"[..], b"ColorSpace"] {
if let Some(Object::Name(n)) = dict.get(key)
&& let Some(to) = renamed(b"ColorSpace", n)
{
dict.set(key.to_vec(), Object::Name(to));
}
}
}
}
if marked > 0 || in_text {
return Err(refuse("its content leaves marked content or a text object open"));
}
// Patterns keep their place relative to the shapes they fill: each one the form selects is
// drawn from a copy that also carries the placement. Nested forms and patterns inside
// patterns keep their own parents, so only this stream's selections change.
let mut patterns: Vec<(Vec<u8>, Object)> = Vec::new();
if placement != Matrix::IDENTITY {
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let mut moved: Renames = Vec::new();
let mut n = 0u32;
for op in &mut ops {
let Ok(Some((b"Pattern", i))) = resource_operand(op) else { continue };
let Some(Object::Name(from)) = op.operands.get(i) else { continue };
// A name the resources don't have selects nothing, inline or not.
let Some(entry) = table.get(from).cloned() else { continue };
let to = match moved.iter().find(|(f, _)| f == from) {
Some((_, to)) => to.clone(),
None => {
if moved.len() >= MAX_MOVED_PATTERNS {
return Err(refuse("it paints with too many patterns"));
}
let copy =
moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?;
let fresh = loop {
n = n.saturating_add(1);
let candidate = format!("PCUgPat{n}").into_bytes();
if table.get(&candidate).is_none() {
break candidate;
}
if n == u32::MAX {
return Err(refuse("too many resources"));
}
};
// Reserved here; the caller replaces it with the copy's reference.
table.set(fresh.clone(), Object::Null);
patterns.push((fresh.clone(), copy));
moved.push((from.clone(), fresh.clone()));
fresh
}
};
if let Some(operand) = op.operands.get_mut(i) {
*operand = Object::Name(to);
}
}
if !patterns.is_empty() {
merged.set(b"Pattern".to_vec(), Object::Dict(table));
}
}
// A form in a layer (/OC) stays in it: its content is marked with the same membership.
let layer = match s.dict.get(b"OC") {
None => None,
Some(oc) => {
let mut props = merged.get(b"Properties").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let existing = props.iter().find(|(_, v)| *v == oc).map(|(k, _)| k.clone());
let name = match existing {
Some(k) => k,
None => {
let fresh = (1..=u32::MAX)
.map(|n| format!("PCUgOC{n}").into_bytes())
.find(|c| props.get(c).is_none())
.ok_or_else(|| refuse("too many resources"))?;
props.set(fresh.clone(), oc.clone());
merged.set(b"Properties".to_vec(), Object::Dict(props));
fresh
}
};
Some(name)
}
};
let num = |v: f64| Object::Real(v);
let mut out = Vec::new();
if let Some(name) = &layer {
out.push(Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())]));
}
out.push(Op::new("q", Vec::new()));
if let Some(m) = matrix {
out.push(Op::new("cm", m.0.iter().copied().map(num).collect()));
}
let (x0, y0, x1, y1) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3]));
out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)]));
out.push(Op::new("W", Vec::new()));
out.push(Op::new("n", Vec::new()));
out.extend(ops);
// A form ends with the state it was drawn with (the Do saves and restores it).
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
out.push(Op::new("Q", Vec::new()));
if layer.is_some() {
out.push(Op::new("EMC", Vec::new()));
}
Ok((out, merged, patterns))
}
/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` in the form's `/ColorSpace`
/// resources that the page's don't have with an equal value (the same object, or the same value
/// resolved). A default colour space remaps the device colours painted with the resource
/// dictionary it is in (ISO 32000-2 §8.6.5.6, §7.8.3), so inline only the page's would apply.
fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<&'static [u8]> {
let spaces = |res: &Dict| res.get(b"ColorSpace").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let (theirs, mine) = (spaces(form), spaces(page));
DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some_and(|v| !mine.get(key).is_some_and(|p| *doc.resolve(p) == *doc.resolve(v))))
}
/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead
/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its
/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy.
fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option<Object> {
let resolved = doc.resolve(entry);
let (mut dict, stream) = match &*resolved {
Object::Stream(s) => (s.dict.clone(), Some(s)),
Object::Dict(d) => (d.clone(), None),
_ => return None,
};
let own = match dict.get(b"Matrix") {
None => Matrix::IDENTITY,
Some(m) => {
let m = doc.resolve(m);
let nums: Vec<Object> = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect();
Matrix::from_operands(&nums)?
}
};
let moved = own.then(&placement);
if !moved.0.iter().all(|v| v.is_finite()) {
return None;
}
dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect()));
match stream {
Some(s) if s.raw.len() > MAX_FORM_BYTES => None,
Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })),
None => Some(Object::Dict(dict)),
}
}
/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending.
const MAX_NESTING: usize = 8;
/// The most nested forms and tiling patterns [`blends_inside`] reads in all; past that the rest
/// count as blending, so a file can't make the check run for ever.
const MAX_NESTED_READS: usize = 1024;
/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a
/// form drawing itself), the answer for each form with resources of its own (its answer doesn't
/// depend on who draws it), and how many reads it has made.
struct BlendWalk {
path: Vec<pdfcraft_cos::ObjRef>,
known: HashMap<pdfcraft_cos::ObjRef, bool>,
reads: usize,
}
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
struct Unreadable;
/// Which operand of `op` names a resource, and from which category, read as the renderer reads
/// it: operands are taken from the end, so a surplus operand before them is ignored. `Do`, `gs`,
/// `sh`, `cs` and `CS` take the last operand; `Tf` a name then a size; `BDC` and `DP` a tag then
/// a name or an inline dictionary; `scn` and `SCN` numbers and the last name among them.
/// `Ok(None)` when `op` names no resource; [`Unreadable`] when the renderer would skip it, which
/// [`ungroup_ops`] refuses rather than guess at.
fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
let operands = &op.operands;
let name_at = |i: Option<usize>| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_))));
let last = operands.len().checked_sub(1);
let (cat, at): (&'static [u8], Option<usize>) = match op.op.as_slice() {
b"Do" => (b"XObject", last),
b"gs" => (b"ExtGState", last),
b"sh" => (b"Shading", last),
b"cs" | b"CS" => (b"ColorSpace", last),
b"Tf" => {
if !last.is_some_and(|i| matches!(operands.get(i), Some(Object::Int(_) | Object::Real(_)))) {
return Err(Unreadable);
}
(b"Font", operands.len().checked_sub(2))
}
b"BDC" | b"DP" => {
if !name_at(operands.len().checked_sub(2)) {
return Err(Unreadable);
}
match last.and_then(|i| operands.get(i)) {
Some(Object::Name(_)) => (b"Properties", last),
Some(Object::Dict(_)) => return Ok(None),
_ => return Err(Unreadable),
}
}
b"scn" | b"SCN" => {
if !operands.iter().all(|o| matches!(o, Object::Int(_) | Object::Real(_) | Object::Name(_))) {
return Err(Unreadable);
}
return Ok(operands.iter().rposition(|o| matches!(o, Object::Name(_))).map(|i| (&b"Pattern"[..], i)));
}
_ => return Ok(None),
};
match at {
Some(i) if name_at(Some(i)) => Ok(Some((cat, i))),
_ => Err(Unreadable),
}
}
/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from
/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with.
fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
let Ok(Some((cat @ (b"XObject" | b"Font" | b"Pattern"), i))) = resource_operand(op) else { return false };
let Some(Object::Name(name)) = op.operands.get(i) else { return false };
let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else {
return false;
};
let dict = match &*doc.resolve(&entry) {
Object::Stream(s) => s.dict.clone(),
Object::Dict(d) => d.clone(),
_ => return false,
};
let drawn = match cat {
b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"),
b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"),
// A shading pattern has no content of its own.
_ => int_of(doc, &dict, b"PatternType") == Some(1),
};
drawn && dict.get(b"Resources").is_none()
}
/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they
/// use, or one used by a form or pattern they draw, at any depth. A shading pattern blends through
/// its own `/ExtGState`, a tiling pattern through its content. A nested form or pattern that can't
/// be read, is nested too deep or draws itself counts as blending, so the caller refuses rather
/// than guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped.
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk) -> bool {
let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern"));
let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
for op in ops {
let Ok(Some((cat, i))) = resource_operand(op) else { continue };
let Some(name) = op.name(i) else { continue };
let o = match cat {
b"ExtGState" if state_blends(states.get(name)) => return true,
b"XObject" => xobjects.get(name),
b"Pattern" => patterns.get(name),
_ => continue,
};
let Some(o) = o else { continue };
let resolved = doc.resolve(o);
let nested = match &*resolved {
Object::Stream(st) if op.is("Do") && is_name(doc, &st.dict, b"Subtype", b"Form") => st,
Object::Stream(st) if !op.is("Do") && int_of(doc, &st.dict, b"PatternType") == Some(1) => st,
// A shading pattern.
Object::Stream(st) if !op.is("Do") => {
if state_blends(st.dict.get(b"ExtGState")) {
return true;
}
continue;
}
Object::Dict(d) if !op.is("Do") => {
if state_blends(d.get(b"ExtGState")) {
return true;
}
continue;
}
_ => continue,
};
let r = o.as_ref();
if let Some(known) = r.and_then(|r| walk.known.get(&r)) {
if *known {
return true;
}
continue;
}
if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS {
return true;
}
walk.reads += 1;
let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true };
let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
let has_own = own_resources.is_some();
let own = own_resources.unwrap_or_else(|| resources.clone());
let inner = parse(&data);
if inner.skipped > 0 {
return true;
}
if let Some(r) = r {
walk.path.push(r);
}
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk);
if let Some(r) = r {
walk.path.retain(|x| *x != r);
if has_own {
walk.known.insert(r, blends);
}
}
if blends {
return true;
}
}
false
}
/// The blending colour space of the page group (ISO 32000-2 §11.4.7): the page's transparency
/// `/Group /CS`, resolved. `None` when the page names none, and the PDF processor chooses.
pub(crate) fn page_blend_space(doc: &Document, page: &Dict) -> Option<Object> {
let group = page.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"))?;
group.get(b"CS").map(|cs| (*doc.resolve(cs)).clone())
}
/// Whether a group blending in `cs` composites as the page does: in the page group's own space
/// when it names one, else in DeviceRGB, the space viewers composite a page in on screen.
fn same_blend_space(doc: &Document, cs: &Object, page_space: Option<&Object>) -> bool {
let cs = doc.resolve(cs);
match page_space {
Some(page) => *cs == *page,
None => cs.as_name() == Some(b"DeviceRGB"),
}
}
/// Whether `d`'s `key` is the name `want`, directly or through an indirect object (any value may
/// be one, ISO 32000-2 §7.3.10).
fn is_name(doc: &Document, d: &Dict, key: &[u8], want: &[u8]) -> bool {
d.get(key).is_some_and(|v| doc.resolve(v).as_name() == Some(want))
}
/// `d`'s integer `key`, directly or through an indirect object.
fn int_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<i64> {
d.get(key).and_then(|v| doc.resolve(v).as_int())
}
/// Whether an ExtGState blends with something other than Normal.
fn blends(doc: &Document, gs: &Dict) -> bool {
let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return false };
let first = match &*bm {
Object::Array(a) => a.first().map(|x| doc.resolve(x)),
_ => Some(bm.clone()),
};
!first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible")))
}
/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's
/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs`
/// changes only the parameters it sets.
pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool {
let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
// Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal.
let mut stack: Vec<[bool; 4]> = Vec::new();
let mut now = [false; 4];
for op in ops.iter().take(at) {
match op.op.as_slice() {
b"q" => stack.push(now),
b"Q" => now = stack.pop().unwrap_or([false; 4]),
b"gs" => {
let Ok(Some((_, i))) = resource_operand(op) else { continue };
let Some(gs) = op.name(i).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else {
continue;
};
let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0);
if let Some(v) = alpha(b"ca") {
now[0] = v;
}
if let Some(v) = alpha(b"CA") {
now[1] = v;
}
if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) {
now[2] = m.as_name() != Some(b"None");
}
if gs.get(b"BM").is_some() {
now[3] = blends(doc, &gs);
}
}
_ => {}
}
}
now.iter().any(|v| *v)
}
+3
View File
@@ -26,6 +26,9 @@ pub enum ImageEdit {
Arrange(ArrangeOrder),
/// Remove any private crop on the image.
RemoveCrop,
/// Grouped Form artwork: draw its content in place, so its text and pictures can be edited
/// one by one (#2964).
Ungroup,
}
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
+1
View File
@@ -286,6 +286,7 @@ impl Edit {
pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back",
},
ImageEdit::RemoveCrop => "Remove crop",
ImageEdit::Ungroup => "Ungroup artwork",
}),
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
+2 -1
View File
@@ -306,6 +306,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
ImageEdit::Ungroup => pdfcraft_edit::ImageChange::Ungroup,
};
let page_model = pdfcraft_model::pages(doc)
.into_iter()
@@ -411,7 +412,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
ai.image = new_image;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete | ImageEdit::Ungroup => {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
}
@@ -1101,6 +1101,12 @@ pub(crate) fn image_context_menu(
}
});
// Grouped Form artwork: draw its content in place, so its text and pictures edit one by
// one (#2964, #3005).
if img.is_form && item(ui, true, tl!("Ungroup")) {
view.image_selection = None;
view.pending_edit = edit(E::Ungroup);
}
if item(ui, raster, tl!("Replace image")) {
*action = Some(ImageAction::Replace(page, hit));
}
+63
View File
@@ -10458,6 +10458,69 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() {
}
}
/// #3005: a table drawn inside one Form XObject is a single piece of artwork in Edit text &
/// images. Its right-click menu offers Ungroup, which draws the form in place so the table's
/// text can be edited; one Undo puts the form back.
#[test]
fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET";
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
stream("<<", b"q /Fm1 Do Q"),
stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("table.pdf", None, pdf.clone()).expect("opens");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0).len();
assert_eq!(blocks(&h), 0, "the table's text is inside the form");
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
// An empty spot of the form: the artwork, not a paragraph.
let at = r.left_top() + egui::vec2(r.width() * 0.8, r.height() * 0.85);
right_click_at(&mut h, at);
h.run_steps(1);
let selection = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selection, Some((0, 0)), "the right-click selects the form");
assert!(h.query_by_label("Ungroup").is_some(), "the artwork menu offers Ungroup");
// The page's own menu opens over the artwork menu (#2637, fixed by #2155), so the item is
// applied the way it queues its edit rather than clicked through the overlap.
h.state_mut().views[0].pending_edit =
Some(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Ungroup });
h.run_steps(4);
assert!(blocks(&h) >= 1, "the table's rows are page text now");
let images = h.state().session.get(id).unwrap().page_images(0);
assert!(images.is_empty(), "no artwork left: {images:?}");
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(3);
assert_eq!(blocks(&h), 0, "undo puts the form back");
}
#[test]
fn outward_image_grip_drags_grow_on_rotated_pages() {
for rotation in [0, 90, 180, 270] {
+4 -3
View File
@@ -18,9 +18,10 @@ content, resources, clipping box, and transparency settings remain intact. Movin
deleting one use of a shared figure does not alter its other placements or pages.
**Replace Image** and **Save Image As** apply only to raster Image XObjects; these menu
items are disabled for grouped Form artwork. The tool does not ungroup Forms, edit their
individual internal elements, select arbitrary ungrouped vector paths, or edit inline
images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace.
items are disabled for grouped Form artwork. **Ungroup** (right-click) draws one placement's
content in place so its text and pictures can be edited; while grouped, the tool does not edit
a Form's individual internal elements. It does not select arbitrary ungrouped vector paths or
edit inline images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace.
For automation, `page_images` lists both kinds with `kind: "image"` or `kind: "form"`.
Forms have `pixels: [0, 0]`, since they have no single bitmap resolution. Use the returned
+2 -2
View File
@@ -1895,8 +1895,8 @@ milestone = "M7"
status = "partial"
commands = ["edit.edit_text"]
tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete"]
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md"
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads"]
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms whose /DefaultGray, /DefaultRGB or /DefaultCMYK the page doesn't share (ISO 32000-2 §8.6.5.6), each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
[[feature]]
id = "edit.text-reflow"