Merge PR #3000: fix(edit): batch styled paragraph fallback glyphs

This commit is contained in:
aiongg
2026-10-24 06:21:12 +00:00
9 changed files with 345 additions and 23 deletions
+2
View File
@@ -54,6 +54,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #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. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #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. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
@@ -248,6 +249,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
- **2026-10-18 (M7, #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. Estimates unchanged.
- **2026-10-18 (M7, #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. Estimates unchanged.
- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged.
+37
View File
@@ -2970,6 +2970,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("шалом"));
}
/// #2593: a paragraph whose own font cannot show a character is written from PdfCraft's fallback
/// faces. The Cyrillic the craft-fonts faces lack (Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ) comes from the
/// bundled Inter face, so the edit is not refused even without `CRAFT_FONTS_DIR`, and the words
+208
View File
@@ -1685,6 +1685,214 @@ 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::<String>().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::<usize>(), 300);
assert!(counts.iter().all(|n| *n <= 240));
assert_eq!(text::text_lines(&reopened, 0).unwrap().iter().map(|l| l.text.as_str()).collect::<String>(), 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::<String>(), "見見見ш見ш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::<String>(), 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::<String>(), "alpha ш beta ш");
}
#[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]
+15 -2
View File
@@ -2064,6 +2064,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<Type3Glyph>,
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();
@@ -2079,7 +2092,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
@@ -2717,7 +2730,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::<Vec<_>>());
+69 -18
View File
@@ -2,6 +2,18 @@
use super::*;
// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces.
// 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,
seen: std::collections::HashSet<char>,
glyphs: Vec<Type3Glyph>,
positions: Vec<usize>,
}
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));
@@ -400,6 +412,7 @@ pub(super) fn rewrite_styled_block(
lines: &[TextLine],
text: Option<&str>,
style: &BlockStyle,
max_glyphs: usize,
) -> Result<LineEdit, EditError> {
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 {
@@ -542,7 +555,11 @@ 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<FallbackGroup> = 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 fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES);
let mut full_faces: HashMap<String, String> = HashMap::new();
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = HashMap::new();
for (range, index) in styled_spans {
@@ -605,24 +622,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 >= fallback_budget {
return Err(EditError::Invalid(format!(
"the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font"
)));
}
let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(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('-', ""));
@@ -640,6 +673,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)
};
+1
View File
@@ -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);
}
+9
View File
@@ -44,6 +44,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
+2 -1
View File
@@ -74,6 +74,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #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. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #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. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
@@ -176,7 +177,7 @@ total matches the parity estimate.
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
+2 -2
View File
@@ -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/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.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"]
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; 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/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.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"]
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md"
[[feature]]
id = "edit.text-reflow"