From 9283d2fb22ecee9bfe1b5f8a442f601b071b10b8 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:56:29 +0300 Subject: [PATCH 1/4] fix(edit): batch styled fallback glyphs (#2592) --- ROADMAP.md | 3 + crates/automation/tests/automation.rs | 37 ++++++++ crates/edit/src/tests.rs | 129 ++++++++++++++++++++++++++ crates/edit/src/text.rs | 17 +++- crates/edit/src/text_regions.rs | 84 +++++++++++++---- docs/editing-existing-text.md | 9 ++ docs/roadmap.md | 4 +- parity/acrobat-features.toml | 4 +- 8 files changed, 264 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..577370004 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M7 #2592: styled paragraph fallback batches by actual face; estimates unchanged) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -157,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-17 (M7, #2592):** Styled paragraph edits share Type 3 fallback fonts by actual face, up to 240 glyphs each, instead of writing one font per new character. Consecutive equal-font/style glyphs form one run; Latin source fonts, Japanese serif/sans and weight choices, alignment, save/reopen and undo/redo have regressions. A 300-glyph replacement spans two fonts; unsupported glyphs are refused before fallback objects are added. The styled paragraph has a 15,360-distinct-fallback-glyph budget. No wider glyph coverage, mixed contextual shaping, single-style change or stale-font cleanup is claimed. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f3..6d778bb99 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2474,6 +2474,43 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +#[test] +fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping: built without craft-fonts"); + return; + } + let dir = workdir("styled-fallback"); + std::fs::write( + dir.join("mixed.pdf"), + helvetica_page( + "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", + ), + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "mixed.pdf"}))["doc"].as_u64().unwrap(); + let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field."; + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":replacement,"width":520})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.dict(page.dict.get(b"Resources").unwrap()).unwrap(); + let fonts = saved.dict(resources.get(b"Font").unwrap()).unwrap(); + let fallback_count = fonts.iter().filter(|(name, _)| name.starts_with(b"PCJp")).count(); + assert_eq!(fallback_count, 1, "one shared fallback font for the new word"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!(!page_text(&mut a, doc)[0].contains("шалом")); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert!(page_text(&mut a, doc)[0].contains("шалом")); + let reopened = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({"doc":reopened,"page":1})); + let runs = lines["lines"].as_array().unwrap(); + assert!(runs.iter().any(|l| l["text"].as_str().is_some_and(|t| t.starts_with("The quick")) && l["font"] == "Helvetica"), "{lines}"); + assert_eq!(runs.iter().filter(|l| l["text"] == "шалом").count(), 1, "{lines}"); + assert!(page_text(&mut a, reopened)[0].contains("шалом")); +} + /// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle /// piece and its operator starts the last one. fn split_streams_pdf() -> Vec { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df..a8c0b2349 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1153,6 +1153,135 @@ fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts() assert_eq!(glyphs, 300); } +#[test] +fn styled_paragraph_batches_cyrillic_without_changing_source_fonts() { + if without_craft_fonts("styled_paragraph_batches_cyrillic_without_changing_source_fonts") { + return; + } + let mut doc = text_page( + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET", + ); + let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field."; + text::rewrite_block(&mut doc, 0, 0, Some(replacement), &text::BlockStyle { width: Some(520.0), ..Default::default() }).unwrap(); + let doc = reopen(&doc); + assert_eq!(fallback_fonts(&doc).len(), 1, "the five letters must share one font"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.starts_with("The quick brown fox")), "{lines:?}"); + let fallback: Vec<_> = lines.iter().filter(|l| l.font.starts_with("PCJp")).collect(); + assert_eq!(fallback.len(), 1, "one word must be one text run: {fallback:?}"); + assert_eq!(fallback[0].text, "шалом"); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::().replace(' ', ""), replacement.replace([' ', '\n'], "")); +} + +#[test] +fn styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically() { + let replacement = format!("{}\nLatin", covered_japanese_chars(300)); + let mut doc = text_page("BT /F1 12 Tf 14.4 TL 72 700 Td (Original) Tj T* (Latin) Tj ET"); + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + if without_craft_fonts("styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically") { + assert!(text::replace_block(&mut doc, 0, 0, &replacement).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original); + return; + } + text::rewrite_block(&mut doc, 0, 0, Some(&replacement), &text::BlockStyle { width: Some(14000.0), ..Default::default() }).unwrap(); + let reopened = reopen(&doc); + let fonts = fallback_fonts(&reopened); + assert_eq!(fonts.len(), 2, "300 glyphs need two fonts, not an error at glyph 241"); + let counts: Vec<_> = fonts.iter().map(|(_, f)| reopened.dict(f.get(b"CharProcs").unwrap()).unwrap().len()).collect(); + assert_eq!(counts.iter().sum::(), 300); + assert!(counts.iter().all(|n| *n <= 240)); + assert_eq!(text::text_lines(&reopened, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), replacement.replace('\n', "")); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let snapshot = doc.clone(); + let err = text::replace_block(&mut doc, 0, 0, &format!("{replacement}\u{10ffff}")).unwrap_err(); + assert!(err.to_string().contains("U+10FFFF"), "{err}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), before); +} + +#[test] +fn styled_fallback_batches_keep_face_choices_and_range_styles() { + if without_craft_fonts("styled_fallback_batches_keep_face_choices_and_range_styles") { + return; + } + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") { + eprintln!("skipping: no bold Gothic face"); + return; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET"); + let range = |range, family, bold| text::RangeStyle { + range, + family: Some((family, bold, false)), + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let style = text::BlockStyle { + width: Some(520.0), + ranges: vec![ + range(3..6, added::Family::Times, false), + range(6..9, added::Family::Helvetica, true), + range(9..11, added::Family::Times, false), + ], + ..Default::default() + }; + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let err = text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin\u{10ffff}"), &style).unwrap_err(); + assert!(err.to_string().contains("U+10FFFF"), "{err}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "all faces must validate before writing fallback objects"); + text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin"), &style).unwrap(); + let doc = reopen(&doc); + let fonts = fallback_fonts(&doc); + let mut summary: Vec<_> = fonts + .iter() + .map(|(_, f)| { + let face = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font; + let count = doc.dict(f.get(b"CharProcs").unwrap()).unwrap().len(); + (face, count) + }) + .collect(); + summary.sort(); + assert_eq!( + summary, + [ + ("BIZUDMincho-Regular".into(), 1), + ("BIZUDPGothic-Bold".into(), 1), + ("BIZUDPGothic-Regular".into(), 2), + ("ShipporiMincho-Regular".into(), 1) + ] + ); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "見見見ш見шLatin"); +} + +#[test] +fn styled_fallback_shards_keep_text_advances_and_alignment() { + if without_craft_fonts("styled_fallback_shards_keep_text_advances_and_alignment") { + return; + } + let source = "BT /F1 12 Tf 0.5 Tc 5 Tw 80 Tz 14.4 TL 72 700 Td (Original row) Tj T* (Second row) Tj ET"; + let style = + text::BlockStyle { width: Some(520.0), align: Some(added::Align::Center), char_spacing: Some(0.7), scale: Some(80.0), ..Default::default() }; + let mut whole = text_page(source); + let mut split = text_page(source); + let replacement = "Latin шалом ш\nSecond row"; + text::rewrite_block_with_limit(&mut whole, 0, 0, Some(replacement), &style, 240).unwrap(); + text::rewrite_block_with_limit(&mut split, 0, 0, Some(replacement), &style, 2).unwrap(); + let (whole, split) = (reopen(&whole), reopen(&split)); + assert_eq!(fallback_fonts(&whole).len(), 1); + assert_eq!(fallback_fonts(&split).len(), 3); + let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap()); + assert_eq!(one.len(), many.len(), "{one:?} vs {many:?}"); + for (a, b) in one.iter().zip(&many) { + assert_eq!(a.text, b.text); + for (a, b) in a.rect.iter().zip(b.rect) { + assert!((a - b).abs() < 0.002, "sharding must preserve alignment and advances: {a} vs {b}"); + } + } +} + /// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the /// document nor a snapshot taken before the edit, which is what undo restores. #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a3..8f9adfe40 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1507,6 +1507,19 @@ fn type3_font( .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; glyphs.push(Type3Glyph { code, ch, path, width }); } + Ok(write_type3_glyphs(doc, fonts_res, face, hebrew, glyphs, per_font)) +} + +/// Write already validated outlines; callers can prepare every face before adding any objects. +fn write_type3_glyphs( + doc: &mut Document, + fonts_res: &mut Dict, + face: &CraftFont, + hebrew: bool, + glyphs: Vec, + per_font: usize, +) -> Type3Fallback { + let per_font = per_font.clamp(1, MAX_TYPE3_GLYPHS); let mut codes = HashMap::with_capacity(glyphs.len()); let mut widths = Vec::with_capacity(glyphs.len()); let mut names = Vec::new(); @@ -1522,7 +1535,7 @@ fn type3_font( } names.push(add_type3_font(doc, fonts_res, face, hebrew, group)); } - Ok(Type3Fallback { family: face.family, names, widths, codes }) + Type3Fallback { family: face.family, names, widths, codes } } /// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource @@ -2045,7 +2058,7 @@ fn rewrite_block_from( && style.family.is_none() && style.bold.is_none()) { - return rewrite_styled_block(doc, page, &b, &lines, text, style); + return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); } let streams = content_streams(doc, &p.dict); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519..5078b3d67 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -2,6 +2,16 @@ use super::*; +// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. +const MAX_STYLED_FALLBACK_GLYPHS: usize = MAX_TYPE3_GLYPHS * 64; + +struct FallbackGroup { + face: &'static CraftFont, + seen: std::collections::HashSet, + glyphs: Vec, + positions: Vec, +} + fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); @@ -248,6 +258,7 @@ pub(super) fn rewrite_styled_block( lines: &[TextLine], text: Option<&str>, style: &BlockStyle, + max_glyphs: usize, ) -> Result { let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; let draft = match text { @@ -390,7 +401,10 @@ pub(super) fn rewrite_styled_block( // Each tuple is a character, its source style and an encodable resource name. let mut chars = Vec::new(); let mut substituted = None; - let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new(); + let mut fallback_groups: Vec = Vec::new(); + let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); + let mut fallback_glyphs = 0_usize; + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); let mut full_faces: HashMap = HashMap::new(); let mut programs: HashMap>>> = HashMap::new(); for (range, index) in styled_spans { @@ -453,24 +467,40 @@ pub(super) fn rewrite_styled_block( } else { let family = source_family(&source.base_font); if needs_type3(&s) { - let key = (source.base_font.rsplit('+').next().unwrap_or(&source.base_font).to_owned(), ch); - if let Some(name) = fallback_names.get(&key) { - chars.push((ch, index, name.clone())); - continue; + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = fallback_names.get(&key) { + *index + } else { + // Keep the old per-character face choice: a serif Japanese character + // and a Cyrillic character may need different faces even in one style. + // Hebrew uses the separate RTL path before reaching this writer. + let (faces, _) = type3_faces(&s, family, source.bold)?; + let face = + fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; + let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = fallback_groups.len(); + fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + fallback_names.insert(key, index); + index + }; + let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if fallback_glyphs >= MAX_STYLED_FALLBACK_GLYPHS { + return Err(EditError::Invalid(format!( + "the replacement needs more than {MAX_STYLED_FALLBACK_GLYPHS} fallback glyphs; edit a smaller paragraph or load a complete font" + ))); + } + let (path, width) = type3_path(group.face, ch)?; + let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + fallback_glyphs = fallback_glyphs.saturating_add(1); } - if fallback_names.len() >= MAX_TYPE3_GLYPHS { - return Err(EditError::Invalid("the replacement has too many fallback glyphs".into())); - } - let fallback = type3_font(doc, &mut fonts, &s, family, source.bold, MAX_TYPE3_GLYPHS)?; - let name = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - let dict = fonts - .get(name.as_bytes()) - .and_then(|f| doc.resolve(f).as_dict().cloned()) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &dict)); - substituted = Some(fallback.family.to_string()); - fallback_names.insert(key, name.clone()); - name + group.positions.push(chars.len()); + // Filled from the prepared font/code map before measuring or drawing. + String::new() } else { let base = family.base_font(source.bold, source.italic); let name = format!("PCEd{}", base.replace('-', "")); @@ -488,6 +518,24 @@ pub(super) fn rewrite_styled_block( chars.push((ch, index, name)); } } + // All fallback outlines have now succeeded. Batch by actual face, preserving source + // style indices; the existing writer then coalesces adjacent equal-font/style characters. + for group in fallback_groups { + let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); + for name in &fallback.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(fallback.family.to_owned()); + } let size_of = |source: &TextLine, index: usize| { overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) }; diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index e8bb03b9a..ee6e4b0e4 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -37,6 +37,15 @@ 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. +When mixed-style text needs outline fallback, characters using the same actual fallback face +share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same +font and source style are written as one run. Latin characters that the source font can encode +stay in it; Japanese serif/sans and weight choices remain separate. More than 240 missing glyphs +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. + 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 kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f..356634bb4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M7 #2592: styled paragraph fallback batches by actual face; estimates unchanged) + > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -118,7 +120,7 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | 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: 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. 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.
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. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks #2592: styled fallback fonts batch up to 240 glyphs per actual face, retaining source styles; glyph coverage and complex mixed runs remain limited. | | 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). 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6dbc34f35..fece02ef5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,8 +1866,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/editing.rs::editing_existing_text_in_place", "crates/automation/tests/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/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/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/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/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/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/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] -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. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +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/editing.rs::editing_existing_text_in_place", "crates/automation/tests/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/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/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/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/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/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/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "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/automation/tests/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. 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" From 1d477655b5e65bc3f244a430007cd7e60cafa610 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:23:19 +0300 Subject: [PATCH 2/4] test(edit): cover fallback budget and weight cache (#2592) --- ROADMAP.md | 1 + crates/edit/src/tests.rs | 79 +++++++++++++++++++++++++++++++++ crates/edit/src/text_regions.rs | 9 ++-- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 2 +- 5 files changed, 88 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 577370004..c6a99283f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-17 (M7, #3000 review):** Added mutation-checked regressions for the styled fallback budget (64 distinct glyphs accepted, 65 refused before any objects are added when the internal batch size is one) and the same character in regular/bold resources sharing a BaseFont but differing in descriptor weight. Ignoring either guard/key component makes its test fail. The internal budget scales with batch size; production stays at 15,360 glyphs. Estimates unchanged. - **2026-10-17 (M7, #2592):** Styled paragraph edits share Type 3 fallback fonts by actual face, up to 240 glyphs each, instead of writing one font per new character. Consecutive equal-font/style glyphs form one run; Latin source fonts, Japanese serif/sans and weight choices, alignment, save/reopen and undo/redo have regressions. A 300-glyph replacement spans two fonts; unsupported glyphs are refused before fallback objects are added. The styled paragraph has a 15,360-distinct-fallback-glyph budget. No wider glyph coverage, mixed contextual shaping, single-style change or stale-font cleanup is claimed. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a8c0b2349..e48d148f3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1256,6 +1256,85 @@ fn styled_fallback_batches_keep_face_choices_and_range_styles() { assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "見見見ш見шLatin"); } +#[test] +fn styled_fallback_budget_is_enforced_before_objects_are_written() { + let glyphs = covered_japanese_chars(65); + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET"); + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let snapshot = doc.clone(); + let style = text::BlockStyle { width: Some(14000.0), ..Default::default() }; + let over_budget = format!("{glyphs}\nLatin"); + let err = text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&over_budget), &style, 1).unwrap_err().to_string(); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "refusal must add no objects"); + assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), original); + if without_craft_fonts("styled_fallback_budget_is_enforced_before_objects_are_written") { + assert!(err.contains("CRAFT_FONTS_DIR"), "{err}"); + return; + } + assert!(err.contains("more than 64 fallback glyphs"), "{err}"); + // One glyph per font gives a small, real budget of 64 distinct glyphs. At the + // boundary a repeated glyph is free: this is not a limit on text length. + let at_budget: String = glyphs.chars().take(64).chain(glyphs.chars().take(1)).collect(); + text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&format!("{at_budget}\nLatin")), &style, 1).unwrap(); + let doc = reopen(&doc); + assert_eq!(fallback_fonts(&doc).len(), 64); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), format!("{at_budget}Latin")); +} + +#[test] +fn styled_fallback_same_base_font_and_character_keep_both_weights() { + if without_craft_fonts("styled_fallback_same_base_font_and_character_keep_both_weights") { + return; + } + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") { + eprintln!("skipping: no bold Gothic face"); + return; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (alpha ) Tj /F2 12 Tf (beta ) Tj ET"); + let source = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().unwrap().clone(); + for (object, weight) in [(5, 400), (6, 700)] { + let mut font = source.clone(); + // The same BaseFont can have different descriptor weights. Changing a + // RangeStyle family would also rename BaseFont and miss this cache key. + font.set(b"BaseFont".to_vec(), Object::name("NeutralFace")); + let mut descriptor = Dict::new(); + descriptor.set(b"FontName".to_vec(), Object::name("NeutralFace")); + descriptor.set(b"FontWeight".to_vec(), Object::Int(weight)); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + doc.set(pdfcraft_cos::ObjRef::new(object, 0), Object::Dict(font)); + } + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].base_font, lines[1].base_font); + assert_eq!((lines[0].bold, lines[1].bold), (false, true)); + // Unchanged words anchor each inserted copy of the same letter in its own source style. + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let mut inherited = Vec::new(); + for (range, source) in block.edited_spans("alpha ш beta ш") { + for _ in "alpha ш beta ш".get(range).unwrap().chars().filter(|ch| *ch == 'ш') { + inherited.push(lines[source].bold); + } + } + assert_eq!(inherited, [false, true], "the fixture must exercise both weights of the same BaseFont"); + text::replace_block(&mut doc, 0, 0, "alpha ш beta ш").unwrap(); + let doc = reopen(&doc); + let fonts = fallback_fonts(&doc); + let mut names: Vec<_> = fonts.iter().map(|(_, f)| pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font).collect(); + names.sort(); + assert_eq!(names, ["BIZUDPGothic-Bold", "BIZUDPGothic-Regular"]); + let outlines: Vec<_> = fonts + .iter() + .map(|(_, f)| { + let procs = doc.dict(f.get(b"CharProcs").unwrap()).unwrap(); + assert_eq!(procs.len(), 1, "each weight needs just the shared letter"); + stream_bytes(&doc, procs.iter().next().unwrap().1).unwrap() + }) + .collect(); + assert_ne!(outlines[0], outlines[1], "weight must change the outline, not only the descriptor"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "alpha ш beta ш"); +} + #[test] fn styled_fallback_shards_keep_text_advances_and_alignment() { if without_craft_fonts("styled_fallback_shards_keep_text_advances_and_alignment") { diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 5078b3d67..3ebdd0036 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -3,7 +3,9 @@ use super::*; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. -const MAX_STYLED_FALLBACK_GLYPHS: usize = MAX_TYPE3_GLYPHS * 64; +// A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). +// Lowering the internal batch size for tests lowers the budget by the same ratio. +const MAX_STYLED_FALLBACK_BATCHES: usize = 64; struct FallbackGroup { face: &'static CraftFont, @@ -405,6 +407,7 @@ pub(super) fn rewrite_styled_block( let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); let mut fallback_glyphs = 0_usize; let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); let mut full_faces: HashMap = HashMap::new(); let mut programs: HashMap>>> = HashMap::new(); for (range, index) in styled_spans { @@ -487,9 +490,9 @@ pub(super) fn rewrite_styled_block( }; let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; if group.seen.insert(ch) { - if fallback_glyphs >= MAX_STYLED_FALLBACK_GLYPHS { + if fallback_glyphs >= fallback_budget { return Err(EditError::Invalid(format!( - "the replacement needs more than {MAX_STYLED_FALLBACK_GLYPHS} fallback glyphs; edit a smaller paragraph or load a complete font" + "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" ))); } let (path, width) = type3_path(group.face, ch)?; diff --git a/docs/roadmap.md b/docs/roadmap.md index 356634bb4..ef03aca8f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -120,7 +120,7 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | 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: 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. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks #2592: styled fallback fonts batch up to 240 glyphs per actual face, retaining source styles; glyph coverage and complex mixed runs remain limited. | +| 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. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks #2592: styled fallback fonts batch up to 240 glyphs per actual face, retaining source styles; glyph coverage and complex mixed runs remain limited. #3000 review: budget boundary/atomic refusal and same-BaseFont descriptor weights have mutation-checked regressions. | | 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). 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fece02ef5..3ae80e308 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,7 +1866,7 @@ 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/editing.rs::editing_existing_text_in_place", "crates/automation/tests/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/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/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/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/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/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/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "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/automation/tests/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] +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/editing.rs::editing_existing_text_in_place", "crates/automation/tests/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/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/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/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/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/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/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "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/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. 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]] From eb85314b5b25cf4cb9c50575d7cf5f46df058092 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:41:08 +0300 Subject: [PATCH 3/4] M7: keep roadmap status blockquotes valid --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 445ffe118..39931a02a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M7 #2592: styled paragraph fallback batches by actual face; estimates unchanged) - +> > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) From 3be929978329592fff441e5815bdfca5f1bbc2aa Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:45:41 +0300 Subject: [PATCH 4/4] M7 PR #3000: wait for the asynchronous form-save fixture --- crates/ui-egui/tests/it/forms.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd65..64440612c 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -647,6 +647,7 @@ fn typing_and_saving_in_the_same_frame_saves_the_last_keystrokes() { let out = dir.join("saved.pdf"); let mut h = harness(); type_then_save_in_one_frame(&mut h, "name", "Browser", egui::Event::Text(" Form 123".into()), &out); + wait_for_file(&mut h, &out); assert_eq!(saved_value(&out, "name"), ["Browser Form 123"]); let _ = std::fs::remove_dir_all(&dir); }