M14: text the fonts can't draw is kept and reported, never written as ? silently (#1112)

* M14: text the fonts can't draw is kept and reported, never written as ? silently

Form values and captions, text boxes, callouts, Fill & Sign text, measurement labels and stamp by-lines keep text the standard fonts can't draw, draw ? for it, mark the annotation (PCUndrawn) and report the edit (interface notice, tools' warnings); forms set /NeedAppearances (not in PDF/A). Flattening, redaction overlay text, typed and visible signatures and create-from-text refuse instead. Reports escape such characters as [U+XXXX].

* Catalogs: the two new can't-draw messages in Czech, Slovene, Kazakh, Turkish and Greek; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Zeyu Li 2026-10-12 05:53:55 +08:00 committed by GitHub
parent d415749556
commit b91d38c073
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1149 additions and 82 deletions

View File

@ -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"

View File

@ -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.

View File

@ -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).

View File

@ -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<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.

View File

@ -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<Stream> {
/// 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 {

View File

@ -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]

View File

@ -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()))?;

View File

@ -969,7 +969,13 @@ impl Automation {
fn apply(&mut self, a: &Args, edit: Edit) -> Result<Value> {
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::<Vec<_>>());
}
Ok(out)
}
fn marks(&mut self, tool: &str, a: &Args) -> Result<Value> {
@ -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:?}"))),
};

View File

@ -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)",

View File

@ -303,7 +303,7 @@ pub fn tools() -> Vec<ToolDef> {
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<ToolDef> {
"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<ToolDef> {
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(

View File

@ -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"),
] {

View File

@ -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.

View File

@ -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<Document, CreateError> {
// 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<Document, CreateError> {
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;

View File

@ -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();

View File

@ -97,9 +97,45 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result<usize, E
flatten_which(doc, pages, Which::FillSign)
}
/// The first annotation flattening would turn into page content with `?` in it, before anything
/// changes: one whose appearance PdfCraft drew with `?` for characters its fonts can't draw
/// (`pdfcraft_fonts::UNDRAWN_MARK`: a form field's value, a comment's text), or a comment without
/// an appearance whose text the standard fonts can't draw. Its page (0-based), whether it is a
/// form field, and the character.
fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], which: Which) -> 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<usize, EditError> {
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<ObjRef> = HashSet::new();
for &i in pages {
@ -132,7 +168,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw only fails here; it is still removed below.
// A subtype this build cannot draw only fails here; it is still removed below. (Text
// the standard fonts can't draw was refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
for (k, entry) in list.iter().enumerate() {

View File

@ -395,12 +395,6 @@ pub fn undrawable_message(c: char) -> String {
format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c))
}
/// The first character of plain `text` that Helvetica/WinAnsi can't draw (line and page breaks
/// and tabs are layout, not glyphs).
pub fn first_undrawable_plain(text: &str) -> Option<char> {
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<u8> {
let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes();
v.extend(literal(&win_ansi(text)));

View File

@ -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 <FEFF6B228FCE> /F 4 >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V <FEFF6B228FCE> /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
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 <FEFF6B228FCE> /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.

View File

@ -85,7 +85,7 @@ impl Session {
pub fn compare_report(&self, old: DocId, new: DocId) -> Result<Arc<Vec<u8>>, 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

View File

@ -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<String>,
/// 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<TextNotice>,
}
/// 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<String>,
/// 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<TextNotice> {
let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() };
let redrawn_elsewhere = pdfcraft_forms::need_appearances(next);
let mut names: Option<std::collections::HashMap<pdfcraft_cos::ObjRef, String>> = None;
let mut out: Vec<TextNotice> = 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::<Vec<_>>())
.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<XfaLayout, String> {
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<String> {
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<pdfcraft_xfa::FieldDatum> {
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, &notes);
@ -2880,6 +2978,7 @@ impl Session {
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
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<Label, EditError> {
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<Arc<Vec<u8>>, 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<Arc<Vec<u8>>, 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<Vec<u8>>) -> Result<(SourceKind, Arc<Vec<u8>>), EditError> {
@ -3657,7 +3766,7 @@ impl Session {
pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result<Arc<Vec<u8>>, 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.

View File

@ -1170,6 +1170,34 @@ fn form_fixture() -> Vec<u8> {
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);

View File

@ -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;

View File

@ -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

View File

@ -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<char> {
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<u8> {
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<char> {
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<char> {
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<char> {
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<char>) {
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<u8> {
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);

View File

@ -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<char> {
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.");

View File

@ -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

View File

@ -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<char> {
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<char>) {
// The Format event: what is shown, not what is stored.
let formatted: Vec<String>;
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<u8> = Vec::new();
let show = |body: &mut Vec<u8>, 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<char> = None;
let mut show = |body: &mut Vec<u8>, 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,

View File

@ -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<u8>, 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<char>) {
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):

View File

@ -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<usize, FormError> {
let all = fields(doc);

View File

@ -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<String> = 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) <FEFF5317004E4EAC>] /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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="B"/></rdf:RDF></x:xmpmeta>"#;
let objs: Vec<String> = 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]

View File

@ -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] };

View File

@ -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<Report, Reda
if chosen.is_empty() {
return Err(RedactError::NothingToApply);
}
// Overlay text is drawn in Helvetica; applying can't be undone in the file, so text it can't
// draw (a mark another program made) is refused before anything changes, never burnt in as `?`.
if let Some((m, c)) = chosen.iter().find_map(|m| pdfcraft_fonts::first_undrawable(&m.overlay).map(|c| (m, c))) {
return Err(RedactError::Undrawable(format!(
"{}; change or clear that mark's overlay text, then apply again (nothing was redacted)",
pdfcraft_fonts::undrawable_message(&format!("the overlay text of a redaction mark on page {}", m.page + 1), c)
)));
}
let mut report = Report { marks: chosen.len(), ..Report::default() };
let mut by_page: Vec<usize> = chosen.iter().map(|m| m.page).collect();
by_page.sort_unstable();

View File

@ -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};

View File

@ -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<Stream, SignError> {
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<String> = 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())

View File

@ -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,

View File

@ -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.

View File

@ -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;

View File

@ -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 إعادة ضبط حجم الصفحة

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 Възстанови размера на страниците

Can't render this file because it contains an unexpected character in line 8 and column 16.

View File

@ -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ží.

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 51.

View File

@ -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. Το κείμενο αποθηκεύεται.

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 39.

View File

@ -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

Can't render this file because it contains an unexpected character in line 7 and column 44.

View File

@ -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}

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 平ら

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 қарау құралдарында ? болып көрінеді. Мәтін сақталады.

Can't render this file because it contains an unexpected character in line 813 and column 12.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -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 Сбросить размер страниц

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -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.

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -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.

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -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 Скинути розмір сторінок

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 重置页面大小

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -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 平頭

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -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(

View File

@ -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##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<draw w="3in" h="0.3in"><value><text>申请表</text></value></draw><draw w="3in" h="0.3in"><value><text>签名</text></value></draw><draw w="3in" h="0.3in"><value><text>Name</text></value></draw>
</subform></template></xdp:xdp>"##;
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();