Browse / github.com/storytold/pdfcraft / patch
M7: keep typed letters visible in Type 1 subsets (#4966)
[Bug] Characters disappear unpredictably during text editing sessions https://github.com/user-attachments/assets/7d4ffb2f-a7cc-4a09-9b01-195a8ac82065 [bayesianopt.pdf](https://github.com/user-attachments/files/34014307/bayesianopt.pdf) ### Bug Description During text editing sessions, characters within the text block spontaneously disappear or get deleted unpredictably as typing or editing occurs, leading to broken words and missing content. ### Steps to Reproduce 1. Launch PDFCraft on Fedora 44 (Wayland / GNOME). 2. Open a PDF document and select the Text tool from the sidebar. 3. Click on an existing text block and begin typing or modifying characters. 4.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- hcchung1 (imported from pull request #5003, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +489 −6 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b87465e2842c27a796e37453
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/automation/tests/it/main.rs crates/automation/tests/it/type1_subset.rs crates/fonts/src/pdf.rs crates/fonts/src/pdf/tests.rs crates/fonts/src/pdf/type1.rs docs/editing-existing-text.md docs/roadmap.md parity/acrobat-features.toml
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)+> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M7 #4966: Type 1 subset glyph availability; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)@@ -199,6 +199,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-27 (M7, #4966):** Type 1 subsets with valid CharSet metadata no longer reuse absent glyphs solely because Widths and ToUnicode describe them. Encoding names and readable built-in Type 1 encodings determine glyph availability; missing letters follow existing fallback. Exact face completion and legacy editor previews remain limited; estimates unchanged.+ - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.@@ -682,6 +684,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | patch | M7 #4966: Type 1 subset glyph checks prevent missing letters after edit commit; estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |@@ -8,3 +8,4 @@ mod button_caption; mod form_bulk; mod hostile_args; mod optimize_content_boundaries;+mod type1_subset;new file mode 100644@@ -0,0 +1,196 @@+//! #4966: committing typed letters must not reuse missing Type 1 subset glyphs.+//! The PDF and its rectangular test glyphs are contributor-original, generated in memory.++use pdfcraft_automation::{Automation, Content};+use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, Stream, write_full};+use pdfcraft_engine::DocId;+use serde_json::{Value, json};++/// A tiny Type 1 program: every defined letter draws a rectangle; .notdef draws nothing.+/// P and b are deliberately absent, just as in the reported TeX subset.+fn subset_program(remap: bool) -> Stream {+ let letters = "QuestionrlmChc";+ let mut private = b"\0\0\0\0dup /Private 8 dict dup begin\n/lenIV -1 def\n/password 5839 def\n/BlueValues [] def\n/RD {string currentfile exch readstring pop} executeonly def\n/ND {noaccess def} executeonly def\nend readonly put\ndup /CharStrings 15 dict dup begin\n".to_vec();+ // 0 600 hsbw endchar. Each visible glyph adds an original 500 x 700 rectangle.+ let empty = [139, 248, 236, 13, 14];+ let rectangle = [139, 248, 236, 13, 189, 139, 21, 139, 249, 80, 5, 248, 136, 139, 5, 139, 253, 80, 5, 252, 136, 139, 5, 9, 14];+ for (name, outline) in+ std::iter::once((".notdef".to_string(), empty.as_slice())).chain(letters.chars().map(|ch| (ch.to_string(), rectangle.as_slice())))+ {+ private.extend_from_slice(format!("/{name} {} RD ", outline.len()).as_bytes());+ private.extend_from_slice(outline);+ private.extend_from_slice(b" ND\n");+ }+ private.extend_from_slice(b"end readonly put\ndup /FontName get exch definefont pop\nmark currentfile closefile\n");+ let mut program = b"%!FontType1-1.0: ABCDEF+Example-Bold 1.0\n11 dict begin\n/FontName /ABCDEF+Example-Bold def\n/FontType 1 def\n/FontMatrix [0.001 0 0 0.001 0 0] def\n/FontBBox [0 0 600 700] def\n/Encoding StandardEncoding def\ncurrentdict end\ncurrentfile eexec\n".to_vec();+ if remap {+ let encoding: String = (0..=255_u8)+ .map(|code| {+ if code == b'P' {+ "/Q ".to_string()+ } else if letters.contains(char::from(code)) {+ format!("/{} ", char::from(code))+ } else {+ "/.notdef ".to_string()+ }+ })+ .collect();+ program = String::from_utf8(program)+ .unwrap()+ .replace("/Encoding StandardEncoding def", &format!("/Encoding [{encoding}] readonly def"))+ .into_bytes();+ }+ let clear_length = program.len();+ // Type 1 eexec cipher (not document encryption); four discarded bytes prefix the private dict.+ let mut state = 55_665_u16;+ for byte in private {+ let encrypted = byte ^ (state >> 8) as u8;+ state = u16::from(encrypted).wrapping_add(state).wrapping_mul(52_845).wrapping_add(22_719);+ program.extend_from_slice(format!("{encrypted:02X}").as_bytes());+ }+ let encrypted_length = program.len() - clear_length;+ program.extend_from_slice(b"\n");+ program.extend_from_slice("0".repeat(512).as_bytes());+ program.extend_from_slice(b"\ncleartomark\n");+ let dict = Dict::from_iter([+ (b"Length1".to_vec(), Object::Int(clear_length as i64)),+ (b"Length2".to_vec(), Object::Int(encrypted_length as i64)),+ (b"Length3".to_vec(), Object::Int((program.len() - clear_length - encrypted_length) as i64)),+ ]);+ Stream::flate(dict, &program)+}++fn fixture(remap: bool) -> Vec<u8> {+ let mut doc = Document::new_empty();+ let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();+ let program = doc.add(Object::Stream(subset_program(remap)));+ let descriptor = Dict::from_iter([+ (b"Type".to_vec(), Object::name("FontDescriptor")),+ (b"FontName".to_vec(), Object::name("ABCDEF+Example-Bold")),+ (b"Flags".to_vec(), Object::Int(32)),+ (b"FontBBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 700.into()])),+ (b"Ascent".to_vec(), Object::Int(700)),+ (b"Descent".to_vec(), Object::Int(0)),+ (b"CapHeight".to_vec(), Object::Int(700)),+ (b"ItalicAngle".to_vec(), Object::Int(0)),+ (b"StemV".to_vec(), Object::Int(80)),+ (b"CharSet".to_vec(), PdfString::literal(b"/Q/u/e/s/t/i/o/n/r/l/m/C/h/c").into()),+ (b"FontFile".to_vec(), Object::Ref(program)),+ ]);+ let descriptor = doc.add(Object::Dict(descriptor));+ let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfrange <20> <7E> <0020> endbfrange")));+ let font = doc.add(Object::Dict(Dict::from_iter([+ (b"Type".to_vec(), Object::name("Font")),+ (b"Subtype".to_vec(), Object::name("Type1")),+ (b"BaseFont".to_vec(), Object::name("ABCDEF+Example-Bold")),+ (b"FirstChar".to_vec(), Object::Int(32)),+ (b"LastChar".to_vec(), Object::Int(126)),+ (b"Widths".to_vec(), Object::Array(vec![Object::Int(600); 95])),+ (b"FontDescriptor".to_vec(), Object::Ref(descriptor)),+ (b"ToUnicode".to_vec(), Object::Ref(unicode)),+ ])));+ let regular = doc.add(Object::Dict(Dict::from_iter([+ (b"Type".to_vec(), Object::name("Font")),+ (b"Subtype".to_vec(), Object::name("Type1")),+ (b"BaseFont".to_vec(), Object::name("Helvetica")),+ ])));+ // Mixed styles take the same per-character source/fallback path as the reported region.+ let contents =+ doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F2 20 Tf 20 125 Td (Guide) Tj ET BT /F1 20 Tf 20 100 Td (Choice Questions) Tj ET")));+ let page = doc.add(Object::Dict(Dict::from_iter([+ (b"Type".to_vec(), Object::name("Page")),+ (b"Parent".to_vec(), Object::Ref(pages)),+ (b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 300.into(), 150.into()])),+ (+ b"Resources".to_vec(),+ Object::Dict(Dict::from_iter([(+ b"Font".to_vec(),+ Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Ref(font)), (b"F2".to_vec(), Object::Ref(regular))])),+ )])),+ ),+ (b"Contents".to_vec(), Object::Ref(contents)),+ ])));+ doc.update_dict(pages, |d| {+ d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));+ d.set(b"Count".to_vec(), Object::Int(1));+ })+ .unwrap();+ write_full(&doc, &SaveOptions::default()).unwrap()+}++fn call(a: &mut Automation, name: &str, args: Value) -> Value {+ match a.call(name, &args).unwrap().remove(0) {+ Content::Json(value) => value,+ other => panic!("expected JSON from {name}: {other:?}"),+ }+}++fn render(a: &mut Automation, id: u64) -> Vec<u8> {+ match a.call("page_render", &json!({"doc": id, "page": 1, "dpi": 144})).unwrap().remove(0) {+ Content::Png { data, .. } => data,+ other => panic!("expected PNG: {other:?}"),+ }+}++fn check_replacement(a: &mut Automation, id: u64) -> Vec<u8> {+ let lines = a.session().get(DocId(id)).unwrap().text_lines(0);+ let png = render(a, id);+ let mut reader = png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap();+ let mut pixels = vec![0; reader.output_buffer_size().unwrap()];+ let image = reader.next_frame(&mut pixels).unwrap();+ let channels = image.color_type.samples();+ assert!(channels >= 3);+ // The subset has no space glyph; the writer preserves its gap with positioning.+ assert_eq!(lines.iter().flat_map(|l| l.text.chars()).filter(|ch| !ch.is_whitespace()).collect::<String>(), "GuideChoiceProblems");+ for ch in ['P', 'b'] {+ let line = lines.iter().find(|l| l.text.contains(ch)).unwrap();+ assert_ne!(line.font, "F1", "{ch} must not use a missing subset outline");+ assert!(line.bold, "the replacement retains the source weight");+ let [left, bottom, right, top] = line.rect;+ let dark = ((150.0 - top).max(0.0) as usize * 2..((150.0 - bottom).ceil() as usize * 2).min(image.height as usize))+ .flat_map(|y| (left.max(0.0) as usize * 2..(right.ceil() as usize * 2).min(image.width as usize)).map(move |x| (x, y)))+ .filter(|(x, y)| pixels[(y * image.width as usize + x) * channels..][..3].iter().any(|v| *v < 128))+ .count();+ assert!(dark > 10, "{ch} has visible ink, not just a Unicode mapping");+ }+ assert!(lines.iter().any(|l| l.font == "F1" && l.text.contains('r')), "surviving source glyphs keep their own font");+ png+}++#[test]+fn typed_letters_absent_from_a_type1_subset_survive_commit_history_and_save() {+ let dir = std::env::temp_dir().join(format!("pdfcraft-type1-subset-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ std::fs::write(dir.join("source.pdf"), fixture(false)).unwrap();+ let mut a = Automation::new().with_root(&dir).unwrap();+ let id = call(&mut a, "doc_open", json!({"path": "source.pdf"}))["doc"].as_u64().unwrap();+ let before = render(&mut a, id);+ call(&mut a, "text_paragraphs", json!({"doc": id, "page": 1}));+ call(&mut a, "text_edit", json!({"doc": id, "page": 1, "paragraph": 1, "text": "Guide\nChoice Problems"}));+ let after = check_replacement(&mut a, id);+ assert_ne!(after, before);+ call(&mut a, "edit_undo", json!({"doc": id}));+ assert_eq!(render(&mut a, id), before);+ call(&mut a, "edit_redo", json!({"doc": id}));+ assert_eq!(check_replacement(&mut a, id), after);+ call(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));+ let reopened = call(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();+ assert_eq!(check_replacement(&mut a, reopened), after);+ std::fs::remove_dir_all(dir).unwrap();+}++#[test]+fn type1_charset_respects_custom_builtin_glyph_names() {+ let dir = std::env::temp_dir().join(format!("pdfcraft-type1-builtin-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ std::fs::write(dir.join("source.pdf"), fixture(true)).unwrap();+ let mut a = Automation::new().with_root(&dir).unwrap();+ let id = call(&mut a, "doc_open", json!({"path": "source.pdf"}))["doc"].as_u64().unwrap();+ call(&mut a, "text_paragraphs", json!({"doc": id, "page": 1}));+ call(&mut a, "text_edit", json!({"doc": id, "page": 1, "paragraph": 1, "text": "Guide\nChoice P"}));+ let lines = a.session().get(DocId(id)).unwrap().text_lines(0);+ assert_eq!(lines.iter().flat_map(|l| l.text.chars()).filter(|ch| !ch.is_whitespace()).collect::<String>(), "GuideChoiceP");+ let line = lines.iter().find(|l| l.text.contains('P')).unwrap();+ assert_eq!(line.font, "F1", "code 80 uses the present Q glyph, not StandardEncoding's absent P");+ std::fs::remove_dir_all(dir).unwrap();+}@@ -16,6 +16,8 @@ use std::collections::{BinaryHeap, HashMap, HashSet}; use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; +mod type1;+ /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] enum Codes {@@ -67,6 +69,8 @@ pub struct Metrics { /// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the /// embedded program. See [`cids_with_outlines`]. outlined_cids: Vec<u64>,+ /// Simple Type 1 codes whose encoding names are absent from the subset's `/CharSet`.+ absent_type1_codes: [u64; 4], } fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {@@ -628,6 +632,7 @@ impl Metrics { code_len: 1, explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(), outlined_cids: Vec::new(),+ absent_type1_codes: [0; 4], } } @@ -657,6 +662,9 @@ impl Metrics { Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; m.unicode = unicode_map(doc, font, m.composite);+ if m.subset {+ m.absent_type1_codes = type1::absent_codes(doc, font);+ } if m.std14.is_some() { embedded_widths(doc, font, &mut m); }@@ -864,6 +872,9 @@ impl Metrics { /// Only asked for codes the font's Unicode mapping gives: a subset's `ToUnicode` lists the /// glyphs it kept, while its `/W` leaves out those of the default width (#1443). fn has_glyph(&self, code: u32) -> bool {+ if self.absent_type1_codes.get(code as usize / 64).is_some_and(|word| (word >> (code % 64)) & 1 != 0) {+ return false;+ } if self.composite { let cid = self.cid(code); // CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word@@ -1,4 +1,4 @@-use pdfcraft_cos::{Dict, Document, Object, Stream};+use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; use super::*; @@ -310,6 +310,144 @@ fn subset_fonts_only_encode_the_glyphs_they_have() { assert_eq!(m.encode("abc"), None); } +fn type1_subset(doc: &mut Document, charset: Object) -> Dict {+ let descriptor = doc.add(Object::Dict(Dict::from_iter([(b"CharSet".to_vec(), charset)])));+ let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfrange <20> <7E> <0020> endbfrange")));+ font(+ doc,+ vec![+ ("Subtype", Object::name("Type1")),+ ("BaseFont", Object::name("ABCDEF+Example")),+ ("FirstChar", Object::Int(32)),+ ("Widths", Object::Array(vec![Object::Int(600); 95])),+ ("Encoding", Object::name("StandardEncoding")),+ ("FontDescriptor", Object::Ref(descriptor)),+ ("ToUnicode", Object::Ref(unicode)),+ ],+ )+}++#[test]+fn type1_subset_charset_rejects_letters_that_only_have_widths_and_unicode() {+ let mut doc = Document::new_empty();+ // Like a TeX subset: the width table and ToUnicode describe more than the embedded glyphs.+ let f = type1_subset(&mut doc, PdfString::literal(b"/Q/u/e/s/t/i/o/n/r/l/m").into());+ let m = Metrics::from_dict(&doc, &f);+ assert_eq!(m.encode("Questions").as_deref(), Some(b"Questions".as_slice()));+ assert_eq!(m.encode("P"), None, "a nonzero width is not an embedded P outline");+ assert_eq!(m.encode("b"), None, "the lowercase b outline is absent too");+ assert_eq!(m.decode(b"Problems"), "Problems", "extraction still follows ToUnicode");+ assert_eq!(m.width(80), 0.6, "reading source text keeps the declared advances");+ let f = type1_subset(&mut doc, PdfString::literal(b"/P/r/o/b/l/e/m/s").into());+ assert_eq!(Metrics::from_dict(&doc, &f).encode("Problems").as_deref(), Some(b"Problems".as_slice()));+}++#[test]+fn type1_charset_checks_exact_encoding_names_instead_of_unicode_text() {+ let mut doc = Document::new_empty();+ let mut f = type1_subset(&mut doc, PdfString::literal(b"/custom#2Dglyph/A.alt").into());+ // These glyphs have arbitrary names, while ToUnicode calls them A, B and C.+ let diffs = vec![32.into(), Object::name("A"), 65.into(), Object::name("custom-glyph"), Object::name("A"), Object::name("A.alt")];+ f.set(b"Encoding".to_vec(), Object::Dict(Dict::from_iter([(b"Differences".to_vec(), Object::Array(diffs))])));+ let m = Metrics::from_dict(&doc, &f);+ assert_eq!(m.encode("A").as_deref(), Some(b"A".as_slice()), "the escaped custom glyph is present");+ assert_eq!(m.encode("B"), None, "A.alt does not supply the distinct glyph A");+ assert_eq!(m.encode("C").as_deref(), Some(b"C".as_slice()));+ assert_eq!(m.encode(" "), None, "code 32 is not implicitly present when it names an absent glyph");+}++#[test]+fn type1_differences_entries_may_be_indirect() {+ let mut doc = Document::new_empty();+ let mut f = type1_subset(&mut doc, PdfString::literal(b"/custom").into());+ let code = doc.add(Object::Int(65));+ let name = doc.add(Object::name("custom"));+ let diffs = vec![Object::Ref(code), Object::Ref(name)];+ let encoding = Dict::from_iter([(b"BaseEncoding".to_vec(), Object::name("StandardEncoding")), (b"Differences".to_vec(), Object::Array(diffs))]);+ f.set(b"Encoding".to_vec(), Object::Dict(encoding));+ let m = Metrics::from_dict(&doc, &f);+ assert_eq!(m.encode("A").as_deref(), Some(b"A".as_slice()), "the referenced code 65 names the present glyph");+ assert_eq!(m.encode("B"), None, "later codes are not shifted onto the wrong glyphs");+}++#[test]+fn type1_charset_name_limit_is_inclusive() {+ let names = |n: usize| -> Object { PdfString::literal((0..n).map(|i| format!("/n{i}")).collect::<String>().into_bytes()).into() };+ let mut doc = Document::new_empty();+ // A usable list restricts the font, so the letter missing from it has no glyph.+ let at_cap = type1_subset(&mut doc, names(4096));+ assert_eq!(Metrics::from_dict(&doc, &at_cap).encode("P"), None);+ // One name past the cap is ignored as a whole and keeps the lenient behaviour.+ let past_cap = type1_subset(&mut doc, names(4097));+ assert_eq!(Metrics::from_dict(&doc, &past_cap).encode("P").as_deref(), Some(b"P".as_slice()));+}++#[test]+fn type1_charset_uses_the_base_encoding_glyph_names() {+ // Annex D's code and name tables must agree without consulting ToUnicode.+ let mut seen = HashSet::new();+ for (name, unicode) in crate::encodings::NAMES {+ assert!(seen.insert(unicode), "ambiguous canonical glyph name for {name}");+ }+ for base in ["StandardEncoding", "WinAnsiEncoding", "MacRomanEncoding"] {+ let mut doc = Document::new_empty();+ let mut f = type1_subset(&mut doc, PdfString::literal(b"/P/period/zero").into());+ f.set(b"Encoding".to_vec(), Object::name(base));+ let m = Metrics::from_dict(&doc, &f);+ assert_eq!(m.encode("P.0").as_deref(), Some(b"P.0".as_slice()), "{base}");+ assert_eq!(m.encode("b"), None, "{base}");+ assert_eq!(m.encode("1"), None, "{base}");+ }+}++#[test]+fn unusable_type1_charset_keeps_the_previous_lenient_encoding() {+ for charset in [+ Object::Null,+ Object::name("P"),+ PdfString::literal(b"").into(),+ PdfString::literal(b"/P 1").into(),+ PdfString::literal(b"/P/").into(),+ PdfString::literal(vec![b'/'; 65_537]).into(),+ PdfString::literal("/P ".repeat(4097).into_bytes()).into(),+ ] {+ let mut doc = Document::new_empty();+ let f = type1_subset(&mut doc, charset);+ assert_eq!(Metrics::from_dict(&doc, &f).encode("Problems").as_deref(), Some(b"Problems".as_slice()));+ }+ let mut doc = Document::new_empty();+ for subtype in ["TrueType", "Type3"] {+ let mut f = type1_subset(&mut doc, PdfString::literal(b"/Q").into());+ f.set(b"Subtype".to_vec(), Object::name(subtype));+ assert_eq!(Metrics::from_dict(&doc, &f).encode("P").as_deref(), Some(b"P".as_slice()), "{subtype}");+ }+ let mut f = type1_subset(&mut doc, PdfString::literal(b"/Q").into());+ f.set(b"Encoding".to_vec(), Object::name("UnknownEncoding"));+ assert_eq!(Metrics::from_dict(&doc, &f).encode("P").as_deref(), Some(b"P".as_slice()), "unknown glyph identity stays lenient");+}++#[test]+fn type1_charset_does_not_guess_an_unavailable_builtin_encoding() {+ let mut doc = Document::new_empty();+ let mut f = type1_subset(&mut doc, PdfString::literal(b"/custom-glyph").into());+ f.remove(b"Encoding");+ assert_eq!(+ Metrics::from_dict(&doc, &f).encode("P").as_deref(),+ Some(b"P".as_slice()),+ "a custom built-in encoding may give code 80 any glyph name"+ );+ for bytes in [b"broken font".to_vec(), vec![b' '; 4 * 1024 * 1024 + 1]] {+ let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes)));+ let descriptor = f.reference(b"FontDescriptor").unwrap();+ doc.update_dict(descriptor, |d| d.set(b"FontFile".to_vec(), Object::Ref(program))).unwrap();+ assert_eq!(+ Metrics::from_dict(&doc, &f).encode("P").as_deref(),+ Some(b"P".as_slice()),+ "unusable or oversized programs keep the unknown encoding"+ );+ }+}+ #[test] fn cmap_streams_decode_within_a_size_cap() { // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole.new file mode 100644@@ -0,0 +1,117 @@+//! Type 1 subset glyph availability (ISO 32000-1 Table 122, `/CharSet`). Widths and+//! ToUnicode can describe the full face even when its embedded subset lacks an outline.++use super::*;++// A simple font has at most 256 encoded glyphs, but its subset can list unencoded glyphs too.+// Bound both the string and its tokens before parsing any input-derived objects.+const MAX_CHARSET_BYTES: usize = 64 * 1024;+const MAX_CHARSET_NAMES: usize = 4096;+const MAX_TYPE1_BYTES: usize = 4 * 1024 * 1024;++fn charset(doc: &Document, font: &Dict) -> Option<HashSet<Vec<u8>>> {+ let descriptor = dict(doc, font.get(b"FontDescriptor"))?;+ let value = doc.resolve(descriptor.get(b"CharSet")?);+ let Object::String(value) = value.as_ref() else { return None };+ if value.bytes.len() > MAX_CHARSET_BYTES {+ return None;+ }+ let mut lexer = pdfcraft_cos::Lexer::new(&value.bytes, 0);+ let mut names = HashSet::new();+ // One more pass than names: the last one only finds the end of the string.+ for _ in 0..=MAX_CHARSET_NAMES {+ lexer.skip_ws();+ if lexer.pos == value.bytes.len() {+ return (!names.is_empty()).then_some(names);+ }+ if names.len() == MAX_CHARSET_NAMES {+ return None;+ }+ // Do not let a corrupt name list build arbitrary nested PDF objects.+ if value.bytes.get(lexer.pos) != Some(&b'/') {+ return None;+ }+ let Object::Name(name) = lexer.object().ok()? else { return None };+ if name.is_empty() {+ return None;+ }+ names.insert(name);+ }+ None+}++/// Without an explicit base encoding, glyph names come from the font program, not+/// StandardEncoding or ToUnicode. Read it through the existing safe font parser.+fn builtin_presence(doc: &Document, font: &Dict, names: &HashSet<Vec<u8>>) -> Option<[Option<bool>; 256]> {+ let descriptor = dict(doc, font.get(b"FontDescriptor"))?;+ let program = doc.resolve(descriptor.get(b"FontFile")?);+ let Object::Stream(program) = program.as_ref() else { return None };+ let bytes = program.decoded_within(MAX_TYPE1_BYTES).ok()?;+ let font = skrifa::raw::ps::type1::Type1Font::new(&bytes).ok()?;+ let encoding = font.encoding()?;+ let mut present = [None; 256];+ for (code, slot) in present.iter_mut().enumerate() {+ // This fixed 256-entry table makes every code fit in one byte.+ *slot = match encoding.map(code as u8) {+ Some(gid) if gid != skrifa::GlyphId::NOTDEF => font.glyph_name(gid).map(|name| names.contains(name.as_bytes())),+ _ => Some(false),+ };+ }+ Some(present)+}++pub(super) fn absent_codes(doc: &Document, font: &Dict) -> [u64; 4] {+ let mut absent = [0_u64; 4];+ if !matches!(font.name(b"Subtype"), Some(b"Type1" | b"MMType1")) {+ return absent;+ }+ let Some(names) = charset(doc, font) else { return absent };+ let encoding = font.get(b"Encoding").map(|e| doc.resolve(e));+ let base = match encoding.as_deref() {+ Some(Object::Name(name)) => Some(name.as_slice()),+ Some(Object::Dict(d)) => d.name(b"BaseEncoding"),+ _ => None,+ };+ let table = match base {+ Some(b"WinAnsiEncoding") => Some(&crate::encodings::WIN_ANSI),+ Some(b"MacRomanEncoding") => Some(&crate::encodings::MAC_ROMAN),+ Some(b"StandardEncoding") => Some(&crate::encodings::STANDARD),+ _ => None,+ };+ // Annex D's tables and NAMES use the same canonical glyph names. This is the base+ // encoding's glyph identity, never the extraction text supplied by ToUnicode.+ let canonical: HashMap<_, _> = crate::encodings::NAMES.iter().map(|(name, unicode)| (*unicode, name.as_bytes())).collect();+ let mut present = if base.is_none() { builtin_presence(doc, font, &names).unwrap_or([None; 256]) } else { [None; 256] };+ for (code, unicode) in table.into_iter().flatten().enumerate() {+ if let Some(slot) = present.get_mut(code) {+ *slot = canonical.get(unicode).map(|name| names.contains(*name));+ }+ }+ if let Some(Object::Dict(d)) = encoding.as_deref()+ && let Some(diffs) = d.get(b"Differences").map(|v| doc.resolve(v))+ && let Some(diffs) = diffs.as_array()+ {+ let mut code = 0_u32;+ for item in diffs {+ // An entry may be an indirect reference; skipping it would shift every later code.+ match doc.resolve(item).as_ref() {+ Object::Int(n) => code = u32::try_from(*n).unwrap_or(u32::MAX),+ Object::Name(name) => {+ if let Some(slot) = present.get_mut(code as usize) {+ *slot = Some(name != b".notdef" && names.contains(name));+ }+ code = code.saturating_add(1);+ }+ _ => {}+ }+ }+ }+ for (code, value) in present.iter().enumerate() {+ if *value == Some(false)+ && let Some(word) = absent.get_mut(code / 64)+ {+ *word |= 1_u64 << (code % 64);+ }+ }+ absent+}@@ -1,5 +1,7 @@ # Editing existing text and source subsets +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (#4966: Type 1 subset glyph availability on edit commit)+ 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; source font programs, colour, size and baseline anchors stay intact. A textless preview@@ -32,6 +34,14 @@ these work in every build, the web one included. Choosing another face is explicit. Fonts with restricted embedding, unsupported collections, excluded families or no open-licence declaration are refused. Interface fallback fonts are never exported. +For Type 1 subsets that declare a valid `/CharSet`, new text only reuses glyphs listed there.+A width or ToUnicode mapping alone does not prove that the subset contains the outline (#4966).+Glyph names come from the PDF encoding (including Differences) or a readable Type 1 program's+built-in encoding, independently of the text used for extraction. Missing glyphs use the existing+fallback path; mixed-style regions retain source glyphs that are still available. The fallback+can differ in appearance from the original face. Missing, malformed or oversized CharSet data,+unsupported encodings, and unavailable built-in encodings retain the previous lenient handling.+ The full selected program, glyph mappings, widths and Unicode text are embedded in the PDF. Saving and reopening therefore needs no locally installed font. Editing the same text again reuses the font resource the page already has, and every resource made from one program@@ -128,3 +138,9 @@ them, a page of an affected document is refused until it is listed again, instea numbers as positions. Without a listing, a number is a position in the page's current paragraphs, as before. The inline editor holds the paragraph a switching click asked for the same way. What is still missing is an identity that survives save and reopen (design: #2508).++## Revision history++| Date | Change |+|---|---|+| 2026-10-27 | Patch: document Type 1 subset glyph checks for #4966 and the remaining fallback limitations. |@@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)+> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M7 #4966: Type 1 subset glyph availability; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -185,7 +185,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |-| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |+| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since (#4966): Type 1 CharSet checks prevent missing typed glyphs from being reused; exact face completion and legacy previews remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |@@ -254,6 +254,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | patch | M7 #4966: typed letters absent from Type 1 subsets use fallback; exact face completion remains limited. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |@@ -1878,8 +1878,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]-evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"]-notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md"+evidence = ["crates/automation/tests/it/type1_subset.rs::typed_letters_absent_from_a_type1_subset_survive_commit_history_and_save", "crates/automation/tests/it/type1_subset.rs::type1_charset_respects_custom_builtin_glyph_names", "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"]+notes = "#4966: Type 1 subset CharSet glyph names constrain source-font reuse, independently of ToUnicode and nonzero widths; explicit and readable built-in Type 1 encodings are honored. Missing glyphs use existing fallback; absent/malformed/over-budget CharSet, unsupported encodings and unavailable built-in encodings retain prior lenient handling. Exact source-face completion and legacy editor previews 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); 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" [[feature]] id = "edit.text-reflow"