diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 97e697246e..2c7ab8cf28 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9148,3 +9148,68 @@ fn ungrouping_a_table_drawn_in_a_form_makes_its_text_editable() { ok(&mut a, "edit_undo", json!({ "doc": doc })); assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "undo brings the form back"); } + +/// One 300 × 300 page that draws form `Fm1` with `page_do`; the form (with `matrix`) fills with +/// pattern `/P1` (`pattern`) in `content`, and a red outline on the page marks where it lands. +fn pattern_in_a_form_pdf(page_do: &str, matrix: &str, content: &str, pattern: &str) -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_string(), + stream_object("<<", &format!("{page_do} 1 0 0 RG 1 w 60 60 180 180 re S")), + stream_object(&format!("<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] {matrix} /Resources << /Pattern << /P1 6 0 R >> >>"), content), + pattern.to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #3067 review: a pattern's matrix maps pattern space to the default space of the stream that +/// uses it (ISO 32000-2 §8.7.2), so a pattern filled inside a form moved by its `/Matrix` or by a +/// `cm` before the `Do` must stay where it was when the form is drawn inline. Each case draws +/// differently in Acrobat when the placement is lost (tiles out of phase or half size, the +/// shading outside its box); ungrouped, every one paints the same pixels. +#[test] +fn ungrouping_keeps_patterns_where_the_forms_placement_put_them() { + let tiling = stream_object( + "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >>", + "0 0 0 rg 0 0 10 10 re f 10 10 10 10 re f", + ); + let shading = "<< /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [false false] >> >>"; + let cases = [ + ("no placement", "q /Fm1 Do Q", "", "/Pattern cs /P1 scn 60 60 180 180 re f", tiling.as_str()), + ("form Matrix, half a tile", "q /Fm1 Do Q", "/Matrix [1 0 0 1 10 0]", "/Pattern cs /P1 scn 50 60 180 180 re f", tiling.as_str()), + ("cm doubling the form", "q 2 0 0 2 0 0 cm /Fm1 Do Q", "", "/Pattern cs /P1 scn 30 30 90 90 re f", tiling.as_str()), + ("shading moved by the form Matrix", "q /Fm1 Do Q", "/Matrix [1 0 0 1 100 100]", "/Pattern cs /P1 scn 0 0 100 100 re f", shading), + ]; + for (what, page_do, matrix, content, pattern) in cases { + let dir = workdir("ungroup_patterns"); + std::fs::write(dir.join("p.pdf"), pattern_in_a_form_pdf(page_do, matrix, content, pattern)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "p.pdf" }))["doc"].as_u64().unwrap(); + let (w, h, before) = a.render_pixels(doc, 1, 1.0).unwrap(); + ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + let (w2, h2, after) = a.render_pixels(saved, 1, 1.0).unwrap(); + assert_eq!((w, h), (w2, h2), "{what}"); + let differing = before.as_ref().as_chunks::<4>().0.iter().zip(after.as_ref().as_chunks::<4>().0).filter(|(p, q)| p != q).count(); + assert_eq!(differing, 0, "{what}: ungrouping draws the same pixels, saved and reopened"); + } +} + +/// An indirect stream object: `dict` (without its closing `>>`) and `data`. +fn stream_object(dict: &str, data: &str) -> String { + format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 286d2d7afb..da67a915ab 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -504,7 +504,14 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag } let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op); - let (inline, merged) = crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent)?; + let (inline, mut merged, patterns) = crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, Matrix(img.matrix))?; + if !patterns.is_empty() { + let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + for (name, copy) in patterns { + table.set(name, Object::Ref(doc.add(copy))); + } + merged.set(b"Pattern".to_vec(), Object::Dict(table)); + } resources = Some(merged); inline } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 77c15610bc..446700e05a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7598,10 +7598,16 @@ fn ungroup_renames_every_kind_of_resource() { 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:?}" - ); + // The form is placed by its Matrix [2 0 0 2 0 0] after the page's `1 0 0 1 40 80 cm`, so + // its pattern is drawn from a copy that carries that placement (ISO 32000-2 §8.7.2). + let want = match tag(&format!("form {cat}")) { + Object::Dict(mut d) if cat == "Pattern" => { + d.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 2, 40, 80].map(Object::Int).to_vec())); + Object::Dict(d) + } + other => other, + }; + assert!(hits.iter().any(|(_, n)| lookup(cat, n) == Some(want.clone())), "{cat}: the inline content names the form's object: {hits:?}"); } assert!( used.iter().filter(|(c, _)| *c == "ColorSpace").all(|(_, n)| lookup("ColorSpace", n) == Some(tag("form ColorSpace"))), @@ -7610,6 +7616,57 @@ fn ungroup_renames_every_kind_of_resource() { assert_eq!(lookup("ExtGState", b"G"), Some(tag("page ExtGState")), "the page's own G is kept"); } +/// #3067 review: a pattern's matrix maps pattern space to the default space of the stream that +/// uses it (ISO 32000-2 §8.7.2), so drawn from the page a form's pattern would lose the form's +/// placement. Ungroup draws it from a copy whose `/Matrix` carries that placement. A pattern +/// selected twice is copied once, the form keeps its own for the other placement, and a pattern +/// whose matrix can't be read is refused rather than drawn somewhere else. +#[test] +fn ungroup_carries_the_placement_into_the_patterns_it_selects() { + let pattern = |matrix: Object| { + let mut d = Dict::new(); + d.set(b"PatternType".to_vec(), Object::Int(2)); + d.set(b"Shading".to_vec(), Object::Dict(Dict::new())); + d.set(b"Matrix".to_vec(), matrix); + Object::Dict(d) + }; + let form_with = |p: Object| { + let mut patterns = Dict::new(); + patterns.set(b"P".to_vec(), p); + let mut res = Dict::new(); + res.set(b"Pattern".to_vec(), Object::Dict(patterns)); + page_with_form(&[("Resources", Object::Dict(res))], b"/Pattern cs /P scn 0 0 10 10 re f /P scn 0 0 5 5 re f", "", None) + }; + let own = pattern(Object::Array([1, 0, 0, 1, 3, 4].map(Object::Int).to_vec())); + let (mut doc, artwork) = form_with(own.clone()); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let table = doc.resolve(res.get(b"Pattern").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one content stream") }; + let selected: Vec> = + pdfcraft_content::parse(&s.decoded().unwrap()).ops.iter().filter(|o| o.is("scn")).filter_map(|o| o.name(0).map(<[u8]>::to_vec)).collect(); + assert_eq!(selected.len(), 2, "{selected:?}"); + assert!(selected[0] == selected[1] && selected[0] != b"P", "both selections use one copy: {selected:?}"); + let copy = doc.resolve(table.get(&selected[0]).unwrap()).as_dict().cloned().unwrap(); + // [1 0 0 1 3 4], then the form's Matrix [2 0 0 2 0 0], then the page's `1 0 0 1 40 80 cm`. + let matrix = copy.get(b"Matrix").and_then(Object::as_array).and_then(|a| pdfcraft_content::Matrix::from_operands(a)); + assert_eq!(matrix.map(|m| m.0), Some([2.0, 0.0, 0.0, 2.0, 46.0, 88.0])); + assert_eq!(table.iter().filter(|(k, _)| k.starts_with(b"PCUgPat")).count(), 1, "one copy"); + let Object::Stream(form) = doc.get(artwork).as_ref().clone() else { panic!("the form") }; + let form_res = doc.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let form_patterns = doc.resolve(form_res.get(b"Pattern").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(form_patterns.get(b"P"), Some(&own), "the form keeps its own pattern for its other placement"); + + let (mut doc, _) = + form_with(pattern(Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0), Object::Int(1), Object::name("Bad"), Object::Int(4)]))); + match images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup) { + Err(EditError::Invalid(m)) => assert!(m.contains("position can't be kept"), "{m}"), + other => panic!("expected a refusal, got {other:?}"), + } +} + /// Ungroup draws the form where the `Do` drew it and only inside its bounding box: the inline /// content starts `q cm re W n`, a form without a Matrix gets no `cm`, and the /// page's other content is unchanged around it. diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index 36e78c3cd2..2c989d2561 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -1,8 +1,8 @@ -//! Edit a PDF ▸ Ungroup: draw a Form XObject's content in place of its `Do` (#2964, #3005). +//! Edit a PDF â–¸ Ungroup: draw a Form XObject's content in place of its `Do` (#2964, #3005). //! //! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own //! content streams. Ungrouping copies the form's content into the page at the same place, -//! wrapped as `q cm re W n … Q`, with the form's resources merged into a copy of +//! wrapped as `q cm re W n … Q`, with the form's resources merged into a copy of //! the page's under names that don't clash. Its text then edits like any other. The form object //! itself is left alone, so another page or placement that draws it is unchanged. //! @@ -11,7 +11,12 @@ //! 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) and content whose marked content or text objects don't balance (inlined, they -//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`. +//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`. +//! +//! A pattern's matrix maps pattern space to the default space of the content stream that uses it +//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is +//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose +//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused. use std::collections::HashMap; @@ -24,16 +29,25 @@ use crate::EditError; /// 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). +/// 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. +/// The most distinct patterns one ungrouped form may select; each becomes a new object. +const MAX_MOVED_PATTERNS: usize = 256; + +/// A name map for one resource category: form name → name in the page's resources. type Renames = Vec<(Vec, Vec)>; -/// 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, Dict), EditError> { +/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with +/// the form's merged in, and the pattern copies to add to the document, each under the name in +/// the merged `/Pattern` dictionary that the ops already use. +pub(crate) type Ungrouped = (Vec, Dict, Vec<(Vec, Object)>); + +/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state +/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a +/// whole. `placement` maps the form's space to the page's default space at the `Do` (its +/// `/Matrix`, then the CTM there). +pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translucent: bool, placement: Matrix) -> Result { let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}")); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; @@ -189,6 +203,56 @@ pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translu return Err(refuse("its content leaves marked content or a text object open")); } + // Patterns keep their place relative to the shapes they fill: each one the form selects is + // drawn from a copy that also carries the placement. Nested forms and patterns inside + // patterns keep their own parents, so only this stream's selections change. + let mut patterns: Vec<(Vec, Object)> = Vec::new(); + if placement != Matrix::IDENTITY { + let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut moved: Renames = Vec::new(); + let mut n = 0u32; + for op in &mut ops { + if !(op.is("scn") || op.is("SCN")) { + continue; + } + let Some(i) = op.operands.len().checked_sub(1) else { continue }; + let Some(Object::Name(from)) = op.operands.get(i) else { continue }; + // A name the resources don't have selects nothing, inline or not. + let Some(entry) = table.get(from).cloned() else { continue }; + let to = match moved.iter().find(|(f, _)| f == from) { + Some((_, to)) => to.clone(), + None => { + if moved.len() >= MAX_MOVED_PATTERNS { + return Err(refuse("it paints with too many patterns")); + } + let copy = + moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?; + let fresh = loop { + n = n.saturating_add(1); + let candidate = format!("PCUgPat{n}").into_bytes(); + if table.get(&candidate).is_none() { + break candidate; + } + if n == u32::MAX { + return Err(refuse("too many resources")); + } + }; + // Reserved here; the caller replaces it with the copy's reference. + table.set(fresh.clone(), Object::Null); + patterns.push((fresh.clone(), copy)); + moved.push((from.clone(), fresh.clone())); + fresh + } + }; + if let Some(operand) = op.operands.get_mut(i) { + *operand = Object::Name(to); + } + } + if !patterns.is_empty() { + merged.set(b"Pattern".to_vec(), Object::Dict(table)); + } + } + // A form in a layer (/OC) stays in it: its content is marked with the same membership. let layer = match s.dict.get(b"OC") { None => None, @@ -231,7 +295,37 @@ pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translu if layer.is_some() { out.push(Op::new("EMC", Vec::new())); } - Ok((out, merged)) + Ok((out, merged, patterns)) +} + +/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead +/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its +/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy. +fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option { + let resolved = doc.resolve(entry); + let (mut dict, stream) = match &*resolved { + Object::Stream(s) => (s.dict.clone(), Some(s)), + Object::Dict(d) => (d.clone(), None), + _ => return None, + }; + let own = match dict.get(b"Matrix") { + None => Matrix::IDENTITY, + Some(m) => { + let m = doc.resolve(m); + let nums: Vec = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect(); + Matrix::from_operands(&nums)? + } + }; + let moved = own.then(&placement); + if !moved.0.iter().all(|v| v.is_finite()) { + return None; + } + dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect())); + match stream { + Some(s) if s.raw.len() > MAX_FORM_BYTES => None, + Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })), + None => Some(Object::Dict(dict)), + } } /// How deep [`blends_inside`] follows nested forms; deeper ones count as blending. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e554a573ee..b46c454529 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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.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/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); 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." +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.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/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/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/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); 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, and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow"