diff --git a/ROADMAP.md b/ROADMAP.md index 71573a6ead..c783eb776e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged) @@ -302,6 +303,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. +- **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged. - **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. - **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. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e0ceba48c0..e5d90bfe5b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4592,6 +4592,51 @@ fn a_line_split_across_content_streams_is_listed_and_edited() { assert_eq!(page_text(&mut a, doc)[0].split_whitespace().collect::>(), ["Target", "Later"]); } +#[test] +fn mixed_script_paragraph_keeps_source_fonts_through_tools_and_reopen() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping mixed-script fallback: build with CRAFT_FONTS_DIR"); + return; + } + let dir = workdir("mixed-script-paragraph"); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + cos.update_dict(pdfcraft_cos::ObjRef::new(2, 0), |d| { + d.set(b"MediaBox".to_vec(), pdfcraft_cos::Object::Array([0, 0, 612, 792].map(pdfcraft_cos::Object::Int).to_vec())); + }) + .unwrap(); + let body = + b"BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET"; + cos.set(pdfcraft_cos::ObjRef::new(5, 0), pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), body))); + std::fs::write(dir.join("mixed.pdf"), pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"mixed.pdf"}))["doc"].as_u64().unwrap(); + let draft = "The quick brown fox jumps over the lazy dog шалом and keeps running through the long field."; + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":draft})); + let check = |a: &mut Automation, doc, expected: &str| { + let listed = ok(a, "text_lines", json!({"doc":doc,"page":1})); + let lines = listed["lines"].as_array().unwrap(); + assert!(lines.iter().any(|l| l["font"] == "Helvetica" && l["text"].as_str().unwrap().contains("The quick brown fox")), "{listed}"); + for line in lines.iter().filter(|l| l["text"].as_str().unwrap().chars().any(|c| c.is_ascii_alphabetic())) { + assert_eq!(line["font"], "Helvetica", "{line}"); + } + let text = page_text(a, doc).join(" "); + assert_eq!(text.split_whitespace().collect::>(), expected.split_whitespace().collect::>()); + }; + check(&mut a, doc, draft); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!(!page_text(&mut a, doc).join(" ").contains("шалом")); + ok(&mut a, "edit_redo", json!({"doc":doc})); + check(&mut a, doc, draft); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + ok(&mut a, "doc_close", json!({"doc":doc})); + let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + check(&mut a, doc, draft); + let extended = format!("{draft} More Latin."); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":extended})); + check(&mut a, doc, &extended); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn paragraph_bold_without_font_keeps_the_source_family() { let dir = workdir("paragraph-bold"); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index c8aff30de6..6c514515d0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2109,6 +2109,229 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() { } } +#[test] +#[ignore = "manual mixed-script rewrite timing probe; run with --ignored --nocapture"] +fn mixed_script_paragraph_rewrite_timing_probe() { + assert!(pdfcraft_fonts::document_japanese_font().is_some(), "the timing probe requires the release-pinned craft-fonts input"); + for (repeats, width) in [(32, 480.0), (512, 480.0), (2048, 480.0), (512, 14_400.0)] { + let draft = "The quick brown fox шалом ".repeat(repeats); + let mut samples = Vec::new(); + for _ in 0..3 { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (The quick brown fox) Tj ET"); + let style = text::BlockStyle { + width: Some(width), + align: Some(added::Align::Center), + underline: Some(true), + char_spacing: Some(1.25), + scale: Some(80.0), + ..Default::default() + }; + let start = std::time::Instant::now(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + samples.push(start.elapsed().as_micros()); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|line| line.base_font == "Helvetica")); + std::hint::black_box(doc); + } + samples.sort_unstable(); + eprintln!("mixed-script-rewrite bytes={} width={width} median_us={} samples_us={samples:?}", draft.len(), samples[1]); + } +} + +#[test] +fn mixed_script_source_measurements_are_bounded_by_distinct_characters() { + if without_craft_fonts("mixed_script_source_measurements_are_bounded_by_distinct_characters") { + return; + } + for resource in ["F1", "F2"] { + for repeats in [1, 256] { + for width in [80.0, 14_400.0] { + let draft = "ab шалом ab ".repeat(repeats); + let distinct = draft.chars().collect::>().len(); + let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let style = text::BlockStyle { width: Some(width), align: Some(added::Align::Justify), underline: Some(true), ..Default::default() }; + text::take_source_character_measurements(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let measured = text::take_source_character_measurements(); + assert_eq!(measured, distinct, "{resource}, {repeats} repeats, width {width}: measure each distinct source candidate once"); + } + } + } +} + +#[test] +fn mixed_script_cached_widths_follow_each_rewrite_state() { + if without_craft_fonts("mixed_script_cached_widths_follow_each_rewrite_state") { + return; + } + let draft = "ab шалом ab шалом ab шалом ab"; + for (resource, size, spacing, scale) in [("F2", 12.0, 0.0, 100.0), ("F1", 18.0, 1.25, 80.0), ("F2", 18.0, 1.25, 80.0), ("F1", 12.0, 0.0, 100.0)] { + let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td (ab) Tj ET")); + let original_font = doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref().clone(); + let style = text::BlockStyle { + size: Some(size), + width: Some(120.0), + align: Some(added::Align::Center), + underline: Some(true), + char_spacing: Some(spacing), + scale: Some(scale), + ..Default::default() + }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref(), &original_font); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::>(), draft.split_whitespace().collect::>()); + let page = pdfcraft_model::pages(&doc).remove(0); + let fonts = doc.dict(doc.dict(page.dict.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let underlines: Vec<_> = ops.iter().filter(|op| op.is("m") || op.is("l")).collect(); + let rows: Vec<_> = lines.chunk_by(|a, b| (a.origin_baseline() - b.origin_baseline()).abs() < 0.001).collect(); + assert!(rows.len() > 1, "the cache must also be used while wrapping"); + assert_eq!(underlines.len(), rows.len() * 2); + for (row, underline) in rows.into_iter().zip(underlines.as_chunks::<2>().0) { + // Recompute from the saved fonts, independently of the rewrite's cache. + let widths: Vec<_> = row + .iter() + .map(|line| { + if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) { + assert_eq!(line.font, resource); + } + let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap()); + let bytes = m.encode(&line.text).unwrap_or_else(|| panic!("saved font {} cannot encode {line:?}", m.base_font)); + m.codes(&bytes).iter().map(|(code, _)| m.width(*code) * size + spacing).sum::() * scale / 100.0 + }) + .collect(); + let total: f64 = widths.iter().sum(); + let left = (120.0 - total).max(0.0) / 2.0; + let mut x = 72.0 + left; + for (line, advance) in row.iter().zip(widths) { + assert!((line.baseline()[0] - x).abs() < 0.02, "{resource}, size {size}: {line:?}, expected {x}"); + x += advance; + } + assert!((underline[0].operands[0].as_f64().unwrap() - left).abs() < 0.02); + assert!((underline[1].operands[0].as_f64().unwrap() - left - total).abs() < 0.02); + } + } +} + +#[test] +fn mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break() { + if without_craft_fonts("mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break") { + return; + } + let original = "The quick brown fox jumps over the lazy dog"; + let ending = "and keeps running through the long field."; + let draft = format!("{original} шалом {ending}"); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL 72 700 Td ({original}) Tj T* ({ending}) Tj ET")); + let font = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref().clone(); + text::replace_block(&mut doc, 0, 0, &draft).unwrap(); + let doc = reopen(&doc); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref(), &font); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.contains("The quick brown fox")), "{lines:#?}"); + for line in &lines { + if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) { + assert_eq!(line.base_font, "Helvetica", "Latin must retain its source font: {line:?}"); + } + } + assert!(lines.iter().any(|l| l.text.contains("шалом") && l.base_font != "Helvetica")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::>(), draft.split_whitespace().collect::>()); + let fallback = page_font_programs(&doc).into_iter().filter(|(subtype, _)| subtype == b"Type3").count(); + assert_eq!(fallback, 1, "the new letters share one fallback font"); +} + +#[test] +fn mixed_script_subset_spaces_remain_extractable_after_saving() { + if without_craft_fonts("mixed_script_subset_spaces_remain_extractable_after_saving") { + return; + } + for (resource, draft) in [("F2", "ab шалом ab"), ("F2", "шалом ab"), ("F2", "ab шалом"), ("F1", "ab шалом ab")] { + for align in [added::Align::Left, added::Align::Center, added::Align::Right] { + let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let font_id = pdfcraft_cos::ObjRef::new(if resource == "F2" { 6 } else { 5 }, 0); + let original_font = doc.get(font_id).as_ref().clone(); + let style = + text::BlockStyle { width: Some(300.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + for bytes in [write_full(&doc, &Default::default()).unwrap(), pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()] { + let saved = Document::open(Arc::new(bytes)).unwrap(); + let lines = text::text_lines(&saved, 0).unwrap(); + let decoded: String = lines.iter().map(|line| line.text.as_str()).collect(); + assert_eq!(decoded, draft, "{resource}: edge spaces of source runs must be real extractable glyphs"); + assert_eq!(text::text_blocks(&saved, 0).unwrap()[0].text, draft); + assert_eq!(saved.get(font_id).as_ref(), &original_font); + for line in lines.iter().filter(|line| line.text.chars().any(|ch| ch.is_ascii_alphabetic())) { + assert_eq!(line.font, resource, "the Latin letters retain their source font: {line:?}"); + } + } + } + } +} + +/// Mixed-font fallback must preserve the refusal of unsupported per-row alignment. +#[test] +fn mixed_script_row_alignment_is_refused_without_changes() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET"); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let style = text::BlockStyle { width: Some(300.0), aligns: vec![Some(added::Align::Center)], ..Default::default() }; + let err = text::rewrite_block(&mut doc, 0, 0, Some("abcшаломdef"), &style).unwrap_err(); + assert!(err.to_string().contains("selected-row alignment"), "{err}"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); +} + +#[test] +fn mixed_script_runs_use_source_and_fallback_advances_for_alignment() { + if without_craft_fonts("mixed_script_runs_use_source_and_fallback_advances_for_alignment") { + return; + } + for (resource, prefix, suffix) in [("F1", "abc", "def"), ("F2", "ab", "ab"), ("F1", "abc ", "def")] { + // Rewriting normalizes Tw; measuring a source space must use that same state. + let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td ({prefix}) Tj ET")); + let draft = format!("{prefix}шалом{suffix}"); + let style = text::BlockStyle { + width: Some(300.0), + align: Some(added::Align::Center), + char_spacing: Some(1.25), + scale: Some(80.0), + ..Default::default() + }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft); + assert_eq!((lines[0].font.as_str(), lines[2].font.as_str()), (resource, resource)); + let page = pdfcraft_model::pages(&doc).remove(0); + let resources = doc.dict(page.dict.get(b"Resources").unwrap()).unwrap(); + let fonts = doc.dict(resources.get(b"Font").unwrap()).unwrap(); + let advances: Vec = lines + .iter() + .zip([prefix, "шалом", suffix]) + .map(|(line, part)| { + let font = doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap(); + let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &font); + let bytes = m.encode(part).unwrap(); + (m.codes(&bytes).iter().map(|(c, _)| m.width(*c) * 12.0).sum::() + part.chars().count() as f64 * 1.25) * 0.8 + }) + .collect(); + let mut x = 72.0 + (300.0 - advances.iter().sum::()) / 2.0; + for (line, width) in lines.iter().zip(advances) { + assert!((line.baseline()[0] - x).abs() < 0.01, "{resource}: {line:?}, expected {x}"); + x += width; + } + } +} + +#[test] +fn mixed_script_paragraph_batches_only_missing_glyphs_and_refuses_atomically() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET"); + // Cyrillic also uses bundled Inter in builds without craft-fonts. + text::rewrite_block_with_limit(&mut doc, 0, 0, Some("Latin шалом text"), &Default::default(), 2).unwrap(); + assert_eq!(page_font_programs(&doc).iter().filter(|(subtype, _)| subtype == b"Type3").count(), 3); + let before = write_full(&doc, &Default::default()).unwrap(); + assert!(text::replace_block(&mut doc, 0, 0, "Latin \u{FDFD} text").is_err()); + assert_eq!(write_full(&doc, &Default::default()).unwrap(), before); +} + #[test] fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") { @@ -2127,7 +2350,33 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { } else { text::replace_line(&mut doc, 0, 0, replacement).unwrap(); } - assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement); + let doc = reopen(&doc); + if paragraph { + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, replacement); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text.contains("world") && l.base_font == "Times-Roman")); + } else { + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, replacement); + } + } +} + +/// A subset missing spaces must still split source and fallback runs while aligning text. +#[test] +fn mixed_source_subset_without_space_keeps_fallback_runs_after_reopen() { + let draft = "a қ b a қ b"; + for align in [added::Align::Left, added::Align::Center, added::Align::Justify] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let original = doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref().clone(); + let style = text::BlockStyle { width: Some(36.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref(), &original); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|line| line.text.contains('a') && line.font == "F2")); + assert!(lines.iter().any(|line| line.text.contains('b') && line.font == "F2")); + assert!(lines.iter().any(|line| line.text.contains('қ') && line.base_font == "Inter-Regular")); + assert!(lines.iter().any(|line| line.text.contains(' ') && line.base_font == "Inter-Regular")); } } @@ -2144,9 +2393,22 @@ fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() { assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}"); let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); assert!(content.contains("/PCLat"), "{replacement}: {content}"); - let lines = text::text_lines(&reopen(&doc), 0).unwrap(); - assert_eq!(lines[0].text, replacement); - assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew"); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + if paragraph { + assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement); + for ch in ['a', 'b'].into_iter().filter(|ch| replacement.contains(*ch)) { + assert!(lines.iter().any(|line| line.text.contains(ch) && line.base_font == "ABCDEF+Arial"), "{lines:?}"); + } + } else { + assert_eq!(lines[0].text, replacement); + } + for ch in replacement.chars().filter(|ch| !ch.is_ascii()) { + assert!( + lines.iter().any(|line| line.text.contains(ch) && line.base_font == "Inter-Regular"), + "the generated Inter face draws {ch}: {lines:?}" + ); + } } } } @@ -12137,16 +12399,19 @@ fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() { #[test] fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() { // қ requires the same bundled fallback for both drafts, without optional font inputs. - for spacing in [0.0, 2.0] { - let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); - let mut selected = plain.clone(); - let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; - text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); - text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); - let a = text::text_lines(&reopen(&plain), 0).unwrap(); - let b = text::text_lines(&reopen(&selected), 0).unwrap(); - assert_eq!(a[0].base_font, b[0].base_font); - assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + // Exercise both retained source runs and a wholly substituted subset. + for resource in ["F1", "F2"] { + for spacing in [0.0, 2.0] { + let mut plain = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let mut selected = plain.clone(); + let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; + text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); + text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); + let a = text::text_lines(&reopen(&plain), 0).unwrap(); + let b = text::text_lines(&reopen(&selected), 0).unwrap(); + assert_eq!(a[0].base_font, b[0].base_font); + assert!(close(a[0].rect, b[0].rect), "{resource}, Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + } } } /// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen. diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index ca9632061a..ac65b83cbe 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -10,7 +10,7 @@ //! otherwise it is set in Helvetica (WinAnsi), 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; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; @@ -45,13 +45,13 @@ pub use blocks::{ use blocks::{kept_words, paragraph_separator, row_break}; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; use editor::{builtin_thai_choice, layout_draft}; -#[cfg(test)] -pub(crate) use fallback::SOURCE_SPACE_CALLS; use fallback::{ MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs, }; #[cfg(test)] +pub(crate) use fallback::{SOURCE_SPACE_CALLS, take_source_character_measurements}; +#[cfg(test)] use fallback::{add_type3_font, is_type3_fallback}; pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; use interp::{Origin, Paint, union}; diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index f7c795ca65..d186548fcb 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,3 +1,4 @@ +use super::HashSet; use super::interp::Ts; use super::lines::page_dict; use super::{ @@ -630,6 +631,21 @@ pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option(mut text: &'a str, source_chars: Option<&'a HashSet>) -> impl Iterator + 'a { + std::iter::from_fn(move || { + let first = text.chars().next()?; + let source = source_chars.is_some_and(|chars| chars.contains(&first)); + let end = source_chars + .and_then(|chars| text.char_indices().find(|(_, ch)| chars.contains(ch) != source).map(|(offset, _)| offset)) + .unwrap_or(text.len()); + // char_indices supplies UTF-8 boundaries; get also handles an invalid range safely. + let run = text.get(..end)?; + text = text.get(end..)?; + Some((source, run)) + }) +} + /// The source font's own TrueType/OpenType program, when the document embeds a complete one whose /// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep /// its mapping there after the subsetter dropped its outline. @@ -702,6 +718,25 @@ pub(crate) fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64, words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing) } +#[cfg(test)] +thread_local! { + static SOURCE_CHARACTER_MEASUREMENTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn take_source_character_measurements() -> usize { + SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.replace(0)) +} + +/// An actually encodable source character in the normalized `Tw = 0` drawing state. +pub(super) fn source_character_advance(metrics: &Metrics, ch: char, size: f64, char_spacing: f64) -> Option { + #[cfg(test)] + SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.set(count.get().saturating_add(1))); + let mut utf8 = [0; 4]; + let bytes = metrics.encode(ch.encode_utf8(&mut utf8))?; + Some(metrics.codes(&bytes).iter().map(|(code, _)| metrics.width(*code) * size + char_spacing).sum()) +} + pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result, EditError> { if let Some(bytes) = metrics.encode(text) { return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]); diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 1681bc0016..398396a53b 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,7 +1,8 @@ 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_character_advance, source_encodes, source_fallback_runs, source_family, source_program_completes, source_show, source_space, source_width, + type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{ @@ -9,8 +10,8 @@ use super::lines::{ text_lines, }; use super::{ - Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred, - parse, rewrite_changed_words, rewrite_styled_block, + Dict, Document, EditError, HashMap, HashSet, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, + is_centred, parse, rewrite_changed_words, rewrite_styled_block, }; /// What replacing a line did. @@ -354,6 +355,7 @@ fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: /// The widths of the paragraph's text in the font it will be written in, in user space. struct Advance<'a> { + partial_advances: &'a HashMap>, /// The source font, when its glyphs are reused. source: Option<&'a Metrics>, type3: Option<&'a Type3Fallback>, @@ -380,11 +382,24 @@ impl Advance<'_> { (None, Some(fallback)) => { // Each character's count in `s`, from one pass over `s`. let mut counts: HashMap = HashMap::new(); + let mut source_advance = 0.0; + let mut fallback_count = 0usize; for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); + // The selector is emitted with zero spacing, so it cannot widen the line. + if is_variation_selector(c) { + continue; + } + if let Some(width) = self.partial_advances.get(&c).copied().flatten() { + source_advance += width; + } else { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + fallback_count = fallback_count.saturating_add(1); + } } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + source_advance + + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + + fallback_count as f64 * self.char_spacing } (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, }; @@ -551,6 +566,8 @@ impl Alignment { /// Everything the wrapped lines of one paragraph are written with. struct LineWriter<'a> { + source_name: &'a str, + partial_chars: Option<&'a HashSet>, advance: &'a Advance<'a>, alignment: Alignment, encode: &'a Encoder, @@ -659,7 +676,8 @@ impl LineWriter<'_> { } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(shown); - if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) { + // Partial reuse must reach show_piece: its missing spaces and glyphs belong to the fallback. + if let Some(metrics) = self.source.filter(|m| self.partial_chars.is_none() && m.encode(" ").is_none()) { let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?); } else { @@ -680,24 +698,30 @@ impl LineWriter<'_> { fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { let n = pdfcraft_content::num; - if let Some(metrics) = self.source { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); - return Ok(()); - } - for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if run.name != cursor.shown_font { - ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); - cursor.shown_font = run.name; - } - if let Some(tc) = run.spacing { - // A variation selector's empty glyph: shown at spacing zero, then the paragraph's - // own spacing back, so it adds no advance. - ops.push(Op::new("Tc", vec![n(tc)])); - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); - ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + for (source, run) in source_fallback_runs(piece, self.partial_chars) { + if let Some(metrics) = self.source.filter(|_| self.partial_chars.is_none() || source) { + if cursor.shown_font != self.source_name { + ops.push(Op::new("Tf", vec![Object::name(self.source_name), n(self.size)])); + cursor.shown_font = self.source_name.to_owned(); + } + let indic_piece = if metrics.visual_order { crate::indic::to_visual(run) } else { run.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); } else { - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + for run in (self.encode)(run).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if run.name != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); + cursor.shown_font = run.name; + } + if let Some(tc) = run.spacing { + // A variation selector's empty glyph: shown at spacing zero, then the paragraph's + // own spacing back, so it adds no advance. + ops.push(Op::new("Tc", vec![n(tc)])); + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + } else { + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + } + } } } Ok(()) @@ -891,6 +915,23 @@ pub(crate) fn rewrite_block_from( let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?; let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); let reuse = source.reuse; + // Preserve every drawable source glyph; cache missing-glyph lookups as well. + let visual_indic = source.metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let hebrew = flat.chars().any(crate::bidi::is_hebrew); + let source_advances: HashMap> = source + .metrics + .as_ref() + .filter(|_| !reuse && upright && !hebrew && !visual_indic && style.family.is_none() && style.bold.is_none() && needs_type3(&flat)) + .map(|m| { + let mut advances = HashMap::new(); + for ch in flat.chars() { + advances.entry(ch).or_insert_with(|| source_character_advance(m, ch, size, o_state.char_spacing)); + } + advances + }) + .unwrap_or_default(); + let source_chars: HashSet = source_advances.iter().filter_map(|(&ch, width)| width.map(|_| ch)).collect(); + let partial_reuse = !source_chars.is_empty(); // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); @@ -920,9 +961,15 @@ pub(crate) fn rewrite_block_from( let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; let movement = drawn_movement(emit.ctm, dx, dy)?; let tm = emit_text_matrix(o, emit)?; - let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; + let type3 = if !reuse && needs_type3(&flat) { + let missing: String = flat.chars().filter(|ch| !source_chars.contains(ch)).collect(); + Some(type3_font(doc, &mut fonts_res, &missing, family, bold, max_glyphs)?) + } else { + None + }; let source_metrics = source.metrics.as_ref().filter(|_| reuse); let advance = Advance { + partial_advances: &source_advances, source: source_metrics, type3: type3.as_ref(), family, @@ -968,10 +1015,12 @@ pub(crate) fn rewrite_block_from( None => type3.is_none(), }; let writer = LineWriter { + source_name: &font_name, + partial_chars: partial_reuse.then_some(&source_chars), advance: &advance, alignment: Alignment { width, k, anchored_right, right_edge, rtl }, encode: &font.encode, - source: source_metrics, + source: source.metrics.as_ref().filter(|_| reuse || partial_reuse), align, aligns: &style.aligns, word_spacing_reaches_spaces, diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index f1007be05b..31289fad66 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -68,6 +68,14 @@ this works only when that build input actually contains the full face. This chan add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +With **Keep source fonts**, an upright single-style paragraph can combine its source font +with a fallback even when the replacement has no explicit line break (#2750). Characters +the source can draw keep that font and its advances; only missing characters enter the +generated Type 3 fonts, in batches. Wrapping and alignment measure both faces, including +character spacing and horizontal scaling. Explicit family/weight changes and the existing +turned, right-to-left and visual-order paths retain their selection rules. The styled-path +fallback grouping below remains independent of this single-style path. + **Keep source fonts** retains the existing Thai family, weight and point size. For legacy THSarabunPSK subsets, PdfCraft reconstructs an editor-only Unicode cmap from the PDF's Unicode and CID-to-glyph maps. HarfRust uses the retained Thai positional glyphs, and the @@ -113,7 +121,7 @@ stay in it; Japanese serif/sans and weight choices remain separate. More than 24 use additional fonts; a styled paragraph needing more than 15,360 distinct fallback glyphs is refused with an error. Unsupported glyphs are refused before fallback objects are written. This does not expand glyph coverage, shape mixed contextual runs or remove old unused fonts -when editing again. The separate single-style fallback path is unchanged. +when editing again. The single-style Keep source fonts path above has its own fallback grouping. Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any two characters. A row never begins with closing punctuation, a closing bracket or quote, a small diff --git a/docs/roadmap.md b/docs/roadmap.md index 686928b06c..d39fcd04c7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -245,7 +245,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.
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
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).
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
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.
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
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.
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: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Thai editing (#2964, #3185): supported Form text is editable; legacy Thai retains source fonts and text settings. Cross-family replacement is explicit; unsafe groups and mixed contextual styles remain partial. | +| 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.
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: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Thai editing (#2964, #3185): supported Form text is editable; legacy Thai retains source fonts and text settings. Cross-family replacement is explicit; unsafe groups and mixed contextual styles remain partial. Candidate #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. | | 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.
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.
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
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. | @@ -323,6 +323,7 @@ Estimates unchanged. | 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. | | 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. | | 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. | +| 2026-10-25 | minor | M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged. | | 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier | | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a62f0611d8..dc12290d64 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", "crates/ui-egui/tests/it/ui.rs::edit_a_pdf_from_all_tools_turns_on_editing", "crates/ui-egui/tests/it/editing.rs::another_tool_from_edit_a_pdf_applies_the_open_paragraph"] -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, and so does All tools ▸ Edit a PDF (also from Home, the command palette and the control channel); leaving the tab, or Edit a PDF for another tool from All tools, 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/ui-egui/tests/it/ui.rs::edit_a_pdf_from_all_tools_turns_on_editing", "crates/ui-egui/tests/it/editing.rs::another_tool_from_edit_a_pdf_applies_the_open_paragraph", "crates/edit/src/tests.rs::mixed_script_subset_spaces_remain_extractable_after_saving", "crates/edit/src/tests.rs::mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break", "crates/edit/src/tests.rs::mixed_script_runs_use_source_and_fallback_advances_for_alignment", "crates/edit/src/tests.rs::mixed_script_paragraph_batches_only_missing_glyphs_and_refuses_atomically", "crates/automation/tests/it/automation.rs::mixed_script_paragraph_keeps_source_fonts_through_tools_and_reopen", "crates/edit/src/tests.rs::variation_public_selector_does_not_shift_centered_text_with_character_spacing", "crates/edit/src/tests.rs::mixed_source_subset_without_space_keeps_fallback_runs_after_reopen"] +notes = "Upright single-style Keep source fonts edits without a line break preserve encodable source runs and batch only missing glyphs in Type 3 fallbacks (#2750); wrapping/alignment use both faces and keep source resources. Unencodable spaces at source/fallback boundaries are extractable glyphs, not numeric-only positioning; encodable spaces stay in the source face. 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, and so does All tools ▸ Edit a PDF (also from Home, the command palette and the control channel); leaving the tab, or Edit a PDF for another tool from All tools, 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 and stale-font cleanup are unchanged; the single-style source/fallback path above is handled separately; 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"