diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5a7020ec67..3ab6b19517 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1081,6 +1081,10 @@ impl Automation { } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), }; + // The page font is Helvetica/WinAnsi: refuse what it would silently turn into `?`. + if let Some(c) = pdfcraft_engine::first_undrawable_plain(&text) { + return Err(ToolError::InvalidArgs(pdfcraft_engine::undrawable_message(c))); + } (format!("{title}.pdf"), self.session.create_from_text(&title, &text).map_err(failed)?) } other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6c2e03bb03..7733dd8fde 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -479,7 +479,7 @@ pub fn tools() -> Vec { t( "page_add_text", "Add text to a page", - "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. It stays editable with content_update. Undoable.", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters; others are refused. It stays editable with content_update. Undoable.", ) .cmd("edit.text") .with(schema( @@ -985,7 +985,7 @@ pub fn tools() -> Vec { t( "doc_create", "Create a PDF", - "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly). Save it with doc_save and a path.", + "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.", ) .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index ed15f1d0f0..48182f58ca 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1915,6 +1915,26 @@ fn creating_and_reducing_through_tools() { assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); } +#[test] +fn creating_from_text_refuses_what_the_font_cannot_draw() { + let dir = workdir("create-undrawable"); + std::fs::write(dir.join("kr.txt"), "ABC 제안서 시험").unwrap(); + let mut a = auto(&dir); + let refused = |r: Result, ToolError>| match r { + Err(ToolError::InvalidArgs(m) | ToolError::Failed(m)) => assert!(m.contains("U+C81C"), "{m}"), + other => panic!("expected a refusal, got {other:?}"), + }; + assert!(matches!(a.call("doc_create", &json!({ "from": "text", "text": "ABC 제안서 시험" })), Err(ToolError::InvalidArgs(_)))); + refused(a.call("doc_create", &json!({ "from": "text", "text": "ABC 제안서 시험" }))); + refused(a.call("doc_create", &json!({ "from": "text", "path": "kr.txt" }))); + // WinAnsi characters, line breaks and tabs are still fine. + let d = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Price: 5 € at café\r\n\tnext\u{c}page two" }))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, d).len(), 2); + // #959: page_add_text refuses it too. + let doc = ok(&mut a, "doc_create", json!({ "from": "blank" }))["doc"].as_u64().unwrap(); + refused(a.call("page_add_text", &json!({ "doc": doc, "page": 1, "text": "ABC 제안서 시험", "at": [20, 20] }))); +} + #[test] fn creating_from_multiple_files_through_tools() { let dir = workdir("create-multiple"); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 48e83cf4d1..8acd7b2aa1 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -387,10 +387,18 @@ pub(crate) fn drawable<'a>(lines: impl IntoIterator) -> Result<( /// The refusal for page text with `c`, which nothing here can draw. pub(crate) fn undrawable(c: char) -> EditError { - EditError::Invalid(format!( - "the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", - u32::from(c) - )) + EditError::Invalid(undrawable_message(c)) +} + +/// The wording of the refusal for text with `c`, which the standard fonts can't draw. +pub fn undrawable_message(c: char) -> String { + format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c)) +} + +/// The first character of plain `text` that Helvetica/WinAnsi can't draw (line and page breaks +/// and tabs are layout, not glyphs). +pub fn first_undrawable_plain(text: &str) -> Option { + text.chars().find(|c| !matches!(c, '\r' | '\n' | '\t' | '\u{c}') && !pdfcraft_fonts::win_ansi_encodable(*c)) } fn text_op(x: f64, y: f64, text: &str) -> Vec { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c059a22a1..307cfb7102 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -46,7 +46,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable}; +pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable, first_undrawable_plain, undrawable_message}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout;