fix(edit): batch styled fallback glyphs (#2592)

This commit is contained in:
PerchAnton
2026-10-17 11:56:29 +03:00
parent 924c4da4ac
commit 9283d2fb22
8 changed files with 264 additions and 23 deletions
+3
View File
@@ -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.
+37
View File
@@ -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<u8> {
+129
View File
@@ -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::<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_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
@@ -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<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();
@@ -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::<Vec<_>>());
+66 -18
View File
@@ -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<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));
@@ -248,6 +258,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 {
@@ -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<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 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 {
@@ -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)
};
+9
View File
@@ -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
+3 -1
View File
@@ -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.<br>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.<br>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.<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. 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. 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.<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). 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/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"