M7: offer bundled Thai font when the source face is unavailable

This commit is contained in:
dekkeng committed 2026-10-16 16:16:09 +07:00
1 parent ace4321b81
commit fba0a29ac2
10 files changed
+203 -19

No files matched your search

+4 -1
View File
@@ -2,7 +2,7 @@
**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-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion, stacked marks and an explicit bundled Thai font choice; 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)
@@ -160,6 +160,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-16 (M7, explicit Thai fallback):** The document-font menu and headless font inventory offer bundled Noto Sans Thai when the complete source face is unavailable. Replacement is explicit, needs no installed font, and preserves neighbouring source runs; errors identify the missing source family. Estimates unchanged.
- **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.
@@ -499,6 +501,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-16 | minor | M7: the existing document-font menu offers bundled Noto Sans Thai for explicit replacement when the complete source face is unavailable, without font installation; estimates unchanged. |
| 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. |
+2 -2
View File
@@ -1265,7 +1265,7 @@ pub fn tools() -> Vec<ToolDef> {
t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.")
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])),
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources and bundled choices can be selected by text_edit.font_resource. When available, builtin:thai explicitly replaces the selected paragraph/range with bundled Noto Sans Thai without installing a font; preview_only faces cannot be selected.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
@@ -1281,7 +1281,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
"paragraph": { "type": "integer", "minimum": 1 },
"font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." },
"font_resource": { "type": "string", "description": "Paragraphs: source/full page resource or reserved bundled choice from text_fonts (builtin:thai explicitly selects Noto Sans Thai); also accepts text_font_import resources." },
"ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." },
"text": { "type": "string" },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] },
+34
View File
@@ -5841,6 +5841,40 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() {
assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ"));
}
#[test]
fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() {
let dir = workdir("thai-font-choice");
std::fs::write(dir.join("source.pdf"), fixture(1)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
let before = page_text(&mut a, doc);
let fonts = ok(&mut a, "text_fonts", json!({"doc":doc,"page":1}));
let choice = fonts["fonts"].as_array().unwrap().iter().find(|f| f["resource"] == "builtin:thai");
let args = json!({"doc":doc,"page":1,"paragraph":1,"text":"น้ำ กิ่ กี้ ผู้ใช้","font_resource":"builtin:thai"});
if pdfcraft_fonts::document_thai_font().is_none() {
assert!(choice.is_none());
assert!(a.call("text_edit", &args).is_err());
assert_eq!(page_text(&mut a, doc), before);
return;
}
assert_eq!(choice.unwrap()["preview_only"], false);
ok(&mut a, "text_edit_layout", args.clone());
assert_eq!(page_text(&mut a, doc), before);
ok(&mut a, "text_edit", args);
let edited = page_text(&mut a, doc);
assert!(edited[0].contains("น้ำ กิ่ กี้ ผู้ใช้"));
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(page_text(&mut a, doc), before);
ok(&mut a, "edit_redo", json!({"doc":doc}));
ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, reopened), edited);
assert!(a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"น้ำ 😀","font_resource":"builtin:thai"})).is_err());
assert_eq!(page_text(&mut a, reopened), edited);
let image = a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":144})).unwrap();
assert!(image.iter().any(|c| matches!(c, Content::Png { .. })));
}
#[test]
fn existing_thai_matching_face_edit_layout_undo_save_reopen_and_render() {
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_full};
+54
View File
@@ -2813,6 +2813,60 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() {
assert!(arabic_font_names(&doc, 0).is_empty());
}
#[test]
fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() {
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+UnavailableThai"));
f.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
f.set(b"FirstChar".to_vec(), Object::Int(65));
f.set(b"LastChar".to_vec(), Object::Int(65));
f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(600)]));
})
.unwrap();
let before = text::text_lines(&doc, 0).unwrap();
let choice = text::text_editor_fonts(&doc, 0).unwrap().into_iter().find(|f| f.resource == "builtin:thai");
let style = text::BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() };
if pdfcraft_fonts::document_thai_font().is_none() {
assert!(choice.is_none());
assert!(text::rewrite_block(&mut doc, 0, 0, Some("น้ำ"), &style).is_err());
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, before[0].text);
return;
}
assert!(choice.is_some(), "the document-font menu must offer a bundled Thai replacement");
let error = text::rewrite_block(&mut doc, 0, 0, Some("นน"), &text::BlockStyle::default()).unwrap_err().to_string();
assert!(error.contains("UnavailableThai") && error.contains("Noto Sans Thai"), "{error}");
let draft = "น้ำ กิ่ กี้ ผู้ใช้";
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().last().unwrap().text, before[1].text);
assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), before[1].baseline());
text::rewrite_block(&mut doc, 0, 0, Some("น้ำ ปู่"), &text::BlockStyle::default()).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "น้ำ ปู่");
// A selected range uses the same explicit choice; neighbouring source runs keep their face.
let mut selected = text_page("BT /F1 16 Tf 50 700 Td (Ab) Tj ET");
let style = text::BlockStyle {
ranges: vec![text::RangeStyle {
range: 0..1,
resource: Some("builtin:thai".into()),
family: None,
size: None,
color: None,
underline: None,
char_spacing: None,
scale: None,
}],
..Default::default()
};
text::rewrite_block(&mut selected, 0, 0, Some("Ab"), &style).unwrap();
let lines = text::text_lines(&reopen(&selected), 0).unwrap();
assert!(lines.iter().any(|l| l.text == "A" && l.base_font.contains("NotoSansThai")));
assert!(lines.iter().any(|l| l.text == "b" && l.base_font == "Helvetica"));
}
#[test]
fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font() {
let Some(_) = pdfcraft_fonts::document_thai_font() else { return };
+22 -3
View File
@@ -78,8 +78,8 @@ impl TextLine {
}
}
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
/// are never installed, copied into assets, or used as a font for another document.
/// An editor font program: an embedded source face or an approved bundled choice.
/// Source bytes stay in memory, never installed or copied into assets or another document.
#[derive(Clone, Debug)]
pub struct TextEditorFont {
pub resource: String,
@@ -87,6 +87,18 @@ pub struct TextEditorFont {
pub bytes: Vec<u8>,
}
/// Reserved document-font choice for explicitly replacing text with the bundled Thai face.
/// This is a selector, never a font resource written into a PDF.
pub const BUILTIN_THAI_FONT: &str = "builtin:thai";
fn builtin_thai_choice(fonts: &Dict) -> String {
let mut choice = BUILTIN_THAI_FONT.to_owned();
while fonts.contains(choice.as_bytes()) {
choice.push('_');
}
choice
}
pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFont>, EditError> {
let p = page_dict(doc, page)?;
let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
@@ -118,6 +130,13 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
result.push(TextEditorFont { resource: format!("PCApproved{}", full.name()), face: face.clone(), bytes: full.bytes.to_vec() });
}
}
// A missing source family must not make the bundled Thai replacement undiscoverable.
// Offering it does not change the source; callers explicitly select this choice to replace it.
if let Some(thai) = pdfcraft_fonts::document_thai_font()
&& thai.bytes.len() <= budget
{
result.push(TextEditorFont { resource: builtin_thai_choice(&fonts), face: thai.family.into(), bytes: thai.bytes.to_vec() });
}
Ok(result)
}
@@ -1939,7 +1958,7 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str)
pub struct BlockStyle {
/// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style.
pub ranges: Vec<RangeStyle>,
/// Explicit page font resource (from register_text_font or the page font inventory).
/// Explicit page font resource, or a reserved bundled-font choice from the page font inventory.
pub resource: Option<String>,
/// A standard font family, bold, italic.
pub family: Option<(crate::added::Family, bool, bool)>,
+29 -8
View File
@@ -248,7 +248,15 @@ fn shape_source(
return Ok(Some((bytes, shaped, family)));
}
}
Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into()))
let fallback = if text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) && pdfcraft_fonts::document_thai_font().is_some() {
"; or select Noto Sans Thai in the document-font menu to replace it"
} else {
""
};
Err(EditError::Invalid(format!(
"complex text in {} needs its complete source TrueType/OpenType font; use Load font to keep it{fallback}",
family.rsplit('+').next().unwrap_or(&family)
)))
}
/// For each output row (a range of the draft's characters), whether it is exactly the source row
@@ -400,6 +408,7 @@ pub(super) fn rewrite_styled_block(
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let thai_choice = builtin_thai_choice(&fonts);
for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
@@ -449,12 +458,23 @@ pub(super) fn rewrite_styled_block(
for (range, index) in styled_spans {
let source = lines.get(index).unwrap_or(first);
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);
// Automatic completion stays in the same family and keeps usable source programs first.
// A bundled replacement for another family requires an explicit document-font choice.
// Interface fallback fonts never enter a PDF.
let selected_resource = overrides.get(&index).and_then(|r| r.resource.as_deref()).or(style.resource.as_deref());
let use_thai_outlines = if selected_resource == Some(thai_choice.as_str()) {
let face = pdfcraft_fonts::document_thai_font()
.ok_or_else(|| EditError::Invalid("the bundled Thai font is unavailable in this build; load a complete Thai font".into()))?;
if !pdfcraft_fonts::embedding::covers(face.bytes, span) {
return Err(EditError::Invalid(
"Noto Sans Thai cannot show every character in this selection; choose a font that covers the text".into(),
));
}
substituted = Some(face.family.to_owned());
true
} else {
selected_resource.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.
@@ -462,7 +482,8 @@ pub(super) fn rewrite_styled_block(
while metrics.contains_key(&name) {
name.push('_');
}
if let Some(m) = metrics.get(&source.font).cloned() {
if let Some(m) = metrics.get(&source.font).or_else(|| source.origin.state.font.as_ref().and_then(|(name, _)| metrics.get(name))).cloned()
{
metrics.insert(name.clone(), m);
}
outlined_thai.insert(name.clone());
+39
View File
@@ -2600,6 +2600,45 @@ fn switching_straight_to_another_box_applies_the_open_paragraph() {
assert_eq!(ed.text, "Bottom");
}
#[test]
fn document_font_menu_offers_bundled_thai_without_an_installed_source_font() {
let mut h = harness(1, |_| {});
let id = h.state().views[0].id;
if !h.state().session.get(id).unwrap().text_editor_fonts(0).iter().any(|f| f.resource == "builtin:thai") {
return;
}
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).unwrap();
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + 142.0 / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
let text = "น้ำ กิ่ กี้";
h.state_mut().views[0].line_editor.as_mut().unwrap().text = text.into();
h.run_steps(1);
h.get_by_value("Keep source fonts").click();
h.run_steps(2);
h.get_by_label("Noto Sans Thai").click();
h.run_steps(5);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.text_blocks(0)[0].editor_text, text);
assert!(doc.text_lines(0).iter().all(|l| l.base_font.contains("NotoSansThai")));
if let Ok(path) = std::env::var("PDFCRAFT_THAI_FALLBACK_SHOT") {
h.render().unwrap().save(path).unwrap();
}
let saved = h.state_mut().session.save_bytes(id).unwrap();
let mut session = pdfcraft_engine::Session::new();
let reopened = session.open("edited.pdf", None, saved, None).unwrap();
assert_eq!(session.get(reopened).unwrap().text_blocks(0)[0].editor_text, text);
h.state_mut().execute("edit.undo");
h.run_steps(2);
assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].editor_text, "Page 1");
}
#[test]
fn editing_existing_text_in_place() {
let mut h = harness(1, |_| {});
+14 -1
View File
@@ -45,7 +45,7 @@ edits; logical text remains available for copying and searching. The inline edit
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
preserving other Thai families still needs 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
@@ -53,6 +53,19 @@ the font's `mark`/`mkmk` GPOS anchors from the
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.
If the complete source font is unavailable, select **Noto Sans Thai** in the document-font menu
(the menu initially says **Keep source fonts**). This explicitly replaces the paragraph or selected
range with the bundled regular Thai face; it does not require an installed font. The choice is
available only in builds containing the approved Thai face. Text outside that face's coverage is
refused without applying the edit. Keeping a different source family still requires its full font;
the error identifies that family and offers the bundled replacement. Existing mixed contextual-run
restrictions still apply.
Headlessly, `text_fonts` includes the selectable `builtin:thai` choice when available. Pass that
returned choice as `text_edit.font_resource` or `text_edit_layout.font_resource`, including in
selected ranges. It is a reserved selector (suffixed if needed to avoid a source resource name);
use the choice returned by the inventory. The selector is never saved as a PDF font resource.
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 -2
View File
@@ -1,6 +1,6 @@
# 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-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion, stacked marks and an explicit bundled Thai font choice; 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)
@@ -120,7 +120,7 @@ total matches the parity estimate.
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<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 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 since: matching Noto Sans Thai subset completion uses positioned outlines; the inline editor uses complete matching layout tables for stacked marks. Other Thai families can explicitly use bundled Noto Sans Thai without installing a font; preserving their face still needs 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. |
@@ -181,6 +181,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-16 | minor | M7: explicit bundled Thai font choice in the existing document-font menu and headless tools; no installed source font is required for an explicit replacement. Estimates unchanged. |
| 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. |
+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::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"
evidence = ["crates/edit/src/tests.rs::explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font", "crates/automation/tests/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/ui-egui/tests/editing.rs::document_font_menu_offers_bundled_thai_without_an_installed_source_font", "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. An explicit builtin:thai font choice replaces an unavailable source with bundled Noto Sans Thai without installation. Preserving other Thai families requires 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"