From a2ad6b77f8fd5e0dcb1d53884df33a876768bbf7 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:22:25 +0800 Subject: [PATCH] M7: replacing a line keeps the source font for the words it can show (#2378) (#2955) --- crates/edit/src/tests.rs | 169 +++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 8 +- crates/edit/src/text/fallback.rs | 43 +++++++- crates/edit/src/text/interp.rs | 6 ++ crates/edit/src/text/lines.rs | 10 +- crates/edit/src/text/rewrite.rs | 107 ++++++++++++++++++- parity/acrobat-features.toml | 4 +- 7 files changed, 334 insertions(+), 13 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0cb7366716..2640eadd5a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3213,6 +3213,175 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>().join(" "), text); } +/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words +/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph), +/// one font per word, and the line reads back whole and in place: one line, in its own font, so +/// editing it again replaces all of it (review on #2955), also when it starts with a substitute +/// word. +#[test] +fn only_the_words_a_subset_cannot_show_are_substituted() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap(); + assert_eq!(r.substituted.as_deref(), Some("Helvetica")); + let mut doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [("ab ba cab ba", "F2")]); + // Each word where its font puts it, the substituted `cab` included: no gap or overlap opens + // where the font changes (review on #2955). + let starts: Vec = lines[0].ink.iter().map(|b| b[0]).collect(); + assert_eq!(starts.len(), 4, "{:?}", lines[0].ink); + for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) { + assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them"); + } + assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink); + for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})"); + } +} + +/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine` +/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader +/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content +/// doesn't grow from one edit to the next. +#[test] +fn replacing_a_mixed_font_line_again_replaces_its_sequence() { + let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET"); + let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count(); + let mut sizes = Vec::new(); + for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let mixed = text.contains('c'); + assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}"); + assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}"); + assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect(); + assert_eq!(read, [text, "ba"], "after {text:?}"); + assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place"); + sizes.push(content.split_whitespace().count()); + } + assert_eq!(sizes[2], sizes[3], "the same text again, the same content"); + // A sequence holding marked content of its own goes whole, and what follows it keeps its + // font and size unless the replacement sets the same again. + for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] { + let mut doc = text_page(&format!( + "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET" + )); + text::replace_line(&mut doc, 0, 0, "ab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect(); + assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}"); + } +} + +/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the +/// subset can show) was split into an empty source-font run and a substitute run, so it was +/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line +/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is +/// substituted as a whole instead, as a line with no word in the source font always was. +#[test] +fn a_leading_space_does_not_make_a_source_font_run() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + // With a word in the source font after it, the line is still written in both. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " ab cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab cab", "F2")]); + // The spaces the subset has no glyph for are moves, each one kept: the leading one before + // the first word, two between words as two. + let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::>().join(" "); + assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc)); + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap(); + let doc = reopen(&doc); + assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc)); + // Before a substitute word, the leading spaces are the substitute's to show, all of them. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("( cab) Tj"), "{content}"); +} + +/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier +/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A +/// later replacement of one of those rows must not take the sequence's other rows with it. +#[test] +fn replacing_one_row_of_a_sequence_keeps_the_other_rows() { + let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET"; + let rows = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(rows.iter().map(|l| l.text.trim()).collect::>(), ["ab cab", "ba"]); + for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] { + let mut doc = text_page(content); + text::replace_line(&mut doc, 0, row, text).unwrap(); + let doc = reopen(&doc); + let after: Vec = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect(); + assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}"); + } +} + +/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run +/// has the substitute's font in effect at its first operator. Replaced by text the row's own font +/// shows, it must set that font again, or the new text is drawn in the substitute. +#[test] +fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() { + // A real two-font line, starting with the substitute run, then a second row put inside its + // sequence, as a paragraph rewrite that keeps the delimiters can leave it. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}"); + let mut doc = doc; + let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1); + let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes())))); + set_page_contents(&mut doc, stream, None); + let rows = text::text_lines(&doc, 0).unwrap(); + assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("cab ab", "F2"), ("ba", "F2")]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab", "F2"), ("ba", "F2")]); +} + +/// Review on #2955: a sequence holding one row can also hold that row's positioning and text +/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the +/// delimiters, fonts and text, not the `Tm` that places it. +#[test] +fn replacing_the_only_row_of_a_sequence_keeps_its_position() { + let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET"; + let mut doc = text_page(content); + let before = text::text_lines(&doc, 0).unwrap(); + assert_eq!(before.iter().map(|l| l.text.trim()).collect::>(), ["ab cab"]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}"); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "{content}"); + let (was, now) = (before[0].rect, after[0].rect); + assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}"); +} + #[test] fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2dbf302685..c08edb1e86 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -6,8 +6,9 @@ //! dropped, and every positioning operator stays, so the lines after it keep their places //! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change). //! -//! The new text uses the line's own font when every character has a code and a glyph in it; -//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. +//! The new text uses the line's own font when every character has a code and a glyph in it. +//! Otherwise the words the font can show keep it and only the others are set in Helvetica +//! (WinAnsi), run by run, and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). use std::collections::HashMap; @@ -132,6 +133,9 @@ pub struct TextLine { /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none /// (#2529). Meaningless otherwise. soft_gap: usize, + /// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in + /// (see [`fallback::continues_font`]). + edited: Option, } impl TextLine { diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index f7c795ca65..91e5258b31 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,4 +1,4 @@ -use super::interp::Ts; +use super::interp::{Shown, Ts}; use super::lines::page_dict; use super::{ CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, @@ -148,6 +148,23 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option bool { + same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited) +} + +/// A line that began with a substitute run takes the source font of a later run, so the next +/// replacement starts from the line's own font again. +pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) { + if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") { + line.font = String::from_utf8_lossy(&s.font).into_owned(); + line.base_font = s.base_font.clone(); + line.face = s.face.clone(); + } +} + /// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese /// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another /// resource for the same font (a writer may split one font into several subsets, #3661), or @@ -730,6 +747,30 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, Ok(out) } +/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in +/// order. A word is shown in one font, so a word with any character the font lacks goes to the +/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are +/// in, since [`source_show`] draws spaces the font lacks with a displacement. +pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> { + let mut runs: Vec<(bool, String)> = Vec::new(); + // Spaces before the first word go with it: on their own they would make a run of the line's + // font with nothing to show (review on #2955). + let mut lead = String::new(); + for (i, word) in text.split(' ').enumerate() { + let piece = if i == 0 { word.to_string() } else { format!(" {word}") }; + if runs.is_empty() && word.is_empty() { + lead.push_str(&piece); + continue; + } + let own = word.is_empty() || metrics.encode(word).is_some(); + match runs.last_mut() { + Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece), + _ => runs.push((own, std::mem::take(&mut lead) + &piece)), + } + } + runs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index b6c2d59cd1..019edf982a 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -359,6 +359,7 @@ pub(super) struct Shown { /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), /// matching the single space a rejoin always used before #2529. pub(super) soft_gap: usize, + pub(super) edited: Option, } impl Shown { @@ -537,6 +538,8 @@ type Invocation = (usize, Vec, Ts); struct MarkedContent { regions: Vec>, layers: Vec>>, + // The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last. + edited: Vec>, actual_text: Vec>, } @@ -550,6 +553,7 @@ impl MarkedContent { let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); self.layers .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() }); // The property list is inline or named in the resources' /Properties (§14.6.2). let list = logical_text.and_then(|properties| match op.operands.get(1) { Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), @@ -565,6 +569,7 @@ impl MarkedContent { fn end(&mut self, index: usize, shown: &mut Vec) { self.regions.pop(); self.layers.pop(); + self.edited.pop(); if let Some(Some((start, open, logical))) = self.actual_text.pop() { let tail = shown.split_off(start.min(shown.len())); if tail.iter().any(|s| s.glyph_count > 0) { @@ -814,6 +819,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_break: state.soft_break, hard_break: state.hard_break, soft_gap: state.soft_gap, + edited: marked.edited.last().copied().flatten(), }); Ok(()) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 2755d6bb71..54c249dbf1 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use super::fallback::{same_font, same_paint}; +use super::fallback::{adopt_source_font, continues_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; @@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in piece == si && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -374,6 +374,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -401,6 +402,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -461,7 +463,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream let bt = pen.bt; lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -492,6 +494,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); } else { lines.push(TextLine { text: s.text.clone(), @@ -518,6 +521,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c146ffcbc0..1bf27d2b9e 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,7 +1,7 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, - source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, + source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; @@ -110,10 +110,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(format!("{} Type3", fallback.family)); } else { + // The words the line's font can show keep it; only the others are set in the + // substitute (#2378): a subset without `V` or `ü` no longer turns the whole line + // into Helvetica. Reordered text (right to left, Indic) keeps one run as before. + let mixed = font + .as_ref() + .filter(|_| !visual_indic && fallback_shown == text) + .map(|m| source_runs(m, &text)) + .filter(|runs| runs.iter().any(|(own, _)| *own)); + // What the substitute has to show: only the runs the line's font can't. + let for_substitute: String = match &mixed { + Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(), + None => fallback_shown.clone(), + }; let win = pdfcraft_fonts::win_ansi(&fallback_shown); // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. - let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); - if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { + let checked = pdfcraft_fonts::win_ansi(&for_substitute); + let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); + if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); } // The size in text space: the current Tf's size. @@ -121,8 +135,36 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let family = source_family(&target.base_font); let base = family.base_font(target.bold, target.italic); let substitute_name = format!("PCEd{}", base.replace('-', "")); - replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + match mixed { + Some(runs) => { + let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + // Marked as one line, so it reads back as one (see `continues_font`). + replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")])); + for (own, run) in runs { + if own { + replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if let Some(m) = font.as_ref() { + replacement.extend(source_show( + m, + &run, + target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), + space, + target.origin.state.char_spacing, + target.origin.state.word_spacing, + )?); + } + } else { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))])); + } + } + replacement.push(Op::new("EMC", Vec::new())); + } + None => { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + } + } replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(base.to_string()); let mut f = Dict::new(); @@ -135,6 +177,43 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } // Rebuild: the line's first operator becomes the replacement, its others go. + // A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own, + // with its fonts and the moves standing in for spaces the source font lacks: those go + // with the line, or each edit would nest one more (review on #2955). The line then starts in + // its own font, set again where the sequence was. + if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) { + // Only when the sequence holds nothing but this line: a paragraph rewrite keeps the + // delimiters, so other rows can be inside it (review on #2955). Then the sequence stays + // as it is, since it is also what reads its own two-font line back as one, and only + // this line's operators go. + let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o))); + // Either way the replacement starts in the line's own font: inside the sequence the font + // in effect at the line's first operator can be the substitute's, which a replacement + // the line's font shows by itself would otherwise inherit (review on #2955). + let size = font_size_before(&ops, first).unwrap_or(target.size); + replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if !others_inside { + // What goes is what the replacement stands in for: the marked-content delimiters + // (nested ones too, which come in pairs), the fonts and the text-showing operators + // (the line's runs and the moves between them). A paragraph rewrite can also write a + // row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET` + // inside kept delimiters; those stay, or the replacement loses its place and the + // content its balance (review on #2955). + drops.extend( + (open..=close) + .filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))), + ); + let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size)); + // The same font set just before the sequence (by the last edit) is set again here. + if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + // And set back after it: the replacement ends in that font too. + if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + } + } let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data)?; let page_ref = p.obj; @@ -150,6 +229,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi Ok(LineEdit { substituted }) } +/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins. +fn matching_emc(ops: &[Op], open: usize) -> Option { + let mut depth = 0usize; + for (i, op) in ops.iter().enumerate().skip(open) { + match op.op.as_slice() { + b"BMC" | b"BDC" => depth = depth.saturating_add(1), + b"EMC" => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + /// The size of the last `Tf` before operator `at`. fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 39d00cacbc..084b06dbae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1907,8 +1907,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", "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." +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", "crates/edit/src/tests.rs::only_the_words_a_subset_cannot_show_are_substituted"] +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; replacing one line keeps the source font for every word it can show and sets only the other words in Helvetica (#2378), while a paragraph rewrite still substitutes the whole paragraph for a Latin character its subset lacks); 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"