diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index ebe2a93526..69c56747c8 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -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)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 8b302e326e..7ef6690783 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1562,14 +1562,14 @@ pub fn tools() -> Vec { .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" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 5addfe5fbd..98055ba1b3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11168,6 +11168,154 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { 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 { + 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 = std::iter::repeat_n([20u8, 60, 220], 16).flatten().collect(); + let alpha: Vec = (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![ + 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 { + 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] diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index bcb188afb1..68e8b1383e 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -342,8 +342,14 @@ impl Stream { /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, 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, 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 diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index fbb37ec1f0..e1b9e3626e 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -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( diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index cb703ea809..da51839047 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -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; diff --git a/crates/edit/src/rules.rs b/crates/edit/src/rules.rs index a5a77903a0..52600ab4dd 100644 --- a/crates/edit/src/rules.rs +++ b/crates/edit/src/rules.rs @@ -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, 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::>()); + let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; let mut out: Vec<[f64; 4]> = Vec::new(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7c8f65dbab..db7817e48f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4905,6 +4905,294 @@ fn form_artwork_edits_only_the_selected_placement() { assert!(images::page_images(&doc, 0).unwrap().is_empty()); } +/// A page of [`fixture`] drawing Form artwork `form` (with `dict`'s entries and `content`) twice, +/// then a caption, after `prefix`. Returns the document and the form's object. +fn page_with_form(dict: &[(&str, Object)], content: &[u8], prefix: &str, page_states: Option) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = fixture(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + form.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 2, 0, 0].map(Object::Int).to_vec())); + for (k, v) in dict { + form.set(k.as_bytes().to_vec(), v.clone()); + } + let artwork = doc.add(Object::Stream(Stream::flate(form, content))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + if let Some(states) = page_states { + d.set(b"ExtGState".to_vec(), Object::Dict(states)); + } + }) + .unwrap(); + let page = format!("{prefix} q 1 0 0 1 40 80 cm /Art Do Q q 1 0 0 1 300 400 cm /Art Do Q BT /F1 9 Tf 1 0 0 1 40 40 Tm (Caption) Tj ET"); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), page.as_bytes()))); + (reopen(&doc), artwork) +} + +/// #2964 / #3005: Ungroup draws a form's content in place of one of its `Do`s, so its text is the +/// page's own and edits like any other. Its fonts are merged under fresh names where the page's +/// clash, the form object and the other placement stay as they were, and a page sharing the +/// content stream is untouched. +#[test] +fn ungrouping_form_artwork_makes_its_text_editable() { + let mut times = Dict::new(); + times.set(b"Type".to_vec(), Object::name("Font")); + times.set(b"Subtype".to_vec(), Object::name("Type1")); + times.set(b"BaseFont".to_vec(), Object::name("Times-Roman")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(times.clone())); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + // An unclosed `q` too: Ungroup ends the form with the state it was drawn with. + let content = b"BT /F1 12 Tf 1 0 0 1 20 30 Tm (Inside) Tj ET q 0 0 1 rg 5 5 10 10 re f"; + let (mut doc, artwork) = page_with_form(&[("Resources", Object::Dict(res))], content, "", None); + let original = doc.get(artwork); + let texts = |doc: &Document, page: usize| text::text_lines(doc, page).unwrap().into_iter().map(|l| l.text).collect::>(); + assert_eq!(texts(&doc, 0), ["Caption"], "form text isn't the page's own"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 2); + + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let inside = lines.iter().find(|l| l.text == "Inside").expect("the form's text is now page text"); + // Tm (20, 30) through the form's Matrix (×2) and the placement (40, 80): baseline (80, 140). + assert!((inside.rect[0] - 80.0).abs() < 1.0 && inside.rect[1] < 140.0 && 140.0 < inside.rect[3], "{:?}", inside.rect); + assert!((inside.size - 24.0).abs() < 1e-6, "12 pt scaled ×2: {}", inside.size); + assert!(inside.base_font.contains("Times"), "the form's own F1, not the page's Courier: {}", inside.base_font); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "the other placement is still the form"); + assert_eq!(*doc.get(artwork), *original, "the form object is untouched"); + assert_eq!(texts(&doc, 1), ["Caption"], "page 2 shared the content stream and keeps drawing the form"); + assert_eq!(images::page_images(&doc, 1).unwrap().len(), 2); + let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))), "the page's F1 is kept"); + assert_eq!(fonts.get(b"PCUg1"), Some(&Object::Dict(times)), "the form's F1 joins under a fresh name"); + let refs: Vec = match doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref() { + Object::Array(a) => a.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + }; + let data: Vec = refs + .iter() + .flat_map(|r| match doc.resolve(r).as_ref() { + Object::Stream(s) => [ + s.decoded().unwrap(), + b" +" + .to_vec(), + ] + .concat(), + _ => Vec::new(), + }) + .collect(); + let ops = pdfcraft_content::parse(&data).ops; + let depth = ops.iter().fold(0i32, |d, op| d + i32::from(op.is("q")) - i32::from(op.is("Q"))); + assert_eq!(depth, 0, "the unclosed q inside the form is closed"); +} + +/// Ungroup refuses what would draw differently inline: knockout groups, transparency groups that +/// blend inside or are drawn translucent, and forms whose content restores more states than it +/// saves. Plain groups drawn opaque, and translucency that a later `gs` turns off, are fine. +#[test] +fn ungroup_refuses_what_would_draw_differently() { + let group = |extra: &[(&str, Object)]| { + let mut g = Dict::new(); + g.set(b"S".to_vec(), Object::name("Transparency")); + for (k, v) in extra { + g.set(k.as_bytes().to_vec(), v.clone()); + } + Object::Dict(g) + }; + let gs = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + Object::Dict(d) + }; + let states = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let ungroup = |dict: &[(&str, Object)], content: &[u8], prefix: &str, page_states: Option| { + let (mut doc, _) = page_with_form(dict, content, prefix, page_states); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup) + }; + let fill = b"1 0 0 rg 0 0 10 10 re f"; + let refused = |r: Result<(), EditError>, why: &str| match r { + Err(EditError::Invalid(m)) => assert!(m.contains(why), "{m}"), + other => panic!("expected a refusal mentioning {why:?}, got {other:?}"), + }; + refused(ungroup(&[("Group", group(&[("K", Object::Bool(true))]))], fill, "", None), "knockout"); + let mut inner = Dict::new(); + inner.set(b"ExtGState".to_vec(), Object::Dict(states(&[("M", gs(&[("BM", Object::name("Multiply"))]))]))); + refused(ungroup(&[("Group", group(&[])), ("Resources", Object::Dict(inner))], b"/M gs 1 0 0 rg 0 0 10 10 re f", "", None), "blend"); + let half = + || Some(states(&[("H", gs(&[("ca", Object::Real(0.5))])), ("O", gs(&[("LW", Object::Int(2))])), ("One", gs(&[("ca", Object::Int(1))]))])); + refused(ungroup(&[("Group", group(&[]))], fill, "/H gs", half()), "translucent"); + // A later gs that sets only the line width leaves the fill alpha at 0.5. + refused(ungroup(&[("Group", group(&[]))], fill, "/H gs /O gs", half()), "translucent"); + // A later gs that sets the fill alpha back to 1 ends it. + ungroup(&[("Group", group(&[]))], fill, "/H gs /One gs", half()).unwrap(); + // The translucency ends with its q … Q scope. + ungroup(&[("Group", group(&[]))], fill, "q /H gs Q", half()).unwrap(); + // Without a group, translucency at the Do applies to each part just as it would inline. + ungroup(&[], fill, "/H gs", half()).unwrap(); + ungroup(&[("Group", group(&[]))], fill, "", None).unwrap(); + refused(ungroup(&[], b"Q 1 0 0 rg 0 0 10 10 re f", "", None), "restores more"); + // A raster image isn't grouped artwork. + let mut doc = fixture(); + let mut img = Dict::new(); + for (k, v) in [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] { + img.set(k.as_bytes().to_vec(), v); + } + img.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let pic = doc.add(Object::Stream(Stream::flate(img, b"\x00"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q 10 0 0 10 0 0 cm /Pic Do Q"))); + let mut doc = reopen(&doc); + refused(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup), "only grouped Form artwork"); +} + +/// Review on #3067: a transparency group with a blending colour space of its own (ISO 32000-2 +/// §11.6.6, Table 145 /CS) composites its contents there, and inline they would composite in the +/// page's, so Ungroup refuses it. The page group's own space (§11.4.7), or DeviceRGB on a page +/// that names none, is fine, as is a group that names no space. +#[test] +fn ungroup_refuses_a_group_blending_in_another_colour_space() { + let group = |cs: Option<&str>| { + let mut g = Dict::new(); + g.set(b"S".to_vec(), Object::name("Transparency")); + if let Some(cs) = cs { + g.set(b"CS".to_vec(), Object::name(cs)); + } + Object::Dict(g) + }; + let fill = b"1 0 0 rg 0 0 10 10 re f"; + let ungroup = |cs: Option<&str>, page_cs: Option<&str>| { + let (mut doc, _) = page_with_form(&[("Group", group(cs))], fill, "", None); + if let Some(page_cs) = page_cs { + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Group".to_vec(), group(Some(page_cs)))).unwrap(); + } + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup) + }; + match ungroup(Some("DeviceCMYK"), None) { + Err(EditError::Invalid(m)) => assert!(m.contains("colour space of its own"), "{m}"), + other => panic!("a CMYK group on a page with no group space: {other:?}"), + } + assert!(matches!(ungroup(Some("DeviceRGB"), Some("DeviceCMYK")), Err(EditError::Invalid(_))), "the page blends in CMYK"); + ungroup(Some("DeviceRGB"), None).unwrap(); + ungroup(Some("DeviceCMYK"), Some("DeviceCMYK")).unwrap(); + ungroup(None, Some("DeviceCMYK")).unwrap(); +} + +/// A gamma-3 calibrated RGB space: as `/DefaultRGB` it draws DeviceRGB red darker than red. +fn dark_cal_rgb() -> Object { + let mut d = Dict::new(); + d.set(b"WhitePoint".to_vec(), Object::Array([1, 1, 1].map(Object::Int).to_vec())); + d.set(b"Gamma".to_vec(), Object::Array([3, 3, 3].map(Object::Int).to_vec())); + Object::Array(vec![Object::name("CalRGB"), Object::Dict(d)]) +} + +/// Ungroup fixture page 1's form `Art` (red square) whose `/ColorSpace` resources are `form`, +/// on a page whose `/ColorSpace` resources are `page` (none when `None`). `build` may add objects +/// first. Returns the result and the document. +fn ungroup_with_spaces(build: impl FnOnce(&mut Document) -> (Dict, Option)) -> (Result<(), EditError>, Document) { + let mut doc = fixture(); + let (form, page) = build(&mut doc); + let mut res = Dict::new(); + res.set(b"ColorSpace".to_vec(), Object::Dict(form)); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Form")); + dict.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + dict.set(b"Resources".to_vec(), Object::Dict(res)); + let artwork = doc.add(Object::Stream(Stream::flate(dict, b"1 0 0 rg 0 0 10 10 re f"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + if let Some(page) = page { + d.set(b"ColorSpace".to_vec(), Object::Dict(page)); + } + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).map(|_| ()); + (r, doc) +} + +fn spaces(entries: &[(&str, Object)]) -> Dict { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d +} + +/// A form's `/DefaultRGB` (likewise `/DefaultGray`, `/DefaultCMYK`) remaps the device colours it +/// paints (ISO 32000-2 §8.6.5.6), looked up in the current resource dictionary (§7.8.3). Inline, +/// that is the page's: renamed into it, the form's entry would no longer remap anything and its +/// red would draw in another colour, so Ungroup refuses unless the page has the same entry. +#[test] +fn ungroup_refuses_a_form_with_its_own_default_colour_space() { + let refused = |r: Result<(), EditError>, key: &str| match r { + Err(EditError::Invalid(m)) => assert!(m.contains(key) && m.contains("device colours"), "{m}"), + other => panic!("expected a refusal naming {key}, got {other:?}"), + }; + refused(ungroup_with_spaces(|_| (spaces(&[("DefaultRGB", dark_cal_rgb())]), None)).0, "/DefaultRGB"); + // The page has one too, but another. + let r = ungroup_with_spaces(|_| (spaces(&[("DefaultRGB", dark_cal_rgb())]), Some(spaces(&[("DefaultRGB", Object::name("DeviceRGB"))])))).0; + refused(r, "/DefaultRGB"); + refused(ungroup_with_spaces(|_| (spaces(&[("DefaultGray", Object::name("CalGray"))]), None)).0, "/DefaultGray"); + // Only an entry of the same name counts: the page's DefaultRGB doesn't stand for a DefaultCMYK. + let r = ungroup_with_spaces(|_| (spaces(&[("DefaultCMYK", dark_cal_rgb())]), Some(spaces(&[("DefaultRGB", dark_cal_rgb())])))).0; + refused(r, "/DefaultCMYK"); +} + +/// When the page's resources have the same default colour space (the same object, or an equal +/// value), it remaps the form's colours inline just as the form's did, so Ungroup goes ahead and +/// adds no renamed copy of it that nothing would use. +#[test] +fn ungroup_allows_a_form_whose_default_colour_space_matches_the_page() { + let check = |doc: &Document, page_entry: &Object| { + let page = pdfcraft_model::pages(doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let cs = doc.resolve(res.get(b"ColorSpace").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(cs.get(b"DefaultRGB"), Some(page_entry), "the page's DefaultRGB is kept"); + assert_eq!(cs.len(), 1, "no renamed copy: {:?}", cs.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>()); + assert!(images::page_images(doc, 0).unwrap().is_empty(), "ungrouped"); + }; + // Equal values written out twice. + let (r, doc) = ungroup_with_spaces(|_| (spaces(&[("DefaultRGB", dark_cal_rgb())]), Some(spaces(&[("DefaultRGB", dark_cal_rgb())])))); + r.unwrap(); + check(&doc, &dark_cal_rgb()); + // The same object, and an indirect value equal to a direct one. + let mut shared = None; + let (r, doc) = ungroup_with_spaces(|doc| { + let cs = Object::Ref(doc.add(dark_cal_rgb())); + shared = Some(cs.clone()); + (spaces(&[("DefaultRGB", cs.clone())]), Some(spaces(&[("DefaultRGB", cs)]))) + }); + r.unwrap(); + check(&doc, shared.as_ref().unwrap()); + let (r, doc) = + ungroup_with_spaces(|doc| (spaces(&[("DefaultRGB", Object::Ref(doc.add(dark_cal_rgb())))]), Some(spaces(&[("DefaultRGB", dark_cal_rgb())])))); + r.unwrap(); + check(&doc, &dark_cal_rgb()); +} + #[test] fn paragraphs_take_new_formatting() { let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"); @@ -9501,6 +9789,20 @@ fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document { doc } +#[test] +fn page_rules_join_decoded_streams_before_parsing_operators() { + let doc = page_with(|doc| { + let first = doc.add(Stream::flate(Dict::new(), b"q 2 0 0 2 0 0 cm 10 20 m 30")); + let second = doc.add(Stream::flate(Dict::new(), b"20 l S Q 40 50 1 30 re f")); + (Object::Array(vec![Object::Ref(first), Object::Null, Object::Ref(second)]), Dict::new()) + }); + assert_eq!(page_rules(&doc, 0).unwrap(), vec![[20.0, 40.0, 60.0, 40.0], [40.0, 50.0, 41.0, 80.0]]); + assert_eq!(page_rules(&doc, 1), Err(EditError::NoSuchPage(1))); + + let empty = page_with(|_| (Object::Array(Vec::new()), Dict::new())); + assert!(page_rules(&empty, 0).unwrap().is_empty()); +} + #[test] fn repeated_content_references_share_one_decode_budget() { // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents. @@ -10763,6 +11065,1046 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning() assert_eq!(page_content_bytes(&reopen(&doc), 0), before); } +/// Ungroup renames every kind of resource a content stream names, where the page already uses +/// that name for something else: fonts, XObjects, graphics states, colour spaces (also an inline +/// image's /CS), patterns, shadings and marked-content properties. Each name the inline content +/// uses resolves, in the page's merged resources, to the form's own object. +#[test] +fn ungroup_renames_every_kind_of_resource() { + let tag = |s: &str| { + let mut d = Dict::new(); + d.set(b"Tag".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false })); + Object::Dict(d) + }; + let categories = ["Font", "XObject", "ExtGState", "ColorSpace", "Pattern", "Shading", "Properties"]; + let names = ["F1", "Art", "G", "C", "P", "S", "M"]; + // The page uses every one of these names for something else; the form's are "form …". + let mut form_res = Dict::new(); + let mut page_res = Dict::new(); + for (cat, name) in categories.iter().zip(names) { + let mut f = Dict::new(); + f.set(name.as_bytes().to_vec(), tag(&format!("form {cat}"))); + form_res.set(cat.as_bytes().to_vec(), Object::Dict(f)); + if *cat != "Font" && *cat != "XObject" { + let mut p = Dict::new(); + p.set(name.as_bytes().to_vec(), tag(&format!("page {cat}"))); + page_res.set(cat.as_bytes().to_vec(), Object::Dict(p)); + } + } + let content = b"/G gs /C cs /Pattern cs /P scn /S sh /OC /M BDC EMC /Art Do BT /F1 9 Tf (x) Tj ET BI /CS /C /W 1 /H 1 /BPC 8 ID \x00 EI"; + let (mut doc, _) = page_with_form(&[("Resources", Object::Dict(form_res))], content, "", None); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + for (k, v) in page_res.iter() { + d.set(k.clone(), v.clone()); + } + }) + .unwrap(); + let mut doc = reopen(&doc); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let contents = doc.resolve(page.dict.get(b"Contents").unwrap()); + let Object::Stream(s) = contents.as_ref() else { panic!("one content stream") }; + let ops = pdfcraft_content::parse(&s.decoded().unwrap()).ops; + let lookup = |cat: &str, name: &[u8]| { + let d = doc.resolve(res.get(cat.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + d.get(name).map(|o| doc.resolve(o).as_ref().clone()) + }; + let used: Vec<(&str, Vec)> = ops + .iter() + .filter_map(|op| match op.op.as_slice() { + b"gs" => Some(("ExtGState", op.name(0)?.to_vec())), + b"cs" if op.name(0) != Some(b"Pattern") => Some(("ColorSpace", op.name(0)?.to_vec())), + b"scn" => Some(("Pattern", op.name(0)?.to_vec())), + b"sh" => Some(("Shading", op.name(0)?.to_vec())), + b"BDC" => Some(("Properties", op.name(1)?.to_vec())), + b"Do" if op.name(0) != Some(b"Art") || ops.iter().filter(|o| o.is("Do")).count() == 1 => Some(("XObject", op.name(0)?.to_vec())), + b"Tf" => Some(("Font", op.name(0)?.to_vec())), + b"BI" => Some(("ColorSpace", op.inline.as_ref()?.0.get(b"CS")?.as_name()?.to_vec())), + _ => None, + }) + .collect(); + for cat in categories { + let hits: Vec<_> = used.iter().filter(|(c, _)| *c == cat).collect(); + assert!(!hits.is_empty(), "{cat} is used: {used:?}"); + // The form is placed by its Matrix [2 0 0 2 0 0] after the page's `1 0 0 1 40 80 cm`, so + // its pattern is drawn from a copy that carries that placement (ISO 32000-2 §8.7.2). + let want = match tag(&format!("form {cat}")) { + Object::Dict(mut d) if cat == "Pattern" => { + d.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 2, 40, 80].map(Object::Int).to_vec())); + Object::Dict(d) + } + other => other, + }; + assert!(hits.iter().any(|(_, n)| lookup(cat, n) == Some(want.clone())), "{cat}: the inline content names the form's object: {hits:?}"); + } + assert!( + used.iter().filter(|(c, _)| *c == "ColorSpace").all(|(_, n)| lookup("ColorSpace", n) == Some(tag("form ColorSpace"))), + "cs and the inline image's /CS both point at the form's colour space" + ); + assert_eq!(lookup("ExtGState", b"G"), Some(tag("page ExtGState")), "the page's own G is kept"); +} + +/// The renderer takes an operator's operands from the end and ignores a surplus before them +/// (`0 /Inner Do` draws `/Inner`): one name for `Do`, `gs`, `sh` and `cs`, a name then a size for +/// `Tf`, a tag then a name for `BDC` and `DP`, the last name for `scn`. Ungroup renames that +/// operand, so the inline content still selects the form's objects and not the page's. +#[test] +fn ungroup_renames_the_name_operand_the_renderer_reads() { + let tag = |s: &str| { + let mut d = Dict::new(); + d.set(b"Tag".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false })); + Object::Dict(d) + }; + let categories = ["Font", "XObject", "ExtGState", "ColorSpace", "Pattern", "Shading", "Properties"]; + let names = ["F1", "Inner", "G", "C", "P", "S", "M"]; + let mut form_res = Dict::new(); + let mut page_res = Dict::new(); + for (cat, name) in categories.iter().zip(names) { + let mut f = Dict::new(); + f.set(name.as_bytes().to_vec(), tag(&format!("form {cat}"))); + form_res.set(cat.as_bytes().to_vec(), Object::Dict(f)); + if *cat != "Font" && *cat != "XObject" { + let mut p = Dict::new(); + p.set(name.as_bytes().to_vec(), tag(&format!("page {cat}"))); + page_res.set(cat.as_bytes().to_vec(), Object::Dict(p)); + } + } + let content = b"0 /G gs 1 /C cs /Pattern cs 0.5 /P scn 0 /S sh /X /OC /M BDC EMC /X /OC /M DP 0 /Inner Do BT /X /F1 9 Tf (x) Tj ET"; + let (mut doc, art) = page_with_form(&[("Resources", Object::Dict(form_res))], content, "", None); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + for (k, v) in page_res.iter() { + d.set(k.clone(), v.clone()); + } + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(art)); + xo.set(b"Inner".to_vec(), tag("page XObject")); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + let mut doc = reopen(&doc); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let contents = doc.resolve(page.dict.get(b"Contents").unwrap()); + let Object::Stream(s) = contents.as_ref() else { panic!("one content stream") }; + let ops = pdfcraft_content::parse(&s.decoded().unwrap()).ops; + let lookup = |cat: &str, name: &[u8]| { + let d = doc.resolve(res.get(cat.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + d.get(name).map(|o| doc.resolve(o).as_ref().clone()) + }; + // The operand each of the form's operators is drawn with, counted from the end as the + // renderer does (the page's own `/Art Do` and `/F1 9 Tf` have no surplus and are skipped). + let from_end = |op: &pdfcraft_content::Op, back: usize| op.operands.len().checked_sub(back + 1).and_then(|i| op.name(i)).map(<[u8]>::to_vec); + let used: Vec<(&str, Vec)> = ops + .iter() + .filter(|op| op.operands.len() >= if op.is("Tf") || op.is("BDC") || op.is("DP") { 3 } else { 2 }) + .filter_map(|op| match op.op.as_slice() { + b"gs" => Some(("ExtGState", from_end(op, 0)?)), + b"cs" => Some(("ColorSpace", from_end(op, 0)?)), + b"scn" => Some(("Pattern", from_end(op, 0)?)), + b"sh" => Some(("Shading", from_end(op, 0)?)), + b"BDC" | b"DP" => Some(("Properties", from_end(op, 0)?)), + b"Do" => Some(("XObject", from_end(op, 0)?)), + b"Tf" => Some(("Font", from_end(op, 1)?)), + _ => None, + }) + .collect(); + for cat in categories { + let hits: Vec<_> = used.iter().filter(|(c, _)| *c == cat).collect(); + assert!(!hits.is_empty(), "{cat} is used: {used:?}"); + let want = match tag(&format!("form {cat}")) { + Object::Dict(mut d) if cat == "Pattern" => { + d.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 2, 40, 80].map(Object::Int).to_vec())); + Object::Dict(d) + } + other => other, + }; + for (_, n) in &hits { + assert_eq!(lookup(cat, n), Some(want.clone()), "{cat}: the operand the renderer reads names the form's object: {hits:?}"); + } + } + let surplus = ops.iter().filter(|o| o.operands.len() == 3).filter_map(|o| o.name(0)).filter(|n| *n == b"X").count(); + assert_eq!(surplus, 3, "the surplus operands of BDC, DP and Tf are left as they were"); +} + +/// A surplus operand before a resource name can't hide what the name selects from Ungroup's +/// checks either: a nested form without resources of its own drawn with `0 /N Do` still inherits +/// renamed ones, and a group's `0 /M gs` still blends, directly or in a nested form. +#[test] +fn ungroup_checks_the_name_operand_the_renderer_reads() { + let refused = |r: Result<(), EditError>, why: &str| match r { + Err(EditError::Invalid(m)) => assert!(m.contains(why), "{m}"), + other => panic!("expected a refusal mentioning {why:?}, got {other:?}"), + }; + let nested = |doc: &mut Document, res: Option, content: &[u8]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + if let Some(res) = res { + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(d, content)))) + }; + let one = |cat: &str, name: &str, v: Object| { + let mut inner = Dict::new(); + inner.set(name.as_bytes().to_vec(), v); + let mut d = Dict::new(); + d.set(cat.as_bytes().to_vec(), Object::Dict(inner)); + d + }; + // The form's F1 (Times) is renamed past the page's Courier F1, so N would find the page's. + let r = ungroup_art(b"BT /F1 9 Tf (a) Tj ET 0 /N Do", false, |doc| { + let n = nested(doc, None, b"BT /F1 9 Tf (n) Tj ET"); + let mut times = Dict::new(); + times.set(b"Type".to_vec(), Object::name("Font")); + times.set(b"Subtype".to_vec(), Object::name("Type1")); + times.set(b"BaseFont".to_vec(), Object::name("Times-Roman")); + let mut res = one("Font", "F1", Object::Dict(times)); + res.set(b"XObject".to_vec(), one("XObject", "N", n).get(b"XObject").cloned().unwrap()); + res + }); + refused(r, "uses its resources"); + let multiply = || { + let mut gs = Dict::new(); + gs.set(b"BM".to_vec(), Object::name("Multiply")); + Object::Dict(gs) + }; + refused(ungroup_art(b"0 /M gs 1 0 0 rg 0 0 10 10 re f", true, |_| one("ExtGState", "M", multiply())), "blend"); + let r = ungroup_art(b"/N Do", true, |doc| { + let n = nested(doc, Some(one("ExtGState", "M", multiply())), b"0 /M gs 1 0 0 rg 0 0 10 10 re f"); + one("XObject", "N", n) + }); + refused(r, "blend"); + let r = ungroup_art(b"0 /N Do", true, |doc| { + let n = nested(doc, Some(one("ExtGState", "M", multiply())), b"/M gs 1 0 0 rg 0 0 10 10 re f"); + one("XObject", "N", n) + }); + refused(r, "blend"); +} + +/// An operator whose operands the renderer can't read as a resource selection (no name where +/// it takes one, a `Tf` without a size, a `BDC` whose properties are a number) is skipped when +/// drawn. Ungroup refuses rather than guess what it should rename. A `BDC` with an inline +/// properties dictionary names no resource and is fine. +#[test] +fn ungroup_refuses_a_resource_operator_it_cannot_read() { + for content in [&b"Do"[..], b"1 Do", b"/F1 Tf", b"/F1 /F2 Tf", b"/Tag 5 BDC EMC", b"/P BDC EMC", b"(s) /P scn", b"/G (s) gs"] { + match ungroup_art(content, false, |_| Dict::new()) { + Err(EditError::Invalid(m)) => assert!(m.contains("can't be read"), "{}: {m}", String::from_utf8_lossy(content)), + other => panic!("{}: expected a refusal, got {other:?}", String::from_utf8_lossy(content)), + } + } + ungroup_art(b"/Span <> BDC EMC 0 0 1 rg 0 0 5 5 re f", false, |_| Dict::new()).unwrap(); +} + +/// #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 drawn from the page a form's pattern would lose the form's +/// placement. Ungroup draws it from a copy whose `/Matrix` carries that placement. A pattern +/// selected twice is copied once, the form keeps its own for the other placement, and a pattern +/// whose matrix can't be read is refused rather than drawn somewhere else. +#[test] +fn ungroup_carries_the_placement_into_the_patterns_it_selects() { + let pattern = |matrix: Object| { + let mut d = Dict::new(); + d.set(b"PatternType".to_vec(), Object::Int(2)); + d.set(b"Shading".to_vec(), Object::Dict(Dict::new())); + d.set(b"Matrix".to_vec(), matrix); + Object::Dict(d) + }; + let form_with = |p: Object| { + let mut patterns = Dict::new(); + patterns.set(b"P".to_vec(), p); + let mut res = Dict::new(); + res.set(b"Pattern".to_vec(), Object::Dict(patterns)); + page_with_form(&[("Resources", Object::Dict(res))], b"/Pattern cs /P scn 0 0 10 10 re f /P scn 0 0 5 5 re f", "", None) + }; + let own = pattern(Object::Array([1, 0, 0, 1, 3, 4].map(Object::Int).to_vec())); + let (mut doc, artwork) = form_with(own.clone()); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let table = doc.resolve(res.get(b"Pattern").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one content stream") }; + let selected: Vec> = + pdfcraft_content::parse(&s.decoded().unwrap()).ops.iter().filter(|o| o.is("scn")).filter_map(|o| o.name(0).map(<[u8]>::to_vec)).collect(); + assert_eq!(selected.len(), 2, "{selected:?}"); + assert!(selected[0] == selected[1] && selected[0] != b"P", "both selections use one copy: {selected:?}"); + let copy = doc.resolve(table.get(&selected[0]).unwrap()).as_dict().cloned().unwrap(); + // [1 0 0 1 3 4], then the form's Matrix [2 0 0 2 0 0], then the page's `1 0 0 1 40 80 cm`. + let matrix = copy.get(b"Matrix").and_then(Object::as_array).and_then(|a| pdfcraft_content::Matrix::from_operands(a)); + assert_eq!(matrix.map(|m| m.0), Some([2.0, 0.0, 0.0, 2.0, 46.0, 88.0])); + assert_eq!(table.iter().filter(|(k, _)| k.starts_with(b"PCUgPat")).count(), 1, "one copy"); + let Object::Stream(form) = doc.get(artwork).as_ref().clone() else { panic!("the form") }; + let form_res = doc.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let form_patterns = doc.resolve(form_res.get(b"Pattern").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(form_patterns.get(b"P"), Some(&own), "the form keeps its own pattern for its other placement"); + + let (mut doc, _) = + form_with(pattern(Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0), Object::Int(1), Object::name("Bad"), Object::Int(4)]))); + match images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup) { + Err(EditError::Invalid(m)) => assert!(m.contains("position can't be kept"), "{m}"), + other => panic!("expected a refusal, got {other:?}"), + } +} + +/// Ungroup draws the form where the `Do` drew it and only inside its bounding box: the inline +/// content starts `q cm re W n`, a form without a Matrix gets no `cm`, and the +/// page's other content is unchanged around it. +#[test] +fn ungrouped_content_keeps_the_forms_matrix_and_clip() { + let content = b"0 0 1 rg -50 -50 300 300 re f"; + let page_ops = |doc: &Document| { + let page = pdfcraft_model::pages(doc).into_iter().next().unwrap(); + let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one stream") }; + pdfcraft_content::parse(&s.decoded().unwrap()).ops + }; + let (mut doc, _) = page_with_form(&[], content, "", None); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let ops = page_ops(&reopen(&doc)); + let words: Vec = ops.iter().map(|o| String::from_utf8_lossy(&o.op).into_owned()).collect(); + // q 1 0 0 1 40 80 cm | q 2 0 0 2 0 0 cm 0 0 100 50 re W n
Q | Q … + let at = words.iter().position(|w| w == "re").expect("a clip"); + assert_eq!(words[at - 3..at + 3], ["cm", "q", "cm", "re", "W", "n"], "{words:?}"); + assert_eq!(ops[at - 1].nums::<6>(), Some([2.0, 0.0, 0.0, 2.0, 0.0, 0.0]), "the form's Matrix"); + assert_eq!(ops[at].nums::<4>(), Some([0.0, 0.0, 100.0, 50.0]), "the form's BBox"); + assert_eq!(words.iter().filter(|w| *w == "Do").count(), 1, "the other placement still draws the form"); + // No Matrix, no cm. + let (mut doc, artwork) = page_with_form(&[], content, "", None); + if let Object::Stream(mut s) = doc.get(artwork).as_ref().clone() { + s.dict.remove(b"Matrix"); + doc.set(artwork, Object::Stream(s)); + } + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let ops = page_ops(&reopen(&doc)); + let at = ops.iter().position(|o| o.is("re")).unwrap(); + assert!(ops[at - 1].is("q"), "no Matrix, no cm: {:?}", ops[at - 1]); +} + +/// Merging resources: an object the page already has under the same name keeps that name, a +/// fresh name never takes one the page already uses, and a form without resources of its own +/// (PDF 1.1) draws with the page's as they are. +#[test] +fn ungroup_merges_resources_without_duplicates_or_clashes() { + let courier = Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)); + let mut times = Dict::new(); + times.set(b"BaseFont".to_vec(), Object::name("Times-Roman")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), courier.clone()); + fonts.set(b"F2".to_vec(), Object::Dict(times.clone())); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let content = b"BT /F1 9 Tf (a) Tj /F2 9 Tf (b) Tj ET"; + let (mut doc, _) = page_with_form(&[("Resources", Object::Dict(res))], content, "", None); + // The page already uses PCUg1 for another font. + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut f = Dict::new(); + f.set(b"F1".to_vec(), courier.clone()); + f.set(b"F2".to_vec(), Object::name("PageF2")); + f.set(b"PCUg1".to_vec(), Object::name("Taken")); + d.set(b"Font".to_vec(), Object::Dict(f)); + }) + .unwrap(); + let mut doc = reopen(&doc); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let names: Vec = fonts.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(); + assert_eq!(names.len(), 4, "F1 shared, F2 renamed once: {names:?}"); + assert_eq!(fonts.get(b"PCUg1"), Some(&Object::name("Taken")), "a fresh name never replaces one in use"); + assert_eq!(fonts.get(b"PCUg2"), Some(&Object::Dict(times)), "the form's F2 under the next free name"); + let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one stream") }; + let used: Vec> = + pdfcraft_content::parse(&s.decoded().unwrap()).ops.iter().filter(|o| o.is("Tf")).filter_map(|o| o.name(0).map(<[u8]>::to_vec)).collect(); + assert!(used.contains(&b"F1".to_vec()) && used.contains(&b"PCUg2".to_vec()), "{used:?}"); + + // No resources of its own: nothing to merge or rename. + let (mut doc, _) = page_with_form(&[], b"BT /F1 9 Tf (c) Tj ET", "", None); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let doc = reopen(&doc); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text == "c" && l.base_font.contains("Courier"))); +} + +/// Ungroup never drops what it can't read: a form whose content can't be decoded or parsed is +/// refused and the page is unchanged. (A form without a usable BBox or Matrix isn't offered as +/// artwork at all.) +#[test] +fn ungroup_refuses_unreadable_forms() { + let refused = |dict: &[(&str, Object)], content: &[u8], raw: Option<&[u8]>| { + let (mut doc, artwork) = page_with_form(dict, content, "", None); + if let Some(raw) = raw + && let Object::Stream(mut s) = doc.get(artwork).as_ref().clone() + { + s.raw = raw.to_vec().into(); + doc.set(artwork, Object::Stream(s)); + } + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup); + assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("can't be ungrouped")), "{r:?}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before, "nothing changed"); + }; + // Flate data that isn't Flate. + refused(&[], b"0 0 1 1 re f", Some(b"not deflated at all")); + // Bytes that aren't operators. + refused(&[], b"0 0 1 1 re f ) ) ]]", None); +} + +/// Translucency at the `Do` is followed parameter by parameter: stroke alpha counts, a soft mask +/// of /None doesn't, a blend mode set on the page counts (Compatible doesn't, and an array's first +/// entry decides), it carries over from an earlier content stream, and a group whose /S isn't +/// Transparency isn't checked. +#[test] +fn ungroup_follows_the_graphics_state_at_the_do() { + let gs = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + Object::Dict(d) + }; + let mut states = Dict::new(); + for (k, v) in [ + ("Stroke", gs(&[("CA", Object::Real(0.4))])), + ("NoMask", gs(&[("SMask", Object::name("None"))])), + ("Mask", gs(&[("SMask", gs(&[("S", Object::name("Luminosity"))]))])), + ("Mul", gs(&[("BM", Object::name("Multiply"))])), + ("Compat", gs(&[("BM", Object::name("Compatible"))])), + ("MulArr", gs(&[("BM", Object::Array(vec![Object::name("Multiply"), Object::name("Normal")]))])), + ("NormArr", gs(&[("BM", Object::Array(vec![Object::name("Normal"), Object::name("Multiply")]))])), + ] { + states.set(k.as_bytes().to_vec(), v); + } + let group = |s: &str| { + let mut g = Dict::new(); + g.set(b"S".to_vec(), Object::name(s)); + Object::Dict(g) + }; + let try_with = |prefix: &str, s: &str| { + let (mut doc, _) = page_with_form(&[("Group", group(s))], b"0 0 1 1 re f", prefix, Some(states.clone())); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_ok() + }; + assert!(!try_with("/Stroke gs", "Transparency"), "stroke alpha"); + assert!(try_with("/NoMask gs", "Transparency"), "SMask /None"); + assert!(!try_with("/Mask gs", "Transparency"), "a soft mask"); + assert!(!try_with("/Mul gs", "Transparency"), "Multiply"); + assert!(try_with("/Compat gs", "Transparency"), "Compatible"); + assert!(!try_with("/MulArr gs", "Transparency"), "an array's first entry decides: Multiply"); + assert!(try_with("/NormArr gs", "Transparency"), "an array's first entry decides: Normal"); + assert!(try_with("/Mul gs", "Other"), "not a transparency group"); + // Nor are the blend modes inside a group that isn't a transparency group. + let mut inner = Dict::new(); + let mut inner_states = Dict::new(); + inner_states.set(b"Mul".to_vec(), gs(&[("BM", Object::name("Multiply"))])); + inner.set(b"ExtGState".to_vec(), Object::Dict(inner_states)); + let (mut doc, _) = page_with_form(&[("Group", group("Other")), ("Resources", Object::Dict(inner.clone()))], b"/Mul gs 0 0 1 1 re f", "", None); + assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_ok(), "blending inside a non-transparency group"); + let (mut doc, _) = page_with_form(&[("Group", group("Transparency")), ("Resources", Object::Dict(inner))], b"/Mul gs 0 0 1 1 re f", "", None); + assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_err(), "blending inside a transparency group"); + // The gs in an earlier content stream of the page. + let (mut doc, _) = page_with_form(&[("Group", group("Transparency"))], b"0 0 1 1 re f", "", Some(states.clone())); + let first = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Mul gs"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))])); + }) + .unwrap(); + let mut doc = reopen(&doc); + assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_err(), "carried from the first stream"); + // #3067 review: the Do's index counts the page's streams as one. Read as an index into its + // own stream it ran past the Do to the Q after it, and missed the Multiply it is drawn with. + let (mut doc, _) = page_with_form(&[("Group", group("Transparency"))], b"0 0 1 1 re f", "", Some(states.clone())); + let first = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 m 1 1 l n"))); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Mul gs /Art Do Q 0 0 m"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))])); + }) + .unwrap(); + let mut doc = reopen(&doc); + let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup); + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("drawn translucent")), "drawn under Multiply in the second stream: {r:?}"); +} + +/// A transparency group's blend modes are looked for where they are used: in the `gs` its +/// content sets (not every ExtGState its resources happen to list) and inside the forms it +/// draws, at any depth. A nested form that draws itself counts as blending, without looping. +#[test] +fn ungroup_looks_for_blend_modes_where_they_are_used() { + let gs = |bm: &str| { + let mut d = Dict::new(); + d.set(b"BM".to_vec(), Object::name(bm)); + Object::Dict(d) + }; + let mut group = Dict::new(); + group.set(b"S".to_vec(), Object::name("Transparency")); + let attempt = |form_content: &[u8], nested_content: Option<(&[u8], &str)>, self_loop: bool| { + let mut doc = fixture(); + let mut inner_res = Dict::new(); + let mut states = Dict::new(); + states.set(b"Mul".to_vec(), gs("Multiply")); + states.set(b"Norm".to_vec(), gs("Normal")); + inner_res.set(b"ExtGState".to_vec(), Object::Dict(states)); + let nested = nested_content.map(|(content, _)| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + d.set(b"Resources".to_vec(), Object::Dict(inner_res.clone())); + doc.add(Object::Stream(Stream::flate(d, content))) + }); + if let (Some(n), true) = (nested, self_loop) { + // The nested form draws itself. + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + let mut res = inner_res.clone(); + let mut xo = Dict::new(); + xo.set(b"Me".to_vec(), Object::Ref(n)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + doc.set(n, Object::Stream(Stream::flate(d, b"/Me Do"))); + } + let mut res = inner_res.clone(); + if let (Some(n), Some((_, name))) = (nested, nested_content) { + let mut xo = Dict::new(); + xo.set(name.as_bytes().to_vec(), Object::Ref(n)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + form.set(b"Group".to_vec(), Object::Dict(group.clone())); + form.set(b"Resources".to_vec(), Object::Dict(res)); + let artwork = doc.add(Object::Stream(Stream::flate(form, form_content))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_ok() + }; + assert!(attempt(b"/Norm gs 0 0 1 1 re f", None, false), "Normal is fine"); + assert!(attempt(b"0 0 1 1 re f", None, false), "Multiply listed but never used"); + assert!(!attempt(b"/Mul gs 0 0 1 1 re f", None, false), "Multiply used"); + assert!(!attempt(b"/Inner Do", Some((b"/Mul gs 0 0 1 1 re f", "Inner")), false), "Multiply inside a nested form"); + assert!(attempt(b"/Inner Do", Some((b"/Norm gs 0 0 1 1 re f", "Inner")), false), "a nested form without blending"); + assert!(!attempt(b"/Inner Do", Some((b"", "Inner")), true), "a nested form that draws itself"); +} + +/// Ungroup the form `Art` drawn by fixture page 1, with `content`; `build` adds the form's other +/// entries (`/Group`, `/Resources`) and the objects they refer to. +fn ungroup_built(content: &[u8], build: impl FnOnce(&mut Document, &mut Dict)) -> Result<(), EditError> { + let mut doc = fixture(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + build(&mut doc, &mut form); + let artwork = doc.add(Object::Stream(Stream::flate(form, content))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).map(|_| ()) +} + +/// #3067 review: the entries a refusal depends on count when they are indirect objects too +/// (ISO 32000-2 §7.3.10): a group's `/S` and `/K`, and the `/Subtype` or `/PatternType` of what +/// the form draws. +#[test] +fn ungroup_reads_indirect_group_and_type_entries() { + let refused = |r: Result<(), EditError>, why: &str| match r { + Err(EditError::Invalid(m)) => assert!(m.contains(why), "{m}"), + other => panic!("expected a refusal mentioning {why:?}, got {other:?}"), + }; + let fill = b"1 0 0 rg 0 0 10 10 re f"; + let group = |doc: &mut Document, form: &mut Dict, s: bool, k: Option| { + let mut g = Dict::new(); + let name = Object::name("Transparency"); + g.set(b"S".to_vec(), if s { Object::Ref(doc.add(name)) } else { name }); + if let Some(k) = k { + g.set(b"K".to_vec(), Object::Ref(doc.add(Object::Bool(k)))); + } + form.set(b"Group".to_vec(), Object::Dict(g)); + }; + refused(ungroup_built(fill, |doc, form| group(doc, form, true, Some(true))), "knockout"); + refused( + ungroup_built(fill, |doc, form| { + group(doc, form, false, None); + let k = Object::Ref(doc.add(Object::Bool(true))); + if let Some(Object::Dict(g)) = form.get_mut(b"Group") { + g.set(b"K".to_vec(), k); + } + }), + "knockout", + ); + ungroup_built(fill, |doc, form| group(doc, form, true, Some(false))).unwrap(); + + // A nested form whose /Subtype is indirect is still looked into for blend modes. + refused( + ungroup_built(b"/Inner Do", |doc, form| { + group(doc, form, true, None); + let mut bm = Dict::new(); + bm.set(b"BM".to_vec(), Object::name("Multiply")); + let mut states = Dict::new(); + states.set(b"Mul".to_vec(), Object::Dict(bm)); + let mut inner_res = Dict::new(); + inner_res.set(b"ExtGState".to_vec(), Object::Dict(states)); + let mut inner = Dict::new(); + inner.set(b"Subtype".to_vec(), Object::Ref(doc.add(Object::name("Form")))); + inner.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + inner.set(b"Resources".to_vec(), Object::Dict(inner_res)); + let inner = doc.add(Object::Stream(Stream::flate(inner, b"/Mul gs 0 0 1 1 re f"))); + let mut xo = Dict::new(); + xo.set(b"Inner".to_vec(), Object::Ref(inner)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + form.set(b"Resources".to_vec(), Object::Dict(res)); + }), + "blend", + ); + + // A nested form or tiling pattern without resources, typed through an indirect object, still + // inherits the form's renamed ones. + for (cat, content) in [("XObject", &b"BT /F1 9 Tf (a) Tj ET /N Do"[..]), ("Pattern", b"BT /F1 9 Tf (a) Tj ET /Pattern cs /N scn 0 0 10 10 re f")] + { + let r = ungroup_built(content, |doc, form| { + let mut d = Dict::new(); + if cat == "XObject" { + d.set(b"Subtype".to_vec(), Object::Ref(doc.add(Object::name("Form")))); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + } else { + d.set(b"PatternType".to_vec(), Object::Ref(doc.add(Object::Int(1)))); + } + let nested = doc.add(Object::Stream(Stream::flate(d, b"BT /F1 9 Tf (n) Tj ET"))); + let mut times = Dict::new(); + times.set(b"Type".to_vec(), Object::name("Font")); + times.set(b"Subtype".to_vec(), Object::name("Type1")); + times.set(b"BaseFont".to_vec(), Object::name("Times-Roman")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(times)); + let mut named = Dict::new(); + named.set(b"N".to_vec(), Object::Ref(nested)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(cat.as_bytes().to_vec(), Object::Dict(named)); + form.set(b"Resources".to_vec(), Object::Dict(res)); + }); + refused(r, "uses its resources"); + } +} + +/// Ungroup the form `Art` drawn by fixture page 1, with `content` and `resources`, as a +/// transparency group or not; `build` adds what the resources refer to first. +fn ungroup_art(content: &[u8], group: bool, build: impl FnOnce(&mut Document) -> Dict) -> Result<(), EditError> { + let mut doc = fixture(); + let res = build(&mut doc); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + if group { + let mut g = Dict::new(); + g.set(b"S".to_vec(), Object::name("Transparency")); + form.set(b"Group".to_vec(), Object::Dict(g)); + } + form.set(b"Resources".to_vec(), Object::Dict(res)); + let artwork = doc.add(Object::Stream(Stream::flate(form, content))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).map(|_| ()) +} + +/// A nested form, Type 3 font or tiling pattern without `/Resources` takes its names from the +/// form drawing it (the renderer chains them). Once the form's names are renamed into the page's, +/// it would find the page's instead (the page's Courier `F1`, not the form's Times), so ungroup +/// refuses. With resources of its own it is unaffected, and so is a form without resources of +/// its own, whose names are already the page's. +#[test] +fn ungroup_refuses_nested_content_that_inherits_renamed_resources() { + let times = || { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + d.set(b"Subtype".to_vec(), Object::name("Type1")); + d.set(b"BaseFont".to_vec(), Object::name("Times-Roman")); + Object::Dict(d) + }; + let nested = |doc: &mut Document, kind: &str, own: bool| -> (&'static str, Object) { + let mut d = Dict::new(); + if own { + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + } + match kind { + "form" => { + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + ("XObject", Object::Ref(doc.add(Object::Stream(Stream::flate(d, b"BT /F1 9 Tf (n) Tj ET"))))) + } + "type3" => { + d.set(b"Type".to_vec(), Object::name("Font")); + d.set(b"Subtype".to_vec(), Object::name("Type3")); + ("Font", Object::Dict(d)) + } + _ => { + d.set(b"PatternType".to_vec(), Object::Int(1)); + ("Pattern", Object::Ref(doc.add(Object::Stream(Stream::flate(d, b"BT /F1 9 Tf (n) Tj ET"))))) + } + } + }; + let content = |kind: &str| -> &'static [u8] { + match kind { + "form" => b"BT /F1 9 Tf (a) Tj ET /N Do", + "type3" => b"BT /F1 9 Tf (a) Tj /N 9 Tf (b) Tj ET", + // An uncoloured pattern: the name comes after the colour. + _ => b"BT /F1 9 Tf (a) Tj ET /PG cs 0.5 /N scn 0 0 10 10 re f", + } + }; + let run = |kind: &'static str, own: bool| { + ungroup_art(content(kind), false, |doc| { + let (cat, obj) = nested(doc, kind, own); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), times()); + let mut res = Dict::new(); + if cat == "Font" { + fonts.set(b"N".to_vec(), obj); + } else { + let mut d = Dict::new(); + d.set(b"N".to_vec(), obj); + res.set(cat.as_bytes().to_vec(), Object::Dict(d)); + } + res.set(b"Font".to_vec(), Object::Dict(fonts)); + // `cs` takes a name (ISO 32000-2 §8.6.8, Table 73), here the uncoloured pattern space. + let mut spaces = Dict::new(); + spaces.set(b"PG".to_vec(), Object::Array(vec![Object::name("Pattern"), Object::name("DeviceGray")])); + res.set(b"ColorSpace".to_vec(), Object::Dict(spaces)); + res + }) + }; + for kind in ["form", "type3", "pattern"] { + let r = run(kind, false); + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("uses its resources")), "{kind}: {r:?}"); + assert!(run(kind, true).is_ok(), "{kind} with resources of its own"); + } + + // An image and a shading pattern have no content to look names up for. + let r = ungroup_art(b"BT /F1 9 Tf (a) Tj ET /Im Do /Pattern cs /Sh scn 0 0 10 10 re f", false, |doc| { + let mut img = Dict::new(); + img.set(b"Subtype".to_vec(), Object::name("Image")); + img.set(b"Width".to_vec(), Object::Int(1)); + img.set(b"Height".to_vec(), Object::Int(1)); + img.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + img.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let img = doc.add(Object::Stream(Stream::flate(img, b"@"))); + let mut shading = Dict::new(); + shading.set(b"ShadingType".to_vec(), Object::Int(2)); + let mut pattern = Dict::new(); + pattern.set(b"PatternType".to_vec(), Object::Int(2)); + pattern.set(b"Shading".to_vec(), Object::Dict(shading)); + let mut res = Dict::new(); + let mut d = Dict::new(); + d.set(b"Im".to_vec(), Object::Ref(img)); + res.set(b"XObject".to_vec(), Object::Dict(d)); + let mut d = Dict::new(); + d.set(b"Sh".to_vec(), Object::Dict(pattern)); + res.set(b"Pattern".to_vec(), Object::Dict(d)); + let mut d = Dict::new(); + d.set(b"F1".to_vec(), times()); + res.set(b"Font".to_vec(), Object::Dict(d)); + res + }); + assert!(r.is_ok(), "an image and a shading pattern: {r:?}"); + + // The outer form has no resources: the nested form already finds the page's, as it will. + let mut doc = fixture(); + let mut inner = Dict::new(); + inner.set(b"Subtype".to_vec(), Object::name("Form")); + inner.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + let inner = doc.add(Object::Stream(Stream::flate(inner, b"BT /F1 9 Tf (n) Tj ET"))); + let mut outer = Dict::new(); + outer.set(b"Subtype".to_vec(), Object::name("Form")); + outer.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + let outer = doc.add(Object::Stream(Stream::flate(outer, b"/N Do"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(outer)); + xo.set(b"N".to_vec(), Object::Ref(inner)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + let art = images::page_images(&doc, 0).unwrap().iter().position(|i| i.name == "Art").unwrap(); + images::change_image(&mut doc, 0, art, &images::ImageChange::Ungroup).unwrap(); +} + +/// #3067 review: a form whose marked content or text objects don't balance within its own +/// stream is refused: inlined, an extra EMC would close what the page opened, and one left open +/// would take in the page's later content. +#[test] +fn ungroup_refuses_unbalanced_marked_content_and_text_objects() { + assert!(ungroup_art(b"/P BMC BT ET EMC /Q <> BDC EMC", false, |_| Dict::new()).is_ok(), "balanced"); + for content in [&b"EMC"[..], b"/P BMC", b"/P <> BDC", b"BT", b"ET", b"BT BT ET ET", b"BT EMC ET"] { + let r = ungroup_art(content, false, |_| Dict::new()); + assert!( + matches!(r, Err(EditError::Invalid(ref m)) if m.contains("marked content") || m.contains("text object")), + "{}: {r:?}", + String::from_utf8_lossy(content) + ); + } +} + +/// #3067 review: inside a transparency group, painting with a pattern blends when a shading +/// pattern's own graphics state does, or a tiling pattern's content does. +#[test] +fn ungroup_looks_for_blend_modes_in_patterns() { + let attempt = |shading_bm: Option<&str>, tiling_content: Option<&[u8]>| { + ungroup_art(b"/Pattern cs /P1 scn 0 0 10 10 re f", true, |doc| { + let mut states = Dict::new(); + for (name, bm) in [("Mul", "Multiply"), ("Norm", "Normal")] { + let mut gs = Dict::new(); + gs.set(b"BM".to_vec(), Object::name(bm)); + states.set(name.as_bytes().to_vec(), Object::Dict(gs)); + } + let pattern = match (shading_bm, tiling_content) { + (Some(bm), _) => { + let mut gs = Dict::new(); + gs.set(b"BM".to_vec(), Object::name(bm)); + let mut p = Dict::new(); + p.set(b"PatternType".to_vec(), Object::Int(2)); + p.set(b"ExtGState".to_vec(), Object::Dict(gs)); + doc.add(Object::Dict(p)) + } + (None, content) => { + let mut p = Dict::new(); + p.set(b"PatternType".to_vec(), Object::Int(1)); + p.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + let mut res = Dict::new(); + res.set(b"ExtGState".to_vec(), Object::Dict(states.clone())); + p.set(b"Resources".to_vec(), Object::Dict(res)); + doc.add(Object::Stream(Stream::flate(p, content.unwrap_or_default()))) + } + }; + let mut patterns = Dict::new(); + patterns.set(b"P1".to_vec(), Object::Ref(pattern)); + let mut res = Dict::new(); + res.set(b"Pattern".to_vec(), Object::Dict(patterns)); + res + }) + }; + assert!(attempt(Some("Normal"), None).is_ok(), "a shading pattern without blending"); + assert!(attempt(Some("Multiply"), None).is_err(), "a shading pattern that multiplies"); + assert!(attempt(None, Some(b"/Norm gs 0 0 1 1 re f")).is_ok(), "a tiling pattern without blending"); + assert!(attempt(None, Some(b"/Mul gs 0 0 1 1 re f")).is_err(), "a tiling pattern that multiplies"); +} + +/// #3067 review: a form drawn many times inside a group is read once (its answer is kept), so +/// shared forms nested to the limit can't multiply the work; and the reads have a ceiling, past +/// which the group counts as blending. +#[test] +fn ungroup_reads_each_nested_form_once_and_within_a_ceiling() { + // Six levels, each drawing the one below ten times: 10^6 reads without the cache. + let shared = ungroup_art(&b"/F Do ".repeat(10), true, |doc| { + let mut below: Option = None; + for _ in 0..6 { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + let mut res = Dict::new(); + let content = match below { + Some(r) => { + let mut xo = Dict::new(); + xo.set(b"F".to_vec(), Object::Ref(r)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + b"/F Do ".repeat(10) + } + None => b"0 0 1 1 re f".to_vec(), + }; + d.set(b"Resources".to_vec(), Object::Dict(res)); + below = Some(doc.add(Object::Stream(Stream::flate(d, &content)))); + } + let mut xo = Dict::new(); + xo.set(b"F".to_vec(), Object::Ref(below.unwrap())); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + res + }); + assert!(shared.is_ok(), "{shared:?}"); + // 2000 different forms: more reads than the ceiling allows. + let many = ungroup_art(&(0..2000).map(|i| format!("/F{i} Do ")).collect::().into_bytes(), true, |doc| { + let mut xo = Dict::new(); + for i in 0..2000 { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + xo.set(format!("F{i}").into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(d, b"0 0 1 1 re f"))))); + } + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + res + }); + assert!(matches!(many, Err(EditError::Invalid(ref m)) if m.contains("blend")), "{many:?}"); +} + +/// A transparency group that draws a nested form whose content can't be read is refused: what +/// it blends can't be known, so it isn't guessed. +#[test] +fn ungroup_refuses_a_group_drawing_an_unreadable_form() { + let mut doc = fixture(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let broken = doc.add(Object::Stream(Stream::from_raw(d, b"not deflated at all".to_vec()))); + let mut xo = Dict::new(); + xo.set(b"Inner".to_vec(), Object::Ref(broken)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut group = Dict::new(); + group.set(b"S".to_vec(), Object::name("Transparency")); + let (mut with_group, _) = { + let mut doc2 = doc.clone(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + form.set(b"Group".to_vec(), Object::Dict(group)); + form.set(b"Resources".to_vec(), Object::Dict(res)); + let artwork = doc2.add(Object::Stream(Stream::flate(form, b"/Inner Do"))); + doc2.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc2.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + (reopen(&doc2), artwork) + }; + let r = images::change_image(&mut with_group, 0, 0, &images::ImageChange::Ungroup); + assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("blend")), "{r:?}"); +} + +/// A form in a layer keeps its layer once ungrouped: its content is marked `/OC BDC … EMC` +/// with a Properties entry for the same optional-content object (an existing one reused), so +/// hiding the layer still hides it. Tagged form content is refused: inline, it would lose its +/// structure element. +#[test] +fn ungroup_keeps_a_forms_layer_and_refuses_tagged_content() { + let mut doc = fixture(); + let mut ocg = Dict::new(); + ocg.set(b"Type".to_vec(), Object::name("OCG")); + ocg.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"Walls".to_vec(), hex: false })); + let layer = doc.add(Object::Dict(ocg)); + let base = reopen(&doc); + let ops_after = |reuse: bool| { + let mut doc = base.clone(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + form.set(b"OC".to_vec(), Object::Ref(layer)); + let artwork = doc.add(Object::Stream(Stream::flate(form, b"0 0 1 rg 0 0 10 10 re f"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + if reuse { + let mut props = Dict::new(); + props.set(b"MC0".to_vec(), Object::Ref(layer)); + d.set(b"Properties".to_vec(), Object::Dict(props)); + } + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(res.get(b"Properties").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one stream") }; + (pdfcraft_content::parse(&s.decoded().unwrap()).ops, props) + }; + for reuse in [false, true] { + let (ops, props) = ops_after(reuse); + let bdc = ops.iter().position(|o| o.is("BDC")).expect("the content is marked"); + assert_eq!(ops[bdc].name(0), Some(&b"OC"[..])); + let name = ops[bdc].name(1).unwrap().to_vec(); + assert_eq!(props.get(&name), Some(&Object::Ref(layer)), "the marked content names the form's layer"); + if reuse { + assert_eq!(name, b"MC0", "the page's own entry for that layer is reused"); + assert_eq!(props.iter().count(), 1); + } + let emc = ops.iter().position(|o| o.is("EMC")).expect("closed"); + let (q, last_q) = (ops.iter().position(|o| o.is("q")).unwrap(), ops.iter().rposition(|o| o.is("Q")).unwrap()); + assert!( + q < bdc && bdc < emc && emc < last_q, + "inside the placement's q … Q: {:?}", + ops.iter().map(|o| String::from_utf8_lossy(&o.op).into_owned()).collect::>() + ); + } + + let mut doc = base.clone(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + form.set(b"StructParent".to_vec(), Object::Int(3)); + let artwork = doc.add(Object::Stream(Stream::flate(form, b"0 0 1 1 re f"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup); + assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("tagged")), "{r:?}"); +} + +/// A transparency group drawing a nested form whose content has parts that can't be read as +/// operators is refused too: a blend mode could hide in what was skipped. +#[test] +fn ungroup_refuses_a_group_drawing_a_partly_unreadable_form() { + let mut doc = fixture(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + let nested = doc.add(Object::Stream(Stream::flate(d, b"0 0 1 1 re f ) ) ]]"))); + let mut xo = Dict::new(); + xo.set(b"Inner".to_vec(), Object::Ref(nested)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut group = Dict::new(); + group.set(b"S".to_vec(), Object::name("Transparency")); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec())); + form.set(b"Group".to_vec(), Object::Dict(group)); + form.set(b"Resources".to_vec(), Object::Dict(res)); + let artwork = doc.add(Object::Stream(Stream::flate(form, b"/Inner Do"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Art".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q"))); + let mut doc = reopen(&doc); + let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup); + assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("blend")), "{r:?}"); +} + /// The OCR stamp path registers the plan's Type3 faces (a free `PCOcr…` name per stamp, so a /// page that is OCR'd twice keeps both layers' fonts), and the stamped codes extract back /// through the font's ToUnicode CMap. diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs new file mode 100644 index 0000000000..76a1f3fe5c --- /dev/null +++ b/crates/edit/src/ungroup.rs @@ -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 cm 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, Vec)>; + +/// 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, Dict, Vec<(Vec, 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 { + 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> { + let a = s.dict.get(key).map(|o| doc.resolve(o))?; + let v: Vec = 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> { + 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, 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 { + 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 = 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, + known: HashMap, + 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, Unreadable> { + let operands = &op.operands; + let name_at = |i: Option| 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) = 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 { + 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 { + 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) +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 91b33ad017..f9c7015071 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -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 diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 615ac0fea5..3b8a9a8ace 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -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"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 4719de302f..61e48a7d04 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -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)?; } } diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 07ca017798..6f0187f417 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -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)); } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 4596e90622..24f46ac6a1 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -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![ + 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] { diff --git a/docs/editing-images.md b/docs/editing-images.md index 4ebafd115f..16a78f711a 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -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 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2168a2c49d..7202ac00d8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"