From 224416a3ddd92738431d455513ece4ba461cf2dd Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:50:04 +0000 Subject: [PATCH 1/9] Tests: the signing test's password meets the 8-character minimum (#2417) (#2420) signing_with_translated_labels_through_tools (from #1951) creates and uses a digital ID with the password "secret1"; #2201 ("harden .p12 digital IDs") now requires at least 8 characters. Each passed alone; together the test fails on main, and so does every PR's test job. The password is "secret12" in both places. Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh Co-authored-by: Claude Fable 5.1 --- crates/automation/tests/automation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f45aec3f57..ff1a759d6c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4527,11 +4527,11 @@ fn signature_appearances(path: &Path) -> String { fn signing_with_translated_labels_through_tools() { let dir = workdir("signing-labels"); let mut a = auto(&dir); - ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" })); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); let open = |a: &mut Automation| ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); let doc = open(&mut a); let args = |doc: u64, extra: Value, out: &str| { - let mut args = json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [10, 150, 190, 290], "reason": "Genehmigt", "location": "Berlin", "out": out }); + let mut args = json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 1, "rect": [10, 150, 190, 290], "reason": "Genehmigt", "location": "Berlin", "out": out }); for (k, v) in extra.as_object().unwrap() { args[k] = v.clone(); } From 1bc3a31814dd68ce1b67616559b8fdc4fb36c042 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 22:50:26 +0800 Subject: [PATCH 2/9] fix(parity): merge the keys concurrent merges left twice in acrobat-features.toml (#2419) Nine features carried `evidence`, `notes` (and once `tools`) twice, so the file was no longer valid TOML and `cargo xtask parity` stopped with "duplicate key". Each pair is merged into one: evidence and tools as the union of both lists, notes keeping every fact either side added. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- parity/acrobat-features.toml | 42 +++++++++++------------------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e841dec082..533013b547 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2620,10 +2620,8 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel"] -notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection." -evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] -notes = "Opening the Organize pages tool enters the overview directly. Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." +evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] +notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." [[feature]] id = "organize.multi-select-pages" @@ -3197,11 +3195,8 @@ tier = "P2" milestone = "M4" status = "partial" commands = [] -tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times"] -notes = "Read only: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults for what is missing or out of range, and doc_info lists them (DocInfo::transitions). Not yet: playing them in full screen, auto-advancing on /Dur, or the Page Transitions dialog that sets them" -tools = [] -evidence = [] +tools = ["doc_info", "page_transition_set"] +evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" [[feature]] @@ -4421,10 +4416,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"] +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"] notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4)" -evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)" [[feature]] id = "form.fill-checkbox-radio" @@ -5311,10 +5304,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add", "comment_edit", "comment_image_preview"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones"] -notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" -evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour"] -notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" +evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] +notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" [[feature]] id = "form.fill-sign-flatten" @@ -5821,10 +5812,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" @@ -7273,8 +7262,6 @@ commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"] -notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels" [[feature]] id = "optimize.downsample-images" @@ -7285,10 +7272,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" [[feature]] id = "optimize.recompress-images" @@ -9952,8 +9937,6 @@ status = "partial" commands = ["app.preferences"] tools = [] evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] -notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish translates them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish and Italian translate them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." [[feature]] @@ -10110,8 +10093,7 @@ status = "partial" commands = [] tools = [] evidence = [] -notes = "Release builds provide signed and notarized macOS artifacts and code-signed Windows installers when signing credentials are configured (packaging/macos/package.sh; packaging/windows/sign.ps1; README.md). Linux AppImage, Flatpak, deb and rpm packages ship, but are not code-signed. Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut and an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682), and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported." -notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut, an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682) and an optional (default on, INSTALLIMAGECONTEXTMENU) \"Create PDF with PdfCraft…\" entry for images; an update keeps whichever of the three the installed version has (#1058, #305). It confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." +notes = "Release builds provide signed and notarized macOS artifacts and code-signed Windows installers when signing credentials are configured (packaging/macos/package.sh; packaging/windows/sign.ps1; README.md). Linux AppImage, Flatpak, deb and rpm packages ship, but are not code-signed. Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut, an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682) and an optional (default on, INSTALLIMAGECONTEXTMENU) \"Create PDF with PdfCraft…\" entry for images; an update keeps whichever of the three the installed version has (#1058, #305). It confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported." [[feature]] id = "misc.web-deploy" From 491ce2da5bc4be9f44ce44a6d24fbc1a0456f0a8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 22:50:33 +0800 Subject: [PATCH 3/9] fix(i18n): zh-hant had two keys twice after concurrent merges (#2414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the Revert prompt to zh-hant.tsv; merged together, the catalog held both copies and `bundled_catalogs_are_consistent` (and the tests that load zh-hant as a reference) failed on main. Keep #1750's wording (字型大小, 「」 quotes, as the rest of the catalog) and drop the other copy. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f3791f719..a7853cd559 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2" +sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0ea252e56c..2ddb33dd6d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -787,7 +787,6 @@ Redaction code: 遮蔽代碼: Font: 字型: Font size: 字型大小: - Font size: 字號: Font size 字號 Auto-size text to fit redaction region 自動調整文字大小以適應遮蔽內容區域 Font colour: 字型顏色: @@ -1322,7 +1321,6 @@ Sort comments by: 註解排序依據: Create PDF Comment Summary 建立 PDF 註解摘要 Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到「{name}」的上次儲存版本?此後的更改將無法復原。 - Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到“{name}”的上次儲存版本?此後的更改將無法復原。 Save over the changed file? 覆寫已變更的檔案? “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. 「{name}」在開啟或上次儲存後已在磁碟上變更。現在儲存會以此版本取代那個較新的版本。 Save anyway 仍要儲存 From 5259491cf425a8645fed8d694a865529589f4bfd Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:50:39 +0200 Subject: [PATCH 4/9] Tests: update save and search regressions for current behavior (#2484) Co-authored-by: Codex --- crates/automation/src/lib.rs | 48 +++++++++++++++++++++++++-- crates/automation/tests/automation.rs | 22 ++++++++---- crates/ui-egui/src/canvas.rs | 7 +++- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index ad18f71124..88856305b2 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -932,11 +932,24 @@ impl Automation { // A save that adds or removes encryption, or must not keep the old revision (Sanitize, // redaction), is rewritten in full even when an incremental save was asked for. let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); + self.publish_save(id, &target, bytes, incremental, write) + } + + /// Validate the prepared bytes before publishing them or marking the document saved. + fn publish_save( + &mut self, + id: DocId, + target: &Path, + bytes: Arc>, + incremental: bool, + write: impl FnOnce(&Path, &[u8]) -> Result<()>, + ) -> Result { self.session.check_save(id, &bytes).map_err(failed)?; - write(&target, &bytes)?; + write(target, &bytes)?; let path = target.to_string_lossy().into_owned(); self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; - Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) })) + let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; + Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) } fn doc_revisions(&mut self, a: &Args) -> Result { @@ -2958,6 +2971,37 @@ mod tests { } } + #[test] + fn invalid_save_bytes_never_reach_publication_or_mark_saved() { + let dir = staging_dir("invalid-save-bytes"); + let target = dir.join("out.pdf"); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = search_document(&mut a, 2); + json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"})); + let old = std::fs::read(&target).unwrap(); + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + let before = summary(a.session.get(id).unwrap()); + // Exercise both parse failure and a valid PDF that loses a page. This does not + // depend on a malformed input remaining unrepaired by future reader improvements. + for bytes in [b"not a PDF".to_vec(), numbered_pages(1)] { + let mut published = false; + let result = a.publish_save(id, &target, Arc::new(bytes), false, |_, _| { + published = true; + Ok(()) + }); + assert!(result.is_err(), "invalid output must be refused"); + assert!(result.unwrap_err().to_string().contains("could not be reopened")); + assert!(!published, "validation must precede the writer"); + assert_eq!(summary(a.session.get(id).unwrap()), before, "failed validation must preserve document state"); + assert_eq!(std::fs::read(&target).unwrap(), old); + assert_eq!(listing(&dir), ["out.pdf"]); + } + json(&mut a, "doc_save", json!({"doc": id.0})); + assert!(!a.session.get(id).unwrap().dirty); + assert_ne!(std::fs::read(&target).unwrap(), old); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() { let dir = staging_dir("sync-dirty"); diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index ff1a759d6c..1e662d8f38 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -711,11 +711,10 @@ fn an_in_place_save_reports_a_required_rewrite_as_not_incremental() { } } -/// Issue #1698: a save whose bytes don't reopen is refused before it touches the file. The -/// catalog's `/Pages` is not a page tree, so the reader finds the page by scanning, while a -/// full rewrite keeps only what the catalog reaches and drops it. +/// #1664 repairs the missing page tree on open. The former #1698 failure fixture now +/// saves successfully, keeping its page and pixels through a full rewrite. #[test] -fn a_save_that_does_not_reopen_leaves_the_file_as_it_was() { +fn a_recovered_page_tree_survives_a_full_save() { let dir = workdir("save-verify-before-write"); let objs: [&[u8]; 4] = [ b"<< /Type /Catalog /Pages 3 0 R >>", @@ -743,10 +742,19 @@ fn a_save_that_does_not_reopen_leaves_the_file_as_it_was() { let opened = ok(&mut a, "doc_open", json!({ "path": "damaged.pdf" })); assert_eq!(opened["pages"], 1, "{opened}"); let doc = opened["doc"].as_u64().unwrap(); - let e = err(&mut a, "doc_save", json!({ "doc": doc, "full": true })); - assert!(e.to_string().contains("could not be reopened"), "{e}"); + let (width, height, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + assert!(pixels.as_ref().as_chunks::<4>().0.iter().any(|p| p[..3] != [255, 255, 255]), "the original page is not blank"); + let saved = ok(&mut a, "doc_save", json!({ "doc": doc, "full": true })); + assert_eq!(saved["document"]["dirty"], false); let on_disk = std::fs::read(dir.join("damaged.pdf")).unwrap(); - assert!(on_disk == original, "the file was replaced by {} bytes that don't reopen", on_disk.len()); + assert_ne!(on_disk, original, "a full save writes the repaired page tree"); + let mut reopened = auto(&dir); + let opened = ok(&mut reopened, "doc_open", json!({ "path": "damaged.pdf" })); + assert_eq!(opened["pages"], 1); + let again = opened["doc"].as_u64().unwrap(); + let (new_width, new_height, new_pixels) = reopened.render_pixels(again, 1, 1.0).unwrap(); + assert_eq!((new_width, new_height), (width, height)); + assert_eq!(new_pixels.as_ref(), pixels.as_ref(), "saving must preserve the recovered page's drawing"); } /// organize.field-name-conflicts: combining files whose forms share a field name keeps the diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8bc3a5c7a8..5941f6a4ae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5107,6 +5107,7 @@ trailer << /Root 1 0 R >> fn explicit_search_jumps_record_history_and_keep_match_destination() { for step in [None, Some(true), Some(false)] { let mut v = history_search_view(); + v.page_input = "draft in progress".into(); v.goto_point = Some((3, Some(0.8), Some(0.9), Vec2::ZERO)); if let Some(forward) = step { v.find_step(forward); @@ -5115,7 +5116,11 @@ trailer << /Root 1 0 R >> } assert_eq!(v.back, vec![0], "explicit search jump must remember its origin"); assert_eq!(v.goto, Some((3, 0.5)), "keep the match offset instead of the page top"); - assert_eq!(v.page_input, "4"); + assert_eq!(v.current, 3); + assert_eq!(v.current_page_label(None), "4"); + // Chrome refreshes the idle page box; navigation must not overwrite a draft + // that may still be focused. DocView does not own the editor's focus state. + assert_eq!(v.page_input, "draft in progress"); assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset"); v.go_to_match(1); assert_eq!(v.back, vec![0], "same-page matches do not duplicate history"); From 609a724235a5554fcc1b1b1e1af58d1afe5ddca6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:50:45 +0200 Subject: [PATCH 5/9] Fix main UI regressions in paging, fonts and test expectations (#2502) * Fix main UI regressions in paging, fonts and test expectations * Wait for asynchronous dropped-document preparation in regression --------- Co-authored-by: Codex --- crates/ui-egui/src/canvas.rs | 5 +++-- crates/ui-egui/src/theme.rs | 3 +++ crates/ui-egui/tests/control.rs | 11 ++++++++++- crates/ui-egui/tests/editing.rs | 5 ++++- crates/ui-egui/tests/fonts.rs | 9 +++++++-- crates/ui-egui/tests/print.rs | 8 +++++--- crates/ui-egui/tests/view.rs | 2 ++ 7 files changed, 34 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5941f6a4ae..989b731b7e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2397,7 +2397,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Paging owns the vertical axis in single-page view; leave it to the pager. // The rest of a gesture that turned the page is swallowed too, or it would pan the new page // away from the edge it was shown at. - if single_paging || (!view.continuous && view.wheel.gesture_turned()) { + let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned()); + if paging_owns_vertical { precise_scroll.y = 0.0; } if now < view.precise_scroll_until { @@ -2410,7 +2411,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it. // Asked of the preference, not of egui's animation time, which test harnesses zero. let animates = app.animation_effects.animates(app.motion.reduced); - let may_glide = !single_paging && !middle_gesture && navigation_enabled && unobstructed && animates; + let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d28a7b6580..3f43a03dcc 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -367,6 +367,9 @@ fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned()); fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned()); + // Keep missing Latin/Cyrillic letters in the app's own faces (for example Bulgarian ѝ, + // absent from JetBrains Mono), before egui's defaults or optional system/CJK fallbacks. + fonts.families.entry(FontFamily::Monospace).or_default().insert(1, "Inter".to_owned()); // The same static bytes pdfcraft-fonts uses for Japanese/Chinese text in PDFs: one copy, not two. for face in pdfcraft_fonts::ui_cjk_fonts(prefer_hans) { let name = face.name(); diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 847ffcc754..88ed1c0a90 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1603,7 +1603,15 @@ fn page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant() { #[test] fn navigation_history_commands_and_rail_are_per_tab_and_non_editing() { + for os in [egui::os::OperatingSystem::Nix, egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows] { + navigation_history_for_os(os); + } +} + +fn navigation_history_for_os(os: egui::os::OperatingSystem) { let (mut h, c) = harness(); + h.ctx.set_os(os); + h.run_steps(2); let enabled = |list: Value, id: &str| list["commands"].as_array().unwrap().iter().find(|cmd| cmd["id"] == id).unwrap()["enabled"].as_bool().unwrap(); assert!(!enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.previous_view")); @@ -1617,7 +1625,8 @@ fn navigation_history_commands_and_rail_are_per_tab_and_non_editing() { ok(&mut h, &c, "ui.set", json!({"key": "page", "value": "4"})); assert!(enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.previous_view")); // The real rail button is accessible and invokes the same history, not the adjacent page. - ok(&mut h, &c, "ui.click", json!({"label": &format!("Previous view ({})", if cfg!(target_os = "macos") { "⌘[" } else { "Ctrl+[" })})); + let shortcut = if h.ctx.os() == egui::os::OperatingSystem::Mac { "⌘[" } else { "Ctrl+[" }; + ok(&mut h, &c, "ui.click", json!({"label": &format!("Previous view ({shortcut})")})); assert_eq!(h.state().views[0].current, 0); assert!(enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.next_view")); ok(&mut h, &c, "ui.command", json!({"id": "view.next_view"})); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 65b90dad55..f8db525751 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1777,9 +1777,12 @@ fn files_dropped_beside_the_pages_panel_still_open_as_documents() { h.hover_at(egui::pos2(thumb.left() - 300.0, thumb.center().y)); h.run_steps(2); drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]); - h.run_steps(3); + // Native opening prepares the document on a worker; three UI frames need not give it + // any CPU time. Wait for the result, as the other dropped-document regression does. + support::wait_until(&mut h, "document dropped beside the Pages panel", |app| app.views.len() == 2 && app.password_prompt.is_none()); assert_eq!(h.state().views.len(), 2); assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]); + assert_eq!(texts_of(h.state(), 1), ["Page 1"], "the dropped PDF opens as its own document"); } fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 { diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index 258abe89c4..9cfa36c8ab 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -291,12 +291,16 @@ fn ui_fonts_work_without_craft_fonts() { } /// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Hungarian, Ukrainian, Italian, Slovene) are drawn entirely by the app's own faces -/// (Inter, JetBrains Mono): no letter falls through to egui's defaults or a CJK fallback. (egui's +/// (Inter, JetBrains Mono with Inter fallback): no letter falls through to egui's defaults or a CJK fallback. (egui's /// `has_glyphs` can't answer this: with only the primary face it is also the replacement face.) #[test] fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { use skrifa::MetadataProvider as _; let defs = theme::font_definitions(); + assert_eq!(&defs.families[&FontFamily::Monospace][..2], ["JetBrainsMono", "Inter"]); + let fallback = &defs.font_data["Inter"]; + let fallback_font = skrifa::FontRef::from_index(&fallback.font, fallback.index).unwrap(); + let fallback_charmap = fallback_font.charmap(); for (code, catalog) in [ ("cs", include_str!("../src/i18n/cs.tsv")), ("el", include_str!("../src/i18n/el.tsv")), @@ -325,7 +329,8 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { let data = &defs.font_data[name]; let font = skrifa::FontRef::from_index(&data.font, data.index).unwrap(); let charmap = font.charmap(); - let mut missing: Vec = text.chars().filter(|c| charmap.map(*c).is_none()).collect(); + let mut missing: Vec = + text.chars().filter(|c| charmap.map(*c).is_none() && (name != "JetBrainsMono" || fallback_charmap.map(*c).is_none())).collect(); missing.sort_unstable(); missing.dedup(); assert!(missing.is_empty(), "{code}: {name} lacks {missing:?}"); diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs index 48f12feab8..f3d10238cd 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/print.rs @@ -244,14 +244,16 @@ fn print_dialog_shows_the_printer_driver_options() { h.get_by_label("Printer properties"); h.get_by_label("Media"); h.get_by_label("Paper tray"); - assert_eq!(h.state().print_draft.job("form.pdf").options, [("InputSlot".to_string(), "Tray2".to_string())]); + // The selected sheet's media is always sent, independently of changed driver properties. + let media = vec![("media".to_string(), "Letter".to_string())]; + assert_eq!(h.state().print_draft.job("form.pdf").options, [("InputSlot".to_string(), "Tray2".to_string()), media[0].clone()]); h.get_by_label("Reset to printer defaults").click(); h.run_steps(2); - assert!(h.state().print_draft.job("form.pdf").options.is_empty(), "defaults are not sent"); + assert_eq!(h.state().print_draft.job("form.pdf").options, media, "driver defaults are not sent; selected paper is retained"); // Options read for another printer never go with this one's job. h.state_mut().print_draft.driver_choices.insert("InputSlot".into(), "Tray2".into()); h.state_mut().print_draft.printer = Some("Other_Queue".into()); - assert!(h.state().print_draft.job("form.pdf").options.is_empty()); + assert_eq!(h.state().print_draft.job("form.pdf").options, media, "another queue must not inherit driver options"); } /// The desktop app lists printers in the background (PowerShell on Windows takes a second or diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 65f4b31324..3fdeff637c 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -347,6 +347,7 @@ fn precise_touchpad_wheel_scrolls_one_to_one() { /// Steps `frames` frames and returns how far the document scrolled down over them, from the /// pages on screen in each pair of consecutive frames (a page can leave the screen on the way). +#[cfg(not(target_os = "macos"))] fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 { let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::>(); let mut moved = 0.0; @@ -363,6 +364,7 @@ fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 { /// A two-finger swipe down the page, 25 points a frame for six frames, lifted the frame after /// (the axis stop Wayland sends), at 300 % so the document is long enough to glide in: how far /// it scrolled by the frame after the lift (the page is drawn a frame behind its scroll). +#[cfg(not(target_os = "macos"))] fn swipe_and_lift(h: &mut Harness<'static, PdfCraftApp>) -> f32 { let at = h.state().views[0].viewport_rect().center(); h.hover_at(at); From dc41044ba78f6cef2ff4bdb14d19272f54ad6aea Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:50:50 +0200 Subject: [PATCH 6/9] Fill Vietnamese and Lithuanian catalog coverage gaps (#2510) Co-authored-by: Codex --- ATTRIBUTION.md | 4 +- ATTRIBUTION.toml | 8 ++-- crates/ui-egui/src/i18n/lt.tsv | 7 ++++ crates/ui-egui/src/i18n/vi.tsv | 68 +++++++++++++++++++++++++++++++++- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c9acb0512a..903d13284c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,8 +27,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index a7853cd559..34f17b5132 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -223,24 +223,24 @@ sha256 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339" path = "crates/ui-egui/src/i18n/lt.tsv" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5" +sha256 = "62151d79be5089d964876b6569d490ee3fdacef4388684b574be9627b1354e92" [[asset]] path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3" +sha256 = "f9002d56e44e49429e0cdeabb10525f79f32831b94233f3c0a8a0ecb202850d5" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 247b6cff95..936590768e 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2672,3 +2672,10 @@ key Space Tarpas Make properties default Nustatyti ypatybes kaip numatytąsias Pages selected Pasirinkti puslapiai Page properties Puslapio ypatybės + +# AI-assisted additions for concurrently merged UI features (2026-10-15). +# These additions have not had separate native-speaker review. + Mouse wheel scroll speed Pelės ratuko slinkimo greitis + The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. +theme Appearance Išvaizda diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a01b47f8a1..3a4f3903fa 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1,7 +1,7 @@ # Vietnamese (vi) catalog. Columns, separated by a TAB: context source translation # # Clean-room translations from PdfCraft's English UI labels. Reviewed by a native -# Vietnamese-speaking contributor. PhotoCraft/VectorCraft Vietnamese catalogs were used only as +# Vietnamese-speaking contributor for the original catalog; later additions are marked below. PhotoCraft/VectorCraft Vietnamese catalogs were used only as # translation memory when the English source and its meaning in context matched. Product and # format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain names where appropriate. # Plural rule: `plural_none` — every @plural row lists exactly one form. @@ -2615,3 +2615,69 @@ key Space Space Redact Image Xóa nội dung nhạy cảm của hình ảnh Create Link… Tạo liên kết… Recognize Text… Nhận dạng văn bản… + +# AI-assisted additions for concurrently merged UI features (2026-10-15). +# These additions have not had separate native-speaker review. + Characters Ký tự + Characters without spaces Ký tự không tính dấu cách + Counting words… {n} pages read Đang đếm từ… đã đọc {n} trang + Crop Image Xén hình ảnh + Current page only Chỉ trang hiện tại + DN: Tên phân biệt: + Date: Ngày: + Digitally signed by Được ký số bởi + Exclude image/OCR text Loại trừ văn bản trong ảnh/OCR + Exclude tagged code blocks Loại trừ khối mã đã gắn thẻ + Exclude tagged tables Loại trừ bảng đã gắn thẻ + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Việc loại trừ dựa trên thẻ PDF. Bảng và mã chưa gắn thẻ vẫn được tính. Loại trừ hình ảnh cũng loại bỏ văn bản ẩn. + Fill colour: Màu tô: + Font size Cỡ chữ + Full Screen Toàn màn hình + Location: Vị trí: + Make properties default Đặt thuộc tính làm mặc định + Mark as artifact Đánh dấu là thành phần trang trí + Mark content as artifact Đánh dấu nội dung là thành phần trang trí + Merge image fragments Gộp các mảnh hình ảnh + Mobile Di động + New file only Chỉ tệp mới + New file: {name} Tệp mới: {name} + Next tab Thẻ tiếp theo + No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Không tìm thấy máy in. Kiểm tra xem máy in đã được cài đặt và dịch vụ hàng đợi in đang chạy, rồi mở lại hộp thoại In. + Not now Để sau + Old file: {name} Tệp cũ: {name} + Opacity: Độ mờ: + Open in Full Screen mode? Mở ở chế độ toàn màn hình? + Optimizing… merging image fragments Đang tối ưu hóa… gộp các mảnh hình ảnh + Outline colour: Màu đường viền: + Page properties Thuộc tính trang + Pages counted Số trang đã đếm + Pages selected Số trang đã chọn + Previous tab Thẻ trước + Print as image In dưới dạng hình ảnh + Reason: Lý do: + Redaction mark appearance Hình thức dấu xóa nội dung nhạy cảm + Remove Crop Bỏ xén + Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là thành phần trang trí + Resolution: Độ phân giải: + Save anyway Vẫn lưu + Save over the changed file? Lưu đè lên tệp đã thay đổi? + Scroll speed (mouse wheel and arrow keys) Tốc độ cuộn (con lăn chuột và phím mũi tên) + Set page transitions Đặt hiệu ứng chuyển trang + Shown while the pointer is over a mark. Hiển thị khi con trỏ nằm trên một dấu. + Side by side Cạnh nhau + Some content could not be classified safely and was kept in the count. Một số nội dung không thể được phân loại an toàn và vẫn được tính. + Some pages contain no countable text. Scanned images need OCR first. Một số trang không có văn bản có thể đếm. Ảnh quét cần được nhận dạng văn bản (OCR) trước. + Standard Tiêu chuẩn + The labels are in English: the signature's font can't draw them in this language. Các nhãn được hiển thị bằng tiếng Anh: phông chữ của chữ ký không thể hiển thị chúng bằng ngôn ngữ này. + The password must have at least 8 characters. Mật khẩu phải có ít nhất 8 ký tự. + This document asks to open in Full Screen mode: the page fills the screen and nothing else shows. Press Esc at any time to leave it. Tài liệu này yêu cầu mở ở chế độ toàn màn hình: trang chiếm toàn bộ màn hình và không hiển thị nội dung nào khác. Nhấn Esc bất kỳ lúc nào để thoát. + This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tệp này đã thay đổi trên đĩa. Tải lại sẽ hiển thị phiên bản mới và bỏ các thay đổi chưa lưu của bạn; Lưu sẽ thay thế tệp bằng phiên bản này. + Trim the picture with its handles; Enter or Done applies Xén hình bằng các tay nắm; nhấn Enter hoặc Xong để áp dụng + Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Dùng khi máy in bỏ sót văn bản, chuyển sắc hoặc độ trong suốt. Bản in không còn chứa văn bản có thể tìm kiếm; với In thang độ xám, hình ảnh sẽ có màu xám. + What takes up the space in this file, by kind of content. Dung lượng mà từng loại nội dung chiếm trong tệp này. + Word count Số từ + Word counting stopped unexpectedly. Việc đếm từ đã dừng đột ngột. + Word count… Đếm từ… + Words Từ + “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này. +@id file.word_count Đếm từ… From 13e3154ab3c0eee937038ea188c0a731a331c5e7 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 12:48:23 -0700 Subject: [PATCH 7/9] fix(fonts): read the Annex D.2 alias codes as the ink they draw #2305 fixed WinAnsi 0xAD in `crates/fonts`, and closed with two loose ends it named: the user-visible extraction path goes through hayro, not that table, so it still reported the invisible character; and the same Annex D.2 note makes 0xA0 the `space` glyph. This finishes both, and makes the two tables in the product agree about the same byte. WinAnsiEncoding gives codes 0xA0 and 0xAD the glyph names `space` and `hyphen` -- the names it also gives 0x20 and 0x2D -- and MacRomanEncoding gives 0xCA `space`. The tables called them `nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are invisible formatting characters, so extraction hid ink the reader can see: "well-known" drawn with the 0xAD byte matched no search and no redaction pattern, and a non-breaking space did not match the space a reader typed. Evidence. Two sources transcribe the table the same way (an Annex D implementation's docs, and the WinAnsi.enc encoding vector R ships, which gives `/space` at octal 240 and `/hyphen` at 255), and a black-box check settles the rest: on a synthetic fixture pdftotext reads the base-encoded codes as `a-b a b`, and keeps U+00AD for a code an explicit `/Differences` names `sfthyphen`. We now do both. Over the 989-file pdf.js corpus the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26; everything left comes from `/ToUnicode` maps, which say what they mean and are not touched. Scope. The base table only, in the text direction only. - A `/Differences` entry naming `sfthyphen` or `nbspace` is a deliberate statement about one code and keeps its glyph list value, as pdftotext does. - Glyph lookup is untouched, so a font program's own `sfthyphen` program is still found first. In hayro that is why the alias sits in the Unicode path rather than in the generated encoding table: `code_to_ps_name` is shared with lookup. - Writing is unaffected: `encode` takes the lowest code showing a character, so ' ' and '-' still write 0x20 and 0x2D, and a caller holding a literal U+00A0 still writes 0xA0. Tests. `fonts` pins both tables and the whole decode/encode path including the `/Differences` case; `render` pins the extraction a reader actually gets, over WinAnsi, MacRoman and `/Differences`. Each was watched failing with each cell and each match arm reverted in turn, so no assertion is passing by accident. fonts 70, render 296+4, redact 49, a11y 43 and edit 156 are green, with fmt, clippy, `xtask layers` (37 crates) and `xtask assets` clean. Co-authored-by: Claude Opus 5 --- crates/fonts/src/encodings.rs | 15 +++--- crates/fonts/src/lib.rs | 40 +++++++++----- crates/fonts/src/pdf.rs | 26 +++++++++ crates/render/src/text.rs | 54 +++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/font/mod.rs | 23 ++++++++ .../hayro-interpret/src/font/standard_font.rs | 8 +++ vendor/hayro-interpret/src/font/true_type.rs | 6 +++ vendor/hayro-interpret/src/font/type1.rs | 16 ++++++ vendor/hayro-interpret/src/font/type3.rs | 3 ++ 10 files changed, 173 insertions(+), 20 deletions(-) diff --git a/crates/fonts/src/encodings.rs b/crates/fonts/src/encodings.rs index 98666802bb..88f0de5d12 100644 --- a/crates/fonts/src/encodings.rs +++ b/crates/fonts/src/encodings.rs @@ -5,11 +5,13 @@ pub(crate) static WIN_ANSI: [u32; 256] = [ 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 8364, 0, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338, - 0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, - // 0xAD is `hyphen`, not U+00AD: WinAnsiEncoding's table (Annex D.2) gives codes 0x2D and 0xAD - // the same glyph name, so the byte a Windows codepage calls a soft hyphen draws, and extracts - // as, an ordinary hyphen. Emitting U+00AD instead hides the character from search and from - // redaction's pattern matching, because a soft hyphen is invisible and matches nothing. + // 0xA0 is `space` and 0xAD is `hyphen`, not U+00A0 and U+00AD: WinAnsiEncoding's table + // (Annex D.2) gives those codes the same glyph names as 0x20 and 0x2D, so the bytes a Windows + // codepage calls a non-breaking space and a soft hyphen draw, and extract as, an ordinary + // space and hyphen. Emitting the formatting characters instead hides them from search and + // from redaction's pattern matching: a soft hyphen is invisible and matches nothing, and a + // non-breaking space does not match the space a reader typed. + 0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 32, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 45, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, @@ -21,7 +23,8 @@ pub(crate) static MAC_ROMAN: [u32; 256] = [ 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 196, 197, 199, 201, 209, 214, 220, 225, 224, 226, 228, 227, 229, 231, 233, 232, 234, 235, 237, 236, 238, 239, 241, 243, 242, 244, 246, 245, 250, 249, 251, 252, 8224, 176, 162, 163, 167, 8226, 182, 223, 174, 169, 8482, 180, 168, 8800, 198, 216, 8734, 177, 8804, 8805, 165, 181, 8706, 8721, 8719, 960, 8747, 170, 186, 937, 230, 248, 191, 161, 172, 8730, 402, - 8776, 8710, 171, 187, 8230, 160, 192, 195, 213, 338, 339, 8211, 8212, 8220, 8221, 8216, 8217, 247, 9674, 255, 376, 8260, 8364, 8249, 8250, 64257, + // 0xCA is `space`, the same glyph name Annex D.2 gives 0x20, for the reason WinAnsi 0xA0 is. + 8776, 8710, 171, 187, 8230, 32, 192, 195, 213, 338, 339, 8211, 8212, 8220, 8221, 8216, 8217, 247, 9674, 255, 376, 8260, 8364, 8249, 8250, 64257, 64258, 8225, 183, 8218, 8222, 8240, 194, 202, 193, 203, 200, 205, 206, 207, 204, 211, 212, 63743, 210, 218, 219, 217, 305, 710, 732, 175, 728, 729, 730, 184, 733, 731, 711, ]; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 73b4fbb08b..cd0d8358e5 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -305,31 +305,45 @@ mod tests { assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)"); } - /// WinAnsiEncoding's 0xAD is the `hyphen` glyph, not U+00AD. + /// WinAnsiEncoding's 0xAD is the `hyphen` glyph and its 0xA0 is `space`, not U+00AD and + /// U+00A0; MacRomanEncoding's 0xCA is `space` too. /// - /// Annex D.2 gives codes 0x2D and 0xAD the same glyph name, so the byte a Windows codepage - /// calls a soft hyphen draws an ordinary hyphen and must extract as one. Reading it as U+00AD - /// hid the character: a soft hyphen is invisible, so a search for "well-known" missed text - /// that drew exactly that, and `redact`'s pattern matching missed it too. One corpus fixture - /// alone extracted 104 of them. + /// Annex D.2 gives each of those codes the same glyph name as the ordinary one (0x2D, 0x20), + /// so the bytes a Windows codepage calls a soft hyphen and a non-breaking space draw ordinary + /// ink and must extract as it. Reading them as the formatting characters hid them: a soft + /// hyphen is invisible, so a search for "well-known" missed text that drew exactly that, and + /// `redact`'s pattern matching missed it too; a non-breaking space does not match the space a + /// reader types. Over the 989-file pdf.js corpus this takes the soft hyphens we extract from + /// 117 to 13 (104 of them in `XiaoBiaoSong.pdf` alone) and the non-breaking spaces from 30 to + /// 26; what remains comes from `/ToUnicode` maps, which say so explicitly and are not touched. + /// pdftotext reads both codes as the ordinary character. #[test] - fn win_ansi_soft_hyphen_byte_reads_as_an_ordinary_hyphen() { + fn the_alias_codes_read_as_the_glyph_they_draw() { assert_eq!(encodings::WIN_ANSI.get(0xAD), Some(&u32::from('-'))); assert_eq!(encodings::WIN_ANSI.get(0x2D), Some(&u32::from('-'))); - // Writing is unaffected: '-' takes the canonical low code, and a caller holding a literal - // U+00AD still writes the 0xAD byte, which is the one that draws a hyphen. + assert_eq!(encodings::WIN_ANSI.get(0xA0), Some(&u32::from(' '))); + assert_eq!(encodings::WIN_ANSI.get(0x20), Some(&u32::from(' '))); + assert_eq!(encodings::MAC_ROMAN.get(0xCA), Some(&u32::from(' '))); + assert_eq!(encodings::MAC_ROMAN.get(0x20), Some(&u32::from(' '))); + // MacRoman has no soft hyphen; 0xAD there is `notequal`, and keeps it. + assert_eq!(encodings::MAC_ROMAN.get(0xAD), Some(&0x2260)); + // Writing is unaffected: '-' and ' ' take the canonical low codes, and a caller holding a + // literal U+00AD or U+00A0 still writes the high byte, which is the one that draws them. assert_eq!(win_ansi_byte('-'), Some(0x2D)); + assert_eq!(win_ansi_byte(' '), Some(0x20)); assert_eq!(win_ansi_byte('\u{00ad}'), Some(0xAD)); - assert_eq!(win_ansi("a-b"), b"a-b"); + assert_eq!(win_ansi_byte('\u{00a0}'), Some(0xA0)); + assert_eq!(win_ansi("a-b c"), b"a-b c"); } #[test] fn win_ansi_covers_the_0x80_to_0x9f_glyphs() { // Every character in the WinAnsiEncoding table encodes to its code and back (#347), except - // where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, so it decodes - // to '-' and '-' encodes back to the canonical 0x2D. Round-tripping the *code* is what + // where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, and 0xA0 is + // `space`, the same glyph as 0x20, so each decodes to the ordinary character and that + // character encodes back to the canonical low code. Round-tripping the *code* is what // cannot hold there; round-tripping the character still does. - const ALIASES: &[u8] = &[0xAD]; + const ALIASES: &[u8] = &[0xA0, 0xAD]; for (code, &u) in encodings::WIN_ANSI.iter().enumerate() { let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue }; if ALIASES.contains(&(code as u8)) { diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 3ad34eeade..0cc456cba6 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1863,6 +1863,32 @@ mod encoding_tests { } } + /// The Annex D.2 alias codes read, through the whole decode path, as the ink they draw. + /// + /// This is the map `redact` matches its patterns on (`unicode_map`), so a code that decodes + /// to an invisible formatting character is a silent miss, not a cosmetic one: "well-known" + /// written with the 0xAD byte matched no search and no redaction pattern. + #[test] + fn the_alias_codes_decode_as_ordinary_ink_and_encode_back_to_the_low_code() { + let (doc, d) = simple("Helvetica", None, Some(Object::name("WinAnsiEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"well\xadknown"), "well-known"); + assert_eq!(m.decode(b"a\xa0b"), "a b"); + assert_eq!(m.encode("well-known a b").as_deref(), Some(&b"well-known a b"[..]), "the low code is the one written"); + + let (doc, d) = simple("Helvetica", None, Some(Object::name("MacRomanEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"a\xcab"), "a b"); + assert_eq!(m.decode(b"a\xadb"), "a\u{2260}b", "MacRoman 0xAD is `notequal`, not a hyphen"); + assert_eq!(m.encode("a b").as_deref(), Some(&b"a b"[..])); + + // An explicit /Differences name still says what it says: this is a property of those + // codes in those tables, not of the glyph names. + let (doc, d) = simple("Helvetica", None, Some(differences(vec![Object::Int(0x41), Object::name("sfthyphen"), Object::name("nbspace")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"AB"), "\u{ad}\u{a0}"); + } + /// Pins a deliberate restraint, so it is not "fixed" into a regression: a great many ordinary /// Latin TrueType subsets set the `/FontDescriptor` symbolic flag and still rely on a Latin /// encoding. Suppressing the implicit table on the flag would lose real text on a large share diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index ae5ffa9a9c..b767e28314 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1740,6 +1740,60 @@ mod tests { } } + /// One page per `(encoding, content)`, in Helvetica (`/F1`). + fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut kids = Vec::new(); + for (encoding, content) in pages { + let font = + doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => encoding.clone() }); + let content = doc.add_object(Stream::new(dictionary! {}, content.to_vec())); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages_id, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + kids.push(Object::Reference(page)); + } + let count = kids.len() as i64; + doc.objects.insert(pages_id, dictionary! {"Type" => "Pages", "Kids" => kids, "Count" => count}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() + } + + /// The Annex D.2 alias codes extract as the ink they draw, not as formatting characters. + /// + /// WinAnsiEncoding gives 0xAD and 0xA0 the glyph names `hyphen` and `space`, the names it + /// also gives 0x2D and 0x20, and MacRomanEncoding gives 0xCA `space`. Reporting the Adobe + /// Glyph List values U+00AD and U+00A0 for them hid visible text: a soft hyphen is invisible, + /// so "well-known" written that way matched no search and no redaction pattern. + /// `XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 of them. + /// + /// A `/Differences` entry naming `sfthyphen` or `nbspace` is a deliberate statement about one + /// code and keeps its glyph list value. pdftotext draws the same line: on this fixture it + /// reads the base-encoded codes as "a-b a b" and the named ones as U+00AD. + #[test] + fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let differences = Object::Dictionary(dictionary! { + "Type" => "Encoding", + "BaseEncoding" => "WinAnsiEncoding", + "Differences" => vec![Object::Integer(88), name("sfthyphen"), Object::Integer(89), name("nbspace")], + }); + let pdf = encoded_pages(&[ + (name("WinAnsiEncoding"), &b"BT /F1 20 Tf 1 0 0 1 20 150 Tm (a\xadb\xa0c) Tj ET"[..]), + (name("MacRomanEncoding"), &b"BT /F1 20 Tf 1 0 0 1 20 150 Tm (a\xcab) Tj ET"[..]), + (differences, &b"BT /F1 20 Tf 1 0 0 1 20 150 Tm (aXbYc) Tj ET"[..]), + ]); + let texts = + |page| extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().glyphs.iter().map(|g| g.text.clone()).collect::>(); + assert_eq!(texts(0), ["a", "-", "b", " ", "c"], "WinAnsi 0xAD and 0xA0"); + assert_eq!(texts(1), ["a", " ", "b"], "MacRoman 0xCA"); + assert_eq!(texts(2), ["a", "\u{ad}", "b", "\u{a0}", "c"], "a named glyph keeps its glyph list value"); + } + #[test] fn narrow_ligatures_keep_every_letter() { // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a diff --git a/vendor/README.md b/vendor/README.md index fb6588c142..87f9d8682e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,7 +9,7 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 5ccf5c48a6..7c8c8d086d 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -444,6 +444,29 @@ impl Encoding { Self::BuiltIn => None, } } + + /// PdfCraft patch: the glyph name an Annex D.2 *alias code* reads as when the glyph is taken + /// as text rather than looked up in a font program; `None` for every other code. + /// + /// Annex D.2 gives WinAnsiEncoding codes 0xA0 and 0xAD the glyph names `space` and `hyphen`, + /// the same names it gives 0x20 and 0x2D, and MacRomanEncoding 0xCA `space`. The tables above + /// call them `nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are + /// *formatting* characters, so extraction hid ink the reader can see: a soft hyphen is + /// invisible, and text that drew one matched no search and no redaction pattern -- one corpus + /// file alone produced 104 of them -- while a non-breaking space did not match the space a + /// reader typed. pdftotext reads both codes as the ordinary character. + /// + /// Only the base table is aliased, in the text direction only. A `/Differences` entry naming + /// `sfthyphen` or `nbspace` is a deliberate statement and keeps its glyph list value, which + /// is what pdftotext does too; and glyph *lookup* is untouched, so a font program's own + /// `sfthyphen` program is still found first. + fn text_alias(&self, code: u8) -> Option<&'static str> { + match (self, code) { + (Self::WinAnsi, 0xA0) | (Self::MacRoman, 0xCA) => Some("space"), + (Self::WinAnsi, 0xAD) => Some("hyphen"), + _ => None, + } + } } /// The font stretch. diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index c73c75dd22..139454a0b2 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -546,6 +546,14 @@ impl StandardKind { } pub(crate) fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index 40f3359030..b362669859 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -286,7 +286,13 @@ impl EmbeddedKind { self.differences .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)) + // The last resort reads the code as WinAnsi whatever the font said, so it takes the + // same aliasing. + .or_else(|| Encoding::WinAnsi.text_alias(code)) .or_else(|| win_ansi::get(code)) } diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 32b5e3ddd9..02f76bfb80 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -239,6 +239,14 @@ impl Type1Kind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } @@ -336,6 +344,14 @@ impl CffKind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index ccfeeb7664..324fa03dfe 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -108,6 +108,9 @@ impl<'a> Type3<'a> { .encodings .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; + // see `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)); name.and_then(crate::font::glyph_name_to_unicode) .or_else(|| (0x20..0x7f).contains(&code).then_some(code as char)) From 50cbf5fa195e327f1d2270adbe1bb2d58335d491 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 13:32:27 -0700 Subject: [PATCH 8/9] feat(oracle): score reading order, which word F1 cannot see `cargo xtask text-oracle` reported one number: word F1 against `pdftotext`. F1 is a bag of words. It catches a character read wrong, a glyph not read at all, a font we cannot map -- and it is blind to order, so a page emitted back to front scores a perfect 1.000. Reading order is most of what text extraction has left to get wrong: a column read into the middle of its neighbour (#2387), a figure's labels threaded through a paragraph (#2586), a form's value parted from its label. The hole is not hypothetical. Reviewing #2431, a change that moves text and alters nothing else, **word F1 called 26 of the 29 files whose output changed identical**, while scoring the word sequence separated 18 improvements from 6 regressions and named the shape of the regressions (forms: `prefilled_f1040.pdf` 0.672 -> 0.481, a checkbox's number parted from its label). The harness could not see the only thing that change does. So a second score per file: the longest common subsequence of the two word sequences, as a share of the longer one. It shows up in the first three corpus files -- `Test-plusminus.pdf` scores F1 1.000 and reading order 0.875, the same forty words in a title block that we read down the column and `pdftotext` reads across. Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages is plenty of signal; the run says how many files that bit, so the cap cannot quietly become the measurement. It reports; it does not gate, and like the pixel score it is a *disagreement* with Poppler rather than a verdict -- in the #2431 measurement the same split scored as a win on one file and a loss on another purely because `pdftotext` makes it in one place and not the other. The function's doc comment, `crates/oracle/README.md` and `docs/gaps.md` gap 12 all say so. `words` now takes the sequence rather than re-splitting the text, so both scores read the same tokens. `docs/file-format-parity.md` claimed plain-text export was good "(word-F1 0.98)" in a row whose own column is reading order; it now says the two are scored separately. Verified: `cargo xtask text-oracle --limit 60` end to end; `cargo test -p xtask` 49 passed; fmt and clippy clean. The unit test was watched failing with the score blinded to order (sorting both sides made it 1.000 where it must be below 0.7). The corpus-wide first reading is not in this commit: the machine is shared with a parallel build and the full sweep was stopped twice for memory. Co-authored-by: Claude Opus 5 --- crates/oracle/README.md | 26 +++++++- docs/architecture.md | 2 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 11 ++++ xtask/src/gates.rs | 132 +++++++++++++++++++++++++++++++++---- 5 files changed, 157 insertions(+), 16 deletions(-) diff --git a/crates/oracle/README.md b/crates/oracle/README.md index 7d2996b77b..04eea193aa 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -38,7 +38,7 @@ match pdfcraft_oracle::pdftotext() { |---|---|---| | `qpdf()` | qpdf | `--check` structural validation; the independent owner-password oracle | | `pdftoppm()` | Poppler | page pixels, for comparing against our own raster output | -| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 against it) | +| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 and reading order against it) | Each returns `Result`. `Oracle::command()` hands back a fresh `Command`; `Absent` renders as an actionable sentence naming the install command. @@ -92,6 +92,30 @@ picture through every corner of the format, so a correct decoder renders them al render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them. `crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. +## Comparing text + +`cargo xtask text-oracle` scores two different things against `pdftotext`, because one number +hides the other. + +**Word F1** is a bag of words: how much of what each side extracted the other also extracted, +with multiplicity. It catches a character read wrong, a glyph not read at all, a font we cannot +map. It is blind to *order* — a page emitted back to front scores 1.000. + +**Reading order** is the longest common subsequence of the two word sequences, as a share of the +longer one. It catches the failures F1 cannot see: a column read into the middle of its +neighbour, a figure's labels threaded through a paragraph, a form's value parted from its label. +Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages +is plenty of signal; the run says how many files that bit. + +The second score exists because of a measurement. Reviewing #2431, a change that moves text and +alters nothing else, word F1 called 26 of the 29 files whose output changed *identical*, while +the order score separated 18 improvements from 6 regressions — the regressions on forms, where +a checkbox's number was parted from its label. + +Read it the same way as the pixel score, and then once more: Poppler's reading order is a second +opinion, not the truth, and the same split can score as a win on one file and a loss on another +purely because `pdftotext` happens to make it in one place and not the other. + ## Still to do - Acrobat itself as the reference, rather than other open renderers. diff --git a/docs/architecture.md b/docs/architecture.md index a226cc8de6..aa91c36c4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,7 +101,7 @@ Every user-facing feature is reachable headlessly (`AGENTS.md` §3): `cargo xtask ci` runs fmt, clippy (`-D warnings`), tests, the layering check, the WASM check, `xtask assets` (asset licences and hashes), `xtask parity` (checklist claims must cite real commands, tools and tests) and `xtask deny` (dependency licences). Other tasks: `xtask fuzz`, `xtask corpus` and `xtask check` (robustness -sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 against `pdftotext`), +sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 and reading order against `pdftotext`), `xtask demo-pdf`, `xtask screenshots`, `xtask models` (OCR models), `xtask version`. Releases are built by `.github/workflows/release.yml` ([releasing.md](releasing.md)). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9a5f4d20d4..d7230c0962 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -86,7 +86,7 @@ features total). | PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial | | RTF | yes | yes | basic | `create.export-rtf` | | HTML (single page, multiple pages) | yes | single file with headings, paragraphs, images, tables | basic | `create.export-html` partial | -| Plain text | yes | yes (reading order) | good (word-F1 0.98 vs `pdftotext`) | `create.export-text`, `xtask text-oracle` | +| Plain text | yes | yes (reading order) | good (word-F1 0.98 vs `pdftotext`; reading order is scored separately, because F1 cannot see it) | `create.export-text`, `xtask text-oracle` | | Accessible text (from tags) | yes | no | none | `create.export-accessible-text` planned | | PNG, JPEG, TIFF | yes | yes | good | `create.export-png`, `create.export-jpeg-tiff` | | JPEG 2000 | yes | no | none | `create.export-jpeg2000` planned | diff --git a/docs/gaps.md b/docs/gaps.md index c006e48d29..75d6fa4f2b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -247,6 +247,17 @@ update the numbers. every appearance we *do* draw: over the same 250 files, shown gives mean 2.16% / p90 5.35% / 25 files within 0.1%, hidden gives 2.18% / 5.55% / 22, with `annotation-tx3.pdf` jumping to 45.64%. The biases nearly cancel; `--hide-annotations` tells the two apart for one file at a time. +- **The text score was blind to reading order (2026-10-15).** Word F1 is a bag of words, so a page + emitted back to front scores a perfect 1.000 — and reading order is most of what text extraction + has left to get wrong (#2387, #2586). `cargo xtask text-oracle` now reports a second score: the + longest common subsequence of the two word sequences over the longer one, both cut to 3,000 + words. The hole was not hypothetical. Measuring #2431, a change that moves text and alters + nothing else, **word F1 called 26 of the 29 files whose output changed identical**, where the + order score separated 18 improvements from 6 regressions and named their shape: forms, where a + checkbox's number was parted from its label (`prefilled_f1040.pdf` 0.672 → 0.481). Read it as a + disagreement, like the pixel score: the same split scored as a win on one file and a loss on + another purely because `pdftotext` makes it in one place and not the other. The corpus-wide + first reading has not been taken yet. - **Evidence:** text extraction (word-F1 0.98 against `pdftotext`) and now rendering (above); qpdf checks written output on every CI run since `PDFCRAFT_ORACLES_REQUIRED`. Acrobat captures must stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 6591233e91..8e7db8cf08 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -196,8 +196,13 @@ pub fn check(args: &[String]) -> anyhow::Result<()> { } /// `cargo xtask text-oracle [--limit N]`: compare `pdfcraft-cli text` with poppler's `pdftotext` -/// (external oracle process, never linked) over the corpus. Reports word-level F1 per file and the -/// median. Plan target (M2): median ≥ 0.97. +/// (external oracle process, never linked) over the corpus. +/// +/// Two scores per file, because one hides the other. **Word F1** ([`f1`]) is a bag of words: it +/// catches a character read wrong, a glyph not read at all, a font we cannot map, and is blind to +/// order. **Reading order** ([`order_score`]) catches what F1 cannot see — a column read into the +/// middle of its neighbour, a figure's labels threaded through a paragraph. Reports both; gates +/// neither. Plan target (M2): median F1 ≥ 0.97. pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let limit: usize = args.iter().position(|a| a == "--limit").and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(usize::MAX); let pdftotext = match pdfcraft_oracle::pdftotext() { @@ -211,8 +216,11 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { files.sort(); let cli = release_cli(); let mut scores = Vec::new(); + let mut orders = Vec::new(); let mut worst: Vec<(f64, String)> = Vec::new(); + let mut worst_order: Vec<(f64, String)> = Vec::new(); let mut not_permitted = 0usize; + let mut truncated = 0usize; for f in files.iter().take(limit) { // A document may forbid copying its text, and we honour that: `pdfcraft-cli text` refuses // and says how to override it with the owner password. pdftotext ignores the flag and @@ -225,29 +233,49 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20); let theirs = run_capture(pdftotext.command().arg("-q").arg(f).arg("-"), 20); let (Some(ours), Some(theirs)) = (ours, theirs) else { continue }; - let (a, b) = (words(&ours), words(&theirs)); + let (sa, sb) = (word_sequence(&ours), word_sequence(&theirs)); + let (a, b) = (words(&sa), words(&sb)); if b.len() < 20 { continue; // image-only or near-empty per the oracle } let score = f1(&a, &b); + let (order, cut) = order_score(&sa, &sb); + truncated = truncated.saturating_add(usize::from(cut)); + let name = f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); scores.push(score); - worst.push((score, f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())); + orders.push(order); + worst.push((score, name.clone())); + worst_order.push((order, name)); } if scores.is_empty() { bail!("no comparable files"); } - scores.sort_by(f64::total_cmp); + let summary = |v: &mut Vec| { + v.sort_by(f64::total_cmp); + let mean = v.iter().sum::() / v.len() as f64; + (v[v.len() / 2], mean, v[v.len() / 10]) + }; + let compared = scores.len(); + let (median, mean, p10) = summary(&mut scores); + let (omedian, omean, op10) = summary(&mut orders); worst.sort_by(|a, b| a.0.total_cmp(&b.0)); - let median = scores[scores.len() / 2]; - let mean = scores.iter().sum::() / scores.len() as f64; - println!("text oracle: {} files with text; median F1 {:.3}, mean {:.3}, p10 {:.3}", scores.len(), median, mean, scores[scores.len() / 10]); + worst_order.sort_by(|a, b| a.0.total_cmp(&b.0)); + println!("text oracle: {compared} files with text; median F1 {median:.3}, mean {mean:.3}, p10 {p10:.3}"); + println!( + " reading order: median {omedian:.3}, mean {omean:.3}, p10 {op10:.3}{}", + if truncated > 0 { format!(" ({truncated} cut to {MAX_ORDER_WORDS} words)") } else { String::new() } + ); if not_permitted > 0 { println!(" {not_permitted} skipped: the document forbids copying its text, which we honour and pdftotext does not"); } - println!("lowest:"); + println!("lowest F1:"); for (s, n) in worst.iter().take(12) { println!(" {s:.3} {n}"); } + println!("lowest reading order:"); + for (s, n) in worst_order.iter().take(12) { + println!(" {s:.3} {n}"); + } Ok(()) } @@ -416,14 +444,57 @@ fn run_capture_bytes(cmd: &mut Command, timeout_s: u64) -> Option> { Some(out) } -fn words(s: &str) -> BTreeMap { +/// The words of `s`, in the order they were extracted. +fn word_sequence(s: &str) -> Vec { + s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_lowercase).collect() +} + +fn words(seq: &[String]) -> BTreeMap { let mut m = BTreeMap::new(); - for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) { - *m.entry(w.to_lowercase()).or_insert(0) += 1; + for w in seq { + *m.entry(w.clone()).or_insert(0) += 1; } m } +/// Both word sequences are cut to this length before [`order_score`] compares them: the +/// comparison is quadratic, and three pages of prose is plenty of signal about reading order. +const MAX_ORDER_WORDS: usize = 3000; + +/// How much of the longer word sequence reads in the same order in both, and whether either side +/// was cut to [`MAX_ORDER_WORDS`] first. +/// +/// [`f1`] is a bag of words and cannot see order at all: a page emitted back to front scores a +/// perfect 1.000 against the page read properly. Reading order is most of what extraction has +/// left to get +/// wrong — a column read into the middle of its neighbour, a figure's labels threaded through a +/// paragraph, a form's value parted from its label — so it needs a number of its own. Reviewing +/// #2431, a change that moves text and alters nothing else, F1 called 26 of the 29 files whose +/// output changed identical, while this separated 18 improvements from 6 regressions. +/// +/// The score is the length of the longest common subsequence over the longer side. Like every +/// other oracle number here it is a *disagreement* with Poppler, not a verdict: the same split +/// scores as a win on one file and a loss on another purely because `pdftotext` happens to make +/// it in one place and not the other. +fn order_score(a: &[String], b: &[String]) -> (f64, bool) { + let cut = a.len() > MAX_ORDER_WORDS || b.len() > MAX_ORDER_WORDS; + let a = a.get(..a.len().min(MAX_ORDER_WORDS)).unwrap_or(a); + let b = b.get(..b.len().min(MAX_ORDER_WORDS)).unwrap_or(b); + let (n, m) = (a.len(), b.len()); + if n == 0 || m == 0 { + return (0.0, cut); + } + // Two rows of the usual table: after row `i`, `prev[j]` answers `a[..i]` against `b[..j]`. + let (mut prev, mut cur) = (vec![0u32; m + 1], vec![0u32; m + 1]); + for x in a { + for (j, y) in b.iter().enumerate() { + cur[j + 1] = if x == y { prev[j].saturating_add(1) } else { cur[j].max(prev[j + 1]) }; + } + std::mem::swap(&mut prev, &mut cur); + } + (f64::from(prev[m]) / n.max(m) as f64, cut) +} + fn f1(a: &BTreeMap, b: &BTreeMap) -> f64 { let common: usize = a.iter().map(|(w, n)| (*n).min(*b.get(w).unwrap_or(&0))).sum(); let (na, nb): (usize, usize) = (a.values().sum(), b.values().sum()); @@ -436,7 +507,42 @@ fn f1(a: &BTreeMap, b: &BTreeMap) -> f64 { #[cfg(test)] mod tests { - use super::root; + use super::{f1, order_score, root, word_sequence, words}; + + /// The two scores answer different questions, and the point of adding the second one is the + /// first row: F1 cannot tell a page from the same page read backwards. + #[test] + fn reading_order_sees_what_word_f1_cannot() { + let score = |ours: &str, theirs: &str| { + let (a, b) = (word_sequence(ours), word_sequence(theirs)); + (f1(&words(&a), &words(&b)), order_score(&a, &b).0) + }; + + // Two columns, read as columns and read interleaved: every word is there either way. + let columns = "left one left two left three right one right two right three"; + let woven = "left one right one left two right two left three right three"; + let (same_words, out_of_order) = score(woven, columns); + assert_eq!(same_words, 1.0, "F1 is a bag of words and cannot see the difference"); + assert!(out_of_order < 0.7, "reading order can: {out_of_order}"); + + // It is not blind to the thing F1 measures, either: a missing word costs both. + let (f, o) = score("left one left two left", columns); + assert!(f < 0.9 && o < 0.9, "{f} {o}"); + + // Identical text, and the degenerate inputs. + assert_eq!(score(columns, columns), (1.0, 1.0)); + assert_eq!(score("", columns).1, 0.0); + assert_eq!(score(columns, "").1, 0.0); + assert_eq!(order_score(&[], &[]), (0.0, false)); + + // Case and punctuation are not order: both sides are folded the same way. + assert_eq!(score("Left, one. LEFT two!", "left one left two"), (1.0, 1.0)); + + // Over the cap, the comparison still answers, and says that it was cut. + let long: Vec = (0..super::MAX_ORDER_WORDS + 10).map(|i| i.to_string()).collect(); + let (s, cut) = order_score(&long, &long); + assert!((s - 1.0).abs() < 1e-9 && cut, "{s} {cut}"); + } // Issue #211: Finder lists the app under Open With > Recommended only when the // bundle claims PDFs by UTI (#176). From 99941daa5af17155becd2aec66d072ca0f4b8a14 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 13:34:25 -0700 Subject: [PATCH 9/9] docs(roadmap): record the day's two changes `CLAUDE.md` asks for a progress-log line and a status line at the end of a session. - The Annex D.2 alias codes, in both tables (`13e3154a`). - Reading order in the text oracle (`50cbf5fa`), with gap 12's revision history. Co-authored-by: Claude Opus 5 --- ROADMAP.md | 3 +++ docs/gaps.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index fe9723fdf6..66b9677569 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -156,6 +157,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. +- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). diff --git a/docs/gaps.md b/docs/gaps.md index 75d6fa4f2b..a3ce0d1f17 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -377,6 +378,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. | | 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) | | 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. | | 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. |