From 3933f5fb23105532c7f06a4afc548e2b56064a4e Mon Sep 17 00:00:00 2001 From: McanKul Date: Sun, 4 Oct 2026 20:32:09 +0300 Subject: [PATCH] fix(pdf): reject unsafe font encodings --- src-tauri/src/pdf_engine/source_content.rs | 42 +++++++++ .../src/pdf_engine/source_content_integ.rs | 92 ++++++++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index c97c05a..67f3ed3 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -171,6 +171,8 @@ struct Flags { no_tounicode: bool, missing_font: bool, ambiguous: bool, + subset_font: bool, + custom_encoding: bool, masked: bool, shared: bool, geometry: bool, @@ -256,6 +258,8 @@ struct TextInspect { vertical: bool, no_tounicode: bool, ambiguous: bool, + subset_font: bool, + custom_encoding: bool, width: f64, font_id: ObjectId, } @@ -618,6 +622,8 @@ impl Walker<'_> { no_tounicode: inspect.no_tounicode, missing_font: inspect.missing_font, ambiguous: inspect.ambiguous, + subset_font: inspect.subset_font, + custom_encoding: inspect.custom_encoding, masked: gs.masked, geometry: geom_unsafe, ..Flags::default() @@ -798,6 +804,10 @@ fn pick_reason(flags: &Flags) -> (SourceCapability, Option) { Some("NO_TOUNICODE") } else if flags.ambiguous { Some("AMBIGUOUS_UNICODE") + } else if flags.subset_font { + Some("SUBSET_FONT") + } else if flags.custom_encoding { + Some("CUSTOM_ENCODING") } else if flags.masked { Some("MASKED_IMAGE") } else if flags.shared { @@ -1010,10 +1020,14 @@ fn inspect_text( let mut vertical = false; let mut no_tounicode = false; let mut ambiguous = false; + let mut subset_font = false; + let mut custom_encoding = false; let mut width_sum = 0.0; if let Some(font) = dict { type3 = is_type3(font); vertical = is_vertical(doc, font); + subset_font = is_subset_font(doc, font); + custom_encoding = !is_cid_or_type0(font) && has_custom_encoding(doc, font); if is_cid_or_type0(font) { if tounicode_usable(doc, font) { ambiguous = true; @@ -1036,6 +1050,8 @@ fn inspect_text( vertical, no_tounicode, ambiguous, + subset_font, + custom_encoding, width: (width_sum + tj_adj) / 1000.0 * size, font_id, } @@ -1046,6 +1062,32 @@ fn is_type3(font: &Dictionary) -> bool { || font.get(b"CharProcs").is_ok() } +fn is_subset_font(doc: &Document, font: &Dictionary) -> bool { + let Ok(Object::Name(name)) = font.get_deref(b"BaseFont", doc) else { + return false; + }; + name.len() > 7 + && name.get(6) == Some(&b'+') + && name[..6].iter().all(u8::is_ascii_uppercase) +} + +fn has_custom_encoding(doc: &Document, font: &Dictionary) -> bool { + if font.get(b"Encoding").is_err() { + return false; + } + let Ok(encoding) = font.get_deref(b"Encoding", doc) else { + return true; + }; + !matches!( + encoding, + Object::Name(name) + if matches!( + name.as_slice(), + b"StandardEncoding" | b"WinAnsiEncoding" | b"MacRomanEncoding" + ) + ) +} + fn is_cid_or_type0(font: &Dictionary) -> bool { match font.get(b"Subtype").ok().and_then(|o| o.as_name().ok()) { Some(b"Type0") | Some(b"CIDFontType0") | Some(b"CIDFontType2") => return true, diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index 0d85005..b7748cb 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -35,6 +35,8 @@ const FILE_CAP_BYTES: u64 = 400 * 1024 * 1024; const FROZEN_REASONS: &[&str] = &[ "MISSING_FONT", + "SUBSET_FONT", + "CUSTOM_ENCODING", "NO_TOUNICODE", "AMBIGUOUS_UNICODE", "TYPE3", @@ -784,6 +786,73 @@ fn classify_missing_font_is_not_supported() { assert_eq!(reason_code(&hits[0]).as_deref(), Some("MISSING_FONT")); } +#[test] +fn classify_subset_and_custom_encoded_fonts_are_not_supported() { + let scratch = Scratch::new("review-font-compatibility"); + + let mut subset = Dictionary::new(); + subset.set("Type", "Font"); + subset.set("Subtype", "Type1"); + subset.set("BaseFont", "ABCDEF+Helvetica"); + let subset_path = scratch.file("subset.pdf"); + write_text_page_with_font( + &subset_path, + b"BT /F1 12 Tf 72 400 Td (Hi) Tj ET", + subset, + ); + let subset_hits = classify(&subset_path, "REVIEW-SUBSET-FONT"); + assert_unsupported( + first_of_kind(&subset_hits, "text", "REVIEW-SUBSET-FONT"), + "text", + "SUBSET_FONT", + "REVIEW-SUBSET-FONT", + ); + + let mut differences = Dictionary::new(); + differences.set("Type", "Encoding"); + differences.set("BaseEncoding", "WinAnsiEncoding"); + differences.set( + "Differences", + vec![Object::Integer(72), Object::Name(b"customH".to_vec())], + ); + let mut custom = Dictionary::new(); + custom.set("Type", "Font"); + custom.set("Subtype", "Type1"); + custom.set("BaseFont", "Helvetica"); + custom.set("Encoding", differences); + let custom_path = scratch.file("custom-encoding.pdf"); + write_text_page_with_font( + &custom_path, + b"BT /F1 12 Tf 72 400 Td (Hi) Tj ET", + custom, + ); + let custom_hits = classify(&custom_path, "REVIEW-CUSTOM-ENCODING"); + assert_unsupported( + first_of_kind(&custom_hits, "text", "REVIEW-CUSTOM-ENCODING"), + "text", + "CUSTOM_ENCODING", + "REVIEW-CUSTOM-ENCODING", + ); +} + +#[test] +fn classify_named_standard_encoding_remains_supported() { + let scratch = Scratch::new("review-standard-encoding"); + let path = scratch.file("win-ansi.pdf"); + let mut font = Dictionary::new(); + font.set("Type", "Font"); + font.set("Subtype", "Type1"); + font.set("BaseFont", "Helvetica"); + font.set("Encoding", "WinAnsiEncoding"); + write_text_page_with_font(&path, b"BT /F1 12 Tf 72 400 Td (Hi) Tj ET", font); + let hits = classify(&path, "REVIEW-STANDARD-ENCODING"); + assert_supported_text_or_image( + first_of_kind(&hits, "text", "REVIEW-STANDARD-ENCODING"), + "text", + "REVIEW-STANDARD-ENCODING", + ); +} + #[test] fn classify_repeated_form_occurrences_have_unique_locators() { let scratch = Scratch::new("review-repeated-form"); @@ -823,15 +892,36 @@ fn write_helvetica_page(path: &Path, content: &[u8]) { } fn write_helvetica_page_with_stream_dict(path: &Path, content: &[u8], stream_dict: Dictionary) { + let mut font = Dictionary::new(); + font.set("Type", "Font"); + font.set("Subtype", "Type1"); + font.set("BaseFont", "Helvetica"); + write_text_page_with_font_and_stream_dict(path, content, font, stream_dict); +} + +fn write_text_page_with_font(path: &Path, content: &[u8], font: Dictionary) { + write_text_page_with_font_and_stream_dict(path, content, font, Dictionary::new()); +} + +fn write_text_page_with_font_and_stream_dict( + path: &Path, + content: &[u8], + font: Dictionary, + stream_dict: Dictionary, +) { let mut doc = Document::with_version("1.7"); let pages_id = doc.new_object_id(); let content_id = doc.add_object(Object::Stream(Stream::new(stream_dict, content.to_vec()))); + let mut fonts = Dictionary::new(); + fonts.set("F1", Object::Dictionary(font)); + let mut resources = Dictionary::new(); + resources.set("Font", Object::Dictionary(fonts)); let mut page = Dictionary::new(); page.set("Type", "Page"); page.set("Parent", pages_id); page.set("MediaBox", box_obj([0, 0, 612, 792])); page.set("Contents", content_id); - page.set("Resources", Object::Dictionary(helvetica_resources())); + page.set("Resources", Object::Dictionary(resources)); let page_id = doc.add_object(Object::Dictionary(page)); let mut pages = Dictionary::new();