diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 436809c045..a7b652a681 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13881,3 +13881,84 @@ fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_aft assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added"); } } + +/// The Unicode row breaks are invisible moves, not glyphs. A draft carrying them (text pasted +/// from a browser or Word) breaks the row on both rewrite paths, rather than being refused +/// because no fallback face has an outline for U+2028 (#6272). The paragraph is one wide row, so +/// a break that was dropped would leave the words on one row instead of two. A one-line edit has +/// no next row, so there each break is written as a space. +#[test] +fn unicode_row_breaks_break_the_row_instead_of_needing_a_glyph() { + let red = text::RangeStyle { + range: 0..3, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }; + for separator in ['\u{2028}', '\u{2029}', '\u{0085}', '\u{000B}', '\u{000C}', '\u{001C}', '\u{001D}', '\u{001E}'] { + let draft = format!("one{separator}two"); + for style in [ + text::BlockStyle::default(), + text::BlockStyle { ranges: vec![red.clone()], ..Default::default() }, + text::BlockStyle { ranges: vec![text::RangeStyle { range: 0..draft.len(), ..red.clone() }], ..Default::default() }, + text::BlockStyle { bold: Some(true), ..Default::default() }, + ] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator))); + let doc = reopen(&doc); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one", "two"], "U+{:04X}", u32::from(separator)); + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::replace_line(&mut doc, 0, 0, &draft).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator))); + let doc = reopen(&doc); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one two"], "a one-line edit keeps U+{:04X} on its line", u32::from(separator)); + } +} + +/// Thai text on the styled path has its own coverage checks and row splits. A Unicode row break +/// in a Thai draft breaks the row there too, whether the paragraph keeps its legacy subset, takes +/// the bundled Thai face by choice or is completed by the matching one, instead of being refused +/// for a glyph no face has (#6272). +#[test] +fn thai_paragraphs_break_the_row_on_unicode_row_breaks() { + let rows = |doc: &Document| -> Vec { text::text_lines(&reopen(doc), 0).unwrap().into_iter().map(|l| l.text).collect() }; + let thai_face = pdfcraft_fonts::document_thai_font().is_some(); + if !thai_face { + eprintln!("thai_paragraphs_break_the_row_on_unicode_row_breaks: built without craft-fonts, so only the legacy subset is checked"); + } + for separator in ['\u{2028}', '\u{001C}'] { + let draft = format!("กข{separator}ค"); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("legacy U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "legacy U+{:04X}", u32::from(separator)); + if !thai_face { + continue; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + let chosen = BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &chosen).unwrap_or_else(|e| panic!("chosen U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "chosen U+{:04X}", u32::from(separator)); + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET"); + let cmap = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar", + ))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + }) + .unwrap(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &BlockStyle::default()) + .unwrap_or_else(|e| panic!("matching U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "matching U+{:04X}", u32::from(separator)); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2e73a5a075..a3ee880a64 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -36,13 +36,13 @@ mod interp; mod lines; mod rewrite; -use blocks::paragraph_separator; #[cfg(test)] use blocks::wrap; pub use blocks::{ BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, replace_block, text_blocks, }; +use blocks::{paragraph_separator, row_break}; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; use editor::{builtin_thai_choice, layout_draft}; #[cfg(test)] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index dcebb050dd..f2da128071 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -344,6 +344,15 @@ pub(crate) fn paragraph_separator(c: char) -> bool { c.is_whitespace() && c != '\u{3000}' } +/// Characters that end a row rather than draw a glyph: the Unicode mandatory breaks (UAX #14 +/// class BK: vertical tab, form feed, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR), the +/// next line (U+0085), the information separators (bidi class B) and CR/LF. Text pasted from a +/// browser or Word carries these, so a rewrite moves to the next row instead of asking a font +/// for an outline no face has (#6272). +pub(crate) fn row_break(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{001C}'..='\u{001E}' | '\u{2028}' | '\u{2029}') +} + /// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); /// those pieces join without a space. Every white-space character is written as one space, so diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 4816271e30..c19f4ce98f 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,4 +1,4 @@ -use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; +use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, @@ -42,7 +42,7 @@ fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet< /// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result { - let text = text.replace(['\n', '\r'], " "); + let text = text.replace(row_break, " "); let lines = text_lines(doc, page)?; let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?; target.ensure_editable()?; @@ -240,7 +240,7 @@ fn choose_rewrite_path( } let visual_indic = members.iter().any(|line| line.visual_order); let has_special_text = text.is_some_and(|draft| { - draft.contains(['\n', '\r']) + draft.contains(row_break) || pdfcraft_fonts::source_shaping::required(draft) || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() }); @@ -259,14 +259,14 @@ fn choose_rewrite_path( } /// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed -/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are -/// breaks too; other white space is written as one space. +/// U+0020 space: a run of them is not folded into one (#4199, #3797). Every [`row_break`] is a break +/// too; other white space is written as one space. fn paragraph_text(text: &str) -> String { text.replace("\r\n", "\n") .chars() .map(|c| match c { - '\r' => '\n', - c if c != '\n' && paragraph_separator(c) => ' ', + c if row_break(c) => '\n', + c if paragraph_separator(c) => ' ', c => c, }) .collect() diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 98de481444..ab9a055429 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -3,9 +3,9 @@ use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, - needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, - source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, - union, write_type3_glyphs, + needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font, + same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, + type3_glyph, union, write_type3_glyphs, }; #[path = "text_regions/grouping.rs"] diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e6d786e8b7..d1e0738a49 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -3,7 +3,7 @@ use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, - needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, + needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, row_break, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs, }; @@ -181,8 +181,8 @@ impl CharacterPreparation<'_> { "the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists" )) })?; - if !pdfcraft_fonts::embedding::covers(face.bytes, span) { - let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4]))); + if !covers_rows(face.bytes, span) { + let missing = span.chars().find(|&c| !row_break(c) && !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4]))); return Err(EditError::Invalid(match missing { Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)), None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family), @@ -251,12 +251,13 @@ impl CharacterPreparation<'_> { }; let start = range.start; for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + // A row break moves to the next row instead of asking a font for a glyph. The CR + // of a CRLF stays a `\r` the rows skip, so the pair is one break; every other break + // is written as `\n`. Either way it keeps its place among the draft's characters, + // which rows are counted in. + if row_break(ch) { + let crlf = ch == '\r' + && start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); continue; } @@ -923,7 +924,7 @@ fn legacy_thai_font( if !has_thai_source_face(cache, &family) { return Ok(None); } - let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty()); + let rows = || span.split(row_break).filter(|row| !row.is_empty()); for row in rows() { if let Err(error) = shape_existing_thai(cache, &source.font, row) { // Not in the face's existing codes. A complete program of the face that shapes every @@ -1097,7 +1098,7 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: } if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) || !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face)) - || !pdfcraft_fonts::embedding::covers(face.bytes, span) + || !covers_rows(face.bytes, span) { return false; } @@ -1112,16 +1113,22 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: && pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| { pdfcraft_fonts::embedding::validate(&bytes).is_ok() && pdfcraft_fonts::source_shaping::has_thai_layout(&bytes) - && pdfcraft_fonts::embedding::covers(&bytes, span) - && span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) + && covers_rows(&bytes, span) + && span.split(row_break).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) }) }) } +/// Whether a complete program draws every row of `span`. [`row_break`] characters move to the next +/// row and are never drawn, so they need no glyph (#6272); `embedding::covers` exempts only CR and LF. +fn covers_rows(bytes: &[u8], span: &str) -> bool { + span.split(row_break).all(|row| pdfcraft_fonts::embedding::covers(bytes, row)) +} + /// Use the same Thai script selection and mark/cluster positioning as Add Text, including /// rows beginning with Latin or digits (guessing from their first character would select Latin). fn shape_thai_row(text: &str) -> Result { - if text.len() > 262_144 || text.contains(['\n', '\r']) { + if text.len() > 262_144 || text.contains(row_break) { return Err(EditError::Invalid("shape one bounded text row at a time".into())); } let clusters = diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 0124c24851..3a38487ee0 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -122,6 +122,13 @@ sign before an amount: the line-start and line-end rules of JIS X 4051, as in Un breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only at spaces. +Text pasted from a browser or a word processor often carries an invisible break instead of a +newline. Every Unicode row break starts a new row: U+2028 LINE SEPARATOR, U+2029 PARAGRAPH +SEPARATOR, U+0085 NEXT LINE, the vertical tab, the form feed and the information separators +(U+001C to U+001E), alongside the carriage return and the newline. These move the text down a +row rather than being looked up in a font, so a paste no longer fails because no face has an +outline for them. Other white space is still written as one space. + Select text within the editor before changing size, colour, font, underline or spacing to format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the same properties; invalid or overlapping ranges fail atomically. Untouched source formatting diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 70a0cc34df..6604bafd4e 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", "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." +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", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph"] +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. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). 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"