mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
20 files changed
+328
-17
No files matched your search
+10
-10
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user