M7: fix matching Thai subset edits and stacked marks

This commit is contained in:
dekkeng committed 2026-10-16 15:36:30 +07:00
1 parent f0c9901c39
commit ace4321b81
12 files changed
+474 -24

No files matched your search

+5
View File
@@ -2,6 +2,8 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion and stacked-mark editor layout; estimates unchanged)
> **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)
> **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; 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 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
@@ -157,6 +159,8 @@ 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-16 (M7, Thai editing):** Existing Noto Sans Thai paragraphs use matching shaped outlines when their subset cannot shape the draft, avoiding variable-font embedding errors. The inline editor prefers complete matching layout tables for stacked vowels/tone marks. Regression coverage includes long rows, repeated edits, layout, undo/redo and save/reopen rendering; other Thai families and mixed contextual styles remain limited. 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.
@@ -495,6 +499,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-16 | minor | M7: matching Thai subset completion avoids variable-font embedding; complete matching preview tables preserve stacked marks. Other Thai families and mixed contextual styles remain limited; estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
+54
View File
@@ -5841,6 +5841,60 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() {
assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ"));
}
#[test]
fn existing_thai_matching_face_edit_layout_undo_save_reopen_and_render() {
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_full};
let dir = workdir("thai-existing");
let mut source = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
// Synthetic source encoding lacks Thai; the approved matching family supplies the missing
// contextual glyphs. No fixture/font bytes are checked in.
source
.update_dict(ObjRef::new(3, 0), |f| {
f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"));
})
.unwrap();
std::fs::write(dir.join("thai-source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "thai-source.pdf"}))["doc"].as_u64().unwrap();
let before = page_text(&mut a, doc);
let text = "น้ำ ผู้ใช้ ๑๒๓";
let args = json!({"doc": doc, "page": 1, "paragraph": 1, "text": text, "size": 12});
if pdfcraft_fonts::document_thai_font().is_none() {
assert!(a.call("text_edit", &args).is_err());
assert_eq!(page_text(&mut a, doc), before);
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false);
return;
}
ok(&mut a, "text_edit_layout", args.clone());
assert_eq!(page_text(&mut a, doc), before, "layout is read-only");
ok(&mut a, "text_edit", args);
assert!(page_text(&mut a, doc)[0].contains(text));
ok(&mut a, "edit_undo", json!({"doc": doc}));
assert_eq!(page_text(&mut a, doc), before);
ok(&mut a, "edit_redo", json!({"doc": doc}));
assert!(page_text(&mut a, doc)[0].contains(text));
let render = |a: &mut Automation, doc| {
a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 144}))
.unwrap()
.into_iter()
.find_map(|c| match c {
Content::Png { data, .. } => Some(data),
_ => None,
})
.unwrap()
};
let image = render(&mut a, doc);
ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai-edited.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path": "thai-edited.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(render(&mut a, reopened), image);
assert!(page_text(&mut a, reopened)[0].contains(text));
ok(&mut a, "text_edit", json!({"doc": reopened, "page": 1, "paragraph": 1, "text": "ปู่ น้ำ ๔๕๖"}));
assert!(page_text(&mut a, reopened)[0].contains("ปู่ น้ำ ๔๕๖"));
let unchanged = page_text(&mut a, reopened);
assert!(a.call("text_edit", &json!({"doc": reopened, "page": 1, "paragraph": 1, "text": "น้ำ 😀"})).is_err());
assert_eq!(page_text(&mut a, reopened), unchanged, "an unavailable glyph leaves the edit intact");
}
#[test]
fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
let dir = workdir("hebrew-content");
+52
View File
@@ -737,6 +737,58 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
doc.add(Object::Dict(font))
}
/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline
/// writer instead of trying to embed the matching craft-fonts variable program as a static face.
pub(crate) fn thai_glyph_resources(
doc: &mut Document,
fonts: &mut Dict,
shaped: &pdfcraft_fonts::source_shaping::ShapedRun,
) -> Result<Vec<(String, Vec<u8>)>, EditError> {
if shaped.glyphs.len() > 65_534 {
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
}
let mut outlines = HashMap::new();
let mut clusters = Vec::new();
let mut index = HashMap::new();
let mut indices = Vec::new();
for glyph in &shaped.glyphs {
let id = glyph.id.to_u32();
let key = (ShapedScript::Thai, id);
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) {
entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?);
}
let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| {
let at = clusters.len();
let width = outlines.get(&key).map_or(0.0, |g| g.width);
clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() });
at
});
indices.push(at);
}
// Validate all outlines before adding resources. Type 3 character codes are single bytes;
// shard long rows and return the appropriate resource/code for every positioned glyph.
let mut names = Vec::new();
for chunk in clusters.chunks(GLYPHS_PER_FONT) {
let refs: Vec<_> = chunk.iter().collect();
let font = type3_shaped(doc, &refs, &outlines);
doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?;
let mut name = format!("PCThai{}", font.num);
while fonts.contains(name.as_bytes()) {
name.push('_');
}
fonts.set(name.as_bytes().to_vec(), Object::Ref(font));
names.push(name);
}
indices
.into_iter()
.map(|at| {
let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?;
let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?;
Ok((name.clone(), vec![code]))
})
.collect()
}
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or
/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to
+30
View File
@@ -2813,6 +2813,36 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() {
assert!(arabic_font_names(&doc, 0).is_empty());
}
#[test]
fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font() {
let Some(_) = pdfcraft_fonts::document_thai_font() else { return };
// Synthetic source subset: only one Thai letter is encoded. No binary fixture or font
// is committed; the complete, openly licensed matching face is the optional build input.
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET");
let cmap =
doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> <FF> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar")));
doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| {
f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"));
f.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
})
.unwrap();
let neighbour = text::text_lines(&doc, 0).unwrap()[1].clone();
// More than 240 distinct logical clusters requires several single-byte Type 3 resources.
let long: String =
"กขคงจฉชซญดตถทนบปผฝพฟมยรลวศษสหฬอฮ".chars().flat_map(|c| ["ิ่", "ิ้", "ี่", "ี้", "ึ่", "ึ้", "ื่", "ื้"].map(move |marks| format!("{c}{marks}"))).collect();
let draft = format!("{long} ผู้ใช้ ปู่ ๑๒๓");
let style = text::BlockStyle { width: Some(14_400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
let mut doc = reopen(&doc);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft);
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [50.0, 700.0]);
assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().text, neighbour.text);
assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), neighbour.baseline());
let next = "Hello ผู้ใช้ น้ำ ๔๕๖";
text::rewrite_block(&mut doc, 0, 0, Some(next), &text::BlockStyle::default()).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, next);
}
#[test]
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
for content in [
+5 -5
View File
@@ -48,7 +48,7 @@ pub struct TextLine {
pub decodable: bool,
/// Whether the font requires visual-order Indic text.
pub visual_order: bool,
/// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]).
/// The generated fallback face the font subsets, when the line is in one (see [`same_font`]).
face: Option<String>,
/// The content stream piece the line starts in, and its operators (indices into the page's
/// streams parsed joined).
@@ -506,7 +506,7 @@ struct Ts {
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
fill: Vec<Op>,
font: Option<(Vec<u8>, Rc<Metrics>)>,
/// The Japanese or Hebrew fallback face the font subsets, when the font is one (see [`is_type3_fallback`]).
/// The generated fallback face the font subsets, when the font is one (see [`is_type3_fallback`]).
face: Option<String>,
size: f64,
char_spacing: f64,
@@ -1304,14 +1304,14 @@ const JAPANESE_FONT_NAME: &str = "PCJapanese";
/// The `/Name` of the Type 3 fonts generated for Hebrew text.
const HEBREW_FONT_NAME: &str = "PCHebrew";
/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than
/// Whether `font` is one of those generated fonts (Japanese, Hebrew or Thai): a replacement longer than
/// one of them is split across several, which read back as one line (see [`same_font`]).
fn is_type3_fallback(font: &Dict) -> bool {
font.name(b"Subtype") == Some(b"Type3")
&& font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes())
&& font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == b"PCThai")
}
/// Whether a run shown in `font` (a resource name, and the Japanese or Hebrew fallback face it subsets, if
/// Whether a run shown in `font` (a resource name, and the generated fallback face it subsets, if
/// any) continues `line`: the same font resource, or another subset of the same face.
fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool {
line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face)
+112 -12
View File
@@ -168,13 +168,65 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
blocks
}
/// Whether the approved Thai face completes this source family, whose embedded subset cannot
/// shape the replacement. A usable complete source program always takes precedence.
fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: &str) -> bool {
let Some(face) = pdfcraft_fonts::document_thai_font() else { return false };
span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
&& pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face))
&& pdfcraft_fonts::embedding::covers(face.bytes, span)
&& !fonts.get(source.font.as_bytes()).and_then(|f| doc.dict(f)).and_then(|f| pdfcraft_fonts::pdf::embedded_sfnt(doc, &f)).is_some_and(
|bytes| {
pdfcraft_fonts::embedding::validate(&bytes).is_ok()
&& pdfcraft_fonts::source_shaping::has_thai_layout(&bytes)
&& pdfcraft_fonts::embedding::covers(&bytes, span)
&& span.split('\n').all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
},
)
}
/// Use the same Thai script selection and mark/cluster positioning as Add Text, including
/// rows beginning with Latin or digits (guessing from their first character would select Latin).
fn shape_thai_row(text: &str) -> Result<pdfcraft_fonts::source_shaping::ShapedRun, EditError> {
if text.len() > 262_144 || text.contains(['\n', '\r']) {
return Err(EditError::Invalid("shape one bounded text row at a time".into()));
}
let clusters =
pdfcraft_fonts::shape_thai(text).map_err(|e| EditError::Invalid(format!("the matching Thai font cannot shape this text ({e:?})")))?;
let mut glyphs = Vec::new();
let mut advance = 0.0;
for cluster in clusters {
for (i, (id, [x, y])) in cluster.glyphs.into_iter().enumerate() {
if glyphs.len() >= 65_534 {
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
}
glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph {
id: id.into(),
position: [advance + x, y],
text: if i == 0 { cluster.text.clone() } else { String::new() },
});
}
advance += cluster.advance;
}
if !advance.is_finite() {
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
}
Ok(pdfcraft_fonts::source_shaping::ShapedRun { glyphs, advance })
}
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
fn shape_source(
doc: &Document,
fonts: &Dict,
outlined_thai: &std::collections::HashSet<String>,
name: &str,
text: &str,
) -> Result<Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> {
if outlined_thai.contains(name) {
let face = pdfcraft_fonts::document_thai_font().ok_or_else(|| EditError::Invalid("the matching Thai face is unavailable".into()))?;
let shaped = shape_thai_row(text)?;
return Ok(Some((face.bytes.to_vec(), shaped, face.family.to_owned())));
}
if !pdfcraft_fonts::source_shaping::required(text) {
return Ok(None);
}
@@ -393,15 +445,40 @@ pub(super) fn rewrite_styled_block(
let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new();
let mut full_faces: HashMap<String, String> = HashMap::new();
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = HashMap::new();
let mut outlined_thai = std::collections::HashSet::new();
for (range, index) in styled_spans {
let source = lines.get(index).unwrap_or(first);
for ch in draft.get(range).unwrap_or_default().chars() {
let span = draft.get(range).unwrap_or_default();
// Only complete the same Thai family, only when no face was explicitly selected,
// and keep a usable embedded source program first. Never export interface fallback
// fonts or change unrelated scripts' static-font embedding rules.
let use_thai_outlines = style.resource.is_none()
&& overrides.get(&index).and_then(|r| r.resource.as_ref()).is_none()
&& matching_thai_outline(doc, &fonts, source, span);
let outline_name = if use_thai_outlines {
// A draft-only name prevents this choice from affecting another style span that
// explicitly selects the original resource. It is never saved into the PDF.
let mut name = format!("PCOutlineThai{index}");
while metrics.contains_key(&name) {
name.push('_');
}
if let Some(m) = metrics.get(&source.font).cloned() {
metrics.insert(name.clone(), m);
}
outlined_thai.insert(name.clone());
Some(name)
} else {
None
};
for ch in span.chars() {
if ch == '\n' {
chars.push((ch, index, source.font.clone()));
continue;
}
let s = ch.to_string();
let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) {
let name = if let Some(name) = &outline_name {
name.clone()
} else if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) {
if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) {
return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch))));
}
@@ -538,7 +615,7 @@ pub(super) fn rewrite_styled_block(
}
let segment = items.get(at..end).unwrap_or_default();
let string: String = segment.iter().map(|v| v.0).collect();
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? {
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &outlined_thai, &item.2, &string)? {
let source = lines.get(item.1).unwrap_or(first);
let scale =
overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0));
@@ -654,7 +731,7 @@ pub(super) fn rewrite_styled_block(
}
segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::<f64>());
let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect();
if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? {
if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &outlined_thai, &item.2, &string)? {
shaped_runs.insert((row_index, at), (bytes, shaped, family));
}
at = end;
@@ -662,9 +739,20 @@ pub(super) fn rewrite_styled_block(
}
let mut glyph_resources = HashMap::new();
for (key, (bytes, shaped, family)) in &shaped_runs {
let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect();
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
glyph_resources.insert(*key, resource);
let name = rows.get(key.0).and_then(|row| row.get(key.1)).map(|c| &c.2);
let resources = if name.is_some_and(|n| outlined_thai.contains(n)) {
crate::added::thai_glyph_resources(doc, &mut fonts, shaped)?
} else {
let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect();
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
(0..shaped.glyphs.len())
.map(|i| {
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
Ok((resource.clone(), code.to_be_bytes().to_vec()))
})
.collect::<Result<Vec<_>, EditError>>()?
};
glyph_resources.insert(*key, resources);
}
let n = pdfcraft_content::num;
let mut block_ops = vec![Op::new("q", vec![])];
@@ -748,29 +836,41 @@ pub(super) fn rewrite_styled_block(
tm[5] += ay;
let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0);
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
let resources = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
let mut properties = Dict::new();
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string)));
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
state.font = Some((resource.clone(), size_of(source, item.1)));
// Absolute glyph matrices preserve marks and contextual positions; font-program
// codes are separate from Unicode and ToUnicode/ActualText keep logical text.
let size = size_of(source, item.1);
let mut extra = 0.0;
let thai = outlined_thai.contains(&item.2);
let mut cluster_spacing = 0.0;
for (i, glyph) in shaped.glyphs.iter().enumerate() {
if thai && !glyph.text.is_empty() {
// Thai cluster text belongs to its first glyph. Defer its spacing
// until the next cluster so the vowel/tone glyphs stay on their base.
extra += cluster_spacing;
cluster_spacing =
state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
}
let mut matrix = tm;
let x = (glyph.position[0] * size + extra) * state.scale;
let y = glyph.position[1] * size;
matrix[4] += matrix[0] * x + matrix[2] * y;
matrix[5] += matrix[1] * x + matrix[3] * y;
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
let (resource, code) = resources.get(i).ok_or_else(|| EditError::Invalid("shaped glyph resource is missing".into()))?;
state.font = Some((resource.clone(), size));
block_ops.push(Op::new("BT", vec![]));
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", matrix.map(n).to_vec()));
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))]));
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.clone()))]));
block_ops.push(Op::new("ET", vec![]));
extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
if !thai {
extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
}
}
extra += cluster_spacing;
block_ops.push(Op::new("EMC", vec![]));
segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k;
} else {
+22 -1
View File
@@ -238,6 +238,27 @@ mod tests {
mod thai_tests {
use super::*;
#[test]
fn thai_vowels_and_tones_occupy_separate_vertical_bands() {
if crate::document_thai_font().is_none() {
return;
}
for text in ["กิ่", "กี้", "กึ้", "น้ำ", "น่ำ", "น้ํา", "นํ้า"] {
let mut marks = Vec::new();
for cluster in shape_thai(text).unwrap() {
for (id, [_, y]) in cluster.glyphs {
let g = shaped_glyph(ShapedScript::Thai, id).unwrap();
if g.width == 0.0 && !g.contours.is_empty() {
marks.push([g.bbox[1] + y, g.bbox[3] + y]);
}
}
}
assert_eq!(marks.len(), 2, "{text}: vowel/Sara Am dot and tone");
marks.sort_by(|a, b| a[0].total_cmp(&b[0]));
assert!(marks[0][1] <= marks[1][0] + 0.005, "{text}: lower and upper marks overlap: {marks:?}");
}
}
#[test]
fn thai_clusters_preserve_sara_am_and_stacked_marks() {
if crate::document_thai_font().is_none() {
@@ -245,7 +266,7 @@ mod thai_tests {
assert!(!thai_has('ก'));
return;
}
for text in ["น้ำ", "กิ่", "ผู้ใช้", "ปู่", "เก้า", "สวัสดีภาษาไทย", "๑๒๓", ""]
for text in ["น้ำ", "น้ํา", "นํ้า", "กิ่", "ผู้ใช้", "ปู่", "เก้า", "สวัสดีภาษาไทย", "๑๒๓", ""]
{
let clusters = shape_thai(text).unwrap();
assert_eq!(clusters.iter().map(|c| c.text.as_str()).collect::<String>(), text);
+37 -1
View File
@@ -22,6 +22,13 @@ pub fn required(text: &str) -> bool {
.any(|c| matches!(u32::from(c), 0x0300..=0x036f | 0x0590..=0x0fff | 0x1780..=0x17ff | 0x200c..=0x200f | 0x202a..=0x202e | 0x2066..=0x2069))
}
/// Whether a Thai source program retains its mark-positioning table.
/// Character coverage alone does not show that stacked vowels and tones can be laid out.
/// OpenType Thai requires `mark`/`mkmk` (GPOS); `ccmp` substitution (GSUB) is optional.
pub fn has_thai_layout(bytes: &[u8]) -> bool {
FontRef::new(bytes).is_ok_and(|f| f.gpos().is_ok())
}
pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
if text.len() > 262_144 || text.contains(['\n', '\r']) {
return Err("shape one bounded text row at a time".into());
@@ -30,7 +37,12 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
return Ok(ShapedRun { glyphs: Vec::new(), advance: 0.0 });
}
let face = FontRef::new(bytes).map_err(|e| e.to_string())?;
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0fff | 0x1780..=0x17ff)) && face.gsub().is_err() {
if text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) && face.gpos().is_err() {
return Err("the source subset has no Thai mark-positioning table (GPOS); select its full font".into());
}
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0dff | 0x0e80..=0x0fff | 0x1780..=0x17ff))
&& face.gsub().is_err()
{
return Err("the source subset has no contextual substitution tables; select its full font".into());
}
let data = ShaperData::new(&face);
@@ -74,3 +86,27 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
}
Ok(ShapedRun { glyphs, advance: x })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thai_positioning_does_not_require_optional_gsub() {
let Some(face) = crate::document_thai_font() else { return };
let mut bytes = face.bytes.to_vec();
// Keep the actual GPOS anchors and outlines while removing optional substitutions.
// Table payloads stay at their original offsets; no font fixture is committed.
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
let tables: Vec<_> = bytes[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB").map(|t| t.to_vec()).collect();
bytes[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
for (i, table) in tables.iter().enumerate() {
bytes[12 + i * 16..28 + i * 16].copy_from_slice(table);
}
assert!(FontRef::new(&bytes).unwrap().gsub().is_err());
assert!(has_thai_layout(&bytes));
let shaped = shape(&bytes, "กิ่ น้ำ").unwrap();
assert!(shaped.advance > 0.0);
assert!(shaped.glyphs.iter().all(|g| g.position.iter().all(|p| p.is_finite())));
}
}
+135 -2
View File
@@ -1129,6 +1129,7 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D
}
for program in &programs {
let Some(name) = families.get(&program.resource) else { continue };
let complete_thai = thai_subset_needs_layout(&program.bytes, &program.face);
// Word-created PDFs often split one face into several subsets. Use all of the
// document's matching subsets before falling back for newly inserted characters.
let mut stack = vec![name.clone()];
@@ -1136,11 +1137,24 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D
programs
.iter()
.filter(|p| {
pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face)
&& p.resource != program.resource
p.resource != program.resource
&& (pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face)
|| complete_thai && p.resource.starts_with("PCApproved") && matching_thai_face(&p.face))
})
.filter_map(|p| families.get(&p.resource).cloned()),
);
// A Thai subset can contain every typed character while lacking the layout tables
// that raise tone marks above vowels/Sara Am. It would win ordinary glyph fallback
// and keep those marks on one level. Prefer only its approved complete matching face;
// a complete source with usable tables, and every other family, keeps the existing order.
if complete_thai {
let approved: Vec<_> = programs
.iter()
.filter(|p| p.resource.starts_with("PCApproved") && matching_thai_face(&p.face))
.filter_map(|p| families.get(&p.resource))
.collect();
stack.sort_by_key(|name| !approved.contains(&name));
}
if let Some(fallback) = definitions.families.get(&FontFamily::Proportional) {
stack.extend(fallback.iter().cloned());
}
@@ -1152,6 +1166,28 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D
families
}
fn thai_subset_needs_layout(bytes: &[u8], face: &str) -> bool {
use skrifa::MetadataProvider;
if !matching_thai_face(face) {
return false;
}
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return false };
let (Ok(source), Ok(full)) = (skrifa::FontRef::new(bytes), skrifa::FontRef::new(thai.bytes)) else { return true };
// New marks must be shaped with their base in one face, not picked individually from
// the fallback stack. A complete source keeps priority; an incomplete Thai cmap does not.
!pdfcraft_fonts::source_shaping::has_thai_layout(bytes)
|| !full
.charmap()
.mappings()
.filter(|(cp, _)| matches!(*cp, 0x0e00..=0x0e7f))
.all(|(cp, _)| source.charmap().map(cp).is_some_and(|g| g.to_u32() != 0))
}
fn matching_thai_face(face: &str) -> bool {
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return false };
pdfcraft_fonts::embedding::matching_faces(face).iter().any(|f| std::ptr::eq(*f, thai))
}
fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> egui::text::LayoutJob {
let mut job = egui::text::LayoutJob::default();
// An unchanged PDF row already has an authoritative line break. Do not rewrap it
@@ -1444,6 +1480,103 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
mod tests {
use super::*;
#[test]
fn thai_editor_keeps_complete_sources_but_completes_new_marks_as_one_face() {
use skrifa::MetadataProvider;
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return };
assert!(!thai_subset_needs_layout(thai.bytes, "NotoSansThai-Regular"));
// Keep valid GPOS, but remove mai tho from the Unicode map. Per-character fallback
// would put this newly typed tone in a different shaping run from its source base.
let full = skrifa::FontRef::new(thai.bytes).unwrap();
let mappings: Vec<_> = full.charmap().mappings().filter(|(cp, _)| *cp != 0x0e49).collect();
let mut cmap = vec![0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12];
cmap.extend_from_slice(&12u16.to_be_bytes());
cmap.extend_from_slice(&0u16.to_be_bytes());
cmap.extend_from_slice(&u32::try_from(16 + mappings.len() * 12).unwrap().to_be_bytes());
cmap.extend_from_slice(&0u32.to_be_bytes());
cmap.extend_from_slice(&u32::try_from(mappings.len()).unwrap().to_be_bytes());
for (cp, id) in mappings {
for v in [cp, cp, id.to_u32()] {
cmap.extend_from_slice(&v.to_be_bytes());
}
}
let mut bytes = thai.bytes.to_vec();
while !bytes.len().is_multiple_of(4) {
bytes.push(0);
}
let offset = u32::try_from(bytes.len()).unwrap();
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
let record = bytes[12..12 + count * 16].as_chunks_mut::<16>().0.iter_mut().find(|t| &t[..4] == b"cmap").unwrap();
record[8..12].copy_from_slice(&offset.to_be_bytes());
record[12..16].copy_from_slice(&u32::try_from(cmap.len()).unwrap().to_be_bytes());
bytes.extend(cmap);
assert!(pdfcraft_fonts::source_shaping::has_thai_layout(&bytes));
assert!(!pdfcraft_fonts::embedding::covers(&bytes, "้"));
assert!(thai_subset_needs_layout(&bytes, "NotoSansThai-Regular"));
assert!(!thai_subset_needs_layout(&bytes, "UnrelatedFamily"));
}
#[test]
fn thai_editor_uses_complete_matching_layout_for_stacked_marks() {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return };
// Synthetic subset: keep all outlines/cmap, remove only GSUB/GPOS. Existing characters
// still win ordinary glyph fallback, but the two upper marks no longer stack correctly.
let mut subset = thai.bytes.to_vec();
let count = u16::from_be_bytes([subset[4], subset[5]]) as usize;
let tables: Vec<_> =
subset[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB" && &t[..4] != b"GPOS").map(|t| t.to_vec()).collect();
subset[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
for (i, table) in tables.iter().enumerate() {
subset[12 + i * 16..28 + i * 16].copy_from_slice(table);
}
let (mut app, _) = preview_app();
let mut pdf = Document::open(app.session.get(app.views[0].id).unwrap().bytes.clone()).unwrap();
let page = pdfcraft_model::pages(&pdf).remove(0);
let resource = app.session.get(app.views[0].id).unwrap().text_lines(0)[0].font.clone();
let program = pdf.add(Object::Stream(Stream::from_raw(Dict::new(), subset)));
let descriptor =
Dict::from_iter([(b"FontFile2".to_vec(), Object::Ref(program)), (b"FontName".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"))]);
let font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("TrueType")),
(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")),
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
]);
let mut resources = pdf.dict(page.dict.get(b"Resources").unwrap()).unwrap();
let mut fonts = pdf.dict(resources.get(b"Font").unwrap()).unwrap();
fonts.set(resource.as_bytes().to_vec(), Object::Dict(font));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
pdf.update_dict(page.obj, |p| p.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
app.open_bytes("thai-preview.pdf", None, write_full(&pdf, &SaveOptions::default()).unwrap()).unwrap();
let doc = app.session.get(app.views.last().unwrap().id).unwrap();
let ctx = egui::Context::default();
ctx.begin_pass(Default::default());
let families = install_source_fonts(&ctx, doc, 0);
ctx.end_pass().textures_delta.clear();
ctx.begin_pass(Default::default());
let name = families.get(&resource).unwrap();
let family = FontFamily::Name(name.clone().into());
let definitions = ctx.fonts(|f| f.definitions().clone());
let stack = definitions.families.get(&family).unwrap();
let preferred = stack.first().unwrap();
assert!(definitions.font_data.get(preferred).unwrap().font.as_ref() == thai.bytes, "the editor must shape with the complete matching face");
let text = "กิ่ น้ำ น่ำ กี้ กึ้";
let actual = ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), FontId::new(48.0, family), Color32::BLACK));
// Compare the mark offsets against the complete face alone, independently of the
// source-font stack; this also catches falling back one character at a time.
let expected_family = FontFamily::Name("thai-complete-only".into());
let mut expected_definitions = definitions.clone();
expected_definitions.families.insert(expected_family.clone(), vec![preferred.clone()]);
ctx.set_fonts(expected_definitions);
ctx.end_pass().textures_delta.clear();
ctx.begin_pass(Default::default());
let expected = ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), FontId::new(48.0, expected_family), Color32::BLACK));
let offsets = |galley: &egui::Galley| galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.uv_rect.offset)).collect::<Vec<_>>();
assert_eq!(offsets(&actual), offsets(&expected));
ctx.end_pass().textures_delta.clear();
}
/// An app with one text document open, and an editor on its first paragraph.
fn preview_app() -> (crate::PdfCraftApp, LineEditor) {
let mut app = crate::PdfCraftApp::new();
+16
View File
@@ -37,6 +37,22 @@ 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.
For an existing Noto Sans Thai paragraph whose subset cannot shape the draft, the matching
approved Thai face supplies positioned outlines, using the same Thai shaping as Add Text.
It does not attempt to embed the shared variable font as a static program. Stacked vowels,
tone marks and Sara Am keep their OpenType positions, including after save/reopen and repeated
edits; logical text remains available for copying and searching. The inline editor also prefers
that complete matching face when the subset lacks GPOS mark-positioning tables or Thai characters,
so newly typed marks stay in the same shaping run as their base. A usable complete source program keeps precedence.
This requires the optional Thai build input and applies to matching-family paragraph edits;
other Thai families still need their complete source font, and mixed contextual styles retain
the restrictions below. No font asset or release pin is changed.
Thai shaping reuses the existing [HarfRust](https://github.com/harfbuzz/harfrust) shaper and
the font's `mark`/`mkmk` GPOS anchors from the
[OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai).
Vertical mark offsets are not guessed or hardcoded; GSUB `ccmp` substitution is optional for
Thai and its absence alone no longer refuses a source face with usable positioning.
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
+4 -1
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion and stacked-mark editor layout; 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 since: matching Noto Sans Thai subset completion uses positioned outlines; the inline editor uses complete matching layout tables for stacked marks. Other Thai families still need the complete source font. 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. 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. |
@@ -179,6 +181,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-16 | minor | M7: existing Noto Sans Thai paragraph edits reuse shaped outlines instead of embedding the matching variable font; inline subset previews prefer the complete matching layout tables. Other Thai families and mixed contextual runs remain limited; estimates unchanged. |
| 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
+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::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/automation/tests/automation.rs::existing_thai_matching_face_edit_layout_undo_save_reopen_and_render", "crates/ui-egui/src/edit_text_ui.rs::thai_editor_uses_complete_matching_layout_for_stacked_marks", "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 = "Matching Noto Sans Thai paragraph subsets use shaped outlines without embedding the variable face; editor previews prefer complete matching Thai layout tables for stacked marks. Other Thai families require their full source face; mixed contextual styles remain limited. 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"
[[feature]]
id = "edit.text-reflow"