M7: Ungroup draws Form artwork in place, so its text can be edited (#2964, #3005)

Text drawn inside a Form XObject was one piece of artwork in Edit text &
images: HTML-to-PDF output that wraps a block with a translucent picture
in a transparency group showed a whole table as a single box with no
editable text (#3005).

Ungroup (artwork right-click menu; image_edit action ungroup) replaces
that placement's Do with the form's content, wrapped as
q <Matrix> cm <BBox> re W n ... Q, its resources merged into a copy of the
page's under fresh names where they clash (fonts, XObjects, graphics
states, colour spaces incl. inline images', patterns, shadings,
properties). The form object and its other placements are untouched; a
form in a layer keeps it as /OC ... BDC ... EMC.

Refused rather than changed: knockout groups, transparency groups that
blend inside (used gs, nested forms) or are drawn translucent, blended or
soft-masked at the Do (followed per parameter across q/Q and streams),
tagged form content, content that can't be decoded strictly or parsed,
and unbalanced Q. cos gains Stream::decoded_strict_within for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-31 08:03:39 +08:00
1 parent fa5f9f8c3b
commit 6eea737e14
36 files changed
+1146 -6

No files matched your search

+1
View File
@@ -413,6 +413,7 @@ impl Automation {
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
"flip_vertical" => ImageEdit::Flip { horizontal: false },
"delete" => ImageEdit::Delete,
"ungroup" => ImageEdit::Ungroup,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
+2 -2
View File
@@ -1378,14 +1378,14 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), 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), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. 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), 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), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place), delete, or 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 or are drawn translucent, which would composite differently). 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", "flip_horizontal", "flip_vertical", "replace", "delete"] },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete", "ungroup"] },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"quarters": { "type": "integer" },
"degrees": { "type": "number" },
+83
View File
@@ -10190,3 +10190,86 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
}
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how
/// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece
/// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its
/// text becomes paragraphs that `text_edit` changes, and undo brings the form back.
fn table_in_a_form_pdf() -> Vec<u8> {
let table = b"BT /F1 12 Tf 1 0 0 1 20 120 Tm (Revenue) Tj 1 0 0 1 120 120 Tm (153,300.00) Tj 1 0 0 1 20 100 Tm (Costs) Tj 1 0 0 1 120 100 Tm (382,110.00) Tj ET 0.6 G 0.5 w 15 115 m 220 115 l S";
// Two pictures with soft masks overlapping each other, the second also over the table's
// last cell: a translucent wash, and a picture whose corner pixels are see-through.
let inner = [&table[..], b" q 40 0 0 20 30 40 cm /Sig Do Q q 60 0 0 40 50 30 cm /Wash Do Q q 100 0 0 30 110 90 cm /Sig Do Q"].concat();
let rgb: Vec<u8> = std::iter::repeat_n([20u8, 60, 220], 16).flatten().collect();
let alpha: Vec<u8> = (0..16).map(|i| if i % 3 == 0 { 255 } else { 0 }).collect();
let page = b"q /Fm1 Do Q";
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 160] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
stream("<<", page),
stream(
"<< /Type /XObject /Subtype /Form /BBox [0 0 240 160] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /Font << /F1 6 0 R >> /XObject << /Sig 7 0 R /Wash 9 0 R >> >>",
&inner,
),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 8 0 R", &rgb),
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceGray /BitsPerComponent 8", &alpha),
stream(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 10 0 R",
&[230, 40, 40].repeat(4),
),
stream("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[128, 96, 160, 64]),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn ungrouping_a_table_drawn_in_a_form_makes_its_text_editable() {
let dir = workdir("ungroup");
std::fs::write(dir.join("table.pdf"), table_in_a_form_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
let paragraphs = |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"].as_array().unwrap().len();
let objects = |a: &mut Automation| ok(a, "object_list", json!({ "doc": doc, "page": 1 }))["objects"].as_array().unwrap().len();
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "one piece of artwork, no text");
let (w, h, before) = a.render_pixels(doc, 1, 2.0).unwrap();
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" }));
let (w2, h2, after) = a.render_pixels(doc, 1, 2.0).unwrap();
assert_eq!((w, h), (w2, h2));
let differing = before.as_ref().as_chunks::<4>().0.iter().zip(after.as_ref().as_chunks::<4>().0).filter(|(p, q)| p != q).count();
assert_eq!(differing, 0, "ungrouping draws the same pixels");
assert!(paragraphs(&mut a) >= 4, "the table's text is page text now");
let images = ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }));
let kinds: Vec<_> = images["images"].as_array().unwrap().iter().map(|i| i["kind"].clone()).collect();
assert_eq!(kinds, vec![json!("image"); 3], "the pictures are separate pictures now: {images}");
let list = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
let target =
list["paragraphs"].as_array().unwrap().iter().find(|p| p["text"].as_str().is_some_and(|t| t.contains("382,110.00"))).unwrap()["paragraph"]
.clone();
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": target, "text": "400,000.00" }));
let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
let s = text.to_string();
assert!(s.contains("400,000.00") && !s.contains("382,110.00"), "{s}");
ok(&mut a, "edit_undo", json!({ "doc": doc }));
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "undo brings the form back");
}
+7 -1
View File
@@ -323,8 +323,14 @@ impl Stream {
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
self.decoded_strict_within(MAX_DECODED)
}
/// Like [`Stream::decoded_strict`], bounded by `max` (and [`MAX_DECODED`]): for data that
/// will be copied elsewhere, where a damaged stream must not pass for a shorter one.
pub fn decoded_strict_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0));
self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] bounded by `max` and counted like
+15 -1
View File
@@ -1,7 +1,7 @@
//! Edit a PDF ▸ edit existing images and grouped artwork (Image/Form XObjects painted by `Do`
//! in its own content streams), and changing one: move/resize/rotate/flip (the `Do` is wrapped in
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
//! or delete. Only the stream that draws it is rewritten, as a new object.
//! delete, or ungroup grouped artwork (its content drawn in place, see `ungroup`). Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, Pieces, parse};
@@ -394,6 +394,9 @@ pub enum ImageChange {
/// Draw this image XObject instead, in the same place.
Replace(ObjRef),
Delete,
/// 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).
@@ -493,6 +496,17 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
ImageChange::Crop(keep) => {
return crop_image(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 parsed: Vec<Vec<Op>> = all.iter().map(|(_, d)| parse(d).ops).collect();
let translucent = crate::ungroup::translucent_at(doc, &res, &parsed, img.stream, img.op);
let (inline, merged) = crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent)?;
resources = Some(merged);
inline
}
ImageChange::Replace(r) => {
if img.is_form {
return Err(EditError::Invalid(
+1
View File
@@ -43,6 +43,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::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
+613
View File
@@ -3778,6 +3778,165 @@ 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<Dict>) -> (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::<Vec<_>>();
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<Object> = 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<u8> = 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<Dict>| {
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");
}
#[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");
@@ -8576,3 +8735,457 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning()
assert_eq!(page_content_bytes(&doc, 0), before);
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<u8>)> = 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:?}");
assert!(
hits.iter().any(|(_, n)| lookup(cat, n) == Some(tag(&format!("form {cat}")))),
"{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");
}
/// Ungroup draws the form where the `Do` drew it and only inside its bounding box: the inline
/// content starts `q <Matrix> cm <BBox> 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<String> = 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 <form> 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<String> = 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<Vec<u8>> =
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");
}
/// 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");
}
/// 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 <name> 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::<Vec<_>>()
);
}
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:?}");
}
+303
View File
@@ -0,0 +1,303 @@
//! Edit a PDF ▸ Ungroup: draw a Form XObject's content in place of its `Do` (#2964, #3005).
//!
//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own
//! content streams. Ungrouping copies the form's content into the page at the same place,
//! wrapped as `q <Matrix> cm <BBox> re W n … Q`, with the form's resources merged into a copy of
//! the page's under names that don't clash. Its text then edits like any other. The form object
//! itself is left alone, so another page or placement that draws it is unchanged.
//!
//! 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. Those are refused rather than changed, as is tagged form content (its structure link
//! would be lost). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
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"];
/// A name map for one resource category: form name → name in the page's resources.
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
/// The ops that draw form `name` (from the page's `resources`) inline, and the page resources
/// with the form's merged in. `translucent` says whether the graphics state at the `Do` is
/// translucent, blended or soft-masked, which a transparency group composites as a whole.
pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translucent: bool) -> Result<(Vec<Op>, Dict), EditError> {
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
if s.dict.name(b"Subtype") != Some(b"Form") {
return Err(refuse("it is not a Form XObject"));
}
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
};
let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?;
let matrix = match s.dict.get(b"Matrix") {
None => None,
Some(_) => Some(Matrix(
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?)
.map_err(|_| refuse("its matrix is unreadable"))?,
)),
};
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
let form_res = own.clone().unwrap_or_else(|| resources.clone());
// A tagged form's content is a structure element's: drawn inline it would lose that link.
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags"));
}
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| g.name(b"S") == Some(b"Transparency"));
if let Some(group) = &group {
if matches!(group.get(b"K"), Some(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"));
}
}
// 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 seen: Vec<pdfcraft_cos::ObjRef> = form.as_ref().into_iter().collect();
if blends_inside(doc, &form_res, &ops, 0, &mut seen) {
return Err(refuse("it is a transparency group with blend modes inside"));
}
}
// 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() {
if mine.get(key) == Some(value) {
continue;
}
let fresh = loop {
n = n.saturating_add(1);
let candidate = format!("PCUg{n}").into_bytes();
if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() {
break candidate;
}
if n == u32::MAX {
return Err(refuse("too many resources"));
}
};
mine.set(fresh.clone(), value.clone());
map.push((key.to_vec(), fresh));
}
merged.set(cat.to_vec(), Object::Dict(mine));
if !map.is_empty() {
renames.push((cat, map));
}
}
}
let renamed = |cat: &[u8], name: &[u8]| -> Option<Vec<u8>> {
renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone())
};
let mut depth = 0usize;
for op in &mut ops {
let slot: Option<(&[u8], usize)> = match op.op.as_slice() {
b"Tf" | b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => {
let cat: &[u8] = match op.op.as_slice() {
b"Tf" => b"Font",
b"Do" => b"XObject",
b"gs" => b"ExtGState",
b"sh" => b"Shading",
_ => b"ColorSpace",
};
Some((cat, 0))
}
b"scn" | b"SCN" => op.operands.len().checked_sub(1).map(|i| (&b"Pattern"[..], i)),
b"BDC" | b"DP" => Some((&b"Properties"[..], 1)),
b"q" => {
depth = depth.saturating_add(1);
None
}
b"Q" => {
depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?;
None
}
_ => None,
};
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));
}
}
}
}
// 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))
}
/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending.
const MAX_NESTING: usize = 8;
/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they
/// use, or one used by a form they draw, at any depth. A nested form that can't be read, is
/// nested too deep or draws itself counts as blending, so the caller refuses rather than guesses.
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, seen: &mut Vec<pdfcraft_cos::ObjRef>) -> 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) = (category(b"ExtGState"), category(b"XObject"));
for op in ops {
if op.is("gs")
&& op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs))
{
return true;
}
if !op.is("Do") {
continue;
}
let Some(o) = op.name(0).and_then(|n| xobjects.get(n)) else { continue };
let Object::Stream(nested) = &*doc.resolve(o) else { continue };
if nested.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
if depth >= MAX_NESTING || o.as_ref().is_some_and(|r| seen.contains(&r)) {
return true;
}
let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true };
let own = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_else(|| resources.clone());
if let Some(r) = o.as_ref() {
seen.push(r);
}
let inner = parse(&data);
if inner.skipped > 0 {
return true;
}
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, seen);
if let Some(r) = o.as_ref() {
seen.retain(|x| *x != r);
}
if blends {
return true;
}
}
false
}
/// 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 stream
/// `stream`, following `q`/`Q` and `gs` through the page's streams in order. Each `gs` changes
/// only the parameters it sets.
pub(crate) fn translucent_at(doc: &Document, resources: &Dict, streams: &[Vec<Op>], stream: usize, 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 (si, ops) in streams.iter().enumerate().take(stream.saturating_add(1)) {
let end = if si == stream { at.min(ops.len()) } else { ops.len() };
for op in ops.iter().take(end) {
match op.op.as_slice() {
b"q" => stack.push(now),
b"Q" => now = stack.pop().unwrap_or([false; 4]),
b"gs" => {
let Some(gs) = op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else {
continue;
};
let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0);
if let Some(v) = alpha(b"ca") {
now[0] = v;
}
if let Some(v) = alpha(b"CA") {
now[1] = v;
}
if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) {
now[2] = m.as_name() != Some(b"None");
}
if gs.get(b"BM").is_some() {
now[3] = blends(doc, &gs);
}
}
_ => {}
}
}
}
now.iter().any(|v| *v)
}
+3
View File
@@ -20,6 +20,9 @@ pub enum ImageEdit {
bytes: Arc<Vec<u8>>,
},
Delete,
/// Grouped Form artwork: draw its content in place, so its text and pictures can be edited
/// one by one (#2964).
Ungroup,
}
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
+1
View File
@@ -259,6 +259,7 @@ impl Edit {
ImageEdit::Flip { .. } => "Flip image",
ImageEdit::Replace { .. } => "Replace image",
ImageEdit::Delete => "Delete image",
ImageEdit::Ungroup => "Ungroup artwork",
}),
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
+1
View File
@@ -556,6 +556,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
}
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Ungroup => pdfcraft_edit::ImageChange::Ungroup,
};
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
}
+2
View File
@@ -2005,6 +2005,7 @@
Rotate image تدوير الصورة
Flip image قلب الصورة
Delete image حذف الصورة
Ungroup artwork فك تجميع العمل الفني
Add header & footer إضافة رأس وتذييل
Update header & footer تحديث الرأس والتذييل
Update watermark تحديث العلامة المائية
@@ -2788,6 +2789,7 @@ certificate Version الإصدار
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. استخدمه عندما تتجاهل الطابعة النص أو التظليل أو الشفافية. لا تعود المطبوعة نصًا قابلًا للبحث؛ ومع الطباعة بتدرج الرمادي تكون الصور رمادية.
Crop Image قص الصورة
Remove Crop إزالة القص
Ungroup فك التجميع
Trim the picture with its handles; Enter or Done applies قص الصورة بمقابضها؛ يطبّق Enter أو تم
Redaction mark appearance مظهر علامة الحجب
Outline colour: لون الحد الخارجي:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2032,6 +2032,7 @@
Delete document JavaScript Изтрий JavaScript на документа
Delete field Изтрий полето
Delete image Изтрий изображението
Ungroup artwork Разгрупиране на графиката
Delete link Изтрий връзката
Delete page Изтрий страницата
Device colour is used, but there is no PDF/A output intent Използва се цвят, зависим от устройството, но няма изходно намерение за PDF/A
@@ -2474,6 +2475,7 @@ animation effects Off Изключени
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Използвайте го, когато принтерът пропуска текст, сенки или прозрачност. Отпечатаното вече не е текст за търсене; с печат в сиво изображенията са сиви.
Crop Image Изрязване на изображението
Remove Crop Премахване на изрязването
Ungroup Разгрупиране
Trim the picture with its handles; Enter or Done applies Изрежете картината чрез дръжките; Enter или Готово прилага
Redaction mark appearance Вид на маркировката за заличаване
Outline colour: Цвят на контура:
Can't render this file because it contains an unexpected character in line 8 and column 16.
+2
View File
@@ -2055,6 +2055,7 @@ key Space Mezerník
Rotate image Otočit obrázek
Flip image Převrátit obrázek
Delete image Odstranit obrázek
Ungroup artwork Zrušit seskupení grafiky
Add header & footer Přidat záhlaví a zápatí
Update header & footer Aktualizovat záhlaví a zápatí
Update watermark Aktualizovat vodoznak
@@ -2585,6 +2586,7 @@ animation effects Off Vypnuto
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Použijte, když tiskárna vynechává text, stínování nebo průhlednost. Výtisk už není prohledávatelný text; s tiskem v odstínech šedi jsou obrázky šedé.
Crop Image Oříznout obrázek
Remove Crop Odebrat oříznutí
Ungroup Zrušit seskupení
Trim the picture with its handles; Enter or Done applies Ořízněte obrázek úchyty; Enter nebo Hotovo použije
Redaction mark appearance Vzhled značky začernění
Outline colour: Barva obrysu:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1941,6 +1941,7 @@
Rotate image Bild drehen
Flip image Bild spiegeln
Delete image Bild löschen
Ungroup artwork Gruppierung der Grafik aufheben
Add header & footer Kopf- und Fußzeile hinzufügen
Update header & footer Kopf- und Fußzeile aktualisieren
Update watermark Wasserzeichen aktualisieren
@@ -2512,6 +2513,7 @@ animation effects Off Aus
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Verwenden, wenn ein Drucker Text, Schattierungen oder Transparenz auslässt. Der Ausdruck ist kein durchsuchbarer Text mehr; mit Graustufendruck sind die Bilder grau.
Crop Image Bild zuschneiden
Remove Crop Zuschnitt entfernen
Ungroup Gruppierung aufheben
Trim the picture with its handles; Enter or Done applies Bild mit den Griffen zuschneiden; Eingabe oder Fertig übernimmt
Redaction mark appearance Aussehen der Schwärzungsmarkierung
Outline colour: Konturfarbe:
Can't render this file because it contains an unexpected character in line 6 and column 51.
+2
View File
@@ -1368,6 +1368,7 @@
Rotate image Περιστροφή εικόνας
Flip image Αναστροφή εικόνας
Delete image Διαγραφή εικόνας
Ungroup artwork Κατάργηση ομαδοποίησης γραφικού
Add header & footer Προσθήκη κεφαλίδας και υποσέλιδου
Update header & footer Ενημέρωση κεφαλίδας και υποσέλιδου
Update watermark Ενημέρωση υδατογραφήματος
@@ -2517,6 +2518,7 @@ animation effects Off Ανενεργά
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Χρησιμοποιήστε το όταν ένας εκτυπωτής παραλείπει κείμενο, σκίαση ή διαφάνεια. Η εκτύπωση δεν είναι πλέον κείμενο με δυνατότητα αναζήτησης· με εκτύπωση σε αποχρώσεις του γκρι, οι εικόνες είναι γκρι.
Crop Image Περικοπή εικόνας
Remove Crop Κατάργηση περικοπής
Ungroup Κατάργηση ομαδοποίησης
Trim the picture with its handles; Enter or Done applies Περικόψτε την εικόνα με τις λαβές της· Enter ή Τέλος για εφαρμογή
Redaction mark appearance Εμφάνιση σήμανσης απαλοιφής
Outline colour: Χρώμα περιγράμματος:
Can't render this file because it contains an unexpected character in line 490 and column 9.
+2
View File
@@ -2020,6 +2020,7 @@ action wizard Start Iniciar
Rotate image Girar imagen
Flip image Voltear imagen
Delete image Eliminar imagen
Ungroup artwork Desagrupar ilustración
Add header & footer Añadir encabezado y pie de página
Update header & footer Actualizar encabezado y pie de página
Update watermark Actualizar marca de agua
@@ -2654,6 +2655,7 @@ animation effects Off Desactivados
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Úsalo cuando una impresora omite texto, sombreados o transparencias. La impresión deja de ser texto que se puede buscar; con Imprimir en escala de grises, las imágenes son grises.
Crop Image Recortar imagen
Remove Crop Quitar recorte
Ungroup Desagrupar
Trim the picture with its handles; Enter or Done applies Recorta la imagen con sus tiradores; Intro o Hecho lo aplica
Redaction mark appearance Aspecto de la marca de censura
Outline colour: Color del contorno:
Can't render this file because it contains an unexpected character in line 6 and column 60.
+2
View File
@@ -1936,6 +1936,7 @@
Rotate image Pivoter l'image
Flip image Retourner l'image
Delete image Supprimer l'image
Ungroup artwork Dissocier l’illustration
Add header & footer Ajouter un en-tête et un pied de page
Update header & footer Mettre à jour l'en-tête et le pied de page
Update watermark Mettre à jour le filigrane
@@ -2511,6 +2512,7 @@ animation effects Off Désactivés
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. À utiliser quand une imprimante saute du texte, des dégradés ou de la transparence. L'impression n'est plus du texte consultable ; avec l'impression en niveaux de gris, les images sont grises.
Crop Image Recadrer l'image
Remove Crop Supprimer le recadrage
Ungroup Dissocier
Trim the picture with its handles; Enter or Done applies Recadrez l'image avec ses poignées ; Entrée ou Terminé applique
Redaction mark appearance Apparence de la marque de biffure
Outline colour: Couleur du contour :
Can't render this file because it contains an unexpected character in line 6 and column 39.
+2
View File
@@ -1926,6 +1926,7 @@
Rotate image Kép forgatása
Flip image Kép tükrözése
Delete image Kép törlése
Ungroup artwork Grafika csoportbontása
Add header & footer Fejléc és lábléc hozzáadása
Update header & footer Fejléc és lábléc frissítése
Update watermark Vízjel frissítése
@@ -2510,6 +2511,7 @@ animation effects Off Ki
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Akkor használja, ha a nyomtató kihagy szöveget, árnyékolást vagy átlátszóságot. A nyomat már nem kereshető szöveg; szürkeárnyalatos nyomtatással a képek szürkék.
Crop Image Kép körülvágása
Remove Crop Körülvágás eltávolítása
Ungroup Csoportbontás
Trim the picture with its handles; Enter or Done applies Vágja körül a képet a fogantyúival; Enter vagy Kész alkalmazza
Redaction mark appearance Kitakarási jelölés megjelenése
Outline colour: Körvonal színe:
Can't render this file because it contains an unexpected character in line 7 and column 44.
+2
View File
@@ -2011,6 +2011,7 @@
Rotate image Ruota immagine
Flip image Capovolgi immagine
Delete image Elimina immagine
Ungroup artwork Separa grafica
Add header & footer Aggiungi intestazione e piè di pagina
Update header & footer Aggiorna intestazione e piè di pagina
Update watermark Aggiorna filigrana
@@ -2520,6 +2521,7 @@ animation effects Off Disattivati
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Usalo quando una stampante salta testo, sfumature o trasparenze. La stampa non è più testo ricercabile; con Stampa in scala di grigi le immagini sono grigie.
Crop Image Ritaglia immagine
Remove Crop Rimuovi ritaglio
Ungroup Separa
Trim the picture with its handles; Enter or Done applies Ritaglia l'immagine con le maniglie; Invio o Fatto applica
Redaction mark appearance Aspetto del segno di redazione
Outline colour: Colore contorno:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2018,6 +2018,7 @@
Rotate image 画像を回転
Flip image 画像を反転
Delete image 画像を削除
Ungroup artwork アートワークのグループ解除
Add header & footer ヘッダーとフッターを追加
Update header & footer ヘッダーとフッターを更新
Update watermark 透かしを更新
@@ -2511,6 +2512,7 @@ animation effects Off オフ
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. プリンターが文字、グラデーション、透明部分を印刷しないときに使います。印刷物は検索できる文字ではなくなります。グレースケール印刷と併用すると画像はグレーになります。
Crop Image 画像を切り抜き
Remove Crop 切り抜きを解除
Ungroup グループ解除
Trim the picture with its handles; Enter or Done applies ハンドルで画像を切り抜きます。Enter または完了で適用
Redaction mark appearance 墨消しマークの外観
Outline colour: 枠線の色:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1927,6 +1927,7 @@
Rotate image Кескінді бұру
Flip image Кескінді аудару
Delete image Кескінді жою
Ungroup artwork Графиканы топтан шығару
Add header & footer Үстіңгі және астыңғы колонтитулдарды қосу
Update header & footer Үстіңгі және астыңғы колонтитулдарды жаңарту
Update watermark Су белгісін жаңарту
@@ -2509,6 +2510,7 @@ animation effects Off Өшірулі
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Принтер мәтінді, көлеңкені немесе мөлдірлікті өткізіп жіберсе, қолданыңыз. Басылған құжат енді іздеуге болатын мәтін емес; сұр реңкпен басып шығарғанда суреттер сұр болады.
Crop Image Суретті қию
Remove Crop Қиюды алып тастау
Ungroup Топтан шығару
Trim the picture with its handles; Enter or Done applies Суретті тұтқаларымен қиыңыз; Enter немесе Дайын қолданады
Redaction mark appearance Жасыру белгісінің көрінісі
Outline colour: Контур түсі:
Can't render this file because it contains an unexpected character in line 886 and column 9.
+2
View File
@@ -2062,6 +2062,7 @@ comment menu Edit Redaguoti
Delete document JavaScript Naikinti dokumento „JavaScript“ scenarijų
Delete field Naikinti lauką
Delete image Naikinti paveikslėlį
Ungroup artwork Išgrupuoti grafiką
Delete link Naikinti saitą
Delete page Naikinti puslapį
Delete selected fields Naikinti pasirinktus laukus
@@ -2761,6 +2762,7 @@ key Space Tarpas
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Naudokite, kai spausdintuvas praleidžia tekstą, šešėliavimą ar skaidrumą. Atspaudas nebėra ieškomas tekstas; spausdinant pilkais atspalviais vaizdai yra pilki.
Crop Image Apkarpyti vaizdą
Remove Crop Pašalinti apkarpymą
Ungroup Išgrupuoti
Trim the picture with its handles; Enter or Done applies Apkarpykite paveikslėlį rankenėlėmis; Enter arba Atlikta pritaiko
Redaction mark appearance Užtamsinimo žymės išvaizda
Outline colour: Kontūro spalva:
Can't render this file because it contains an unexpected character in line 406 and column 9.
+2
View File
@@ -1999,6 +1999,7 @@
Rotate image Obróć obraz
Flip image Odbij obraz
Delete image Usuń obraz
Ungroup artwork Rozgrupuj grafikę
Add header & footer Dodaj nagłówek i stopkę
Update header & footer Zaktualizuj nagłówek i stopkę
Update watermark Zaktualizuj znak wodny
@@ -2487,6 +2488,7 @@ animation effects Off Wyłączone
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Użyj, gdy drukarka pomija tekst, cieniowanie lub przezroczystość. Wydruk nie jest już tekstem do przeszukiwania; przy druku w skali szarości obrazy są szare.
Crop Image Przytnij obraz
Remove Crop Usuń przycięcie
Ungroup Rozgrupuj
Trim the picture with its handles; Enter or Done applies Przytnij obraz uchwytami; Enter lub Gotowe stosuje
Redaction mark appearance Wygląd znacznika redakcji
Outline colour: Kolor obrysu:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1905,6 +1905,7 @@
Delete document JavaScript Excluir JavaScript do documento
Delete field Excluir campo
Delete image Excluir imagem
Ungroup artwork Desagrupar arte
Delete link Excluir link
Delete page Excluir página
Device colour is used, but there is no PDF/A output intent A cor do dispositivo foi usada, mas não há intenção de saída PDF/A
@@ -2525,6 +2526,7 @@ animation effects Off Desativados
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Use quando uma impressora omite texto, sombreamento ou transparência. A impressão deixa de ser texto pesquisável; com Imprimir em tons de cinza, as imagens ficam cinza.
Crop Image Cortar imagem
Remove Crop Remover corte
Ungroup Desagrupar
Trim the picture with its handles; Enter or Done applies Corte a imagem com as alças; Enter ou Concluído aplica
Redaction mark appearance Aparência da marca de redação
Outline colour: Cor do contorno:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2006,6 +2006,7 @@
Rotate image Повернуть изображение
Flip image Отразить изображение
Delete image Удалить изображение
Ungroup artwork Разгруппировать графику
Add header & footer Добавить колонтитулы
Update header & footer Обновить колонтитулы
Update watermark Обновить водяной знак
@@ -2424,6 +2425,7 @@ animation effects Off Выкл
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Используйте, когда принтер пропускает текст, заливки или прозрачность. Отпечаток больше не текст с поиском; при печати в оттенках серого изображения серые.
Crop Image Обрезать изображение
Remove Crop Убрать обрезку
Ungroup Разгруппировать
Trim the picture with its handles; Enter or Done applies Обрежьте изображение за маркеры; Enter или «Готово» применяет
Redaction mark appearance Вид метки скрытия
Outline colour: Цвет контура:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -557,6 +557,7 @@
Delete document JavaScript Izbriši JavaScript dokumenta
Delete field Izbriši polje
Delete image Izbriši sliko
Ungroup artwork Razdruži grafiko
Delete link Izbriši povezavo
Delete page Izbriši stran
Delete pages Izbriši strani
@@ -2516,6 +2517,7 @@ animation effects Off Izklopljeni
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Uporabite, ko tiskalnik izpusti besedilo, senčenje ali prosojnost. Natis ni več iskalno besedilo; s tiskanjem v sivinah so slike sive.
Crop Image Obreži sliko
Remove Crop Odstrani obrezovanje
Ungroup Razdruži
Trim the picture with its handles; Enter or Done applies Obrežite sliko z ročicami; Enter ali Končano uporabi
Redaction mark appearance Videz oznake za zakritje
Outline colour: Barva obrobe:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -1925,6 +1925,7 @@ action wizard Start ప్రారంభించు
Rotate image చిత్రాన్ని తిప్పు
Flip image చిత్రాన్ని తిరగేయి
Delete image చిత్రాన్ని తొలగించు
Ungroup artwork కళాకృతి సమూహాన్ని విడదీయండి
Add header & footer హెడర్ & ఫుటర్ జోడించు
Update header & footer హెడర్ & ఫుటర్‌ను అప్‌డేట్ చేయి
Update watermark వాటర్‌మార్క్‌ను అప్‌డేట్ చేయి
@@ -2420,6 +2421,7 @@ animation effects Off ఆఫ్
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. ప్రింటర్ వచనం, షేడింగ్ లేదా పారదర్శకతను వదిలేసినప్పుడు దీన్ని వాడండి. ముద్రణ ఇక శోధించగల వచనం కాదు; బూడిద రంగులో ముద్రించినప్పుడు చిత్రాలు బూడిద రంగులో ఉంటాయి.
Crop Image చిత్రాన్ని కత్తిరించు
Remove Crop కత్తిరింపును తీసివేయి
Ungroup సమూహాన్ని విడదీయండి
Trim the picture with its handles; Enter or Done applies హ్యాండిల్‌లతో చిత్రాన్ని కత్తిరించండి; Enter లేదా పూర్తయింది వర్తింపజేస్తుంది
Redaction mark appearance రెడాక్షన్ గుర్తు రూపం
Outline colour: అంచు రంగు:
Can't render this file because it contains an unexpected character in line 13 and column 16.
+2
View File
@@ -1899,6 +1899,7 @@
Rotate image Resmi döndür
Flip image Resmi çevir
Delete image Resmi sil
Ungroup artwork Çizimin grubunu çöz
Add header & footer Üstbilgi ve altbilgi ekle
Update header & footer Üstbilgi ve altbilgiyi güncelle
Update watermark Filigranı güncelle
@@ -2507,6 +2508,7 @@ animation effects Off Kapalı
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Yazıcı metni, gölgelendirmeyi veya saydamlığı atladığında kullanın. Çıktı artık aranabilir metin değildir; gri tonlamalı yazdırmayla resimler gri olur.
Crop Image Resmi kırp
Remove Crop Kırpmayı kaldır
Ungroup Grubu çöz
Trim the picture with its handles; Enter or Done applies Resmi tutamaçlarıyla kırpın; Enter veya Bitti uygular
Redaction mark appearance Karartma işareti görünümü
Outline colour: Kenar rengi:
Can't render this file because it contains an unexpected character in line 885 and column 9.
+2
View File
@@ -2003,6 +2003,7 @@
Rotate image Повернути зображення
Flip image Віддзеркалити зображення
Delete image Видалити зображення
Ungroup artwork Розгрупувати графіку
Add header & footer Додати колонтитули
Update header & footer Оновити колонтитули
Update watermark Оновити водяний знак
@@ -2533,6 +2534,7 @@ animation effects Off Вимкнено
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Використовуйте, коли принтер пропускає текст, заливки або прозорість. Відбиток більше не текст із пошуком; під час друку у відтінках сірого зображення сірі.
Crop Image Обрізати зображення
Remove Crop Прибрати обрізання
Ungroup Розгрупувати
Trim the picture with its handles; Enter or Done applies Обріжте зображення за маркери; Enter або «Готово» застосовує
Redaction mark appearance Вигляд позначки приховування
Outline colour: Колір контуру:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2050,6 +2050,7 @@ comment menu Edit Sửa
Delete document JavaScript Xóa JavaScript của tài liệu
Delete field Xóa trường
Delete image Xóa hình ảnh
Ungroup artwork Rã nhóm hình vẽ
Delete link Xóa liên kết
Delete page Xóa trang
Delete selected fields Xóa các trường đã chọn
@@ -2757,6 +2758,7 @@ key Space Space
Reason: Lý do:
Redaction mark appearance Hình thức dấu xóa nội dung nhạy cảm
Remove Crop Bỏ xén
Ungroup Rã nhóm
Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là thành phần trang trí
Remove {standard} identification Gỡ thông tin nhận dạng {standard}
Resolution: Độ phân giải:
Can't render this file because it contains an unexpected character in line 493 and column 12.
+2
View File
@@ -2042,6 +2042,7 @@
Delete document JavaScript 删除文档 JavaScript
Delete field 删除字段
Delete image 删除图像
Ungroup artwork 取消组合图稿
Delete link 删除链接
Delete page 删除页面
Device colour is used, but there is no PDF/A output intent 使用了设备颜色,但未设置 PDF/A 输出意图
@@ -2475,6 +2476,7 @@ animation effects Off 关
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. 当打印机漏印文字、渐变或透明效果时使用。打印结果不再是可搜索的文字;配合灰度打印时图像为灰色。
Crop Image 裁剪图像
Remove Crop 移除裁剪
Ungroup 取消组合
Trim the picture with its handles; Enter or Done applies 用控制柄裁剪图片;按 Enter 或“完成”应用
Redaction mark appearance 涂黑标记外观
Outline colour: 轮廓颜色:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2005,6 +2005,7 @@
Rotate image 旋轉圖片
Flip image 翻轉圖片
Delete image 刪除圖片
Ungroup artwork 取消群組圖稿
Add header & footer 新增頁首與頁尾
Update header & footer 更新頁首與頁尾
Update watermark 更新浮水印
@@ -2461,6 +2462,7 @@ animation effects Off 關
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. 當印表機漏印文字、漸層或透明效果時使用。列印結果不再是可搜尋的文字;搭配灰階列印時影像為灰色。
Crop Image 裁切影像
Remove Crop 移除裁切
Ungroup 取消群組
Trim the picture with its handles; Enter or Done applies 用控點裁切圖片;按 Enter 或「完成」套用
Redaction mark appearance 塗黑標記外觀
Outline colour: 輪廓色彩:
Can't render this file because it contains an unexpected character in line 9 and column 16.
@@ -657,6 +657,13 @@ fn image_menu(
ui.close();
}
let raster = images.get(hit).is_some_and(|image| !image.is_form);
// Grouped Form artwork: draw its content in place, so its text and pictures edit one by
// one (#2964, #3005).
if images.get(hit).is_some_and(|image| image.is_form) && ui.button(tl!("Ungroup")).clicked() {
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Ungroup });
ui.close();
}
if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() {
*action = Some(ImageAction::Replace(page, hit));
ui.close();
+63
View File
@@ -7554,3 +7554,66 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() {
assert_eq!(blocks(&h)[0].text, "Page 1");
}
}
/// #3005: a table drawn inside one Form XObject is a single piece of artwork in Edit text &
/// images. Its right-click menu offers Ungroup, which draws the form in place so the table's
/// text can be edited; one Undo puts the form back.
#[test]
fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET";
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
stream("<<", b"q /Fm1 Do Q"),
stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("table.pdf", None, pdf.clone()).expect("opens");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0).len();
assert_eq!(blocks(&h), 0, "the table's text is inside the form");
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
// An empty spot of the form: the artwork, not a paragraph.
let at = r.left_top() + egui::vec2(r.width() * 0.8, r.height() * 0.85);
right_click_at(&mut h, at);
h.run_steps(1);
let selection = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selection, Some((0, 0)), "the right-click selects the form");
assert!(h.query_by_label("Ungroup").is_some(), "the artwork menu offers Ungroup");
// The page's own menu opens over the artwork menu (#2637, fixed by #2155), so the item is
// applied the way it queues its edit rather than clicked through the overlap.
h.state_mut().views[0].pending_edit =
Some(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Ungroup });
h.run_steps(4);
assert!(blocks(&h) >= 1, "the table's rows are page text now");
let images = h.state().session.get(id).unwrap().page_images(0);
assert!(images.is_empty(), "no artwork left: {images:?}");
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(3);
assert_eq!(blocks(&h), 0, "undo puts the form back");
}
+2 -2
View File
@@ -1878,8 +1878,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"]
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 or are drawn translucent are refused, so ungrouping never changes the pixels."
[[feature]]
id = "edit.text-reflow"