Issue #125: page text the standard fonts can't draw is refused, not written as ? (#510)

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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-09 13:20:47 -04:00
1 parent 3af93c7585
commit f1af1e2e79
20 files changed
+328 -17

No files matched your search

+10 -10
View File
@@ -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"
+39
View File
@@ -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<char> {
// 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 {
+25 -1
View File
@@ -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<Item = &'a str>) -> 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<u8> {
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;
+77 -3
View File
@@ -336,12 +336,22 @@ fn own(doc: &Document, index: usize) -> Vec<String> {
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::<String>());
}
// 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<Vec<String>> = (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<Vec<String>> = (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<<Bates Number#6#1#ACME-#>>"), 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();
}
}
+1 -1
View File
@@ -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;
+22
View File
@@ -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<char> {
s.chars().find(|c| !win_ansi_encodable(*c))
}
/// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped.
pub fn literal(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len() + 2);
@@ -196,6 +206,18 @@ pub fn literal(bytes: &[u8]) -> Vec<u8> {
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);
+4
View File
@@ -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;
}
+65 -1
View File
@@ -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<char> {
// 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<Option<(AddedText, Option<char>)>>;
/// 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<char> {
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));
+19 -1
View File
@@ -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.
+1
View File
@@ -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}). Засега като текст могат да се добавят само западноевропейски символи.
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -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})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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}). Пока в виде текста можно добавлять только западноевропейские символы.
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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}). Поки що як текст можна додавати лише західноєвропейські символи.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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})。目前只能添加西欧字符作为文本。
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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})。目前只能新增西歐字元作為文字。
Can't render this file because it contains an unexpected character in line 9 and column 16.
+56
View File
@@ -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());
}