diff --git a/crates/automation/README.md b/crates/automation/README.md index e87495cfd7..937dfecb7d 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -217,8 +217,8 @@ the listing's 32 MiB preview budget can leave it out too; `text_edit` still acce ### Preparing grouped text -`text_prepare {doc, page}` makes supported grouped Form text editable on one page, as entering -Edit text in the GUI does. It keeps the group's original glyph operators and shared source +`text_prepare {doc, page}` makes supported grouped Form text editable on one page, as one click +on grouped text in the GUI's Edit text does. It keeps the group's original glyph operators and shared source objects, records preparation and map repairs in the repair log, and is one undoable step. List `text_paragraphs` afterwards; its indices belong to the prepared page. Besides the document summary it returns what this edit did: diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 8faa62b766..4e290a8178 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -12816,6 +12816,34 @@ fn grouped_image_artwork_is_not_prepared() { assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "inner")); } +/// A click on grouped artwork prepares its page only when that artwork has text preparation +/// would open: a text group does, a logo, a refused group and a picture don't, and asking reads +/// the page without changing it. +#[test] +fn grouped_text_at_says_which_artwork_preparation_would_open() { + let forms = [ + ("Body".to_string(), "BT /F1 14 Tf 40 500 Td (body) Tj ET".to_string()), + ("Logo".to_string(), "q 10 0 0 10 0 0 cm /Im Do Q".to_string()), + ("Knock".to_string(), "BT /F1 14 Tf 40 400 Td (kept) Tj ET".to_string()), + ]; + let mut doc = page_with_forms("/Body Do /Logo Do /Knock Do q 10 0 0 10 0 0 cm /Im Do Q", &forms); + let r = pdfcraft_cos::ObjRef::new(9, 0); + let Object::Stream(mut s) = doc.get(r).as_ref().clone() else { panic!("fixture") }; + s.dict.set( + b"Group".to_vec(), + Object::Dict(build(&["<< /S /Transparency /K true >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone()), + ); + doc.set(r, Object::Stream(s)); + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let images = images::page_images(&doc, 0).unwrap(); + let at = |name: &str| images.iter().position(|i| i.name == name).unwrap(); + let says = |name: &str| text::forms::grouped_text_at(&doc, 0, at(name)).unwrap(); + assert!(says("Body")); + assert!(!says("Logo") && !says("Knock") && !says("Im")); + assert!(!text::forms::grouped_text_at(&doc, 0, images.len()).unwrap(), "no such picture"); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); +} + /// Artwork and groups refused for what they are (knockout groups, content that restores more /// graphics states than it saves) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text /// group are 41 reads, within the 64-placement limit, where reading either again on the second diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index f6d7f412a1..d51392a0e2 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -22,6 +22,9 @@ pub struct Preparation { /// grouped and is reported in [`Preparation::refused`]. const MAX_LEVELS: usize = 8; +/// What preparing one page may decode, and add to its content, in all. +const MAX_PREPARED_BYTES: usize = 16 << 20; + /// What one pass of [`prepare`] inlined, as positions in the next pass's parse of the page. struct Inlined { /// How many operators that parse has; with any other count the positions are wrong. @@ -39,9 +42,9 @@ struct Inlined { pub fn prepare(doc: &mut Document, page: usize) -> Result { let mut next = doc.clone(); let mut result = Preparation::default(); - let mut bytes_left = 16usize << 20; + let mut bytes_left = MAX_PREPARED_BYTES; let mut visits = 0usize; - let mut decode = pdfcraft_cos::DecodeBudget::new(16 << 20, 0); + let mut decode = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0); // Forms found to be artwork, or refused for a reason in the form itself (`Refusal::intrinsic`): // a placement of one that an expanded group adds is left as it is without being read or // counted, so logos drawn inside many text groups don't use up the limit. A form refused only @@ -199,6 +202,26 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result Result { + let Some(img) = crate::page_images(doc, page)?.into_iter().nth(image).filter(|i| i.is_form) else { return Ok(false) }; + let p = page_dict(doc, page)?; + let parsed = joined(&strict_streams(doc, &p.dict)?).parse(); + if parsed.skipped > 0 { + return Ok(false); + } + let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let space = ungroup::page_blend_space(doc, &p.dict); + let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0); + Ok(match ungroup::ungroup_ops(doc, &resources, &parsed.ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget) { + Ok(u) => u.shows_text == Some(true), + Err(r) => r.renaming, + }) +} + /// A font is copied into this page: repairing the map must never alter a shared form/font. fn repair_thai_fonts(doc: &mut Document, page: usize) -> Result { let p = page_dict(doc, page)?; diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index eb28a92fe0..22667377c3 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -260,18 +260,13 @@ impl Document { pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) } - /// Whether this page has grouped source text that can be prepared without changing its appearance. - /// Used once when entering the existing-text tool, before queuing an undoable preparation. - pub fn can_prepare_text(&self, page: usize) -> bool { - self.text_preparation(page).is_ok_and(|p| p.expanded > 0) - } - - /// Preview preparation, including skipped groups and character-map repairs, without editing. - pub fn text_preparation(&self, page: usize) -> Result { - let editor = self.editor.as_ref().ok_or("the document is not editable")?; - crate::guard(|| pdfcraft_edit::text::forms::prepare(&mut editor.cos.clone(), page)) - .map_err(|e| format!("grouped text could not be prepared safely ({e})"))? - .map_err(|e| e.to_string()) + /// Edit text: whether grouped artwork `index` on `page` (0-based, as [`Self::page_images`] + /// lists them) shows text that [`Edit::PrepareText`](crate::Edit::PrepareText) would make + /// editable. Reads the page without changing it; `false` for a logo, a group that can't be + /// opened or a page that can't be read. + pub fn grouped_text_at(&self, page: usize, index: usize) -> bool { + let Some(editor) = self.editor.as_ref() else { return false }; + crate::guard(|| pdfcraft_edit::text::forms::grouped_text_at(&editor.cos, page, index)).is_ok_and(|r| r.is_ok_and(|yes| yes)) } /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ace0f99e5..8ba3e0fcdb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -658,8 +658,10 @@ pub struct DocView { /// Edit text: the lines per page (with the document generation they were read at), and the /// line being edited. pub(crate) edit_lines: HashMap)>, - /// Pages checked for grouped source text during this Edit text activation. - pub(crate) prepared_text_pages: std::collections::HashSet, + /// Edit text: a click on grouped artwork with text queued [`Edit::PrepareText`] for its page: + /// the page and the point clicked (user space), whose paragraph opens once the page is + /// prepared, so one click on grouped text starts editing it. + pub(crate) grouped_text_click: Option<(usize, [f64; 2])>, pub line_editor: Option, /// The paragraph editor whose edit was just queued. It opens again with its typing if the /// edit fails, as a refused form field does. @@ -1061,7 +1063,7 @@ impl DocView { organize_range: Default::default(), pending_edit: None, edit_lines: HashMap::new(), - prepared_text_pages: Default::default(), + grouped_text_click: None, line_editor: None, applied_editor: None, pending_text_open: None, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f48e6e6ea1..940eca080b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -76,13 +76,6 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let (lines, images) = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; - if tool != QuickTool::EditText { - view.prepared_text_pages.clear(); - } else if can_modify && view.pending_edit.is_none() && view.line_editor.is_none() && view.prepared_text_pages.insert(i) && doc.can_prepare_text(i) - { - view.pending_edit = Some(pdfcraft_engine::Edit::PrepareText { page: i }); - ui.ctx().request_repaint(); - } let field_tool = match tool { QuickTool::Field(f) => Some(f), _ => None, @@ -226,7 +219,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let adding = (tool == QuickTool::AddText).then_some(added.as_slice()); let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding }; let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view); - let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate }; + let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, doc, info, press, gate }; let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); text_used || image_used }; diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f2833de356..2413881c0e 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -707,6 +707,16 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text None => view.pending_text_open = None, } } + // A click on grouped text prepared the page (`image_input`): the paragraph now at the point + // clicked opens, and none if the point is on the group's artwork. A preparation that failed + // left the page as it was, where no paragraph is at that point. It is taken either way. + if let Some((_, [x, y])) = view.grouped_text_click.take_if(|(p, _)| *p == page) + && gate + && active.is_none() + && let Some((block, _)) = grab_at(&boxes, user_to_screen(xf, info, page, x, y), false) + { + open_editor(cx, lines, &boxes, block, view); + } if !gate { return false; } diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 6f0187f417..61b8fa866f 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -12,6 +12,7 @@ pub(crate) struct ImageCx<'a> { pub xf: &'a PageXform, pub ptr: &'a PagePointer, pub page: usize, + pub doc: &'a pdfcraft_engine::Document, pub info: &'a DocInfo, /// What the press the pages hold is to this page. pub press: PagePress, @@ -268,7 +269,7 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool { /// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]). #[allow(clippy::cognitive_complexity)] fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, press, gate, .. } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; @@ -686,7 +687,7 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { /// pointer was used. #[allow(clippy::cognitive_complexity)] pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx; + let ImageCx { ui, resp, xf, ptr, page, doc, info, press, gate } = *cx; let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); let painter = ui.painter(); let hover_pos = ptr.pos; @@ -977,18 +978,32 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage return true; } } - // A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this - // page leaves it unselected (where the page's objects can be selected together, the area - // selection the press starts has done so already). + // A click selects the picture its press was on; its cursor is the hit's. One on grouped + // artwork with text prepares the page instead (one undoable step), and the paragraph clicked + // opens once it has (`edit_text_ui::page_input`): a drag still moves the group selected. Not + // while an edit is queued or a paragraph is open, whose typing that edit would overtake. One + // nobody holds on this page leaves the picture unselected (where the page's objects can be + // selected together, the area selection the press starts has done so already). let owner = match press { PagePress::Mine(Owner::Picture { index, .. }) => Some(index), _ => None, }; if gate && resp.clicked() { - match owner { - Some(index) => view.image_selection = Some(ImageSelection::at(page, index)), - None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None, - None => {} + // Where grouped artwork with text was clicked; the page is read for it once, on the click. + let grouped_text = owner + .filter(|i| images.get(*i).is_some_and(|im| im.is_form) && view.pending_edit.is_none() && view.line_editor.is_none()) + .zip(ptr.released.or(ptr.origin)) + .filter(|(i, _)| doc.grouped_text_at(page, *i)); + match (owner, grouped_text) { + (_, Some((_, at))) => { + let (x, y) = screen_to_user(xf, info, page, at); + view.image_selection = None; + view.pending_edit = Some(Edit::PrepareText { page }); + view.grouped_text_click = Some((page, [x, y])); + } + (Some(index), None) => view.image_selection = Some(ImageSelection::at(page, index)), + (None, _) if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None, + (None, _) => {} } } owner.is_some() diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 24f46ac6a1..4f3e15bd5d 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -10464,29 +10464,18 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() { #[test] fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() { let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET"; - let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(); let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(), - stream("<<", b"q /Fm1 Do Q"), - stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table), + pdf_stream("<<", b"q /Fm1 Do Q"), + pdf_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 pdf = pdf_of_objects(&objs); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); @@ -10521,6 +10510,89 @@ fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() { assert_eq!(blocks(&h), 0, "undo puts the form back"); } +/// A stream object `dict` (its opening `<<` and entries, without `/Length` or `>>`) holding `data`. +fn pdf_stream(dict: &str, data: &[u8]) -> Vec { + [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat() +} + +/// A PDF of `objs`, numbered from 1, with the first as its catalog. +fn pdf_of_objects(objs: &[Vec]) -> 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()); + pdf +} + +/// Grouped text is not prepared while the pages are only looked at: entering Edit text and +/// scrolling change nothing. One click on the group's text prepares that page, as one undoable +/// step, and opens the paragraph clicked for typing. A click on a grouped logo selects it, as a +/// click on any picture does, and changes nothing; a drag from the text group prepares nothing. +#[test] +fn one_click_on_grouped_text_opens_it_for_editing() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Body 5 0 R /Logo 7 0 R >> >> >>".to_vec(), + pdf_stream("<<", b"q /Body Do Q q 1 0 0 1 300 20 cm /Logo Do Q"), + pdf_stream( + "<< /Type /XObject /Subtype /Form /BBox [0 0 280 300] /Resources << /Font << /F1 6 0 R >> >>", + b"BT /F1 24 Tf 1 0 0 1 40 200 Tm (Grouped words) Tj ET", + ), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + pdf_stream("<< /Type /XObject /Subtype /Form /BBox [0 0 60 60] /Resources << >>", b"0 0 1 rg 0 0 60 60 re f"), + ]; + let pdf = pdf_of_objects(&objs); + 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("grouped.pdf", None, pdf.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let doc = |h: &Harness<'static, PdfCraftApp>| { + let d = h.state().session.get(id).unwrap(); + (d.text_blocks(0).len(), d.page_images(0).len(), d.can_undo().map(str::to_string)) + }; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(6); + assert_eq!(doc(&h), (0, 2, None), "nothing is prepared while the page is only shown"); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + // User space (x, y) on the page, which is 400 by 300 points and upright. + let at = |x: f32, y: f32| r.left_top() + egui::vec2(r.width() * x / 400.0, r.height() * (300.0 - y) / 300.0); + + click_at(&mut h, at(330.0, 50.0)); + let logo = h.state().session.get(id).unwrap().page_images(0).iter().position(|i| i.name == "Logo"); + assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)), logo.map(|i| (0, i)), "the logo is selected"); + assert_eq!(doc(&h), (0, 2, None), "a click on artwork without text changes nothing"); + assert!(h.state().views[0].line_editor.is_none()); + + drag(&mut h, at(60.0, 208.0), at(120.0, 150.0)); + assert_eq!(doc(&h).0, 0, "a drag on the text group prepares nothing"); + + click_at(&mut h, at(60.0, 208.0)); + h.run_steps(2); + assert_eq!(doc(&h), (1, 1, Some("Prepare grouped text".into())), "the page's grouped text is prepared, in one step"); + let editing = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone()); + assert_eq!(editing.as_deref(), Some("Grouped words"), "the paragraph clicked opens for typing"); + + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + let (blocks, images, _) = doc(&h); + assert_eq!((blocks, images), (0, 2), "undo puts the group back"); +} + #[test] fn outward_image_grip_drags_grow_on_rotated_pages() { for rotation in [0, 90, 180, 270] { diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 6127f9a234..d8e8ec44d2 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -1,20 +1,28 @@ # Editing existing text and source subsets -Entering **Edit text and images** prepares supported grouped Form XObjects on the visible -page in an undoable **Prepare grouped text** step. The group's original operators, clip, -placement and resources become page content; shared forms on other pages stay unchanged. -Headless callers use `text_prepare`, then list `text_paragraphs` and call `text_edit`. +In **Edit text and images**, text drawn inside a Form XObject (grouped artwork) is edited +with one click: clicking its text prepares that page in an undoable **Prepare grouped text** +step and opens the paragraph clicked for typing. Nothing is prepared while pages are only +shown or scrolled. A drag still moves the selected group as a whole; a click on grouped +artwork that preparation wouldn't open (a logo, a refused group) selects it as before, and +changes nothing. To select a text group for moving, right-click it. While a paragraph is open +or another edit is waiting, the click selects the group instead, as before; the next click +prepares it. The group's original operators, clip, placement and resources become page +content; shared forms on other pages stay unchanged. Ungroup (the artwork's right-click menu) +draws one placement in place with the same code. Headless callers use `text_prepare`, then +list `text_paragraphs` and call `text_edit`. This edits existing glyph operators; it adds neither a cover rectangle nor an Add Text box. -Preparation refuses knockout/blended groups, groups that blend in a colour space the page -group does not also name (including DeviceRGB on a page that names none), tagged forms, -recursive forms, unfinished paths and unreadable content. Forms that draw only images or -vector artwork are left grouped. Decoding is capped at 16 MiB, 64 placements and eight -expansion levels per page; text grouped deeper is reported. Unsupported groups remain intact: -`text_prepare` lists each with its reason, and the app records them in the repair log when it -prepares other groups on the page. When no group on a page can be prepared, the app says -nothing; the text stays grouped. A narrowly fingerprinted legacy THSarabunPSK exporter map -can also be repaired in copied page fonts, only when every glyph the subset kept advances as -its assumed Thai character does; the repair log records both preparation and map recovery. +Preparation refuses knockout/blended groups, groups that blend in a colour space other than +the page group's (DeviceRGB on a page that names none), forms whose default +colour spaces differ from the page's, tagged forms, forms drawn inside text objects or +unfinished paths, groups whose nested content can't be checked and unreadable content. Forms +that draw only images or vector artwork are left grouped. Decoding is capped at 16 MiB, 64 +placements and eight expansion levels per page; text grouped deeper is reported. Unsupported +groups remain intact: `text_prepare` lists each with its reason, and the repair log records +them when the page is prepared. A narrowly fingerprinted legacy THSarabunPSK exporter map can +also be repaired in copied page fonts, only when every glyph the subset kept advances as its +assumed Thai character does; `text_prepare` repairs it also for text that was never grouped, +and the repair log records both preparation and map recovery. Click a text region in **Edit a PDF → Edit text and images**. One editor contains its source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dcc07a1ec7..c108221d44 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1895,8 +1895,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads"] -notes = "Supported grouped Forms are prepared on entering Edit text, or via text_prepare, so their existing source glyphs can be edited directly. Unsafe groups (including RGB-blended groups on a page that names no blending space) remain intact and are reported by text_prepare (the app records them in the repair log only when it prepares other groups on the page, and says nothing when it can prepare none), as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded. Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms whose /DefaultGray, /DefaultRGB or /DefaultCMYK the page doesn't share (ISO 32000-2 §8.6.5.6), each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow"