mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 10:46:38 +00:00
M7: offer bundled Thai font when the source face is unavailable
This commit is contained in:
+4
-1
@@ -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. |
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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)>,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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, |_| {});
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user