From f1af1e2e79ba9d2df3cb7debf88098b952dc6fd7 Mon Sep 17 00:00:00 2001 From: Hal Goff Date: Fri, 9 Oct 2026 18:20:47 +0100 Subject: [PATCH] Issue #125: page text the standard fonts can't draw is refused, not written as ? (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add text, headers/footers and watermarks wrote every character outside WinAnsiEncoding as `?` and reported success. They now refuse it with a specific error ("the standard fonts can't draw "中" (U+4E2D) ...") before changing anything, as the maintainer asked on #327. Form fields and text-box comments are unchanged (they keep the value). - fonts: win_ansi_encodable / first_non_win_ansi, defined through win_ansi itself so a fuller table widens them too; the edited-text Type 3 decision uses the same check (same character set as before). - edit: one drawable() check, fed the lines exactly as each tool draws them, so a CR that would become `?` is refused and CRLF in headers is not. - ui: the Add-text editor keeps such text open with an inline warning. Done, a click elsewhere or a double-click on another item don't take or replace it; it counts as unsaved work; Save stops and shows it; a deleted page takes its draft with it. - Arabic (#403): one public check, first_undrawable, follows the drawing: Arabic shaped with the craft-fonts face is drawable, and only what's left to the standard font counts. Without the face, #403 still decides for the Arabic letters (new items refused with what's missing, existing ones stay movable), but nothing else in the item may become `?`. The editor uses the same check, cached per draft. - #403's odd_arabic_text_never_panics: its U+2029/U+0085 input, which was drawn as `?`, now expects a refusal naming the character. - The warning is translated (fr, ru, zh-hans, zh-hant, es, ja, bg, de); the catalogs' SHA-256 entries in ATTRIBUTION.toml are updated. Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 20 ++++---- crates/edit/src/added.rs | 39 ++++++++++++++ crates/edit/src/lib.rs | 26 +++++++++- crates/edit/src/tests.rs | 80 +++++++++++++++++++++++++++-- crates/engine/src/lib.rs | 2 +- crates/fonts/src/lib.rs | 22 ++++++++ crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/content_ui.rs | 66 +++++++++++++++++++++++- crates/ui-egui/src/editing.rs | 20 +++++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/tests/content.rs | 56 ++++++++++++++++++++ 20 files changed, 328 insertions(+), 17 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 07ec3777c..b33510f34 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "163a07f4dcf89deebb8bc44fa05f59a5d85a35ddfe82665dd650522fa9f1fe20" +sha256 = "2d56f8464f3c25efe9737b204db34cef012ea3716f4d78209d9abeaf0ea9393b" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8e2051a5fdf7c52cc4fd5f79bf4be3405c213e247cf80bba714e008ee405ec13" +sha256 = "b0ca648560c45e554447aae8dba37c1c9c636c86b277d00fceeb2a21b474c8f7" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "05ebb95a81e3516fed37329237a6b4f26fd2168fa36cb3e16d4ae218d00e373a" +sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a5b888d86c9866ec8825a91e845cfa881c16e774be16d9be636e7dfb8dfe3b67" +sha256 = "0351382a91d2df51da3d45e25a09af87a081bc88e3496c3512ad89f5d4460a3a" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bdf30a0f69c3aafb6e660f2666534b16d05b053812ae344a4f9a281fe294851e" +sha256 = "57f52e3671a322938a160ac11b73b1f55fb6311140f20d89ae12c9a91a0500d6" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09e063f1c9bb0a5c1485f5e8b4633df3b7f77b00f8f8cbfa4f8c169764e329c2" +sha256 = "e505c00bcc8b8af6aec27abf84bebac7a1925c632130411000a6c57241816193" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c9db94bc77c6d7dd046adae0a81bebe4cafb372a789711c37911f46756f8c9d" +sha256 = "c198f1de831d7f9c853baa6977894ccc34d94e6b36c772c2e53fc5b0e0fe708b" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b09f7de47fed031109de88a028b82ca86d9228d872f7f54e7e113d9df3d00ac2" +sha256 = "9682b2c3c28449dc63637cb5962b309df3ad7db7e8d96bf8bb0f0bc386c9c611" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d0cbe639f664b47ec97dd573ab2642b72a1b80c170f3a756f045647f5afa34cb" +sha256 = "319b032d2c50097ec14e2c7eab92e8696d33e198aeac3585ff46e98c61dec90f" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fde7607a8e14c8d39d2807a21f94b78da56207fcafab7271e41fc10e69f848e7" +sha256 = "f3a03fc466c1f956cbd44a5cad101c53db5ffc80b3b7551a273ac41f43263dd5" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 6537f92df..40a2770e5 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -239,6 +239,34 @@ fn is_arabic(c: char) -> bool { matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC) } +/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn +/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no +/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open. +pub fn first_undrawable(t: &AddedText) -> Option { + // Arabic that nothing can draw: there's no face, or the face lacks it. + if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) { + return Some(c); + } + if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) { + // Drawn line by line in the item's standard font (`\n` splits lines). Without the face + // that is also how `draw` redraws an existing item, so it's checked that way. + return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); + } + // What `arabic_layout` leaves to the standard font, exactly as `draw_arabic` splits it. + wrapped(t).into_iter().find_map(|(line, rtl)| { + let (pieces, _) = arabic_layout(t, &line, rtl).ok()?; + pieces.iter().find_map(|p| match p { + Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s), + Piece::Arabic(_) => None, + }) + }) +} + +/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped). +fn shaped_arabic(c: char) -> bool { + is_arabic(c) && !is_bidi_control(c) +} + /// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn. fn is_bidi_control(c: char) -> bool { matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') @@ -706,6 +734,17 @@ fn validate(c: &Content) -> Result<(), EditError> { if (r[2] - r[0]).abs() < 1.0 { return Err(EditError::Invalid("the text box is too narrow".into())); } + // Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new + // item is refused with what's missing, and one that already holds Arabic stays movable. + // Everything else in it is drawn in the standard font, so it still has to fit. + let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() { + t.text.split('\n').find_map(|line| line.chars().find(|c| !shaped_arabic(*c) && !pdfcraft_fonts::win_ansi_encodable(*c))) + } else { + first_undrawable(t) + }; + if let Some(c) = undrawable { + return Err(crate::undrawable(c)); + } } Content::Image(_) => { if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7950184eb..93df6829f 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -375,6 +375,24 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { const END: &str = "EMC\nQ\n"; +/// Refuses page text the standard-14 fonts (WinAnsiEncoding) can't draw: written anyway, each +/// such character would become a `?` on the page (#125). `lines` must split the text the way the +/// caller draws it, so a `\r` that would be drawn is refused too. +pub(crate) fn drawable<'a>(lines: impl IntoIterator) -> Result<(), EditError> { + match lines.into_iter().find_map(pdfcraft_fonts::first_non_win_ansi) { + Some(c) => Err(undrawable(c)), + None => Ok(()), + } +} + +/// 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) + )) +} + fn text_op(x: f64, y: f64, text: &str) -> Vec { let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes(); v.extend(literal(&win_ansi(text))); @@ -390,6 +408,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, if hf.text.iter().all(|t| t.trim().is_empty()) { return Err(EditError::Invalid("type the header or footer text first".into())); } + // Tokens only add ASCII (Bates prefixes and suffixes are part of the template), so checking + // the templates, split as `expand`'s output is, covers every page. + hf.text.iter().try_for_each(|t| drawable(t.lines()))?; if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) { return Err(EditError::Invalid("invalid font size or margins".into())); } @@ -452,6 +473,9 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac if text.is_empty() && wm.source.is_none() { return Err(EditError::Invalid("type the watermark text first".into())); } + if wm.source.is_none() { + drawable(text.lines())?; + } if !(wm.opacity.is_finite() && wm.rotation.is_finite() && wm.font_size.is_finite() && wm.font_size >= 0.0) { return Err(EditError::Invalid("invalid watermark settings".into())); } @@ -594,7 +618,7 @@ pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect pub mod text; pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, replace_block, replace_line, rewrite_block, text_blocks, text_lines}; pub mod added; -pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, list_added, update_content}; +pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, first_undrawable, list_added, update_content}; #[cfg(test)] mod tests; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 3896b8302..f7756941b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -336,12 +336,22 @@ fn own(doc: &Document, index: usize) -> Vec { fn odd_arabic_text_never_panics() { let mut doc = fixture(); let long = "بسم الله ".repeat(2_000); - for s in - ["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ا\u{2029}ب\rc\u{85}د\n", "ﷺ ١٢٣ 456", long.as_str()] - { + for s in ["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ﷺ ١٢٣ 456", long.as_str()] { let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() })); assert_eq!(r.is_ok(), pdfcraft_fonts::document_arabic_font().is_some(), "{:?}: {r:?}", s.chars().take(12).collect::()); } + // U+2029 and U+0085 (paragraph and line separators) have no WinAnsi code, so the standard font + // would draw them as `?`: refused, by name, rather than written (#125). Laying it out still + // mustn't panic. + let r = add_content( + &mut doc, + 0, + &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ا\u{2029}ب\rc\u{85}د\n".into(), ..AddedText::default() }), + ); + assert!(r.is_err(), "{r:?}"); + if pdfcraft_fonts::document_arabic_font().is_some() { + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("U+2029") || m.contains("U+0085")), "{r:?}"); + } // A character the face lacks is an error that names it, not a box or a crash. if pdfcraft_fonts::document_arabic_font().is_some() && !pdfcraft_fonts::arabic_has('\u{FDFD}') { let r = add_content( @@ -1194,3 +1204,67 @@ fn the_graphics_state_carries_over_between_content_streams() { let l = &text::text_lines(&doc, 0).unwrap()[0]; assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}"); } + +/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`, +/// and leave the document untouched. +#[test] +fn page_text_the_standard_fonts_cant_draw_is_refused() { + let mut doc = fixture(); + let before: Vec> = (0..3).map(|p| streams(&reopen(&doc), p)).collect(); + let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "日本語のテキスト".into(), ..AddedText::default() }; + let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err().to_string(); + assert!(err.contains("\"日\" (U+65E5)"), "{err}"); + let hf = HeaderFooter { + text: ["Ελληνικά".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + ..HeaderFooter::default() + }; + assert!(add_header_footer(&mut doc, &[0, 1], &hf, true, &cx()).unwrap_err().to_string().contains("U+0395")); + let wm = Watermark { text: "机密".into(), ..Watermark::default() }; + assert!(add_watermark(&mut doc, &[0], &wm, true).unwrap_err().to_string().contains("U+673A")); + let after: Vec> = (0..3).map(|p| streams(&reopen(&doc), p)).collect(); + assert_eq!(before, after, "a refused edit changes nothing"); + // Western European text, including what only WinAnsi (not Latin-1) has, still works. + let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Café — 5€ ™".into(), ..AddedText::default() }; + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + // Retyping existing text is refused the same way, and the whole document is left as it was. + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let retyped = AddedText { text: "Café 中".into(), ..text.clone() }; + assert!(update_content(&mut doc, 0, 0, &Content::Text(retyped)).unwrap_err().to_string().contains("U+4E2D")); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "a refused update changes nothing"); + // A carriage return is checked where it would be drawn: added text splits on LF only, so + // CRLF would leave a `?`; headers and watermarks split CRLF into lines, but not a lone CR. + let crlf = AddedText { text: "A\r\nB".into(), ..text }; + assert!(add_content(&mut doc, 0, &Content::Text(crlf)).unwrap_err().to_string().contains("U+000D")); + let hf = |t: &str| HeaderFooter { + text: [t.into(), String::new(), String::new(), String::new(), String::new(), String::new()], + ..HeaderFooter::default() + }; + add_header_footer(&mut doc, &[0], &hf("Left\r\n<>"), true, &cx()).unwrap(); + assert!(add_header_footer(&mut doc, &[0], &hf("A\rB"), true, &cx()).unwrap_err().to_string().contains("U+000D")); + // Arabic is shaped with the craft-fonts Arabic face (#403): drawable when that face is built + // in, refused (never written as `?`) when it isn't. Text left to the standard font still counts. + let arabic = |text: &str| AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: text.into(), ..AddedText::default() }; + // U+061C is in the Arabic block but is a direction mark that's dropped, not shaped: it never + // makes text drawable, nor (with the face) undrawable. + assert!(add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}中"))).is_err()); + if pdfcraft_fonts::arabic_has('م') { + add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap(); + add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}Hello"))).unwrap(); + for text in ["مرحبا 中", "\u{061C}中"] { + let err = add_content(&mut doc, 0, &Content::Text(arabic(text))).unwrap_err().to_string(); + assert!(err.contains("U+4E2D"), "{text:?}: {err}"); + } + } else { + // #403's own refusal says what's missing; the Add-text editor still keeps the draft. + let err = add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap_err().to_string(); + assert!(err.contains("CRAFT_FONTS_DIR"), "{err}"); + assert_eq!(first_undrawable(&arabic("مرحبا Hello")), Some('م')); + // An existing item may still take Arabic (#403 keeps it movable), but nothing else that + // the standard font would write as `?`. + let n = list_added(&doc).iter().filter(|a| a.page == 0).count(); + add_content(&mut doc, 0, &Content::Text(arabic("x"))).unwrap(); + let err = update_content(&mut doc, 0, n, &Content::Text(arabic("ب中"))).unwrap_err().to_string(); + assert!(err.contains("U+4E2D"), "{err}"); + update_content(&mut doc, 0, n, &Content::Text(arabic("ب"))).unwrap(); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7532da50e..9ee20873b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -44,7 +44,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine}; +pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index ecd7630bf..ee94566d0 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -176,6 +176,16 @@ impl UnicodeCMap { } } +/// Whether [`win_ansi`] keeps `c` rather than writing `?` (a tab becomes a space). +pub fn win_ansi_encodable(c: char) -> bool { + c == '\t' || win_ansi_byte(c).is_some() +} + +/// The first character of `s` that [`win_ansi`] can't encode and would write as `?`. +pub fn first_non_win_ansi(s: &str) -> Option { + s.chars().find(|c| !win_ansi_encodable(*c)) +} + /// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped. pub fn literal(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len() + 2); @@ -196,6 +206,18 @@ pub fn literal(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn win_ansi_encodability_matches_the_encoder() { + for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] { + assert!(win_ansi_encodable(c), "{c:?}"); + } + for c in ['中', 'あ', 'Ω', 'č', 'ă', '☃', '😀'] { + assert!(!win_ansi_encodable(c), "{c:?}"); + } + assert_eq!(first_non_win_ansi("Café — 5€"), None); + assert_eq!(first_non_win_ansi("Dvořák"), Some('ř')); + } + #[test] fn widths_wrap_and_encode() { assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a349c843c..f985925e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -429,6 +429,10 @@ impl DocView { self.current = self.current.min(last); self.page_input = (self.current + 1).to_string(); self.selected.retain(|p| *p <= last); + // Text being typed on a page that no longer exists goes with the page. + if self.content.draft.as_ref().is_some_and(|d| d.page > last || self.page_count == 0) { + self.content.draft = None; + } if self.select_anchor.is_some_and(|a| a > last) { self.select_anchor = None; } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index fcebab348..7d0f10f03 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -11,6 +11,37 @@ use crate::canvas::{DocView, PageXform}; use crate::theme::Tokens; use crate::widgets; +/// The colour of the warning under text the standard fonts can't draw. +const UNDRAWABLE: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F); + +/// The first character of a draft that can't be drawn, as the engine decides it (it refuses +/// such text, #125): outside the standard fonts and not shaped with the Arabic face. +pub(crate) fn undrawable(d: &TextDraft, memo: &UndrawableMemo) -> Option { + // Checked as `finish` will commit it: without trailing whitespace. + let t = AddedText { text: d.text.trim_end().to_string(), rect: d.rect, ..d.style.clone() }; + if let Some((key, c)) = memo.borrow().as_ref() + && *key == t + { + return *c; + } + let c = pdfcraft_engine::first_undrawable(&t); + *memo.borrow_mut() = Some((t, c)); + c +} + +/// The last draft [`undrawable`] checked and its answer: the check lays the text out (and shapes +/// Arabic), too slow to repeat every frame for long text, so it reruns only when the text or +/// anything that changes its layout does. +pub(crate) type UndrawableMemo = std::cell::RefCell)>>; + +/// The warning shown for text with `c` in it. +pub(crate) fn undrawable_message(c: char) -> String { + crate::i18n::fmt( + tl!("The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now."), + &[("c", &c.to_string()), ("code", &format!("U+{:04X}", u32::from(c)))], + ) +} + const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// The text being typed or retyped in place. @@ -41,6 +72,24 @@ pub struct ContentView { /// Select the item an add creates (the page's item count before it). pub select_added: Option<(usize, usize)>, grab: Option<(usize, Grab, Pos2)>, + undrawable: UndrawableMemo, +} + +impl ContentView { + /// The draft's first character the standard fonts can't draw. Such a draft is kept open, and + /// counts as unsaved work, until it is corrected or discarded. + pub(crate) fn blocked(&self) -> Option { + self.draft.as_ref().and_then(|d| undrawable(d, &self.undrawable)) + } + + /// Keeps a blocked draft open and focused instead of finishing or replacing it. + pub(crate) fn hold_blocked(&mut self) -> bool { + let blocked = self.blocked().is_some(); + if let Some(d) = self.draft.as_mut().filter(|_| blocked) { + d.focus = true; + } + blocked + } } /// Text style for new text (the panel's Format controls set it). @@ -151,6 +200,10 @@ pub(crate) fn page_input( if adding_text && hit.is_none() { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); if resp.clicked() { + // Text that can't be drawn stays where it is being typed (#125). + if cv.hold_blocked() { + return true; + } // A click elsewhere keeps the text being typed and starts a new box (#74). The click // can reach the page before the editor sees it lose focus, so finish it here. let typed = cv.draft.take().and_then(|d| finish(cv, d, added)); @@ -195,6 +248,9 @@ pub(crate) fn page_input( && let Some((i, a)) = hit && let AddedContent::Text(t) = &a.content { + if cv.hold_blocked() { + return true; + } cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true }); cv.selected = Some((page, i)); consumed = true; @@ -277,6 +333,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad let zoom = xf.rect.width() / xf.pw.max(1.0); let tokens = Tokens::get(ctx); let (mut commit, mut cancel, mut done) = (false, false, false); + let mut undrawable = None; + let memo = &view.content.undrawable; egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { let Some(t) = view.content.draft.as_mut() else { return }; let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); @@ -315,12 +373,18 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); commit = done || resp.lost_focus() && !on_buttons; }); + // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125). + undrawable = self::undrawable(t, memo); + if let Some(c) = undrawable { + ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE)); + } }); if cancel { view.content.draft = None; return (None, false); } - if !commit { + // Text that can't be drawn stays in the editor, with the warning, rather than being lost. + if !commit || undrawable.is_some() { return (None, false); } let edit = view.content.draft.take().and_then(|d| finish(&mut view.content, d, added)); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index fe1736b13..c2dd20e62 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -267,6 +267,21 @@ impl PdfCraftApp { /// brought forward first, so the commit and any field scripts act on that document. pub(crate) fn commit_typing_in(&mut self, id: pdfcraft_engine::DocId) -> bool { let Some(i) = self.views.iter().position(|v| v.id == id) else { return true }; + // Added text the standard fonts can't draw can't be saved: show it rather than drop it. + // Its page is brought into view, so the text, the warning and Discard are on screen. + let blocked = self.views.get_mut(i).and_then(|v| { + let c = v.content.blocked()?; + v.content.hold_blocked(); + if let Some(page) = v.content.draft.as_ref().map(|d| d.page) { + v.go_to_page(page); + } + Some(c) + }); + if let Some(c) = blocked { + self.active = Some(i); + self.notify(crate::content_ui::undrawable_message(c)); + return false; + } if self.active != Some(i) { let typing = self.views.get(i).is_some_and(|v| v.pending_edit.is_some() || v.forms.focus.is_some()); if !typing { @@ -281,7 +296,10 @@ impl PdfCraftApp { pub(crate) fn has_unsaved_work(&self, index: usize) -> bool { let Some(v) = self.views.get(index) else { return false }; let Some(doc) = self.session.get(v.id) else { return false }; - doc.dirty || v.pending_edit.is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) + doc.dirty + || v.pending_edit.is_some() + || v.content.blocked().is_some() + || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) } /// Save the active document. Returns `true` if it was written. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3a9165799..fb9f352be 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2048,3 +2048,4 @@ action wizard Start Старт Clear Recent Files Изчисти последно отваряните файлове Remove from Recent Премахни от последно отваряните Remove {name} from Recent Премахни {name} от последно отваряните + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартните шрифтове не могат да покажат „{c}“ ({code}). Засега като текст могат да се добавят само западноевропейски символи. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b33ae95fe..888c6eef9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2086,3 +2086,4 @@ action wizard Start Starten Clear Recent Files Liste „Zuletzt geöffnet“ leeren Remove from Recent Aus „Zuletzt geöffnet“ entfernen Remove {name} from Recent {name} aus „Zuletzt geöffnet“ entfernen + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Die Standardschriften können „{c}“ ({code}) nicht darstellen. Derzeit lassen sich nur westeuropäische Zeichen als Text hinzufügen. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0b3b79a55..b478e910d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2004,3 +2004,4 @@ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elija una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia. Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e} Go to page… Ir a la página… + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Las fuentes estándar no pueden mostrar «{c}» ({code}). Por ahora solo se pueden añadir como texto caracteres de Europa occidental. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 69f9a0bfc..c027fbaa0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2039,3 +2039,4 @@ Pinned {folder} to Home {folder} épinglé à l'Accueil No recent files Aucun fichier récent Go to page… Aller à la page… + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Les polices standard ne peuvent pas afficher « {c} » ({code}). Pour l'instant, seuls les caractères d'Europe occidentale peuvent être ajoutés comme texte. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ba51c4e8b..b09895169 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2084,3 +2084,4 @@ action wizard Start Indítás H / Space (hold) H / szóköz (nyomva tartva) Rotation: Elforgatás: Line ending Vonalvég + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. A szabványos betűtípusok nem tudják megjeleníteni ezt: „{c}” ({code}). Egyelőre csak nyugat-európai karakterek adhatók hozzá szövegként. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c527d8b7c..103f4cbad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1980,3 +1980,4 @@ Couldn't import the signature image: {e} 署名画像を読み込めませんでした: {e} Go to page… ページへ移動… Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準フォントでは「{c}」({code})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fc1e1d622..bd29398e2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2000,3 +2000,4 @@ Created a PDF from {n} files; left out: {names} PDF создан из файлов: {n}; пропущены: {names} Created a PDF from the first {n} files PDF создан из первых файлов: {n} Go to page… Перейти на страницу… + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартные шрифты не могут отобразить «{c}» ({code}). Пока в виде текста можно добавлять только западноевропейские символы. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 25fc9bda0..623b8a4f7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2106,3 +2106,4 @@ calendar date December грудня H / Space (hold) H / Пробіл (утримувати) Rotation: Поворот: Line ending Закінчення лінії + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартні шрифти не можуть відобразити «{c}» ({code}). Поки що як текст можна додавати лише західноєвропейські символи. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c3a9d7690..90636c358 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2050,3 +2050,4 @@ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 选择 PNG 或 JPEG 图像(最大 4 MiB)。透明度会保留。 Couldn't import the signature image: {e} 无法导入签名图像:{e} Go to page… 转到页面… + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 标准字体无法显示“{c}”({code})。目前只能添加西欧字符作为文本。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 824b4e85d..b6efd14a7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1973,3 +1973,4 @@ Couldn't import the signature image: {e} 無法匯入簽名圖像:{e} Go to page… 轉到頁面… Reopen the files that were open when PdfCraft last closed 重新開啟上次關閉 PdfCraft 時開啟的檔案 + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準字型無法顯示「{c}」({code})。目前只能新增西歐字元作為文字。 diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index eefec1ce2..fb91af5f6 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -155,3 +155,59 @@ fn typing_moving_styling_and_deleting_added_content() { let pdfcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() }; assert!(img.flip_h); } + +/// #125: text the standard fonts can't draw stays in the editor with a warning instead of being +/// written to the page as `?` (or lost). +#[test] +fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let p = at(&h, 40.0, 300.0); + click(&mut h, p); + h.event(egui::Event::Text("Hello 世界".into())); + h.run_steps(2); + h.get_by_label_contains("can't draw “世” (U+4E16)"); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/undrawable-text.png")).unwrap(); + } + h.get_by_label("Done adding text").click(); + h.run_steps(3); + assert!(texts(&h).is_empty(), "nothing was written to the page"); + let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); + assert_eq!(draft.as_deref(), Some("Hello 世界"), "the typed text is kept"); + // A click elsewhere on the page doesn't finish it, or replace it with a new box. + let elsewhere = at(&h, 40.0, 150.0); + click(&mut h, elsewhere); + let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); + assert_eq!(draft.as_deref(), Some("Hello 世界"), "clicking elsewhere keeps the typed text"); + assert!(texts(&h).is_empty()); + // Once the text can be drawn, Done adds it (Done took the focus, so edit the draft directly). + if let Some(d) = h.state_mut().views[0].content.draft.as_mut() { + d.text = "Hello world".into(); + } + h.run_steps(2); + h.get_by_label("Done adding text").click(); + h.run_steps(3); + assert_eq!(texts(&h), ["Hello world"]); +} + +/// Text kept because it can't be drawn goes with its page when the page is deleted, so nothing +/// is left blocking Save out of sight. +#[test] +fn a_kept_draft_goes_with_its_deleted_page() { + let mut h = harness(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 })); + h.run_steps(2); + assert!(h.state_mut().execute("edit.text")); + h.state_mut().views[0].go_to_page(1); + h.run_steps(4); + let p = h.state().views[0].page_screen_rect(1).expect("page 2 on screen").min + egui::vec2(40.0, 40.0); + click(&mut h, p); + h.event(egui::Event::Text("日本語".into())); + h.run_steps(2); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| (d.page, d.text.as_str())), Some((1, "日本語"))); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![1] })); + h.run_steps(2); + assert!(h.state().views[0].content.draft.is_none()); +}