mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
fix(edit): batch styled fallback glyphs (#2592)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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<_>>());
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user