diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 80866b35..c8c9b2a3 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "23a9402d3ebbcff69aed133706e6e428a804ae06a7dba4923892cca5c98e92d1" +sha256 = "bf6e968d781894c6641df2b52224a2b273b33ab4110b05d5b92ea67d80753d19" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,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 = "1863437c886142b2bccce3f44a538ae80d0ceea81074b84dfdfe8d9a4a4227ab" +sha256 = "eebd627422ee48d52e35655c41e8a587e4d86c38fcb56d5465d8446d5971dabf" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,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 = "6c8604d5cd71412c6a673b75001c7ef629727839eca1449147c7d6ec4bf0be99" +sha256 = "2f11d8535473dfdefc0aea011d3079eaada0286a58982a7084e20bda8477de54" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5ae2e315a9b99b4c0dfaf7be019c4d430ace17b4fbbfea5a2779b3e972846a21" +sha256 = "7d3f5d26df0596f96f5ff0e7cccabbdf1f0c2278062cfa0a1046b364b04c7ba7" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8b9e22fa25339e4aedb0542b8bf6f68dfd97bc3634105199e1951475e30eed53" +sha256 = "d1a7742f43f01253d40f339c525ad0e2c15c18be1a60d5cc4c8a7bebbebcf3de" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,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 = "6d81592677f2b4eb3b97ba5810de9752da969545d91897e27033f37310696ffc" +sha256 = "819ab9c8426bf3d1fc2c7021f4b5a19d41f8b8cc65301da625ecb12a2297fc8c" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,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 = "fb9c91a98c6975bc24249e8ae750fd46b9245ac5d2d7381f1e21319b9adbcaac" +sha256 = "258518681eb774c7b90335f1d2089bf1bb37831084cf8121f3bbea75931e3f6b" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,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 = "f36aefae1a1ba6d576a4647fbfdd65b9b5086bfb266ea260e513244adc7af05a" +sha256 = "404766adf3b492801346390914884c1b75d97e2b4e9e3dcd9239fc8062e7332c" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "14884c08a550600d310a794ceb0b2bb864987a074178ee49b657578b66625bef" +sha256 = "b5ad0282d7a1ddb4e818c7f8a76c2182bd3c9343b8a555b0dae7b78b831b594a" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "be960bdf9b6eeac2d95db91325076ee79c9b2efdcc4e459c7400e7ddb05fab3a" +sha256 = "71dfed7bedeff0755c7377b7244ccfe57d9958cc803202c52b04da58bce9b1dd" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,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 = "bada9b6f0b3775e4dab1213d9759070f7551d8a6142de10159268c88a04d3f0c" +sha256 = "5d0bfe97ef2ef2d1577c538793fc3267483f5c9fe9de4df97924d04cf18f7292" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,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 = "efb75d8575df9e1dd4610602f20545b3a14a3a0814060efc27eb7cab591ccccb" +sha256 = "b69b54cbeee8c9a876db5ed7d49e056e794742bd0a001a2c6383d464e24c31e4" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,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 = "ca305b188abc8d9c261901dde10f7cd8518520983fdf88d2358374b352ad3d5a" +sha256 = "db5ba8dc27fb272e2a76e017c5bb38bad200bb7299b9cce40d94d837704507fb" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "77afe7d7fa215c99e8e627a758178d79c51e08e33564e00b97b8840aeb91a9dd" +sha256 = "3c94bc6bb9950d8172c3f43c32d09496a382eea32717fcee3855d338881f35fa" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,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 = "f004b06f4e7eb8f4a99e22e1abfd97c95795b2215080e4008574522e54eda74c" +sha256 = "10ff47a04951e99f902efeb9832d12b78f5f632d54503843283adfd2ad9e8186" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,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 = "cd9ecaf661533cbd3b055cfd3994602f6739c83061d1e225ba43ee6f229068cf" +sha256 = "983c0cc2efa98d2d3f0449f45c7f7ebdd39c93578b77a07631ee5613b0989a98" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a8a66390b2d80b4708c4f854318b923eef7f0f5932439fb5ef25f2651980f24e" +sha256 = "439ad5e6ae9c3e2484b1a5f9f11868ff33ddb150308d32d8c32691f2817c15a6" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5688538845a80c154feabd096badbf097485dea8a7402fede4ef7d71466e9d6a" +sha256 = "b4e90e5929a95a8c85cf88099003cb772e45591759e192f89026cd883a8149c7" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8590f5acce1da70d6a3ce58de073bf4f86815651ff74a59625980c6c52e051b7" +sha256 = "be33bd6c0f2952eafb04d22cbc71f4e43594bceecc0acaca9cfd2b602100c29c" diff --git a/ROADMAP.md b/ROADMAP.md index bfe8ac0d..923639fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -161,6 +161,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M14, #307, Plan 2):** Forms now return terminal fields and the page-widget adoption count from one enumeration, and engine open reuses that result while retaining XFA script-button marking. The 12,000-page/no-form PDF (1,442,052 bytes) and 2,500-orphan-widget PDF (673,432 bytes) produce byte-identical `doc_open`/`doc_info` outputs against upstream `33c14e07cfd742a82bf03baa6b103c01008fa68c`, including the orphan repair note. After one warm-up per binary and three interleaved base/candidate rounds, median open times were 0.16/0.15 s and 0.08/0.07 s, with median peak RSS 62.69/59.98 MiB and 33.28/32.20 MiB. Workspace tests, Clippy, formatting, focused forms/engine/organize tests, and the WASM check pass. A behavior-preserving local binding in `organize` also lets the workspace Clippy gate pass. No estimate change. - **2026-10-11 (M14, Traditional Chinese semantics):** Corrected accessibility pass/fail labels, the inverted script-accessibility rule, Taiwan row/column terminology and form choice/read-only labels; added the missing table-row explanation. Catalog regressions cover the rule metadata and preserve field names. Milestone and effort estimates unchanged. + +- **2026-10-11 (M14, no silent `?`):** Text PdfCraft's fonts can't draw (Chinese, Greek, Czech's ř in Helvetica) is never written into a PDF as `?` without a word. Form values and button captions, text boxes, callouts, Fill & Sign text, measurement labels and dynamic stamps' by-lines are kept as typed, as Acrobat keeps them: the appearance draws `?` there, the annotation is marked (`PCUndrawn`), and the edit says so (the interface shows a translated notice; tools return `warnings`). A form also sets `/NeedAppearances`, so other viewers redraw the field from its value (not in a PDF/A document, which forbids it); a restyle doesn't replace a comment another program drew with those characters. Where `?` would be permanent, the edit is refused with the character and what to do instead: flattening what PdfCraft drew with `?`, redaction overlay text (also checked before anything is redacted), typed signatures through `fill_sign_add`, visible signature details, and new documents from text (with the line). Reports PdfCraft writes from documents (comment summaries, compare reports) write such characters as `[U+XXXX]` and say so; a dynamic XFA form's own text is laid out and reported. One end-to-end test drives every path through the tools. Not yet: drawing those characters (embedding a font that has them). Overall estimate unchanged. - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/crates/annot/README.md b/crates/annot/README.md index 4d0fa7c9..67285f8d 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -10,7 +10,14 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, - **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`). Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with - line breaking by approximate Helvetica proportions (no font program is bundled). + line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't + draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line; + `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with + the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and + flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction + mark's overlay text, which becomes page content when applied; and a restyle or resize of a + comment another program drew with a font that has those characters, whose appearance is kept + rather than redrawn with `?` (new text for it is taken). - **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status" does), change text, move, resize, restyle, delete (with pop-up and replies). diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index fbd77b3e..8aeaa1b3 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -11,8 +11,8 @@ //! Helvetica's proportions by character class (no font program or metrics file is bundled). use pdfcraft_cos::{Dict, Object, PdfString, Stream}; +use pdfcraft_fonts::{first_undrawable, literal, win_ansi}; pub use pdfcraft_fonts::{helvetica_width as text_width, wrap}; -use pdfcraft_fonts::{literal, win_ansi}; use crate::{NOTE_SIZE, Rgb, n}; @@ -264,7 +264,27 @@ pub(crate) fn picture_turn(d: &Dict) -> i64 { d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 } +/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that +/// they can't, which would come out as `?`: what it is (for the message) and the character. The +/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's +/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of +/// it can be drawn. +pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { + let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); + let (what, text) = match d.name(b"Subtype")? { + b"FreeText" => ("the comment's text", text(b"Contents")?), + b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), + b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), + // Drawn in Helvetica when the redaction is applied: refused when it is marked. + b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?), + _ => return None, + }; + Some((what, first_undrawable(&text)?)) +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. +/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and +/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 00e8776b..642969c5 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -34,6 +34,11 @@ pub enum AnnotError { Invalid(String), #[error("{0} comments can't be restyled yet (their appearance can't be regenerated)")] Unsupported(String), + /// Text the standard fonts can't draw where `?` would be permanent or replace a good drawing: + /// a redaction's overlay text, or an edit that would redraw a comment another program drew + /// with a font that has those characters. The message names the character. + #[error("{0}")] + Undrawable(String), #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -1202,8 +1207,16 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R // Survives a later subject edit, so flatten-on-save still finds the mark. d.set(b"PCFillSign".to_vec(), Object::Bool(true)); } + // A redaction's overlay text becomes page content when it is applied, where `?` would be there + // for good: refused before anything is added. A comment's own text is stored as typed and + // drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`). + if d.name(b"Subtype") == Some(b"Redact") + && let Some((what, c)) = appearance::undrawable_text(&d) + { + return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); + } let r = doc.add(Object::Dict(d.clone())); - set_appearance(doc, r)?; + draw_appearance(doc, r)?; let mut list = annots(doc, page); let index = list.len(); list.push(Object::Ref(r)); @@ -1250,9 +1263,33 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio Ok(()) } -/// (Re)generate `/AP /N` for the annotation `r` from its dictionary. -/// Regenerate an annotation's normal appearance from its dictionary. +/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its +/// text (a restyle, a new size or colour). +/// +/// A comment whose text the standard fonts can't draw, with an appearance another program drew +/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in +/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` +/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { + let d = annot_dict(doc, r); + let drawn_elsewhere = + d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(&d).is_none(); + if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(&d) { + return Err(AnnotError::Undrawable(format!( + "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", + c.escape_debug(), + u32::from(c) + ))); + } + draw_appearance(doc, r) +} + +/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard +/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, +/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that +/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font +/// can't show, and for display-only copies. +fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; @@ -1280,6 +1317,8 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option { /// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { + // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. + let missing = appearance::undrawable_text(d).map(|(_, c)| c); let ap = doc.add(Object::Stream(stream)); // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover // appearances would show the previous look on press or hover, so they go with the old /N. @@ -1290,6 +1329,7 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); })?; Ok(()) } @@ -1489,10 +1529,12 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m })?; if free_text { // The box grows and shrinks with the text instead of clipping it. A locked box keeps its - // text editable (as in Acrobat), so the re-fit ignores the lock. + // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even + // where the standard fonts can't draw some of it (marked and reported, see + // `draw_appearance`). match fitted_box(doc, r, text) { - Some(rect) => apply_text_box(doc, r, rect, meta)?, - None => set_appearance(doc, r)?, + Some(rect) => apply_text_box(doc, r, rect, meta, true)?, + None => draw_appearance(doc, r)?, } } Ok(()) @@ -1700,7 +1742,7 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); } - apply_text_box(doc, r, rect, meta) + apply_text_box(doc, r, rect, meta, false) } /// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half @@ -1750,8 +1792,9 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res } /// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's -/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn. -fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { +/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn +/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). +fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { let rect = normalize(rect); if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); @@ -1782,7 +1825,7 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> } touch(d, meta); })?; - set_appearance(doc, r) + if new_text { draw_appearance(doc, r) } else { set_appearance(doc, r) } } fn border_width_of(d: &Dict) -> f64 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 589a6abb..c00ed2be 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1159,6 +1159,93 @@ fn drawings_resize_by_scaling_their_strokes() { assert!(matches!(set_rect(&mut doc, 0, i, [100.0, 400.0, 101.0, 401.0], &meta("")), Err(AnnotError::Invalid(_)))); } +/// Comment text the standard fonts can't draw is kept, as Acrobat keeps it: a text box, Fill & Sign +/// text or a dynamic stamp's by-line is added with its text as typed, its appearance draws `?` for +/// those characters, and the comment is marked with the first one (cleared once all of it can be +/// drawn). A comment another program drew with a font that has those characters keeps that +/// appearance: a restyle is refused rather than replace it with `?`, while new text is taken. +#[test] +fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { + let mut doc = fixture(); + let mark = |doc: &Document, i: usize| pdfcraft_fonts::undrawn_mark(&list(doc, 0)[i]); + let mut boxed = new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 }); + boxed.contents = "签名 Ada".into(); + let t = add_annotation(&mut doc, &boxed, &meta("t")).unwrap(); + assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名 Ada"); + assert!(ap_content(&doc, &list(&doc, 0)[t]).contains("(?? Ada)"), "{}", ap_content(&doc, &list(&doc, 0)[t])); + assert_eq!(mark(&doc, t), Some('签')); + let typed = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }; + let f = add_annotation( + &mut doc, + &NewAnnotation { page: 0, style: Style::default_for(&typed), shape: typed, contents: "张三".into(), author: "Ada".into() }, + &meta("x"), + ) + .unwrap(); + assert_eq!(mark(&doc, f), Some('张')); + let stamp = + Shape::Stamp { rect: [100.0, 100.0, 260.0, 142.0], stamp: StampKind::DynApproved, by: Some("By 张三 at 2:14 pm, Oct 02, 2026".into()) }; + let s = add_annotation( + &mut doc, + &NewAnnotation { page: 0, style: Style::default_for(&stamp), shape: stamp, contents: String::new(), author: "张三".into() }, + &meta("s"), + ) + .unwrap(); + assert_eq!(mark(&doc, s), Some('张')); + // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. + set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + assert_eq!(mark(&doc, t), Some('签')); + set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); + assert_eq!(mark(&doc, t), None); + set_contents(&mut doc, 0, t, "签名", &meta("")).unwrap(); + assert_eq!((text(&list(&doc, 0)[t], b"Contents").as_str(), mark(&doc, t)), ("签名", Some('签'))); + // A text box another program drew (with a font that has 欢): a restyle keeps that appearance. + let mut foreign = Dict::new(); + foreign.set(b"Type".to_vec(), Object::name("Annot")); + foreign.set(b"Subtype".to_vec(), Object::name("FreeText")); + foreign.set(b"Rect".to_vec(), Object::Array([100.0, 400.0, 300.0, 430.0].iter().map(|v| Object::Real(*v)).collect())); + foreign.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec())); + foreign.set(b"Contents".to_vec(), PdfString::text("欢迎")); + let mut drawn = Dict::new(); + drawn.set(b"Type".to_vec(), Object::name("XObject")); + drawn.set(b"Subtype".to_vec(), Object::name("Form")); + drawn.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 30.0].iter().map(|v| Object::Real(*v)).collect())); + let drawn = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(drawn, b"BT /F1 12 Tf (\x01\x02) Tj ET".to_vec()))); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(drawn)); + foreign.set(b"AP".to_vec(), Object::Dict(ap)); + let r = doc.add(Object::Dict(foreign)); + let page = page_ref(&doc, 0).unwrap(); + let mut on_page = annots(&doc, page); + on_page.push(Object::Ref(r)); + set_annots(&mut doc, page, on_page).unwrap(); + let k = list(&doc, 0).len() - 1; + let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap_err(); + assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); + assert_eq!(list(&doc, 0)[k].get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")), Some(drawn), "its appearance is kept"); + // New text from the user is taken (and marked), as for any comment. + set_contents(&mut doc, 0, k, "欢迎光临", &meta("")).unwrap(); + assert_eq!(mark(&doc, k), Some('欢')); + // One without an appearance is shown in the display copy only; drawing it marks it. + let mut bare = Dict::new(); + bare.set(b"Type".to_vec(), Object::name("Annot")); + bare.set(b"Subtype".to_vec(), Object::name("FreeText")); + bare.set(b"Rect".to_vec(), Object::Array([100.0, 300.0, 300.0, 330.0].iter().map(|v| Object::Real(*v)).collect())); + bare.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec())); + bare.set(b"Contents".to_vec(), PdfString::text("欢迎")); + let b = doc.add(Object::Dict(bare)); + let mut on_page = annots(&doc, page); + on_page.push(Object::Ref(b)); + set_annots(&mut doc, page, on_page).unwrap(); + let shown = with_missing_appearances(&doc).expect("a display copy"); + assert!(shown.get(b).as_dict().unwrap().contains(b"AP"), "displayed anyway"); + assert!(!doc.get(b).as_dict().unwrap().contains(b"AP"), "the document itself is unchanged"); + set_appearance(&mut doc, b).unwrap(); + assert_eq!(doc.get(b).as_dict().and_then(pdfcraft_fonts::undrawn_mark), Some('欢')); + // Kept through a save. + let doc = reopen(&doc); + assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名"); +} + /// Content edits rebuild the display copy on every commit but leave annotations alone: the /// cached appearance is drawn once and reused after that, byte-identical. #[test] diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 4aac6ac5..29c11a1c 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -501,6 +501,14 @@ impl Automation { // A typed signature or initials in the script font, left edge at `at`, upright as displayed. kind @ ("signature" | "initials") => { let t = a.str("text")?; + // Nobody sees a preview here: refuse what the script font would leave out. + if let Some(c) = pdfcraft_engine::script_missing(t) { + return Err(ToolError::InvalidArgs(format!( + "the signature script font can't draw \"{}\" (U+{:04X}); type the {kind} in Latin, Cyrillic or Greek letters, or pass `path` to a PNG or JPEG image of it instead of `text`", + c.escape_debug(), + u32::from(c) + ))); + } let h = if kind == "initials" { 24.0 } else { 32.0 }; let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation)) .ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index ab5d9ee1..1d76b40c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -969,7 +969,13 @@ impl Automation { fn apply(&mut self, a: &Args, edit: Edit) -> Result { let id = self.doc(a)?.id; self.session.apply(id, edit).map_err(failed)?; - Ok(summary(self.doc(a)?)) + let doc = self.doc(a)?; + let mut out = summary(doc); + // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. + if !doc.edit_notices.is_empty() { + out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); + } + Ok(out) } fn marks(&mut self, tool: &str, a: &Args) -> Result { @@ -1136,11 +1142,13 @@ impl Automation { } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), }; - // The page font is Helvetica/WinAnsi: refuse what it would silently turn into `?`. - if let Some(c) = pdfcraft_engine::first_undrawable_plain(&text) { - return Err(ToolError::InvalidArgs(pdfcraft_engine::undrawable_message(c))); - } - (format!("{title}.pdf"), self.session.create_from_text(&title, &text).map_err(failed)?) + // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with + // its line) by `pdfcraft_create::from_text`, as for every other caller. + let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { + pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), + e => failed(e), + })?; + (format!("{title}.pdf"), bytes) } other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), }; diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 7701f612..2d0281fc 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -214,6 +214,14 @@ impl Automation { } if let Some(text) = o.get("text") { let text = text.as_str().ok_or_else(|| bad("graphic.text must be a string"))?; + // Nobody sees a preview here: refuse what the script font and its fallback would leave out. + if let Some(c) = pdfcraft_engine::script_missing(text) { + return Err(bad(format!( + "the signature script font can't draw \"{}\" (U+{:04X}) in graphic.text; type it in Latin, Cyrillic or Greek letters, or pass graphic.image instead", + c.escape_debug(), + u32::from(c) + ))); + } return pdfcraft_engine::typed_signature_graphic(text).ok_or_else(|| { bad(format!( "graphic.text {text:?} has nothing the script font can draw (at most {} characters)", diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7e5e0267..c712ab38 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -303,7 +303,7 @@ pub fn tools() -> Vec { t( "form_fill", "Fill in form fields", - "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values.", + "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", ) .with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])), t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") @@ -600,7 +600,7 @@ pub fn tools() -> Vec { "Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \ note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \ rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \ - polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable.", + polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.", ) .with(schema( json!({ @@ -995,7 +995,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1834d187..593350ce 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2921,6 +2921,58 @@ fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_)))); } +/// Text the standard fonts can't draw is never written as `?` silently, through the tools. A form +/// value, a button caption, a text box and Fill & Sign text are kept as typed (as Acrobat keeps +/// them) and the result's `warnings` names the character; a form also asks other viewers to +/// redraw it. Where the `?` would be permanent the tool refuses: a typed signature, a redaction's +/// overlay text, a new document from text, and flattening what PdfCraft drew with `?`. A comment +/// summary that quotes such text writes it as [U+XXXX]. +#[test] +fn text_the_standard_fonts_cannot_draw_is_never_silent_through_tools() { + let dir = workdir("undrawable"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let refused = |a: &mut Automation, tool: &str, args: Value, ch: &str| match a.call(tool, &args) { + Err(ToolError::Failed(e) | ToolError::InvalidArgs(e)) => assert!(e.contains(ch), "{tool}: {e}"), + other => panic!("{tool}: expected a refusal, got {other:?}"), + }; + let warned = |out: &Value, parts: &[&str]| { + let w = out["warnings"].as_array().unwrap_or_else(|| panic!("no warnings: {out}")); + assert!(w.iter().filter_map(Value::as_str).any(|m| parts.iter().all(|p| m.contains(p))), "{parts:?} not in {w:?}"); + }; + // Forms: a value is kept and the form asks other viewers to redraw it; a button caption too. + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "who" })); + let out = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "who": "Ada" } })); + assert!(out.get("warnings").is_none(), "{out}"); + let out = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "who": "张三" } })); + warned(&out, &["form field \"who\"", "\"张\" (U+5F20)", "other PDF viewers redraw the field"]); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(fields["fields"][0]["value"], "张三", "{fields}"); + let out = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 60, 120, 82], "caption": "提交" })); + warned(&out, &["\"提\" (U+63D0)"]); + // Comments and Fill & Sign text: kept, and said. + let out = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "欢迎" })); + warned(&out, &["a comment on page 1", "\"欢\" (U+6B22)", "every viewer"]); + let out = ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 300], "text": "签名" })); + warned(&out, &["U+7B7E"]); + // A sticky note draws an icon, not its text: nothing to say. + let out = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "签名 OK", "author": "Ada" })); + assert!(out.get("warnings").is_none(), "{out}"); + // Permanent `?`: refused. + refused(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 340], "text": "张三" }), "pass `path`"); + // Cyrillic comes from the script font's Inter fallback (#963), so it isn't refused. + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 380], "text": "Саша" })); + refused(&mut a, "redact_mark", json!({ "doc": doc, "page": 1, "rect": [10, 400, 190, 420], "overlay": "机密" }), "\"机\" (U+673A)"); + refused(&mut a, "doc_flatten", json!({ "doc": doc }), "nothing was flattened"); + refused(&mut a, "doc_create", json!({ "from": "text", "text": "Notes\n第二行" }), "(line 2)"); + // A summary quotes the note: escaped, not refused, not `?`. + ok(&mut a, "comments_summarize", json!({ "doc": doc, "out": "summary.pdf" })); + let summary = ok(&mut a, "doc_open", json!({ "path": "summary.pdf" }))["doc"].as_u64().unwrap(); + let text = page_text(&mut a, summary).join("\n"); + assert!(text.contains("[U+7B7E][U+540D] OK") && text.contains("[U+XXXX]"), "{text}"); + assert!(!text.contains("?? OK"), "{text}"); +} + #[test] fn line_endings_through_the_tool() { let dir = workdir("endings"); @@ -3061,6 +3113,7 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { (json!("logo.png"), "not an object"), (json!({ "image": "broken.png" }), "not an image"), (json!({ "text": " " }), "nothing to draw"), + (json!({ "text": "张三" }), "a name the script font and its fallback can't draw"), (json!({ "strokes": [[[0, 0], ["x", 1]]] }), "malformed strokes"), (json!({ "strokes": [] }), "empty strokes"), ] { diff --git a/crates/create/README.md b/crates/create/README.md index 7cd68277..88c6da2e 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -16,6 +16,10 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi for one point per pixel, or `ImageResolution::Embedded` for the same behavior as `from_images`. The largest page side is still capped at 14,400 points; -- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. +- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Text Helvetica + can't draw is refused, naming the line and the character, never set as `?`. +- `from_text_escaped(text, …)`: the same for reports PdfCraft generates from documents + (comment summaries, compare reports): such characters are written as `[U+XXXX]` and the first + line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index aa6e8f6f..4d21ea5e 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -618,8 +618,50 @@ fn turkish_code_page(bytes: &[u8]) -> bool { && !bytes.iter().any(|b| matches!(b, 0xC1 | 0xCD | 0xD3 | 0xDA | 0xE1 | 0xED | 0xF3 | 0xFA)) } -/// Plain text set in Helvetica on pages of `page` size with 1-inch margins. +/// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't +/// draw (outside Western European) is refused with [`CreateError::Invalid`] naming the line and +/// the character; text PdfCraft generates goes through [`from_text_escaped`] instead. pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { + // Helvetica in WinAnsiEncoding: text it can't draw is refused with where it is, not set as `?`. + if let Some((line, c)) = first_undrawable_line(text) { + return Err(CreateError::Invalid(pdfcraft_fonts::undrawable_message(&format!("the text (line {line})"), c))); + } + set_text(title, text, page, font_size) +} + +/// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and +/// form feeds (new pages) are layout, not drawn. +fn first_undrawable_line(text: &str) -> Option<(usize, char)> { + text.split('\n').enumerate().find_map(|(i, l)| pdfcraft_fonts::first_undrawable(&l.replace('\u{c}', "")).map(|c| (i + 1, c))) +} + +/// What a PDF made from text PdfCraft generated says, in its first line, about characters it +/// wrote as `[U+XXXX]`. +pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points."; + +/// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare +/// report), which may quote any language. Each character Helvetica can't draw is written as +/// `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first line says +/// so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written that way. +pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> { + let mut escaped = 0usize; + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + if matches!(c, '\n' | '\r' | '\u{c}') || pdfcraft_fonts::first_undrawable(c.encode_utf8(&mut [0; 4])).is_none() { + out.push(c); + } else { + escaped = escaped.saturating_add(1); + out.push_str(&format!("[U+{:04X}]", u32::from(c))); + } + } + if escaped > 0 { + out = format!("{ESCAPED_NOTE}\n\n{out}"); + } + // The title is a text string in /Info, which holds any character. + Ok((set_text(title, &out, page, font_size)?, escaped)) +} + +fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 }; let (w, h) = page; let margin = 72.0; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 79732121..88ac114a 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,32 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +/// Text Helvetica can't draw is refused with its line and character, never set as `?`; text +/// PdfCraft generates writes such characters as [U+XXXX] and says so. +#[test] +fn text_helvetica_cannot_draw_is_refused_or_escaped() { + let err = from_text("notes", "Café —\tok\r\n\u{c}第二页 notes", LETTER, 11.0).unwrap_err(); + let CreateError::Invalid(msg) = &err else { panic!("{err:?}") }; + assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Western European"), "{msg}"); + // Western European text, tabs, CRLF and form feeds included, still converts. + from_text("notes", "Café —\tok\r\n\u{c}Seite zwei: Grüße", LETTER, 11.0).unwrap(); + let (doc, escaped) = from_text_escaped("Summary of Comments on 报告.pdf", "Comment: 签名 ok\nPage 2", LETTER, 11.0).unwrap(); + assert_eq!(escaped, 2); + let doc = reopen(&doc); + let p = pages(&doc); + let c = doc.resolve(p[0].get(b"Contents").unwrap()); + let Object::Stream(s) = &*c else { panic!() }; + let shown = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(shown.contains("(Comment: [U+7B7E][U+540D] ok) Tj") && shown.contains(&format!("({ESCAPED_NOTE}) Tj")), "{shown}"); + assert!(!shown.contains('?'), "{shown}"); + // Nothing to escape: no note. + let (doc, escaped) = from_text_escaped("r", "Plain report", LETTER, 11.0).unwrap(); + assert_eq!(escaped, 0); + let p = pages(&doc); + let Object::Stream(s) = &*doc.resolve(p[0].get(b"Contents").unwrap()) else { panic!() }; + assert!(!String::from_utf8_lossy(&s.decoded().unwrap()).contains("U+XXXX")); +} + fn image_of(doc: &Document, page: usize) -> Dict { let p = &pages(doc)[page]; let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 13469666..92cc56d3 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -97,9 +97,45 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result Option<(usize, bool, char)> { + for &i in pages { + let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for entry in &list { + let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue }; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 { + continue; + } + let widget = d.name(b"Subtype") == Some(b"Widget"); + let drawn = pdfcraft_fonts::undrawn_mark(&d); + let would_draw = || (!widget && appearance(doc, &d).is_none()).then(|| pdfcraft_annot::appearance::undrawable_text(&d)).flatten(); + if let Some(c) = drawn.or_else(|| would_draw().map(|(_, c)| c)) { + return Some((i, widget, c)); + } + } + } + None +} + fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { let all = page_list(doc); check(pages, all.len())?; + // `?` in an appearance can still be fixed (a field's value, a comment's text are stored as + // typed, and other viewers redraw form fields); flattened, it would be the page for good. + if let Some((i, widget, c)) = undrawn_on(doc, &all, pages, which) { + return Err(EditError::Invalid(format!( + "can't flatten {} on page {}: it shows ? in place of \"{}\" (U+{:04X}), which its font can't draw, and flattening would make that permanent (nothing was flattened); change or remove that text first", + if widget { "a form field" } else { "a comment" }, + i + 1, + c.escape_debug(), + u32::from(c) + ))); + } let mut drawn = 0; let mut removed_widgets: HashSet = HashSet::new(); for &i in pages { @@ -132,7 +168,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result String { format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c)) } -/// The first character of plain `text` that Helvetica/WinAnsi can't draw (line and page breaks -/// and tabs are layout, not glyphs). -pub fn first_undrawable_plain(text: &str) -> Option { - text.chars().find(|c| !matches!(c, '\r' | '\n' | '\t' | '\u{c}') && !pdfcraft_fonts::win_ansi_encodable(*c)) -} - fn text_op(x: f64, y: f64, text: &str) -> Vec { let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes(); v.extend(literal(&win_ansi(text))); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4a68edf6..0c26de0e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -234,6 +234,58 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an +/// appearance whose text Helvetica can't draw (another program's), and a comment or form field +/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew +/// with a font that has those characters (an appearance, no mark) flattens as before. +#[test] +fn flattening_refuses_what_would_bake_in_question_marks() { + let page = stream("", " "); + let refused = |annot: &str, comments: bool, fields: bool, what: &str| { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>", + &page, + annot, + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let err = flatten(&mut doc, &[0], comments, fields).unwrap_err().to_string(); + assert!(err.contains(what) && err.contains("page 1") && err.contains("\"欢\" (U+6B22)") && err.contains("nothing was flattened"), "{err}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before, "a refused flatten changes nothing"); + assert!(pdfcraft_model::pages(&doc)[0].dict.contains(b"Annots"), "the annotation is still there"); + }; + refused( + "<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents /F 4 >>", + true, + false, + "a comment", + ); + refused( + "<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents /F 4 /AP << /N 6 0 R >> /PCUndrawn >>", + true, + false, + "a comment", + ); + refused( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> /PCUndrawn >>", + false, + true, + "a form field", + ); + // Drawn by another program, with a font that has 欢: no mark, so it flattens. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>", + &page, + "<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents /F 4 /AP << /N 6 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1); +} + /// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many /// user-space units. Items record the `/UserUnit` they were written for; one without it was /// written when display space was in user-space units, and reads back scaled to points. diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index c11fc261..04dc83cd 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -85,7 +85,7 @@ impl Session { pub fn compare_report(&self, old: DocId, new: DocId) -> Result>, EditError> { let c = self.compare(old, new)?; let name = |id| self.get(id).map(|d| d.name.clone()).unwrap_or_default(); - self.create_from_text("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new))) + self.create_report("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new))) } /// Mark the differences in `new` as comments: highlights over replaced and inserted text diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a0ac6ed3..ffc22681 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,7 +38,7 @@ pub use pdfcraft_organize::LabelStyle; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::Algorithm; -pub use pdfcraft_create::{CONVERTIBLE, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; @@ -49,7 +49,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable, first_undrawable_plain, undrawable_message}; +pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; @@ -72,7 +72,7 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_outline}; +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. @@ -291,6 +291,81 @@ pub struct Document { /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, + /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, + /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn +/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat +/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextNotice { + /// The form field's full name, or `None` for a comment. + pub field: Option, + /// The page (0-based) of the comment or of the field's widget. + pub page: usize, + pub ch: char, + /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` + /// set); otherwise the `?` shows in every viewer. + pub redrawn_elsewhere: bool, +} + +impl std::fmt::Display for TextNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); + let page = self.page + 1; + match (&self.field, self.redrawn_elsewhere) { + (Some(name), true) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" + ), + (Some(name), false) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" + ), + (None, _) => write!( + f, + "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" + ), + } + } +} + +/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying +/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. +fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; + let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); + let mut names: Option> = None; + let mut out: Vec = Vec::new(); + for (page, p) in pages.iter().enumerate() { + let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); + for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { + let now = next.get(r); + let Some(d) = now.as_dict() else { continue }; + let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; + // Only what this edit drew: a new annotation, or a new appearance. + let was = prev.get(r); + if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { + continue; + } + let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { + let names = names.get_or_insert_with(|| { + pdfcraft_forms::fields(next) + .into_iter() + .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) + .collect() + }); + names.get(&r).cloned().unwrap_or_default() + }); + if field.is_some() && out.iter().any(|n| n.field == field) { + continue; + } + out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); + } + } + out } impl Document { @@ -2352,8 +2427,10 @@ pub struct Session { /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { - let report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; xfa_redraw(doc)?; + // The form's own values: one its field's font can't draw is drawn with `?` and reported. + report.warnings.extend(undrawn_field_warnings(doc)); Ok(report) } @@ -2369,6 +2446,17 @@ pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> Ok(()) } +/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font +/// can't draw (laying out an XFA form's own values). +fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { + pdfcraft_forms::undrawn_fields(doc) + .into_iter() + .map(|(name, c)| { + format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) + }) + .collect() +} + /// The form's fields as the XFA data layer wants them. fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { use pdfcraft_forms::FieldKind as K; @@ -2750,6 +2838,7 @@ impl Session { xfa_template: None, fonts_stale: false, xfa_warnings: Vec::new(), + edit_notices: Vec::new(), }); Ok(id) } @@ -2764,6 +2853,7 @@ impl Session { let today = self.today(); let js_off = self.js_off; let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); let name = doc.name.clone(); let is_xfa = doc.info.xfa.is_some(); let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); @@ -2838,6 +2928,13 @@ impl Session { if signed && rewrites(&next) && !rewrites(&editor.cos) { return Err(EditError::SignedRewrite(edit.label().to_string())); } + // What this edit drew with `?` (a value or comment text its font can't draw, stored as + // typed): reported, never silent (AGENTS.md §4). + let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() + } else { + Vec::new() + }; let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); @@ -2864,6 +2961,7 @@ impl Session { } doc.dirty = true; doc.generation += 1; + doc.edit_notices = notices; note_warnings(&mut doc.xfa_warnings, &xfa_notes); let notes = doc.xfa_warnings.clone(); note_warnings(&mut doc.info.warnings, ¬es); @@ -2880,6 +2978,7 @@ impl Session { pub fn undo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; let current = std::mem::replace(&mut editor.cos, prev); @@ -2894,6 +2993,7 @@ impl Session { pub fn redo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; let current = std::mem::replace(&mut editor.cos, next); @@ -3236,11 +3336,20 @@ impl Session { self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) } - /// A new document from plain text (US Letter, 11 pt Helvetica). + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) } + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked /// (it must open and allow copying pages) and returned as it is. pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { @@ -3657,7 +3766,7 @@ impl Session { pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_from_text(&format!("Summary of Comments on {}", doc.name), &text) + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) } /// The certificates trusted for signing. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 838f9af2..6b99cec4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1170,6 +1170,34 @@ fn form_fixture() -> Vec { out } +/// A value its field's font can't draw is kept, and the edit that drew it reports it in +/// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a +/// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other +/// viewers to redraw it. +#[test] +fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + let fill = |s: &mut Session, name: &str, v: FieldValue| s.apply(id, Edit::SetFieldValue { name: name.into(), value: v }); + fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.form[0].value, ["张三"]); + let n = &d.edit_notices; + assert_eq!(n.len(), 1, "{n:?}"); + assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true)); + assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]); + fill(&mut s, "ok", FieldValue::Check(true)).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing"); + fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap(); + assert_eq!(s.get(id).unwrap().edit_notices.len(), 1, "a new value is reported again"); + assert!(fill(&mut s, "missing", FieldValue::Text("x".into())).is_err()); + assert!(s.get(id).unwrap().edit_notices.is_empty(), "no stale notice after a failed edit"); + fill(&mut s, "name", FieldValue::Text("Ada".into())).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + assert!(pdfcraft_forms::need_appearances(&saved)); +} + #[test] fn filling_a_form_shows_saves_and_undoes() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index b0f4c0b3..bca3bc36 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -453,6 +453,7 @@ impl<'a, 'b> Runner<'a, 'b> { let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?; self.console(report.warnings); crate::xfa_redraw(self.doc)?; + self.console(crate::undrawn_field_warnings(self.doc)); self.by_som = None; self.fields = None; self.cached = None; diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 44b617dd..4f0dda79 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -9,7 +9,13 @@ Today it holds what generated appearances need without a font program: enough for line breaking and alignment, not for typesetting. - `wrap`: greedy line breaking with that measure (paragraphs on newlines, long words split). - `win_ansi`: Unicode → WinAnsiEncoding bytes (`?` for characters it can't represent), and - `literal` to write bytes as a PDF literal string. + `literal` to write bytes as a PDF literal string. Nothing writes that `?` silently. Callers + check `first_undrawable` (or `UnicodeCMap::first_unencodable`, for a character beyond U+FFFF in + UCS-2), then either refuse with `undrawable_message` (one wording that names the character and + what can be written instead) where the `?` would be permanent page content, or, for a form + value or a comment's text, which is stored as typed, draw the `?` and mark the annotation with + `set_undrawn_mark` (`UNDRAWN_MARK`, read back by `undrawn_mark`) so the edit is reported and + flattening refuses. `script_missing` names what the typed-signature script font and its Inter fallback both lack. It also holds the fonts of the optional [craft-fonts](https://github.com/storytold/craft-fonts) build input. `build.rs` embeds them as `CRAFT_FONTS` when the build sets diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 672e0a6c..2cc41ba3 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -15,7 +15,9 @@ pub use craft::{ CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style, document_japanese_fonts_for_style, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts, ui_telugu_fonts, }; -pub use script::{GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline}; +pub use script::{ + GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline, +}; /// Approximate advance of `s` in Helvetica (or Arial) at `size` points. pub fn helvetica_width(s: &str, size: f64) -> f64 { @@ -151,7 +153,17 @@ impl UnicodeCMap { }) } - /// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?`. + /// The first character of `s` this encoding can't hold, which [`Self::encode`] would write as + /// `?`: a character beyond U+FFFF in UCS-2 (`𠀋`). UTF-16, UTF-8 and UTF-32 hold every one. + pub fn first_unencodable(self, s: &str) -> Option { + match self { + Self::Ucs2 => s.chars().find(|c| u16::try_from(u32::from(*c)).is_err()), + Self::Utf16 | Self::Utf8 | Self::Utf32 => None, + } + } + + /// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?` (check them + /// first with [`Self::first_unencodable`]: PdfCraft refuses such text rather than write `?`). pub fn encode(self, s: &str) -> Vec { let mut out = Vec::with_capacity(s.len().saturating_mul(2)); let mut units = [0u16; 2]; @@ -186,6 +198,47 @@ pub fn first_non_win_ansi(s: &str) -> Option { s.chars().find(|c| !win_ansi_encodable(*c)) } +/// The first character of `s` the standard fonts (Helvetica and the rest of the standard 14, in +/// WinAnsiEncoding) can't draw, so that a generated appearance would show it as `?`. Line breaks +/// don't count: appearances lay lines out themselves. PdfCraft refuses such text with +/// [`undrawable_message`] instead of writing `?` (AGENTS.md §4: never lose work silently). +pub fn first_undrawable(s: &str) -> Option { + s.chars().find(|c| !matches!(c, '\n' | '\r') && !win_ansi_encodable(*c)) +} + +/// The error for text the standard fonts can't draw ([`first_undrawable`]): which character, in +/// what (`"the comment"`, `"the reason"`), and what can be written instead. One wording for every +/// path that writes text in the standard fonts, so agents and users can act on it the same way. +pub fn undrawable_message(what: &str, c: char) -> String { + format!( + "the standard fonts can't draw \"{}\" (U+{:04X}) in {what}; only Western European characters can be written there for now", + c.escape_debug(), + u32::from(c) + ) +} + +/// The private key on an annotation (a comment or a form field's widget) whose appearance PdfCraft +/// drew with `?` in place of characters its fonts can't draw: a text string holding the first +/// such character. The text itself (a field's `/V`, a comment's `/Contents`) is stored as typed; +/// the mark lets flattening refuse to make that `?` permanent, lets an edit report it, and tells +/// such an appearance apart from one another program drew with a font that has those characters. +pub const UNDRAWN_MARK: &[u8] = b"PCUndrawn"; + +/// The character [`UNDRAWN_MARK`] records on `d`, if PdfCraft drew its appearance with `?`. +pub fn undrawn_mark(d: &pdfcraft_cos::Dict) -> Option { + d.get(UNDRAWN_MARK).and_then(pdfcraft_cos::Object::as_string).and_then(|s| s.to_text().chars().next()) +} + +/// Record (`Some`) or clear (`None`) [`UNDRAWN_MARK`] on `d`, for the appearance just drawn. +pub fn set_undrawn_mark(d: &mut pdfcraft_cos::Dict, c: Option) { + match c { + Some(c) => d.set(UNDRAWN_MARK.to_vec(), pdfcraft_cos::PdfString::text(&c.to_string())), + None => { + d.remove(UNDRAWN_MARK); + } + } +} + /// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped. pub fn literal(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len() + 2); @@ -216,6 +269,30 @@ mod tests { } assert_eq!(first_non_win_ansi("Café — 5€"), None); assert_eq!(first_non_win_ansi("Dvořák"), Some('ř')); + // Line breaks are laid out, not drawn; other controls would be written as `?`. + assert_eq!(first_undrawable("Café\r\n— 5€\tok"), None); + assert_eq!(first_undrawable("签名 Ada"), Some('签')); + assert_eq!(first_undrawable("a\u{7}b"), Some('\u{7}')); + assert_eq!(first_undrawable("𠀋"), Some('𠀋')); + let m = undrawable_message("the comment", '签'); + assert_eq!(m, "the standard fonts can't draw \"签\" (U+7B7E) in the comment; only Western European characters can be written there for now"); + assert!(undrawable_message("the reason", '\u{7}').contains("\"\\u{7}\" (U+0007)")); + assert!(undrawable_message("x", '𠀋').contains("(U+2000B)")); + } + + #[test] + fn the_undrawn_mark_records_one_character_and_clears() { + let mut d = pdfcraft_cos::Dict::new(); + assert_eq!(undrawn_mark(&d), None); + set_undrawn_mark(&mut d, Some('𠀋')); + assert_eq!(undrawn_mark(&d), Some('𠀋'), "beyond U+FFFF, as a UTF-16 text string"); + set_undrawn_mark(&mut d, Some('签')); + assert_eq!(undrawn_mark(&d), Some('签')); + set_undrawn_mark(&mut d, None); + assert!(!d.contains(UNDRAWN_MARK)); + // Another value under the key is not a character: no mark. + d.set(UNDRAWN_MARK.to_vec(), pdfcraft_cos::Object::Bool(true)); + assert_eq!(undrawn_mark(&d), None); } #[test] @@ -261,6 +338,12 @@ mod tests { assert_eq!(UnicodeCMap::Utf16.encode("令A"), [0x4e, 0xe4, 0x00, 0x41]); assert_eq!(UnicodeCMap::Utf16.encode("𠀋"), [0xd8, 0x40, 0xdc, 0x0b]); assert_eq!(UnicodeCMap::Ucs2.encode("𠀋\t"), [0x00, b'?', 0x00, b' ']); + // That `?` is never written silently: callers check first. + assert_eq!(UnicodeCMap::Ucs2.first_unencodable("令和𠀋"), Some('𠀋')); + assert_eq!(UnicodeCMap::Ucs2.first_unencodable("令和"), None); + for cmap in [UnicodeCMap::Utf16, UnicodeCMap::Utf8, UnicodeCMap::Utf32] { + assert_eq!(cmap.first_unencodable("令和𠀋"), None, "{cmap:?}"); + } assert_eq!(UnicodeCMap::Utf8.encode("令"), "令".as_bytes()); assert_eq!(UnicodeCMap::Utf32.encode("令"), [0, 0, 0x4e, 0xe4]); assert_eq!(cjk_width("令和", 10.0), 20.0); diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs index 5fee1009..70fc6ce0 100644 --- a/crates/fonts/src/script.rs +++ b/crates/fonts/src/script.rs @@ -188,9 +188,21 @@ pub(crate) fn bounded_outline(font: &FontRef, gid: GlyphId, width: f64) -> Resul Ok(GlyphOutline { contours: pen.contours, width, bbox }) } +/// The first character of `text` (other than white space) that neither the script font of typed +/// signatures nor its Inter fallback has a glyph for: [`script_outline`] would leave it out. +/// `None` when they can draw all of them. +pub fn script_missing(text: &str) -> Option { + let font = FontRef::new(FONT).ok()?; + let charmap = font.charmap(); + let fallback = FontRef::new(INTER_REGULAR).ok(); + let fallback_map = fallback.as_ref().map(|f| f.charmap()); + text.chars().find(|c| !c.is_whitespace() && charmap.map(*c).is_none() && fallback_map.as_ref().and_then(|m| m.map(*c)).is_none()) +} + /// The outlines of `text` in the script font. Characters it lacks (Cyrillic, Greek, …) are drawn /// from Inter, slanted and scaled to the script's capital height; characters neither font has are -/// skipped. Over-limit input returns an empty outline instead of a silently truncated signature. +/// skipped (check [`script_missing`] first where nobody sees a preview). Over-limit input returns +/// an empty outline instead of a silently truncated signature. pub fn script_outline(text: &str) -> ScriptOutline { if text.chars().take(MAX_SIGNATURE_CHARS + 1).count() > MAX_SIGNATURE_CHARS { return ScriptOutline::default(); @@ -281,6 +293,17 @@ mod tests { assert!(mixed.width > latin_only.width + 0.5, "{} vs {}", mixed.width, latin_only.width); } + #[test] + fn missing_script_glyphs_are_named() { + assert_eq!(super::script_missing("Ada Lovelace-King"), None); + assert_eq!(super::script_missing("Zoë Ñúñez"), None); + assert_eq!(super::script_missing("Ada 张三"), Some('张')); + // Cyrillic and Greek come from the Inter fallback (#963), so they aren't missing. + assert_eq!(super::script_missing("Саша Дермановић Νίκος"), None); + assert_eq!(super::script_missing("Саша 张"), Some('张')); + assert_eq!(super::script_missing(" \t"), None); + } + #[test] fn a_name_becomes_outlines() { let o = super::script_outline("Ada L."); diff --git a/crates/forms/README.md b/crates/forms/README.md index 9af549ad..3280cc24 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -33,7 +33,15 @@ delete_field(&mut doc, "email")?; field tree; the form's `/DA` and `/Q` are the last fallback. - Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb, multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. - The `/DA` font is used when `/DR` has it as a simple font; otherwise Helvetica (WinAnsi). + The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined + Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold + (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat + keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is + marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn) + and the form sets `/NeedAppearances`, so other viewers draw the field from its value with fonts + that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`). + `undrawn_fields` lists the marked fields; the engine reports each edit that draws one, and + flattening refuses to make that `?` permanent. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 8478cc26..dc5ffcef 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -8,9 +8,13 @@ //! The `/DA` font is used when the form's `/DR` defines it as a simple font that can encode the //! displayed text (using its own encoding and Differences), or as a composite font with a //! predefined Unicode CMap such as `UniJIS-UTF16-H`, as Japanese forms use. Otherwise (unsupported -//! characters, other composite fonts, missing resources) Helvetica is used. Widths use the -//! approximate metrics of `pdfcraft-fonts` (Helvetica, or one em per full-width character in -//! composite fonts). +//! characters, other composite fonts, missing resources) Helvetica is used. Text the chosen font +//! can't hold (outside WinAnsi in Helvetica, or beyond U+FFFF in a UCS-2 CMap) is drawn as `?`, as +//! Acrobat keeps a value its field font can't show: the value itself is stored as typed, +//! [`field_appearance_drawn`] returns the first such character, and the caller marks the widget +//! (`pdfcraft_fonts::UNDRAWN_MARK`) and asks other viewers to redraw the field +//! (`/NeedAppearances`). Widths use the approximate metrics of `pdfcraft-fonts` (Helvetica, or one +//! em per full-width character in composite fonts). //! //! `/MK /R` (0, 90, 180, 270, counterclockwise) lays the content out in the upright box and sets //! the appearance `/Matrix` so that box lands on the widget rectangle. Any other value is 0, and @@ -139,6 +143,19 @@ impl FontText { } } + /// The first character of `s` the encoding can't hold, which [`Self::encode`] writes as `?` + /// (a reused simple font is only chosen when it holds them all): reported by + /// [`field_appearance_drawn`], so that `?` is never silent (AGENTS.md §4). + fn first_unencodable(&self, s: &str) -> Option { + match self { + Self::WinAnsi => pdfcraft_fonts::first_undrawable(s), + Self::Simple(metrics) => { + s.chars().map(|c| if c == '\t' { ' ' } else { c }).find(|c| metrics.encode(c.encode_utf8(&mut [0; 4])).is_none()) + } + Self::Unicode(cmap) => cmap.first_unencodable(s), + } + } + fn width(&self, s: &str, size: f64) -> f64 { match self { Self::WinAnsi | Self::Simple(_) => helvetica_width(s, size), @@ -268,7 +285,8 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { (c, 0.0) } -/// The appearance of a text or choice field's widget showing `values`. +/// The appearance of a text or choice field's widget showing `values`. Characters its font can't +/// hold come out as `?`; [`field_appearance_drawn`] says which. pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream { field_appearance_as(doc, f, w, values, true) } @@ -276,6 +294,13 @@ pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String] /// [`field_appearance`], where `format` false means `values` are already what to show (a /// custom Format script ran). pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> Stream { + field_appearance_drawn(doc, f, w, values, format).0 +} + +/// [`field_appearance_as`] with the first character it drew as `?` because the field's font can't +/// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't +/// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. +pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -319,9 +344,16 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri let inner_w = (width - 2.0 * pad).max(1.0); let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding); let mut body: Vec = Vec::new(); - let show = |body: &mut Vec, x: f64, y: f64, text: &str| { + // The first character drawn that the font can't hold (drawn as `?`), for the caller to report. + let mut unencodable: Option = None; + let mut show = |body: &mut Vec, x: f64, y: f64, text: &str| { + // A line break in a single-line value is a space, as in viewers (multiline text is split). + let text: String = text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); + if unencodable.is_none() { + unencodable = enc.first_unencodable(&text); + } body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - body.extend(literal(&enc.encode(text))); + body.extend(literal(&enc.encode(&text))); body.extend_from_slice(b" Tj\n"); }; let x_for = |text: &str, size: f64| -> f64 { @@ -419,7 +451,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); - Stream::flate(d, &content) + (Stream::flate(d, &content), unencodable) } /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index e75beeb3..458bf3f0 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -669,13 +669,19 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel Ok(field_name) } -/// Regenerate every widget appearance of a field from its current value and settings. +/// Regenerate every widget appearance of a field from its current value and settings. A value or +/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked +/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see +/// `ask_viewers_to_redraw`), as when a value is set. pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let mut undrawn = false; for w in &f.widgets { + let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let s = appearance::field_appearance(doc, &f, w, &f.value); + let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + missing = m; let r = doc.add(Object::Stream(s)); let mut d = Dict::new(); d.set(b"N".to_vec(), Object::Ref(r)); @@ -686,7 +692,8 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { appearance::check_box_states(doc, w, f.kind, &on) } FieldKind::PushButton => { - let s = button_appearance(doc, w); + let (s, m) = button_appearance(doc, w); + missing = m; let r = doc.add(Object::Stream(s)); let mut d = Dict::new(); d.set(b"N".to_vec(), Object::Ref(r)); @@ -700,8 +707,15 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { d } }; + undrawn |= missing.is_some(); let existing = crate::merged_appearance(doc, w.obj, &ap); - doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(existing)))?; + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(existing)); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + } + if undrawn { + crate::ask_viewers_to_redraw(doc)?; } Ok(()) } @@ -737,8 +751,9 @@ fn form_stream(place: crate::appearance::Placement, content: Vec, resources: } /// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its -/// centred caption (unless the layout is icon only, `/TP 1`). -fn button_appearance(doc: &Document, w: &Widget) -> Stream { +/// centred caption (unless the layout is icon only, `/TP 1`). The caption is drawn in Helvetica, +/// with the first character it can't draw (drawn as `?`), for the caller to mark and report. +fn button_appearance(doc: &Document, w: &Widget) -> (Stream, Option) { let (mut c, place) = frame_only(doc, w); let (width, height) = (place.layout_w, place.layout_h); let wobj = doc.get(w.obj); @@ -762,7 +777,9 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream { xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); } } + let mut missing = None; if !icon_only && !caption.is_empty() { + missing = pdfcraft_fonts::first_undrawable(&caption); let size = ((height - 4.0) * 0.6).clamp(4.0, 14.0); let tw = helvetica_width(&caption, size); content.extend(format!("BT /Helv {size:.2} Tf 0 g 1 0 0 1 {:.3} {:.3} Tm ", (width - tw) / 2.0, (height - size * 0.7) / 2.0).bytes()); @@ -781,7 +798,7 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream { if !xobjects.is_empty() { res.set(b"XObject".to_vec(), Object::Dict(xobjects)); } - form_stream(place, content, res) + (form_stream(place, content, res), missing) } /// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 5d889559..b3de8ca6 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1096,11 +1096,13 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), _ => None, }; + let mut undrawn = false; for w in &f.widgets { - let stream = match &shown { - Some(s) => appearance::field_appearance_as(doc, f, w, std::slice::from_ref(s), false), - None => appearance::field_appearance(doc, f, w, values), + let (stream, missing) = match &shown { + Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), + None => appearance::field_appearance_drawn(doc, f, w, values, true), }; + undrawn |= missing.is_some(); let ap = doc.add(Object::Stream(stream)); let mut fresh = Dict::new(); fresh.set(b"N".to_vec(), Object::Ref(ap)); @@ -1108,11 +1110,66 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc doc.update_dict(w.obj, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); })?; } + if undrawn { + ask_viewers_to_redraw(doc)?; + } Ok(()) } +/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps +/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values +/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a +/// document that declares PDF/A, which forbids it: there the widget's mark +/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. +pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { + if declares_pdfa(doc) || need_appearances(doc) { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); + match entry { + Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, + Some(Object::Dict(mut af)) => { + af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + Ok(()) +} + +/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). +pub fn need_appearances(doc: &Document) -> bool { + acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) +} + +/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). +pub fn declares_pdfa(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; + let m = doc.resolve(&m); + match &*m { + Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), + _ => false, + } +} + +/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font +/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per +/// field. +pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { + fields(doc) + .into_iter() + .filter_map(|f| { + let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; + Some((f.name, c)) + }) + .collect() +} + /// Acrobat's Clear form: every field (or the named ones) back to its default value. pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { let all = fields(doc); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index aa98dd7c..45e85013 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -369,6 +369,8 @@ fn japanese_choices_use_the_unicode_cid_font() { assert_eq!(field(&fields(&doc), "era").options.get(2), Some(&("令".to_string(), "令".to_string()))); set_value(&mut doc, "era", &FieldValue::Text("令".into())).unwrap(); set_value(&mut doc, "address", &FieldValue::Text("日本語のテキスト入力欄です".into())).unwrap(); + // An Identity-H subset can't be addressed by Unicode: Helvetica stands in, and can't draw 令. + // The value is kept, drawn as `??`, marked, and other viewers are asked to redraw the form. set_value(&mut doc, "subset", &FieldValue::Text("令和".into())).unwrap(); let doc = reopen(&doc); let all = fields(&doc); @@ -392,10 +394,115 @@ fn japanese_choices_use_the_unicode_cid_font() { let addr_text = String::from_utf8_lossy(&addr); assert!(addr_text.matches(" Tj").count() >= 4, "{addr_text}"); assert!(addr.windows(4).any(|x| x == utf16("日本").as_slice())); - // An Identity-H subset can't be addressed by Unicode: Helvetica, as before. let (sub, sub_fonts) = raw("subset"); assert!(String::from_utf8_lossy(&sub).contains("/Helv "), "{}", String::from_utf8_lossy(&sub)); + assert!(sub.windows(4).any(|x| x == b"(??)"), "{}", String::from_utf8_lossy(&sub)); assert_eq!(sub_fonts, ["Helv"]); + assert_eq!(field(&all, "subset").value, ["令和"]); + // Only that widget is marked: the CID fonts drew theirs. + assert_eq!(undrawn_fields(&doc), [("subset".to_string(), '令')]); + assert!(need_appearances(&doc)); +} + +/// Text a field's font can't hold is kept, as Acrobat keeps it: the value is stored as typed, the +/// appearance draws `?` for those characters, the widget is marked with the first one, and the +/// form asks other viewers to redraw it (`/NeedAppearances`). Drawable text clears the mark. +#[test] +fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /CN 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 10 Tf 0 g) /V (Ada) /Rect [50 700 200 715] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (cn) /DA (/CN 10 Tf 0 g) /Rect [50 600 200 615] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (city) /Ff 131072 /DA (/Helv 10 Tf 0 g) /Opt [(Zurich) ] /Rect [50 500 200 515] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let shown = |doc: &Document, name: &str| { + let all = fields(doc); + let w = &field(&all, name).widgets[0]; + let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + let mark = doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark); + (String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), mark) + }; + // A WinAnsi field: the Chinese value is kept and marked; Helvetica draws `?` for it. + assert!(!need_appearances(&doc)); + set_value(&mut doc, "name", &FieldValue::Text("签名 Ada".into())).unwrap(); + assert_eq!(field(&fields(&doc), "name").value, ["签名 Ada"]); + let (ap, mark) = shown(&doc, "name"); + assert!(ap.contains("(?? Ada) Tj"), "{ap}"); + assert_eq!(mark, Some('签')); + assert!(need_appearances(&doc), "other viewers are asked to redraw the form"); + // A choice whose label Helvetica can't draw, the same way. + set_value(&mut doc, "city", &FieldValue::Text("北N京".into())).unwrap(); + assert_eq!(shown(&doc, "city").1, Some('北')); + // A UCS-2 CMap holds 签, so nothing is marked, but not a character beyond U+FFFF. + set_value(&mut doc, "cn", &FieldValue::Text("签名".into())).unwrap(); + assert_eq!(shown(&doc, "cn").1, None); + set_value(&mut doc, "cn", &FieldValue::Text("𠀋".into())).unwrap(); + assert_eq!(field(&fields(&doc), "cn").value, ["𠀋"]); + assert_eq!(shown(&doc, "cn").1, Some('𠀋')); + let mut undrawn = undrawn_fields(&doc); + undrawn.sort(); + assert_eq!(undrawn, [("city".to_string(), '北'), ("cn".to_string(), '𠀋'), ("name".to_string(), '签')]); + // Drawable text clears the mark; a line break in a single-line value is a space, not `?`. + set_value(&mut doc, "name", &FieldValue::Text("Ada\nLovelace".into())).unwrap(); + let (ap, mark) = shown(&doc, "name"); + assert!(ap.contains("(Ada Lovelace) Tj") && !ap.contains('?'), "{ap}"); + assert_eq!(mark, None); + // Kept through a save. + let doc = reopen(&doc); + assert_eq!(field(&fields(&doc), "city").value, ["北N京"]); + assert!(need_appearances(&doc)); +} + +/// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids +/// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off. +#[test] +fn a_pdfa_form_keeps_the_value_without_asking_viewers_to_redraw() { + let xmp = r#""#; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Metadata 7 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 10 Tf 0 g) /Rect [50 700 200 715] /P 3 0 R >>".into(), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len() + 1), + ]; + let mut doc = document(&objs); + assert!(declares_pdfa(&doc)); + set_value(&mut doc, "name", &FieldValue::Text("张三".into())).unwrap(); + assert_eq!(field(&fields(&doc), "name").value, ["张三"]); + assert_eq!(undrawn_fields(&doc), [("name".to_string(), '张')]); + assert!(!need_appearances(&doc), "PDF/A forbids /NeedAppearances"); +} + +/// A button caption Helvetica can't draw is kept and marked like a value; redrawing a value another +/// program stored (a dynamic form's layout, a property change) draws and marks it rather than +/// failing, and an edit to an unrelated field never fails because of it. +#[test] +fn captions_and_redrawn_values_are_kept_and_marked() { + let mut doc = fixture(); + let go = add_field(&mut doc, 0, [50.0, 50.0, 150.0, 80.0], &NewField::Button { caption: "提交".into() }, Some("go2")).unwrap(); + assert_eq!(undrawn_fields(&doc), [(go.clone(), '提')]); + assert!(need_appearances(&doc)); + // A value stored by another program that Helvetica can't draw. + let name = field(&fields(&doc), "name").clone(); + doc.update_dict(name.obj, |d| d.set(b"V".to_vec(), PdfString::text("张三"))).unwrap(); + redraw_field(&mut doc, "name").unwrap(); + let mut undrawn = undrawn_fields(&doc); + undrawn.sort(); + assert_eq!(undrawn, [(go, '提'), ("name".to_string(), '张')]); + set_value(&mut doc, "notes", &FieldValue::Text("Unrelated".into())).unwrap(); + // Drawable again: the mark goes. + doc.update_dict(name.obj, |d| d.set(b"V".to_vec(), PdfString::text("Ada"))).unwrap(); + redraw_field(&mut doc, "name").unwrap(); + assert!(!undrawn_fields(&doc).iter().any(|(n, _)| n == "name")); } #[test] diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index a34386c1..d6e2f00c 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -234,6 +234,14 @@ mod tests { assert_eq!(w.up, [0.0, 20.0]); } + /// The text layer is Helvetica in WinAnsiEncoding: every character the model can recognise + /// must be one it can hold, or recognised text would be saved as `?`. A model with a wider + /// alphabet (Chinese, for example) needs a text layer in another font first. + #[test] + fn the_model_alphabet_fits_the_text_layer_font() { + assert_eq!(pdfcraft_fonts::first_undrawable(ALPHABET), None); + } + #[test] fn text_layer_is_invisible_text_fitted_to_each_box() { let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] }; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 4ceef50b..e9310165 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -37,6 +37,9 @@ pub enum RedactError { Unreadable(usize), #[error("redaction could not be verified: {0} item(s) were still found under the marks, so nothing was changed")] Residue(usize), + /// A mark's overlay text has a character Helvetica can't draw; nothing was redacted. + #[error("{0}")] + Undrawable(String), #[error("page {0} has vertical-writing text under a redaction mark, which PdfCraft cannot remove yet, so nothing was changed")] VerticalText(usize), #[error(transparent)] @@ -276,6 +279,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = chosen.iter().map(|m| m.page).collect(); by_page.sort_unstable(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index ef725674..bd1fa5a1 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -560,6 +560,39 @@ fn overlay_text_takes_its_font_size_colour_alignment_and_repeats() { assert!(fonts.contains(b"PCCour") && !fonts.contains(b"PCTimes")); } +/// Overlay text the standard fonts can't draw: refused when it is marked, and for a mark another +/// program made, refused when it is applied, before anything is redacted, rather than burnt into +/// the page as `?`. +#[test] +fn overlay_text_the_standard_fonts_cannot_draw_is_refused() { + let mut doc = one_page(b"BT /F1 12 Tf 20 250 Td (Secret salary figures) Tj ET", "", Vec::new()); + let look = pdfcraft_annot::OverlayLook::default(); + let mark = |overlay: &str| Shape::Redact { quads: vec![rect_quad([10.0, 200.0, 290.0, 270.0])], overlay: overlay.into(), look }; + let add = |doc: &mut Document, shape: Shape| { + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "T".into() }, &Meta::default()) + }; + let err = add(&mut doc, mark("机密")).unwrap_err().to_string(); + assert!(err.contains("\"机\" (U+673A)") && err.contains("overlay text"), "{err}"); + assert!(marks(&doc).is_empty()); + // A mark whose overlay text was written by another program. + add(&mut doc, mark("REDACTED")).unwrap(); + let obj = marks(&doc)[0].obj; + doc.update_dict(obj, |d| d.set(b"OverlayText".to_vec(), pdfcraft_cos::PdfString::text("机密"))).unwrap(); + let before = content(&doc, 0); + let err = apply(&mut doc, None).unwrap_err(); + assert!( + matches!(&err, RedactError::Undrawable(m) if m.contains("page 1") && m.contains("U+673A") && m.contains("nothing was redacted")), + "{err:?}" + ); + assert_eq!(content(&doc, 0), before, "nothing was redacted"); + assert_eq!(marks(&doc).len(), 1, "the mark is still there"); + // Cleared, it applies. + doc.update_dict(obj, |d| d.set(b"OverlayText".to_vec(), pdfcraft_cos::PdfString::text(""))).unwrap(); + apply(&mut doc, None).unwrap(); + assert!(!content(&reopen(&doc), 0).contains("Secret")); +} + #[test] fn redaction_codes_join_in_set_order_and_reject_strangers() { use crate::codes::{CODE_SETS, CodeSet}; diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 4ba11e9d..9627dbdf 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1909,11 +1909,31 @@ pub fn display_date(pdf: &str) -> String { /// The visible signature: the signer's name large on the left, or the graphic in its place, and /// the details on the right (Acrobat's standard layout), in Helvetica. A graphic with no details -/// beside it fills the box. +/// beside it fills the box. An error, before anything is drawn, when a detail it writes in +/// Helvetica (the signer's name, the certificate's subject, the reason or the location) has a +/// character Helvetica can't draw: signing invisibly, or leaving that part out, still works. A +/// graphic doesn't change this: the name it replaces on the left is still among the details. fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result { - use pdfcraft_fonts::{helvetica_width, literal, win_ansi, wrap}; + use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, undrawable_message, win_ansi, wrap}; let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0)); let a = &opts.appearance; + let dn = cert.subject.display(); + if w > 1.0 && h > 1.0 { + let shown = [ + (a.name, "the signer's name", Some(name)), + (a.distinguished_name, "the certificate's subject", Some(dn.as_str())), + (a.reason, "the reason", opts.reason.as_deref()), + (a.location, "the location", opts.location.as_deref()), + ]; + for (on, what, text) in shown { + if on && let Some(c) = text.and_then(first_undrawable) { + return Err(SignError::Unsupported(format!( + "{}; sign without a visible appearance, or leave {what} out of it", + undrawable_message(&format!("{what} of the visible signature"), c) + ))); + } + } + } let mut lines: Vec = Vec::new(); let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() }; // The labels are the caller's words, so a signature signed in another language reads in it. @@ -1923,7 +1943,7 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate lines.push(name.to_string()); } if a.distinguished_name { - lines.push(label(&wording.distinguished_name, &cert.subject.display())); + lines.push(label(&wording.distinguished_name, &dn)); } if a.reason && let Some(r) = opts.reason.as_deref().filter(|r| !r.is_empty()) diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index bb0d3b48..54d76af6 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -1738,6 +1738,29 @@ fn encrypted_documents_take_a_document_timestamp() { assert!(matches!(pdfcraft_sign::timestamp_document(&print_only, &tsa, "D:20261006120000Z"), Err(SignError::Pdf(_)))); } +/// A visible signature draws its details in Helvetica: a reason or location it can't draw is +/// refused with the character and what to do instead, rather than drawn as `?`. An +/// invisible signature stores the same reason as text and signs. +#[test] +fn visible_appearance_text_helvetica_cannot_draw_is_refused() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let shown = pdfcraft_sign::Appearance { reason: true, location: true, ..pdfcraft_sign::Appearance::default() }; + let chinese = SignOptions { reason: Some("同意此文件".into()), appearance: shown.clone(), ..opts() }; + let err = pdfcraft_sign::sign(&open(&fixture()), &id, &chinese).unwrap_err().to_string(); + assert!(err.contains("\"同\" (U+540C)") && err.contains("the reason") && err.contains("sign without a visible appearance"), "{err}"); + let located = SignOptions { location: Some("北京".into()), appearance: shown, ..opts() }; + assert!(pdfcraft_sign::sign(&open(&fixture()), &id, &located).unwrap_err().to_string().contains("the location")); + // Left out of the appearance, or signed invisibly: it signs, and the reason is kept as text. + let hidden = SignOptions { reason: Some("同意此文件".into()), ..opts() }; + assert!(!hidden.appearance.reason, "the default appearance doesn't show the reason"); + pdfcraft_sign::sign(&open(&fixture()), &id, &hidden).unwrap(); + let invisible = SignOptions { rect: None, ..chinese }; + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &invisible).unwrap(); + let doc = open(&signed); + let s = signatures(&doc, &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.reason.as_deref(), Some("同意此文件")); +} + /// Every sentence this crate says about a signature is listed in `WORDINGS`, and every sentence on /// that list is one it still says: the details are written as `Detail::plain`/`Detail::of`, the /// sentences the modules that read a signature word for themselves are pushed as notes and quirks, diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index fdabe8b3..49c1c9e1 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -42,6 +42,19 @@ pub(crate) fn undrawable_message(c: char) -> String { ) } +/// The warning after an edit that saved text its font can't draw: PdfCraft drew `?` for it (a form +/// field, which other viewers redraw from its value, or a comment, where the `?` shows everywhere). +pub(crate) fn undrawn_message(n: &pdfcraft_engine::TextNotice) -> String { + let template = if n.redrawn_elsewhere { + tl!( + "This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it." + ) + } else { + tl!("The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved.") + }; + crate::i18n::fmt(template, &[("c", &n.ch.to_string()), ("code", &format!("U+{:04X}", u32::from(n.ch)))]) +} + const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// The text being typed or retyped in place. diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index bd57ea4c..41126bd0 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -138,6 +138,11 @@ impl PdfCraftApp { if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { self.quick_tool = crate::QuickTool::Select; } + // Text saved as typed but drawn with `?` (its font can't draw it): said, not silent. + // Before the scripts' output, so an alert they raise still shows. + if let Some(n) = self.session.get(id).and_then(|d| d.edit_notices.first()).cloned() { + self.notify(crate::content_ui::undrawn_message(&n)); + } let out = self.session.take_js_output(id); self.handle_js(id, out); true @@ -1310,6 +1315,29 @@ mod revert_draft_tests { } } + /// A value the field's font can't draw is kept, and the user is told why it shows `?`, in the + /// interface language; a drawable value says nothing. + #[test] + fn a_value_its_font_cannot_draw_is_kept_and_explained() { + let mut app = app(); + let fill = + |app: &mut PdfCraftApp, text: &str| app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text(text.into()) }); + assert!(fill(&mut app, "张三")); + let toast = app.toast.as_ref().map(|t| t.0.clone()).expect("a notice"); + assert!(toast.contains("“张” (U+5F20)") && toast.contains("other PDF viewers redraw the field"), "{toast}"); + assert_eq!(app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "name").unwrap().value, ["张三"]); + // The shell sets the drawing language each frame; here, directly (per thread). + let previous = crate::i18n::current(); + crate::i18n::set_current(crate::i18n::Lang::from_code("zh-hans").expect("registered")); + assert!(fill(&mut app, "李四")); + crate::i18n::set_current(previous); + let toast = app.toast.as_ref().map(|t| t.0.clone()).expect("a notice"); + assert!(toast.contains("此字段的字体无法显示“李”(U+674E)"), "{toast}"); + app.toast = None; + assert!(fill(&mut app, "Ada")); + assert!(app.toast.is_none(), "{:?}", app.toast); + } + #[test] fn unsaved_flag_follows_dirty_state() { use std::sync::atomic::Ordering; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index f6904d3c..097afcc6 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2152,6 +2152,8 @@ calendar date December ديسمبر Reverse closed arrow سهم مغلق معكوس Slash شرطة مائلة The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. الخطوط القياسية لا تستطيع رسم «{c}» ({code}). حاليًا يمكن إضافة محارف أوروبا الغربية فقط كنص. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. خط هذا الحقل لا يستطيع رسم «{c}» ({code})، لذا يعرض PdfCraft علامة ? مكانه. القيمة محفوظة، وتعيد برامج عرض PDF الأخرى رسم الحقل منها. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. الخطوط التي يكتب بها PdfCraft لا تستطيع رسم «{c}» ({code})، لذا يظهر بشكل ? في كل برامج عرض PDF. النص محفوظ. Larger pages صفحات أكبر Smaller pages صفحات أصغر Reset page size إعادة ضبط حجم الصفحة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3da0853c..ecfab7a9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2094,6 +2094,8 @@ action wizard Start Старт 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}). Засега като текст могат да се добавят само западноевропейски символи. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Шрифтът на това поле не може да покаже „{c}“ ({code}), затова PdfCraft показва ? на негово място. Стойността е запазена и другите програми за PDF преначертават полето по нея. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Шрифтовете, с които PdfCraft пише, не могат да покажат „{c}“ ({code}), затова във всяка програма за PDF то се показва като ?. Текстът е запазен. Larger pages По-големи страници Smaller pages По-малки страници Reset page size Възстанови размера на страниците diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c579e549..04110e5b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2324,3 +2324,5 @@ scroll speed Fast Rychlá Original Originál Greyscale Stupně šedi Black and white Černobílé + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Písmo tohoto pole neumí zobrazit „{c}“ ({code}), proto PdfCraft místo něj ukazuje ?. Hodnota se uloží a jiné prohlížeče PDF pole podle ní překreslí. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Písma, kterými PdfCraft píše, neumí zobrazit „{c}“ ({code}), proto se ve všech prohlížečích PDF zobrazí jako ?. Text se uloží. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ccf07c12..b498a892 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2136,6 +2136,8 @@ action wizard Start Starten 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. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Die Schrift dieses Felds kann „{c}“ ({code}) nicht darstellen, daher zeigt PdfCraft an dieser Stelle ? an. Der Wert ist gespeichert, und andere PDF-Viewer zeichnen das Feld anhand des Werts neu. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Die Schriften, mit denen PdfCraft schreibt, können „{c}“ ({code}) nicht darstellen, daher erscheint es in jedem PDF-Viewer als ?. Der Text ist gespeichert. Larger pages Größere Seiten Smaller pages Kleinere Seiten Reset page size Seitengröße zurücksetzen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3c6c2ab4..26fd3792 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2266,3 +2266,5 @@ scroll speed Fast Γρήγορη Original Πρωτότυπο Greyscale Κλίμακα του γκρι Black and white Ασπρόμαυρο + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Η γραμματοσειρά αυτού του πεδίου δεν μπορεί να σχεδιάσει το «{c}» ({code}), γι' αυτό το PdfCraft δείχνει ? στη θέση του. Η τιμή αποθηκεύεται και άλλα προγράμματα προβολής PDF επανασχεδιάζουν το πεδίο από αυτήν. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Οι γραμματοσειρές με τις οποίες γράφει το PdfCraft δεν μπορούν να σχεδιάσουν το «{c}» ({code}), γι' αυτό εμφανίζεται ως ? σε κάθε πρόγραμμα προβολής PDF. Το κείμενο αποθηκεύεται. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 62a6e601..b751dbea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2049,6 +2049,8 @@ Go to page… Ir a la página… For page sizes and margins. Para tamaños de página y márgenes. 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. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. La fuente de este campo no puede mostrar «{c}» ({code}), así que PdfCraft muestra ? en su lugar. El valor se guarda y los demás visores de PDF vuelven a dibujar el campo a partir de él. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Las fuentes con las que escribe PdfCraft no pueden mostrar «{c}» ({code}), así que aparece como ? en todos los visores de PDF. El texto se guarda. Open arrow Flecha abierta Closed arrow Flecha cerrada Butt Extremo recto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4e6bd6e4..60e0752d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2090,6 +2090,8 @@ Go to page… Aller à la page… For page sizes and margins. Pour les tailles de page et les marges. 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. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. La police de ce champ ne peut pas afficher « {c} » ({code}) : PdfCraft affiche ? à sa place. La valeur est enregistrée, et les autres lecteurs PDF redessinent le champ à partir de celle-ci. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Les polices qu'utilise PdfCraft pour écrire ne peuvent pas afficher « {c} » ({code}) : il apparaît comme ? dans tous les lecteurs PDF. Le texte est enregistré. Larger pages Pages plus grandes Smaller pages Pages plus petites Reset page size Rétablir la taille des pages diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5ff1e26d..60dec82d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2128,6 +2128,8 @@ action wizard Start Indítás Reverse closed arrow Fordított zárt nyíl Slash Perjel 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. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. A mező betűtípusa nem tudja megjeleníteni ezt: „{c}” ({code}), ezért a PdfCraft ? jelet mutat a helyén. Az érték mentve van, és a többi PDF-megjelenítő ebből rajzolja újra a mezőt. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. A PdfCraft által íráshoz használt betűtípusok nem tudják megjeleníteni ezt: „{c}” ({code}), ezért minden PDF-megjelenítőben ? látszik a helyén. A szöveg mentve van. Larger pages Nagyobb oldalak Smaller pages Kisebb oldalak Reset page size Oldalméret visszaállítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 48fe778c..26a1e0a3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2144,6 +2144,8 @@ The document was closed before it could be saved. Il documento è stato chiuso prima che potesse essere salvato. The pages were extracted but not deleted, because the document changed while you were choosing a folder. Le pagine sono state estratte ma non eliminate, perché il documento è cambiato mentre sceglievi una cartella. The printer driver's own settings for this print: paper tray, paper type, finishing and more. Le impostazioni proprie del driver della stampante per questa stampa: vassoio, tipo di carta, finitura e altro. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Il font di questo campo non può disegnare «{c}» ({code}), quindi PdfCraft mostra ? al suo posto. Il valore è salvato e gli altri visualizzatori PDF ridisegnano il campo a partire da esso. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. I font con cui PdfCraft scrive non possono disegnare «{c}» ({code}), quindi appare come ? in ogni visualizzatore PDF. Il testo è salvato. The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. I caratteri standard non possono disegnare “{c}” ({code}). Per ora si possono aggiungere come testo solo caratteri dell'Europa occidentale. This printer offers no settings of its own. Questa stampante non offre impostazioni proprie. Today: {date} Oggi: {date} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 94d10925..2bbe998e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2020,6 +2020,8 @@ For page sizes and margins. ページサイズと余白に使います。 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})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。 + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. このフィールドのフォントでは「{c}」({code})を表示できないため、PdfCraft では ? と表示されます。値は保存され、他の PDF ビューアーではこの値からフィールドが再描画されます。 + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft が書き込みに使うフォントでは「{c}」({code})を表示できないため、どの PDF ビューアーでも ? と表示されます。テキストは保存されています。 Open arrow 開いた矢印 Closed arrow 閉じた矢印 Butt 平ら diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 1cd217d0..e6abf105 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2248,3 +2248,5 @@ scroll speed Fast Жылдам Original Түпнұсқа Greyscale Сұр реңктер Black and white Ақ-қара + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Бұл өрістің қаріпі «{c}» ({code}) таңбасын сала алмайды, сондықтан PdfCraft оның орнына ? көрсетеді. Мән сақталады, ал басқа PDF қарау құралдары өрісті соған қарап қайта салады. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft жазатын қаріптер «{c}» ({code}) таңбасын сала алмайды, сондықтан ол барлық PDF қарау құралдарында ? болып көрінеді. Мәтін сақталады. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 093f931d..2ebeae6f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2002,6 +2002,8 @@ Go to page… Przejdź do strony… Reopen the files that were open when PdfCraft last closed Otwórz ponownie pliki otwarte przy ostatnim zamknięciu PdfCraft The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standardowe czcionki nie obsługują znaku „{c}” ({code}). Na razie można dodawać tylko tekst ze znakami zachodnioeuropejskimi. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Czcionka tego pola nie obsługuje znaku „{c}” ({code}), więc PdfCraft wyświetla w jego miejscu ?. Wartość została zapisana, a inne przeglądarki PDF odrysują pole na jej podstawie. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Czcionki, którymi pisze PdfCraft, nie obsługują znaku „{c}” ({code}), więc w każdej przeglądarce PDF w jego miejscu widać ?. Tekst został zapisany. Open arrow Otwarta strzałka Closed arrow Zamknięta strzałka Butt Poprzeczna kreska diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 88b53958..9c17dd23 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2032,6 +2032,8 @@ This web address uses letters from another alphabet, which can look like familiar ones. Este endereço da web usa letras de outro alfabeto, que podem parecer familiares. Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Continue apenas se você confiar neste documento. Um endereço pode carregar informações do documento, como o que você digitou no formulário dele. The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. As fontes padrão não conseguem desenhar “{c}” ({code}). Por enquanto, apenas caracteres da Europa Ocidental podem ser adicionados como texto. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. A fonte deste campo não consegue desenhar “{c}” ({code}), então o PdfCraft mostra ? no lugar. O valor foi salvo, e outros leitores de PDF redesenham o campo a partir dele. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. As fontes que o PdfCraft usa para escrever não conseguem desenhar “{c}” ({code}), então ele aparece como ? em qualquer leitor de PDF. O texto foi salvo. Line ending Fim de linha Show Mostrar Sort Classificar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 947b74b0..8d424256 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2050,6 +2050,8 @@ Go to page… Перейти на страницу… For page sizes and margins. Для размеров страниц и полей. The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартные шрифты не могут отобразить «{c}» ({code}). Пока в виде текста можно добавлять только западноевропейские символы. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Шрифт этого поля не может отобразить «{c}» ({code}), поэтому PdfCraft показывает на его месте ?. Значение сохранено, и другие программы просмотра PDF перерисуют поле по нему. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Шрифты, которыми пишет PdfCraft, не могут отобразить «{c}» ({code}), поэтому во всех программах просмотра PDF на его месте будет ?. Текст сохранён. Larger pages Крупнее страницы Smaller pages Мельче страницы Reset page size Сбросить размер страниц diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index c6805c34..c03f604a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2255,3 +2255,5 @@ scroll speed Fast Hitro Original Izvirnik Greyscale Sivine Black and white Črno-belo + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Pisava tega polja ne more prikazati »{c}« ({code}), zato PdfCraft namesto njega prikaže ?. Vrednost se shrani, drugi pregledovalniki PDF pa polje znova narišejo iz nje. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Pisave, s katerimi piše PdfCraft, ne morejo prikazati »{c}« ({code}), zato se v vseh pregledovalnikih PDF prikaže kot ?. Besedilo se shrani. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f4c6213a..f429a63c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2246,3 +2246,5 @@ scroll speed Fast Hızlı Original Orijinal Greyscale Gri tonlamalı Black and white Siyah beyaz + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Bu alanın yazı tipi “{c}” ({code}) karakterini çizemiyor, bu yüzden PdfCraft yerine ? gösteriyor. Değer kaydedilir ve diğer PDF görüntüleyiciler alanı ondan yeniden çizer. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft'ın yazdığı yazı tipleri “{c}” ({code}) karakterini çizemiyor, bu yüzden her PDF görüntüleyicide ? olarak görünür. Metin kaydedilir. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 03160ba3..ad874cef 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2152,6 +2152,8 @@ calendar date December грудня Reverse closed arrow Зворотна закрита стрілка Slash Коса риска The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартні шрифти не можуть відобразити «{c}» ({code}). Поки що як текст можна додавати лише західноєвропейські символи. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Шрифт цього поля не може відобразити «{c}» ({code}), тому PdfCraft показує на його місці ?. Значення збережено, і інші програми перегляду PDF перемалюють поле за ним. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Шрифти, якими пише PdfCraft, не можуть відобразити «{c}» ({code}), тому в усіх програмах перегляду PDF на його місці буде ?. Текст збережено. Larger pages Більші сторінки Smaller pages Менші сторінки Reset page size Скинути розмір сторінок diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d649c3b5..16a1d7ef 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2101,6 +2101,8 @@ Go to page… 转到页面… For page sizes and margins. 用于页面尺寸和边距。 The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 标准字体无法显示“{c}”({code})。目前只能添加西欧字符作为文本。 + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. 此字段的字体无法显示“{c}”({code}),PdfCraft 会在该处显示 ?。值已保存,其他 PDF 阅读器会根据该值重新绘制此字段。 + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft 写入时使用的字体无法显示“{c}”({code}),因此它在所有 PDF 阅读器中都会显示为 ?。文本已保存。 Larger pages 放大页面 Smaller pages 缩小页面 Reset page size 重置页面大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0e1d0bd5..c689159a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2011,6 +2011,8 @@ For page sizes and margins. 用於頁面尺寸和邊距。 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})。目前只能新增西歐字元作為文字。 + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. 此欄位的字型無法顯示「{c}」({code}),PdfCraft 會在該處顯示 ?。值已儲存,其他 PDF 閱讀器會依據該值重新繪製此欄位。 + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft 寫入時使用的字型無法顯示「{c}」({code}),因此它在所有 PDF 閱讀器中都會顯示為 ?。文字已儲存。 Open arrow 開口箭頭 Closed arrow 閉合箭頭 Butt 平頭 diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 44896d40..e3413112 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -584,6 +584,18 @@ impl Emitter<'_> { ); } Item::Text(s) => { + // The form's own text, set in the standard fonts: what they can't draw is + // shown as `?` and reported once (the layout goes on), never silently. + if let Some(c) = pdfcraft_fonts::first_undrawable(&s.text) { + const PREFIX: &str = "some of the form's text has characters the standard fonts can't draw"; + if !self.warnings.iter().any(|w| w.starts_with(PREFIX)) { + self.warnings.push(format!( + "{PREFIX} (the first is \"{}\" (U+{:04X})); they are shown as ? until PdfCraft can embed fonts for them", + c.escape_debug(), + u32::from(c) + )); + } + } let name = s.face.resource_name(); fonts.insert(name, s.face.family.base_font(s.face.bold, s.face.italic)); content.extend( diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 24260ec0..5f891991 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -191,6 +191,25 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() { assert!(!is_dynamic(&doc)); } +/// A dynamic form's own text that the standard fonts can't draw is laid out anyway and reported +/// once, never written as `?` silently. +#[test] +fn form_text_the_standard_fonts_cannot_draw_is_reported() { + let xdp = r##" +"##; + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + let report = render_into(&mut doc).unwrap(); + let hits: Vec<&String> = report.warnings.iter().filter(|w| w.contains("standard fonts can't draw")).collect(); + assert_eq!(hits.len(), 1, "reported once: {:?}", report.warnings); + assert!(hits[0].contains("\"申\" (U+7533)"), "{hits:?}"); + // Latin text alone reports nothing. + let latin = xdp.replace("申请表", "Application").replace("签名", "Signature"); + let mut doc = Document::open(Arc::new(shell(&latin))).unwrap(); + assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty();