From a6d48707f7b6734f68798cf8e75de420fd1cdc6d Mon Sep 17 00:00:00 2001 From: Baltsar Date: Mon, 12 Oct 2026 14:52:06 +0200 Subject: [PATCH 01/70] feat(preflight): verify image resolution and bleed without rewriting the file (#1408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * feat(preflight): report image resolution and bleed without rewriting the file * test upload session tests only * Include the print-readiness module from the OCR module so the crate compiles. * Record the one-line print-readiness patch so the dispatch can be restored exactly. * Restore the print-readiness dispatch from the original sources. * M3: select an image on the page and copy it (#1363) * M3: select an image on the page and copy it Select tool: a click on a raster image selects it (also one drawn through Form artwork, or with text over it such as an OCR layer); ⌘C copies it as a file in its original format (a JPEG as stored in the PDF, otherwise a lossless PNG). Right-click ▸ Copy Image copies its pixels at full resolution, and Copy Image in Original Format the file; Edit text & images has both for the selected image. Both respect the document's copy permission. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWUuTDPKL5wivnSJuCkC8Q * M3: translate the copy-image strings into Czech, Kazakh, Slovene and Turkish Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWUuTDPKL5wivnSJuCkC8Q * Copy image: keep the clipboard file in a private per-user folder Copy Image in Original Format wrote into the shared temporary folder (/tmp/pdfcraft-clipboard) and deleted everything in it first, so another user on the machine could plant a link or folder there and have PdfCraft delete or overwrite their target. The file now goes in PdfCraft's per-user cache folder (or the portable data folder), which is checked to be a real folder and set to 0700 on Unix (refused when it isn't ours). Only earlier copies (regular files named ' p img.jpg|png') are removed, links are never followed, and the new file is created with create_new so it can't be redirected. Co-Authored-By: Claude Opus 5.5 * Tests: escape Windows paths in synthetic GoToR file specifications canonicalize() returns \\?\C:\... on Windows; unescaped backslashes in a PDF literal string become control characters, so link_open refused the target and main's Windows CI failed. Taken from #1359's helper fix. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Co-authored-by: Brandon Thomas * fix(cli): edit refuses move/insert destinations past the end (#1330) (#1400) Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 * fix(sign): refuse to sign a field whose /V is not a signature (#1328) (#1402) Follow-up to #1374. Signing an existing field refused a field whose /V is a signature dictionary and signed one whose /V is missing or null, but a /V that is neither (an empty string, a number, a name, an array) was treated as unsigned and overwritten with the new signature. Signing such a field by name now fails with "the value of the field ... is not a signature" and leaves the document as it is. A missing or null /V, direct or indirect, still signs, and a signed field is still refused as already signed. The signature list is unchanged: it still shows such a field as unsigned, since it carries no signature. Adds a_field_whose_value_is_not_a_signature_is_refused_by_name, which fails before this change. * fix(forms): keep a whitespace-only value in a text field without a format (#1323) (#1403) The commit-time Keystroke check returned an empty value for any whitespace-only text before looking at the field's format, so setting a space, a tab or a non-breaking space in a plain text field reported success and removed the field's value. Text and XFDF imports go through the same setter and lost the value the same way. Blank input now clears only a field with a format (number, percent, date, time, special or mask), as before; a field without one stores the text as given. An empty value still clears any field. Adds tests::whitespace_only_text_is_kept_in_a_field_without_a_format and a keystroke assertion in af::tests::numbers_format_like_acrobat, both of which fail before this change. * fix(content): skip an inline image with an unreadable key to its EI (#1318) (#1404) When a key in an inline image dictionary was not a name (`BI /W 4 H 4 ID ... EI`), the parser ended the image at the end of the stream and counted nothing as skipped, so every operator after the image was lost: text_lines found no text on a page whose text follows such an image. An image whose parameters can't be read is now skipped up to the EI that ends it, the skipped bytes are counted, and parsing resumes after it. Without an EI the image still runs to the end of the stream, as before. Adds tests::an_unreadable_inline_image_is_skipped_to_its_end, which fails before this change. * fix(automation): doc_save reports a required full rewrite as not incremental (#1329) (#1405) doc_save reported "incremental": !full, from the arguments alone. When an in-place save had to be a full rewrite (after doc_protect adds or removes encryption, or after Sanitize), write_incremental fell back to write_full, but the result still said the save was incremental. The result now says whether the written file starts with the file it was saved over, read from the new Document::saved_bytes. An ordinary in-place save still reports true and an explicit full save false. Adds an_in_place_save_reports_a_required_rewrite_as_not_incremental, which fails before this change for both doc_protect and doc_remove_hidden. * fix(audit): an identity cm between re and fill no longer hides a cover (#1333) (#1410) Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 * fix(ui): Current page prints the viewed page, not a page labelled with its number (#1308) (#1411) The Print dialog turned Current page into the range "N" for the page on screen, and ranges read numbers as page labels first. In a document labelled i, ii, iii, 1, 2, viewing the first page printed the fourth, whose label is "1". Current page is now passed as a position, the way Selected pages is, through print::select_listed. A typed range still reads labels first. Adds print_ui::tests::current_page_is_the_viewed_page_not_a_label, which fails before this change. * fix(render): draw Turkish Ğ İ Ş ğ ş in non-embedded fonts (vendored hayro-interpret patch 26) (#1409) A PDF whose fonts aren't embedded is drawn with PDFium's Foxit stand-ins for the standard 14 fonts. Those carry only the 230-odd glyphs of the Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond them in its /Differences mapped to .notdef and drew nothing. iText 2 writes Turkish exactly this way (Times New Roman left unembedded, /Gbreve /Idotaccent /Scedilla /gbreve /scedilla over Ð Ý Þ ð þ), and such letters showed blanks for Ğ, İ, Ş, ğ and ş while ı, ç, ö and ü drew. StandardKind now builds a Latin Extended-A letter the stand-in lacks from the base letter and accent it does have, as Type 1 seac composites and the standard-14 AFM composites are built: the accent is centred on the letter, raised over capitals and letters with ascenders (and moved along the slant in italics), cedillas hang below and ogoneks from the right edge. Composite glyph ids sit above any font's glyph count, one per character code; their width comes from the standard metrics of the name. Test: raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts (fails without the patch: Gbreve drew no pixel). Co-authored-by: Claude Opus 5.5 * M10: edit OCR text on scanned pages so the change shows (#1397) * M10: edit OCR text on scanned pages so the change shows Editing a recognised word on a scan rewrote it as invisible OCR text, so the page still showed the old word, and the whole OCR layer grouped into one paragraph that rewrapped when one word changed. - An edited invisible paragraph is drawn visibly, over a cover in the paper colour sampled around it, in the scan's sampled ink colour (or the chosen colour). - OCR words group into phrases: a table cell or a tab stop splits them and each scanned line stays apart; a phrase grows rightwards. - Styled rewrites measure with the text matrix's horizontal scale, so stretched OCR words keep their widths instead of overlapping. - The inline editor covers the scanned word and grows like the result. * M10: size OCR text from each word's letters A word box from recognition is tight around the ink, but the text layer assumed every box spans Helvetica's ascender-descender height. A capitals-only word such as "DT" spans only the cap height, so its text was set about 23% too small and its baseline floated up; editing it then drew the new word visibly smaller than the scanned one. The em and baseline now come from the word's letters: cap height or x-height on top, descender or baseline at the bottom. On a real scanned form, a rewritten "DT" -> "DSI" now matches the scanned word's height and baseline. The engine OCR-edit tests build their words the way a scan would box them. * Edit OCR text: sample colours only for invisible blocks, from the current bytes Session::apply looked up every edited block with a fresh text_blocks pass and re-serialized the whole document to render the sampling tile. Text blocks are now cached per page on the editor (keyed on the unmodified document's source identity, so any edit, undo or save invalidates them), visible blocks skip sampling entirely, and the tile renders from the document's current bytes. Adds a visible stretched-text regression test and documents that editing any invisible text makes it visible over a painted cover. Co-Authored-By: Claude Opus 5.5 * Edit OCR text: name the cached text-blocks tuple (clippy), rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 * Edit text: support per-font visual-order Indic text and nukta reordering (#1380) * Edit text: support per-font visual-order Indic text and nukta reordering - Detect legacy visual-order Indic fonts via Metrics.visual_order (fonts with Indic codepoints without OpenType GSUB tables, or legacy visual font names). - Implement Indic visual-to-logical and logical-to-visual pre-base vowel reordering in crates/edit/src/indic.rs, with support for decomposed/precomposed nuktas and conjuncts. - Apply reordering during extraction and source font reuse only when visual_order is set, leaving modern logical-order text untouched. - Add unit tests for nuktas, conjuncts, and round-trips in crates/edit/src/indic.rs and font visual_order tests in crates/edit/src/tests.rs. * Visual-order Indic: positive evidence only, ActualText kept logical, no font decoding - A font is visual order only when it is a known legacy Indic family (subset tag allowed; aps-/dv- as prefixes) with an Indic Unicode mapping. A missing GSUB is no evidence (bare CFF and subset fonts drop it), so Metrics::from_dict no longer decodes every embedded font program on every call. - Lines with /ActualText text are already logical: skip indic::to_logical, so "बढ़िया" no longer becomes "बढ़यिा". - reading_lines no longer runs bidi recovery on every line (it changed RTL reading_blocks output for all documents); only the Indic recovery remains. - indic.rs indexes through get(). - Tests use a ToUnicode fixture (not visual-order ActualText), cover an edit round-trip, logical ActualText in a legacy font, and a subset Noto font. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 * M12: preview text structure proposals for auto-tagging (#1383) * M12: preview text structure proposals for auto-tagging * Auto-tag preview: no unregistered command, allowed on copy-restricted files like a11y.check The preview is read-only with no UI command yet, like render_preview, so it no longer names the Planned a11y.autotag catalog id (tool_table_is_well_formed). Co-Authored-By: Claude Opus 5.5 * M12: preview text structure proposals for auto-tagging * Keep read-only auto-tag preview separate from unavailable command --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 * M10: Test current-page print previews and saved output (#1415) Extend the coverage of #1411 with preview raster selection and a dialog-driven PDF save test. Reopen and render the saved PDF to verify the viewed physical page when labels restart. Cover duplicate labels and selection guards without changing production code. Refs #1308, #1411 Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> * fix(content): count an unreadable inline image with no EI as skipped (#1318) (#1418) Follow-up to #1404. An inline image whose parameters can't be read and that has no EI after them ran to the end of the stream as a BI op with no data, and those bytes were not counted. It is now skipped like one with an EI, its bytes counted in Parsed::skipped. A stream that ends inside the parameters (BI /W 1) still keeps its BI op. * feat(ui): add command palette arrow-key navigation (#1419) * M4: Compare and cache actual Combine file duplicates (#1420) Different equal-sized PDFs with the same name no longer receive duplicate warnings. Share exact-content detection between Grid and List, and cache the result using the existing list revision to remove the per-redraw all-pairs scan. Cover distinct contents, actual duplicates, source parts, cache reuse, removal, undo, redo and closing the tab. Both UI regressions fail before the fix. Fixes #1303 Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> * feat(preflight): verify image resolution and bleed without rewriting the file (#1408) Squashed from the PR branch (its history added and removed a workflow file; the final tree has no .github changes). * printcheck: proper engine module, one TrimBox note, count unmeasurable images Declares printcheck in engine/src/lib.rs instead of a #[path] module inside ocr.rs. Pages without a TrimBox get one informational document-level finding instead of a bleed warning per page; bleed is only measured against a real TrimBox. Placements with a non-finite or degenerate matrix are counted as resolution unknown (info finding, text line and JSON field) rather than passing every check. Adds a print_readiness test through Automation::call. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Francesco Clemeno Co-authored-by: Claude Co-authored-by: Brandon Thomas Co-authored-by: umesh429 Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com> Co-authored-by: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Co-authored-by: TheLastRoadRunner <99679216+TheLastRoadRunner@users.noreply.github.com> Co-authored-by: Tangu Achilis <43493154+TanguAchilis@users.noreply.github.com> Co-authored-by: krinry <121806532+krinry@users.noreply.github.com> Co-authored-by: Earl Co Co-authored-by: LabDennis Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> Co-authored-by: Pratham Bhanushali <134723366+Morpheus004@users.noreply.github.com> --- crates/automation/src/a11y.rs | 50 +++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 3 + crates/automation/tests/automation.rs | 23 ++ crates/engine/src/lib.rs | 1 + crates/engine/src/printcheck.rs | 474 ++++++++++++++++++++++++++ 6 files changed, 552 insertions(+) create mode 100644 crates/engine/src/printcheck.rs diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1bdae1e6d4..b10a94f455 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -652,6 +652,56 @@ impl Automation { })) } + pub(crate) fn print_readiness(&self, a: &Args) -> Result { + let report = self.doc(a)?.print_readiness(); + let findings: Vec = + report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); + let pages: Vec = report + .pages + .iter() + .map(|p| { + json!({ + "page": p.page, + "media_mm": p.media_mm, + "crop_mm": p.crop_mm, + "trim_mm": p.trim_mm, + "bleed_mm": p.bleed_mm, + "trim_set": p.trim_set, + "bleed_set": p.bleed_set, + "bleed_margin_mm": p.bleed_margin_mm, + }) + }) + .collect(); + let images: Vec = report + .images + .iter() + .map(|image| { + json!({ + "page": image.page, + "name": image.name, + "ppi": image.ppi, + "pixels": image.pixels, + "size_mm": image.size_mm, + }) + }) + .collect(); + let pdfa: Vec = report + .pdfa_issues + .iter() + .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) + .collect(); + Ok(json!({ + "text": report.text(), + "findings": findings, + "pages": pages, + "images": images, + "output_intents": report.output_intents, + "fonts_not_embedded": report.fonts_not_embedded, + "pdfa_issues": pdfa, + "resolution_unknown": report.resolution_unknown, + })) + } + pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { let level = self.pdfa_level(a)?; let id = self.doc(a)?.id; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8adc450349..eb681a95bb 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -729,6 +729,7 @@ impl Automation { "doc_compare" => self.doc_compare(&a)?, "action_list" => self.action_list()?, "pdfa_verify" => self.pdfa_verify(&a)?, + "print_readiness" => self.print_readiness(&a)?, "doc_export_office" => self.doc_export_office(&a)?, "pdfa_convert" => self.pdfa_convert(&a)?, "action_run" => self.action_run(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 727251a680..6b742d2612 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -977,6 +977,9 @@ pub fn tools() -> Vec { .ro() .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"], "description": "Default 2b." } }), &["doc"])), + t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.") + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 204d344ff7..ddfceccec7 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4300,6 +4300,29 @@ fn pdfa_through_tools() { assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); } +#[test] +fn print_readiness_through_tools() { + let dir = workdir("print-readiness"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Proof me" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "doc_info", json!({ "doc": doc })); + let r = ok(&mut a, "print_readiness", json!({ "doc": doc })); + assert!(r["text"].as_str().unwrap().contains("verification only"), "{r}"); + assert_eq!(r["pages"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(r["pages"][0]["trim_set"], false, "{r}"); + assert_eq!(r["resolution_unknown"], 0, "{r}"); + let findings = r["findings"].as_array().unwrap(); + // A page without a TrimBox is one informational note for the document, not a bleed warning. + assert!(!findings.iter().any(|f| f["id"] == "page-bleed"), "{r}"); + let trim: Vec<_> = findings.iter().filter(|f| f["id"] == "page-trim").collect(); + assert_eq!(trim.len(), 1, "{r}"); + assert_eq!(trim[0]["severity"], "info"); + assert!(trim[0]["page"].is_null(), "{r}"); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), before, "verification doesn't change the document"); + assert!(a.call("print_readiness", &json!({ "doc": 999 })).is_err()); + assert!(pdfcraft_automation::tools().iter().any(|t| t.name == "print_readiness" && t.read_only)); +} + #[test] fn exporting_to_word_html_and_rtf() { let dir = workdir("office"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ea9c2b40a..4a71f7855c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -27,6 +27,7 @@ pub mod js; pub mod links; pub mod ocr; pub mod optimizer; +pub mod printcheck; mod scan_colours; pub mod signature_image; pub mod signature_scan; diff --git a/crates/engine/src/printcheck.rs b/crates/engine/src/printcheck.rs new file mode 100644 index 0000000000..744e52d4e4 --- /dev/null +++ b/crates/engine/src/printcheck.rs @@ -0,0 +1,474 @@ +//! Print readiness, verification only. +//! +//! Measures what a press looks at and changes nothing: effective image resolution where each +//! image is drawn, the page boxes (media, crop, trim, bleed), the output intent, fonts that +//! aren't embedded, and the existing PDF/A-2b result. There is no fixup and no generated colour +//! profile. A screen profile such as sRGB is reported as a screen profile, not accepted as a +//! press condition. + +use pdfcraft_cos::Document; +use pdfcraft_edit::PageImage; +use pdfcraft_preflight::{Issue, Level}; + +use crate::Document as OpenDocument; + +/// Below this, ordinary sheet printing cannot hold the picture. Large-format work sometimes can. +const FAIL_PPI: f64 = 150.0; +/// Ordinary close-up print. Between this and [`FAIL_PPI`] the picture is usable but soft. +const WARN_PPI: f64 = 300.0; +/// Less than this past the trim, on the tightest side, is not a normal 3 mm bleed. +const MIN_BLEED_MM: f64 = 2.0; + +/// One thing a printer should see, without the file having been rewritten. +#[derive(Clone, Debug, PartialEq)] +pub struct Finding { + pub id: &'static str, + pub severity: &'static str, + pub message: String, + /// 1-based page, when the finding is about one page. + pub page: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PageBoxes { + pub page: u32, + pub media_mm: [f64; 2], + pub crop_mm: [f64; 2], + pub trim_mm: [f64; 2], + pub bleed_mm: [f64; 2], + pub trim_set: bool, + pub bleed_set: bool, + /// Left, bottom, right, top, in millimetres, from the trim out to the bleed. + pub bleed_margin_mm: [f64; 4], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DrawnImage { + pub page: u32, + pub name: String, + pub ppi: f64, + pub pixels: [u32; 2], + pub size_mm: [f64; 2], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PrintReadiness { + pub pages: Vec, + pub images: Vec, + pub output_intents: Vec, + pub fonts_not_embedded: Vec, + pub pdfa_issues: Vec, + pub findings: Vec, + /// Image placements whose resolution couldn't be measured (a degenerate or non-finite + /// placement matrix). They are neither passed nor failed. + pub resolution_unknown: usize, +} + +impl PrintReadiness { + /// A plain-text report. The document is unchanged. + pub fn text(&self) -> String { + let mut out = String::from("Print readiness (verification only; the file was not changed)\n"); + if self.output_intents.is_empty() { + out.push_str("Output intent: none\n"); + } else { + out.push_str(&format!("Output intent: {}\n", self.output_intents.join(", "))); + } + for page in &self.pages { + if !page.trim_set { + out.push_str(&format!("Page {}: media {} mm, no TrimBox\n", page.page, size_mm(page.media_mm))); + continue; + } + out.push_str(&format!( + "Page {}: media {} mm, trim {} mm, bleed margin {} mm{}\n", + page.page, + size_mm(page.media_mm), + size_mm(page.trim_mm), + min_mm(page.bleed_margin_mm), + if page.bleed_set { "" } else { " (no BleedBox)" }, + )); + } + for image in &self.images { + out.push_str(&format!( + "Image {} on page {}: {:.0} ppi, {} × {} px, {} mm\n", + image.name, + image.page, + image.ppi, + image.pixels[0], + image.pixels[1], + size_mm(image.size_mm), + )); + } + if self.resolution_unknown > 0 { + out.push_str(&format!("Images with unknown resolution: {}\n", self.resolution_unknown)); + } + if self.fonts_not_embedded.is_empty() { + out.push_str("Fonts: every referenced font is embedded, or the file has none\n"); + } else { + out.push_str(&format!("Fonts not embedded: {}\n", self.fonts_not_embedded.join(", "))); + } + let level = Level::A2b.label(); + if self.pdfa_issues.is_empty() { + out.push_str(&format!("{level}: no problems found by the structural check\n")); + } else { + out.push_str(&format!("{level}: {} problem{}\n", self.pdfa_issues.len(), if self.pdfa_issues.len() == 1 { "" } else { "s" })); + for issue in &self.pdfa_issues { + match issue.page { + Some(p) => out.push_str(&format!("- page {}: {}\n", p + 1, issue.message)), + None => out.push_str(&format!("- {}\n", issue.message)), + } + } + } + if self.findings.is_empty() { + out.push_str("Print checks: no stop for image resolution or bleed\n"); + } else { + out.push_str("Print checks:\n"); + for finding in &self.findings { + out.push_str(&format!("- [{}] {}\n", finding.severity, finding.message)); + } + } + out.push_str("Not checked: PDF/X, overprint, total area coverage, spot colours, transparency flattening.\n"); + out + } +} + +impl OpenDocument { + /// A print-readiness report. Reads the file and does not edit it. + pub fn print_readiness(&self) -> PrintReadiness { + match &self.editor { + Some(editor) => inspect(&editor.cos), + None => PrintReadiness { + pages: Vec::new(), + images: Vec::new(), + output_intents: Vec::new(), + fonts_not_embedded: Vec::new(), + pdfa_issues: Vec::new(), + resolution_unknown: 0, + findings: vec![Finding { + id: "document", + severity: "info", + message: "The document has no object graph to measure.".into(), + page: None, + }], + }, + } + } +} + +fn inspect(doc: &Document) -> PrintReadiness { + let declared = pdfcraft_preflight::declared(doc); + let pdfa_issues = pdfcraft_preflight::verify(doc, Level::A2b); + let fonts_not_embedded = pdfa_issues + .iter() + .filter(|issue| issue.pattern.contains("isn't embedded") || issue.pattern == "A font isn't embedded") + .map(|issue| issue.value.clone().unwrap_or_else(|| "unnamed font".into())) + .collect(); + let model_pages = pdfcraft_model::pages(doc); + let pages = page_reports(doc, &model_pages); + let (images, resolution_unknown) = image_reports(doc, &model_pages); + let mut findings = Vec::new(); + // Without a TrimBox the trim, the bleed and the crop are the same box, so there is no bleed to + // measure. That is a property of the document, not a fault on every page: report it once. + let untrimmed = pages.iter().filter(|page| !page.trim_set).count(); + if untrimmed > 0 { + findings.push(Finding { + id: "page-trim", + severity: "info", + message: if untrimmed == pages.len() { + "No page has a TrimBox, so bleed can't be measured.".to_string() + } else { + format!("{untrimmed} of {} pages have no TrimBox, so their bleed can't be measured.", pages.len()) + }, + page: None, + }); + } + for page in pages.iter().filter(|page| page.trim_set) { + let tight = page.bleed_margin_mm.into_iter().fold(f64::INFINITY, f64::min); + if tight < MIN_BLEED_MM { + findings.push(Finding { + id: "page-bleed", + severity: "warn", + message: format!( + "Page {} has {:.1} mm of bleed past the trim on its tightest side (a press usually wants about 3 mm). Nothing was added.", + page.page, + tight.max(0.0) + ), + page: Some(page.page), + }); + } + } + for image in &images { + if image.ppi < FAIL_PPI { + findings.push(Finding { + id: "image-ppi", + severity: "fail", + message: format!( + "Image {} on page {} is drawn at {:.0} ppi, below {:.0}. Enlarging it would not add detail.", + image.name, image.page, image.ppi, FAIL_PPI + ), + page: Some(image.page), + }); + } else if image.ppi < WARN_PPI { + findings.push(Finding { + id: "image-ppi", + severity: "warn", + message: format!( + "Image {} on page {} is drawn at {:.0} ppi, below {:.0} for close-up print.", + image.name, image.page, image.ppi, WARN_PPI + ), + page: Some(image.page), + }); + } + } + if declared.output_intents.iter().any(|intent| is_screen_profile(intent)) + && !declared.output_intents.iter().any(|intent| is_press_profile(intent)) + { + findings.push(Finding { + id: "output-intent", + severity: "warn", + message: format!( + "The output intent ({}) is a screen profile. A press condition was not added, because inventing one would retint the file.", + declared.output_intents.join(", ") + ), + page: None, + }); + } + if resolution_unknown > 0 { + findings.push(Finding { + id: "image-ppi-unknown", + severity: "info", + message: format!( + "The resolution of {resolution_unknown} image placement{} couldn't be measured (its placement matrix is degenerate or not finite).", + if resolution_unknown == 1 { "" } else { "s" } + ), + page: None, + }); + } + findings.sort_by(|a, b| a.page.cmp(&b.page).then(a.id.cmp(b.id)).then(a.message.cmp(&b.message))); + PrintReadiness { pages, images, output_intents: declared.output_intents, fonts_not_embedded, pdfa_issues, findings, resolution_unknown } +} + +fn page_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> Vec { + let boxes = pdfcraft_organize::page_boxes(doc).unwrap_or_default(); + pages + .iter() + .enumerate() + .map(|(i, page)| { + let row = boxes.get(i).copied().unwrap_or([[0.0; 4]; 5]); + let trim = row[3]; + let bleed = row[2]; + PageBoxes { + page: (i + 1) as u32, + media_mm: wh_mm(row[0]), + crop_mm: wh_mm(row[1]), + trim_mm: wh_mm(trim), + bleed_mm: wh_mm(bleed), + trim_set: page.dict.contains(b"TrimBox"), + bleed_set: page.dict.contains(b"BleedBox"), + bleed_margin_mm: [mm(trim[0] - bleed[0]), mm(trim[1] - bleed[1]), mm(bleed[2] - trim[2]), mm(bleed[3] - trim[3])], + } + }) + .collect() +} + +/// The images drawn on each page, and how many placements had no measurable resolution. +fn image_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> (Vec, usize) { + let mut out = Vec::new(); + let mut unknown = 0usize; + let mut seen = std::collections::HashSet::new(); + for (i, page) in pages.iter().enumerate() { + let page_no = (i + 1) as u32; + if let Ok(placed) = pdfcraft_edit::page_images(doc, i) { + for image in placed { + if let Some(ppi) = placed_ppi(&image) { + if let Some(object) = image.object { + seen.insert((page_no, object)); + } + if !ppi.is_finite() { + unknown = unknown.saturating_add(1); + continue; + } + let w = (image.rect[2] - image.rect[0]).abs(); + let h = (image.rect[3] - image.rect[1]).abs(); + out.push(DrawnImage { page: page_no, name: image.name, ppi, pixels: [image.width, image.height], size_mm: [mm(w), mm(h)] }); + } + } + } + // Images drawn inside a form XObject are not listed above. Their resolution is the + // smallest use on this page, which is what the optimizer already measures. + for (object, ppi) in pdfcraft_optimize::effective_resolutions(doc, &[page.obj]) { + if seen.contains(&(page_no, object)) { + continue; + } + if !ppi.is_finite() { + unknown = unknown.saturating_add(1); + continue; + } + out.push(DrawnImage { page: page_no, name: format!("object {object}"), ppi, pixels: [0, 0], size_mm: [0.0, 0.0] }); + } + } + out.sort_by(|a, b| a.page.cmp(&b.page).then(a.name.cmp(&b.name)).then(a.ppi.total_cmp(&b.ppi))); + (out, unknown) +} + +/// The effective resolution of a raster placement, `None` for Form artwork. Not finite (NaN) +/// when the placement can't be measured: callers report that rather than pass it. +fn placed_ppi(image: &PageImage) -> Option { + if image.is_form { + return None; + } + Some(matrix_ppi(image.matrix, image.width, image.height)) +} + +/// Pixels per inch of a `width` × `height` image drawn through `matrix`, or NaN when the matrix +/// is degenerate or not finite or the image has no pixels. +fn matrix_ppi(matrix: [f64; 6], width: u32, height: u32) -> f64 { + let [a, b, c, d, ..] = matrix; + let (sx, sy) = (a.hypot(b), c.hypot(d)); + if width == 0 || height == 0 || !sx.is_finite() || !sy.is_finite() || sx <= 1e-6 || sy <= 1e-6 { + return f64::NAN; + } + let ppi = (f64::from(width) / (sx / 72.0)).min(f64::from(height) / (sy / 72.0)); + if ppi.is_finite() { ppi } else { f64::NAN } +} + +fn is_screen_profile(intent: &str) -> bool { + let id = intent.to_ascii_lowercase(); + id.contains("srgb") || id.contains("adobe rgb") || id.contains("display") +} + +fn is_press_profile(intent: &str) -> bool { + let id = intent.to_ascii_lowercase(); + id.contains("cmyk") || id.contains("fogra") || id.contains("coated") || id.contains("swop") || id.contains("gracol") +} + +fn mm(points: f64) -> f64 { + points * 25.4 / 72.0 +} + +fn wh_mm(rect: [f64; 4]) -> [f64; 2] { + [mm((rect[2] - rect[0]).abs()), mm((rect[3] - rect[1]).abs())] +} + +fn size_mm(wh: [f64; 2]) -> String { + format!("{:.1} × {:.1}", wh[0], wh[1]) +} + +fn min_mm(sides: [f64; 4]) -> String { + let n = sides.into_iter().fold(f64::INFINITY, f64::min); + if n.is_finite() { format!("{n:.1}") } else { "0.0".into() } +} + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + + use super::*; + + fn image(doc: &mut Document, w: u32, h: u32) -> pdfcraft_cos::ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + doc.add(Object::Stream(Stream::flate(d, &vec![0; (w as usize) * (h as usize)]))) + } + + fn page(doc: &mut Document, name: &str, object: pdfcraft_cos::ObjRef, content: &str) -> pdfcraft_cos::ObjRef { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(name.as_bytes().to_vec(), Object::Ref(object)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + r + } + + #[test] + fn a_drawn_image_is_measured_where_it_is_placed() { + let mut doc = Document::new_empty(); + // 100 px across 144 pt (2 in) is 50 ppi, not "100 px on a letter page". + let soft = image(&mut doc, 100, 100); + // 600 px across 72 pt (1 in) is 600 ppi. + let sharp = image(&mut doc, 600, 600); + page(&mut doc, "Soft", soft, "q 144 0 0 144 36 600 cm /Soft Do Q"); + let sharp_page = page(&mut doc, "Sharp", sharp, "q 72 0 0 72 36 36 cm /Sharp Do Q"); + doc.update_dict(sharp_page, |d| { + let inset = |n: i64| Object::Array(vec![n.into(), n.into(), (612 - n).into(), (792 - n).into()]); + d.set(b"TrimBox".to_vec(), inset(18)); + d.set(b"BleedBox".to_vec(), inset(0)); + }) + .unwrap(); + + let report = inspect(&doc); + let soft = report.images.iter().find(|image| image.name == "Soft").unwrap(); + let sharp = report.images.iter().find(|image| image.name == "Sharp").unwrap(); + assert!((soft.ppi - 50.0).abs() < 0.5, "{}", soft.ppi); + assert!((sharp.ppi - 600.0).abs() < 0.5, "{}", sharp.ppi); + assert!(report.findings.iter().any(|f| f.id == "image-ppi" && f.severity == "fail" && f.page == Some(1))); + assert!(!report.findings.iter().any(|f| f.id == "image-ppi" && f.page == Some(2))); + // Page 1 has no TrimBox: one document-level note, not a bleed warning per page. + assert!(!report.findings.iter().any(|f| f.id == "page-bleed"), "{:?}", report.findings); + let trim: Vec<_> = report.findings.iter().filter(|f| f.id == "page-trim").collect(); + assert_eq!(trim.len(), 1, "{:?}", report.findings); + assert_eq!((trim[0].severity, trim[0].page), ("info", None)); + assert!(trim[0].message.starts_with("1 of 2 pages"), "{}", trim[0].message); + assert_eq!(report.resolution_unknown, 0); + assert!(report.text().contains("50 ppi")); + assert!(report.text().contains("verification only")); + } + + #[test] + fn untrimmed_pages_get_one_note_and_a_tight_trim_gets_a_warning() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 600, 600); + for _ in 0..3 { + page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q"); + } + let report = inspect(&doc); + let notes: Vec<_> = report.findings.iter().filter(|f| f.id.starts_with("page-")).collect(); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert_eq!(notes[0].message, "No page has a TrimBox, so bleed can't be measured."); + + // A TrimBox 1 pt inside the crop, with no BleedBox: 0.35 mm of bleed is a real warning. + let tight = page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q"); + doc.update_dict(tight, |d| d.set(b"TrimBox".to_vec(), Object::Array(vec![1.into(), 1.into(), 611.into(), 791.into()]))).unwrap(); + let report = inspect(&doc); + assert!(report.findings.iter().any(|f| f.id == "page-bleed" && f.severity == "warn" && f.page == Some(4))); + assert_eq!(report.findings.iter().filter(|f| f.id == "page-bleed").count(), 1); + } + + #[test] + fn an_unmeasurable_placement_is_never_passed() { + assert!(matrix_ppi([f64::NAN, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([f64::INFINITY, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([0.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 0, 600).is_nan()); + assert!((matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600) - 600.0).abs() < 1e-9); + + // A placement scaled past f64's range and then multiplied by zero has a NaN matrix. The + // content readers drop it before it is measured; it must never be reported as passing. + let mut doc = Document::new_empty(); + let img = image(&mut doc, 100, 100); + let huge = format!("1{}", "0".repeat(400)); + page(&mut doc, "Im", img, &format!("q {huge} 0 0 {huge} 0 0 cm 0 1 1 0 0 0 cm /Im Do Q")); + let report = inspect(&doc); + assert!(report.images.iter().all(|image| image.ppi.is_finite()), "{:?}", report.images); + assert!(!report.findings.iter().any(|f| f.id == "image-ppi"), "{:?}", report.findings); + } +} From 4bfd3c2729790f608fe65d1c4cdddb0e7119e765 Mon Sep 17 00:00:00 2001 From: tdslot Date: Mon, 12 Oct 2026 15:54:15 +0300 Subject: [PATCH 02/70] M14: add Lithuanian interface and date localization (#1401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M14: add Lithuanian interface localization Register Lithuanian UI and date languages with three integer plural forms and a 2,572-entry contributor-original catalog. Cover dynamic dialog labels, contextual calendar and certificate wording, commands, tools, and every key in the other bundled catalogs. Add real-shell calendar, inspector and password-dialog regressions; preserve PDF values and document state. Update localization documentation and asset attribution. Native-speaker/VLKK terminology review remains outstanding. Validation: cargo xtask ci passed; cargo-deny was skipped because it is not installed. Five rendered dialogs and contributor manual testing checked UI. Co-Authored-By: OpenAI gpt-6.1-sol * M14: preserve multilingual localization documentation Restore the existing language descriptions instead of replacing general localization guidance with Lithuanian-specific coverage. List every registered language and control-channel code; keep Lithuanian details alongside the existing catalog descriptions. Include the previously omitted Kazakh, Polish, Slovenian, Greek and Turkish catalogs in the shipped-language table without changing the historical coverage snapshot or existing assessments. Keep Lithuanian roadmap notes concise and additive. Validation: all 22 registered languages appear in documentation; every existing descriptive paragraph is retained. cargo xtask ci passed, with cargo-deny skipped because it is not installed. Co-Authored-By: OpenAI gpt-6.1-sol * M14: record completed Lithuanian native-speaker review The contributor confirms they are a native Lithuanian speaker and have reviewed the translation. Remove obsolete pending-review labels from the asset manifest and documentation; regenerate ATTRIBUTION.md. Remove the redundant generated-message disclaimer from the Lithuanian description, keeping the existing general localization documentation unchanged. Validation: cargo xtask ci passed; cargo-deny was skipped because it is not installed. Catalog content and its SHA-256 are unchanged. Co-Authored-By: OpenAI gpt-6.1-sol * Lithuanian: cover main's Copy Image labels, list lt last, check its glyphs Adds the six Copy Image rows from #1363, moves lt to the end of LANGUAGES and DATE_LANGUAGES (after tr), adds lt.tsv to the primary-font coverage test, writes the JavaScript preferences path with '>' as the other catalogs do (Inter has no ▸), and refreshes counts and hashes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: OpenAI gpt-6.1-sol Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 11 + ROADMAP.md | 1 + crates/engine/src/dates.rs | 27 +- crates/ui-egui/src/docking.rs | 8 + crates/ui-egui/src/forms_ui.rs | 2 +- crates/ui-egui/src/i18n/lt.tsv | 2584 +++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 242 ++- crates/ui-egui/tests/control.rs | 40 +- crates/ui-egui/tests/fonts.rs | 1 + crates/ui-egui/tests/forms.rs | 28 + docs/localization-parity.md | 15 +- docs/localization.md | 9 +- docs/roadmap.md | 2 +- 14 files changed, 2956 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/i18n/lt.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 40f1c008c5..b7e254556d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (268) +## In this repository (269) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -27,6 +27,7 @@ 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 | | `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 6aafc77ecd..a4154d5ac4 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -219,6 +219,17 @@ kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" sha256 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1" +[[asset]] +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" +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 = "d2e6be7cf74525310a203bed7434e7f69d2288d337ee696de2882df8da31a746" + [[asset]] path = "assets/icons/accessibility.svg" title = "Lucide icon \"accessibility\"" diff --git a/ROADMAP.md b/ROADMAP.md index 57d1964ca8..8c3c70dab2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. - **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged. - **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open. +- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged. - **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged. - **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged. - **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged. diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index 3f63a5fd9b..e26d9ec60a 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", /// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// the interface languages, in the same order and with the same names. -pub static DATE_LANGUAGES: [DateLanguage; 21] = [ +pub static DATE_LANGUAGES: [DateLanguage; 22] = [ DateLanguage { code: "en", name: "English", @@ -338,6 +338,28 @@ pub static DATE_LANGUAGES: [DateLanguage; 21] = [ days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"], days_short: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"], }, + DateLanguage { + code: "lt", + name: "Lietuvių", + months: ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"], + months_with_day: Some([ + "sausio", + "vasario", + "kovo", + "balandžio", + "gegužės", + "birželio", + "liepos", + "rugpjūčio", + "rugsėjo", + "spalio", + "lapkričio", + "gruodžio", + ]), + months_short: ["saus.", "vas.", "kov.", "bal.", "geg.", "birž.", "liep.", "rugpj.", "rugs.", "spal.", "lapkr.", "gruod."], + days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"], + days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"], + }, ]; /// The date language for `code` (any case), or `None` when there's none. @@ -651,6 +673,9 @@ mod tests { assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November")); assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages"); assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one"); + s.set_date_language(Some("LT")).unwrap(); + assert_eq!(s.date_language(), Some("lt")); + assert_eq!(s.today_text(Some("dddd d mmmm"), None).as_deref(), Ok("antradienis 14 lapkričio")); s.set_date_language(Some("IT")).unwrap(); assert_eq!(s.date_language(), Some("it")); assert_eq!(s.today_text_for_pdf(None, None).as_deref(), Ok("martedì 14 novembre"), "Italian names and accents can be written into a PDF"); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index caf2d4c438..fe4c0390df 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -451,6 +451,14 @@ fn panel_limits(panel: &Panel) -> PanelLimits { mod tests { use super::*; + #[test] + fn lithuanian_translates_every_dynamic_panel_title() { + let lang = crate::i18n::Lang::from_code("lt").expect("Lithuanian is registered"); + for panel in Panel::ALL { + assert_ne!(crate::i18n::tr(lang, panel.label()), panel.label(), "{} remains untranslated", panel.id()); + } + } + #[test] fn tools_side_moves_to_both_edges_and_survives_closed_restore() { let mut workspace = Workspace::default(); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 12068f14f3..339389b182 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -218,7 +218,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize; egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { - ui.label(egui::RichText::new(d).small()); + ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); } ui.end_row(); for cell in 0..(first + len).div_ceil(7) * 7 { diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv new file mode 100644 index 0000000000..2b6ffa0bff --- /dev/null +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -0,0 +1,2584 @@ +# Lithuanian (lt) catalog. Columns, separated by a TAB: context source translation +# +# Clean-room translations from English source labels. Use standard Lithuanian computing terms; +# consult the Lietuvos Respublikos terminų bankas (terminai.vlkk.lt) when selecting terminology. +# Product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain unchanged. + + Menu Meniu + Edit Redaguoti + Pages Puslapiai + View Rodinys + Help Žinynas + Zoom Mastelis + Actual size Faktinis dydis + Zoom to page level Pritaikyti puslapiui + Fit to width Pritaikyti pločiui + Display theme Išvaizdos tema + Side panels Šoniniai skydeliai + Preferences Nuostatos + Preferences… Nuostatos… + Interface language Sąsajos kalba + Interface size Sąsajos dydis + Auto ({percent}%) Automatiškai ({percent} %) + Identity Tapatybė + Name on new comments Naujų komentarų autoriaus vardas + Open… Atverti… + Open Recent Atverti paskiausiai naudotus failus + Clear Recent Files Išvalyti paskiausiai naudotų failų sąrašą + Remove from Recent Pašalinti iš paskiausiai naudotų failų + No recent files Nėra paskiausiai naudotų failų + New blank PDF Naujas tuščias PDF dokumentas + Create PDF from file… Sukurti PDF iš failo… + Create PDF from images… Sukurti PDF iš paveikslėlių… + Create PDF from multiple files… Sukurti PDF iš kelių failų… + Create separate PDFs… Sukurti atskirus PDF dokumentus… + Create PDF from clipboard Sukurti PDF iš iškarpinės + Combine files… Sujungti failus… + Save Įrašyti + Save as… Įrašyti kaip… + Close file Užverti failą + Close all Užverti visus + Revert Grąžinti įrašytą versiją + Print… Spausdinti… + Document properties… Dokumento savybės… + Find… Ieškoti… + Advanced search… Išplėstinė paieška… + Copy Kopijuoti + Cut Iškirpti + Paste Įdėti + Delete Naikinti + Select all Pažymėti viską + Cancel Atšaukti + OK Gerai + Apply Taikyti + Close Užverti + Back Atgal + Next Pirmyn + Previous Ankstesnis + Name Pavadinimas + File name Failo pavadinimas + Location Vieta + Browse… Naršyti… + Choose… Pasirinkti… + Search Ieškoti + Clear Išvalyti + Refresh Atnaujinti + Loading… Įkeliama… + Save changes? Įrašyti pakeitimus? + Don't save Neįrašyti + Page Puslapis + Page {n} {n} puslapis + of iš + Fit one full page Pritaikyti visą puslapį + Single page Vienas puslapis + Two-page view Dviejų puslapių rodinys + Continuous scrolling Nepertraukiamas slinkimas + Page display Puslapių rodymas + Default page display Numatytasis puslapių rodymas + Default zoom Numatytasis mastelis + Full screen mode Viso ekrano veiksena + Read mode Skaitymo veiksena + Comments Komentarai + Comment Komentaras + Add comment Pridėti komentarą + Delete comment Naikinti komentarą + Reply Atsakyti + Highlight Paryškinti + Underline Pabraukti + Strikethrough Perbraukti + Text Tekstas + Font Šriftas + Size Dydis + Color Spalva + Opacity Nepermatomumas + Width Plotis + Height Aukštis + Rotation Pasukimas + More Daugiau + Settings Nuostatos + General Bendrieji + Language Kalba + Appearance Išvaizda + Theme Tema + Light Šviesi + Dark Tamsi + System Pagal sistemą + Open file Atverti failą + Save file Įrašyti failą + Export Eksportuoti + Import Importuoti + Export to image… Eksportuoti kaip paveikslėlį… + Export to text… Eksportuoti kaip tekstą… + Export to Word… Eksportuoti į Word… + Export to HTML… Eksportuoti į HTML… + Export to RTF… Eksportuoti į RTF… + Reduce file size… Sumažinti failo dydį… + Optimize PDF… Optimizuoti PDF… + Protect using password… Apsaugoti slaptažodžiu… + Remove security Pašalinti apsaugą + Security properties… Apsaugos savybės… + Organize pages Tvarkyti puslapius + Rotate pages… Pasukti puslapius… + Delete pages Naikinti puslapius + Insert blank page Įterpti tuščią puslapį + Insert pages from file… Įterpti puslapius iš failo… + Replace pages… Pakeisti puslapius… + Extract pages… Išskirti puslapius… + Split document… Skaidyti dokumentą… + Number pages… Numeruoti puslapius… + Bookmarks Žymelės + New bookmark Nauja žymelė + Forms Formos + Form fields Formos laukai + Signatures Parašai + Sign Pasirašyti + Fill & Sign Pildyti ir pasirašyti + Accessibility Prieinamumas + Help… Žinynas… + Check for updates… Tikrinti, ar yra naujinimų… + About PdfCraft Apie „PdfCraft“ + Automatically Automatiškai + Never Niekada + Always Visada + Yes Taip + No Ne + On Įjungta + Off Išjungta + Enabled Įjungta + Disabled Išjungta + Required Privaloma + Optional Pasirenkama + None Nėra + Unknown Nežinoma + Error Klaida + Warning Įspėjimas + Information Informacija + Success Pavyko + Failed Nepavyko + File Failas + Folder Aplankas + Choose a file… Pasirinkti failą… + Choose a folder… Pasirinkti aplanką… + Open folder Atverti aplanką + Create folder Sukurti aplanką + Remove Pašalinti + Rename Pervadinti + Duplicate Dubliuoti + Move Perkelti + Copy to clipboard Kopijuoti į iškarpinę + Copied to clipboard Nukopijuota į iškarpinę + Undo Anuliuoti + Redo Pakartoti + Keyboard shortcuts Spartieji klavišai + Find tools and commands… Ieškoti įrankių ir komandų… + Take a snapshot Padaryti momentinę kopiją + Organize Tvarkyti + Comment and review Komentuoti ir peržiūrėti + Prepare a form Rengti formą + Protect Apsaugoti + Print Spausdinti + Create Kurti + Convert Konvertuoti + Optimize Optimizuoti + Measure Matuoti + Redact Pašalinti konfidencialią informaciją + Search results Paieškos rezultatai + No results Rezultatų nėra + Page {n} of {total} Puslapis {n} iš {total} + {n} pages selected Pažymėta puslapių: {n} + · not fixable here · čia nepataisoma + (page {p}) (puslapis {p}) + (searching… {s} of {p} pages) (ieškoma… {s} iš {p} puslapių) + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n}, {dpi} dpi – didžiausias galimas vaizdo dydis) + ({s} not exported: {first}) ({s} neeksportuota: {first}) + on page {p} puslapyje {p} + · password-protected · apsaugotas slaptažodžiu + (none) (nėra) + , Expires: , galiojimo pabaiga: + 1 difference found Rastas 1 skirtumas + 1 file 1 failas + 1 file done 1 failas apdorotas + 1 file needs attention 1 failui reikia dėmesio + 1 instance 1 egzempliorius + 1 issue 1 problema + 1 mark 1 žyma + 1 page 1 puslapis + 1 page selected Pažymėtas 1 puslapis + 1 page selected. Pažymėtas 1 puslapis. + 180 degrees 180 laipsnių + 9 digit, A letter, O letter or digit, X any character 9 – skaitmuo, A – raidė, O – raidė arba skaitmuo, X – bet kuris simbolis + ; 1 page already had text ; 1 puslapyje tekstas jau buvo + ; failed: {list} ; nepavyko: {list} + ; {n} pages already had text ; {n} puslapiuose tekstas jau buvo + ? of {n} ? iš {n} + A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. Švarių patalpų principu kuriama atvirojo kodo PDF programa, parašyta „Rust“ kalba. MIT OR Apache-2.0. + Accessibility Checker Prieinamumo tikrintuvas + Accessibility Checker Options Prieinamumo tikrintuvo parinktys + Acrobat 3.0 and later (40-bit RC4) „Acrobat 3.0“ ir naujesnės versijos (40 bitų RC4) + Acrobat 5.0 and later (128-bit RC4) „Acrobat 5.0“ ir naujesnės versijos (128 bitų RC4) + Acrobat 7.0 and later (128-bit AES) „Acrobat 7.0“ ir naujesnės versijos (128 bitų AES) + Acrobat X and later (256-bit AES) „Acrobat X“ ir naujesnės versijos (256 bitų AES) + Action name: Veiksmo pavadinimas: + Actions Veiksmai + Active Aktyvi + Actual Size Faktinis dydis + Actual text Faktinis tekstas + Add Pridėti + Add Step Pridėti veiksmą + Add a comment Pridėti komentarą + Add a reply Atsakyti + Add a sticky note here Čia pridėti lipnųjį raštelį + Add an Action Pridėti veiksmą + Add at least two files Pridėkite bent du failus + Add files… Pridėti failus… + Add folder Pridėti aplanką + Add folder and subfolders Pridėti aplanką ir jo poaplankius + Add folder and subfolders… Pridėti aplanką ir jo poaplankius… + Add folder… Pridėti aplanką… + Add open documents Pridėti atvertus dokumentus + Add the choices people pick from. Pridėkite pasirinkimus, kuriuos galės rinktis naudotojai. + Add the files to combine Pridėkite jungiamus failus + Add the steps this action runs, in order. Pridėkite veiksmus, kuriuos šis veiksmas vykdys iš eilės. + Add to Trusted Certificates Pridėti prie patikimų sertifikatų + Add to trusted certificates Pridėti prie patikimų sertifikatų + Added 1 comment Pridėtas 1 komentaras + Added more than once Pridėta daugiau nei vieną kartą + Added {n} comments Pridėta komentarų: {n} + Adds invisible text over each word; the page image is not changed Virš kiekvieno žodžio prideda nematomą tekstą; puslapio vaizdas nesikeičia + Advanced Išplėstiniai + Advanced Options {chevron} Išplėstinės parinktys {chevron} + Align Lygiuoti + Align left Lygiuoti kairėje + Align right Lygiuoti dešinėje + Alignment: Lygiuotė: + All Visi + All document contents Visas dokumento turinys + All document contents except metadata Visas dokumento turinys, išskyrus metaduomenis + All open documents Visi atverti dokumentai + All pages Visi puslapiai + All pages in document Visi dokumento puslapiai + All pages in range Visi intervalo puslapiai + Allow Rich Text Formatting Leisti raiškiojo teksto formatavimą + Allow user to enter custom text Leisti naudotojui įvesti pasirinktinį tekstą + Also recognize pages that already have text Taip pat atpažinti puslapius, kuriuose jau yra teksto + Alternate text Alternatyvusis tekstas + An action with this name already exists. Veiksmas tokiu pavadinimu jau yra. + An open-source PDF workbench — local, private, and scriptable. Atvirojo kodo PDF aplinka — vietinė, privati ir valdoma scenarijais. + Angle Kampas + Animation effects Animacijos efektai + Annotations, form fill-in, and digital signatures Anotacijos, formų pildymas ir skaitmeniniai parašai + Any Bet kuris + Any except extracting pages Bet kas, išskyrus puslapių išskyrimą + Anyone opening the document will need this password. It's applied when you save. Dokumentą atveriant reikės šio slaptažodžio. Jis pritaikomas įrašant dokumentą. + Appear behind page Rodyti už puslapio + Appear on top of page Rodyti virš puslapio + Appearance mode Išvaizdos veiksena + Appearance settings… Išvaizdos nuostatos… + Applies to 1 page Taikoma 1 puslapiui + Applies to {n} pages Taikoma {n} puslapiams + Apply redactions Pašalinti pažymėtą konfidencialią informaciją + Apply scale Taikyti mastelį + Asks GitHub for the latest release. Nothing is downloaded or installed automatically. „GitHub“ tikrinama, ar yra naujausia laida. Nieko automatiškai neatsisiunčiama ir neįdiegiama. + At most Daugiausia + Attachments Priedai + Attachments Panel and Page Priedų skydelis ir puslapis + Audit space usage… Tikrinti vietos naudojimą… + Author Autorius + Auto Automatiškai + Auto follows the system's text size. Pages always render at the display's full resolution. Automatinis režimas naudoja sistemos teksto dydį. Puslapiai visada atvaizduojami visa ekrano raiška. + Auto portrait/landscape Automatiškai parinkti stačią arba gulsčią padėtį + Auto-rotate pages Automatiškai pasukti puslapius + Auto-size text to fit redaction region Automatiškai pritaikyti tekstą šalinimo sričiai + Available Prieinama + Back side only Tik kita pusė + Back to all tools Grįžti į visus įrankius + Back to the document Grįžti į dokumentą + Before each selected page (select pages in Organize) Prieš kiekvieną pasirinktą puslapį (puslapius pasirinkite skiltyje „Tvarkyti“) + Before the number Prieš numerį + Bicubic downsampling to Bikubinis sumažinimas iki + Binding Įrišimas + Binding: Įrišimas: + Black and white Nespalvota + Bold Pusjuodis + Booklet Brošiūra + Booklet subset: Brošiūros dalis: + Bookmark options Žymelės parinktys + Bookmarks Panel and Page Žymelių skydelis ir puslapis + Border Color: Rėmelio spalva: + Borders and Colors Rėmeliai ir spalvos + Both sides Abi pusės + Bottom Apačia + Bottom: Apačia: + Box Stačiakampis + Browser's print dialog Naršyklės spausdinimo dialogas + Butt Plokščias galas + Button is checked by default Mygtukas pažymėtas pagal numatytuosius parametrus + Buttons with the same name and value are selected in unison Mygtukai su tuo pačiu pavadinimu ir reikšme pasirenkami kartu + Bytes Baitai + Calibrate from two points Kalibruoti pagal du taškus + Can't be read: {e} Nepavyksta perskaityti: {e} + Cancelling… Atšaukiama… + Case-sensitive Skiriamos didžiosios ir mažosios raidės + Category: Kategorija: + Center Centruoti + Center window on screen Centruoti langą ekrane + Centre Centras + Certify Patvirtinti + Change settings… Keisti nuostatas… + Change the colour of line art as well as text Keisti ne tik teksto, bet ir linijinių piešinių spalvą + Change {what} Keisti {what} + Changes allowed Leidžiami pakeitimai + Character limit 0 means no limit. A comb of characters needs a limit. Simbolių skaičius 0 reiškia, kad apribojimo nėra. Simbolių tinkleliui apribojimas būtinas. + Character spacing Tarpai tarp simbolių + Check Again Tikrinti dar kartą + Check again Tikrinti dar kartą + Check box is checked by default Žymimasis langelis pažymėtas pagal numatytuosius parametrus + Check for updates Tikrinti, ar yra naujinimų + Check spelling Tikrinti rašybą + Check the pages Tikrinti puslapius + Check the pages of {name}: {why} Tikrinti dokumento {name} puslapius: {why} + Checking Options ({on} of 32 in all categories) Tikrinimo parinktys (visose kategorijose: {on} iš 32) + Checking for a newer version… Tikrinama, ar yra naujesnė versija… + Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Pasirinkite PNG arba JPEG paveikslėlį (iki 4 MiB). Skaidrumas išsaugomas. + Choose a folder for the exported files Pasirinkite aplanką eksportuojamiems failams + Choose a folder for the extracted pages Pasirinkite aplanką išskirtiems puslapiams + Choose a folder for the results Pasirinkite aplanką rezultatams + Choose a folder for the searchable files Pasirinkite aplanką failams su ieškomu tekstu + Choose a layout: Pasirinkite išdėstymą: + Choose a stamp, then click on the page to place it. Pasirinkite antspaudą, tada spustelėkite puslapį, kad jį įdėtumėte. + Choose an image Pasirinkite paveikslėlį + Choose images Pasirinkite paveikslėlius + Choose image… Pasirinkite paveikslėlį… + Choose photo… Pasirinkite nuotrauką… + Choose the digital ID that you want to use for signing: Pasirinkite skaitmeninį ID, kurį naudosite pasirašydami: + Circle Apskritimas + Clean Up Išvalyti + Clean-up Valymas + Clear All Išvalyti viską + Clear all Išvalyti viską + Clear selection / close find Panaikinti pasirinkimą / užverti paiešką + Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. Spustelėkite puslapį, kad pridėtumėte tekstą; kiekvienas spustelėjimas sukuria naują laukelį, o ✓ užbaigia veiksmą. Vilkite elementus, kad juos perkeltumėte, vilkite kampą, kad pakeistumėte dydį, dukart spustelėkite tekstą, kad jį redaguotumėte. + Click the first point to begin. Spustelėkite pirmąjį tašką, kad pradėtumėte. + Click to sort, drag to move the column. Drag its edge to resize it. Spustelėkite, kad surikiuotumėte; vilkite stulpelį, kad jį perkeltumėte. Vilkite jo kraštą, kad pakeistumėte plotį. + Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. Spustelėkite viršūnes. Paspauskite „Enter“ arba dukart spustelėkite, kad baigtumėte perimetrą ar plotą. Spustelėkite pirmąjį tašką, kad užvertumėte plotą. + Clockwise 90 degrees 90 laipsnių pagal laikrodžio rodyklę + Close (Esc) Užverti (Esc) + Close Tab Užverti kortelę + Close panel Užverti skydelį + Closed arrow Uždara rodyklė + Collapse Suskleisti + Collapse all Suskleisti viską + Collapse all bookmarks Suskleisti visas žymeles + Collapse tag Suskleisti žymą + Collate Rūšiuoti komplektais + Color: Spalva: + Colour Spalva + Colours are remapped while rendering; the document itself never changes. Atvaizduojant spalvos perskaičiuojamos, tačiau pats dokumentas nekeičiamas. + Comb of Simbolių tinklelis: + Combine the rest Sujungti likusius + Combined {n} files Sujungta failų: {n} + Coming in milestone Numatyta būsimame etape + Comment and form data Komentarai ir formų duomenys + Commenting, filling in form fields, and signing existing signature fields Komentavimas, formos laukų pildymas ir esamų parašo laukų pasirašymas + Comments & Forms Komentarai ir formos + Comments only Tik komentarai + Commit selected value immediately Iš karto patvirtinti pasirinktą reikšmę + Common Properties Bendrosios savybės + Compare Files Palyginti failus + Compare this file with an older version: Compare files… in All tools. Palyginkite šį failą su ankstesne versija: įrankyje „Visi įrankiai“ pasirinkite „Palyginti failus…“. + Compatibility Suderinamumas + Compress document structure (object streams) Suglaudinti dokumento struktūrą (objektų srautus) + Compression Glaudinimas + Configure New Digital ID Konfigūruoti naują skaitmeninį ID + Confirm Password Patvirtinti slaptažodį + Conformance level: Atitikties lygis: + Continue Tęsti + Copies, two-sided printing and colour are chosen in the browser's print dialog. Kopijų skaičius, dvipusis spausdinimas ir spalvos pasirenkami naršyklės spausdinimo dialoge. + Copies: Kopijų skaičius: + Copy selected text Kopijuoti pažymėtą tekstą + Copy text Kopijuoti tekstą + Couldn't check for updates: {e} Nepavyko patikrinti, ar yra naujinimų: {e} + Couldn't open the printer's preferences: {e} Nepavyko atverti spausdintuvo nuostatų: {e} + Counterclockwise 90 degrees 90 laipsnių prieš laikrodžio rodyklę + Country/Region Šalis / regionas + Create Custom Stamp Kurti pasirinktinį antspaudą + Create Link Kurti saitą + Create PDF Comment Summary Kurti PDF komentarų suvestinę + Create PDF comment summary… Kurti PDF komentarų suvestinę… + Create PDF from images Kurti PDF iš paveikslėlių + Create a new Digital ID (self-signed, saved to a password-protected file) Kurti naują skaitmeninį ID (savarankiškai pasirašytą, įrašomą į slaptažodžiu apsaugotą failą) + Create accessibility report Kurti prieinamumo ataskaitą + Create custom stamp… Kurti pasirinktinį antspaudą… + Create {what} Kurti {what} + Created a PDF from {n} files Iš {n} failų sukurtas PDF + Created a PDF from {n} files; left out: {names} Iš {n} failų sukurtas PDF; neįtraukti: {names} + Creates 1 file from {n} pages. Iš {n} puslapių sukuriamas 1 failas. + Creates {f} files from {n} pages. Iš {n} puslapių sukuriama failų: {f}. + Crop (% trimmed from each side) Apkirpimas (% pašalinama nuo kiekvienos kraštinės) + Currency symbol: Valiutos simbolis: + Current page Dabartinis puslapis + Current page ({n}) Dabartinis puslapis ({n}) + Current page — type a page number or label (such as iv) and press Enter Dabartinis puslapis — įveskite puslapio numerį arba žymą (pvz., iv) ir paspauskite „Enter“ + Custom Pasirinktinis + Custom scale: Pasirinktinis mastelis: + Custom text: Pasirinktinis tekstas: + Custom: Pasirinktinis: + Cut and stack Spausdinti iškarpomis ir sudėti + Cut marks Pjovimo žymės + DPI sets the printed page size without resampling the image. DPI nustato spausdinamo puslapio dydį nekeičiant paveikslėlio raiškos. + Dark theme Tamsioji tema + Date format Datos formatas + Decimal places: Dešimtainių skilčių skaičius: + Decimals Dešimtainės skiltys + Declared conformance: {shown} Deklaruota atitiktis: {shown} + Decorative figure Dekoratyvinis elementas + Default Numatytasis + Default Value: Numatytoji reikšmė: + Default workspace mode Numatytoji darbo srities veiksena + Default: Numatyta: + Delete page {label} Naikinti puslapį {label} + Delete pages (Delete) Naikinti puslapius („Delete“) + Delete pages after extracting Išskyrus puslapius juos panaikinti + Delete selected pages (Organize) Naikinti pasirinktus puslapius (skiltyje „Tvarkyti“) + Delete stamp Naikinti antspaudą + Delete {o} Naikinti {o} + Describe the figure Aprašykite elementą + Description Aprašas + Description: Aprašas: + Details Išsami informacija + Diamond Rombas + Digital ID Skaitmeninis ID + Digital ID password Skaitmeninio ID slaptažodis + Direction: Kryptis: + Discard Atmesti + Discard Objects Pašalinti objektus + Discard User Data Pašalinti naudotojo duomenis + Discard alternate images Pašalinti alternatyvius paveikslėlius + Discard document tags Pašalinti dokumento žymas + Discard embedded page thumbnails Pašalinti įdėtąsias puslapių miniatiūras + Discard embedded print settings Pašalinti įdėtąsias spausdinimo nuostatas + Discard this text (Esc) Atmesti šį tekstą (Esc) + Dismiss Atmesti + Distinguished name Išskirtinis vardas + Distribute Paskirstyti + Document Dokumentas + Document Properties Dokumento savybės + Document Title Dokumento pavadinimas + Document and markups Dokumentas ir žymos + Document and stamps Dokumentas ir antspaudai + Document language Dokumento kalba + Document properties ({key}) Dokumento savybės ({key}) + Documents and view Dokumentai ir rodinys + Done Atlikta + Done adding text Tekstas pridėtas + Download Atsisiųsti + Downsample to Sumažinti raišką iki + Drag the selection to move it. Esc clears the selection. Vilkite pažymėtą objektą, kad jį perkeltumėte. Paspaudus „Esc“ pasirinkimas panaikinamas. + Drag to reorder Vilkite, kad pakeistumėte tvarką + Draw Brėžti + Draw your {what} below. Žemiau nupieškite {what}. + Drawing scale changes. Use Actual size for full-scale plans. Brėžinio mastelis keičiasi. Tikram masteliui pasirinkite „Faktinis dydis“. + Drop PDFs, images, text files or folders here, or choose them. Įkelkite PDF, paveikslėlius, tekstinius failus ar aplankus čia arba pasirinkite juos. + Duplicate "{name}" onto: Dubliuoti „{name}“ į: + Duplicate Field Dubliuoti lauką + Duplicate… Dubliuoti… + Dynamic Dinaminė + Dynamic XFA form laid out from its template: {pages}, {fields}. Pagal šabloną išdėstyta dinaminė XFA forma: {pages}, {fields}. + Each file holds as many of the {n} pages as fit. Kiekviename faile telpa tiek iš {n} puslapių, kiek leidžia dydis. + Each image is measured where pages draw it; an image is replaced only if the result is smaller. Kiekvienas paveikslėlis vertinamas pagal jo atvaizduojamą dydį puslapyje; pakeičiamas tik tada, jei rezultatas mažesnis. + Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. Kiekvienas paveikslėlis įrašomas vieną kartą, pavadinant pagal dokumentą ir puslapį. JPEG paveikslėliai paliekami nepakeisti, kiti įrašomi PNG formatu. + Earlier in tab order Ankstesnė skirtukų tvarka + Edit Action Redaguoti veiksmą + Edit image Redaguoti paveikslėlį + Edit text… Redaguoti tekstą… + Edit… Redaguoti… + Email Address El. pašto adresas + Enable Acrobat JavaScript Įjungti „Acrobat JavaScript“ + Enable copying of text, images, and other content Leisti kopijuoti tekstą, paveikslėlius ir kitą turinį + Enable text access for screen reader devices for the visually impaired Leisti regos negalią turintiems žmonėms skirtoms ekrano skaitymo programoms pasiekti tekstą + Encrypt Šifruoti + Endpoints Galiniai taškai + Enhance page Pagerinti puslapį + Enter the password that opens it Įveskite jį atveriantį slaptažodį + Enter the permissions password to allow combining Įveskite teisių slaptažodį, kad galėtumėte sujungti + Entire page Visas puslapis + Even Pages Only Tik lyginiai puslapiai + Even and Odd Pages Lyginiai ir nelyginiai puslapiai + Even pages only Tik lyginiai puslapiai + Every PDF, image and text file in the folder Visi aplanke esantys PDF, paveikslėlių ir tekstiniai failai + Every PDF, image and text file in the folder and the folders inside it Visi aplanke ir jo poaplankiuose esantys PDF, paveikslėlių ir tekstiniai failai + Example: {sample} Pavyzdys: {sample} + Exclude images smaller than Neįtraukti mažesnių paveikslėlių nei + Expand Išskleisti + Expand all Išskleisti viską + Expand all bookmarks Išskleisti visas žymeles + Expand tag Išskleisti žymą + Expand top-level bookmarks Išskleisti aukščiausio lygio žymeles + Explain Paaiškinti + Export All Images Eksportuoti visus paveikslėlius + Export Value: {on} Eksportuoti reikšmę: {on} + Export all to data file… Eksportuoti viską į duomenų failą… + Export certificate… Eksportuoti sertifikatą… + Export comments Eksportuoti komentarus + Export form data Eksportuoti formos duomenis + Export stopped: {e} Eksportavimas sustabdytas: {e} + Export to Image Eksportuoti kaip paveikslėlį + Export to Text Eksportuoti kaip tekstą + Exported 1 image Eksportuotas 1 paveikslėlis + Exported the text of 1 page Eksportuotas 1 puslapio tekstas + Exported the text of {n} pages Eksportuotas {n} puslapių tekstas + Exported {n} images Eksportuota paveikslėlių: {n} + Exporting… {d} of {t} Eksportuojama… {d} iš {t} + Export… Eksportuoti… + Extract pages as separate files Išskirti puslapius į atskirus failus + Extract pages to a new document Išskirti puslapius į naują dokumentą + Extracted 1 page Išskirtas 1 puslapis + Extracted {n} pages Išskirta puslapių: {n} + Field is used for file selection Laukas skirtas failui pasirinkti + Field names (comma-separated) Laukų pavadinimai (atskirti kableliais) + Field names with + - * / and parentheses; put \\ before spaces in names. Laukų pavadinimai su + - * / ir skliaustais; prieš tarpą pavadinime rašykite \\. + Field value is in range: Lauko reikšmė patenka į intervalą: + Field value is not validated Lauko reikšmė netikrinama + Field: {field} on page {p} Laukas {field}, puslapis {p} + Fields to reset (comma-separated; empty: all) Atkuriami laukai (atskirti kableliais; tuščia reikšmė – visi) + Figure {i} of {n}{page} Paveikslas {i} iš {n}{page} + File (.p12, .pfx) Failas (.p12, .pfx) + File Name Failo pavadinimas + File size Failo dydis + Files are combined from top to bottom. Leave Pages empty to take every page. Failai jungiami iš viršaus į apačią. Palikite lauką „Puslapiai“ tuščią, jei norite įtraukti visus puslapius. + Files are combined in the order shown. Drag them to change it. Failai sujungiami rodoma tvarka. Vilkite juos, kad ją pakeistumėte. + Files for {name} Failai: {name} + Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Čia rodomi „PdfCraft“ atverti failai. Įkelkite PDF bet kurioje vietoje, kad jį atvertumėte. + Fill & Sign dates Datos pildymo ir pasirašymo įrankyje + Fill & Sign signature Parašas pildymo ir pasirašymo įrankyje + Fill Color: Užpildo spalva: + Fill colour Užpildo spalva + Fill colour: none Užpildo spalva: nėra + Filling in form fields and signing existing signature fields Formos laukų pildymas ir esamų parašo laukų pasirašymas + Filter comments Filtruoti komentarus + Find a tag Rasti žymą + Find options Paieškos parinktys + Find text Ieškoti teksto + Find tools and commands Ieškoti įrankių ir komandų + Finding document fonts… Ieškoma dokumento šriftų… + Finish or discard the text being edited before selecting several objects. Prieš pasirinkdami kelis objektus užbaikite redaguoti tekstą arba atmeskite pakeitimus. + First / last page Pirmas / paskutinis puslapis + Fit Pritaikyti + Fit Height Pritaikyti aukštį + Fit Page Pritaikyti puslapiui + Fit Visible Pritaikyti matomai sričiai + Fit Width Pritaikyti plotį + Fit to height Pritaikyti aukščiui + Fit to page Pritaikyti puslapiui + Fix Taisyti + Fixed what could be fixed; 1 problem remains Ištaisyta tai, ką buvo galima; liko 1 problema + Fixed what could be fixed; {n} problems remain Ištaisyta tai, ką buvo galima; liko problemų: {n} + Flatten Fill & Sign when saving Įrašant suplokštinti pildymo ir pasirašymo turinį + Flip Horizontal Apversti horizontaliai + Flip Vertical Apversti vertikaliai + Flip horizontal Apversti horizontaliai + Flip on long edge Apversti per ilgąją kraštinę + Flip on short edge Apversti per trumpąją kraštinę + Flip vertical Apversti vertikaliai + Folders Aplankai + Font Size: Šrifto dydis: + Font colour: Šrifto spalva: + Font size 0 uses automatic sizing. Šrifto dydis 0 reiškia, kad dydis parenkamas automatiškai. + Font size: Šrifto dydis: + Font: Šriftas: + Font: Helvetica Šriftas: „Helvetica“ + Fonts Šriftai + For page sizes and margins. Puslapių dydžiams ir paraštėms. + Form data and PDF forms Formų duomenys ir PDF formos + Form fields are flattened: their values stay visible. Saving rewrites the whole file. Formos laukai suplokštinami: jų reikšmės lieka matomos. Įrašant perrašomas visas failas. + Form fields only Tik formos laukai + Form fill-in and digital signatures Formų pildymas ir skaitmeniniai parašai + Format Formatas + Format text Formatuoti tekstą + Format: Formatas: + From Nuo + From a PDF page or an image Iš PDF puslapio arba paveikslėlio + From colour Iš spalvos + From {file} Iš {file} + From: Nuo: + Front side only Tik priekinė pusė + Get help, share feedback and follow development on Discord. Gaukite pagalbos, pateikite atsiliepimų ir sekite naujienas „Discord“. + Go to a page view Pereiti į puslapio rodinį + Go to page {p} Pereiti į puslapį {p} + Go to the page Pereiti į puslapį + Go to this result Pereiti prie šio rezultato + Graphic Grafika + Greyscale Pilki atspalviai + Grid view Tinklelio rodinys + Hand (H) Rankos įrankis (H) + Has JavaScript actions (run in M6) Turi JavaScript veiksmų (bus vykdomi M6 etape) + Height: Aukštis: + Hide field highlights Slėpti laukų paryškinimą + Hide layer Slėpti sluoksnį + Hide menu bar Slėpti meniu juostą + Hide password Slėpti slaptažodį + Hide toolbars Slėpti įrankių juostas + Hide window controls Slėpti lango valdiklius + High Resolution Didelė raiška + High resolution Didelė raiška + Highlight Style: Paryškinimo stilius: + Highlight fields Paryškinti laukus + Home Pradžia + Horizontal Horizontaliai + Horizontal reversed Horizontaliai atvirkščiai + Horizontal scale Horizontalusis mastelis + Icon Piktograma + Images and text Paveikslėliai ir tekstas + Images and text (converted to PDF) Paveikslėliai ir tekstas (konvertuoti į PDF) + Import list… Importuoti sąrašą… + Includes unsaved changes Yra neįrašytų pakeitimų + Indent Įtrauka + Initial View Pradinis rodinys + Ink sensitivity Rašiklio jautrumas + Insert Date Įterpti datą + Insert Page Number Įterpti puslapio numerį + Insert a blank page after the selection Po pasirinkimo įterpti tuščią puslapį + Insert a file at the end Failą įterpti pabaigoje + Insert a file before page {label} Failą įterpti prieš puslapį {label} + Insert pages from a file… Įterpti puslapius iš failo… + Inserting, deleting, and rotating pages Puslapių įterpimas, naikinimas ir pasukimas + Installed fonts… Įdiegti šriftai… + Intersections Sankirtos + Invisible Rectangle Nematomos stačiakampės srities anotacija + Issued by: Išdavė: + Italic Kursyvas + Item List Elementų sąrašas + Item: Elementas: + Items marked Ready work today. Elementai, pažymėti „Parengta“, veikia dabar. + Its security settings don't allow copying pages Saugos nuostatos neleidžia kopijuoti puslapių + JavaScript Console JavaScript konsolė + JavaScript is turned off (Preferences ▸ JavaScript). JavaScript išjungtas (Nuostatos > JavaScript). + JavaScript, e.g. getField("total").value JavaScript, pvz., getField("total").value + Join our Discord Prisijunkite prie mūsų „Discord“ serverio + Join the ArtCraft community Prisijunkite prie „ArtCraft“ bendruomenės + Justify Abipusė lygiuotė + Keep source fonts Išsaugoti šaltinio šriftus + Keep tool selected Palikti įrankį pasirinktą + Keep tool selected: on Palikti įrankį pasirinktą: įjungta + Key Algorithm Rakto algoritmas + Keychain · Raktinė · + Label Žyma + Labels Žymos + Labels: {preview}. Later pages keep their labels. Žymos: {preview}. Tolesniuose puslapiuose žymos išlieka. + Landscape Gulsčias + Landscape Pages Gulsčios orientacijos puslapiai + Landscape and Portrait Pages Gulsčios ir stačios orientacijos puslapiai + Larger pages Didesni puslapiai + Later Vėliau + Later in tab order Vėlesnė skirtukų tvarka + Layers Sluoksniai + Layers Panel and Page Sluoksnių skydelis ir puslapis + Layout and Magnification Išdėstymas ir mastelis + Leave out files that can't be read? Neįtraukti failų, kurių nepavyksta perskaityti? + Left Kairė + Left Edge Kairysis kraštas + Left out: {names} Neįtraukti: {names} + Left: Kairėje: + Light theme Šviesioji tema + Limit of Riba: + Line Style: Linijos stilius: + Line Thickness: Linijos storis: + Line ending Linijos galas + Line spacing Eilučių intervalas + Line thickness Linijos storis + Link Action Saito veiksmas + Link Appearance Saito išvaizda + Link Properties Saito savybės + Link Type: Saito tipas: + List view Sąrašo rodinys + Load font… Įkelti šriftą… + Locked Užrakinta + Looking for PDFs… Ieškoma PDF failų… + Looking for printers… Ieškoma spausdintuvų… + Low Resolution (150 dpi) Maža raiška (150 dpi) + Low resolution Maža raiška + Magnification Didinimas + Make Current Properties Default Dabartines savybes nustatyti kaip numatytąsias + Margins Paraštės + Mark Page Range Pažymėti puslapių intervalą + Mark as comments Pažymėti kaip komentarus + Mark with checkmark Pažymėti varnele + Marked Pažymėta + Mask: Kaukė: + Match page at 100% Sutapatinti puslapį esant 100 % masteliui + Medium Vidutinė + Midpoints Vidurio taškai + Mixed Mišrios reikšmės + Modified Pakeista + More options Daugiau parinkčių + More options: Daugiau parinkčių: + More ways to add files Daugiau būdų pridėti failų + Mouse wheel scroll speed Slinkimo ratuku greitis + Move down Perkelti žemyn + Move earlier Perkelti anksčiau + Move later Perkelti vėliau + Move up Perkelti aukštyn + Multi-line Kelių eilučių + Multiple Keli + Multiple selection Kelių elementų pasirinkimas + Multiple words or phrases Keli žodžiai ar frazės + Name: Pavadinimas: + Navigation tab Naršymo kortelė + Needs manual check Reikia patikrinti rankiniu būdu + Negative number style: Neigiamų skaičių formatas: + New Action Naujas veiksmas + New Action… Naujas veiksmas… + New bookmark ({key}) Nauja žymelė ({key}) + New file: Naujas failas: + Next ({key}) Kitas ({key}) + Next / previous match Kitas / ankstesnis atitikmuo + Next figure Kitas paveikslas + Next month Kitas mėnuo + Next page Kitas puslapis + Next sheet Kitas lapas + Next view Kitas rodinys + No PDFs in this folder yet Šiame aplanke PDF failų dar nėra + No actions Veiksmų nėra + No changes allowed Pakeitimai neleidžiami + No changes to save Nėra įrašytinų pakeitimų + No check has run yet. Patikra dar nevykdyta. + No color Be spalvos + No colour Be spalvos + No comments match. Atitinkančių komentarų nėra. + No comments yet. Komentarų dar nėra. + No differences in the text. Tekste skirtumų nėra. + No digital IDs yet. Skaitmeninių ID dar nėra. + No documents are open Nėra atvertų dokumentų + No file chosen Failas nepasirinktas + No fill Be užpildo + No fonts are referenced by the pages. Puslapiuose nenaudojami jokie šriftai. + No issues found Problemų nerasta + No limit Neribojama + No matches Atitikmenų nėra + No matches. Atitikmenų nėra. + No matching tools Atitinkančių įrankių nėra + No openly licensed document fonts found. Load a font file. Nerasta atvirąją licenciją turinčių dokumento šriftų. Įkelkite šrifto failą. + No pages selected Nepasirinkta puslapių + No photo chosen Nuotrauka nepasirinkta + No preview Peržiūros nėra + No problems found: the document conforms to {level}. Problemų nerasta: dokumentas atitinka {level} reikalavimus. + No security Apsaugos nėra + No sheets Lapų nėra + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Rėmelyje parašo nerasta. Perkelkite rėmelį ant parašo arba padidinkite rašalo jautrumą. + No status has been set. Būsena nenustatyta. + None (prefix only) Nėra (tik priešdėlis) + None found Nieko nerasta + None — everything is allowed Nėra — viskas leidžiama + Not allowed Neleidžiama + Not present Nėra + Nothing to redo Nėra ką pakartoti + Nothing to undo Nėra ko anuliuoti + Number Skaičius + Number of pages Puslapių skaičius + Number pages Numeruoti puslapius + Odd Pages Only Tik nelyginiai puslapiai + Odd pages only Tik nelyginiai puslapiai + Offset from centre Poslinkis nuo centro + Old file: Senas failas: + One word or phrase per line Po vieną žodį ar frazę eilutėje + One {f} file per page, named after the document. Po vieną {f} failą kiekvienam puslapiui, pavadintą pagal dokumentą. + Only change the colour of black text or line art Keisti tik juodo teksto ir linijinių piešinių spalvą + Only checked properties will change. Mixed means the selected fields have different values. Bus pakeistos tik pažymėtos savybės. „Mišrios reikšmės“ reiškia, kad pasirinktų laukų reikšmės skiriasi. + Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. Keičiamas tik puslapio turinys: saitai, komentarai, formos laukai ir žymelės pradiniuose puslapiuose lieka. + Open Atverti + Open a PDF ({key}) Atverti PDF ({key}) + Open a PDF to measure it. Atverkite PDF, kad galėtumėte jį išmatuoti. + Open a document first Pirmiausia atverkite dokumentą + Open a web page Atverti tinklalapį + Open arrow Atvira rodyklė + Open email app Atverti el. pašto programą + Open in Full Screen mode Atverti viso ekrano veiksena + Open in a new tab Atverti naujoje kortelėje + Open link Atverti saitą + Open the file as it was saved then Atverti anksčiau įrašytą failo versiją + Open the older version first Pirmiausia atverkite senesnę versiją + Open this web page? Atverti šį tinklalapį? + Open to page Atverti ties puslapiu + Opened, but its security settings don't allow combining. Enter the permissions password. Atverta, tačiau saugos nuostatos neleidžia sujungti. Įveskite teisių slaptažodį. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Atveriamos spausdintuvo tvarkyklės nuostatos. Jos įrašomos visiems šiuo kompiuteriu atliekamiems spausdinimams; čia pasirinktas kopijų skaičius, dvipusis spausdinimas, pilki atspalviai ir popierius lieka taikomi. + Optimizing… cleaning up Optimizuojama… tvarkoma + Optimizing… image {d} of {t} Optimizuojama… paveikslėlis {d} iš {t} + Optimizing… merging identical objects Optimizuojama… jungiami vienodi objektai + Optimizing… removing user data Optimizuojama… šalinami naudotojo duomenys + Optimizing… writing the copy Optimizuojama… įrašoma kopija + Optional: needs an AI provider you configure Pasirenkama: reikia sukonfigūruoti DI teikėją + Options… Parinktys… + Organization Name Organizacijos pavadinimas + Organizational Unit Organizacijos padalinys + Orientation: Padėtis: + Original Pradinis + Original: replace pages Pradinis dokumentas: pakeisti puslapius + Other colour… Kita spalva… + Outdent Sumažinti įtrauką + Output Išvestis + Output appears here. Čia bus rodoma išvestis. + Output intent: {list} Išvesties paskirtis: {list} + Overlap: Persidengimas: + Owner password (no restrictions apply) Savininko slaptažodis (apribojimai netaikomi) + PDF Optimizer PDF optimizatorius + PDF or image PDF arba paveikslėlis + PDF page: {size} PDF puslapis: {size} + PDF scale: {scale}% PDF mastelis: {scale} % + PDF, images and text PDF, paveikslėliai ir tekstas + Page Only Tik puslapis + Page Range Options Puslapių intervalo parinktys + Page Sizing & Handling Puslapio dydis ir apdorojimas + Page background Puslapio fonas + Page display: {layout} Puslapių rodinys: {layout} + Page layout Puslapio išdėstymas + Page navigation Naršymas puslapiuose + Page number Puslapio numeris + Page order: Puslapių tvarka: + Page size Puslapio dydis + Page {label} Puslapis {label} + Page {p} of {n} Puslapis {p} iš {n} + Pages Panel and Page Puslapių skydelis ir puslapis + Pages from Puslapiai nuo + Pages of {name} to combine, e.g. 1-3, 6 Jungiami dokumento {name} puslapiai, pvz., 1–3, 6 + Pages per file Puslapių faile + Pages per sheet: Puslapių lape: + Pages to Print Spausdinami puslapiai + Pages: Puslapiai: + Pages: {k} of {n} Puslapiai: {k} iš {n} + Paper: Popierius: + Part of Dalis iš + Password Slaptažodis + Password required Reikalingas slaptažodis + Password security Apsauga slaptažodžiu + Password-protected Apsaugotas slaptažodžiu + Paste after Įdėti po + Paths Keliai + Pattern Šablonas + Patterns Šablonai + Patterns (graphics objects) Šablonai (grafiniai objektai) + PdfCraft didn't shut down normally. These documents had changes that were autosaved: „PdfCraft“ buvo uždaryta netinkamai. Šių dokumentų pakeitimai buvo automatiškai įrašyti: + PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. „PdfCraft“ veikia neprisijungus. Nerenkami telemetrijos duomenys, nereikia paskyros ir nenaudojamos debesijos paslaugos, nebent pridėtumėte jų teikėją. + PdfCraft {c} is up to date. Naudojate naujausią „PdfCraft“ versiją {c}. + PdfCraft {v} is available. Išleista „PdfCraft“ versija {v}. + Percentage Procentai + Permissions password Teisių slaptažodis + Permitted actions after certifying Po patvirtinimo leidžiami veiksmai + Photo Nuotrauka + Pick a tool in the quick bar, or type above. Pasirinkite įrankį sparčiojoje juostoje arba įveskite paieškos tekstą aukščiau. + Pictures on those pages are recognized too; words already in the page's text are not added again Paveikslėliuose esantis tekstas taip pat atpažįstamas; puslapio tekste jau esantys žodžiai dar kartą nepridedami + Pin a folder to Home Prisegti aplanką pradžios lange + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Prisekite aplanką, kad čia būtų rodomi naujausi PDF failai, pavyzdžiui, nuskaityti ar sinchronizuotoje debesijos saugykloje įrašyti dokumentai. + Pin a folder… Prisegti aplanką… + Plain text in reading order; pages are separated by form feeds. Paprastasis tekstas skaitymo tvarka; puslapiai atskiriami puslapio skirtukais. + Planned Suplanuota + Planned for milestone Numatyta etapui + Points from the page's bottom-left corner. Taškai skaičiuojami nuo puslapio apatinio kairiojo kampo. + Portrait Stačias + Portrait Pages Stačios orientacijos puslapiai + Post Skelbti + Poster Plakatas + Prefix Priešdėlis + Preview Peržiūra + Preview unavailable: {e} Peržiūra negalima: {e} + Preview: page {p} of {n} Peržiūra: {p} puslapis iš {n} + Previous ({key}) Ankstesnis ({key}) + Previous / next page Ankstesnis / kitas puslapis + Previous figure Ankstesnis paveikslas + Previous month Ankstesnis mėnuo + Previous page Ankstesnis puslapis + Previous sheet Ankstesnis lapas + Previous view Ankstesnis rodinys + Print ({key}) Spausdinti ({key}) + Print in grayscale Spausdinti pilkais atspalviais + Print page border Spausdinti puslapio rėmelį + Print, NextPage, PrevPage, FirstPage or LastPage Print, NextPage, PrevPage, FirstPage arba LastPage + Printer paper and scaling may differ. Check driver settings. Spausdintuvo popieriaus formatas ir mastelis gali skirtis. Patikrinkite tvarkyklės nuostatas. + Printer properties Spausdintuvo savybės + Printer: Spausdintuvas: + Printing allowed Leidžiama spausdinti + Privacy Privatumas + Properties… Savybės… + Protect Using Password Apsaugoti slaptažodžiu + Protected: the combined file won't keep its security settings Apsaugota: sujungtame faile saugos nuostatos neišliks + Quality Kokybė + Read Only Tik skaityti + Reading colours Skaitomos spalvos + Ready Parengta + Reason Priežastis + Recent Paskiausiai naudoti + Recent colours Paskiausiai naudotos spalvos + Recognize Text Atpažinti tekstą + Recognized 1 word Atpažintas 1 žodis + Recognized {n} words Atpažinta žodžių: {n} + Recognized {w} words in 1 file 1 faile atpažinta žodžių: {w} + Recognized {w} words in {n} files {n} failuose atpažinta žodžių: {w} + Recognizing text… file {d} of {t} Atpažįstamas tekstas… failas {d} iš {t} + Recognizing text… page {d} of {t} Atpažįstamas tekstas… puslapis {d} iš {t} + Recommended tools Rekomenduojami įrankiai + Recover Atkurti + Recover unsaved documents? Atkurti neįrašytus dokumentus? + Redact all Pašalinti visą konfidencialią informaciją + Redacted area fill colour: Šalinamos srities užpildo spalva: + Redaction Tool Properties Konfidencialios informacijos šalinimo įrankio savybės + Redaction code: Šalinimo kodas: + Reload Įkelti iš naujo + Reload documents when their files change on disk Iš naujo įkelti dokumentus, kai pasikeičia jų failai diske + Remove checkmark Pašalinti varnelę + Remove invalid links and bookmarks Pašalinti netinkamus saitus ir žymeles + Remove page Pašalinti puslapį + Remove unreferenced named destinations Pašalinti nenaudojamus pavadintus paskirties taškus + Remove unused objects and merge identical ones Pašalinti nenaudojamus objektus ir sujungti vienodus + Remove {name} Pašalinti {name} + Remove {name} from Recent Pašalinti {name} iš paskiausiai naudotų failų + Remove {n} files Pašalinti failų: {n} + Remove {n} pages Pašalinti puslapių: {n} + Reopen Closed Tab Vėl atverti užvertą kortelę + Reopen the files that were open when PdfCraft last closed Vėl atverti failus, kurie buvo atverti paskutinį kartą užveriant „PdfCraft“ + Repair log Taisymo žurnalas + Repeat overlay text Kartoti perdangos tekstą + Replace Image… Pakeisti paveikslėlį… + Replace Pages Pakeisti puslapius + Replace document colours Pakeisti dokumento spalvas + Replace image Pakeisti paveikslėlį + Replacement text Pakaitinis tekstas + Replacement: pages of {name} Pakaitinis dokumentas: {name} puslapiai + Replaces the existing one on those pages. Tuose puslapiuose pakeičia esamą. + Report Ataskaita + Report… Ataskaita… + Requires user to enter a password for: Naudotojas turi įvesti slaptažodį, kad galėtų: + Reset columns Atkurti stulpelius + Reset page size Atkurti puslapio dydį + Reset to printer defaults Atkurti numatytąsias spausdintuvo nuostatas + Resize window to initial page Pakeisti lango dydį, kad tilptų pradinis puslapis + Resolution Raiška + Retain existing Palikti esamą + Reveal in File Explorer Rodyti „File Explorer“ + Reveal in Finder Rodyti „Finder“ + Reverse closed arrow Atvirkštinė uždara rodyklė + Reverse open arrow Atvirkštinė atvira rodyklė + Reverse pages Apversti puslapius + Revert changes Atšaukti pakeitimus + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. Grąžinti anksčiau įrašytą „{name}“ versiją? Po to atliktų pakeitimų nebegalėsite atkurti. + Review History Pakeitimų retrospektyva + Revisions Versijos + Right Dešinė + Right Edge Dešinysis kraštas + Rotate Clockwise Pasukti pagal laikrodžio rodyklę + Rotate Counterclockwise Pasukti prieš laikrodžio rodyklę + Rotate Pages Pasukti puslapius + Rotate clockwise Pasukti pagal laikrodžio rodyklę + Rotate counterclockwise Pasukti prieš laikrodžio rodyklę + Rotate view Pasukti rodinį + Rotate view clockwise Pasukti rodinį pagal laikrodžio rodyklę + Rotate view clockwise ({key}) Pasukti rodinį pagal laikrodžio rodyklę ({key}) + Rotate view counterclockwise Pasukti rodinį prieš laikrodžio rodyklę + Rotate: Pasukimas: + Rotation: Pasukimas: + Running action… file {d} of {t} Vykdomas veiksmas… failas {d} iš {t} + Same as interface ({language}) Kaip sąsajoje ({language}) + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. Išvalant dokumentą pašalinama paslėpta informacija: metaduomenys, failų priedai, komentarai, formos laukai (suplokštinami), paslėptas tekstas ir sluoksniai, žymelės, saitai, veiksmai, scenarijai ir privatūs programos duomenys. + Save & Close Įrašyti ir užverti + Save ({key}) Įrašyti ({key}) + Save Image As… Įrašyti paveikslėlį kaip… + Copy Image Kopijuoti paveikslėlį + The image has been copied ({w} × {h} pixels) Paveikslėlis nukopijuotas ({w} × {h} pikselių) + Couldn't copy the image: {e} Nepavyko nukopijuoti paveikslėlio: {e} + Copy Image in Original Format Kopijuoti paveikslėlį originaliu formatu + The image has been copied as a {ext} file Paveikslėlis nukopijuotas kaip {ext} failas + The page has changed; select the image again Puslapis pasikeitė; pažymėkite paveikslėlį iš naujo + Save as PDF Įrašyti kaip PDF + Save attachment… Įrašyti priedą… + Save changes to “{name}” before closing? Prieš užveriant įrašyti „{name}“ pakeitimus? + Save pages Įrašyti puslapius + Save tag properties Įrašyti žymos savybes + Save the accessibility report Įrašyti prieinamumo ataskaitą + Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. Po to įrašykite dokumentą: įrašant perrašomas visas failas, kad neliktų pašalinto turinio pėdsakų. + Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. Po to įrašykite dokumentą; įrašant perrašomas visas failas, kad neliktų pašalinto turinio. + Save the spreadsheet Įrašyti skaičiuoklę + Save these pages as one PDF Įrašyti šiuos puslapius kaip vieną PDF + Saved measurements Įrašyti matavimai + Saved measurements. Matavimai įrašyti. + Scale relative to target page Mastelis pagal paskirties puslapį + Scan Nuskaityti + Script Name: Scenarijaus pavadinimas: + Script: Scenarijus: + Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Scenarijai vykdomi izoliuotoje aplinkoje be prieigos prie failų ir tinklo. Scenarijui paprašius atverti tinklalapį, pirmiausia reikės jūsų leidimo. Išjungus JavaScript, vis tiek veikia standartinės „Acrobat“ formatavimo, tikrinimo ir skaičiavimo funkcijos. + Scroll long text Slinkti ilgą tekstą + Scroll tabs left Slinkti korteles kairėn + Scroll tabs right Slinkti korteles dešinėn + Search comments Ieškoti komentaruose + Search tools and commands… Ieškoti įrankių ir komandų… + Searchable Image (Exact) Paveikslėlis su tiksliu atpažinimu + Searching… {s} of {p} pages Ieškoma… {s} iš {p} puslapių + Searching… {s}/{p} Ieškoma… {s}/{p} + Security Sauga + Security settings Saugos nuostatos + Select (V) Pasirinkti (V) + Select Action: Pasirinkite veiksmą: + Select All Pažymėti viską + Select Icon Pasirinkti piktogramą + Select Trigger: Pasirinkite paleidiklį: + Select a file first Pirmiausia pasirinkite failą + Select a file for the stamp Pasirinkite failą antspaudui + Select a password-protected file first Pirmiausia pasirinkite slaptažodžiu apsaugotą failą + Select a word Pasirinkti žodį + Select all pages (Organize) Pasirinkti visus puslapius (skiltyje „Tvarkyti“) + Select at most 1000 objects at a time. Vienu metu pasirinkite ne daugiau kaip 1 000 objektų. + Select data files to merge Pasirinkite jungiamus duomenų failus + Select format category: Pasirinkite formato kategoriją: + Select page {label} Pasirinkti puslapį {label} + Select pages Pasirinkti puslapius + Select the items to remove from this document. Pasirinkite iš dokumento šalinamus elementus. + Selected image files: {count} Pasirinkti paveikslėlių failai: {count} + Selected pages Pasirinkti puslapiai + Selection Pasirinkimas + Sending to {printer}… Siunčiama į {printer}… + Sensitivity Jautrumas + Sent to {printer} Išsiųsta į {printer} + Sent to {printer}: {msg} Išsiųsta į {printer}: {msg} + Separator style: Skirtuko stilius: + Set Alternate Text Nustatyti alternatyvųjį tekstą + Set Fields to Same Size Nustatyti vienodą laukų dydį + Set Page Boxes Nustatyti puslapio sritis + Set layer visibility Nustatyti sluoksnio matomumą + Set status Nustatyti būseną + Set to current page ({n}) Nustatyti į dabartinį puslapį ({n}) + Set to zero Nustatyti į nulį + Shared Field Properties Bendrosios laukų savybės + Sheet {s} of {n} {s} lapas iš {n} + Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. „Shift“ apriboja kampą iki 45 laipsnių. „Backspace“ pašalina viršūnę. „Escape“ atšaukia veiksmą. + Shift- or Ctrl/Command-click to select several fields. Norėdami pasirinkti kelis laukus, spustelėkite laikydami „Shift“ arba „Ctrl“ / „Command“. + Shift-click to select several objects. Drag empty space to select an area. Norėdami pasirinkti kelis objektus, spustelėkite laikydami „Shift“. Vilkite tuščioje vietoje, kad pažymėtumėte sritį. + Show Report Rodyti ataskaitą + Show all Rodyti viską + Show all comments Rodyti visus komentarus + Show certificate… Rodyti sertifikatą… + Show layer Rodyti sluoksnį + Show less Rodyti mažiau + Show password Rodyti slaptažodį + Show the accessibility report Rodyti prieinamumo ataskaitą + Show this dialog when the Checker starts Paleidus tikrintuvą rodyti šį dialogą + Show {n} more Rodyti dar {n} + Show: Rodyti: + Shrink oversized pages Sumažinti per didelius puslapius + Sign Here Pasirašykite čia + Sign this field… Pasirašyti šiame lauke… + Sign yourself Pasirašyti pačiam + Signature Details Parašo informacija + Signature is INVALID: Parašas NETEISINGAS: + Signature is valid: Parašas galioja: + Signature panel Parašų skydelis + Signature validity is UNKNOWN: Parašo galiojimas NEŽINOMAS: + Signer's name Pasirašiusiojo vardas + Simplified field notation: Supaprastinta lauko išraiška: + Single Page Vienas puslapis + Single Page Continuous Vienas puslapis, nepertraukiamas rodinys + Single word or phrase Vienas žodis ar frazė + Skip Rule Praleisti taisyklę + Skipped Praleista + Slash Pasvirasis brūkšnys + Smaller pages Mažesni puslapiai + Snap to drawing Pritraukti prie brėžinio + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Kai kurios žymelės nerodomos: skydelyje pateikiamas ribotas jų skaičius ir įdėjimo lygis. + Some page content couldn't be read; snapping uses the rest. Nepavyko perskaityti dalies puslapio turinio; pritraukimui naudojama likusi dalis. + Sort by Rūšiuoti pagal + Sort comments by: Rūšiuoti komentarus pagal: + Sort items Rūšiuoti elementus + Source Šaltinis + Space Audit Vietos naudojimo analizė + Special Specialusis + Split document Skaidyti dokumentą + Split into files… Skaidyti į failus… + Square Kvadratas + Standard Business Standartinis verslo formatas + Start Pradėti + Start Checking Pradėti tikrinimą + Start page number Pradinio puslapio numeris + Status Būsena + Steps Veiksmai + Strength: {s} Stiprumas: {s} + Style Stilius + Subject Tema + Subset Poaibis + Summarize Options Apibendrinimo parinktys + Summary Suvestinė + Switch to List view to change the pages it takes. Norėdami pakeisti pasirinktus puslapius, perjunkite sąrašo rodinį. + Switch to two-page view first to show the cover page Norėdami rodyti viršelio puslapį, pirmiausia perjunkite dviejų puslapių rodinį + Tab order: move a field with its arrows. Skirtukų tvarka: lauką perkelkite rodyklėmis. + Tag type Žymos tipas + Tags Žymos + Text Color: Teksto spalva: + Text alignment: Teksto lygiuotė: + Text in images isn't found; recognise text (OCR) first. Paveikslėliuose esantis tekstas nerandamas; pirmiausia atpažinkite tekstą (OCR). + Text is compared word by word; the differences are shown on the new file. Tekstas lyginamas žodis po žodžio; skirtumai rodomi naujame faile. + Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Teksto atpažinimas neįdiegtas: trūksta modelio failų. Iš naujo įdiekite „PdfCraft“ arba nustatykite PDFCRAFT_MODELS aplanką, kuriame jie laikomi. + Text to find Ieškomas tekstas + Text to insert Įterpiamas tekstas + Text used as a clipping path cannot be moved with this selection. Teksto, naudojamo kaip iškarpymo kelias, negalima perkelti pasirinkus šį objektą. + Text, marks and signatures become part of the page. Other comments stay editable. Tekstas, žymos ir parašai tampa puslapio dalimi. Kitus komentarus galima redaguoti. + Thai and Arabic use regular fallback fonts when available. Tajų ir arabų kalboms naudojami įprasti atsarginiai šriftai, jei jie prieinami. + The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF teksto šriftai negali atvaizduoti simbolio „{c}“ ({code}). Pasirinkite palaikomą tekstą. + The checker found 1 problem which may prevent the document from being fully accessible. Tikrintuvas aptiko 1 problemą, dėl kurios dokumentas gali būti nevisiškai pritaikytas prieinamumui. + The checker found no problems in this document. Tikrintuvas šiame dokumente problemų nerado. + The checker found {n} problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų: {n}. Dėl jų dokumentas gali būti nevisiškai pritaikytas prieinamumui. + The document now conforms to {level}; save it to keep the changes Dokumentas dabar atitinka {level}; įrašykite jį, kad išsaugotumėte pakeitimus + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. Dokumentą galima atverti be slaptažodžio; šis slaptažodis reikalingas apribojimams keisti. Jis pritaikomas įrašant dokumentą. + The document's security settings don't allow copying text and images Dokumento saugos nuostatos neleidžia kopijuoti teksto ir paveikslėlių + The document's security settings don't allow this change Dokumento saugos nuostatos neleidžia atlikti šio pakeitimo + The existing action will be preserved. Esamas veiksmas bus išsaugotas. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. „PdfCraft“ naudojami šriftai negali atvaizduoti simbolio „{c}“ ({code}), todėl visose PDF žiūryklėse jis rodomas kaip ?. Tekstas įrašytas. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. Raktas saugomas „Windows“ sertifikatų saugykloje; gali būti paprašyta leisti „PdfCraft“ juo naudotis. + The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. Raktas saugomas „macOS“ raktinėje; gali būti paprašyta leisti „PdfCraft“ juo naudotis. + The newest {n} of {total} PDFs Naujausi PDF failai: {n} iš {total} + The password is only used to read the file; it isn't saved. Slaptažodis naudojamas tik failui perskaityti ir nėra įrašomas. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Šio spausdinimo spausdintuvo tvarkyklės nuostatos: popieriaus dėklas, popieriaus tipas, apdaila ir kita. + The replacement file doesn't have that many pages. Pakaitiniame faile nėra tiek puslapių. + The same password is tried on each file. Kiekvienam failui bandoma naudoti tą patį slaptažodį. + The signature will be invisible. Parašas bus nematomas. + The text is the same Tekstas sutampa + The value is shown as typed. Reikšmė rodoma taip, kaip įvesta. + There are no redaction marks (mark text, areas or pages first) Nėra šalinimo žymų (pirmiausia pažymėkite tekstą, sritis arba puslapius) + These files can't be read, so they won't be in the combined PDF: Šių failų nepavyksta perskaityti, todėl jie nebus įtraukti į sujungtą PDF: + These objects could not be moved. Select them again and try a smaller move. Šių objektų nepavyko perkelti. Pasirinkite juos iš naujo ir pabandykite perkelti mažesniu atstumu. + Thick Storas + Thickness Storis + Thin Plonas + This area has too many overlapping paths; intersection snapping is limited. Šioje srityje persidengia per daug kelių; pritraukimas prie sankirtų apribotas. + This certificate is in your list of trusted certificates. Šis sertifikatas yra patikimų sertifikatų sąraše. + This certificate is not trusted. Signatures made with it show an unknown identity. Šis sertifikatas nepatikimas. Juo pasirašius rodoma nežinoma tapatybė. + This certificate is trusted through a certificate above it in the chain. Šis sertifikatas patikimas dėl aukštesnio sertifikatų grandinės sertifikato. + This comment's appearance can't be changed yet. Šio komentaro išvaizdos kol kas negalima pakeisti. + This dialog shows the details of a certificate and its chain. Šiame dialoge pateikiama sertifikato ir jo grandinės informacija. + This document contains {n} interactive form fields. Šiame dokumente yra interaktyvių formos laukų: {n}. + This document has no comments to flatten Šiame dokumente nėra suplokštinamų komentarų + This document has no document-level scripts. Šiame dokumente nėra dokumento lygmens scenarijų. + This document has no editable structure tags. Šiame dokumente nėra redaguojamų struktūros žymų. + This document has no form fields to flatten Šiame dokumente nėra suplokštinamų formos laukų + This document has no form fields you can fill in Šiame dokumente nėra pildomų formos laukų + This document has no pages. Šiame dokumente nėra puslapių. + This document has no restrictions to lift Šiam dokumentui netaikomi apribojimai, kuriuos būtų galima panaikinti + This document has no signatures. Šiame dokumente nėra parašų. + This document has no tagged figures. Šiame dokumente nėra pažymėtų paveikslų. + This document has no {kind} to change Šiame dokumente nėra keistinų elementų ({kind}) + This document hasn't been checked yet. Šis dokumentas dar netikrintas. + This document is secured. Some changes are restricted by its security settings. Dokumentas apsaugotas. Saugos nuostatos riboja kai kuriuos pakeitimus. + This document isn't password-protected Šis dokumentas neapsaugotas slaptažodžiu + This drawing exceeds the snapping limit; only the first paths are used. Brėžinys viršija pritraukimo ribą; naudojami tik pirmieji keliai. + This field is locked. Unlock it to change its properties. Šis laukas užrakintas. Atrakinkite jį, kad galėtumėte keisti savybes. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Failas diske pasikeitė. Įkelkite jį iš naujo, kad matytumėte naują versiją; neįrašyti pakeitimai bus prarasti. + This file was damaged and has been repaired. Saving keeps the repaired version. Failas buvo pažeistas ir pataisytas. Įrašius bus išsaugota pataisyta versija. + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Aplankas šiuo metu nepasiekiamas. Jis galėjo būti perkeltas arba diskas ar sinchronizavimas su debesija neveikia. + This form's scripts are turned off because one ran too long. Reopen the document to run them again. Šios formos scenarijai išjungti, nes vienas jų veikė per ilgai. Atverkite dokumentą iš naujo, kad scenarijai būtų vykdomi vėl. + This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. Tai dinaminė XFA forma, kurios „PdfCraft“ dar negali rodyti. Matote faile esantį pakaitinį puslapį. + This measurement needs at least {n} points. Šiam matavimui reikia bent {n} taškų. + This page contains objects that cannot be selected together. Šiame puslapyje yra objektų, kurių negalima pasirinkti kartu. + This page couldn't be displayed.\n{e} Nepavyko parodyti šio puslapio.\n{e} + This printer offers no settings of its own. Šis spausdintuvas neturi atskirų nuostatų. + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Šio spausdintuvo tvarkyklė neperima dvipusio spausdinimo nuostatų iš kitų programų, todėl taikoma jos nuostata. Pakeiskite ją skiltyje „Savybės“… + This web address mixes letters from different alphabets, a common way to imitate another site's name. Šiame žiniatinklio adrese sumaišytos skirtingų abėcėlių raidės — taip dažnai imituojamas kitos svetainės pavadinimas. + This web address uses letters from another alphabet, which can look like familiar ones. Šiame žiniatinklio adrese naudojamos kitos abėcėlės raidės, kurios gali būti panašios į įprastas. + Tile scale: Išklotinių mastelis: + Time Laikas + Title Pavadinimas + To change locked fields, also set Locked to No. Norėdami keisti užrakintus laukus, nustatykite „Užrakinta“ į „Ne“. + To: Kam: + Today: {date} Šiandien: {date} + Toggle fit page / fit width Perjungti puslapio / pločio pritaikymą + Tool Įrankis + Tools panel side Įrankių skydelio pusė + Tooltip: Patarimas: + Top Viršus + Top-level bookmarks ({n}) Aukščiausio lygio žymelės ({n}) + Total Iš viso + Trust Pasitikėti + Try the form as people filling it in will see it Peržiūrėti formą taip, kaip ją matys pildantys žmonės + Two-Up (Cover Page) Du puslapiai (viršelio puslapis) + Two-Up (Facing) Du atgręžti puslapiai + Two-Up Continuous (Cover Page) Du puslapiai nepertraukiamai (viršelio puslapis) + Two-Up Continuous (Facing) Du atgręžti puslapiai nepertraukiamai + Two-sided: Dvipusis spausdinimas: + Type Tipas + Type text Įvesti tekstą + Type the web address. Įveskite žiniatinklio adresą. + Type your {what}. Įveskite {what}. + URL to submit to Pateikimo URL adresas + Unit Vienetas + Units Vienetai + Unknown author Nežinomas autorius + Unlock Atrakinti + Unlock {name} Atrakinti {name} + Unlock {n} files Atrakinti failus: {n} + Unlocked: the combined file won't be password-protected Atrakinta: sujungtas failas nebus apsaugotas slaptažodžiu + Unlock… Atrakinti… + Unpin Atsegti + Unpin {folder} Atsegti aplanką {folder} + Unplaced Neįdėta + Unsupported measurements Nepalaikomi matavimo vienetai + Up Aukštyn + Update Atnaujinti + Use 72 DPI (one point per pixel) Naudoti 72 DPI (vienas taškas vienam pikseliui) + Use Flate to encode streams that are not encoded Nekoduotiems srautams koduoti naudoti „Flate“ + Use a Digital ID from a file Naudoti faile esantį skaitmeninį ID + Use custom DPI Naudoti pasirinktinį DPI + Use each image's embedded DPI; use 72 DPI when it is absent. Naudoti paveikslėlyje įrašytą DPI; jei jo nėra, naudoti 72 DPI. + Use image resolution Naudoti paveikslėlio raišką + Use now Naudoti dabar + Use overlay text Naudoti perdangos tekstą + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Naudojama, kai PDF nenurodo išdėstymo ar mastelio. Nepertraukiamai slenkant puslapiai nesusiejami. + Used when opening PDFs. An explicit launch or control mode takes precedence for the session. Naudojama atveriant PDF. Aiškiai nurodyta paleidimo ar valdymo veiksena turi pirmenybę šio seanso metu. + User Interface Options Naudotojo sąsajos parinktys + User password Naudotojo slaptažodis + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. Galioja 5 metus ir skirtas skaitmeniniams parašams. Savarankiškai pasirašytais ID kiti paprastai nepasitiki. + Validate all Tikrinti viską + Value is not calculated Reikšmė neskaičiuojama + Value is the Reikšmė yra + Value: Reikšmė: + Verify Patikrinti + Verify to see what the document needs. Patikrinkite, kad sužinotumėte, ko reikia dokumentui. + Version {v} Versija {v} + Vertical Vertikaliai + Vertical reversed Vertikaliai atvirkščiai + Vertical text cannot be moved with this selection. Vertikalaus teksto negalima perkelti pasirinkus šį objektą. + View revision {n} Peržiūrėti versiją {n} + View signed version Peržiūrėti pasirašytą versiją + Viewport Peržiūros sritis + Viewport rectangle in page points (x, y, width, height) Peržiūros srities stačiakampis puslapio taškais (x, y, plotis, aukštis) + Visible Rectangle Matomo stačiakampio anotacija + Visual Vaizdinis + Warnings Įspėjimai + Welcome to PdfCraft Sveiki atvykę į „PdfCraft“ + What word or phrase would you like to search for? Kokio žodžio ar frazės norite ieškoti? + Whole words only Tik ištisi žodžiai + Width: Plotis: + Window Options Lango parinktys + Windows store · „Windows“ sertifikatų saugykla · + Write this email? Rašyti šį el. laišką? + Wrong password Netinkamas slaptažodis + XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA forma: jos laukai ir XFA duomenys sinchronizuojami, todėl kitose žiūryklėse rodoma tai, ką įvedate. + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Ketinate pritaikyti 1 šalinimo žymą. Po žymomis esantis tekstas, paveikslėliai ir brėžiniai, taip pat su jomis persidengiantys komentarai ir formos laukai bus pašalinti visam laikui. + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Ketinate pritaikyti šalinimo žymų: {n}. Po žymomis esantis tekstas, paveikslėliai ir brėžiniai, taip pat su jomis persidengiantys komentarai ir formos laukai bus pašalinti visam laikui. + You have version {c}. All releases are listed at {page}. Turite versiją {c}. Visos laidos išvardytos čia: {page}. + You have version {c}. Download the new version from its release page. Turite versiją {c}. Atsisiųskite naują versiją iš jos laidos puslapio. + Your changes will be lost if you don't save them. Jei pakeitimų neįrašysite, jie bus prarasti. + ZIP ZIP + Zoom in Priartinti + Zoom in ({key}) Priartinti ({key}) + Zoom in / out (also pinch or {key}-scroll) Priartinti / nutolinti (taip pat suspaudžiant pirštus arba slenkant su {key}) + Zoom out Nutolinti + Zoom out ({key}) Nutolinti ({key}) + already a PDF jau yra PDF + and {n} more ir dar {n} + background fonas + characters simboliai + e.g. My stamps pvz., „Mano antspaudai“ + e.g. en-US pvz., en-US + for images above kai raiška viršija + header or footer antraštė arba poraštė + initials inicialai + is available prieinama + is not available yet dar neprieinama + just now ką tik + yesterday vakar + needs an AI provider (off by default) reikalingas DI teikėjas (išjungta pagal numatytuosius parametrus) + no output folder was chosen nepasirinktas išvesties aplankas + no unused output name nėra nenaudojamo išvesties pavadinimo + none nėra + of the following fields: šiems laukams: + AI assistant DI asistentas + Action Wizard Veiksmų vedlys + Action Wizard… Veiksmų vedlys… + Actions list Veiksmų sąrašas + Add Bates numbering… Pridėti Bates numeraciją… + Add a button Pridėti mygtuką + Add a callout Pridėti paaiškinimo laukelį + Add a checkbox Pridėti žymimąjį langelį + Add a date field Pridėti datos lauką + Add a digital signature field Pridėti skaitmeninio parašo lauką + Add a drop-down list Pridėti išskleidžiamąjį sąrašą + Add a list box Pridėti sąrašo lauką + Add a radio button Pridėti akutę + Add a stamp Pridėti antspaudą + Add a sticky note Pridėti lipnųjį raštelį + Add a text box Pridėti teksto laukelį + Add a text field Pridėti teksto lauką + Add alternate text Pridėti alternatyvųjį tekstą + Add alternate text… Pridėti alternatyvųjį tekstą… + Add an image field Pridėti paveikslėlio lauką + Add background… Pridėti foną… + Add comments Pridėti komentarus + Add content Pridėti turinį + Add files to combine Pridėti jungiamus failus + Add form components Pridėti formos komponentus + Add header & footer… Pridėti antraštę ir poraštę… + Add image… Pridėti paveikslėlį… + Add or edit links Pridėti arba redaguoti saitus + Add printer marks Pridėti spaudos žymes + Add search index Pridėti paieškos indeksą + Add text Pridėti tekstą + Add watermark… Pridėti vandenženklį… + Advanced optimization Išplėstinis optimizavimas + Advanced options Išplėstinės parinktys + Apply PDF standards Taikyti PDF standartus + Apply redactions… Pritaikyti konfidencialios informacijos šalinimą… + Archive documents Archyvuoti dokumentus + Area Plotas + Arrow Rodyklė + ArtCraft website „ArtCraft“ svetainė + Ask about this document Klausti apie šį dokumentą + Attach a file Pridėti failą + Attach file Pridėti failą + Audit space usage Patikrinti vietos naudojimą + Automatically tag PDF Automatiškai sudėti PDF žymas + Background Fonas + Bates numbering Bates numeracija + Blank page Tuščias puslapis + Bring your own model (off by default) Naudoti savo modelį (išjungta pagal numatytuosius parametrus) + Button Mygtukas + Certificates Sertifikatai + Certify (invisible signature) Patvirtinti (nematomas parašas) + Certify (visible signature) Patvirtinti (matomas parašas) + Change initials Keisti inicialus + Change reading options Keisti skaitymo parinktis + Change reading options… Keisti skaitymo parinktis… + Change signature Keisti parašą + Check for accessibility Tikrinti prieinamumą + Check for accessibility… Tikrinti prieinamumą… + Checkbox Žymimasis langelis + Checkmark Varnelė + Clear form Išvalyti formą + Clear redaction marks Pašalinti šalinimo žymas + Clipboard Iškarpinė + Cloud Debesis + Column select Stulpelio pasirinkimas + Combine Sujungti + Combine files Sujungti failus + Comment list Komentarų sąrašas + Comments panel Komentarų skydelis + Compare Palyginti + Compare files Palyginti failus + Compare files… Palyginti failus… + Compress a PDF Suglaudinti PDF + Console Konsolė + Convert colors Konvertuoti spalvas + Copy pages Kopijuoti puslapius + Correct recognized text Taisyti atpažintą tekstą + Create a PDF Kurti PDF + Create from Kurti iš + Create links from URLs Kurti saitus iš URL adresų + Crop pages Apkirpti puslapius + Cross Kryželis + Cut pages Iškirpti puslapius + Dark gray Tamsiai pilka + Date Data + Date field Datos laukas + Detect form fields Aptikti formos laukus + Digital signature Skaitmeninis parašas + Digitally sign Pasirašyti skaitmeniniu būdu + Distance Atstumas + Document JavaScripts Dokumento JavaScript scenarijai + Document JavaScripts… Dokumento JavaScript scenarijai… + Document actions Dokumento veiksmai + Dot Taškas + Draw a cloud Brėžti debesėlį + Draw a line Brėžti liniją + Draw a polygon Brėžti daugiakampį + Draw a rectangle Brėžti stačiakampį + Draw an arrow Brėžti rodyklę + Draw an oval Brėžti ovalą + Draw connected lines Brėžti sujungtas linijas + Draw freehand Brėžti laisva ranka + Drawing Brėžinys + Drawing scale Brėžinio mastelis + Drop-down list Išskleidžiamasis sąrašas + Duplicate pages Dubliuoti puslapius + Edit a PDF Redaguoti PDF + Edit text & images Redaguoti tekstą ir paveikslėlius + Encrypt with certificate Šifruoti sertifikatu + Enhance camera image Pagerinti fotoaparatu padarytą vaizdą + Enhance scanned file Pagerinti nuskaitytą failą + Enter permissions password… Įvesti teisių slaptažodį… + Erase drawings Ištrinti brėžinius + Export a PDF Eksportuoti PDF + Export all comments to data file… Eksportuoti visus komentarus į duomenų failą… + Export all images Eksportuoti visus paveikslėlius + Export all images… Eksportuoti visus paveikslėlius… + Export data Eksportuoti duomenis + Export form data… Eksportuoti formos duomenis… + Export measurements Eksportuoti matavimus + Export measurements as CSV Eksportuoti matavimus kaip CSV + Export to Eksportuoti į + Export to PowerPoint… Eksportuoti į „PowerPoint“… + Extract Išskirti + Extract pages Išskirti puslapius + Field list Laukų sąrašas + Field properties Lauko savybės + Field properties… Lauko savybės… + Fields Laukai + Fill Pildyti + Fill & Sign: add text Pildyti ir pasirašyti: pridėti tekstą + Fill & Sign: change initials Pildyti ir pasirašyti: keisti inicialus + Fill & Sign: change signature Pildyti ir pasirašyti: keisti parašą + Fill & Sign: checkmark Pildyti ir pasirašyti: varnelė + Fill & Sign: cross Pildyti ir pasirašyti: kryželis + Fill & Sign: date Pildyti ir pasirašyti: data + Fill & Sign: dot Pildyti ir pasirašyti: taškas + Fill & Sign: initials Pildyti ir pasirašyti: inicialai + Fill & Sign: line Pildyti ir pasirašyti: linija + Fill & Sign: remove saved initials Pildyti ir pasirašyti: pašalinti įrašytus inicialus + Fill & Sign: remove saved signature Pildyti ir pasirašyti: pašalinti įrašytą parašą + Fill & Sign: sign Pildyti ir pasirašyti: pasirašyti + Find text and redact Rasti tekstą ir pašalinti konfidencialią informaciją + Find text and redact… Rasti tekstą ir pašalinti konfidencialią informaciją… + Fit to width scrolling Slinkti pritaikius puslapį prie pločio + Fit visible Pritaikyti matomai sričiai + Fix hairlines Taisyti itin plonas linijas + Fix reading order Taisyti skaitymo tvarką + Flatten comments Suplokštinti komentarus + Flatten form fields Suplokštinti formos laukus + Form fields panel Formos laukų skydelis + Geospatial location Geografinė vieta + Go to page… Pereiti į puslapį… + HTML web page HTML tinklalapis + Header and footer Antraštė ir poraštė + Hide all comments Slėpti visus komentarus + Highlight text Paryškinti tekstą + Image Paveikslėlis + Image (PNG) Paveikslėlis (PNG) + Image field Paveikslėlio laukas + Images Paveikslėliai + Import comments… Importuoti komentarus… + Import data Importuoti duomenis + Import form data… Importuoti formos duomenis… + In multiple files Keliuose failuose + In this file Šiame faile + Index Indeksas + Initials Inicialai + Insert Įterpti + Insert pages Įterpti puslapius + Insert text Įterpti tekstą + JavaScript JavaScript + JavaScript console… JavaScript konsolė… + Join the ArtCraft Discord Prisijungti prie „ArtCraft“ „Discord“ serverio + Light gray Šviesiai pilka + Line Linija + Link Saitas + List box Sąrašo laukas + Make accessible Pritaikyti prieinamumui + Manage embedded index Tvarkyti įdėtąjį indeksą + Markup Žymėjimas + Marquee zoom Didinti pažymėtą sritį + Measure area Matuoti plotą + Measure distance Matuoti atstumą + Measure objects Matuoti objektus + Measure perimeter Matuoti perimetrą + Measurement information Matavimo informacija + Measurement snapping Matavimo pritraukimas + Merge data files into spreadsheet… Sujungti duomenų failus į skaičiuoklę… + Microsoft Word (.docx) „Microsoft Word“ (.docx) + Modify page Keisti puslapį + Multiple files Keli failai + New bookmarks from structure Kurti žymeles pagal struktūrą + Open accessibility report Atverti prieinamumo ataskaitą + Optimize scanned documents Optimizuoti nuskaitytus dokumentus + Optimize scanned documents… Optimizuoti nuskaitytus dokumentus… + Output preview Išvesties peržiūra + Oval Ovalas + PDF/A… PDF/A… + Page grid Puslapių tinklelis + Page labels Puslapių žymos + Page options Puslapio parinktys + Page transitions Puslapių perėjimai + Paste pages Įdėti puslapius + PdfCraft on GitHub „PdfCraft“ „GitHub“ puslapis + PdfCraft web page „PdfCraft“ svetainė + Perimeter Perimetras + Pin folder to Home… Prisegti aplanką prie pradžios lango… + PostScript / EPS PostScript / EPS + Preflight Parengiamoji patikra + Prepare for accessibility Parengti prieinamumui + Prepare for distribution Parengti platinimui + Prepare for distribution… Parengti platinimui… + Presentation (.pptx) Pateiktis (.pptx) + Print production Spaudos paruošimas + Protect a PDF Apsaugoti PDF + Protect with password Apsaugoti slaptažodžiu + Radio button Akutė + Recognize text Atpažinti tekstą + Recognize text in multiple files… Atpažinti tekstą keliuose failuose… + Recognize text… Atpažinti tekstą… + Rectangle Stačiakampis + Redact a PDF Pašalinti konfidencialią informaciją iš PDF + Redact pages Pašalinti konfidencialią informaciją iš puslapių + Redact pages… Pašalinti konfidencialią informaciją iš puslapių… + Redact text and images Pašalinti konfidencialią informaciją iš teksto ir paveikslėlių + Redaction properties… Konfidencialios informacijos šalinimo savybės… + Reduce file size Sumažinti failo dydį + Remove all links Pašalinti visus saitus + Remove background Pašalinti foną + Remove header & footer Pašalinti antraštę ir poraštę + Remove hidden information Pašalinti paslėptą informaciją + Remove hidden information… Pašalinti paslėptą informaciją… + Remove watermark Pašalinti vandenženklį + Replace Pakeisti + Replace text Pakeisti tekstą + Reset panel layout Atkurti skydelių išdėstymą + Review Peržiūrėti + Rich Text Format (.rtf) Rich Text Format (.rtf) + Rotate Pasukti + Rotate pages Pasukti puslapius + Rotate pages clockwise Pasukti puslapius pagal laikrodžio rodyklę + Rotate pages counterclockwise Pasukti puslapius prieš laikrodžio rodyklę + Sanitize document Pašalinti dokumento paslėptą informaciją + Sanitize document… Pašalinti dokumento paslėptą informaciją… + Save as PDF/A Įrašyti kaip PDF/A + Save as PDF/UA Įrašyti kaip PDF/UA + Save as PDF/X Įrašyti kaip PDF/X + Scan & OCR Nuskaityti ir atpažinti tekstą + Security properties Apsaugos savybės + Select files to compare Pasirinkti lyginamus failus + Separate PDFs Atskiri PDF dokumentai + Set measurement scale Nustatyti matavimo mastelį + Set page boxes Nustatyti puslapio sritis + Set page boxes… Nustatyti puslapio sritis… + Set properties Nustatyti savybes + Show cover page in two-page view Rodyti viršelio puslapį dviejų puslapių rodinyje + Signature Parašas + Signatures panel Parašų skydelis + Single file Vienas failas + Snapping Pritraukimas + Split Skaidyti + Spreadsheet (.xlsx) Skaičiuoklė (.xlsx) + Squiggly underline Banguota pabraukta linija + Squiggly underline text Banguotai pabraukti tekstą + Stamp Antspaudas + Stamp palette Antspaudų paletė + Stamps Antspaudai + Standards Standartai + Sticky note Lipnusis raštelis + Strikethrough text Perbraukti tekstą + Summarize Apibendrinti + Summarize comments Apibendrinti komentarus + Switch light / dark theme Perjungti šviesiąją ir tamsiąją temas + Tab order by columns Skirtukų tvarka pagal stulpelius + Tab order by rows Skirtukų tvarka pagal eilutes + Tab order: by columns Skirtukų tvarka: pagal stulpelius + Tab order: by document structure Skirtukų tvarka: pagal dokumento struktūrą + Tab order: by rows Skirtukų tvarka: pagal eilutes + Tags panel Žymų skydelis + Text (plain) Paprastasis tekstas + Text box Teksto laukelis + Text field Teksto laukas + Timestamp Laiko žyma + Translate Versti + Underline text Pabraukti tekstą + Update background… Atnaujinti foną… + Update header & footer… Atnaujinti antraštę ir poraštę… + Update watermark… Atnaujinti vandenženklį… + Use JavaScript Naudoti JavaScript + Use a certificate Naudoti sertifikatą + Use guided actions Naudoti vedamuosius veiksmus + Use print production Naudoti spaudos paruošimo įrankius + Use system setting Naudoti sistemos nuostatą + Validate all signatures Patikrinti visus parašus + Watermark Vandenženklis + Move object Perkelti objektą + Move objects Perkelti objektus + All tools Visi įrankiai + Read Skaityti + E-Sign Pasirašyti + Tools Įrankiai + Inspector Inspektorius + on page {label} puslapyje {label} + A font isn't embedded Šriftas neįdėtas + A {} action isn't allowed Veiksmas {} neleidžiamas + A {} annotation has additional actions Anotacija {} turi papildomų veiksmų + A {} annotation has no appearance stream Anotacija {} neturi išvaizdos srauto + A {} annotation isn't set to print, or is hidden Anotacija {} nenumatyta spausdinti arba yra paslėpta + Accessibility Report Prieinamumo ataskaita + Add the PDFs to combine Pridėkite jungiamus PDF failus + Added the first {n} PDFs in {folder} Pridėti pirmieji {n} PDF failai iš aplanko {folder} + CRL Signing Sertifikatų atšaukimo sąrašo pasirašymas + Certificate Signing Sertifikatų pasirašymas + Change Author Keisti autorių + Change Keywords Keisti raktažodžius + Change Subject Keisti temą + Change Title Keisti pavadinimą + Choose a digital ID. Pasirinkite skaitmeninį ID. + Create links Kurti saitus + Creator Kūrimo programa + Cut and stack needs Two-sided: Off. Print single-sided sheets. Pjovimui ir sudėjimui reikia išjungti dvipusį spausdinimą. Spausdinkite vienpusius lapus. + Data Encipherment Duomenų šifravimas + Decipher Only Tik iššifravimas + Detailed Report Išsami ataskaita + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile Naudojama „DeviceCMYK“, tačiau PDF/A išvesties profilis nėra CMYK + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile Naudojama „DeviceRGB“, tačiau PDF/A išvesties profilis nėra RGB + Digital Signature Skaitmeninis parašas + Document properties (⌘D) Dokumento savybės (⌘D) + Drop PDFs or folders here, or choose them. Nuvilkite PDF failus ar aplankus čia arba pasirinkite juos. + Encipher Only Tik šifravimas + English Anglų + Enter a URL Įveskite URL adresą + Enter a name. Įveskite pavadinimą. + Enter a page number Įveskite puslapio numerį + Enter the field names Įveskite laukų pavadinimus + Every PDF in the folder Visi aplanko PDF failai + Every PDF in the folder and the folders inside it Visi aplanko ir jo poaplankių PDF failai + Field "{}" on page {p} has no description (tooltip) Laukas „{}“ puslapyje {p} neturi aprašo (patarimo) + Filename Failo vardas + Flatten Fill & Sign Suplokštinti pildymo ir pasirašymo turinį + Font (unnamed) on page {p} doesn't map its characters to Unicode Šriftas be pavadinimo puslapyje {p} nesusieja simbolių su „Unicode“ + Font {} on page {p} doesn't map its characters to Unicode Šriftas {} puslapyje {p} nesusieja simbolių su „Unicode“ + Form field on page {p} isn't tagged Formos laukas puslapyje {p} neturi struktūros žymos + H{n} follows an H{d}; headings should not skip levels Po H{d} eina H{n}; antraščių lygiai neturi būti praleidžiami + Inserted text Įterptas tekstas + Key Agreement Raktų suderinimas + Key Encipherment Raktų šifravimas + Margins (in) Paraštės (col.) + New bookmark (⌘B) Nauja žymelė (⌘B) + Next (⌘G) Kitas (⌘G) + No PDFs found in {folder} Aplanke {folder} nerasta PDF failų + No document language is set Dokumento kalba nenustatyta + No page has text: the document appears to be images of pages Nė viename puslapyje nėra teksto: dokumentą, atrodo, sudaro puslapių paveikslėliai + Non-Repudiation Neišsižadėjimas + Only the document's owner can change its security (open it with the permissions password) Dokumento apsaugą gali keisti tik savininkas (atverkite dokumentą naudodami teisių slaptažodį) + Open a PDF (⌘O) Atverti PDF (⌘O) + Page {p} doesn't tab in structure order Puslapyje {p} tabuliavimo tvarka neatitinka struktūros tvarkos + Page {p}: {n} untagged content item Puslapis {p}: turinio elementų be žymų: {n} + Page {p}: {n} untagged content items Puslapis {p}: turinio elementų be žymų: {n} + Page {} has additional actions Puslapis {} turi papildomų veiksmų + Panel layout could not be restored: {error} Nepavyko atkurti skydelių išdėstymo: {error} + Panel move was not applied: {error} Skydelio perkėlimas nepritaikytas: {error} + PdfCraft can't check this signature yet: {}. „PdfCraft“ dar negali patikrinti šio parašo: {}. + Previous (⇧⌘G) Ankstesnis (⇧⌘G) + Print (⌘P) Spausdinti (⌘P) + Producer PDF kūrimo programa + Redo {action} Pakartoti {action} + Rendering… Atvaizduojama… + Report created Ataskaita sukurta + Rev. {rev}: Certified by an unknown signer Versija {rev}: patvirtino nežinomas pasirašiusysis + Rev. {rev}: Certified by {name} Versija {rev}: patvirtino {name} + Rev. {rev}: Signed by an unknown signer Versija {rev}: pasirašė nežinomas pasirašiusysis + Rev. {rev}: Signed by {name} Versija {rev}: pasirašė {name} + Rotate view clockwise (⇧⌘+) Pasukti rodinį pagal laikrodžio rodyklę (⇧⌘+) + Rule Name Taisyklės pavadinimas + Save (M4) Įrašyti (M4) + Save as {level} Įrašyti {level} formatu + Signature is invalid Parašas negalioja + Signature is valid Parašas galioja + Signature validity is unknown Parašo galiojimas nežinomas + Signing time is from the clock on the signer's computer. Pasirašymo laikas nustatytas pagal pasirašiusiojo kompiuterio laikrodį. + Still rendering — this page is unusually complex… Vis dar atvaizduojama — šis puslapis neįprastai sudėtingas… + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. ECDSA parašas nėra kanoninio DER formato (yra papildomų baitų, užpildo arba trūksta ženklo baito); jis vis tiek perskaitytas. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. ECDSA parašas pateiktas kaip neapdorotos r ir s reikšmės, o ne DER seka; jis vis tiek perskaitytas. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. RSA parašo reikšmės ilgis neatitinka rakto modulio ilgio (pradžioje pašalinti arba pridėti nuliniai baitai); ji vis tiek perskaityta. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA parašo „DigestInfo“ neturi parametro NULL (taip įrašo senesnės pasirašymo programos); jis vis tiek perskaitytas. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Subjekto {} sertifikatas neleidžia tiek žemesnio lygio sertifikavimo įstaigų sertifikatų (grandinės ilgio riba), todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Subjekto {} sertifikatas nėra sertifikavimo įstaigos sertifikatas, leidžiantis išduoti sertifikatus, todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Subjekto {} sertifikatas pasirašymo metu negaliojo, todėl juo nepatvirtinamas {} sertifikatas. + The checker found problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų, dėl kurių dokumentas gali būti nevisiškai pritaikytas prieinamumui. + The document has been altered or corrupted since the signature was applied. Po pasirašymo dokumentas buvo pakeistas arba pažeistas. + The document has been altered or corrupted since the timestamp was applied. Pridėjus laiko žymą dokumentas buvo pakeistas arba pažeistas. + The document has been altered since this signature was applied in ways it does not permit ({}). Po pasirašymo dokumentas pakeistas parašo neleidžiamais būdais ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). Pridėjus šią laiko žymą dokumentas pakeistas jos neleidžiamais būdais ({}). + The document has been modified since this signature was applied, but the changes are permitted ({}). Po pasirašymo dokumentas pakeistas, tačiau pakeitimai leidžiami ({}). + The document has been modified since this timestamp was applied, but the changes are permitted ({}). Pridėjus šią laiko žymą dokumentas pakeistas, tačiau pakeitimai leidžiami ({}). + The document has no tags (structure tree) Dokumentas neturi žymų (struktūros medžio) + The document has no title Dokumentas neturi pavadinimo + The document isn't marked as tagged (MarkInfo Marked) Dokumentas nepažymėtas kaip turintis struktūros žymas (MarkInfo Marked) + The document isn't tagged, so none of its content is Dokumentas neturi struktūros žymų, todėl jų neturi ir jo turinys + The document's embedded revocation information shows that the signer's certificate has not been revoked. Dokumente įdėta atšaukimo informacija rodo, kad pasirašiusiojo sertifikatas neatšauktas. + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. Įdėtas laiko žymos žetonas galioja, o ją išdavusi įstaiga patikima; patikimas laikas: {}. + The font {} isn't embedded Šriftas {} neįdėtas + The metadata doesn't identify the file as {} Metaduomenys nenurodo, kad failas atitinka {} + The password is incorrect. Slaptažodis netinkamas. + The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. + The passwords do not match. Slaptažodžiai nesutampa. + The security settings don't allow screen readers to read the text Saugos nuostatos neleidžia ekrano skaitymo programoms skaityti teksto + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. Parašo algoritmas ir „SignerInfo“ santraukos algoritmas nurodo skirtingas maišos funkcijas; pasirašyta naudojant „SignerInfo“ nurodytą funkciją. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. Parašas turi nepatikrintą laiko žymą ({}): ją išdavusios įstaigos nėra patikimų sertifikatų sąraše, todėl naudojamas pasirašiusiojo nurodytas pasirašymo laikas. + The signature contents are not hexadecimal. Parašo turinys nėra šešioliktainis. + The signature could not be checked ({}). Nepavyko patikrinti parašo ({}). + The signature could not be read ({}). Nepavyko perskaityti parašo ({}). + The signature could not be verified ({}). Nepavyko patvirtinti parašo ({}). + The signature has no valid byte range. Parašas neturi tinkamo baitų intervalo. + The signature includes an embedded timestamp. Parašas turi įdėtą laiko žymą. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. Parašas koduotas BER formatu (neapibrėžti ilgiai), o ne DER; jis perskaitytas taikant lankstesnes taisykles. + The signature uses the legacy adbe.x509.rsa_sha1 format. Parašas naudoja pasenusį adbe.x509.rsa_sha1 formatą. + The signature value does not match the signer's certificate: the signature is corrupt. Parašo reikšmė neatitinka pasirašiusiojo sertifikato: parašas pažeistas. + The signature's byte range does not exclude exactly its contents. Parašo baitų intervalas neišskiria tiksliai jo turinio. + The signature's byte range does not match the file. Parašo baitų intervalas neatitinka failo. + The signature's timestamp token could not be verified ({}). Nepavyko patvirtinti parašo laiko žymos žetono ({}). + The signature's timestamp token does not cover the signature value. Parašo laiko žymos žetonas neapima parašo reikšmės. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. Pasirašytas turinys yra sudėtinis (segmentuotas) OCTET STRING, kaip leidžia BER; jis vis tiek perskaitytas. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). Pasirašiusiojo sertifikatas neleidžia skaitmeninių parašų (rakto paskirtyje nenurodyta nei pasirašymo, nei neišsižadėjimo paskirtis). + The signer's certificate has been revoked ({}). Pasirašiusiojo sertifikatas atšauktas ({}). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). Pasirašiusiojo sertifikatas turi kritinių plėtinių, kurių „PdfCraft“ neatpažįsta ({}). + The signer's certificate is not in the signature. Paraše nėra pasirašiusiojo sertifikato. + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Pasirašiusiojo sertifikatas neskirtas dokumentams pasirašyti (išplėstinėje rakto paskirtyje nenurodytas dokumentų pasirašymas). + The signer's certificate was not valid at the time of signing. Pasirašiusiojo sertifikatas pasirašymo metu negaliojo. + The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Pasirašiusiojo tapatybė nežinoma, nes jo sertifikato nėra patikimų sertifikatų sąraše ir nė vienas aukštesnio lygio sertifikatas nėra patikimas. + The signer's identity is valid. Pasirašiusiojo tapatybė patvirtinta. + The timestamp authority's certificate was not valid at the time of timestamping. Laiko žymą išdavusios įstaigos sertifikatas žymos sukūrimo metu negaliojo. + The timestamp contents are not hexadecimal. Laiko žymos turinys nėra šešioliktainis. + The timestamp has no valid byte range. Laiko žyma neturi tinkamo baitų intervalo. + The timestamp token could not be read ({}). Nepavyko perskaityti laiko žymos žetono ({}). + The timestamp token is valid and its authority is trusted. Laiko žymos žetonas galioja, o jį išdavusi įstaiga patikima. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. Laiko žymos žetonas galioja, tačiau ją išdavusios įstaigos nėra patikimų sertifikatų sąraše. + The timestamp's byte range does not exclude exactly its contents. Laiko žymos baitų intervalas neišskiria tiksliai jos turinio. + The timestamp's byte range does not match the file. Laiko žymos baitų intervalas neatitinka failo. + The window shows the file name, not the title (Initial View > Show) Lange rodomas failo vardas, o ne dokumento pavadinimas („Pradinis rodinys“ > „Rodyti:“) + There is no folder to save the digital ID in. Nėra aplanko skaitmeniniam ID įrašyti. + This document has not been modified since this signature was applied. Po pasirašymo dokumentas nepakeistas. + This document has not been modified since this timestamp was applied. Pridėjus šią laiko žymą dokumentas nepakeistas. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Šis parašas naudoja RIPEMD-160 — pasenusį 160 bitų maišos algoritmą, kuris parašams neberekomenduojamas. Parašas vis dar patvirtinamas, tačiau juo nereikėtų pasikliauti. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Šis parašas naudoja silpną SHA-1 maišos algoritmą, kuriam įrodytos kolizijos. Parašas vis dar patvirtinamas, nes SHA-1 naudoja daug prieš daugelį metų pasirašytų dokumentų, tačiau juo nereikėtų pasikliauti. + Undo {action} Atšaukti {action} + Unsigned signature field Nepasirašytas parašo laukas + Unsigned signature field: {name} Nepasirašytas parašo laukas: {name} + Zoom in (⌘+) Priartinti (⌘+) + Zoom in / out (also pinch or ⌘-scroll) Priartinti / nutolinti (taip pat suspaudžiant pirštus arba slenkant su ⌘) + Zoom out (⌘−) Nutolinti (⌘−) + alternate text hides an annotation inside it alternatyvusis tekstas paslepia vidinę anotaciją + alternate text on an element with no page content alternatyvusis tekstas elemente be puslapio turinio + comments komentarai + document security store dokumento saugos saugykla + document structure dokumento struktūra + form fill formos pildymas + its alternate text is inside another element's and will never be read alternatyvusis tekstas yra kito elemento alternatyviojo teksto viduje ir nebus perskaitytas + links saitai + metadata metaduomenys + no alternate text nėra alternatyviojo teksto + no header cells (TH) nėra antraščių langelių (TH) + no summary nėra santraukos + not in a TR nepriklauso TR + not in a Table, THead, TBody or TFoot nepriklauso Table, THead, TBody arba TFoot + not in an L (list) nepriklauso L (sąrašui) + not in an LI (list item) nepriklauso LI (sąrašo elementui) + other changes kiti pakeitimai + page content puslapio turinys + page {n} is out of range (1–{count}) puslapis {n} nepatenka į intervalą (1–{count}) + pages added or removed puslapių pridėjimas arba šalinimas + rows have between {min} and {max} columns eilutėse yra nuo {min} iki {max} stulpelių + the first heading is an H{n}; headings should not skip levels pirmoji antraštė yra H{n}; antraščių lygiai neturi būti praleidžiami + the signed version could not be read nepavyko perskaityti pasirašytos versijos + {n} pages and no bookmarks Puslapių: {n}, žymelių nėra + {page} is not a page number or label {page} nėra puslapio numeris ar žyma + {ty} element on page {p}: {what} Elementas {ty} puslapyje {p}: {what} + {ty} element: {what} Elementas {ty}: {what} + {who}, certificate issued by an unknown issuer. {who}, sertifikatą išdavė nežinomas išdavėjas. + {who}, certificate issued by {issuer}. {who}, sertifikatą išdavė {issuer}. + {} annotation on page {p} isn't tagged Anotacija {} puslapyje {p} neturi struktūros žymos + {} annotations aren't allowed Anotacijos {} neleidžiamos + … and {n} more … ir dar {n} +@plural Delete {n} field|Delete {n} fields Naikinti {n} lauką|Naikinti {n} laukus|Naikinti {n} laukų +@plural {n} byte|{n} bytes {n} baitas|{n} baitai|{n} baitų +@plural {n} image optimized|{n} images optimized Optimizuotas {n} paveikslėlis|Optimizuoti {n} paveikslėliai|Optimizuota {n} paveikslėlių +@plural {n} item discarded|{n} items discarded Pašalintas {n} elementas|Pašalinti {n} elementai|Pašalinta {n} elementų +@plural {n} pixel|{n} pixels {n} pikselis|{n} pikseliai|{n} pikselių +action wizard Start Pradėti +comment menu Edit Redaguoti + A button Mygtukas + A diagonal CONFIDENTIAL watermark on every page. Įstrižas vandenženklis „KONFIDENCIALU“ kiekviename puslapyje. + A document needs at least one page: copy instead Dokumente turi likti bent vienas puslapis: vietoj perkėlimo kopijuokite + A form field has additional actions Formos laukas turi papildomų veiksmų + A link Saitas + A script Scenarijus + A selected field has no widget on any page, so the fields can't be edited together. Pasirinktas laukas neturi valdiklio jokiame puslapyje, todėl laukų negalima redaguoti kartu. + A stream refers to an external file Srautas nurodo išorinį failą + A stream uses LZW compression Srautas suglaudintas naudojant LZW + A summary describes the table's purpose and layout. Add a Summary attribute to each table. Santrauka apibūdina lentelės paskirtį ir išdėstymą. Kiekvienai lentelei pridėkite atributą „Summary“. + About Apie programą + Accepted Priimta + Accessibility permission flag Prieinamumo leidimo požymis + Accessibility permission flag is set Prieinamumo leidimo požymis nustatytas + Add Page Numbers Pridėti puslapių numerius + Add arrow Pridėti rodyklę + Add background Pridėti foną + Add bookmark Pridėti žymelę + Add callout Pridėti išnašą + Add checkmark Pridėti varnelę + Add cloud Pridėti debesies kontūrą + Add connected lines Pridėti sujungtas linijas + Add cross Pridėti kryželį + Add dot Pridėti tašką + Add drawing Pridėti piešinį + Add field Pridėti lauką + Add file attachment Pridėti failo priedą + Add footer Pridėti poraštę + Add header Pridėti antraštę + Add header & footer Pridėti antraštę ir poraštę + Add highlight Pridėti paryškinimą + Add image Pridėti paveikslėlį + Add inserted text Pridėti įterpiamą tekstą + Add line Pridėti liniją + Add link Pridėti saitą + Add oval Pridėti ovalą + Add polygon Pridėti daugiakampį + Add rectangle Pridėti stačiakampį + Add redaction mark Pridėti konfidencialios informacijos šalinimo žymą + Add signature Pridėti parašą + Add squiggly underline Pridėti banguotą pabraukimą + Add stamp Pridėti antspaudą + Add sticky note Pridėti lipnųjį raštelį + Add strikethrough Pridėti perbraukimą + Add text box Pridėti teksto langelį + Add underline Pridėti pabraukimą + Add watermark Pridėti vandenženklį + Add {what} Pridėti {what} + Added the first {n} files in {folder} Pridėti pirmieji {n} failai iš aplanko {folder} + Adding images arrives on the web with file pickers for images Žiniatinklio versijoje paveikslėlius bus galima pridėti įdiegus jų failų pasirinkimą + Algorithm Algoritmas + Align fields Lygiuoti laukus + All annotations are tagged Visos anotacijos turi struktūros žymas + All form fields are tagged Visi formos laukai turi struktūros žymas + All form fields have description Visi formos laukai turi aprašą + All multimedia objects are tagged Visi daugialypės terpės objektai turi struktūros žymas + All page content is tagged Visas puslapio turinys turi struktūros žymas + Allowed Leidžiama + Alternate Text Alternatyvusis tekstas + Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. Elemento, kurio pirminis elementas jau turi alternatyvųjį tekstą, alternatyvusis tekstas neskaitomas. Pašalinkite vidinį alternatyvųjį tekstą. + Alternate text must be associated with some content Alternatyvusis tekstas turi būti susietas su turiniu + Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. Elemento, kuriame yra saitas arba laukas, alternatyvusis tekstas paslepia anotaciją nuo pagalbinių technologijų. Perkelkite alternatyvųjį tekstą. + Alternate text on an element without page content is never reached. Remove it or attach the element to content. Su puslapio turiniu nesusieto elemento alternatyvusis tekstas nepasiekiamas. Pašalinkite jį arba susiekite elementą su turiniu. + Alternate text should not hide annotation Alternatyvusis tekstas neturi paslėpti anotacijos + Alternate text that will never be read Alternatyvusis tekstas, kuris nebus perskaitytas + An action is already running Veiksmas jau vykdomas + An embedded file has no AFRelationship Įdėtasis failas neturi atributo „AFRelationship“ + An image asks to be interpolated Paveikslėliui nurodyta taikyti interpoliaciją + An image has alternates or OPI information Paveikslėlis turi alternatyvių versijų arba OPI informacijos + Animation that flashes can cause seizures. Check scripts and media for flicker. Mirganti animacija gali sukelti priepuolius. Patikrinkite, ar scenarijai ir medija nesukelia mirgėjimo. + Another file dialog is still open. Finish with it first. Kitas failų dialogas dar atvertas. Pirmiausia užbaikite veiksmus jame. + Application Programa + Applied 1 redaction mark. Save to remove the content from the file. Pritaikyta 1 konfidencialios informacijos šalinimo žyma. Įrašykite, kad turinys būtų pašalintas iš failo. + Applied {n} redaction marks. Save to remove the content from the file. Pritaikyta konfidencialios informacijos šalinimo žymų: {n}. Įrašykite, kad turinys būtų pašalintas iš failo. + Appropriate nesting Tinkamas įdėjimas + Arbitrary Mask Pasirinktinė kaukė + Associated with content Susieta su turiniu + At least one signature has problems. Bent vienas parašas turi problemų. + At least one signature is invalid. Bent vienas parašas negalioja. + Attachment Priedas + Basic constraints Pagrindiniai apribojimai + Beveled Nuožulnus + Black Juoda + Black on white Juoda baltame fone + Blue Mėlyna + Bookmarks are present in large documents Dideli dokumentai turi žymeles + Both Abu + Button Properties Mygtuko savybės + Button Style: Mygtuko stilius: + Calculate Skaičiavimas + Callout Išnaša + Cancelled Atšaukta + Center Footer Text Vidurinis poraštės tekstas + Center Header Text Vidurinis antraštės tekstas + Center horizontally Centruoti horizontaliai + Center vertically Centruoti vertikaliai + Certificate Viewer Sertifikatų žiūryklė + Certified by {by} Patvirtino {by} + Change comment properties Keisti komentaro savybes + Change document properties Keisti dokumento savybes + Change field properties Keisti lauko savybes + Change initial view Keisti pradinį rodinį + Change link properties Keisti saito savybes + Change tag properties Keisti žymos savybes + Change {key} Keisti {key} + Changing the document Dokumento keitimas + Character encoding Simbolių koduotė + Character limit: Simbolių skaičiaus riba: + Check Varnelė + Check Box Properties Žymimojo langelio savybės + Check Box Style: Žymimojo langelio stilius: + Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. Patikrinkite, ar žymos skaitomos ta tvarka, kuria skaitytų matantis žmogus. Tvarką taisykite skaitymo tvarkos įrankiu arba žymų skydelyje. + CheckBox Žymimasis langelis + Checked Pažymėta + Choose a folder for the split files Pasirinkite aplanką suskaidytiems failams + Choose a folder on this computer to pin Pasirinkite prisegamą šio kompiuterio aplanką + Click on the page to add a {tool}, or drag to set its size Spustelėkite puslapį, kad pridėtumėte {tool}, arba vilkite, kad nustatytumėte dydį + Click text or an image to edit it Spustelėkite tekstą ar paveikslėlį, kad jį redaguotumėte + Color Images Spalvoti paveikslėliai + Color Spaces Spalvų erdvės + Color contrast Spalvų kontrastas + Comb of characters Simboliai atskiruose langeliuose + Combo Išskleidžiamasis sąrašas + Coming in milestone {m}. Items marked Ready work today. Planuojama {m} etape. Paruoštais pažymėti elementai jau veikia. + Command Komanda + Commenting Komentavimas + Comments and markups Komentarai ir žymėjimai + Comments, links and fields must be in the tag tree to be announced. Tag the annotations. Komentarai, saitai ir laukai turi būti žymų medyje, kad būtų perskaitomi. Pridėkite anotacijų žymas. + Completed Baigta + Configure a Digital ID for Signing Konfigūruoti skaitmeninį ID pasirašymui + Connected lines Sujungtos linijos + Content Streams Turinio srautai + Content copying Turinio kopijavimas + Content copying for accessibility Turinio kopijavimas prieinamumui + Contributors Bendraautoriai + Copied {what} Nukopijuota: {what} + Copy or cut pages first Pirmiausia nukopijuokite arba iškirpkite puslapius + Could not save: {e} Nepavyko įrašyti: {e} + Couldn't add {name}: {e} Nepavyko pridėti {name}: {e} + Couldn't combine files: {e} Nepavyko sujungti failų: {e} + Couldn't create a PDF: {e} Nepavyko sukurti PDF: {e} + Couldn't download {name}: {e} Nepavyko atsisiųsti {name}: {e} + Couldn't extract pages: {e} Nepavyko išskirti puslapių: {e} + Couldn't fit the visible content: {e} Nepavyko pritaikyti matomo turinio: {e} + Couldn't follow linked PDF: {reason} Nepavyko atverti susieto PDF: {reason} + Couldn't import the signature image: {e} Nepavyko importuoti parašo paveikslėlio: {e} + Couldn't open revision {n}: {e} Nepavyko atverti versijos {n}: {e} + Couldn't open the result: {e} Nepavyko atverti rezultato: {e} + Couldn't open the signed version: {e} Nepavyko atverti pasirašytos versijos: {e} + Couldn't open {name}: {e} Nepavyko atverti {name}: {e} + Couldn't optimize the file Nepavyko optimizuoti failo + Couldn't optimize the file: {e} Nepavyko optimizuoti failo: {e} + Couldn't print from the browser ({e}); downloaded {name} to print instead Nepavyko spausdinti iš naršyklės ({e}); vietoj to atsisiųstas spausdintinas failas {name} + Couldn't read {name}: {e} Nepavyko perskaityti {name}: {e} + Couldn't recover {name}: {e} Nepavyko atkurti {name}: {e} + Couldn't reload {name}: {e} Nepavyko iš naujo įkelti {name}: {e} + Couldn't save the image: {e} Nepavyko įrašyti paveikslėlio: {e} + Couldn't save {name}: {e} Nepavyko įrašyti {name}: {e} + Couldn't save: {e} Nepavyko įrašyti: {e} + Couldn't show the file picker. Please try again. Nepavyko parodyti failų pasirinkimo lango. Bandykite dar kartą. + Couldn't split: {e} Nepavyko suskaidyti: {e} + Couldn't summarize comments: {e} Nepavyko apibendrinti komentarų: {e} + Couldn't take the snapshot: {e} Nepavyko padaryti momentinės kopijos: {e} + Couldn't unlock {name}: {e} Nepavyko atrakinti {name}: {e} + Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e} + Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e} + Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e} + Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e} + Create link Kurti saitą + Created 1 link Sukurtas 1 saitas + Created a PDF from the first {n} files PDF sukurtas iš pirmųjų {n} failų + Created a comment summary Sukurta komentarų suvestinė + Created {n} links Sukurta saitų: {n} + Credit Cards Mokėjimo kortelių numeriai + Crop page Apkirpti puslapį + Custom font Pasirinktinis šriftas + Custom stamps arrive on the web with file pickers for images Žiniatinklio versijoje pasirinktiniai antspaudai bus prieinami įdiegus paveikslėlių failų pasirinkimą + Cut {what} Iškirpta: {what} + Dark gray theme Tamsiai pilka tema + Dashed Brūkšninis + Dates Datos + Delete bookmark Naikinti žymelę + Delete content Naikinti turinį + Delete document JavaScript Naikinti dokumento „JavaScript“ scenarijų + Delete field Naikinti lauką + Delete image Naikinti paveikslėlį + Delete link Naikinti saitą + Delete page Naikinti puslapį + Delete selected fields Naikinti pasirinktus laukus + Deleted Pašalinta + Detected 1 form field Aptiktas 1 formos laukas + Detected {n} form fields Aptikta formos laukų: {n} + Device colour is used, but there is no PDF/A output intent Naudojamos įrenginio spalvos, tačiau nėra PDF/A išvesties profilio + DeviceCMYK is used: it needs a CMYK output intent (not added automatically) Naudojama „DeviceCMYK“: reikalingas CMYK išvesties profilis (automatiškai nepridedamas) + Diagnostic details Diagnostikos informacija + Digital Signature Properties Skaitmeninio parašo savybės + Discard all comments, forms and multimedia Pašalinti visus komentarus, formas ir daugialypės terpės turinį + Discard all links, actions and JavaScript Pašalinti visus saitus, veiksmus ir „JavaScript“ scenarijus + Discard all object data (file attachments) Pašalinti visus objektų duomenis (failų priedus) + Discard bookmarks Pašalinti žymeles + Discard document information and metadata Pašalinti dokumento informaciją ir metaduomenis + Discard hidden layer content Pašalinti paslėptų sluoksnių turinį + Discard hidden text Pašalinti paslėptą tekstą + Discard private data of other applications Pašalinti kitų programų privačius duomenis + Distribute fields Tolygiai išdėstyti laukus + Document Overhead Dokumento pagalbiniai duomenys + Document assembly Dokumento komponavimas + Document has appropriate color contrast Dokumento spalvų kontrastas tinkamas + Document is not image-only PDF PDF dokumentas sudarytas ne vien iš paveikslėlių + Document is tagged PDF PDF dokumentas turi struktūros žymas + Document sanitized. Save to finish: saving rewrites the whole file. Dokumentas išvalytas. Užbaikite įrašydami: bus perrašytas visas failas. + Document structure provides a logical reading order Dokumento struktūra užtikrina loginę skaitymo tvarką + Document title is showing in title bar Lango antraštėje rodomas dokumento pavadinimas + Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. Daugiau nei 20 puslapių dokumentams reikia naršymo žymelių. Pridėkite pagrindinių skyrių žymeles. + Double-click Dukart spustelėti + Downloaded {name} Atsisiųsta: {name} + Downloading attachments on the web arrives with M3.10 ({n} bytes ready) Žiniatinklio versijoje priedus bus galima atsisiųsti M3.10 etape (paruošta baitų: {n}) + Drag a rectangle around the area to copy Apibrėžkite kopijuojamą sritį vilkdami stačiakampį + Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes Vilkite stačiakampį puslapyje, kad jį apkirptumėte; dukart spustelėkite puslapį, kad nustatytumėte puslapio ribas + Drag a rectangle to create a link; double-click a link to edit it Vilkite stačiakampį, kad sukurtumėte saitą; dukart spustelėkite saitą, kad jį redaguotumėte + Drag the corners onto the document's edges, then choose Enhance page. Nutempkite kampus prie dokumento kraštų, tada pasirinkite „Pagerinti puslapį“. + Drag to draw the area where the signature should appear. Vilkite, kad apibrėžtumėte sritį, kurioje turi būti parašas. + Dropdown Properties Išskleidžiamojo sąrašo savybės + Duplicate field Dubliuoti lauką + Duplicate page Dubliuoti puslapį + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. Kiekvienam laukui reikia patarimo, nurodančio, ką įvesti. Pridėkite jį skiltyje „Lauko savybės“ > „Bendrosios“ > „Patarimas“. + Edit comment Redaguoti komentarą + Edit content Redaguoti turinį + Edit document JavaScript Redaguoti dokumento „JavaScript“ scenarijų + Edit fields Redaguoti laukus + Edit script of {name} Redaguoti {name} scenarijų + Edit text Redaguoti tekstą + Editing Redaguoti + Email Addresses El. pašto adresai + Embedded Files Įdėtieji failai + Embedded files must be PDF/A files themselves (use PDF/A-3 for others) Įdėtieji failai taip pat turi būti PDF/A formato (kitiems formatams naudokite PDF/A-3) + Encryption Šifravimas + Endpoint Galinis taškas + Erase Trinti + Eraser Trintukas + Even pages Lyginiai puslapiai + Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. Visas puslapio turinys turi turėti struktūros žymas arba būti pažymėtas kaip pagalbinis elementas (puošyba, antraštės, poraštės). Pridėkite trūkstamas turinio žymas. + Execute a menu item Vykdyti meniu komandą + Export certificate Eksportuoti sertifikatą + Exported to {path} Eksportuota į {path} + Extended Graphics States Išplėstinės grafikos būsenos + Field Laukas + Field descriptions Laukų aprašai + Figures alternate text Paveikslų alternatyvusis tekstas + Figures require alternate text Paveikslams reikia alternatyviojo teksto + File attachments Failų priedai + Files are combined in this order. Leave Pages empty to take every page. Failai jungiami šia tvarka. Palikite lauką „Puslapiai“ tuščią, jei norite įtraukti visus puslapius. + Fill in {name} Pildyti {name} + Filling of form fields Formos laukų pildymas + Flatten Suplokštinti + Flip image Apversti paveikslėlį + Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. Šriftų simboliai turi būti susieti su „Unicode“, kad tekstą būtų galima skaityti ir kopijuoti. Pataisykite šriftus arba iš naujo sukurkite PDF. + Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. Formos laukai turi būti žymų medyje (žymos „Form“), kad būtų perskaitomi. Pridėkite formos laukų žymas. + Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulėms ir panašiems netekstiniams elementams reikia alternatyviojo teksto. Pridėkite jį. + Gray Pilka + Grayscale Images Pilkų atspalvių paveikslėliai + Green Žalia + Green on black Žalia juodame fone + H / Space (hold) H / tarpo klavišas (laikyti) + Header cells (TH) tell listeners what each column or row holds. Mark the header cells. Antraščių langeliai (TH) nurodo klausytojui kiekvieno stulpelio ar eilutės paskirtį. Pažymėkite antraščių langelius. + Headers Antraštės + Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. Antraščių lygiai neturėtų būti praleidžiami (pvz., po H1 iškart H3). Pataisykite antraščių žymas. + Headings Antraštės + Hidden layers Paslėpti sluoksniai + Hidden text Paslėptas tekstas + Hide a field Slėpti lauką + Hides annotation Paslepia anotaciją + High Didelis + Home / End Home / End + Horizontally Horizontaliai + Image-only PDF PDF vien iš paveikslėlių + Import {name} Importuoti {name} + Importing data arrives on the web with file pickers for data files Žiniatinklio versijoje duomenis bus galima importuoti įdiegus duomenų failų pasirinkimą + Incorrect password. Try again. Netinkamas slaptažodis. Bandykite dar kartą. + Insert pages from {name} Įterpti puslapius iš {name} + Insert pages from {name} — 1 field renamed Įterpti puslapius iš {name} — pervadintas 1 laukas + Insert pages from {name} — fields renamed Įterpti puslapius iš {name} — laukai pervadinti + Inserted Įterpta + Inset Įspaustas + Intended usage Naudojimo paskirtis + Intersection Sankirta + Invert Invertuoti + Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). Netaisyklingos lentelės trikdo naršymą tarp langelių. Kiekvienoje eilutėje turi būti vienodas langelių skaičius, įskaitant sujungimus. + Issued by Išdavė + Issued to Išduota kam + Issuer Išdavėjas + Key usage Rakto naudojimas + Keywords Raktažodžiai + LI must be a child of L LI turi būti L antrinis elementas + Landscape pages Gulsčiai orientuoti puslapiai + Lbl and LBody Lbl ir LBody + Lbl and LBody must be children of LI Lbl ir LBody turi būti LI antriniai elementai + Left Footer Text Kairysis poraštės tekstas + Left Header Text Kairysis antraštės tekstas + Light blue Šviesiai mėlyna + Links, actions and JavaScripts Saitai, veiksmai ir „JavaScript“ scenarijai + List Sąrašas + List Box Properties Sąrašo langelio savybės + List items Sąrašo elementai + List items belong in a list. Fix the list's tags. Sąrašo elementai turi priklausyti sąrašui. Pataisykite sąrašo žymas. + List labels and bodies belong in a list item. Fix the list's tags. Sąrašo žymenys ir turinys turi priklausyti sąrašo elementui. Pataisykite sąrašo žymas. + Lists Sąrašai + Load document font Įkelti dokumento šriftą + Local cross-PDF links require the desktop app Vietiniams saitams į kitus PDF failus reikalinga darbalaukio programa + Lock comment Užrakinti komentarą + Logical Reading Order Loginė skaitymo tvarka + Low Mažas + Make scans searchable, then make them smaller. Atpažinkite nuskaitytų dokumentų tekstą, kad būtų galima ieškoti, tada sumažinkite failus. + Mark all Pažymėti viską + Mark as Confidential Pažymėti kaip konfidencialų + Mark differences Pažymėti skirtumus + Mark figure as decorative Pažymėti paveikslą kaip dekoratyvinį + Mark for redaction Pažymėti konfidencialios informacijos šalinimui + Mark pages for redaction Pažymėti puslapius konfidencialios informacijos šalinimui + Match field sizes Suvienodinti laukų dydžius + Maximum Didžiausia reikšmė + Measuring tool Matavimo įrankis + Merged 1 file into {path} 1 failas sujungtas į {path} + Merged {n} files into {path} Į {path} sujungta failų: {n} + Merging data files needs the desktop app Duomenų failams sujungti reikalinga darbalaukio programa + Metadata Metaduomenys + Midpoint Vidurio taškas + Minimum Mažiausia reikšmė + Models Modeliai + Mouse Down Paspaudus pelės mygtuką + Mouse Enter Pelės žymekliui įėjus + Mouse Exit Pelės žymekliui išėjus + Mouse Up Atleidus pelės mygtuką + Move bookmark Perkelti žymelę + Move comment Perkelti komentarą + Move image Perkelti paveikslėlį + Move page Perkelti puslapį + Move pages Perkelti puslapius + Named Destinations Pavadintos paskirties vietos + Navigation links Naršymo saitai + Navigation links are not repetitive Naršymo saitai nesikartoja + Nested alternate text Įdėtasis alternatyvusis tekstas + New {tool} comments will look like this one Nauji {tool} komentarai atrodys kaip šis + No PDFs, images or text files found in {folder} Aplanke {folder} nerasta PDF, paveikslėlių ar tekstinių failų + No form fields were detected Formos laukų neaptikta + No inaccessible scripts Nėra neprieinamų scenarijų + No links to remove Nėra šalintinų saitų + No pages match those choices Nė vienas puslapis neatitinka pasirinkimų + No unlinked web addresses were found in the text Tekste nerasta žiniatinklio adresų be saitų + None of those files can be made into a PDF; use PDFs, images or .txt files Nė vieno iš šių failų negalima paversti PDF; naudokite PDF, paveikslėlius arba .txt failus + Note Raštelis + Number every page in the footer. Sunumeruoti visus puslapius poraštėje. + Odd pages Nelyginiai puslapiai + On Blur Praradus įvesties židinį + On Focus Gavus įvesties židinį + On the web, open or drop an image to convert it Žiniatinklio versijoje atverkite arba nuvilkite paveikslėlį, kad jį konvertuotumėte + Open a web link Atverti žiniatinklio saitą + Opened with Atverta naudojant + Opening the browser's print dialog… Atveriamas naršyklės spausdinimo dialogas… + Optimization cancelled Optimizavimas atšauktas + Optimization is already running Optimizavimas jau vykdomas + Optimize Scanned Documents Optimizuoti nuskaitytus dokumentus + Options Parinktys + Orange Oranžinė + Other elements alternate text Kitų elementų alternatyvusis tekstas + Other elements that require alternate text Kiti elementai, kuriems reikia alternatyviojo teksto + Outline Kontūras + PDF producer PDF kūrimo programa + PDF version PDF versija + Page Content Puslapio turinys + Page Range Puslapių intervalas + Page does not require timed responses Puslapis nereikalauja atsakyti per ribotą laiką + Page thumbnails Puslapių miniatiūros + Page will not cause screen flicker Puslapis nesukels ekrano mirgėjimo + Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. Puslapių, kuriuose tekstas yra tik paveikslėlis, negalima perskaityti balsu ar juose ieškoti. Atpažinkite tekstą (OCR), kad pridėtumėte teksto sluoksnį. + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. Puslapiuose su saitais, komentarais ar laukais pereiti tabuliavimo klavišu reikia struktūros tvarka. Pataisa nustato kiekvieno puslapio tabuliavimo tvarką pagal dokumento struktūrą. + Passed Atitinka + Password protection will be applied when you save Apsauga slaptažodžiu bus pritaikyta įrašant + Path Kelias + PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. „PdfCraft“ atvaizduoja be grafikos procesoriaus, todėl gali veikti lėtai. Gali padėti grafikos tvarkyklės atnaujinimas. + People may need more time to respond. Check that nothing has a time limit. Žmonėms gali reikėti daugiau laiko atsakyti. Patikrinkite, ar nėra laiko apribojimų. + Phone Number Telefono numeris + Phone Numbers Telefono numeriai + Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. Paveikslėliams reikia juos apibūdinančio alternatyviojo teksto arba jie turi būti pažymėti kaip dekoratyviniai. Pridėkite kiekvieno paveikslo alternatyvųjį tekstą. + Pink Rožinė + Pinned folders need the desktop app Aplankams prisegti reikalinga darbalaukio programa + Pinned {folder} to Home Aplankas {folder} prisegtas prie pradžios rodinio + Place saved {what} Įdėti įrašytą {what} + Planned for milestone {m} — open to see what it will include Planuojama {m} etape — atverkite ir sužinokite, kas bus įtraukta + Planned · {m} Planuojama · {m} + Polygon Daugiakampis + Polyline Laužtė + Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Nešiojamoji veiksena išjungta: negalima rašyti į {folder} ({error}). Nuostatos saugomos naudotojo aplanke. + Portrait pages Stačiai orientuoti puslapiai + Position Padėtis + PostScript XObjects aren't allowed „PostScript“ XObject objektai neleidžiami + Prepare for Distribution Parengti platinimui + Preparing sheet {done} of {total} for printing… Spausdinimui ruošiamas {done} lapas iš {total}… + Preparing {n} sheets for printing… Spausdinimui ruošiama lapų: {n}… + Primary language Pagrindinė kalba + Printing Spausdinimas + Private data of other applications Kitų programų privatūs duomenys + Public key Viešasis raktas + Purple Violetinė + PushButton Mygtukas + Radio Akutė + Radio Button Properties Akutės savybės + Re-type Password Pakartokite slaptažodį + Red Raudona + Redid {label} Pakartota: {label} + Regularity Taisyklingumas + Rejected Atmesta + Reliable character encoding is provided Užtikrinama patikima simbolių koduotė + Remove hidden information and flatten comments and form fields, so recipients see only the pages. Pašalinkite paslėptą informaciją ir suplokštinkite komentarus bei formos laukus, kad gavėjai matytų tik puslapius. + Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Pašalinkite paslėptą informaciją, metaduomenis, scenarijus bei įdėtąjį turinį ir perrašykite failus. + Remove redaction marks Pašalinti konfidencialios informacijos šalinimo žymas + Remove saved {what} Pašalinti įrašytą {what} + Removed 1 hidden item. Save to remove them from the file. Pašalintas 1 paslėptas elementas. Įrašykite, kad jis būtų pašalintas iš failo. + Removed 1 link Pašalintas 1 saitas + Removed {n} hidden items. Save to remove them from the file. Pašalinta paslėptų elementų: {n}. Įrašykite, kad jie būtų pašalinti iš failo. + Removed {n} links Pašalinta saitų: {n} + Rename bookmark Pervadinti žymelę + Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) Atvaizdavimas: hayro (pradinė versija) · Sąsaja: egui · Piktogramos: Lucide (ISC) · Šriftai: Inter, JetBrains Mono, Dancing Script (OFL) + Repairs Pataisos + Repeated navigation links should be skippable. Check links that appear on every page. Pasikartojančius naršymo saitus turėtų būti galima praleisti. Patikrinkite kiekviename puslapyje rodomus saitus. + Replace page Pakeisti puslapį + Replace pages Pakeisti puslapius + Replaced Pakeista + Replacing images arrives on the web with image file pickers Žiniatinklio versijoje paveikslėlius bus galima pakeisti įdiegus jų failų pasirinkimą + Replacing images on page {p} arrives on the web with image pickers ({i}) Žiniatinklio versijoje paveikslėlius puslapyje {p} bus galima pakeisti įdiegus jų pasirinkimą ({i}) + Report Options Ataskaitos parinktys + Reset a form Išvalyti formą + Resize comment Keisti komentaro dydį + Restrictions Apribojimai + Restrictions lifted Apribojimai panaikinti + Reverted to the last saved version Grąžinta paskutinė įrašyta versija + Right Footer Text Dešinysis poraštės tekstas + Right Header Text Dešinysis antraštės tekstas + Rotate image Pasukti paveikslėlį + Rotate page Pasukti puslapį + Rotate page {label} clockwise Pasukti puslapį {label} pagal laikrodžio rodyklę + Rotate page {label} counterclockwise Pasukti puslapį {label} prieš laikrodžio rodyklę + Rows Eilutės + Run Vykdyti + Run JavaScript Vykdyti „JavaScript“ + Run a JavaScript Vykdyti „JavaScript“ scenarijų + Run form script Vykdyti formos scenarijų + SHA-1 digest SHA-1 santrauka + SHA-256 digest SHA-256 santrauka + Sanitize Išvalyti paslėptą informaciją + Sanitize Documents Išvalyti paslėptą dokumentų informaciją + Save as PDF/A-1b Įrašyti PDF/A-1b formatu + Save as PDF/A-2b Įrašyti PDF/A-2b formatu + Save as PDF/A-3b Įrašyti PDF/A-3b formatu + Save image Įrašyti paveikslėlį + Save or revert changes before selecting another tag. Prieš pasirinkdami kitą žymą įrašykite arba atmeskite pakeitimus. + Saved the print-ready PDF to {path} Spausdinimui parengtas PDF įrašytas į {path} + Saved {name} Įrašyta: {name} + Saved {place}: {before} → {after} (not smaller){detail} Įrašyta {place}: {before} → {after} (nesumažėjo){detail} + Saved {place}: {before} → {after} ({pct}% smaller){detail} Įrašyta {place}: {before} → {after} (sumažėjo {pct}%){detail} + Saved, but reopening failed: {e} Įrašyta, tačiau nepavyko atverti iš naujo: {e} + Screen flicker Ekrano mirgėjimas + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. Ekrano skaitymo programos pasirenka balsą pagal dokumento kalbą. Nustatykite ją skiltyje „Dokumento savybės“ > „Išplėstiniai“ > „Kalba“. + Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. Scenarijais valdomas turinys turi būti pasiekiamas klaviatūra ir pagalbinėmis technologijomis. Patikrinkite dokumento scenarijus. + Scripts Scenarijai + Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Scenarijai vykdomi izoliuotoje aplinkoje be prieigos prie failų ar tinklo. Išjungus „JavaScript“, vis tiek veikia standartinės „Acrobat“ formatavimo, tikrinimo ir skaičiavimo funkcijos. + Security method Apsaugos būdas + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. Saugos nuostatos turi leisti pagalbinėms technologijoms skaityti turinį („Leisti ekrano skaitymo programoms pasiekti tekstą“). Pakeiskite dokumento saugos nuostatas, kad tai būtų leidžiama. + Security will be removed when you save Apsauga bus pašalinta įrašant + Self-signed Savarankiškai pasirašytas + Serial number Serijos numeris + Set alternate text Nustatyti alternatyvųjį tekstą + Set bookmark destination Nustatyti žymelės paskirties vietą + Set document title Nustatyti dokumento pavadinimą + Set status Accepted Nustatyti būseną „Priimta“ + Set status Cancelled Nustatyti būseną „Atšaukta“ + Set status Completed Nustatyti būseną „Baigta“ + Set status None Nustatyti būseną „Nėra“ + Set status Rejected Nustatyti būseną „Atmesta“ + Set status Unmarked Nustatyti būseną „Nežymėta“ + Set tab order Nustatyti tabuliavimo tvarką + Set the image of {name} Nustatyti {name} paveikslėlį + Shadings Spalvų perėjimai + Show a field Rodyti lauką + Sign with a Digital ID Pasirašyti naudojant skaitmeninį ID + Signed and all signatures are valid. Pasirašyta, visi parašai galioja. + Signed and saved to {path} Pasirašyta ir įrašyta į {path} + Signer Pasirašiusysis + Signing time Pasirašymo laikas + Social Security Number Socialinio draudimo numeris + Social Security Numbers Socialinio draudimo numeriai + Solid Vientisas + Squiggly Banguotas + Star Žvaigždė + Strong Stiprus + Structure Info Struktūros informacija + Submit a form Pateikti formą + TH and TD TH ir TD + TH and TD must be children of TR TH ir TD turi būti TR antriniai elementai + TR must be a child of Table, THead, TBody, or TFoot TR turi būti Table, THead, TBody arba TFoot antrinis elementas + Tab order Tabuliavimo tvarka + Tab order is consistent with structure order Tabuliavimo tvarka atitinka struktūros tvarką + Table cells belong in a row. Fix the table's tags. Lentelės langeliai turi priklausyti eilutei. Pataisykite lentelės žymas. + Table rows belong directly in a table or its head, body or foot. Fix the table's tags. Lentelės eilutės turi tiesiogiai priklausyti lentelei, jos antraštės, turinio arba poraštės daliai. Pataisykite lentelės žymas. + Tables Lentelės + Tables must contain the same number of columns in each row and rows in each column Kiekvienoje lentelės eilutėje turi būti vienodas stulpelių skaičius, o kiekviename stulpelyje — vienodas eilučių skaičius + Tables must have a summary Lentelės turi turėti santrauką + Tables should have headers Lentelės turi turėti antraštes + Tagged PDF PDF su struktūros žymomis + Tagged annotations Anotacijos su struktūros žymomis + Tagged content Turinys su struktūros žymomis + Tagged form fields Formos laukai su struktūros žymomis + Tagged multimedia Daugialypės terpės turinys su struktūros žymomis + Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). Žymos suteikia dokumentui loginę struktūrą, kuria vadovaujasi pagalbinės technologijos. Pridėkite dokumento žymas („Automatiškai žymėti PDF“). + Text Field Properties Teksto lauko savybės + Text language is specified Teksto kalba nurodyta + Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. Tekstas turi pakankamai išsiskirti iš fono, kad jį matytų silpnaregiai. Patikrinkite spalvas vizualiai arba kontrasto tikrinimo įrankiu. + Text recognition is already running Teksto atpažinimas jau vykdomas + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Teksto atpažinimas neįdiegtas: trūksta modelių (vykdykite `cargo xtask models` arba nustatykite PDFCRAFT_MODELS). + That didn't work: an internal error stopped it ({m}). Veiksmas nepavyko: jį sustabdė vidinė klaida ({m}). + That didn't work: an internal error stopped it ({m}). Your documents are unchanged. Veiksmas nepavyko: jį sustabdė vidinė klaida ({m}). Dokumentai nepakeisti. + The Keychain's digital IDs couldn't be listed: {e} Nepavyko gauti raktinėje esančių skaitmeninių ID sąrašo: {e} + The Windows store's digital IDs couldn't be listed: {e} Nepavyko gauti „Windows“ saugykloje esančių skaitmeninių ID sąrašo: {e} + The clipboard has no image or text Iškarpinėje nėra paveikslėlio ar teksto + The document changed while you were choosing a file, so nothing was changed. Renkantis failą dokumentas pasikeitė, todėl pakeitimai nepritaikyti. + The document changed while you were editing field properties. Redaguojant lauko savybes dokumentas pasikeitė. + The document has additional actions (/AA) Dokumentas turi papildomų veiksmų (/AA) + The document has document-level JavaScript Dokumentas turi dokumento lygmens „JavaScript“ scenarijų + The document has no XMP metadata Dokumentas neturi XMP metaduomenų + The document is encrypted Dokumentas užšifruotas + The document is no longer open. Dokumentas jau užvertas. + The document was closed before it could be saved. Dokumentas užvertas prieš jį įrašant. + The document's security settings don't allow filling in form fields Dokumento saugos nuostatos neleidžia pildyti formos laukų + The field properties could not be changed. Nepavyko pakeisti lauko savybių. + The field properties dialog is no longer open. Lauko savybių dialogas jau užvertas. + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. Forma prašo pateikti duomenis į {u}; „PdfCraft“ formos duomenų nesiunčia. Įrašykite dokumentą, kad išsaugotumėte įvestas reikšmes. + The form asks viewers to make appearances (NeedAppearances) Forma prašo žiūryklių sukurti laukų išvaizdą (NeedAppearances) + The linked PDF opened, but its named destination was not applied. Susietas PDF atvertas, tačiau pavadinta paskirties vieta nepritaikyta. + The linked PDF opened, but its page destination was out of range. Susietas PDF atvertas, tačiau paskirties puslapis nepatenka į puslapių intervalą. + The operation failed. Veiksmas nepavyko. + The pages were extracted but not deleted, because the document changed while you were choosing a folder. Puslapiai išskirti, bet nepašalinti, nes renkantis aplanką dokumentas pasikeitė. + The passwords don't match. Slaptažodžiai nesutampa. + The selected area has been copied ({w} × {h} pixels) Pasirinkta sritis nukopijuota ({w} × {h} pikselių) + The selected fields could not be found. Select them again. Pasirinktų laukų nerasta. Pasirinkite juos iš naujo. + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standartiniai šriftai negali atvaizduoti simbolio „{c}“ ({code}). Kol kas galima įvesti tik Vakarų Europos simbolius. + The title in the metadata and the document information differ Pavadinimas metaduomenyse skiriasi nuo pavadinimo dokumento informacijoje + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. Lange turėtų būti rodomas prasmingas pavadinimas, o ne failo vardas. Nustatykite pavadinimą ir pasirinkite „Pradinis rodinys“ > „Rodyti:“ > „Dokumento pavadinimas“. + There are no redaction marks to apply Nėra pritaikytinų konfidencialios informacijos šalinimo žymų + This PDF uses the unsupported {s} action. Šis PDF naudoja nepalaikomą veiksmą {s}. + This compatibility level supports only plain ASCII passwords. Šis suderinamumo lygis palaiko tik slaptažodžius iš paprastų ASCII simbolių. + This document can't be checked: it couldn't be read for editing Dokumento negalima patikrinti: nepavyko jo perskaityti redagavimui + This document has no attachments. Šiame dokumente nėra priedų. + This document has no bookmarks. Šiame dokumente nėra žymelių. + This document has no form fields. Šiame dokumente nėra formos laukų. + This document has no layers. Šiame dokumente nėra sluoksnių. + This file is already as small as it can be made ({size}). No copy was saved. Šio failo daugiau sumažinti negalima ({size}). Kopija neįrašyta. + This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. Ši forma taip pat turi XFA duomenų, kurių „PdfCraft“ dar neskaito. Laukus galite pildyti, tačiau „Acrobat“ gali rodyti XFA reikšmes. + Thumbnails Miniatiūros + Timed responses Atsakymai per ribotą laiką + Type Password Įveskite slaptažodį + Type a password. Įveskite slaptažodį. + Typewriter Teksto įvedimas + Unchecked Nepažymėta + Undid {label} Atšaukta: {label} + Unlock comment Atrakinti komentarą + Unlocked {n} of {m} files Atrakinta failų: {n} iš {m} + Update background Atnaujinti foną + Update header & footer Atnaujinti antraštę ir poraštę + Update watermark Atnaujinti vandenženklį + Use {what} Naudoti {what} + Valid Galioja + Valid from Galioja nuo + Valid to Galioja iki + Validate Tikrinimas + Validity ends Galiojimo pabaiga + Validity starts Galiojimo pradžia + Vertically Vertikaliai + Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. Vaizdo, garso ir kitos daugialypės terpės elementai turi turėti žymas, kad būtų perskaitomi. Pridėkite daugialypės terpės anotacijų žymas. + Viewing Peržiūrėti + Weak Silpnas + White on black Balta juodame fone + Wrote 1 file to {dir} 1 failas įrašytas į {dir} + Wrote {n} files to {dir} Į {dir} įrašyta failų: {n} + X Object Forms XObject formos + Yellow Geltona + Yellow on black Geltona juodame fone + You can pin up to {n} folders. Unpin one first. Galite prisegti iki {n} aplankų. Pirmiausia atsekite vieną. + Your name Jūsų vardas + Zip Code Pašto kodas + Zip Code + 4 Pašto kodas su 4 papildomais skaitmenimis + `{command}` {when} `{command}` · {when} + a bookmark cannot be moved inside itself žymelės negalima perkelti į ją pačią + a document must keep at least one page dokumente turi likti bent vienas puslapis + average vidurkis + maximum didžiausia reikšmė + minimum mažiausia reikšmė + no such document tokio dokumento nėra + nothing in the file matches this document faile nėra šį dokumentą atitinkančių duomenų + nothing to redo nėra ką pakartoti + nothing to undo nėra ką atšaukti + pages puslapiai + product (x) sandauga (×) + sum (+) suma (+) + the document has no form fields dokumente nėra formos laukų + the document has no page tree dokumente nėra puslapių medžio + the document has no tags dokumente nėra žymų + the document is protected by a password dokumentas apsaugotas slaptažodžiu + the image is empty paveikslėlis tuščias + the password is incorrect slaptažodis netinkamas + the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them) teksto atpažinimo modeliai neįdiegti (iš naujo įdiekite „PdfCraft“ arba nustatykite PDFCRAFT_MODELS aplanką, kuriame jie laikomi) + the title must not be empty pavadinimas negali būti tuščias + there are no pages to print nėra spausdintinų puslapių + there are no redaction marks to apply nėra pritaikytinų konfidencialios informacijos šalinimo žymų + this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) šis dokumentas pasirašytas: perrašius parašai nebegaliotų (vietoj to įrašykite palaipsniui) + this file isn't XFDF, FDF, XML or text form data šis failas nėra XFDF, FDF, XML arba tekstiniai formos duomenys + {0}: {1} {0}: {1} + {field}: {e} Laukas {field}: {e} + {file} is too large for a stamp (at most {n} MB) Failas {file} per didelis antspaudui (leidžiama iki {n} MB) + {folder} is already pinned to Home Aplankas {folder} jau prisegtas prie pradžios rodinio + {label} failed: {e} Veiksmas {label} nepavyko: {e} + {name} has no action {name} neturi veiksmo + {name} is too large for a word list (at most 1 MB). {name} per didelis žodžių sąrašui (leidžiama iki 1 MB). + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} pateikia formą į {url}; „PdfCraft“ formos duomenų nesiunčia. Įrašykite dokumentą, kad išsaugotumėte įvestas reikšmes. + {name}: choosing images arrives on the web with file pickers for images {name}: žiniatinklio versijoje paveikslėlius bus galima pasirinkti įdiegus jų failų pasirinkimą + {name}: the {other} action isn't supported yet {name}: veiksmas {other} dar nepalaikomas + {who} in this document tried to open an address PdfCraft won't open: {e}. Šiame dokumente {who} bandė atverti adresą, kurio „PdfCraft“ neatveria: {e}. + {who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. Šiame dokumente {who} bandė atverti adresą, kurio „PdfCraft“ neatveria: {e}. Iš dokumentų atveriami tik žiniatinklio (http, https) ir el. pašto (mailto) saitai. +animation effects Off Išjungta +animation effects On Įjungta + View more Rodyti daugiau + View less Rodyti mažiau + Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Tęskite tik jei pasitikite šiuo dokumentu. Adresu gali būti perduota dokumento informacija, pavyzdžiui, tai, ką įvedėte į jo formą. + Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Spausdinkite vienpusiškai. Išlaikykite lapų tvarką, pjaukite ties žymomis, tada sudėkite krūveles iš kairės į dešinę ir iš viršaus į apačią. + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Pasirašykite ant balto popieriaus lapo ir nufotografuokite jį esant ryškiam, tolygiam apšvietimui (naudokite blykstę). Pasirinkite nuotrauką arba nuvilkite ją čia: „PdfCraft“ suras parašą ir pašalins popieriaus foną. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Šio lauko šriftas negali atvaizduoti simbolio „{c}“ ({code}), todėl „PdfCraft“ vietoj jo rodo ?. Reikšmė įrašoma, o kitos PDF žiūryklės pagal ją iš naujo atvaizduoja lauką. + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. Simbolių {chars} dar negalima įrašyti į PDF: pildymo ir pasirašymo tekstui palaikomi tik Vakarų Europos simboliai. Pasirinkite skaitinį formatą, pvz., dd.mm.yyyy. + January sausis + February vasaris + March kovas + April balandis + May gegužė + June birželis + July liepa + August rugpjūtis + September rugsėjis + October spalis + November lapkritis + December gruodis +calendar date January sausio +calendar date February vasario +calendar date March kovo +calendar date April balandžio +calendar date May gegužės +calendar date June birželio +calendar date July liepos +calendar date August rugpjūčio +calendar date September rugsėjo +calendar date October spalio +calendar date November lapkričio +calendar date December gruodžio +calendar weekday Su Sk +calendar weekday Mo Pr +calendar weekday Tu An +calendar weekday We Tr +calendar weekday Th Kt +calendar weekday Fr Pn +calendar weekday Sa Št +signature pad Type Tekstu +scroll speed Slow Lėtas +scroll speed Normal Įprastas +scroll speed Fast Greitas +certificate Subject Sertifikato subjektas +certificate Issued to Išduota kam +certificate Issued by Išdavė +certificate Valid from Galioja nuo +certificate Valid to Galioja iki +certificate Intended usage Naudojimo paskirtis +certificate Version Versija +certificate Serial number Serijos numeris +certificate Issuer Išdavėjas +certificate Validity starts Galiojimo pradžia +certificate Validity ends Galiojimo pabaiga +certificate Public key Viešasis raktas +certificate Basic constraints Pagrindiniai apribojimai +certificate Key usage Rakto naudojimas +certificate Self-signed Savarankiškai pasirašytas +certificate SHA-1 digest SHA-1 santrauka +certificate SHA-256 digest SHA-256 santrauka + Certificate authority Sertifikavimo įstaiga + End entity Galutinis subjektas + Float panel Atkabinti skydelį + Move to group Perkelti į grupę + Panels Skydeliai + Resize panel window Keisti skydelio lango dydį + Resize panels Keisti skydelių dydį + Grab bag Vardų rinkinys + No contributor data was built into this copy. Šioje programos kopijoje nėra duomenų apie bendraautorius. + No model credits were built into this copy. Šioje programos kopijoje nėra naudotų modelių sąrašo. + Reverse the order Apversti tvarką + Show Rodyti + Sort Rūšiuoti + Table Lentelė + The field {name} no longer exists. Lauko {name} nebėra. + {n} contributors · {c} commits Bendraautorių: {n} · pakeitimų įrašų: {c} + Username Naudotojo vardas + Display name Rodomas vardas + Real name Tikras vardas + Name (A–Z) Vardas (A–Z) + Merged PRs Sujungtos pakeitimų užklausos + PRs Pakeitimų užklausos + Commits Pakeitimų įrašai + Lines added Pridėtos eilutės + +LOC +Eil. + Lines deleted Pašalintos eilutės + −LOC −Eil. + Line delta Eilučių pokytis + ΔLOC ΔEil. + Binary assets added Pridėti dvejetainiai ištekliai + +Bin +Dvej. + Binary assets removed Pašalinti dvejetainiai ištekliai + −Bin −Dvej. + First commit Pirmas pakeitimų įrašas + First Pirmas + Last commit Paskutinis pakeitimų įrašas + Last Paskutinis + Company Įmonė + Model Modelis + Version Versija + % of all commits Visų pakeitimų įrašų dalis (%) + Lines +/− Eilutės +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: pakeitimų užklausų: {prs}, pakeitimų įrašų: {commits}, eilučių: +{added} / −{deleted} (Δ {delta}), dvejetainių išteklių: +{bin_added} / −{bin_deleted}, {first} – {last} + of {name} iš {name} + of {pages} iš {pages} + on page {label} puslapyje {label} + open to see what it will include atverkite ir peržiūrėkite turinį + ships in milestone {m} planuojama {m} etape + signature parašas + sorted ascending rūšiuota didėjimo tvarka + sorted descending rūšiuota mažėjimo tvarka + to iki + to {n} iki {n} + watermark vandenženklis + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. Metai: yyyy arba yy · mėnuo: m arba mm, pavadinimas: mmm arba mmmm · diena: d arba dd · savaitės diena: ddd arba dddd. H, h, M, s ir t yra laiko formato raidės, jų negalima naudoti atskirai: prieš raidę įrašykite {escape}, kad ji būtų rodoma kaip tekstas. + {c} of {n}{more} {c} iš {n}{more} + {dpi} pixels/inch {dpi} pikselių colyje + {kind} Properties {kind} savybės + {kind} field {kind} laukas + {kind} · page {p} {kind} · puslapis {p} + {kind} — click to show it on the page {kind} — spustelėkite, kad būtų parodyta puslapyje + {k} of {n} {k} iš {n} + {label} (1 issue) {label} (1 problema) + {label} ({n} issues) {label} (problemų: {n}) + {month} {day}, {y} {y} m. {month} {day} d. + {month} {y} {y} m. {month} + {msg} to {dir} {msg} į {dir} + {name} (default) {name} (numatytasis) + {name} can't be combined: {why} {name} nepavyko sujungti: {why} + {name} is read-only {name} skirtas tik skaityti + {name} — unsaved changes {name} — neįrašyti pakeitimai + {n} days ago Prieš {n} d. + {n} differences found Rasta skirtumų: {n} + {n} fields selected Pažymėta laukų: {n} + {n} files Failų: {n} + {n} files done Apdorota failų: {n} + {n} files need attention Dėmesio reikia failams: {n} + {n} h ago Prieš {n} val. + {n} instances Egzempliorių: {n} + {n} issues Problemų: {n} + {n} marks Žymų: {n} + {n} match(es) marked for redaction. Šalinimui pažymėta atitikmenų: {n}. + {n} measurements use an unsupported format and are left out. Neįtraukti {n} matavimai, nes jų formatas nepalaikomas. + {n} min ago Prieš {n} min. + {n} pages Puslapių: {n} + {n} pages selected. Pažymėta puslapių: {n}. + {n} selected Pažymėta: {n} + {n} tags Žymų: {n} + {n} word(s) or phrase(s) Žodžių ar frazių: {n} + {n} {kind} Kiekis: {n} · {kind} + {rule} - {status} Taisyklė {rule} – būsena {status} + {s} action Veiksmų: {s} + {verb} Background {verb} foną + {verb} Header and Footer {verb} antraštę ir poraštę + {verb} Watermark {verb} vandenženklį + {verb} as "{name}" {verb} kaip „{name}“ + {who} in this document wants to open this address in your browser: Šiame dokumente {who} prašo atverti šį adresą naršyklėje: + {who} in this document wants to open your email app with this address: Šiame dokumente {who} prašo atverti el. pašto programą šiuo adresu: + {words} on {pages} {words} puslapiuose {pages} + — (protection is applied when you save) — (apsauga pritaikoma įrašant) + “{name}” has security restrictions. Enter its permissions password to lift them. „{name}“ taikomi saugos apribojimai. Norėdami juos panaikinti, įveskite teisių slaptažodį. + “{name}” is protected. Enter a password to open it. „{name}“ apsaugotas. Norėdami jį atverti, įveskite slaptažodį. +@plural {n} page|{n} pages {n} puslapis|{n} puslapiai|{n} puslapių +@plural {n} file|{n} files {n} failas|{n} failai|{n} failų +@plural {n} field|{n} fields {n} laukas|{n} laukai|{n} laukų +@plural {n} comment|{n} comments {n} komentaras|{n} komentarai|{n} komentarų +@plural {n} result|{n} results {n} rezultatas|{n} rezultatai|{n} rezultatų +@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} lauko pavadinimas sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadintas: {list}|{n} laukų pavadinimai sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinti: {list}|{n} laukų pavadinimų sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinta: {list} +@plural {n} object selected|{n} objects selected Pažymėtas {n} objektas|Pažymėti {n} objektai|Pažymėta {n} objektų +@plural Downloaded {n} PDF|Downloaded {n} PDFs Atsisiųstas {n} PDF failas|Atsisiųsti {n} PDF failai|Atsisiųsta {n} PDF failų +key Ctrl Ctrl +key Shift Shift +key Delete Delete +key Esc Esc +key Home Home +key End End +key Space Tarpas diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index ba6be0f838..8d10072bde 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -58,6 +58,17 @@ fn plural_none(_: u64) -> usize { 0 } +/// Lithuanian: one (ending in 1 except 11), few (ending in 2–9 except 12–19), other otherwise. +fn plural_lithuanian(n: u64) -> usize { + match (n % 10, n % 100) { + (1, 11) => 2, + (1, _) => 0, + (2..=9, 12..=19) => 2, + (2..=9, _) => 1, + _ => 2, + } +} + /// Czech: 1 → one, 2–4 → few, everything else (0, 5+) → other. fn plural_cs(n: u64) -> usize { match n { @@ -136,7 +147,7 @@ fn plural_sl(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 21] = [ +pub static LANGUAGES: [LangInfo; 22] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`). @@ -194,6 +205,8 @@ pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "el", name: "Ελληνικά", source: include_str!("el.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, // Turkish; every `tr-*` locale (`tr-TR`, `tr_TR.UTF-8`) resolves here. LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, + // Lithuanian; every `lt-*` locale (`lt-LT`, `lt_LT.UTF-8`) resolves here. Three plural forms (one, few, other). + LangInfo { code: "lt", name: "Lietuvių", source: include_str!("lt.tsv"), plural: plural_lithuanian, rtl: false, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -851,6 +864,11 @@ mod tests { } fn ui_literals() -> std::collections::BTreeSet { + scan_ui_literals(false) + } + + // Lithuanian also checks direct lookup calls and multiline literal arguments. + fn scan_ui_literals(expanded: bool) -> std::collections::BTreeSet { let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut literals = std::collections::BTreeSet::new(); while let Some(dir) = stack.pop() { @@ -862,8 +880,19 @@ mod tests { } } else if path.extension().is_some_and(|ext| ext == "rs") { let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); + let source = if expanded { source.replace("crate::i18n::t(", "tl!(") } else { source }; let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { + while let Some((_, after)) = rest.split_once(if expanded { "tl!(" } else { "tl!(\"" }) { + let after = if expanded { + let after = after.trim_start(); + let Some(after) = after.strip_prefix('"') else { + rest = after; + continue; + }; + after + } else { + after + }; let mut escaped = false; let end = after .char_indices() @@ -876,7 +905,10 @@ mod tests { }) .expect("closed tl! literal"); let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { + let argument_end = tail.strip_prefix('"').expect("closing quote").trim_start(); + let literal_call = + argument_end.starts_with(')') || argument_end.strip_prefix(',').is_some_and(|rest| rest.trim_start().starts_with(')')); + if tail.starts_with("\")") || (expanded && literal_call) { let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); literals.insert(label); } @@ -2005,6 +2037,210 @@ mod tests { set_current(Lang::EN); } + #[test] + fn lithuanian_is_registered_and_persists() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(lt.name(), "Lietuvių"); + assert_eq!(normalize_pref("LT"), Some("lt")); + for tag in ["lt", "lt-LT", "lt_LT.UTF-8", "LT-lt"] { + assert_eq!(lang_from_tag(tag), Some(lt), "{tag}"); + } + assert_eq!(tr(lt, "File"), "Failas"); + assert_eq!(tr(lt, "Edit"), "Redaguoti"); + assert_eq!(tr(lt, "Save"), "Įrašyti"); + assert_eq!(tr(lt, "Interface language"), "Sąsajos kalba"); + assert_eq!(trn(lt, 1, "{n} page", "{n} pages"), "1 puslapis"); + assert_eq!(trn(lt, 2, "{n} page", "{n} pages"), "2 puslapiai"); + assert_eq!(trn(lt, 10, "{n} page", "{n} pages"), "10 puslapių"); + assert_eq!(trn(lt, 11, "{n} page", "{n} pages"), "11 puslapių"); + assert_eq!(trn(lt, 21, "{n} page", "{n} pages"), "21 puslapis"); + assert_eq!(trn(lt, 22, "{n} page", "{n} pages"), "22 puslapiai"); + assert_eq!(trn(lt, 12, "{n} object selected", "{n} objects selected"), "Pažymėta 12 objektų"); + assert_eq!(trn(lt, 22, "{n} object selected", "{n} objects selected"), "Pažymėti 22 objektai"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "LT").unwrap(); + assert_eq!(app.language, "lt"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "lt"); + assert!(restored.set_option("language", "xx").is_err()); + } + + #[test] + fn lithuanian_covers_commands_and_catalogue() { + let lt = Lang::from_code("lt").expect("lt registered"); + let mut missing = std::collections::BTreeSet::new(); + for command in pdfcraft_engine::commands::COMMANDS { + if !has(lt, command.label) { + missing.insert(command.label); + } + if let Some(menu) = command.menu + && !has(lt, menu) + { + missing.insert(menu); + } + } + for group in pdfcraft_engine::catalog::TOOL_GROUPS { + if !has(lt, group.label) { + missing.insert(group.label); + } + for section in group.sections { + if !has(lt, section.title) { + missing.insert(section.title); + } + for item in section.items { + if !has(lt, item.label) { + missing.insert(item.label); + } + } + } + } + assert!(missing.is_empty(), "Lithuanian misses commands/tools: {missing:#?}"); + } + + #[test] + fn lithuanian_covers_ui_literals() { + let lt = Lang::from_code("lt").expect("lt registered"); + let literals = scan_ui_literals(true); + assert!(literals.contains("Float panel"), "direct lookup literals are included"); + assert!(literals.contains("@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"), "multiline literal arguments are included"); + let missing: Vec<_> = literals.iter().filter(|label| !has(lt, label)).collect(); + assert!(missing.is_empty(), "Untranslated Lithuanian UI labels: {missing:#?}"); + } + + #[test] + fn lithuanian_covers_contextual_calendar_certificate_and_preference_labels() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(tr_ctx(lt, "signature pad", "Type"), "Tekstu"); + for label in ["All tools", "Read", "E-Sign", "Tools", "View more", "View less"] { + assert!(has(lt, label), "shell label remains untranslated: {label}"); + } + assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(lt, "scroll speed", label)), ["Lėtas", "Įprastas", "Greitas"]); + let months = [ + ("January", "sausis", "sausio"), + ("February", "vasaris", "vasario"), + ("March", "kovas", "kovo"), + ("April", "balandis", "balandžio"), + ("May", "gegužė", "gegužės"), + ("June", "birželis", "birželio"), + ("July", "liepa", "liepos"), + ("August", "rugpjūtis", "rugpjūčio"), + ("September", "rugsėjis", "rugsėjo"), + ("October", "spalis", "spalio"), + ("November", "lapkritis", "lapkričio"), + ("December", "gruodis", "gruodžio"), + ]; + for (source, standalone, in_date) in months { + assert_eq!(tr(lt, source), standalone); + assert_eq!(tr_ctx(lt, "calendar date", source), in_date); + } + assert_eq!( + ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(lt, "calendar weekday", day)), + ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] + ); + assert_eq!( + fmt(tr(lt, "{month} {day}, {y}"), &[("month", tr_ctx(lt, "calendar date", "March")), ("day", "9"), ("y", "2026")]), + "2026 m. kovo 9 d." + ); + assert_eq!(tr_ctx(lt, "certificate", "Subject"), "Sertifikato subjektas"); + for field in [ + "Issued to", + "Issued by", + "Valid from", + "Valid to", + "Intended usage", + "Serial number", + "Issuer", + "Validity starts", + "Validity ends", + "Public key", + "Basic constraints", + "Key usage", + "Self-signed", + "SHA-1 digest", + "SHA-256 digest", + ] { + assert_ne!(tr_ctx(lt, "certificate", field), field, "certificate field remains untranslated: {field}"); + } + } + + #[test] + fn lithuanian_dialog_wording_preserves_resolution_and_selection_counts() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(tr(lt, "for images above"), "kai raiška viršija"); + assert_eq!( + fmt(tr(lt, "Checking Options ({on} of 32 in all categories)"), &[("on", "31")]), + "Tikrinimo parinktys (visose kategorijose: 31 iš 32)" + ); + } + + #[test] + fn lithuanian_covers_bundled_catalog_keys_including_dynamic_dialog_labels() { + // Compare English keys, not other languages' translations. Literal scans alone + // miss labels passed through helpers, enum methods and conditional expressions. + let lt = Lang::from_code("lt").expect("lt registered"); + let mut missing = std::collections::BTreeSet::new(); + for reference in LANGUAGES.iter().filter(|language| language.code != "lt") { + let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); + assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); + for entry in entries { + let translated = match entry.context.as_str() { + "" => lt.catalog().plain(&entry.source), + "@id" => lt.catalog().id(&entry.source), + "@plural" => entry.source.split_once('|').and_then(|(one, other)| lt.catalog().plural(one, other, 0)), + context => lt.catalog().contextual(context, &entry.source), + }; + if translated.is_none() { + missing.insert((entry.context, entry.source)); + } + } + } + assert!(missing.is_empty(), "Lithuanian misses bundled catalog keys: {missing:#?}"); + } + + #[test] + fn lithuanian_covers_credits_labels() { + let lt = Lang::from_code("lt").expect("lt registered"); + let labels = crate::credits::NameMode::ALL + .iter() + .map(|mode| mode.label()) + .chain(crate::credits::SortKey::ALL.iter().flat_map(|key| { + let (menu, heading) = key.label(); + [menu, heading] + })) + .chain(crate::credits::MODEL_COLUMNS.iter().copied()); + let missing: Vec<_> = labels.filter(|label| !has(lt, label)).collect(); + assert!(missing.is_empty(), "Lithuanian misses credits labels: {missing:#?}"); + } + + #[test] + fn lithuanian_wording_fixes_keep_context_and_meaning() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(tr(lt, "of {name}"), "iš {name}"); + assert_eq!(tr(lt, "Active"), "Aktyvi", "the adjective describes a theme (tema)"); + for n in [1, 2, 12] { + let renamed = trn( + lt, + n, + "{n} field name collides with earlier files and will be renamed: {list}", + "{n} field names collide with earlier files and will be renamed: {list}", + ); + assert!(renamed.contains("ankstesniuose failuose esančių laukų pavadinimais")); + assert!(renamed.contains("bus pervadint")); + assert!(renamed.contains("{list}")); + } + assert_eq!(tr(lt, "Two-Up (Facing)"), "Du atgręžti puslapiai"); + assert_eq!(tr(lt, "Windows store · "), "„Windows“ sertifikatų saugykla · "); + assert_eq!( + tr(lt, "Adds invisible text over each word; the page image is not changed"), + "Virš kiekvieno žodžio prideda nematomą tekstą; puslapio vaizdas nesikeičia" + ); + assert!( + tr(lt, "The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved.") + .contains("visose PDF žiūryklėse") + ); + } + #[test] fn slovene_is_registered_and_persists() { let sl = Lang::from_code("sl").expect("sl registered"); diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 313edc9795..4233089ec6 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -274,6 +274,41 @@ fn theme_commands_and_options_report_preference_and_effective_colours() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["theme_preference"], "System"); } +#[test] +fn lithuanian_password_dialog_translates_dynamic_labels_and_validation() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" })); + ok(&mut h, &c, "ui.set", json!({ "key": "dialog", "value": "protect" })); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Peržiūrėti"); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Redaguoti").click(); + h.run_steps(2); + assert!(!h.state().protect_draft.viewing); + h.get_by_role_and_label(egui::accesskit::Role::PasswordInput, "Įveskite slaptažodį"); + h.get_by_role_and_label(egui::accesskit::Role::PasswordInput, "Pakartokite slaptažodį"); + h.state_mut().protect_draft.password = "abc".into(); + h.state_mut().protect_draft.confirm = "xyz".into(); + h.run_steps(2); + h.get_by_label("Slaptažodžiai nesutampa."); +} + +#[test] +fn lithuanian_inspector_title_updates_through_the_control_channel() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "comments" })); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector"); + let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); + ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" })); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspektorius"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none()); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], documents); + ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" })); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector"); +} + /// Switching the interface language changes labels only: documents, their dirty state and the /// command ids agents drive stay exactly the same. #[test] @@ -285,7 +320,7 @@ fn language_switch_preserves_document_and_command_ids() { let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); assert_eq!(documents[0]["dirty"], true); let commands = ok(&mut h, &c, "ui.commands", json!({})); - for code in ["ja", "zh-hans", "fr", "de", "uk", "en"] { + for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] { ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code })); h.run_steps(2); let state = ok(&mut h, &c, "ui.state", json!({})); @@ -304,7 +339,8 @@ fn language_switch_preserves_document_and_command_ids() { h.run_steps(2); } let error = call(&mut h, &c, "ui.set", json!({ "key": "language", "value": "xx" })).unwrap_err(); - assert!(error.contains("auto, en, ja"), "{error}"); + let codes = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect::>().join(", "); + assert_eq!(error, format!("language must be one of {codes}")); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "en"); } diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index 862e888a1e..96b38b0791 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -263,6 +263,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { ("it", include_str!("../src/i18n/it.tsv")), ("tr", include_str!("../src/i18n/tr.tsv")), ("sl", include_str!("../src/i18n/sl.tsv")), + ("lt", include_str!("../src/i18n/lt.tsv")), ] { let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇәғқңөұүһӘҒҚҢӨҰҮҺ"); for line in catalog.lines().filter(|l| !l.starts_with('#')) { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 0882cc54e5..34e3300c25 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -146,6 +146,34 @@ fn date_fields_offer_a_calendar() { assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format"); } +#[test] +fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.state_mut().set_option("language", "lt").unwrap(); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [50.0, 40.0, 200.0, 60.0], + kind: pdfcraft_engine::NewField::Date, + name: Some("due".into()), + }); + h.run_steps(4); + click_field(&mut h, "due", 0); + let (y, m, _) = h.state().session.today(); + let months = ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"]; + h.get_by_label(&format!("{y} m. {}", months[(m - 1) as usize])); + for day in ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] { + h.get_by_label(day); + } + h.get_by_label("›").click(); + h.run_steps(2); + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + h.get_by_label(&format!("{ny} m. {}", months[(nm - 1) as usize])); + h.get_by_label("15").click(); + h.run_steps(4); + assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "the PDF field keeps its own date format"); +} + /// The value of `name` in a saved PDF, read back by opening it in a fresh app. fn saved_value(path: &std::path::Path, name: &str) -> Vec { let mut app = PdfCraftApp::new(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0ea3daa7ec..ccd5f88b90 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language status, measured from the catalogs) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (list all registered interface languages; preserve the original coverage snapshot) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -60,13 +60,20 @@ none has translated help. | Hungarian | `hu` | 2,126 (99.2%) | partial | yes | | Ukrainian | `uk` | 2,142 (100%) | partial | yes | | Italian | `it` | 2,130 (99.4%) | partial | yes | +| Kazakh | `kk` | 2,327 entries | partial | no | +| Polish | `pl` | 2,287 entries | partial | yes | +| Slovenian | `sl` | 2,324 entries | partial | no | +| Greek | `el` | 2,341 entries | partial | no | +| Turkish | `tr` | 2,313 entries | partial | yes | +| Lithuanian | `lt` | 2,578 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -**8 other languages** ship (15 catalogs plus English, 16 interface languages in Preferences). -Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Polish, Swedish, Turkish, -Hebrew. +**14 other languages** ship (21 catalogs plus English, 22 interface languages in Preferences). +The entry counts without percentages were measured on 2026-10-12; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Added current counts for Kazakh, Polish, Slovenian, Greek, Turkish and Lithuanian; retained existing language assessments and the original percentage snapshot | | 2026-10-10 | major | Created. Catalog counts measured from `crates/ui-egui/src/i18n/*.tsv` against the 2,143-key union; Acrobat's localizations from its bundle's `.lproj` and Sequences folder names | diff --git a/docs/localization.md b/docs/localization.md index 745ab53814..5f27bdb78e 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,7 +1,6 @@ # Interface language -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية** or **Türkçe**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية** or **Slovenščina**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. +Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe** or **Lietuvių**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. @@ -14,7 +13,7 @@ The control channel exposes the setting through `ui.set`: {"method":"ui.set","params":{"key":"language","value":"ja"}} ``` -The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`, `ar`, `tr`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`, `ar` or `tr`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. +The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr` or `lt`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each); Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). @@ -23,9 +22,11 @@ Whole sentences that the layers below word — what the accessibility checker fo The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. +The Lithuanian catalog has 2,578 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. + Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Turkish need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Turkish and Lithuanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. diff --git a/docs/roadmap.md b/docs/roadmap.md index ce19054869..0f8dc06268 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -97,7 +97,7 @@ total matches the parity estimate. | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | From 87b448b664e1b3c960b479cc0f2c2e7a82c7ac71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Bartalos?= Date: Mon, 12 Oct 2026 14:57:37 +0200 Subject: [PATCH 03/70] M1: write a valid xref when an incremental save has no objects (#1339) (#1424) An unchanged classic-table file used to append xref immediately followed by trailer, which qpdf treats as a damaged file. A zero-length subsection keeps trailer-only updates without rewriting any object entry. Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/cos/src/writer.rs | 9 +++ crates/cos/tests/writer.rs | 110 +++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 +- 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8c3c70dab2..44a26604d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; 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 (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -140,6 +141,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. - **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged. diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 26dcfc46fc..34254bf374 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -431,6 +431,15 @@ fn write_xref_table(out: &mut Vec, offsets: &BTreeMap if include_zero { all.insert(0, (0, (0, 65535, false))); } + // A section is one or more subsections. `xref` followed immediately by `trailer` is not a + // table (qpdf: "xref syntax invalid") and is what an incremental save of a classic-table + // file used to append when nothing was edited. A zero-length subsection names no objects, + // so the free list and every in-use entry stay as they were, while a trailer-only change + // (a missing `/ID`, or a caller edit of the trailer) is still written. + if all.is_empty() { + let _ = writeln!(out, "0 0"); + return; + } let mut i = 0; while i < all.len() { let start = all[i].0; diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index 448cf35774..b7514cc053 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -135,6 +135,116 @@ fn full_saves_are_allocated_at_their_exact_size() { } } +/// A one-page classic-table file with no `/ID`, the case that used to append `xref` and then +/// `trailer` with no subsection (#1339). +fn classic_table() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +fn qpdf_check(bytes: &[u8]) -> Option { + let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else { + return None; + }; + assert!(version.status.success()); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let _ = std::fs::remove_file(&path); + Some(check) +} + +fn page(doc: &Document) -> Dict { + let root = doc.resolve(doc.trailer().get(b"Root").unwrap()); + let pages = doc.resolve(root.as_dict().unwrap().get(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + doc.resolve(kids.first().unwrap()).as_dict().cloned().unwrap() +} + +fn assert_qpdf_clean(bytes: &[u8]) { + let Some(check) = qpdf_check(bytes) else { + eprintln!("qpdf not installed: skipped"); + return; + }; + let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr)); + assert!(check.status.success() && !report.contains("WARNING"), "{report}"); +} + +#[test] +fn unchanged_incremental_save_of_a_classic_table_is_a_valid_xref() { + let input = classic_table(); + let doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(!doc.is_modified()); + assert!(doc.repair_log().is_empty()); + assert_eq!(doc.revisions().len(), 1); + assert!(!doc.revisions()[0].is_stream); + + let unchanged = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(unchanged.starts_with(&input)); + let appended = &unchanged[input.len()..]; + assert!(appended.windows(8).any(|w| w == b"xref\n0 0"), "a section with no entries still needs a subsection"); + assert!(!appended.windows(13).any(|w| w == b"xref\ntrailer")); + + let back = Document::open(Arc::new(unchanged.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.revisions().len(), 2); + assert_eq!(page(&back).name(b"Type"), Some(b"Page".as_slice())); + assert!(back.trailer().get(b"ID").is_some(), "a file without an identifier still receives one"); + assert_eq!(hayro_pages(&unchanged), 1); + assert_qpdf_clean(&unchanged); + + let mut edited = doc.clone(); + edited.update_dict(ObjRef::new(3, 0), |d| d.set(b"Rotate".to_vec(), Object::Int(90))).unwrap(); + let edited = write_incremental(&edited, &SaveOptions::default()).unwrap(); + let edited_back = Document::open(Arc::new(edited.clone())).unwrap(); + assert!(edited_back.repair_log().is_empty(), "{:?}", edited_back.repair_log()); + assert_eq!(edited_back.get(ObjRef::new(3, 0)).as_dict().unwrap().int(b"Rotate"), Some(90)); + assert_qpdf_clean(&edited); + + let stamped = write_incremental(&doc, &SaveOptions { mod_date: Some("D:20260101000000Z".into()), ..SaveOptions::default() }).unwrap(); + let stamped_back = Document::open(Arc::new(stamped.clone())).unwrap(); + assert!(stamped_back.repair_log().is_empty(), "{:?}", stamped_back.repair_log()); + let info = stamped_back.resolve(stamped_back.trailer().get(b"Info").unwrap()); + assert_eq!( + info.as_dict().unwrap().get(b"ModDate").and_then(|d| d.as_string()).map(|s| s.bytes.as_slice()), + Some(b"D:20260101000000Z".as_slice()) + ); + assert_qpdf_clean(&stamped); +} + +#[test] +fn incremental_save_keeps_a_trailer_only_edit() { + let input = classic_table(); + let mut doc = Document::open(Arc::new(input)).unwrap(); + assert!(!doc.is_modified()); + doc.trailer_mut().set(b"Custom".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"keep me".to_vec()))); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.trailer().get(b"Custom").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"keep me".as_slice())); + assert_eq!(hayro_pages(&saved), 1); + assert_qpdf_clean(&saved); +} + #[test] fn qpdf_accepts_object_stream_output() { let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else { diff --git a/docs/roadmap.md b/docs/roadmap.md index 0f8dc06268..9c6d39c9b1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; 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 (M7: preserve Arabic ActualText typing order; earlier: M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; 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 (M7: local GoToR links and confined automation candidate; 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:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -78,7 +80,7 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | @@ -143,6 +145,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. | | 2026-10-12 | minor | M12: bounded text-only auto-tag preview through the engine and automation; actual PDF structure writing and visual review remain open. Estimates unchanged. | | 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. | | 2026-10-12 | minor | M3: case-insensitive Find matches normal, final and capital Greek sigma; exact search, word boundaries, glyph ranges and match limits have regression coverage. The tab-menu test uses Finder on macOS. Estimates unchanged. | From 07df4de63d88f0a7d97001f2b6fb0d4cfdf19f3b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:08:29 -0400 Subject: [PATCH 04/70] Release workflow: pass the Azure signing secrets so Windows builds are signed (#1194) Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f227be879..c6ee0e1c31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From 7163ba3dee8becfff6871c9a65d0d8314314742b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:11:04 -0400 Subject: [PATCH 05/70] Release 0.7.1: rebuild with the fixed shared release workflow The previous release build failed in craft-common-actions, not in this repo: riscv64 packaging got --formats deb,rpm,tar (fixed in craft-common-actions#3), Linux jobs lacked the ALSA headers (craft-common-actions#5), and Windows signing needed the AZURE_* secrets listed in the caller. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 68 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a1b6b1667..05c2f7340b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3561,7 +3561,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.7.0" +version = "0.7.1" dependencies = [ "eframe", "egui", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3593,7 +3593,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3604,7 +3604,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3615,7 +3615,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.7.0" +version = "0.7.1" dependencies = [ "base64 0.22.1", "flate2", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -3650,18 +3650,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.7.0" +version = "0.7.1" [[package]] name = "pdfcraft-content" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -3673,7 +3673,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.7.0" +version = "0.7.1" dependencies = [ "encoding_rs", "hayro-syntax", @@ -3688,7 +3688,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aes", "md-5", @@ -3700,7 +3700,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -3715,7 +3715,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.7.0" +version = "0.7.1" dependencies = [ "chrono", "icu_properties", @@ -3752,14 +3752,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.7.0" +version = "0.7.1" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.7.0" +version = "0.7.1" dependencies = [ "flate2", "proptest", @@ -3769,7 +3769,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.7.0" +version = "0.7.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -3779,7 +3779,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -3791,18 +3791,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.7.0" +version = "0.7.1" [[package]] name = "pdfcraft-js" -version = "0.7.0" +version = "0.7.1" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -3814,14 +3814,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.7.0" +version = "0.7.1" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -3831,7 +3831,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.7.0" +version = "0.7.1" dependencies = [ "image", "pdfcraft-annot", @@ -3844,7 +3844,7 @@ dependencies = [ [[package]] name = "pdfcraft-organize" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -3854,7 +3854,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.7.0" +version = "0.7.1" dependencies = [ "async-channel", "futures-lite", @@ -3863,7 +3863,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-cos", "pdfcraft-model", @@ -3871,7 +3871,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.7.0" +version = "0.7.1" dependencies = [ "craft-print", "pdfcraft-content", @@ -3882,7 +3882,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -3896,7 +3896,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.7.0" +version = "0.7.1" dependencies = [ "bytemuck", "flate2", @@ -3915,7 +3915,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aes", "aws-lc-rs", @@ -3949,7 +3949,7 @@ dependencies = [ [[package]] name = "pdfcraft-ui-egui" -version = "0.7.0" +version = "0.7.1" dependencies = [ "arboard", "base64 0.22.1", @@ -3990,7 +3990,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.7.0" +version = "0.7.1" dependencies = [ "eframe", "egui", @@ -4002,7 +4002,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.7.0" +version = "0.7.1" dependencies = [ "base64 0.22.1", "image", @@ -4016,7 +4016,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 4220db770a..169af97f28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.7.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 681548a47fa5ff216df4589e2c5779b13f21811a Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 12 Oct 2026 13:00:39 -0700 Subject: [PATCH 06/70] Add a PR template, and record the review practice and external-PR bar Replaces this PR's earlier contents. It previously added a committed `docs/plan/` tree -- a status baseline, an architecture doc and an execution plan. Since then `main` has grown `docs/gaps.md`, `docs/target-app-parity.md`, `docs/roadmap.md` and `docs/architecture.md`, which are actively maintained (gaps.md was reviewed 2026-10-10 and updated 2026-10-11) and cover the same ground better, because they sit in the flow contributors already touch. Keeping a parallel plan would have meant two sources of truth and a document that rots; it had already gone conflicting twice in three days. So the plan is dropped and only the parts with no equivalent anywhere remain. `.github/pull_request_template.md` is new -- the repository had none, with 167 PRs open and 66 of them conflicting. It leads with the two things a contributor cannot be expected to know and that cannot be undone after a merge: provenance (no Adobe asset, no committed font, no copied GPL/AGPL code, behaviour from the spec rather than from reading another implementation) and the never-crash rules. It also asks for a test that has been *seen to fail* before the fix, and says plainly that per-crate verification is preferred because CI runs the workspace anyway. CLAUDE.md gains three things it did not record: the four-zoom review habit (clause, function, file, project) with the tests that mirror it; the external-PR bar; and a note that builds and tests should be scoped per crate, because a full workspace build has locked up a development machine and CI covers it. The commit rule now also says that `main` moves fast enough for PRs to rot -- 396 commits in three days -- and that conflict risk tracks how contested the files are rather than how large the change is. Co-Authored-By: Claude --- .github/pull_request_template.md | 76 ++++++++++++++++++++++++++++++++ CLAUDE.md | 11 ++++- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..50ff3e96c4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,76 @@ + + +## What this changes + + + +## How you verified it + + + +- [ ] `cargo fmt --check` +- [ ] clippy with `-D warnings` on the crates I changed +- [ ] tests pass on the crates I changed +- [ ] `cargo xtask layers` and `cargo xtask assets` (if you touched crate boundaries or any asset) + +**Tests.** New behaviour needs a test at the right scale — a property test, a unit test, a +crate integration test or an end-to-end one. For a bug fix, **please check that the test +fails before your fix** by reverting the fix and watching it go red. A test nobody has seen +fail is not evidence that it pins anything. + +## Never-crash + +Every PDF, script, MCP call, settings file and keystroke is untrusted input (`AGENTS.md` §4). + +- [ ] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests, unless it is + provably infallible with a comment saying why +- [ ] No indexing or slicing with input-derived positions (`get`, and strings only at char + boundaries); checked or saturating arithmetic; allocations sized by input are capped; + recursion is bounded +- [ ] No `unsafe` +- [ ] Any crash this fixes has a small synthetic regression test + +## Provenance + +This is the part that cannot be undone after a merge, and the part a new contributor is +least likely to know about. Please read `AGENTS.md` §1 before adding any asset. + +- [ ] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated +- [ ] No font files committed here (they live in + [storytold/craft-fonts](https://github.com/storytold/craft-fonts)) +- [ ] No GPL/AGPL code copied in — MuPDF, Ghostscript, Poppler, veraPDF and DSS may only be + run as external oracle processes +- [ ] Behaviour comes from the specification or black-box observation, **not** from reading + another implementation's source +- [ ] Every new asset has an `ATTRIBUTION.toml` entry and `cargo xtask assets` passes + +## Security + +- [ ] `--root` confinement still holds on every path, including ones reached by nested + tool dispatch +- [ ] MCP and the UI control channel stay opt-in, loopback-only and token-authenticated +- [ ] No new outbound network access (that decision is the owner's) + +## Parity and reach + +- [ ] `parity/acrobat-features.toml` updated in this PR, with `notes` saying what is *still* + missing — `shipped` means "exists and tested", not "as good as Acrobat" +- [ ] Reachable headlessly through an `automation` tool where that applies (`AGENTS.md` §3), + so agents can drive it and not just the UI +- [ ] Docs updated if this changes a documented behaviour (`docs/`, crate README) + +## Anything a reviewer should look at first + + diff --git a/CLAUDE.md b/CLAUDE.md index 77d6445b4e..12779b1e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,15 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - No `unsafe` (`unsafe_code = "forbid"`). - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. -- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace`, and the wasm check once `xtask ci` exists (M0). -- **Commits:** one task id per commit (e.g. `M1.4: xref stream reader`). Commit only green states. End messages with the attribution line required by the environment. +- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. + - Tests mirror the zooms: property → unit → crate integration → end-to-end. + - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path. + - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. +- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. +- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. +- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. + - **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once. ## Running and looking at the app - `cargo run -p pdfcraft -- ` opens the desktop app. From d0d1ad8192cc39c473d752bb1606e00e2c5e8e50 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 8 Oct 2026 13:59:18 -0700 Subject: [PATCH 07/70] F1: exact standard-14 metrics pdfcraft_fonts::helvetica_width was a fifteen-line guess by character class, and seven crates generated appearance streams through it: annot, edit, forms, ocr, redact, sign and xfa. Times was Helvetica x 0.9, bold was x 1.05, Courier a flat 0.6. Against the published metrics a Times capital A is 722, so that estimate said 600 -- a 17% error on a letter, compounding across a line. Std14 carries the real advance widths for all fourteen faces, indexed by character code: WinAnsiEncoding for the twelve text fonts, which is what we write into the appearance streams we generate, and their own built-in encodings for Symbol and ZapfDingbats. The tables are generated by joining the published encodings with the per-glyph widths. helvetica_width now delegates, so every call site gained exact Helvetica with no change. The three places that faked another face by scaling it -- Family in edit::added, the paragraph measure in edit::text, and the redaction overlay fonts -- now select the right face instead. So does Metrics::width in fonts::pdf, which measures existing document text when a font carries no /Widths; that path feeds redaction's glyph removal, where a wrong width means cutting the wrong glyphs. Tests spot-check the table against values typed in from the specification by hand, so it is verified against the spec rather than against whatever produced it; plus Courier being fixed pitch everywhere, full printable-ASCII and Latin-1 coverage, Symbol and ZapfDingbats not being WinAnsi, /BaseFont aliases and subset prefixes resolving, and hostile text staying finite. Co-Authored-By: Claude Opus 5 --- crates/edit/src/added.rs | 30 ++- crates/edit/src/text.rs | 10 +- crates/fonts/src/lib.rs | 32 +-- crates/fonts/src/pdf.rs | 42 +-- crates/fonts/src/std14.rs | 526 ++++++++++++++++++++++++++++++++++++++ crates/redact/src/lib.rs | 11 +- 6 files changed, 581 insertions(+), 70 deletions(-) create mode 100644 crates/fonts/src/std14.rs diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 42f7867162..66047c559a 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{ - GlyphError, GlyphOutline, ShapedCluster, ShapedScript, helvetica_width, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi, + GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi, }; use unicode_bidi::{Level, ParagraphBidiInfo}; @@ -67,15 +67,27 @@ impl Family { (family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique")) } - /// Approximate advance width of `s` (standard-14 metrics are not bundled: Helvetica widths, - /// scaled for Times; Courier is monospaced). + /// Advance width of `s` at `size` points, from the published standard-14 metrics. pub fn width(self, s: &str, size: f64, bold: bool) -> f64 { - let w = match self { - Family::Courier => s.chars().count() as f64 * 0.6 * size, - Family::Times => helvetica_width(s, size) * 0.9, - Family::Helvetica => helvetica_width(s, size), - }; - if bold { w * 1.05 } else { w } + self.std14(bold, false).text_width(s, size) + } + + /// The standard-14 face this family means at the given style. + pub fn std14(self, bold: bool, italic: bool) -> Std14 { + match (self, bold, italic) { + (Family::Courier, false, false) => Std14::Courier, + (Family::Courier, true, false) => Std14::CourierBold, + (Family::Courier, false, true) => Std14::CourierOblique, + (Family::Courier, true, true) => Std14::CourierBoldOblique, + (Family::Times, false, false) => Std14::TimesRoman, + (Family::Times, true, false) => Std14::TimesBold, + (Family::Times, false, true) => Std14::TimesItalic, + (Family::Times, true, true) => Std14::TimesBoldItalic, + (Family::Helvetica, false, false) => Std14::Helvetica, + (Family::Helvetica, true, false) => Std14::HelveticaBold, + (Family::Helvetica, false, true) => Std14::HelveticaOblique, + (Family::Helvetica, true, true) => Std14::HelveticaBoldOblique, + } } } diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 0270322820..cb8b93edfa 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1724,13 +1724,9 @@ pub(crate) fn rewrite_block_with_limit( // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); - let std_width = move |s: &str, size: f64| -> f64 { - match family { - crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size, - crate::added::Family::Times => pdfcraft_fonts::helvetica_width(s, size) * 0.92, - crate::added::Family::Helvetica => pdfcraft_fonts::helvetica_width(s, size) * if bold { 1.05 } else { 1.0 }, - } - }; + // The face is chosen after `bold` settles, so a bold request measures with the bold metrics. + let face = family.std14(bold, italic); + let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) }; let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); if !(dx.is_finite() && dy.is_finite()) { return Err(EditError::Invalid("the paragraph can't be moved that far".into())); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 19d0d1daf4..622c609f69 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -1,7 +1,9 @@ //! pdfcraft-fonts — font metrics and encodings for generated appearances (L2). //! -//! See the README: the metrics are approximations by character class (no vendor metrics files -//! are bundled). The full font subsystem lands in M2.2/M7. +//! Standard-14 advance widths are exact (`Std14`, ISO 32000-2 Annex D). What remains approximate +//! is everything that needs a font *program*: this crate reads font dictionaries, never embedded +//! programs, so it has no glyph outlines for document fonts and cannot subset or re-embed. That +//! is phase F 4.2 in docs/plan/execution-plan.md. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -12,6 +14,7 @@ pub mod pdf; mod script; mod shaping; pub mod source_shaping; +mod std14; pub use craft::{ CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style, document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts, @@ -21,27 +24,14 @@ pub use script::{ GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline, }; pub use shaping::{ShapedCluster, ShapedScript, arabic_glyph, arabic_has, shape_arabic, shape_thai, shaped_glyph, thai_has}; +pub use std14::Std14; -/// Approximate advance of `s` in Helvetica (or Arial) at `size` points. +/// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics. +/// +/// Kept as a free function because generated appearances overwhelmingly use Helvetica; reach for +/// [`Std14`] directly for any other face, and never for a scaled approximation of this one. pub fn helvetica_width(s: &str, size: f64) -> f64 { - let units: f64 = s - .chars() - .map(|c| match c { - ' ' | 'i' | 'j' | 'l' | '\'' | '!' | '|' | '.' | ',' | ':' | ';' | 'I' => 260.0, - 'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '/' | '-' | '"' => 333.0, - 'm' => 833.0, - 'w' => 722.0, - 'M' => 833.0, - 'W' => 944.0, - 'J' | 'c' | 'k' | 's' | 'v' | 'x' | 'y' | 'z' => 500.0, - '0'..='9' | 'a'..='z' | '$' | '#' | '?' | '_' => 556.0, - 'A'..='Z' => 680.0, - '@' => 1015.0, - _ if c.is_whitespace() => 260.0, - _ => 584.0, - }) - .sum(); - units * size / 1000.0 + Std14::Helvetica.text_width(s, size) } /// Approximate advance of `s` at `size` points in a CJK font: full-width characters take one diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index e3de687ff5..b62110d184 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -19,14 +19,6 @@ enum Codes { Ranges(Vec<(usize, Vec, Vec)>), } -#[derive(Clone, Debug, PartialEq)] -enum Std14 { - Helvetica, - Times, - Courier, - Symbolic, -} - #[derive(Clone, Debug, PartialEq)] pub struct Metrics { codes: Codes, @@ -39,7 +31,8 @@ pub struct Metrics { /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). cid_map: Vec<(u32, u32, u32)>, default: f64, - std14: Option, + /// The standard-14 face to measure with when the dictionary carries no `/Widths`. + std14: Option, /// Glyph units → text space (0.001, or `/FontMatrix[0]` for Type 3). pub scale: f64, /// Glyph box in text space per unit of font size. @@ -526,7 +519,7 @@ impl Metrics { cid_ranges: Vec::new(), cid_map: Vec::new(), default: 500.0, - std14: Some(Std14::Helvetica), + std14: Some(crate::Std14::Helvetica), scale: 0.001, ascent: 0.9, descent: -0.25, @@ -655,16 +648,10 @@ impl Metrics { } } } else if m.widths.is_empty() { - let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).to_ascii_lowercase(); - m.std14 = Some(if base.contains("courier") { - Std14::Courier - } else if base.contains("times") { - Std14::Times - } else if base.contains("symbol") || base.contains("dingbats") { - Std14::Symbolic - } else { - Std14::Helvetica - }); + let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned(); + // The real face, bold and italic included: the published metrics differ by style, + // so Times-Bold is not Times-Roman and neither is Helvetica scaled. + m.std14 = crate::Std14::from_base_font(&base).or(Some(crate::Std14::Helvetica)); } } if let Some(d) = descriptor { @@ -726,14 +713,13 @@ impl Metrics { if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { return w * self.scale; } - match &self.std14 { - Some(Std14::Courier) => 0.6, - Some(Std14::Symbolic) => 0.75, - Some(f) => { - let c = char::from_u32(code).filter(|c| !c.is_control()).unwrap_or('n'); - let w = crate::helvetica_width(&c.to_string(), 1.0); - if *f == Std14::Times { w * 0.92 } else { w } - } + match self.std14 { + // A font with no /Widths is one of the standard 14; use its published metrics. + // Codes the font's encoding leaves undefined fall back to /MissingWidth. + Some(f) => match u8::try_from(code).map(|c| f.width(c)).unwrap_or(0.0) { + 0.0 => self.default * self.scale, + w => w / 1000.0, + }, None => self.default * self.scale, } } diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs new file mode 100644 index 0000000000..0aca38a120 --- /dev/null +++ b/crates/fonts/src/std14.rs @@ -0,0 +1,526 @@ +//! Standard-14 font metrics (ISO 32000-2 Annex D; the Adobe Core14 AFM metrics), as advance +//! widths in glyph space — thousandths of an em. +//! +//! A PDF may name any of the fourteen standard fonts without embedding a program, and the viewer +//! has to supply the metrics. These are those metrics, exactly, replacing the character-class +//! estimate that generated appearances used before: that estimate had Times at 0.9 x Helvetica, +//! which makes a capital A 600 where the specification says 722. +//! +//! **Generated.** The tables are data, not typefaces, and are the item AGENTS.md §1.1 permits as +//! Adobe-authored non-visual technical data; `ATTRIBUTION.toml` carries the entry with +//! `adobe_data = true`. Each row is indexed by character **code**, not by Unicode: the twelve text +//! fonts are indexed by WinAnsiEncoding, which is what PdfCraft writes into the appearance streams +//! it generates, and Symbol and ZapfDingbats by their own built-in encodings. A zero means the +//! encoding leaves that code undefined. + +/// One of the fourteen standard fonts (ISO 32000-2 §9.6.2.2). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Std14 { + Helvetica, + HelveticaBold, + HelveticaOblique, + HelveticaBoldOblique, + TimesRoman, + TimesBold, + TimesItalic, + TimesBoldItalic, + Courier, + CourierBold, + CourierOblique, + CourierBoldOblique, + Symbol, + ZapfDingbats, +} + +impl Std14 { + /// The font a `/BaseFont` name selects, by the spec's names and the common aliases viewers + /// accept (`Arial` for `Helvetica`, `TimesNewRoman` for `Times`, `CourierNew` for `Courier`). + /// A subset prefix such as `ABCDEF+Helvetica` is ignored. + pub fn from_base_font(base: &str) -> Option { + let base = base.rsplit('+').next().unwrap_or(base); + let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect(); + let has = |w: &str| flat.contains(w); + let (bold, italic) = (has("bold"), has("italic") || has("oblique")); + if has("symbol") { + return Some(Self::Symbol); + } + if has("zapf") || has("dingbat") { + return Some(Self::ZapfDingbats); + } + if has("courier") || has("mono") { + return Some(match (bold, italic) { + (false, false) => Self::Courier, + (true, false) => Self::CourierBold, + (false, true) => Self::CourierOblique, + (true, true) => Self::CourierBoldOblique, + }); + } + if has("times") || has("serif") || has("roman") { + return Some(match (bold, italic) { + (false, false) => Self::TimesRoman, + (true, false) => Self::TimesBold, + (false, true) => Self::TimesItalic, + (true, true) => Self::TimesBoldItalic, + }); + } + if has("helvetica") || has("arial") { + return Some(match (bold, italic) { + (false, false) => Self::Helvetica, + (true, false) => Self::HelveticaBold, + (false, true) => Self::HelveticaOblique, + (true, true) => Self::HelveticaBoldOblique, + }); + } + None + } + + /// The advance of one character `code`, in thousandths of an em. Zero where the font's + /// encoding leaves the code undefined. + pub fn width(self, code: u8) -> f64 { + f64::from(WIDTHS[self as usize][code as usize]) + } + + /// The advance of `code` at `size` points. + pub fn advance(self, code: u8, size: f64) -> f64 { + self.width(code) * size / 1000.0 + } + + /// The advance of `text` at `size` points, encoded as the twelve text fonts are written: + /// WinAnsiEncoding, with characters it cannot represent replaced as `win_ansi` replaces them. + /// + /// Symbol and ZapfDingbats have their own built-in encodings and are not WinAnsi, so this + /// measures them as if they were; pass codes to [`Std14::width`] for those. + pub fn text_width(self, text: &str, size: f64) -> f64 { + crate::win_ansi(text).iter().map(|&c| self.width(c)).sum::() * size / 1000.0 + } + + /// Courier is the only fixed-pitch family of the fourteen. + pub fn is_fixed_pitch(self) -> bool { + matches!(self, Self::Courier | Self::CourierBold | Self::CourierOblique | Self::CourierBoldOblique) + } + + /// The `/BaseFont` name for this font, as it is written into a PDF. + pub fn base_font(self) -> &'static str { + BASE_FONTS[self as usize] + } +} + +static BASE_FONTS: [&str; 14] = [ + "Helvetica", + "Helvetica-Bold", + "Helvetica-Oblique", + "Helvetica-BoldOblique", + "Times-Roman", + "Times-Bold", + "Times-Italic", + "Times-BoldItalic", + "Courier", + "Courier-Bold", + "Courier-Oblique", + "Courier-BoldOblique", + "Symbol", + "ZapfDingbats", +]; + +/// Advance widths by code, one row per [`Std14`] variant in declaration order. +#[rustfmt::skip] +static WIDTHS: [[u16; 256]; 14] = [ + // Helvetica + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, + 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, + ], + // HelveticaBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, + 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, + ], + // HelveticaOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, + 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, + ], + // HelveticaBoldOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, + 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, + ], + // TimesRoman + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, + 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, + 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, + 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, + 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0, + 500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0, + 0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722, + 250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333, + 400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444, + 722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500, + 444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500, + ], + // TimesBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, + 611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, + 333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, + 556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0, + 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0, + 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722, + 250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333, + 400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500, + ], + // TimesItalic + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500, + 920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722, + 611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500, + 333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500, + 500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0, + 500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0, + 0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556, + 250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333, + 400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500, + 611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500, + 500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444, + ], + // TimesBoldItalic + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722, + 611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500, + 333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500, + 500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0, + 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0, + 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611, + 250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333, + 400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500, + 667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444, + ], + // Courier + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierBoldOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // Symbol + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444, + 549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722, + 768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500, + 500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549, + 549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603, + 400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658, + 823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713, + 768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603, + 494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494, + 0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0, + ], + // ZapfDingbats + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933, + 911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537, + 577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831, + 923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826, + 815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762, + 762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0, + 390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788, + 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, + 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, + 788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834, + 873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874, + 0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0, + ], +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// Widths from the published Core14 metrics, typed in here by hand so the table is checked + /// against the specification rather than against whatever produced it. + #[test] + fn widths_match_the_published_metrics() { + let cases: &[(Std14, char, u16)] = &[ + // Helvetica + (Std14::Helvetica, ' ', 278), + (Std14::Helvetica, 'A', 667), + (Std14::Helvetica, 'W', 944), + (Std14::Helvetica, 'i', 222), + (Std14::Helvetica, 'm', 833), + (Std14::Helvetica, '0', 556), + (Std14::Helvetica, '@', 1015), + (Std14::HelveticaBold, 'A', 722), + (Std14::HelveticaBold, 'i', 278), + // Times: not Helvetica scaled by 0.9, which is what the old estimate assumed. + (Std14::TimesRoman, ' ', 250), + (Std14::TimesRoman, 'A', 722), + (Std14::TimesRoman, 'W', 944), + (Std14::TimesRoman, 'i', 278), + (Std14::TimesRoman, 'm', 778), + (Std14::TimesBold, 'A', 722), + (Std14::TimesItalic, 'A', 611), + // Courier is fixed pitch at 600 for every glyph. + (Std14::Courier, ' ', 600), + (Std14::Courier, 'A', 600), + (Std14::Courier, 'm', 600), + (Std14::CourierBoldOblique, 'W', 600), + ]; + for &(font, ch, want) in cases { + let code = crate::win_ansi(&ch.to_string()); + assert_eq!(code.len(), 1, "{ch:?} is one WinAnsi byte"); + assert_eq!(font.width(code[0]) as u16, want, "{font:?} {ch:?}"); + } + } + + #[test] + fn courier_is_fixed_pitch_everywhere_it_is_defined() { + for font in [Std14::Courier, Std14::CourierBold, Std14::CourierOblique, Std14::CourierBoldOblique] { + assert!(font.is_fixed_pitch()); + for code in 0..=255u8 { + let w = font.width(code); + assert!(w == 0.0 || w == 600.0, "{font:?} code {code} has width {w}"); + } + } + assert!(!Std14::Helvetica.is_fixed_pitch()); + } + + #[test] + fn every_text_font_covers_printable_ascii_and_latin1() { + for font in [Std14::Helvetica, Std14::TimesRoman, Std14::Courier, Std14::TimesBoldItalic] { + for code in 0x20..=0x7Eu8 { + assert!(font.width(code) > 0.0, "{font:?} has no width for ASCII {code}"); + } + for code in 0xA0..=0xFFu8 { + assert!(font.width(code) > 0.0, "{font:?} has no width for Latin-1 {code}"); + } + } + } + + #[test] + fn symbol_and_dingbats_have_their_own_encodings() { + // Symbol's code 0x61 is alpha, not 'a'; ZapfDingbats' is a scissors glyph. + assert!(Std14::Symbol.width(0x61) > 0.0); + assert!(Std14::ZapfDingbats.width(0x61) > 0.0); + // Neither defines the Latin-1 upper half the text fonts do. + assert_eq!(Std14::Symbol.width(0xFF), 0.0); + } + + #[test] + fn base_font_names_round_trip() { + for i in 0..14usize { + let font = [ + Std14::Helvetica, + Std14::HelveticaBold, + Std14::HelveticaOblique, + Std14::HelveticaBoldOblique, + Std14::TimesRoman, + Std14::TimesBold, + Std14::TimesItalic, + Std14::TimesBoldItalic, + Std14::Courier, + Std14::CourierBold, + Std14::CourierOblique, + Std14::CourierBoldOblique, + Std14::Symbol, + Std14::ZapfDingbats, + ][i]; + assert_eq!(Std14::from_base_font(font.base_font()), Some(font), "{}", font.base_font()); + } + } + + #[test] + fn base_font_aliases_and_subset_prefixes_resolve() { + assert_eq!(Std14::from_base_font("ABCDEF+Helvetica-Bold"), Some(Std14::HelveticaBold)); + assert_eq!(Std14::from_base_font("Arial"), Some(Std14::Helvetica)); + assert_eq!(Std14::from_base_font("Arial,BoldItalic"), Some(Std14::HelveticaBoldOblique)); + assert_eq!(Std14::from_base_font("TimesNewRoman,Bold"), Some(Std14::TimesBold)); + assert_eq!(Std14::from_base_font("CourierNew-Oblique"), Some(Std14::CourierOblique)); + assert_eq!(Std14::from_base_font("ZapfDingbats"), Some(Std14::ZapfDingbats)); + assert_eq!(Std14::from_base_font("Wingdings"), None); + assert_eq!(Std14::from_base_font(""), None); + } + + #[test] + fn text_width_sums_the_glyphs_and_scales_with_size() { + let h = Std14::Helvetica; + // "AW" = 667 + 944 = 1611 thousandths of an em. + assert!((h.text_width("AW", 1000.0) - 1611.0).abs() < 1e-9); + assert!((h.text_width("AW", 12.0) - 1611.0 * 12.0 / 1000.0).abs() < 1e-9); + assert_eq!(h.text_width("", 12.0), 0.0); + // Courier: every character is 600, so the width is purely a count. + assert!((Std14::Courier.text_width("hello", 10.0) - 5.0 * 6.0).abs() < 1e-9); + } + + #[test] + fn hostile_text_does_not_panic_and_stays_finite() { + for font in [Std14::Helvetica, Std14::Symbol, Std14::Courier] { + for s in ["", "\0", "\u{202E}", "💥", &"a".repeat(10_000), "\u{FFFD}"] { + let w = font.text_width(s, 12.0); + assert!(w.is_finite() && w >= 0.0, "{font:?} {s:?} -> {w}"); + } + assert!(font.advance(0, 12.0).is_finite()); + assert!(font.advance(255, 0.0).is_finite()); + } + } +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 916d605edb..7ce4cfd167 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -222,12 +222,13 @@ fn overlay_content(marks: &[&Mark]) -> Vec { // The overlay text: its font, colour and alignment; auto-sized to fit unless a size is // set; repeated to fill the area when asked. let look = &m.look; - let (res, width): (&str, fn(&str, f64) -> f64) = match look.font { - pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::helvetica_width), - // Approximations of the standard metrics (no font program is bundled). - pdfcraft_annot::OverlayFont::Times => ("PCTimes", |s, size| pdfcraft_fonts::helvetica_width(s, size) * 0.9), - pdfcraft_annot::OverlayFont::Courier => ("PCCour", |s, size| s.chars().count() as f64 * size * 0.6), + // The published standard-14 metrics, so overlay text is measured as it will be drawn. + let (res, face) = match look.font { + pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::Std14::Helvetica), + pdfcraft_annot::OverlayFont::Times => ("PCTimes", pdfcraft_fonts::Std14::TimesRoman), + pdfcraft_annot::OverlayFont::Courier => ("PCCour", pdfcraft_fonts::Std14::Courier), }; + let width = move |s: &str, size: f64| face.text_width(s, size); let [cr, cg, cb] = look.color.map(|v| v.clamp(0.0, 1.0)); for r in &m.rects { let (w, h) = (r[2] - r[0], r[3] - r[1]); From aaa19d87afa2e37120c0ca9cda377d57b409cbef Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 8 Oct 2026 13:59:28 -0700 Subject: [PATCH 08/70] F1: put the generated table on the AGENTS.md 1.1 closed list Isolated so it can be reverted on its own. AGENTS.md 1.1 keeps a closed list of Adobe-authored non-visual technical data, and adding to it needs the owner's explicit approval, which was given on 2026-10-08. The item itself -- the standard-14 font metrics and encoding tables from the PDF specification -- was already on that list; what is new is holding a copy first-party rather than only through hayro, which is what lets generated appearances measure with it and what survives hayro being retired in M2.8. The gate only understood the list for dependency-bundled files, and rejected adobe_data on a repository file outright. It now accepts it for a closed list of first-party paths, so crates/fonts/src/std14.rs carries an ATTRIBUTION entry with its SHA-256: regenerating the table fails the gate until the entry is updated, which makes a change to Adobe-derived data a deliberate act rather than an ordinary edit. Worth noting that .rs is not an asset extension, so the table would never have been scanned at all. Entering it on the list is a choice to be checked, not a requirement the gate imposed. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- ATTRIBUTION.md | 3 ++- ATTRIBUTION.toml | 12 ++++++++++++ xtask/src/assets.rs | 30 +++++++++++++++++++++++++++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 039cd6a8b1..2ac90cfdf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds, - Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs. - The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed: - The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency. - - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces, and are embedded as code in hayro. + - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14.rs` (approved by the owner, 2026-10-08), which is what generated appearances measure with and what survives hayro being retired. Adding anything to this list needs the user's explicit approval. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b7e254556d..be77e718b2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (269) +## In this repository (270) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -222,6 +222,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | +| `crates/fonts/src/std14.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | | `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index a4154d5ac4..3b08e84401 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2364,6 +2364,18 @@ kind = "data" usage = "Default CMYK colour conversion (vendored hayro-interpret)" sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0" +[[asset]] +path = "crates/fonts/src/std14.rs" +title = "Standard-14 font advance widths and encodings (as Rust tables)" +author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors" +source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs." +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "data" +adobe_data = true +usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree." +sha256 = "4ce47db7e65467d4ea1d1642ff76dfcaeb9669c1811a93819d7e00e63fa9fcf8" + [[bundled]] crate = "epaint_default_fonts" version = "0.36.2" diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 870d9b9530..fc95a6ffb7 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -43,6 +43,12 @@ pub const BRAND_LICENCE: &str = "LicenseRef-Storyteller-Trademark"; pub const ADOBE_DATA_ALLOWED: &[(&str, &str)] = &[("hayro-cmap", "assets/cmaps.brotli"), ("hayro-interpret", "src/font/generated/metrics.rs")]; +/// First-party files on that same closed list: generated tables of Adobe-authored non-visual +/// technical data we hold ourselves rather than through a dependency. Each still needs an +/// `[[asset]]` entry with `adobe_data = true`, so its SHA-256 is checked and any change to the +/// table has to be re-attested deliberately. +pub const ADOBE_DATA_FILES: &[&str] = &["crates/fonts/src/std14.rs"]; + /// File extensions that count as assets wherever they appear in the repository. const ASSET_EXTENSIONS: &[&str] = &[ "png", "jpg", "jpeg", "gif", "svg", "ico", "icns", "webp", "bmp", "tif", "tiff", "avif", "heic", "ttf", "otf", "ttc", "woff", "woff2", "pfb", @@ -245,7 +251,7 @@ pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<( if visual(&a.kind) && (mentions_adobe(&a.author) || mentions_adobe(&a.source) || mentions_adobe(&a.title)) { problems.push(format!("{}: {} from Adobe is forbidden (AGENTS.md §1.1)", a.path, a.kind)); } - if a.adobe_data { + if a.adobe_data && !(ADOBE_DATA_FILES.contains(&a.path.as_str()) && a.kind == "data") { problems.push(format!("{}: adobe_data is only allowed for the closed list in AGENTS.md §1.1", a.path)); } match std::fs::read(root.join(&a.path)) { @@ -570,6 +576,28 @@ mod tests { assert!(check(&root(), &font, &[], &l).iter().any(|p| p.contains("forbidden"))); } + #[test] + fn first_party_adobe_data_only_on_the_closed_list() { + let l = lock(&[]); + // The generated standard-14 table is on the list, as `kind = "data"`. + let mut ok = asset("crates/fonts/src/std14.rs", "Adobe (Core14 AFM metrics); tables by the PdfCraft contributors", "data", "MIT"); + ok.adobe_data = true; + let m = Manifest { asset: vec![ok], ..Default::default() }; + assert!(check(&root(), &m, &[], &l).is_empty(), "{:?}", check(&root(), &m, &[], &l)); + + // Any other first-party file claiming it is refused. + let mut sneaky = asset("Cargo.toml", "Adobe", "data", "MIT"); + sneaky.adobe_data = true; + let m = Manifest { asset: vec![sneaky], ..Default::default() }; + assert!(check(&root(), &m, &[], &l).iter().any(|p| p.contains("closed list"))); + + // Even on the list, it may only be data — never a typeface. + let mut wrong_kind = asset("crates/fonts/src/std14.rs", "Adobe", "font", "MIT"); + wrong_kind.adobe_data = true; + let m = Manifest { asset: vec![wrong_kind], ..Default::default() }; + assert!(!check(&root(), &m, &[], &l).is_empty()); + } + #[test] fn dependency_upgrade_forces_reaudit() { let m = Manifest { bundled: vec![bundled("other", "a.ttf", "Someone", "font", false)], ..Default::default() }; From 117430fade453972a2ce502ff73c8c1789761df4 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 9 Oct 2026 13:19:39 -0700 Subject: [PATCH 09/70] F1: measure an added item with the face it is written as `Family::width` chose its standard-14 face from `bold` alone and passed `italic: false`, while `Family::base_font` writes the item out as the italic or oblique face. Every italic item was therefore laid out with upright metrics: Times-Italic `A` is 611 against Times-Roman's 722, so boxes, wrapping and centring were all sized from the wrong face. The measure now takes `italic` and the five call sites pass the item's own flag, which they already had in scope. The test walks all twelve style combinations and asserts the written face and the measured face are the same one, so the two can't drift apart again. Co-Authored-By: Claude --- crates/edit/src/added.rs | 17 ++++++++++------- crates/edit/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 66047c559a..ad3132fc6f 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -68,8 +68,11 @@ impl Family { } /// Advance width of `s` at `size` points, from the published standard-14 metrics. - pub fn width(self, s: &str, size: f64, bold: bool) -> f64 { - self.std14(bold, false).text_width(s, size) + /// + /// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]), + /// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722). + pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 { + self.std14(bold, italic).text_width(s, size) } /// The standard-14 face this family means at the given style. @@ -252,7 +255,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { // sum of its words' and shaping stays linear in the paragraph's length. The space is the // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths // of an em apart. - let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) }; + let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); for word in para.split(' ') { @@ -426,7 +429,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 let width = pieces .iter() .map(|p| match p { - Piece::Latin(s) => t.family.width(s, t.size, t.bold), + Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic), Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::() * t.size, }) .sum(); @@ -436,7 +439,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 /// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing /// then reports why). fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { - shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold)) + shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic)) } fn arabic_error(e: GlyphError, text: &str) -> EditError { @@ -572,7 +575,7 @@ fn draw_laid_shaped( out.extend(literal(&win_ansi(s))); out.extend_from_slice(b" Tj\n"); current = Some(latin.as_str()); - pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64; + pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64; } Piece::Shaped(clusters) => { for c in clusters { @@ -725,7 +728,7 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); for (i, line) in lines(t).iter().enumerate() { - let w = t.family.width(line, t.size, t.bold); + let w = t.family.width(line, t.size, t.bold, t.italic); let x = match t.align { Align::Left | Align::Justify => r[0], Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 616456e8b1..2bc44b6d90 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3238,3 +3238,31 @@ fn subset_indic_font_without_legacy_name_keeps_logical_text() { assert!(!lines[0].visual_order); assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"); } + +/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold` +/// …), so it has to be *measured* with that same face. Measuring the upright face while writing +/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722. +#[test] +fn an_items_measured_face_is_the_face_it_is_written_as() { + for family in [Family::Helvetica, Family::Times, Family::Courier] { + for bold in [false, true] { + for italic in [false, true] { + let written = family.base_font(bold, italic); + let measured = family.std14(bold, italic); + assert_eq!(written, measured.base_font(), "{family:?} bold={bold} italic={italic}"); + // ... and the public measure reaches that face, rather than an upright stand-in. + assert_eq!( + family.width("Anna", 12.0, bold, italic), + measured.text_width("Anna", 12.0), + "{family:?} bold={bold} italic={italic}" + ); + } + } + } + // The faces really do differ, so the assertions above are not all comparing the same number. + let times = |italic| Family::Times.width("Anna", 12.0, false, italic); + assert_ne!(times(false), times(true), "Times-Italic is not Times-Roman"); + // Courier is monospaced in every style: equal widths here are correct, not a collapsed face. + let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic); + assert_eq!(courier(false), courier(true)); +} From ce9a1d1014871baaac632f1e934b71742f7d5b66 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 9 Oct 2026 13:32:14 -0700 Subject: [PATCH 10/70] F2: read glyph names by the naming algorithm, and stop reading Symbol as Latin Two gaps in code-to-Unicode, both found by probing the current behaviour rather than by reading the code. A font with no /Widths and no /ToUnicode fell back to StandardEncoding whatever it was, so /Symbol code 0x61 -- `alpha` in Symbol's own built-in encoding -- decoded as "a". ZapfDingbats and MacExpert were read as Latin the same way. That map is what `redact` matches patterns against, so a wrong character there is a silent redaction failure, and a wrong character is worse than no character. Symbol, ZapfDingbats and MacExpert now yield no mapping at all unless the font names a base encoding, which is always honoured as the deliberate statement it is. This keys on the base font *name*, not the /FontDescriptor symbolic flag. A great many ordinary Latin TrueType subsets set that flag and still rely on a Latin encoding, so suppressing on the flag would lose real text across a large share of real documents. A test pins that restraint so it is not "fixed" into a regression later. Glyph names now follow the published naming algorithm instead of two of its cases: a name is cut at its first `.` (`a.sc` is `a`), split on `_` into components (`f_i` is "fi"), and `uniXXXX` carries any number of four-digit values (`uni00660069` is "fi"). Names are resolved whole or not at all, so half a ligature is never returned. `glyph_text` returns a String for this; `glyph_unicode` stays as the single-character view. Hex parsing rejects the leading `+` that `from_str_radix` would otherwise accept, and a lone surrogate half. Both halves were verified by reverting each fix and watching the matching tests fail. The parity note on protect.redact-patterns now says Symbol-encoded text is skipped rather than mis-decoded. Still open, and an owner decision: Symbol and ZapfDingbats could be mapped properly with a glyph-name-to-Unicode table, but the vendored Adobe Glyph List is not on the AGENTS.md 1.1 closed list. Co-Authored-By: Claude --- crates/fonts/src/pdf.rs | 231 ++++++++++++++++++++++++++++++++--- parity/acrobat-features.toml | 2 +- 2 files changed, 216 insertions(+), 17 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index b62110d184..ef20f5814a 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1,9 +1,15 @@ //! What content editing and redaction need from a PDF font: how to split a string into character //! codes, each code's advance width, the glyph height (ascent, descent), and what each code means //! (Unicode) — and back, which text the font can show. Widths come from the font dictionary -//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); the standard 14 fonts without widths use -//! approximations. Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and -//! `/Differences` glyph names, ISO 32000-2 Annex D). +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact +//! published metrics ([`crate::Std14`]). +//! +//! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph +//! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own +//! built-in encoding governs and we hold no table for it — Symbol, ZapfDingbats, MacExpert — this +//! module reports nothing rather than the Latin letter at that code. Callers must treat a missing +//! meaning as "unknown", never as "absent": `redact` matches on this map, and a wrong character +//! there is worse than no character. use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; @@ -992,13 +998,49 @@ fn parse_to_unicode_within(data: &[u8], out: &mut Unicode, mut budget: usize) { out.explicit_low = bit_set_below_64k(out.explicit.keys().copied()); } -/// A glyph name's Unicode (Annex D names, `uniXXXX`, `uXXXX[XX]`, single letters). -pub fn glyph_unicode(name: &str) -> Option { - if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(name)) { - return char::from_u32(crate::encodings::NAMES[i].1); +/// A glyph name's text, by the Adobe Glyph List *algorithm* — the published naming rules, not +/// the AGL data table, which this crate does not hold. +/// +/// A name is cut at its first `.`, because a suffix marks a variant of the same character rather +/// than a different one (`a.sc` is small-capital `a`, `one.oldstyle` is still `1`). What remains +/// is split on `_` into components, so a ligature name yields several characters (`f_i` is "fi"). +/// Each component is read as an Annex D name, as `uniXXXX` with one or more four-hex-digit values, +/// or as `uXXXX` through `uXXXXXX`. +/// +/// Returns `None` rather than a partial answer: a name is either fully understood or not, and half +/// a ligature would be worse than admitting we cannot read it. +pub fn glyph_text(name: &str) -> Option { + let stem = name.split('.').next().filter(|s| !s.is_empty())?; + let mut out = String::new(); + for part in stem.split('_') { + out.push_str(&glyph_component(part)?); } - let hex = name.strip_prefix("uni").filter(|h| h.len() == 4).or_else(|| name.strip_prefix('u').filter(|h| (4..=6).contains(&h.len())))?; - u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) + Some(out).filter(|s| !s.is_empty()) +} + +/// One `_`-separated component of a glyph name. +fn glyph_component(part: &str) -> Option { + if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)) { + return char::from_u32(crate::encodings::NAMES[i].1).map(String::from); + } + // `from_str_radix` accepts a leading `+`, which is not a glyph name, so the digits are checked. + let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit()); + if let Some(hex) = part.strip_prefix("uni").filter(|h| h.len() >= 4 && h.len() % 4 == 0 && hexish(h)) { + let mut s = String::new(); + for i in (0..hex.len()).step_by(4) { + // A lone surrogate half is not a character; such a name is not usable text. + s.push(char::from_u32(u32::from_str_radix(hex.get(i..i.saturating_add(4))?, 16).ok()?)?); + } + return Some(s); + } + let hex = part.strip_prefix('u').filter(|h| (4..=6).contains(&h.len()) && hexish(h))?; + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).map(String::from) +} + +/// [`glyph_text`] for names that stand for exactly one character. +pub fn glyph_unicode(name: &str) -> Option { + let mut chars = glyph_text(name)?.chars().collect::>().into_iter(); + chars.next().filter(|_| chars.len() == 0) } /// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding. @@ -1011,12 +1053,33 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { Some(Object::Dict(d)) => d.name(b"BaseEncoding").map(<[u8]>::to_vec), _ => None, }; - let table: &[u32; 256] = match base_name.as_deref() { - Some(b"WinAnsiEncoding") => &crate::encodings::WIN_ANSI, - Some(b"MacRomanEncoding") => &crate::encodings::MAC_ROMAN, - _ => &crate::encodings::STANDARD, + // Symbol and ZapfDingbats carry their own built-in encodings, which are not Latin + // (ISO 32000-2 9.6.6.1): Symbol code 0x61 is `alpha`, not `a`. We do not hold those two + // tables, so with no base encoding named they get no implicit mapping at all. Reporting + // nothing is honest; reporting Latin letters silently corrupts text extraction, find, + // copy and — because `redact` matches on this map — redaction. + // + // Deliberately keyed on the *name*, not on the `/FontDescriptor` symbolic flag. A great + // many ordinary Latin TrueType subsets set that flag and still rely on a Latin encoding, + // so suppressing on the flag would lose real text on a large share of real documents. + // An explicitly named base encoding is a deliberate statement and is always honoured, + // even on Symbol. + let symbolic_builtin = matches!( + font.name(b"BaseFont").map(|b| crate::Std14::from_base_font(&String::from_utf8_lossy(b))), + Some(Some(crate::Std14::Symbol | crate::Std14::ZapfDingbats)) + ); + let table: Option<&[u32; 256]> = match base_name.as_deref() { + Some(b"WinAnsiEncoding") => Some(&crate::encodings::WIN_ANSI), + Some(b"MacRomanEncoding") => Some(&crate::encodings::MAC_ROMAN), + Some(b"StandardEncoding") => Some(&crate::encodings::STANDARD), + // MacExpert is small caps, oldstyle figures and fractions, not Latin at these codes, + // and we hold no table for it either. + Some(b"MacExpertEncoding") => None, + _ if symbolic_builtin => None, + // Standard is the default for a simple font that names no base encoding. + _ => Some(&crate::encodings::STANDARD), }; - for (code, u) in table.iter().enumerate() { + for (code, u) in table.into_iter().flatten().enumerate() { if let Some(c) = char::from_u32(*u).filter(|_| *u != 0) { out.encoding.insert(code as u32, c.to_string()); } @@ -1030,8 +1093,10 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { // Out-of-range codes map nothing a simple font can show. Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX), Object::Name(n) => { - match glyph_unicode(&String::from_utf8_lossy(n)) { - Some(c) => out.encoding.insert(code, c.to_string()), + // A name we cannot read removes any inherited meaning for that code, + // rather than leaving the base encoding's letter standing in for it. + match glyph_text(&String::from_utf8_lossy(n)) { + Some(t) => out.encoding.insert(code, t), None => out.encoding.remove(&code), }; code = code.saturating_add(1); @@ -1580,3 +1645,137 @@ mod tests { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } } + +#[cfg(test)] +mod encoding_tests { + use pdfcraft_cos::{Dict, Document, Object}; + + use super::*; + + /// A simple Type 1 font, optionally symbolic-flagged and optionally with an `/Encoding`. + fn simple(base: &str, flags: Option, enc: Option) -> (Document, Dict) { + let doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + d.set(b"Subtype".to_vec(), Object::name("Type1")); + d.set(b"BaseFont".to_vec(), Object::name(base)); + if let Some(f) = flags { + let mut desc = Dict::new(); + desc.set(b"Flags".to_vec(), Object::Int(f)); + d.set(b"FontDescriptor".to_vec(), Object::Dict(desc)); + } + if let Some(e) = enc { + d.set(b"Encoding".to_vec(), e); + } + (doc, d) + } + + fn differences(items: Vec) -> Object { + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(items)); + Object::Dict(enc) + } + + #[test] + fn glyph_names_follow_the_naming_algorithm() { + // Annex D names and the two hex forms, as before. + assert_eq!(glyph_text("quotedblleft").as_deref(), Some("\u{201c}")); + assert_eq!(glyph_text("uni20AC").as_deref(), Some("\u{20ac}")); + assert_eq!(glyph_text("u1F600").as_deref(), Some("\u{1f600}")); + // A suffix marks a variant of the same character, so the stem still reads. + assert_eq!(glyph_text("a.sc").as_deref(), Some("a")); + assert_eq!(glyph_text("one.oldstyle").as_deref(), Some("1")); + assert_eq!(glyph_text("A.alt017").as_deref(), Some("A")); + // `_` joins components, so a ligature name is more than one character. + assert_eq!(glyph_text("f_i").as_deref(), Some("fi")); + assert_eq!(glyph_text("f_f_l").as_deref(), Some("ffl")); + assert_eq!(glyph_text("a_uni0301").as_deref(), Some("a\u{301}")); + // `uniXXXX` carries several values, so one name can be a whole cluster. + assert_eq!(glyph_text("uni00660069").as_deref(), Some("fi")); + assert_eq!(glyph_text("uni004100300041").as_deref(), Some("A0A")); + // Both forms combine with a suffix. + assert_eq!(glyph_text("uni00660069.alt").as_deref(), Some("fi")); + + // Not names: unknown, empty, a half-read ligature, bad hex, and the `+` that + // `from_str_radix` would otherwise accept. + assert_eq!(glyph_text("nonsense"), None); + assert_eq!(glyph_text(""), None); + assert_eq!(glyph_text(".notdef"), None); + assert_eq!(glyph_text("f_nonsense"), None, "half a ligature is not an answer"); + assert_eq!(glyph_text("uni00"), None, "not a whole four-digit value"); + assert_eq!(glyph_text("uni0041004"), None); + assert_eq!(glyph_text("uni+041"), None, "a sign is not a hex digit"); + assert_eq!(glyph_text("u+0041"), None); + assert_eq!(glyph_text("uniD800"), None, "a lone surrogate half is not a character"); + // Glyph-index names carry no meaning, and must not be guessed at. + assert_eq!(glyph_text("g3"), None); + assert_eq!(glyph_text("index42"), None); + + // `glyph_unicode` is the single-character view of the same algorithm. + assert_eq!(glyph_unicode("uni20AC"), Some('\u{20ac}')); + assert_eq!(glyph_unicode("a.sc"), Some('a')); + assert_eq!(glyph_unicode("f_i"), None, "a ligature is not one character"); + } + + #[test] + fn a_ligature_or_unreadable_name_in_differences_is_handled() { + // A ligature name maps one code to two characters, so text extraction reads "fi". + let (doc, d) = simple("Custom", None, Some(differences(vec![Object::Int(65), Object::name("f_i")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"A"), "fi"); + + // An unreadable name must *remove* the base encoding's letter rather than leave it + // standing in: code 65 is no longer "A", because the font says it is something else. + let (doc, d) = simple("Custom", None, Some(differences(vec![Object::Int(65), Object::name("g7")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(65), None); + assert_eq!(m.text_of(66), Some("B"), "neighbouring codes keep their meaning"); + } + + #[test] + fn symbol_and_dingbats_are_not_read_as_latin() { + // Symbol's built-in encoding puts `alpha` at 0x61. We hold no table for it, so the honest + // answer is none at all -- never "a", which would corrupt find, copy and redaction. + for base in ["Symbol", "ZapfDingbats", "ABCDEF+Symbol"] { + let (doc, d) = simple(base, Some(4), None); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61), None, "{base} must not claim 'a'"); + assert_eq!(m.decode(b"abg"), "", "{base}"); + } + + // MacExpert is small caps and oldstyle figures at these codes, and we hold no table. + let (doc, d) = simple("Times-Roman", None, Some(Object::name("MacExpertEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61), None); + + // /Differences still applies on top of a built-in encoding we cannot read. + let (doc, d) = simple("Symbol", Some(4), Some(differences(vec![Object::Int(0x61), Object::name("uni03B1")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61), Some("\u{3b1}"), "the font told us this one"); + assert_eq!(m.text_of(0x62), None, "and nothing about this one"); + } + + #[test] + fn a_named_base_encoding_is_always_honoured() { + // Naming an encoding is a deliberate statement, so it wins even on Symbol. + for name in ["WinAnsiEncoding", "MacRomanEncoding", "StandardEncoding"] { + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name(name)); + let (doc, d) = simple("Symbol", Some(4), Some(Object::Dict(enc))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61), Some("a"), "{name} was asked for by name"); + } + } + + /// 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 + /// of real documents, so only Symbol and ZapfDingbats -- named, and unambiguous -- suppress it. + #[test] + fn the_symbolic_flag_alone_does_not_suppress_the_latin_default() { + let (doc, d) = simple("SomeSubsetFont", Some(4), None); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61), Some("a")); + assert_eq!(m.decode(b"abc"), "abc"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 74ceae6e90..eeba76118f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5585,7 +5585,7 @@ status = "shipped" commands = ["redact.search"] tools = ["redact_mark"] evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"] -notes = "phone (North American, E.164 with + country code, national with trunk 0), e-mail, credit card (Luhn), SSN, dates (English US forms)" +notes = "phone (North American, E.164 with + country code, national with trunk 0), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded" [[feature]] id = "protect.redact-pattern-locales" From 0a0986483378b57d62103a36490bd66166d32c8f Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 12 Oct 2026 12:57:39 -0700 Subject: [PATCH 11/70] F1, F2: adapt to main and re-pin the Arabic layout golden Rebase onto 7163ba3 (396 commits on). Three adaptations, none of them behavioural: - `crates/fonts` renamed its `arabic` module to `shaping` and added Thai, so `Family::width`'s callers moved; the italic argument is re-applied to all six, including one new call site main added in `draw_laid_shaped`. - `Metrics::text_of` now returns `Cow`, so the F2 tests compare through `as_deref()`. - `unicode_map` writes into a struct field now, so `/Differences` inserts go to `out.encoding`. `SMALL_ITEM_FINGERPRINT` in added/arabic_tests.rs is re-pinned, and this is the part worth reviewing. That golden is the appearance-stream pin F1's acceptance criteria asked for, which landed from the Arabic work after F1 was written; it caught the change correctly. The item's Latin run is "Hi " in Helvetica 12pt. The old character-class guess called H 680, i 260 and space 260 (14.4pt); the published metrics are 722, 222 and 278 (14.664pt), verified against the table. The Arabic clusters that follow start 0.264pt further right, which is the intended effect: the glyphs now land where the font says they do. The Arabic path itself is untouched. Also dropped from the rebase: a re-added ATTRIBUTION entry for vendor/hayro-interpret/assets/LAB.icc, which main deleted in favour of CGATS001Compat-v2-micro.icc. Carrying it would have attributed a file that no longer exists. Co-Authored-By: Claude --- crates/edit/src/added.rs | 6 ++---- crates/edit/src/added/arabic_tests.rs | 7 ++++++- crates/edit/src/tests.rs | 6 +----- crates/fonts/src/pdf.rs | 8 ++++---- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index ad3132fc6f..9f13047b01 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -11,9 +11,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{ - GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi, -}; +use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi}; use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged}; @@ -286,7 +284,7 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))), Piece::Latin(s) => atoms.extend(s.chars().map(|c| { let text = c.to_string(); - let w = t.family.width(&text, t.size, t.bold); + let w = t.family.width(&text, t.size, t.bold, t.italic); (text, w) })), } diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index 4321412ba2..d4ebb73e42 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -8,7 +8,12 @@ use super::*; /// Regression fingerprint for the small-item fixture: drawing bytes, font map and document /// objects, with generated streams decoded so Flate backend selection cannot change the hash. -const SMALL_ITEM_FINGERPRINT: u64 = 4_811_174_621_764_261_623; +// Re-pinned when the standard-14 metrics became exact (F1). The Latin run in the fingerprinted +// item is "Hi " in Helvetica 12pt: the old character-class guess called H 680, i 260 and space +// 260 (14.4pt), where the published metrics are 722, 222 and 278 (14.664pt). The Arabic clusters +// that follow therefore start 0.264pt further right, which is the whole point of F1 -- the glyphs +// now land where the font says they do. Nothing about the Arabic path changed. +const SMALL_ITEM_FINGERPRINT: u64 = 9_200_256_185_091_160_371; /// A cluster that shows one character with the single glyph `id`, at `advance` em. fn cluster(id: u32, advance: f64) -> ShapedCluster { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2bc44b6d90..4bcaef8175 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3251,11 +3251,7 @@ fn an_items_measured_face_is_the_face_it_is_written_as() { let measured = family.std14(bold, italic); assert_eq!(written, measured.base_font(), "{family:?} bold={bold} italic={italic}"); // ... and the public measure reaches that face, rather than an upright stand-in. - assert_eq!( - family.width("Anna", 12.0, bold, italic), - measured.text_width("Anna", 12.0), - "{family:?} bold={bold} italic={italic}" - ); + assert_eq!(family.width("Anna", 12.0, bold, italic), measured.text_width("Anna", 12.0), "{family:?} bold={bold} italic={italic}"); } } } diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index ef20f5814a..cd53ab0b8d 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1729,7 +1729,7 @@ mod encoding_tests { let (doc, d) = simple("Custom", None, Some(differences(vec![Object::Int(65), Object::name("g7")]))); let m = Metrics::from_dict(&doc, &d); assert_eq!(m.text_of(65), None); - assert_eq!(m.text_of(66), Some("B"), "neighbouring codes keep their meaning"); + assert_eq!(m.text_of(66).as_deref(), Some("B"), "neighbouring codes keep their meaning"); } #[test] @@ -1751,7 +1751,7 @@ mod encoding_tests { // /Differences still applies on top of a built-in encoding we cannot read. let (doc, d) = simple("Symbol", Some(4), Some(differences(vec![Object::Int(0x61), Object::name("uni03B1")]))); let m = Metrics::from_dict(&doc, &d); - assert_eq!(m.text_of(0x61), Some("\u{3b1}"), "the font told us this one"); + assert_eq!(m.text_of(0x61).as_deref(), Some("\u{3b1}"), "the font told us this one"); assert_eq!(m.text_of(0x62), None, "and nothing about this one"); } @@ -1763,7 +1763,7 @@ mod encoding_tests { enc.set(b"BaseEncoding".to_vec(), Object::name(name)); let (doc, d) = simple("Symbol", Some(4), Some(Object::Dict(enc))); let m = Metrics::from_dict(&doc, &d); - assert_eq!(m.text_of(0x61), Some("a"), "{name} was asked for by name"); + assert_eq!(m.text_of(0x61).as_deref(), Some("a"), "{name} was asked for by name"); } } @@ -1775,7 +1775,7 @@ mod encoding_tests { fn the_symbolic_flag_alone_does_not_suppress_the_latin_default() { let (doc, d) = simple("SomeSubsetFont", Some(4), None); let m = Metrics::from_dict(&doc, &d); - assert_eq!(m.text_of(0x61), Some("a")); + assert_eq!(m.text_of(0x61).as_deref(), Some("a")); assert_eq!(m.decode(b"abc"), "abc"); } } From 057e453a16c0750ff1c6a784003587f7d2247baa Mon Sep 17 00:00:00 2001 From: Jules Kreuer <25013642+not-a-feature@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:49:57 +0200 Subject: [PATCH 12/70] Retire inherited resources (#1348) * M8: Retire inherited redaction resources * Redaction: pin inherited resources only on redacted pages, shared by reference localize_resources rewrote every page in the document on every apply and copied a direct inherited /Resources dictionary into each one, so a large document grew by N x the dict. Now only redacted pages that inherit resources are pinned; a direct inherited dictionary is made one indirect object and shared by reference. The parent's /Resources is retired when no other page still inherits it (the leak fix), and kept (through the shared reference) when an unredacted page still draws with it. A /Parent chain that can't be followed or disagrees with the page tree retires nothing. Tests: an unredacted page is left untouched and the rewrite is bounded; redacting every inheriting page retires the parent's resources. ROADMAP entry moved into the revision-history table. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/redact/README.md | 5 ++ crates/redact/src/lib.rs | 94 +++++++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 96 ++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- 5 files changed, 197 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 44a26604d8..1ff5c9af70 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -403,6 +403,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. | diff --git a/crates/redact/README.md b/crates/redact/README.md index c156ad0fb6..4a569eaa18 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -36,6 +36,11 @@ leave it unredacted. Structure cleanup resolves named MCIDs and indirect content references. Form content references are updated to the cleaned copy; unchanged sibling descriptions remain. +A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; +a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's +`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed +XObject reachable. Pages that aren't redacted are left untouched. + ## Remove hidden information / Sanitize ```rust diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 7ce4cfd167..15f839691e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -14,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; @@ -272,6 +274,97 @@ fn overlay_content(marks: &[&Mark]) -> Vec { c } +/// The nearest ancestor of `page` that holds `/Resources` (where an inheriting page gets them): +/// `Some(None)` when none does, `None` when the chain can't be followed (a cycle, a broken node or +/// a tree deeper than any real one), so callers can stay conservative. +fn resource_source(doc: &Document, page: ObjRef) -> Option> { + let mut seen = HashSet::new(); + let mut parent = doc.get(page).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); + while let Some(r) = parent { + if !seen.insert(r) || seen.len() > 4096 { + return None; + } + let node = doc.get(r); + let d = node.as_dict()?; + if d.contains(b"Resources") { + return Some(Some(r)); + } + parent = d.get(b"Parent").and_then(Object::as_ref); + } + Some(None) +} + +/// Before rewriting `selected` pages, pin the resources they inherit onto them, and retire the +/// ancestor `/Resources` they came from once no other page still inherits it, so the parent can't +/// keep a removed XObject reachable. Pages that aren't redacted are left untouched; an inherited +/// dictionary that is direct becomes one shared indirect object, never a copy per page. +fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfcraft_cos::CosError> { + let pages = pdfcraft_model::pages(doc); + let inherits = |doc: &Document, p: &pdfcraft_model::Page| doc.get(p.obj).as_dict().is_some_and(|d| !d.contains(b"Resources")); + // Ancestor → the reference its resources are shared through, and whether that reference is new. + let mut shared: HashMap = HashMap::new(); + for page in selected.iter().filter_map(|&i| pages.get(i)) { + // What the page tree gives the page; nothing to pin if it gets nothing. + let Some(effective) = page.dict.get(b"Resources").cloned() else { continue }; + if !inherits(doc, page) { + continue; + } + let source = resource_source(doc, page.obj).flatten(); + let original = source.and_then(|s| doc.get(s).as_dict().and_then(|d| d.get(b"Resources").cloned())); + let value = match source.filter(|_| original.as_ref() == Some(&effective)) { + // `/Parent` agrees with the page tree: share one reference per ancestor. + Some(source) => match shared.get(&source) { + Some((v, _)) => v.clone(), + None => { + // A direct dictionary is made indirect once, so every pinned page shares it. + let (v, new) = match effective { + Object::Ref(_) => (effective, false), + direct => (Object::Ref(doc.add(direct)), true), + }; + shared.insert(source, (v.clone(), new)); + v + } + }, + // A malformed tree whose `/Parent` disagrees: pin this page alone and retire nothing. + None => match effective { + Object::Ref(_) => effective, + direct => Object::Ref(doc.add(direct)), + }, + }; + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), value))?; + } + if shared.is_empty() { + return Ok(()); + } + // Ancestors some page still inherits from keep their resources (that page draws them), through + // the shared reference; the others are retired. A page that inherits resources through a chain + // that can't be followed might inherit from any of them, so then none is retired. + let mut still_used: HashSet = HashSet::new(); + for page in pages.iter().filter(|p| p.dict.contains(b"Resources") && inherits(doc, p)) { + match resource_source(doc, page.obj) { + Some(Some(source)) => { + if shared.contains_key(&source) { + still_used.insert(source); + } + } + _ => still_used.extend(shared.keys().copied()), + } + } + for (source, (value, new)) in shared { + if still_used.contains(&source) { + if !new { + continue; + } + doc.update_dict(source, |d| d.set(b"Resources".to_vec(), value))?; + } else { + doc.update_dict(source, |d| { + d.remove(b"Resources"); + })?; + } + } + Ok(()) +} + /// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved /// file; callers keep the previous document for undo. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { @@ -293,6 +386,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); let widget_owner: Vec<(ObjRef, String)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 997e6f45a1..81b3b328c0 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -726,3 +726,99 @@ fn form_tags_are_cleaned_and_surviving_references_point_at_the_copy() { assert!(String::from_utf8(s.decoded().unwrap()).unwrap().contains("Public")); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText")); } + +#[test] +fn inherited_resources_do_not_keep_the_original_form() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (LEAK_INHERITED) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().clone(); + doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); +} + +#[test] +fn unselected_pages_keep_inherited_forms() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, Some(&[0])).unwrap(); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +/// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. +fn inheriting_pages(n: usize) -> (Document, Vec) { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let mut kids = vec![ObjRef::new(3, 0)]; + kids.extend((1..n).map(|_| doc.add(Object::Dict(page.clone())))); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())); + d.set(b"Count".to_vec(), Object::Int(n as i64)); + }) + .unwrap(); + let saved = reopen(&doc); + (saved, kids) +} + +#[test] +fn redacting_one_page_leaves_the_other_pages_untouched() { + let (mut doc, kids) = inheriting_pages(50); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + let before = doc.modified_objects(); + apply(&mut doc, Some(&[0])).unwrap(); + let changed: Vec = doc.modified_objects().into_iter().filter(|n| !before.contains(n)).collect(); + for kid in &kids[1..] { + assert!(!changed.contains(&kid.num), "unredacted page {kid:?} was rewritten"); + } + // The page, its new content and resources, the cleaned form, the parent and one shared + // resources object: not one object per page. + assert!(changed.len() < 10, "{changed:?}"); + // The parent keeps the resources the other pages draw with, through a single shared object. + let parent = doc.get(ObjRef::new(2, 0)).as_dict().unwrap().clone(); + assert!(parent.get(b"Resources").and_then(Object::as_ref).is_some()); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 49, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +#[test] +fn redacting_every_inheriting_page_retires_the_parent_resources() { + let (mut doc, kids) = inheriting_pages(20); + for page in 0..kids.len() { + mark(&mut doc, page, &[[0.0, 190.0, 300.0, 220.0]], ""); + } + apply(&mut doc, None).unwrap(); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + for page in 0..kids.len() { + assert!(pdfcraft_model::pages(&saved)[page].dict.contains(b"Resources")); + assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c6d39c9b1..b483b336d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -93,7 +93,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | From fcc1d9ead0f4c850a8c71637a90a92aa1f871004 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:59:51 +0200 Subject: [PATCH 13/70] M2: adapt Lab Bradford white points in LMS (#1417) Follow up #1237 by deriving Bradford scaling ratios from the source and D65 white points in LMS. Preserve CalRGB and the merged Indexed/Range handling. Add independent neutral/chromatic, identity, asymmetric Range, and degenerate-cone render regressions. Co-authored-by: Codex --- ROADMAP.md | 1 + crates/render/src/lab_bradford_tests.rs | 113 ++++++++++++++++++++++++ crates/render/src/lib.rs | 3 + docs/roadmap.md | 2 +- vendor/README.md | 7 ++ vendor/hayro-interpret/src/color.rs | 23 ++++- 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 crates/render/src/lab_bradford_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1ff5c9af70..78ad856a9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. +- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged. - **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged. - **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged. - **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. diff --git a/crates/render/src/lab_bradford_tests.rs b/crates/render/src/lab_bradford_tests.rs new file mode 100644 index 0000000000..7ab1e7ed34 --- /dev/null +++ b/crates/render/src/lab_bradford_tests.rs @@ -0,0 +1,113 @@ +//! Independent flat-patch oracles for the Lab Bradford follow-up to #1237. +use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +// These frozen RGB8 values were computed outside the renderer in f64 from the +// CIE Lab inverse, the full XYZ adaptation matrix B^-1 diag(B*D65 / B*white) B, +// and the standard sRGB matrix/transfer function. No production helper is used. +// Bradford reference: https://www.color.org/chadtag/ (ICC TN 02-2003). +// White points: D50 [0.96422, 1, 0.82521], E [1, 1, 1], +// D65 [0.95047, 1, 1.08883]. D65 adaptation is the identity. +const SAMPLES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [40.0, 20.0, -60.0], [70.0, -50.0, 30.0], [100.0, 0.0, 0.0]]; +const D50: [[u8; 3]; 4] = [[119, 119, 119], [67, 86, 194], [66, 194, 113], [255, 255, 255]]; +const E: [[u8; 3]; 4] = [[119, 119, 119], [50, 88, 194], [64, 194, 114], [255, 255, 255]]; +const D65: [[u8; 3]; 4] = [[119, 119, 119], [19, 90, 194], [77, 193, 114], [255, 255, 255]]; + +fn patch(space: &str, operands: &str) -> [u8; 4] { + let content = format!("/C cs {operands} sc 0 0 20 20 re f"); + let bytes = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{space} {operands}: {:?}", page.error); + assert_eq!((page.width, page.height), (20, 20)); + page.rgba.get((10 * 20 + 10) * 4..(10 * 20 + 10) * 4 + 4).unwrap().try_into().unwrap() +} + +fn assert_rgb(actual: [u8; 4], expected: [u8; 3], context: &str) { + assert_eq!(actual[3], 255, "{context}: {actual:?}"); + for (got, want) in actual.into_iter().zip(expected) { + assert!(got.abs_diff(want) <= 1, "{context}: got {actual:?}, expected {expected:?}"); + } +} + +fn check_white(white: &str, expected: [[u8; 3]; 4]) { + let space = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + for (sample, expected) in SAMPLES.into_iter().zip(expected) { + let operands = format!("{} {} {}", sample[0], sample[1], sample[2]); + assert_rgb(patch(&space, &operands), expected, &format!("{white} Lab {sample:?}")); + } +} + +#[test] +fn lab_bradford_d50_neutral_and_chromatic_oracles() { + check_white("[0.96422 1 0.82521]", D50); +} + +#[test] +fn lab_bradford_equal_energy_neutral_and_chromatic_oracles() { + check_white("[1 1 1]", E); +} + +#[test] +fn lab_bradford_d65_identity_oracles() { + check_white("[0.95047 1 1.08883]", D65); +} + +#[test] +fn lab_bradford_indexed_matches_direct_and_oracle() { + for (white, blue) in [("[0.96422 1 0.82521]", D50[1]), ("[1 1 1]", E[1]), ("[0.95047 1 1.08883]", D65[1])] { + // <66 99 33> maps exactly to L*=40, a*=20, b*=-60. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <669933>]"), "0"); + assert_rgb(indexed, blue, &format!("{white} Indexed blue")); + assert_eq!(indexed, patch(&lab, "40 20 -60"), "{white} direct/Indexed blue"); + + // A non-default Range maps <80 80 80> to L*=128/255*100, a*=b*=0. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-128 127 -128 127] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <808080>]"), "0"); + assert_rgb(indexed, [119, 119, 119], &format!("{white} Indexed neutral")); + assert_eq!(indexed, patch(&lab, "50.19607843137255 0 0"), "{white} direct/Indexed neutral"); + } +} + +#[test] +fn lab_bradford_degenerate_cones_fall_back_to_unadapted_xyz() { + // In f32, B*[1, 1, 7.1964064] has a zero L cone response. + // B*[3e38, 3e38, 3e38] overflows its L/M responses. The guard must + // return the sample XYZ in both cases. Independent unadapted XYZ -> + // sRGB references for Lab (50, 0, 0) are frozen below. + for (white, expected) in [ + ("[1 1 7.1964064]", [0, 130, 255]), + ( + "[300000000000000000000000000000000000000 300000000000000000000000000000000000000 300000000000000000000000000000000000000]", + [255, 255, 255], + ), + ] { + let space = format!("[/Lab << /WhitePoint {white} >>]"); + assert_rgb(patch(&space, "50 0 0"), expected, &format!("{white} degenerate cones")); + } +} + +#[test] +fn lab_bradford_asymmetric_chromatic_range_matches_direct_and_oracle() { + // With a* [-40, 70] and b* [-90, 20], <80 99 33> maps to + // L*=128/255*100, a*=26, b*=-68. Frozen independent D50 oracle: + // RGB (98, 108, 237). The unequal, asymmetric ranges expose swaps + // and default-Range scaling that a neutral lookup cannot detect. + let lab = "[/Lab << /WhitePoint [0.96422 1 0.82521] /Range [-40 70 -90 20] >>]"; + let indexed = patch(&format!("[/Indexed {lab} 0 <809933>]"), "0"); + assert_rgb(indexed, [98, 108, 237], "D50 asymmetric chromatic Range"); + assert_eq!(indexed, patch(lab, "50.19607843137255 26 -68")); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d2ee5712c4..ae146779ff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -39,3 +39,6 @@ pub enum OpenError { #[error("{0}")] Unsupported(String), } + +#[cfg(test)] +mod lab_bradford_tests; diff --git a/docs/roadmap.md b/docs/roadmap.md index b483b336d7..42998b70fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,7 +81,7 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | diff --git a/vendor/README.md b/vendor/README.md index 94109f4438..8fb270f587 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -32,3 +32,10 @@ Later rendering errors, device loss, and non-winit renderer construction are unc Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`. Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). + +Lab Bradford follow-up to #1237: Lab adapts XYZ to D65 with white-point ratios +computed in Bradford LMS coordinates. CalRGB's existing flat-white and +black-point normalization is unchanged. `lab_bradford_tests` checks independent +D50/equal-energy neutral and chromatic oracles, D65 identity, and direct/Indexed +consistency (including an asymmetric chromatic Range), and zero/overflowing +cone-response fallback. diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 3be29d19d5..dd2edf241d 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -678,6 +678,27 @@ impl Lab { } } + /// Bradford adaptation scales cone responses, so both white points must + /// be expressed in LMS before taking their ratios. CalRGB's separate + /// flat-white/black-point pipeline retains its existing normalization. + fn adapt_to_d65(white_point: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] { + let source_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, white_point); + // A malformed but positive XYZ white can still yield a zero or + // overflowing cone response. Leave it unadapted rather than divide by + // zero or propagate a non-finite scale. + if source_lms.iter().any(|v| !v.is_finite() || v.abs() <= f32::EPSILON) { + return *xyz; + } + let destination_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, &CalRgb::D65_WHITEPOINT); + let lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, xyz); + let adapted = [ + lms[0] * destination_lms[0] / source_lms[0], + lms[1] * destination_lms[1] / source_lms[1], + lms[2] * destination_lms[2] / source_lms[2], + ]; + CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_INVERSE_MATRIX, &adapted) + } + /// Inverse of the CIE f(t) helper that maps relative fX/fY/fZ to absolute /// XYZ as a ratio of the white point. fn inverse_lab_f(t: f32) -> f32 { @@ -726,7 +747,7 @@ impl ToRgb for Lab { zw * Self::inverse_lab_f(fz), ]; - let xyz_d65 = CalRgb::normalize_white_point_to_d65(&white_point, &xyz); + let xyz_d65 = Self::adapt_to_d65(&white_point, &xyz); let srgb = CalRgb::matrix_product(&CalRgb::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65); output.copy_from_slice(&[ From c6128c039ac9f840e8c989650c4b90652f62b3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Bartalos?= Date: Tue, 13 Oct 2026 06:01:50 +0200 Subject: [PATCH 14/70] M7: keep a neighbouring line when replacing text (#1316) (#1427) Glyph boxes of tightly spaced lines overlap, so replacement treated the next line as a duplicate and removed it. A second drawing is removed only when it also shares the edited line's baseline. --- ROADMAP.md | 2 ++ crates/edit/src/tests.rs | 48 ++++++++++++++++++++++++++- crates/edit/src/text.rs | 58 +++++++++++++++++++++------------ crates/edit/src/text_regions.rs | 3 +- docs/gaps.md | 6 +++- docs/roadmap.md | 4 ++- 6 files changed, 95 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 78ad856a9a..1f774cd566 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; 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 (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -141,6 +142,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4bcaef8175..f5b37776b3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, SaveOptions, write_incremental}; +use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use super::*; @@ -1824,6 +1824,52 @@ fn a_line_drawn_twice_is_replaced_everywhere() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["Hello there", "world"]); } +#[test] +fn replacing_a_line_keeps_a_distinct_nearby_line() { + // 12 pt lines 6 pt apart: the glyph boxes overlap by more than half the smaller box, + // but the baselines are different lines (#1316). + let src = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Beta) Tj ET"; + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + for paragraph in [false, true] { + let mut doc = text_page(src); + assert_eq!(listed(&doc), ["Alpha", "Beta"]); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + if paragraph { + text::rewrite_block(&mut doc, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + } else { + text::replace_line(&mut doc, 0, 0, "ALPHA").unwrap(); + } + assert_eq!(listed(&doc), ["ALPHA", "Beta"]); + assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("(Beta)")); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let back = Document::open(Arc::new(bytes)).unwrap(); + assert!(back.repair_log().is_empty()); + assert_eq!(listed(&back), ["ALPHA", "Beta"]); + } + } + // The same word on the next line is still a different line. + let mut same = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Alpha) Tj ET"); + text::replace_line(&mut same, 0, 0, "ALPHA").unwrap(); + assert_eq!(listed(&same), ["ALPHA", "Alpha"]); + // Ordinary leading, and a paragraph far enough to group apart, keep the neighbour. + let mut spaced = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -14.4 Td (Beta) Tj ET"); + text::replace_line(&mut spaced, 0, 0, "ALPHA").unwrap(); + assert_eq!(listed(&spaced), ["ALPHA", "Beta"]); + let mut apart = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -40 Td (Beta) Tj ET"); + text::rewrite_block(&mut apart, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&apart), ["ALPHA", "Beta"]); + // A second copy at the same position is still one replacement. + for paragraph in [false, true] { + let mut duplicate = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET"); + if paragraph { + text::rewrite_block(&mut duplicate, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + } else { + text::replace_line(&mut duplicate, 0, 0, "ALPHA").unwrap(); + } + assert_eq!(listed(&duplicate), ["ALPHA"]); + } +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index cb8b93edfa..981f3c0d0a 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1471,8 +1471,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the - // line drawn in the same area go too, so the replacement is all that shows. - let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect)); + // line drawn on the same baseline go too, so the replacement is all that shows. + let drops = coincident_ops(&lines, &[&target]); let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1493,27 +1493,44 @@ fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) } -/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes -/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would -/// show the old text under the replaced line. Copies overlap a member's rect by more than half of -/// the smaller rect; neighbouring lines share no area. -fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet { +/// Operators of every line that is another drawing of one of `targets` (fake bold, an invisible +/// text layer). A surviving copy would show the old text under the replacement. +/// +/// A copy overlaps a target by more than half the smaller box and sits on the same baseline +/// (within the tolerance that joins showing operators onto one line). A neighbouring line can +/// share box area when the leading is tighter than the font's ascent plus descent; its baseline +/// is farther away, so it stays. +fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections::HashSet { let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); - for l in lines { - let covered = rects.iter().any(|m| { - let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0); - let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0); - let small = area(*m).min(area(l.rect)); - small > 0.0 && ix * iy / small > 0.5 - }); - if covered { - drop.extend(l.ops.iter().copied()); + for line in lines { + let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + if copy { + drop.extend(line.ops.iter().copied()); } } drop } +fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bool { + let ix = (b[2].min(a[2]) - b[0].max(a[0])).max(0.0); + let iy = (b[3].min(a[3]) - b[1].max(a[1])).max(0.0); + let small = area(b).min(area(a)); + small > 0.0 && ix * iy / small > 0.5 +} + +/// Perpendicular distance between baselines, in user space. Horizontal text compares height; +/// turned text compares the distance across the writing direction. +fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let angle = target.rotation().to_radians(); + let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; + let dx = line.origin.x - target.origin.x; + let dy = line.origin.baseline - target.origin.baseline; + let offset = dx.mul_add(-sin, dy * cos).abs(); + let size = line.size.min(target.size).max(1.0); + offset.is_finite() && offset < size * 0.3 +} + /// The paragraphs on a page (0-based). pub fn text_blocks(doc: &Document, page: usize) -> Result, EditError> { let lines = text_lines(doc, page)?; @@ -1527,8 +1544,8 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?; - let rects: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).collect(); - let mut drops = coincident_ops(&lines, &rects); + let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let mut drops = coincident_ops(&lines, &targets); drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); @@ -1917,10 +1934,9 @@ pub(crate) fn rewrite_block_with_limit( block_ops.push(Op::new("Q", vec![])); } block_ops.push(Op::new("Q", vec![])); - // The paragraph's operators go, and so does anything drawn in the same area (a fake-bold + // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold // second copy, an invisible text layer): the new text is all that may show. - let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect(); - let mut drop = coincident_ops(&lines, &member_rects); + let mut drop = coincident_ops(&lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); // Text shown earlier in the same text object stays first: the paragraph then goes where it // was, between two halves of the text object, so it keeps its place in reading order. diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 58749afe9c..0213d9040b 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -696,8 +696,7 @@ pub(super) fn rewrite_styled_block( let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let rects: Vec<_> = members.iter().map(|l| l.rect).collect(); - let mut drop = coincident_ops(lines, &rects); + let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); let o = &first.origin; let start = first.ops.first().copied().unwrap_or(o.bt_op); diff --git a/docs/gaps.md b/docs/gaps.md index be1000b6a5..0e8b583ded 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (#1316: replacing a line keeps a neighbouring line whose glyph box overlaps; gap 3 remains) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** gap 4 closed: the web build prints through the browser's print dialog; exclusive image-crop resize follow-up; minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 13: large-file memory measured, edits keep two copies instead of three, file-backed input blocked on gap 2) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -87,7 +88,9 @@ update the numbers. and single-face OpenType shaping/bidi have regression coverage. Actual Chinese keyboard insertion/deletion and save/reopen renders are checked on all four private pages. Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable - semantic grouping on every untagged PDF remain open. Estimates are unchanged. + semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer + removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same + baseline is still replaced. Estimates are unchanged. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping @@ -312,6 +315,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps. Gap 3 remains. Estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-12 | minor | Gap 13: measured large-file memory; edits no longer keep the previous working file while writing the next (peak 3 → 2 copies); file-backed input recorded as blocked on gap 2 | | 2026-10-11 | minor | Closed gap 4: the web build prints through the browser's print dialog (sheets drawn by PdfCraft at 150–300 dpi, one per printed page); number kept | diff --git a/docs/roadmap.md b/docs/roadmap.md index 42998b70fb..32567e34b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; 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 (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; 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 (M7: preserve Arabic ActualText typing order; earlier: M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -88,7 +89,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | @@ -145,6 +146,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. | | 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. | | 2026-10-12 | minor | M12: bounded text-only auto-tag preview through the engine and automation; actual PDF structure writing and visual review remain open. Estimates unchanged. | | 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. | From 0d300c6a41a4c2edae02af43e76cfd6be7bc11d0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:03:47 +0200 Subject: [PATCH 15/70] fix(render): Type 3 text ignores rendering modes other than invisible (vendored hayro-interpret patch 27) (fixes #1469) (#1478) --- crates/render/src/raster.rs | 42 ++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/text.rs | 11 ++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 0e64ac241e..c5145abc1d 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -5885,4 +5885,46 @@ trailer << /Root 1 0 R >> assert!([(5, 5), (50, 50), (95, 95)].iter().all(|(x, y)| px(&p, *x, *y) == red), "{content}: no stray clip"); } } + + /// ISO 32000-1 §9.3.6: only text rendering mode 3 has any effect on a Type 3 font (vendored + /// hayro-interpret patch in `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap + /// fonts, and the glyph procedure ran twice (fill, then stroke), so its anti-aliased edges + /// doubled up and the text looked bold; under `1 Tr` an uncoloured glyph took the stroking + /// colour, and under `7 Tr` it disappeared. Both glyph kinds have edges between pixels. + #[test] + fn text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled() { + let render = |glyph: &str, mode: u8| { + let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) Tj ET"); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] + /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj +6 0 obj << /Length {} >> stream +{glyph} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{glyph} {mode} Tr: {:?}", p.error); + p.rgba + }; + for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] { + let filled = render(glyph, 0); + assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints"); + for mode in [1, 2, 4, 5, 6, 7] { + assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr"); + } + assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph"); + } + } } diff --git a/vendor/README.md b/vendor/README.md index 8fb270f587..bfbfb89097 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,7 +7,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | 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 ş | (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` +| 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 | (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` | 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` | (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` | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`) | diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index a631ba902d..ddec0400ea 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -69,7 +69,16 @@ pub(crate) fn show_glyph<'a>( device.set_blend_mode(ctx.get().graphics_state.blend_mode); let stroke_props = ctx.stroke_props(); - match ctx.get().text_state.render_mode { + // PdfCraft patch: only mode 3 has any effect on text in a Type 3 font (ISO 32000-1 + // 9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts + // look bold, and the clip modes have no outline to clip with. + let render_mode = match (glyph, ctx.get().text_state.render_mode) { + (Glyph::Type3(_), TextRenderingMode::Invisible) => TextRenderingMode::Invisible, + (Glyph::Type3(_), _) => TextRenderingMode::Fill, + (_, mode) => mode, + }; + + match render_mode { TextRenderingMode::Fill => { device.draw_glyph( glyph, From 68f8b2d5f78f583356c6cd4000567e4b261c3d7a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:03:55 +0200 Subject: [PATCH 16/70] fix(edit): flattening form fields also drops the ones whose widgets are on no page (fixes #1470) (#1480) --- crates/edit/src/flatten.rs | 32 ++++++++++++++++++++++++-------- crates/edit/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 92cc56d366..7b1865ca80 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -249,33 +249,49 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = page_list(doc) + .iter() + .filter_map(|p| p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())) + .flatten() + .filter_map(|e| e.as_ref()) + .collect(); + prune_fields(doc, &removed_widgets, orphans.then_some(&on_page))?; } Ok(drawn) } -/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. -fn prune_fields(doc: &mut Document, widgets: &HashSet) -> Result<(), EditError> { +/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With +/// `on_page`, also the widgets (fields without kids) that are on no page. +fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<&HashSet>) -> Result<(), EditError> { let Some(root) = doc.root() else { return Ok(()) }; let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; let af_ref = af.as_ref(); let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default(); let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); // Returns whether the node survives. - fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, depth: usize) -> bool { + fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, on_page: Option<&HashSet>, depth: usize) -> bool { if widgets.contains(&r) || depth > 64 { return depth > 64; } let Some(d) = doc.get(r).as_dict().cloned() else { return true }; - let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { return true }; - let survivors: Vec = kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, depth + 1))).cloned().collect(); + let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { + return on_page.is_none_or(|p| p.contains(&r)); + }; + let survivors: Vec = + kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect(); if survivors.len() != kids.len() { let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone()))); } !survivors.is_empty() } - let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect(); + let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect(); + if survivors.len() == fields.len() && widgets.is_empty() { + return Ok(()); // only looked for fields on no page, and there were none + } af_dict.set(b"Fields".to_vec(), Object::Array(survivors)); match af_ref { Some(r) => doc.set(r, Object::Dict(af_dict)), diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f5b37776b3..2ec5f2f495 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -234,6 +234,34 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// #1470: a form field whose widget is on no page (left behind by page extraction or combining) +/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`, +/// or other software keeps treating the flattened file as a form. A hidden widget on a page is +/// left alone, as before. +#[test] +fn flattening_fields_drops_the_ones_on_no_page() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (on_page) /Rect [10 10 150 40] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 /AP << /N 10 0 R >> >>", + "<< /FT /Tx /T (parent) /Kids [9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field on the page is drawn"); + let doc = reopen(&doc); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()); + let left: Vec = fields.as_array().unwrap().iter().filter_map(|f| f.as_ref()).collect(); + assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays"); +} + /// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an /// appearance whose text Helvetica can't draw (another program's), and a comment or form field /// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew From 6ed78b9133d7ac879a0cebedf1d99be96707d458 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:04:05 +0200 Subject: [PATCH 17/70] fix: allow links beneath highlight annotations (#1387) (#1481) AI-assisted with OpenAI. Co-authored-by: FredApps --- crates/ui-egui/src/comments.rs | 11 ++ crates/ui-egui/tests/document_links.rs | 186 +++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 623f89b62e..9b07394576 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -931,7 +931,18 @@ fn select_input( // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { + let follow_link = cx.tool == QuickTool::Select + && !consumed + && cv.gesture.is_none() + && !resp.double_clicked() + && pointer + .is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p))); match pointer.and_then(|p| cx.hit(p)) { + Some(a) if a.subtype == "Highlight" && follow_link => { + // A plain click follows the link beneath text highlighting. Keep other + // annotations and active gestures in control of their own input. + cv.selected = None; + } Some(a) => { cv.selected = Some((cx.page, a.index)); cv.reveal = true; diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs index 0ee9def78d..89d57a8279 100644 --- a/crates/ui-egui/tests/document_links.rs +++ b/crates/ui-egui/tests/document_links.rs @@ -390,3 +390,189 @@ fn a_numeric_host_is_shown_as_the_address_it_goes_to() { h.get_by_label("192.168.1.1"); assert!(h.query_by_label_contains("alphabet").is_none()); } + +fn overlapping_annotation_fixture(subtype: &str, internal: bool) -> Vec { + overlapping_annotation_fixture_with_form(subtype, internal, false) +} + +fn overlapping_annotation_fixture_with_form(subtype: &str, internal: bool, with_form: bool) -> Vec { + let target = if internal { "/Dest [6 0 R /Fit]".to_owned() } else { format!("/A << /S /URI /URI ({LINK_URL}) >>") }; + let mut objs = vec![ + if with_form { + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_owned() + } else { + "<< /Type /Catalog /Pages 2 0 R >>".to_owned() + }, + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 /MediaBox [0 0 300 400] >>".to_owned(), + if with_form { + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 7 0 R] >>".to_owned() + } else { + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>".to_owned() + }, + format!("<< /Type /Annot /Subtype /Link /Rect [100 300 250 350] /Border [0 0 0] {target} >>"), + format!( + "<< /Type /Annot /Subtype /{subtype} /Rect [30 300 200 350] /QuadPoints [30 350 200 350 30 300 200 300] /C [1 1 0] /Contents (Review this text) /F 4 >>" + ), + "<< /Type /Page /Parent 2 0 R >>".to_owned(), + ]; + if with_form { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (overlap-button) /Rect [100 300 200 350] /P 3 0 R /A << /S /URI /URI ({BUTTON_URL}) >> >>")); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn overlapping_annotation_harness(bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("highlighted-link.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(8); + h +} + +#[test] +fn highlighted_link_still_requires_confirmation() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link)); + assert_eq!(h.state().last_opened_url, None); + assert_eq!(h.state().views[0].comments.selected, None); + h.get_by_label("Open link").click(); + h.run_steps(3); + assert_eq!(h.state().last_opened_url.as_deref(), Some(LINK_URL)); + assert_eq!(h.state().pending_link, None); + h.run_steps(3); + assert_eq!(h.state().pending_link, None, "approval must not create another prompt"); +} + +#[test] +fn highlighted_link_cancel_and_escape_do_not_open_url() { + let _gpu = gpu(); + for escape in [false, true] { + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link)); + if escape { + h.key_press(egui::Key::Escape); + } else { + h.get_by_label("Cancel").click(); + } + h.run_steps(3); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); + } +} + +#[test] +fn highlighted_internal_link_navigates_after_save_and_reopen() { + let _gpu = gpu(); + let bytes = overlapping_annotation_fixture("Highlight", true); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("highlighted-link.pdf", None, std::sync::Arc::new(bytes), None).unwrap(); + let saved = session.save_bytes(id).unwrap().as_ref().clone(); + let mut h = overlapping_annotation_harness(saved); + assert_eq!(h.state().views[0].current, 0); + click_link(&mut h); + assert_eq!(h.state().views[0].current, 1); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn highlight_outside_link_remains_selectable() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + let rect = h.state().views[0].page_screen_rect(0).unwrap(); + let at = egui::pos2(rect.min.x + rect.width() * (60.0 / 300.0), rect.min.y + rect.height() * (1.0 - 325.0 / 400.0)); + click_at(&mut h, at); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1))); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn other_annotations_over_links_keep_select_click() { + let _gpu = gpu(); + for subtype in ["Text", "Square"] { + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture(subtype, false)); + click_link(&mut h); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "{subtype}"); + assert_eq!(h.state().pending_link, None, "{subtype}"); + assert_eq!(h.state().last_opened_url, None, "{subtype}"); + } +} + +#[test] +fn form_widget_keeps_priority_over_highlighted_link() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture_with_form("Highlight", false, true)); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(BUTTON_URL, LinkOrigin::Button)); + assert_eq!(h.state().last_opened_url, None); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn active_comment_gesture_keeps_highlighted_link_click() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 1, from: at }); + click_at(&mut h, at); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn highlighted_link_context_click_selects_comment_without_opening_url() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1))); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn highlighted_link_can_be_requested_again_after_cancel() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + for _ in 0..2 { + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link)); + assert_eq!(h.state().last_opened_url, None); + h.get_by_label("Cancel").click(); + h.run_steps(120); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); + } +} From ecc4a305d747659f785e34c18307b74fe0b0314b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Tue, 13 Oct 2026 06:04:16 +0200 Subject: [PATCH 18/70] M12: move tags in the order panel (#1482) --- ATTRIBUTION.toml | 42 +++++++++++++-------------- crates/ui-egui/src/i18n/ar.tsv | 2 ++ crates/ui-egui/src/i18n/bg.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/kk.tsv | 2 ++ crates/ui-egui/src/i18n/lt.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sl.tsv | 2 ++ crates/ui-egui/src/i18n/te.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/tags_ui.rs | 24 +++++++++++++-- crates/ui-egui/tests/accessibility.rs | 21 ++++++++++++++ parity/acrobat-features.toml | 10 +++---- 25 files changed, 111 insertions(+), 28 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3b08e84401..5c57d5829b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e04ab5246125efae2bad439e62c150fc10649c4b6796b30565615f5bd7509ec6" +sha256 = "9e72de3704f5faf040d951d39f11e32536c40572cb6af4a2c8456c20d6189b81" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7f600eaa47f23fac4f9f0fece1454e59756c8dc57c7c697502cb02ee21410ac0" +sha256 = "bf481bf7dc5b7bbd51541c5a8c3b2fbeaf545b313bd60070993f057f5b6da7f0" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "37733adc79d7d32a057811b4dc5eb31251ef8ec91f1dac430253c69faac1c33f" +sha256 = "d07268fcab301a296e6e1c56a691331485cbdee92d8082f125df257fbd808ad1" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "285b689b5224a66079738ab39d5a27e5f9ba72e1ab42e148f263b7ea320c4729" +sha256 = "1b433cb74952dd5eb94e0ea685d6e8c0c715ce9609005483b832ff5c549c4623" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e8154a154129ca158396de84d2d432ce1c587477795a6a14985f7834db255744" +sha256 = "a4d149e4a890a2846d18e9193b45f2b40c2b8cc9743ffef6201acdd1aef4c0ca" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "50d4e66209e8b56707bcd148d4644eb0ad9f49dfc195287cfbd851ffbe5c3830" +sha256 = "aab612c6b21b71f45b09e430a046a5d57d3572b20378caad99e960e0847834f9" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "13e677d44d5224e60af28fcc89d8f8af552368ca3c11f0a871498c60fefa7ee2" +sha256 = "b2cd5d1269a160bc9a02d9a8fc8e7bb0819b40101905ca5552066719134b85f1" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e81112d69acc2e1bbb44c5252f8f323b1d637059b5ac5f4c9fcd3eb985a53442" +sha256 = "35f71ba25148c4d187ef5b1fea0c4ad30d9eac8f6634fdad92cce0328584581f" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "df3d3650af984251372bf22b859ba6aca47f26eeff790e834d57a5a4071a1b48" +sha256 = "162544e5cc09a98c3c3ce957223bca08a09f452a0e1ef25192a2ee07a0cec29d" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c9e453382d9d5c83f7b0f986befc6113a62b4736a6b94a6462805cead406753b" +sha256 = "92c062f32c318875153bebd78afef94528ed7a76c074669325e58755e3494e6e" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2a205868b13f719dbc91fdc50f1133f5f317aa2245143bfeaa048190bc9c7d8c" +sha256 = "3a39b915005b8139e0505e09484d3532d4d2b3fe57629a8b691397097507c5f3" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3053aff461c289404bf3009c611037870b945692c65ce8d7c14ad26ac477660c" +sha256 = "d6dea9f180e65bc80219c36bf58f3017d71f7419bac97b554946c5d0048eef90" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "882b3937802bbbd5afe0087264ee4118161351e8828cb02b608488ccfe39bfe9" +sha256 = "026deb06c481650dafcb5237e88f6b60c2ae11df0d6f998727225e3a10fae5aa" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8cca054e167995ca6d95bc45fc0f75385a15a78852e4b173bd4c5b7b651c5a9d" +sha256 = "2f479cac837466414590f8c4ca5e3d4ff28b45890a60421a3ed6d176bbe8a545" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2f4a6429c0c290a53268733dac08da221085992036a4d304d7663d7947d806d3" +sha256 = "a3245989fecc7f8f0adcc19f4290744d291860c175ea8cfb3defa513e6e4708a" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2792f38b1699269b5886fa99a17c7773c3934df95dc4e2db3ba6a475dd652ba0" +sha256 = "7f6558c2fac98c05cc67d16d470d3f64ecf121261a46c42476cc083830472a3a" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "937ac7eac4230c9a1d26d6bd7ef4f08f8d8c9d391a091682e8758fba3ca9678e" +sha256 = "fb6ff20dc580c7e8fd456fce9857a55a1f2bd9ad560ec9aff886e071040a38fa" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7ced08c3485c5eddf92767e6deb009d72351d70cefe60be654cfdac441f40f4b" +sha256 = "fa96cddb9e789d1af12bc0371fd0757c5cbc068884dde19de82d0beeef07ac90" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1" +sha256 = "818aadf33298dda57fc82326ade9d05219a04b1ce2ba38ba9af3c47290e341be" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ 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 = "d2e6be7cf74525310a203bed7434e7f69d2288d337ee696de2882df8da31a746" +sha256 = "60b0f9f46417ec7b164b14269a17d63a88c1419002ebc3942fb3412a47c9cb7d" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3351,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6c01a439220415b83acab79c014286e5871ba7946a9c3b9a4776be797d55a086" +sha256 = "228b1032b114d08aa967cc0935c97225f8bd3d95bfa1d64ba71dc99fa1fd470e" diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8163ee30ad..d8d76205fe 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2359,3 +2359,5 @@ animation effects Off معطّلة The linked PDF opened, but its named destination was not applied. فُتح ملف PDF المرتبط، لكن لم تُطبَّق وجهته المسماة. The linked PDF opened, but its page destination was out of range. فُتح ملف PDF المرتبط، لكن وجهة الصفحة خارج النطاق. Local cross-PDF links require the desktop app تتطلب الروابط المحلية بين ملفات PDF تطبيق سطح المكتب + Move tag earlier نقل العلامة للأعلى + Move tag later نقل العلامة للأسفل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index febd1f9cec..1355472bcb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2301,3 +2301,5 @@ animation effects Off Изключени The linked PDF opened, but its named destination was not applied. Свързаният PDF се отвори, но именуваната му цел не беше приложена. The linked PDF opened, but its page destination was out of range. Свързаният PDF се отвори, но целевата му страница е извън обхвата. Local cross-PDF links require the desktop app Локалните връзки между PDF файлове изискват настолното приложение + Move tag earlier Преместване на етикета по-рано + Move tag later Преместване на етикета по-късно diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ee4f9bcb96..7847274817 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2412,3 +2412,5 @@ animation effects Off Vypnuto The linked PDF opened, but its named destination was not applied. Odkazovaný PDF se otevřel, ale jeho pojmenovaný cíl nebyl použit. The linked PDF opened, but its page destination was out of range. Odkazovaný PDF se otevřel, ale cílová stránka je mimo rozsah. Local cross-PDF links require the desktop app Místní odkazy mezi soubory PDF vyžadují desktopovou aplikaci + Move tag earlier Přesunout značku dříve + Move tag later Přesunout značku později diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7811a78ee9..462c08f3f9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2339,3 +2339,5 @@ animation effects Off Aus The linked PDF opened, but its named destination was not applied. Die verknüpfte PDF wurde geöffnet, aber ihr benanntes Ziel wurde nicht angewendet. The linked PDF opened, but its page destination was out of range. Die verknüpfte PDF wurde geöffnet, aber ihr Seitenziel liegt außerhalb des Bereichs. Local cross-PDF links require the desktop app Lokale Verknüpfungen zwischen PDFs erfordern die Desktop-App + Move tag earlier Tag früher verschieben + Move tag later Tag später verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a5030e6aa..872a52ea3a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2348,3 +2348,5 @@ animation effects Off Ανενεργά Local cross-PDF links require the desktop app Οι τοπικοί σύνδεσμοι μεταξύ PDF απαιτούν την εφαρμογή υπολογιστή The existing action will be preserved. Η υπάρχουσα ενέργεια θα διατηρηθεί. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Τα σενάρια αυτής της φόρμας είναι απενεργοποιημένα, επειδή ένα από αυτά εκτελούνταν για πολύ ώρα. Ανοίξτε ξανά το έγγραφο για να εκτελεστούν ξανά. + Move tag earlier Μετακίνηση ετικέτας νωρίτερα + Move tag later Μετακίνηση ετικέτας αργότερα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ba79a5b8e0..754acf02fd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2468,3 +2468,5 @@ animation effects Off Desactivados The linked PDF opened, but its page destination was out of range. El PDF vinculado se abrió, pero su página de destino está fuera de rango. Local cross-PDF links require the desktop app Los enlaces locales entre PDF requieren la aplicación de escritorio The existing action will be preserved. Se conservará la acción existente. + Move tag earlier Mover etiqueta antes + Move tag later Mover etiqueta después diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e81b5f01e1..25339bc3ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2338,3 +2338,5 @@ animation effects Off Désactivés The linked PDF opened, but its named destination was not applied. Le PDF lié s'est ouvert, mais sa destination nommée n'a pas été appliquée. The linked PDF opened, but its page destination was out of range. Le PDF lié s'est ouvert, mais sa page de destination est hors limites. Local cross-PDF links require the desktop app Les liens locaux entre PDF nécessitent l'application de bureau + Move tag earlier Déplacer la balise plus tôt + Move tag later Déplacer la balise plus tard diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b9b3f558d4..bf5c55842f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2337,3 +2337,5 @@ animation effects Off Ki The linked PDF opened, but its named destination was not applied. A hivatkozott PDF megnyílt, de a nevesített cél nem lett alkalmazva. The linked PDF opened, but its page destination was out of range. A hivatkozott PDF megnyílt, de a céloldal a tartományon kívül esik. Local cross-PDF links require the desktop app A PDF-ek közötti helyi hivatkozásokhoz az asztali alkalmazás szükséges + Move tag earlier Címke korábbra helyezése + Move tag later Címke későbbre helyezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5448ef6396..b1baa07272 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2347,3 +2347,5 @@ animation effects Off Disattivati The linked PDF opened, but its named destination was not applied. Il PDF collegato è stato aperto, ma la sua destinazione con nome non è stata applicata. The linked PDF opened, but its page destination was out of range. Il PDF collegato è stato aperto, ma la pagina di destinazione è fuori intervallo. Local cross-PDF links require the desktop app I collegamenti locali tra PDF richiedono l'app desktop + Move tag earlier Sposta tag prima + Move tag later Sposta tag dopo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c4277720aa..3752984efb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2338,3 +2338,5 @@ animation effects Off オフ The linked PDF opened, but its named destination was not applied. リンク先の PDF を開きましたが、名前付き移動先は適用されませんでした。 The linked PDF opened, but its page destination was out of range. リンク先の PDF を開きましたが、移動先のページが範囲外です。 Local cross-PDF links require the desktop app PDF 間のローカルリンクにはデスクトップアプリが必要です + Move tag earlier タグを前へ移動 + Move tag later タグを後へ移動 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc807c78d5..8d05f7fe9b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2336,3 +2336,5 @@ animation effects Off Өшірулі The linked PDF opened, but its named destination was not applied. Байланысты PDF ашылды, бірақ оның аталған мақсаты қолданылмады. The linked PDF opened, but its page destination was out of range. Байланысты PDF ашылды, бірақ оның мақсатты беті ауқымнан тыс. Local cross-PDF links require the desktop app PDF файлдары арасындағы жергілікті сілтемелер үшін жұмыс үстелі қолданбасы қажет + Move tag earlier Тегті ертерек жылжыту + Move tag later Тегті кейінірек жылжыту diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2b6ffa0bff..7a342bb02b 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2582,3 +2582,5 @@ key Esc Esc key Home Home key End End key Space Tarpas + Move tag earlier Perkelti žymą anksčiau + Move tag later Perkelti žymą vėliau diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ee205f5646..3d7713a66d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2318,3 +2318,5 @@ animation effects Off Wyłączone Local cross-PDF links require the desktop app Lokalne łącza między plikami PDF wymagają aplikacji komputerowej The existing action will be preserved. Istniejąca akcja zostanie zachowana. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Skrypty tego formularza są wyłączone, ponieważ jeden z nich działał zbyt długo. Otwórz dokument ponownie, aby znów je uruchomić. + Move tag earlier Przenieś znacznik wcześniej + Move tag later Przenieś znacznik później diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f22ad9c86c..cc05346929 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2352,3 +2352,5 @@ animation effects Off Desativados The linked PDF opened, but its named destination was not applied. O PDF vinculado foi aberto, mas seu destino nomeado não foi aplicado. The linked PDF opened, but its page destination was out of range. O PDF vinculado foi aberto, mas sua página de destino está fora do intervalo. Local cross-PDF links require the desktop app Links locais entre PDFs exigem o aplicativo para desktop + Move tag earlier Mover tag para antes + Move tag later Mover tag para depois diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ac054d026c..6455bbd930 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2251,3 +2251,5 @@ animation effects Off Выкл The linked PDF opened, but its named destination was not applied. Связанный PDF открыт, но его именованное назначение не применено. The linked PDF opened, but its page destination was out of range. Связанный PDF открыт, но страница назначения вне допустимого диапазона. Local cross-PDF links require the desktop app Локальные ссылки между PDF работают только в настольном приложении + Move tag earlier Переместить тег раньше + Move tag later Переместить тег позже diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index e8dc01620c..21223fba53 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2343,3 +2343,5 @@ animation effects Off Izklopljeni The linked PDF opened, but its named destination was not applied. Povezani PDF se je odprl, vendar njegov poimenovani cilj ni bil uporabljen. The linked PDF opened, but its page destination was out of range. Povezani PDF se je odprl, vendar je ciljna stran zunaj obsega. Local cross-PDF links require the desktop app Lokalne povezave med PDF-ji zahtevajo namizno aplikacijo + Move tag earlier Premakni oznako prej + Move tag later Premakni oznako pozneje diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 292b9ae886..69bc4ab684 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2251,3 +2251,5 @@ animation effects Off ఆఫ్ Local cross-PDF links require the desktop app PDFల మధ్య స్థానిక లింక్‌లకు డెస్క్‌టాప్ యాప్ అవసరం The existing action will be preserved. ఉన్న చర్య అలాగే ఉంచబడుతుంది. This form's scripts are turned off because one ran too long. Reopen the document to run them again. ఒక స్క్రిప్ట్ చాలా సేపు నడిచినందున ఈ ఫారమ్ స్క్రిప్ట్‌లు ఆపివేయబడ్డాయి. వాటిని మళ్లీ నడపడానికి పత్రాన్ని మళ్లీ తెరవండి. + Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు + Move tag later ట్యాగ్‌ను వెనుకకు తరలించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0d990dc99e..9886180a73 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2334,3 +2334,5 @@ animation effects Off Kapalı The linked PDF opened, but its named destination was not applied. Bağlantılı PDF açıldı, ancak adlandırılmış hedefi uygulanmadı. The linked PDF opened, but its page destination was out of range. Bağlantılı PDF açıldı, ancak sayfa hedefi aralık dışında. Local cross-PDF links require the desktop app PDF'ler arası yerel bağlantılar masaüstü uygulamasını gerektirir + Move tag earlier Etiketi daha erken taşı + Move tag later Etiketi daha geç taşı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 89aa68d54a..1ad99cfadb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2360,3 +2360,5 @@ animation effects Off Вимкнено The linked PDF opened, but its named destination was not applied. Пов’язаний PDF відкрито, але його іменоване призначення не застосовано. The linked PDF opened, but its page destination was out of range. Пов’язаний PDF відкрито, але сторінка призначення поза межами діапазону. Local cross-PDF links require the desktop app Локальні посилання між PDF працюють лише в настільному застосунку + Move tag earlier Перемістити тег раніше + Move tag later Перемістити тег пізніше diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a1e6e25e28..ee5dc338a5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2302,3 +2302,5 @@ animation effects Off 关 The linked PDF opened, but its named destination was not applied. 已打开链接的 PDF,但未应用其命名目标。 The linked PDF opened, but its page destination was out of range. 已打开链接的 PDF,但其页面目标超出范围。 Local cross-PDF links require the desktop app PDF 之间的本地链接需要桌面应用 + Move tag earlier 将标签前移 + Move tag later 将标签后移 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c9db457e30..9240c6f738 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2212,3 +2212,5 @@ animation effects Off 關 The linked PDF opened, but its page destination was out of range. 已開啟連結的 PDF,但其頁面目的地超出範圍。 Local cross-PDF links require the desktop app PDF 之間的本機連結需要桌面應用程式 The existing action will be preserved. 將保留現有動作。 + Move tag earlier 將標記前移 + Move tag later 將標記後移 diff --git a/crates/ui-egui/src/tags_ui.rs b/crates/ui-egui/src/tags_ui.rs index 718c3f8b38..2fe917f7c3 100644 --- a/crates/ui-egui/src/tags_ui.rs +++ b/crates/ui-egui/src/tags_ui.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use egui::Ui; use pdfcraft_cos::ObjRef; -use pdfcraft_engine::a11y::{TagInfo, TagProperty}; +use pdfcraft_engine::a11y::{TagInfo, TagPlacement, TagProperty}; use pdfcraft_engine::{DocId, Edit}; use crate::{icons, theme::Tokens, widgets}; @@ -181,13 +181,33 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state }); let edit = state.draft.edit(tag); let mut save = false; + let siblings: Vec<&TagInfo> = tag.parent.map(|parent| tags.iter().filter(|other| other.parent == Some(parent)).collect()).unwrap_or_default(); + let position = siblings.iter().position(|sibling| sibling.object == tag.object); + let before = position.and_then(|position| position.checked_sub(1)).map(|position| siblings[position]); + let after = position.and_then(|position| siblings.get(position + 1)).copied(); ui.horizontal(|ui| { save = ui.add_enabled(can_edit && !state.draft.kind.is_empty() && edit.is_some(), egui::Button::new(tl!("Save tag properties"))).clicked(); if ui.add_enabled(dirty, egui::Button::new(tl!("Revert changes"))).clicked() { state.draft = Draft::from_tag(tag); } }); - (page, if save { edit } else { None }) + let (mut earlier, mut later) = (false, false); + ui.horizontal(|ui| { + earlier = ui.add_enabled(can_edit && !dirty && before.is_some(), egui::Button::new(tl!("Move tag earlier"))).clicked(); + later = ui.add_enabled(can_edit && !dirty && after.is_some(), egui::Button::new(tl!("Move tag later"))).clicked(); + }); + ( + page, + if save { + edit + } else if earlier { + before.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::Before }) + } else if later { + after.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::After }) + } else { + None + }, + ) } fn visible_tags<'a>(tags: &'a [TagInfo], query: &str, collapsed: &BTreeSet) -> Vec<&'a TagInfo> { diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index 3ac2e2dbfe..cc235c9f31 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -223,6 +223,27 @@ fn tags_panel_edits_a_tag_property_and_undoes_it() { assert!(h.query_by_label("First figure · 7").is_none()); } +#[test] +fn tags_panel_moves_a_tag_within_its_siblings() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("a11y.tags")); + h.run_steps(2); + h.get_by_label("Photo · 8").click(); + h.run_steps(1); + h.get_by_label("Move tag earlier").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.tags().into_iter().map(|tag| tag.object.num).collect::>(), [6, 8, 7]); + assert_eq!(doc.can_undo(), Some("Move object")); +} + #[test] fn checker_finding_opens_its_structure_tag() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index eeba76118f..7d8e8703c5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8909,11 +8909,11 @@ title = "Order panel" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.tags"] +tools = ["accessibility_move_tag"] +evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_moves_a_tag_within_its_siblings", "crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "The Tags panel moves a selected reachable indirect tag earlier or later among direct siblings, preserving page appearance and making one undoable reading-order edit. It does not yet show page-content order visually, edit direct tag dictionaries, move across parents, or reorder arbitrary content." [[feature]] id = "a11y.content-panel" From 55f76ab767cd7740f99e28b0ca83d0aaecd944e9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:04:23 +0900 Subject: [PATCH 19/70] Desktop: prepare large PDFs off the UI thread (#1493) * #307: split document open into prepare and commit * #307: move desktop open preparation off the frame thread --- crates/engine/src/lib.rs | 113 ++++++--- crates/engine/src/tests.rs | 14 ++ crates/ui-egui/src/control.rs | 11 +- crates/ui-egui/src/lib.rs | 314 +++++++++++++++++++++--- crates/ui-egui/src/open_worker.rs | 208 ++++++++++++++++ crates/ui-egui/src/open_worker_tests.rs | 90 +++++++ crates/ui-egui/src/recovery.rs | 19 ++ 7 files changed, 706 insertions(+), 63 deletions(-) create mode 100644 crates/ui-egui/src/open_worker.rs create mode 100644 crates/ui-egui/src/open_worker_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4a71f7855c..76bdc381b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2609,6 +2609,34 @@ pub struct Session { date_language: Option, } +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -2901,13 +2929,45 @@ impl Session { self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) } - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let name = name.into(); - guard(|| self.open_unguarded(name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() } } - fn open_unguarded(&mut self, name: String, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); // The renderer authenticates on its own. It cannot use the owner password of R2–R4 // files, so give it the user password that owner authentication recovers. @@ -2926,7 +2986,7 @@ impl Session { // A dynamic XFA form is a shell around an XML template; lay the template out into real // pages and fields so the rest of the engine works on it. The original bytes stay: the // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; // Write `work` as one more revision and reopen it: what the document then is. let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; @@ -2995,7 +3055,7 @@ impl Session { _ => None, }; let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { Some(tpl) => { let mut work = cos.clone(); let pages = info.pages.len(); @@ -3038,24 +3098,22 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - // A script that ran away on open turns the form's scripts off for this document. - d.xfa_template = if ran_away { None } else { xfa_template }; - d.js_output.append(script_output); - if ran_away { - d.xfa_scripts_off = true; - d.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut d.xfa_warnings, &xfa_warnings); + let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); } - Ok(id) + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) } /// The last step of opening: build the document record and register it. #[allow(clippy::too_many_arguments)] - fn push_document( - &mut self, + fn build_document( + trust: &Arc, name: String, path: Option, bytes: Arc>, @@ -3064,7 +3122,7 @@ impl Session { render_password: Option, password: Option<&str>, xfa: Option, - ) -> Result { + ) -> Document { let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { @@ -3114,11 +3172,9 @@ impl Session { .unwrap_or_default(); let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default(); let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default(); - self.next_id += 1; - let id = DocId(self.next_id); - self.docs.push(Document { - id, + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + Document { + id: DocId(0), name, path, bytes, @@ -3135,7 +3191,7 @@ impl Session { added, links, signatures, - trust: self.trust.clone(), + trust: trust.clone(), sig_cache, editor, config, @@ -3146,8 +3202,7 @@ impl Session { fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), - }); - Ok(id) + } } fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0ec09c2f2c..ff98c2df53 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -36,6 +36,20 @@ fn session_with(n: usize) -> (Session, DocId) { (s, id) } +#[test] +fn prepared_open_does_not_mutate_the_session_until_commit() { + fn assert_send() {} + assert_send::(); + + let mut session = Session::new(); + let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares"); + assert!(session.get(DocId(1)).is_none()); + + let id = session.commit_open(prepared); + assert_eq!(id, DocId(1)); + assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3); +} + #[test] fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 65a12e1997..d83553105e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -36,7 +36,8 @@ //! for this session without changing the saved preference. `ui.state.default_mode` reports it. //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. -//! - `ui.open {path}`: open a file. +//! - `ui.open {path}`: accept a file open; native opens report `pending: true` until they +//! complete and are listed in `ui.state.pending_opens`. //! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect. use std::collections::{HashMap, VecDeque}; @@ -642,9 +643,15 @@ impl Host for crate::PdfCraftApp { Some(json!({ "tab": i, "doc": v.id.0, "name": d.name, "path": d.path, "pages": d.info.pages.len(), "dirty": d.dirty, "active": self.active == Some(i) })) }) .collect(); + #[cfg(not(target_arch = "wasm32"))] + let pending_opens: Vec = + self.pending_opens.iter().map(|pending| json!({ "token": pending.token, "name": pending.name, "path": pending.path })).collect(); + #[cfg(target_arch = "wasm32")] + let pending_opens: Vec = Vec::new(); json!({ "docking": self.docking, "documents": docs, + "pending_opens": pending_opens, "active": active.map(|v| json!({ "doc": v.id.0, "page": v.current + 1, @@ -743,6 +750,8 @@ impl Host for crate::PdfCraftApp { self.open_path(path); if self.views.len() > before || self.password_prompt.is_some() { Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })) + } else if self.pending_opens.iter().any(|pending| pending.path.as_deref() == Some(path)) { + Ok(json!({ "accepted": path, "pending": true })) } else { Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4f5b0993..3418fcdf8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,8 @@ pub(crate) fn date_text_for_pdf(session: &Session) -> Result { /// The longest author name kept (Preferences ▸ Identity, restored settings). pub(crate) const MAX_AUTHOR_CHARS: usize = 200; +#[cfg(not(target_arch = "wasm32"))] +mod open_worker; pub mod portable; mod protect; mod recovery; @@ -130,6 +132,9 @@ pub mod updates; mod wheel_pager; mod widgets; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod open_worker_tests; + use pdfcraft_engine::{DocId, Session}; pub use canvas::{DocView, RasterMemory}; @@ -392,6 +397,8 @@ pub struct PasswordPrompt { pub name: String, pub path: Option, pub bytes: std::sync::Arc>, + #[cfg(not(target_arch = "wasm32"))] + stamp: Option, pub input: String, pub error: Option, /// A late startup file must stay in the background even after it is unlocked. @@ -406,6 +413,32 @@ pub struct PasswordPrompt { after_open: Option, } +#[cfg(not(target_arch = "wasm32"))] +struct PendingOpen { + token: u64, + receiver: std::sync::mpsc::Receiver, + name: String, + path: Option, + stamp: Option, + activate: bool, + after_open: Option, + recovered: Option, + then: Option<&'static str>, +} + +#[cfg(not(target_arch = "wasm32"))] +struct OpenRequest { + name: String, + path: Option, + stamp: Option, + input: open_worker::Input, + password: Option, + activate: bool, + after_open: Option, + recovered: Option, + then: Option<&'static str>, +} + /// Asks for the permissions password of an open document whose security restricts it, which /// lifts the restrictions (#995). Acrobat asks for it when such a document's security is changed. pub struct UnlockPrompt { @@ -524,6 +557,15 @@ pub struct PdfCraftApp { /// Open documents' files, watched for changes on disk. #[cfg(not(target_arch = "wasm32"))] pub(crate) watch: file_watch::FileWatch, + /// One native worker prepares opened documents without owning app or session state. + #[cfg(not(target_arch = "wasm32"))] + open_worker: Option, + #[cfg(not(target_arch = "wasm32"))] + pending_opens: Vec, + #[cfg(not(target_arch = "wasm32"))] + next_open_token: u64, + #[cfg(all(test, not(target_arch = "wasm32")))] + open_prepare_gate: Option>, /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, pub toast: Option<(String, f64)>, @@ -844,6 +886,14 @@ impl PdfCraftApp { reload_changed_files: true, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + open_worker: None, + #[cfg(not(target_arch = "wasm32"))] + pending_opens: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + next_open_token: 0, + #[cfg(all(test, not(target_arch = "wasm32")))] + open_prepare_gate: None, pinned: Default::default(), toast: None, integrated_titlebar: false, @@ -1032,6 +1082,119 @@ impl PdfCraftApp { self.try_open(name, path, std::sync::Arc::new(bytes), None, activate) } + #[cfg(not(target_arch = "wasm32"))] + fn schedule_open(&mut self, request: OpenRequest) -> Result { + if self.open_worker.is_none() { + self.open_worker = Some( + open_worker::OpenWorker::new( + #[cfg(test)] + self.open_prepare_gate.clone(), + ) + .map_err(|e| format!("couldn't start the document-open worker: {e}"))?, + ); + } + let token = self.next_open_token.wrapping_add(1); + let worker = self.open_worker.as_ref().ok_or("the document-open worker is unavailable")?; + if let Some(ctx) = &self.ctx { + worker.set_context(ctx); + } + let receiver = + worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?; + self.next_open_token = token; + self.pending_opens.push(PendingOpen { + token, + receiver, + name: request.name, + path: request.path, + stamp: request.stamp, + activate: request.activate, + after_open: request.after_open, + recovered: request.recovered, + then: request.then, + }); + Ok(token) + } + + #[cfg(not(target_arch = "wasm32"))] + fn poll_pending_opens(&mut self) { + let mut ready = Vec::new(); + let mut waiting = Vec::new(); + let pending_opens = std::mem::take(&mut self.pending_opens); + for pending in pending_opens { + match pending.receiver.try_recv() { + Ok(result) if result.token == pending.token => ready.push((pending, result.kind)), + Ok(_) => waiting.push(pending), + Err(std::sync::mpsc::TryRecvError::Empty) => waiting.push(pending), + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.notify_fmt("Couldn't open {name}: the background worker stopped", &[("name", &pending.name)]); + } + } + } + self.pending_opens = waiting; + for (pending, kind) in ready { + self.finish_pending_open(pending, kind); + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn finish_pending_open(&mut self, pending: PendingOpen, kind: open_worker::ResultKind) { + match kind { + open_worker::ResultKind::ReadFailed(reason) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &pending.name), ("e", &reason)]); + } + open_worker::ResultKind::RemoteFailed(reason) => { + self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &reason)]); + } + open_worker::ResultKind::RecoveryFailed(reason) => { + self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &pending.name), ("e", &reason)]); + } + open_worker::ResultKind::Failed(reason) => { + self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]); + } + open_worker::ResultKind::Conversion(bytes) => { + if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]); + } + } + open_worker::ResultKind::Prepared { bytes, result } => match *result { + Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) { + Ok(id) => { + if pending.path.is_some() { + self.watch.known_as(id, pending.stamp); + } + if let Some(destination) = pending.after_open { + self.apply_remote_destination(destination); + } + if let Some(meta) = &pending.recovered { + self.finish_recovery(meta); + } + if let Some(command) = pending.then { + self.execute(command); + } + } + Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), + }, + Err(error @ (pdfcraft_render::OpenError::NeedsPassword | pdfcraft_render::OpenError::WrongPassword)) => { + let password_error = + matches!(error, pdfcraft_render::OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); + self.password_prompt = Some(PasswordPrompt { + name: pending.name, + path: pending.path, + bytes, + stamp: pending.stamp, + input: String::new(), + error: password_error, + activate: pending.activate, + recovered: pending.recovered, + then: pending.then, + after_open: pending.after_open, + }); + } + Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]), + }, + } + } + fn try_open( &mut self, name: &str, @@ -1041,15 +1204,18 @@ impl PdfCraftApp { activate: bool, ) -> Result<(), String> { use pdfcraft_render::OpenError; - let size = bytes.len(); - let id = match self.session.open(name, path.clone(), bytes.clone(), password) { - Ok(id) => id, + let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) { + Ok(prepared) => prepared, Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); + #[cfg(not(target_arch = "wasm32"))] + let stamp = path.as_deref().and_then(file_watch::Stamp::at); self.password_prompt = Some(PasswordPrompt { name: name.to_string(), path, bytes, + #[cfg(not(target_arch = "wasm32"))] + stamp, input: String::new(), error, activate, @@ -1061,6 +1227,18 @@ impl PdfCraftApp { } Err(e) => return Err(e.to_string()), }; + self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) + } + + fn finish_open( + &mut self, + name: &str, + path: Option, + size: usize, + prepared: pdfcraft_engine::PreparedDocument, + activate: bool, + ) -> Result { + let id = self.session.commit_open(prepared); self.password_prompt = None; // The reading-colours preference is app-wide: a newly opened document starts with it. // Idempotent: the engine returns `false` when the document has the setting already. @@ -1115,7 +1293,7 @@ impl PdfCraftApp { let out = self.session.take_js_output(id); self.handle_js(id, out); } - Ok(()) + Ok(id) } #[cfg(not(target_arch = "wasm32"))] @@ -1128,8 +1306,23 @@ impl PdfCraftApp { let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); match f.bytes() { Ok(bytes) => { - if let Err(e) = self.open_bytes(&name, None, bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + if self.ctx.is_some() && !self.run_inline { + let result = self.schedule_open(OpenRequest { + name: name.clone(), + path: None, + stamp: None, + input: open_worker::Input::Bytes(std::sync::Arc::new(bytes)), + password: None, + activate: true, + after_open: None, + recovered: None, + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + } + } else if let Err(error) = self.open_bytes(&name, None, bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); } } Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), @@ -1260,11 +1453,43 @@ impl PdfCraftApp { } return; }; + #[cfg(not(target_arch = "wasm32"))] + if self.ctx.is_some() && !self.run_inline { + let result = self.schedule_open(OpenRequest { + name: p.name.clone(), + path: p.path.clone(), + stamp: p.stamp, + input: open_worker::Input::Bytes(p.bytes.clone()), + password: Some(pw), + activate: p.activate, + after_open: p.after_open.clone(), + recovered: p.recovered.clone(), + then: p.then, + }); + if let Err(error) = result { + let mut prompt = p; + prompt.error = Some(error); + self.password_prompt = Some(prompt); + } + return; + } + #[cfg(not(target_arch = "wasm32"))] + let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp); let after_open = p.after_open.clone(); match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) { Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]), // A recovered encrypted document is open once its prompt is gone. Ok(()) if self.password_prompt.is_none() => { + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = path_for_watch.as_deref() + && let Some(id) = self + .views + .iter() + .rev() + .find_map(|view| self.session.get(view.id).and_then(|doc| (doc.path.as_deref() == Some(path)).then_some(view.id))) + { + self.watch.known_as(id, stamp_for_watch); + } if let Some(destination) = after_open { self.apply_remote_destination(destination); } @@ -1338,24 +1563,20 @@ impl PdfCraftApp { let path = path.to_string_lossy().into_owned(); let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.clone()); let stamp = file_watch::Stamp::at(&path); - let tabs = self.views.len(); self.startup_superseded = true; - let bytes = match pdfcraft_engine::read_remote_document(std::path::Path::new(&path)) { - Ok(bytes) => bytes, - Err(e) => { - self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]); - return; - } - }; - if let Err(e) = self.try_open(&name, Some(path), std::sync::Arc::new(bytes), None, true) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - } else if let Some(prompt) = self.password_prompt.as_mut() { - prompt.after_open = Some(destination); - } else if self.views.len() > tabs { - if let Some(view) = self.views.last() { - self.watch.known_as(view.id, stamp); - } - self.apply_remote_destination(destination); + let result = self.schedule_open(OpenRequest { + name: name.clone(), + path: Some(path.clone()), + stamp, + input: open_worker::Input::RemotePath(std::path::PathBuf::from(path)), + password: None, + activate: true, + after_open: Some(destination), + recovered: None, + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &error)]); } } @@ -1386,18 +1607,38 @@ impl PdfCraftApp { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); // Taken before reading: a build that rewrites the file meanwhile still counts as a change. let stamp = file_watch::Stamp::at(path); - let tabs = self.views.len(); - match std::fs::read(path) { - Ok(bytes) => { - if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - } else if self.views.len() > tabs - && let Some(doc) = self.views.last().map(|v| v.id) - { - self.watch.known_as(doc, stamp); + // Before the first frame there is no interaction to keep responsive; this also preserves + // deterministic inline startup and test flows. Once live, reading and preparation use + // the bounded worker and commit on the UI thread. + if self.ctx.is_none() || self.run_inline { + match std::fs::read(path) { + Ok(bytes) => { + let before = self.views.len(); + if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + } else if self.views.len() > before + && let Some(doc) = self.views.last().map(|view| view.id) + { + self.watch.known_as(doc, stamp); + } } + Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]), } - Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), + return; + } + self.startup_superseded = true; + if let Err(error) = self.schedule_open(OpenRequest { + name: name.clone(), + path: Some(path.to_string()), + stamp, + input: open_worker::Input::Path(std::path::PathBuf::from(path)), + password: None, + activate: true, + after_open: None, + recovered: None, + then: None, + }) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); } } @@ -2395,6 +2636,13 @@ impl eframe::App for PdfCraftApp { // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); i18n::set_current(lang); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(worker) = &self.open_worker { + worker.set_context(ctx); + } + self.poll_pending_opens(); + } // Chinese wants its own faces before the Japanese ones (one baseline per line). let cjk = theme::CjkPreference::for_language(lang.code()); if !self.styled { diff --git a/crates/ui-egui/src/open_worker.rs b/crates/ui-egui/src/open_worker.rs new file mode 100644 index 0000000000..04bd1fefe5 --- /dev/null +++ b/crates/ui-egui/src/open_worker.rs @@ -0,0 +1,208 @@ +//! Native document reads and preparation run away from the frame thread. + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; + +use crate::RecoveryStore; +use pdfcraft_engine::{OpenContext, PreparedDocument, SourceKind}; +use pdfcraft_render::OpenError; + +const MAX_QUEUED_OPENS: usize = 16; + +pub(crate) enum Input { + Path(PathBuf), + RemotePath(PathBuf), + Recovery(RecoveryStore, String), + Bytes(Arc>), +} + +pub(crate) enum ResultKind { + Conversion(Arc>), + Prepared { bytes: Arc>, result: Box> }, + ReadFailed(String), + RemoteFailed(String), + RecoveryFailed(String), + Failed(String), +} + +pub(crate) struct ResultMessage { + pub token: u64, + pub kind: ResultKind, +} + +struct Job { + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + reply: Sender, + #[cfg(test)] + gate: Option>, +} + +struct Queue { + jobs: VecDeque, + shutdown: bool, + repaint: Option, +} + +pub(crate) struct OpenWorker { + queue: Arc<(Mutex, Condvar)>, + #[cfg(test)] + gate: Option>, +} + +impl OpenWorker { + pub fn new(#[cfg(test)] gate: Option>) -> std::io::Result { + let queue = Arc::new((Mutex::new(Queue { jobs: VecDeque::new(), shutdown: false, repaint: None }), Condvar::new())); + let worker_queue = queue.clone(); + std::thread::Builder::new().name("pdfcraft-open".into()).spawn(move || run(worker_queue))?; + Ok(Self { + queue, + #[cfg(test)] + gate, + }) + } + + pub fn set_context(&self, ctx: &egui::Context) { + let (lock, _) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + queue.repaint = Some(ctx.clone()); + } + + pub fn submit( + &self, + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + ) -> Result, String> { + let (reply, receiver) = mpsc::channel(); + let job = Job { + token, + context, + name, + path, + password, + input, + reply, + #[cfg(test)] + gate: self.gate.clone(), + }; + let (lock, ready) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + if queue.shutdown { + return Err("the document-open worker has stopped".into()); + } + if queue.jobs.len() >= MAX_QUEUED_OPENS { + return Err("too many documents are waiting to open".into()); + } + queue.jobs.push_back(job); + ready.notify_one(); + Ok(receiver) + } +} + +impl Drop for OpenWorker { + fn drop(&mut self) { + let (lock, ready) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + queue.shutdown = true; + queue.jobs.clear(); + queue.repaint = None; + ready.notify_one(); + } +} + +fn run(queue: Arc<(Mutex, Condvar)>) { + loop { + let job = { + let (lock, ready) = &*queue; + let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner); + while state.jobs.is_empty() && !state.shutdown { + state = ready.wait(state).unwrap_or_else(PoisonError::into_inner); + } + if state.shutdown { + return; + } + state.jobs.pop_front() + }; + let Some(job) = job else { continue }; + #[cfg(test)] + if let Some(gate) = &job.gate { + gate.pause_before_prepare(); + } + let token = job.token; + let reply = job.reply.clone(); + #[cfg(test)] + let gate = job.gate.clone(); + let kind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepare(job))) + .unwrap_or_else(|_| ResultKind::Failed("opening failed unexpectedly in the background worker".into())); + let _ = reply.send(ResultMessage { token, kind }); + let repaint = { + let (lock, _) = &*queue; + lock.lock().unwrap_or_else(PoisonError::into_inner).repaint.clone() + }; + if let Some(ctx) = repaint { + ctx.request_repaint(); + } + #[cfg(test)] + if let Some(gate) = &gate { + let _ = gate.finished.send(()); + } + } +} + +fn prepare(job: Job) -> ResultKind { + let bytes = match &job.input { + Input::Path(path) => match std::fs::read(path) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::ReadFailed(error.to_string()), + }, + Input::RemotePath(path) => match pdfcraft_engine::read_remote_document(path) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::RemoteFailed(error.to_string()), + }, + Input::Recovery(store, key) => match store.read(key) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::RecoveryFailed(error.to_string()), + }, + Input::Bytes(bytes) => bytes.clone(), + }; + if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) { + return ResultKind::Conversion(bytes); + } + let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref()); + ResultKind::Prepared { bytes, result: Box::new(result) } +} + +#[cfg(test)] +pub(crate) struct PrepareGate { + pub started: Sender<()>, + release: Mutex>, + pub finished: Sender<()>, +} + +#[cfg(test)] +impl PrepareGate { + pub fn pause_before_prepare(&self) { + let _ = self.started.send(()); + if let Ok(release) = self.release.lock() { + let _ = release.recv(); + } + } +} + +#[cfg(test)] +pub(crate) fn prepare_gate() -> (Arc, Receiver<()>, Sender<()>, Receiver<()>) { + let (started, started_rx) = mpsc::channel(); + let (release_tx, release) = mpsc::channel(); + let (finished, finished_rx) = mpsc::channel(); + (Arc::new(PrepareGate { started, release: Mutex::new(release), finished }), started_rx, release_tx, finished_rx) +} diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs new file mode 100644 index 0000000000..329e6ece15 --- /dev/null +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -0,0 +1,90 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::{PdfCraftApp, control::ControlClient, open_worker}; + +fn fixture(pages: usize) -> Vec { + let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()]; + let kids = (0..pages).map(|page| format!("{} 0 R", 4 + 2 * page)).collect::>().join(" "); + objects.push(format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 300] >>")); + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for page in 0..pages { + objects.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * page)); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", page + 1); + objects.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn harness() -> (Harness<'static, PdfCraftApp>, ControlClient) { + let slot: Arc>> = Arc::default(); + let saved = slot.clone(); + let mut harness = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(move |creation| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").expect("language preference"); + *saved.lock().expect("control-client slot") = Some(app.attach_control(&creation.egui_ctx)); + app.open_bytes("existing.pdf", None, fixture(3)).expect("existing document"); + app + }); + harness.run_steps(3); + (harness, slot.lock().expect("control-client slot").take().expect("control client")) +} + +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn request(harness: &mut Harness<'static, PdfCraftApp>, client: &ControlClient, method: &str, params: Value) -> Value { + let reply = client.send(method, params); + for _ in 0..5 { + harness.step(); + if let Ok(result) = reply.try_recv() { + return result.expect("control request succeeded"); + } + } + panic!("{method} was not answered during the held prepare phase"); +} + +#[test] +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() { + let (mut harness, client) = harness(); + let (gate, started, release, finished) = open_worker::prepare_gate(); + harness.state_mut().open_prepare_gate = Some(gate); + + let path = std::env::temp_dir().join(format!("pdfcraft-open-worker-{}.pdf", std::process::id())); + std::fs::write(&path, fixture(2)).expect("write test PDF"); + let open_reply = request(&mut harness, &client, "ui.open", json!({ "path": path.to_string_lossy() })); + assert_eq!(open_reply["pending"], true); + started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier"); + + let state = request(&mut harness, &client, "ui.state", json!({})); + assert_eq!(state["documents"].as_array().map(Vec::len), Some(1)); + assert_eq!(state["documents"][0]["name"], "existing.pdf"); + assert_eq!(state["pending_opens"].as_array().map(Vec::len), Some(1)); + request(&mut harness, &client, "ui.command", json!({ "id": "page.insert_blank" })); + assert_eq!(harness.state().session.get(harness.state().views[0].id).expect("existing tab").info.pages.len(), 4); + assert_eq!(harness.state().views.len(), 1, "prepared state is not visible in the session"); + + release.send(()).expect("release prepare barrier"); + finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation"); + harness.step(); + let app = harness.state(); + assert_eq!(app.views.len(), 2, "one prepared document commits exactly once"); + assert_eq!(app.session.get(app.views[1].id).expect("new tab").info.pages.len(), 2); + assert_eq!(app.active, Some(1)); + let _ = std::fs::remove_file(path); +} diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 7a8b8d464e..134f54ecd7 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -199,6 +199,25 @@ impl PdfCraftApp { let Some(store) = self.recovery.clone() else { return }; for key in keys { let Some(meta) = self.recoverable.iter().find(|m| &m.key == key).cloned() else { continue }; + #[cfg(not(target_arch = "wasm32"))] + if self.ctx.is_some() && !self.run_inline { + self.startup_superseded = true; + let result = self.schedule_open(crate::OpenRequest { + name: meta.name.clone(), + path: None, + stamp: None, + input: crate::open_worker::Input::Recovery(store.clone(), key.clone()), + password: None, + activate: true, + after_open: None, + recovered: Some(meta.clone()), + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &meta.name), ("e", &error)]); + } + continue; + } let bytes = match store.read(key) { Ok(b) => b, Err(e) => { From 801a2f14880ebdcd74d492ee21b83c2e3902fcd9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:05:43 +0200 Subject: [PATCH 20/70] fix(ui): dragged added text follows the pointer before release (#1508) * fix(ui): dragged added text follows the pointer before release (fixes #1488) * fix(ui): keep a moved item in place until the page is redrawn (fixes #1488) --- crates/engine/src/text_preview.rs | 12 +++ crates/ui-egui/src/canvas.rs | 11 ++- crates/ui-egui/src/content_ui.rs | 122 +++++++++++++++++++++++++++ crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/tests/content.rs | 135 +++++++++++++++++++++++++++++- 5 files changed, 277 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d72b49d1e6..d180169677 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -63,4 +63,16 @@ impl Document { Ok(TextEditPreview { page, bytes, config: self.config.clone() }) })? } + + /// The page beneath an added item while it is dragged: `index` counts the page's added + /// items, as `Edit::DeleteContent` does. Read-only, like [`Self::text_edit_preview`]. + pub fn added_content_preview(&self, page: usize, index: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: self.config.clone() }) + })? + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1f9105e213..82c072350b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -394,6 +394,7 @@ pub struct DocView { pub(crate) edit_lines: HashMap)>, pub line_editor: Option, pub(crate) text_background: crate::edit_text_ui::TextBackground, + pub(crate) move_background: crate::content_ui::MoveBackground, /// Edit text & images: the images per page (by document generation), and the selected one. pub(crate) edit_images: HashMap)>, pub(crate) objects: crate::object_ui::ObjectSelection, @@ -635,6 +636,7 @@ impl DocView { edit_lines: HashMap::new(), line_editor: None, text_background: Default::default(), + move_background: Default::default(), edit_images: HashMap::new(), objects: Default::default(), image_selection: None, @@ -780,7 +782,7 @@ impl DocView { } pub fn render_pending(&self) -> bool { - !self.last_queue.is_empty() || self.text_background.pending() + !self.last_queue.is_empty() || self.text_background.pending() || self.move_background.pending() } /// Re-render every page and text layer (the document's appearance changed, e.g. a layer @@ -1343,6 +1345,7 @@ impl DocView { // Signature previews own a separate renderer and image/background textures. self.signature_drag = Default::default(); self.text_background = Default::default(); + self.move_background = Default::default(); if !self.last_queue.is_empty() { pool.set_queue(Vec::new()); self.last_queue.clear(); @@ -1614,6 +1617,7 @@ impl DocView { let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR); self.pages.insert(page, PageTex { tag: r.request.tag, tex }); self.signature_drag.page_received(page); + self.move_background.page_received(page); self.waiting_since.remove(&page); if !self.stale_tiles.iter().any(|k| k.0 == page) { self.forms.unbaked.retain(|(_, p)| *p != page); @@ -2347,6 +2351,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.content.selected = None; } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); // A destination's position (bookmarks, links) refines `goto`: its point at the window's // top-left, or the centre of a /FitR rectangle at the window's centre. @@ -2546,6 +2551,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } view.text_background.paint(painter, &xf, i, view.line_editor.as_ref()); + if editing_content { + let page_tex = view.pages.get(&i).map(|p| p.tex.id()); + view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); + } painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); // Comments: tools, selection, moving and resizing come before text selection. diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5553c8501e..39355e6913 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -105,6 +105,128 @@ impl ContentView { } } +/// While an added item is moved, the page without it, rendered once per gesture: the item's own +/// pixels follow the pointer over it instead of staying put until release (#1488). +#[derive(Default)] +pub(crate) struct MoveBackground { + key: Option<(u64, usize, usize)>, + renderer: Option, + texture: Option, + tag: Option, + /// The item's display-space box when the gesture started. + from: Option<[f64; 4]>, + /// The applied move's box. The page raster on screen still has the item where it was until + /// its replacement arrives, so the preview stays up until then. + settling: Option<[f64; 4]>, +} + +impl MoveBackground { + pub fn pending(&self) -> bool { + self.renderer.is_some() && self.texture.is_none() + } + + pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) { + let key = match (cv.grab, cv.selected) { + (Some((gp, Grab::Move, _)), Some((sp, si))) if gp == sp => Some((doc.edit_generation(), sp, si)), + _ => None, + }; + if !(key.is_none() && self.settling.is_some()) && self.key != key { + *self = Self { key, ..Default::default() }; + if let Some((_, page, index)) = key { + self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer()); + self.from = on_page(&doc.added, page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect()); + } + } + if self.settling.is_some() { + return; + } + let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let Some(p) = doc.info.pages.get(page) else { return }; + let max_side = ctx.input(|i| i.max_texture_side) as f32; + let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + // Without a background the item stays put while dragged, as before. + self.renderer = None; + return; + } + if out.request.tag == tag { + self.texture = Some(ctx.load_texture( + "added-content-move-background", + crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), + egui::TextureOptions::LINEAR, + )); + } + } + if self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// The move was applied: keep the preview, now at the new generation, until the page is + /// rendered again. Any other edit ends it. + pub fn committed(&mut self, edit: &Edit, generation: u64) { + match edit { + Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { + self.key = Some((generation, *page, *index)); + self.settling = Some(content.rect()); + } + _ => *self = Self::default(), + } + } + + pub fn page_received(&mut self, page: usize) { + if self.settling.is_some() && self.key.is_some_and(|(_, p, _)| p == page) { + *self = Self::default(); + } + } + + /// The page without the moving item, then the item's pixels taken from `page_tex` (the page + /// as rendered, item included where it was) at the dragged or applied position. + #[allow(clippy::too_many_arguments)] + pub fn paint( + &self, + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + cv: &ContentView, + page_tex: Option, + ) { + let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; + if kp != page { + return; + } + let r = screen_rect(xf, info, page, from); + let d = if let Some((_, _, start)) = cv.grab { + let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + p - start + } else if let Some(to) = self.settling { + // A moved text box keeps its top edge (its height follows its lines). + screen_rect(xf, info, page, to).left_top() - r.left_top() + } else { + return; + }; + let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); + let r = r.expand(1.0); + let mut mesh = egui::Mesh::with_texture(page_tex); + for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] { + let (u, v) = xf.screen_to_norm(c); + mesh.vertices.push(egui::epaint::Vertex { pos: c + d, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + } +} + /// Text style for new text (the panel's Format controls set it). pub fn default_style() -> AddedText { AddedText { size: 12.0, ..AddedText::default() } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6410898ce4..b3b02a38bc 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -43,6 +43,7 @@ impl PdfCraftApp { let info = &doc.info; let view = &mut self.views[i]; view.signature_drag.committed(&edit, doc.edit_generation()); + view.move_background.committed(&edit, doc.edit_generation()); if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) { // Comment and content edits change one page: keep every other raster. view.page_changed(page); diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index 2435d77666..722144896b 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -1,16 +1,20 @@ //! Edit a PDF ▸ Add content in the real shell (egui_kittest): type text onto a page, move it, //! restyle it from the panel, add an image, delete with the keyboard. -use egui::Pos2; +use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + harness_bytes(include_bytes!("data/form.pdf").to_vec()) +} + +fn harness_bytes(bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, bytes.clone()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -231,3 +235,128 @@ fn typing_thai_text_keeps_marks_and_commits_to_page() { click(&mut h, p); assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]); } + +/// The screen rectangle of an added item's display-space box (y up, from the page's bottom). +fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect { + let s = h.state(); + let ph = s.session.get(s.views[0].id).unwrap().info.pages[0].height; + let page = s.views[0].page_screen_rect(0).expect("on screen"); + let k = page.width() / 300.0; + let p = |x: f64, y: f64| page.min + egui::vec2(x as f32 * k, (ph - y as f32) * k); + egui::Rect::from_two_pos(p(r[0], r[1]), p(r[2], r[3])) +} + +/// Dark pixels in `r`: glyph ink (the selection outline is blue, the page white). +fn ink(pixels: &image::RgbaImage, r: egui::Rect) -> usize { + let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); + (y0..y1.min(pixels.height())) + .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) + .filter(|(x, y)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90)) + .count() +} + +/// Only the left part of the glyphs: kittest paints its cursor down and right of the pointer. +fn probe(r: egui::Rect) -> egui::Rect { + egui::Rect::from_min_size(r.min, egui::vec2(r.width() * 0.6, r.height())) +} + +/// Ink in the probes of the text's first and dragged places. +fn ink_at(h: &mut Harness<'static, PdfCraftApp>, from: egui::Rect, to: egui::Rect) -> (usize, usize) { + let pixels = h.render().unwrap(); + (ink(&pixels, probe(from)), ink(&pixels, probe(to))) +} + +/// Types text, finishes it, and drags it up without releasing, until the drag preview shows. +/// Returns the text's box, its screen rectangles before and after, and the pointer. +fn drag_added_text(h: &mut Harness<'static, PdfCraftApp>) -> ([f64; 4], egui::Rect, egui::Rect, Pos2) { + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let p = at(h, 40.0, 300.0); + click(h, p); + h.event(egui::Event::Text("MMMMMMMM".into())); + h.run_steps(2); + h.get_by_label("Done adding text").click(); + h.run_steps(3); + let rect = added(h)[0].content.rect(); + let from = item_on_screen(h, rect); + let mut shown = 0; + for _ in 0..100 { + h.run_steps(2); + shown = ink(&h.render().unwrap(), probe(from)); + if shown > 20 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(shown > 20, "the added text is on the page ({shown} dark pixels)"); + let grab = pos2(from.right() - 12.0, from.center().y); + let shift = egui::vec2(0.0, -150.0); + h.hover_at(grab); + h.run_steps(1); + h.drag_at(grab); + h.run_steps(1); + h.hover_at(grab + shift); + let to = from.translate(shift); + for _ in 0..100 { + h.run_steps(2); + let (old, new) = ink_at(h, from, to); + if old == 0 && new > 20 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + (rect, from, to, grab + shift) +} + +/// #1488: while a finished text box is dragged, its text follows the box instead of staying put +/// until the button is released. +#[test] +fn dragged_text_follows_the_pointer_before_release() { + let mut h = harness(); + let (rect, from, to, end) = drag_added_text(&mut h); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/added-text-drag.png")).unwrap(); + } + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20, "the text is drawn where the box was dragged ({new} dark pixels)"); + assert_eq!(old, 0, "and no longer where it was"); + assert_eq!(added(&h)[0].content.rect(), rect, "nothing is applied before release"); + h.drop_at(end); + h.run_steps(3); + assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); +} + +/// After release the text stays in its new place while the page is re-rendered with the move, +/// instead of jumping back to where it was for those frames. +#[test] +fn released_text_does_not_jump_back_before_the_page_is_redrawn() { + // A page slow enough to render that the frames before its new raster can be looked at: many + // pale squares, too light to count as ink. + let mut content = String::from("0.9 g\n"); + for i in 0..60_000 { + content.push_str(&format!("{} {} 1 1 re f\n", i % 290, (i / 290) % 390)); + } + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let mut h = harness_bytes(pdf.into_bytes()); + let (rect, from, to, end) = drag_added_text(&mut h); + h.drop_at(end); + // Frame by frame until the page has been re-rendered. The one frame that swaps in the new + // raster reads back blank here (kittest renders it after its old texture is freed). + let mut shown = Vec::new(); + while h.state().render_pending() || shown.is_empty() { + h.run_steps(1); + assert!(shown.len() < 500, "the page is re-rendered"); + let (old, new) = ink_at(&mut h, from, to); + assert_eq!(old, 0, "frame {} after release: the text is back where it was", shown.len() + 1); + shown.push(new); + } + assert!(shown.len() > 2, "frames before the new raster: {shown:?}"); + assert!(shown.iter().filter(|n| **n <= 20).count() <= 1, "the text stays where it was released: {shown:?}"); + assert_ne!(added(&h)[0].content.rect(), rect, "the move is applied"); + h.run_steps(2); + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); +} From a9745dd22d46e2c2245e2f315e6052b5122e2345 Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:05:50 -0500 Subject: [PATCH 21/70] fix(organize): normalize rotations before adding so large quarter turns cannot overflow (#1512) * Contributors: credit webdevtodayjason as Jason Brashear * fix(organize): normalize rotations before adding so large quarter turns cannot overflow --- contributors/people.toml | 3 +++ crates/organize/src/lib.rs | 4 ++-- crates/organize/src/tests.rs | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 9bf6fd9fe6..aa8dd7a158 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -126,3 +126,6 @@ sync_github_name = true real_name = "Jeremy Yao" display_name = "Jeremy Yao" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index a47adb429c..bb18939b52 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -158,11 +158,11 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu } let all = walk(doc)?; check(indices, all.len())?; - let delta = (degrees / 90) * 90; + let delta = degrees.rem_euclid(360); for &i in indices { let (page, inherited) = &all[i]; let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0); - let new = (current + delta).rem_euclid(360); + let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } Ok(()) diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index b4aae6b576..2ae38fb863 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -28,6 +28,20 @@ fn rotate_rejects_angles_that_are_not_multiples_of_90() { assert_eq!(page_rotation(&doc, 0).unwrap(), 0); } +#[test] +fn rotate_handles_large_quarter_turns() { + let large = i64::MAX - i64::MAX % 90; + for (current, degrees, expected) in [(90, large, 90), (-90, -large, 270), (large, 90, 90), (-large, -90, 270), (180, large - 90, 90)] { + for inherited in [false, true] { + let mut doc = open(fixture()); + let target = if inherited { pages_root(&doc).unwrap() } else { pages(&doc).unwrap()[0].obj }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Int(current))).unwrap(); + rotate_pages(&mut doc, &[0], degrees).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), expected); + } + } +} + /// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root, /// Resources from an intermediate node; each page's content says which page it is. fn fixture() -> Vec { From 6803ee894ba4676855e0a804b9fe4da8272de80c Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:05:58 +0200 Subject: [PATCH 22/70] Allow mouse-wheel scrolling during text selection (#1514) * Allow mouse-wheel scrolling during text selection * Keep wheel-scrolling a selection drag over the page gap and the next page (#721) Move the wheel handling out of the per-page drag branch to after the page loop, gated on the pointer being over the document area (like #759), so it no longer stops once the pointer leaves the selection's page. Co-authored-by: Waleed --------- Co-authored-by: Waleed --- crates/ui-egui/src/canvas.rs | 26 +++ crates/ui-egui/tests/ui_group/text_select.rs | 87 +++++++++- crates/ui-egui/tests/viewer.rs | 174 +++++++++++++++++++ 3 files changed, 285 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 82c072350b..363c3c837c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -354,6 +354,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// The drag in progress started a text selection: the wheel scrolls the page during it + /// (#721), which egui's scroll area refuses while anything is dragged. + selection_drag: bool, /// Clicks in the current run on the text layer: two select a word, three the line, four the page. clicks: u32, last_press_time: f64, @@ -613,6 +616,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + selection_drag: false, clicks: 0, last_press_time: 0.0, last_press_pos: None, @@ -2382,6 +2386,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + // A drag lasts only while egui reports it, so the mark can't outlive one. + if !resp.dragged_by(egui::PointerButton::Primary) { + view.selection_drag = false; + } // The Hand tool pans: the content widget takes every drag, so scroll by its delta. if hand { if resp.dragged() { @@ -2798,6 +2806,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if resp.drag_started() && press_here && !over_link { + view.selection_drag = true; let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); if view.clicks < 2 { @@ -2988,6 +2997,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } + // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag + // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the + // pointer leaves the selection's page for the gap or the next page; over the whole + // document area, like #759. A Line/Page event clears precise_scroll_until; a Point + // gesture's eased tail after its suppression deadline is not picked up. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && view.precise_scroll_until == 0.0 + && ui.rect_contains_pointer(avail) + { + let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)); + if delta != Vec2::ZERO { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } // The page navigated to stays current while no page shows more of itself, so the next // page step starts from it rather than from a page further down the screen (#188). // It also stays current while it is wholly on screen: a short page (a cheque) gone to diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index d6f970de80..a98b2f8ed0 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -1,7 +1,7 @@ //! Issue #295: quick clicks on text widen the selection, as in Acrobat: two clicks select a //! word, three the line, four all the text on the page. -use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, Pos2, TouchPhase}; use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; @@ -27,10 +27,15 @@ trailer << /Root 1 0 R >> /// The page on screen and the pointer over the "u" of "quick". Frames are 1/60 s: a click takes /// two frames (press, release), and egui counts a double-click only within 0.3 s. fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) { + harness_with(two_lines(), setup) +} + +/// `harness` with another document; the pointer ends over glyph 5 of its first page. +fn harness_with(pdf: Vec, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("lines.pdf", None, two_lines()).expect("opens"); + app.open_bytes("lines.pdf", None, pdf).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("author", "Tester").unwrap(); setup(&mut app); @@ -184,3 +189,81 @@ fn multiclick_selects_on_pointer_press_before_release() { h.run_steps(1); assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line stays selected on release"); } + +/// `n` pages of 300×200, each ending with one line of Helvetica text near its bottom edge. +fn bottom_lines(n: usize) -> Vec { + let mut objects = String::new(); + let mut kids = Vec::new(); + for k in 0..n { + let (page, contents) = (4 + 2 * k, 5 + 2 * k); + kids.push(format!("{page} 0 R")); + let content = format!("BT /F1 14 Tf 20 20 Td (Page {} ends with this line) Tj ET", k + 1); + objects.push_str(&format!( + "{page} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents {contents} 0 R /Resources << /Font << /F1 3 0 R >> >> >> endobj\n\ + {contents} 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + )); + } + format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{}] /Count {n} >> endobj\n\ + 3 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n{objects}trailer << /Root 1 0 R >>\n%%EOF", + kids.join(" ") + ) + .into_bytes() +} + +/// Press at `from` and move to `to`, keeping the button down. +fn press_and_move(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } +} + +/// One wheel event, with time to scroll (egui spreads a wheel line over a few frames). +fn wheel(h: &mut Harness<'static, PdfCraftApp>, unit: MouseWheelUnit, dy: f32) { + h.event(Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + h.run_steps(10); +} + +/// The top edge of `page` on screen. +fn page_top(h: &Harness<'static, PdfCraftApp>, page: usize) -> f32 { + h.state().views[0].page_screen_rect(page).expect("page on screen").min.y +} + +/// Issue #721: selecting near the bottom of a page with the button held, the wheel keeps +/// scrolling while the pointer crosses the gap onto the next page. The selection stays on its +/// own page (a selection can't span pages yet). +#[test] +fn a_selection_drag_keeps_scrolling_over_the_gap_and_the_next_page() { + let (mut h, _) = harness_with(bottom_lines(3), |app| { + app.set_option("layout", "continuous").unwrap(); + app.set_option("zoom", "200").unwrap(); + }); + let glyph = |h: &Harness<'static, PdfCraftApp>, g: usize| h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen"); + // "ends" (glyphs 7..=10) in "Page 1 ends with this line". + let (from, to) = (glyph(&h, 7), glyph(&h, 10)); + press_and_move(&mut h, from, to); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("ends")); + let (mut over_gap, mut over_next) = (false, false); + for _ in 0..100 { + let before = page_top(&h, 0); + wheel(&mut h, MouseWheelUnit::Line, -0.1); + assert!(page_top(&h, 0) < before - 0.5, "the wheel scrolls during the drag (over the gap yet: {over_gap})"); + let view = &h.state().views[0]; + let on = |page| view.page_screen_rect(page).is_some_and(|r| r.contains(to)); + over_gap |= !on(0) && !on(1); + over_next = on(1); + if over_next { + break; + } + } + assert!(over_gap && over_next, "the pointer crossed the gap ({over_gap}) onto the next page ({over_next})"); + let held = h.state().views[0].selected_text().expect("still selected"); + assert!(held.starts_with("ends") && "Page 1 ends with this line".contains(held.as_str()), "the selection stays on page 1: {held:?}"); + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection"); +} diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 35a82c59ec..b5b1668bf6 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1044,3 +1044,177 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() { let png = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png).unwrap().into_rgba8(); assert_eq!(png.into_raw(), vec![255, 0, 0, 255, 0, 0, 255, 255]); } +#[test] +fn mouse_wheel_scrolls_while_selecting_text() { + let mut h = harness(); + h.state_mut().set_option("zoom", "200").unwrap(); + h.state_mut().set_option("left", "closed").unwrap(); + h.state_mut().set_option("panel", "none").unwrap(); + h.run_steps(8); + let view = h.state().active.unwrap(); + let page = h.state().views[view].page_screen_rect(0).unwrap(); + let start = egui::pos2(page.left() + page.width() * 0.11, page.top() + page.height() * 0.48); + let end = start + egui::vec2(100.0, 0.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.hover_at(end); + h.run_steps(2); + assert!(h.state().views[view].selected_text().is_some_and(|s| !s.is_empty()), "a text selection is active"); + assert!(h.ctx.dragged_id().is_some(), "the primary drag is still held"); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -3.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(20); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + assert!(after < before - 1.0, "wheel should move the page during selection: {before} -> {after}"); + assert!(h.state().views[view].selected_text().is_some_and(|s| !s.is_empty())); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} +fn selection_wheel_harness() -> (Harness<'static, PdfCraftApp>, usize, egui::Pos2) { + let mut h = harness(); + h.state_mut().set_option("zoom", "200").unwrap(); + h.state_mut().set_option("left", "closed").unwrap(); + h.state_mut().set_option("panel", "none").unwrap(); + h.run_steps(8); + let view = h.state().active.unwrap(); + let page = h.state().views[view].page_screen_rect(0).unwrap(); + let start = egui::pos2(page.left() + page.width() * 0.11, page.top() + page.height() * 0.48); + let end = start + egui::vec2(100.0, 0.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.hover_at(end); + h.run_steps(2); + assert!(h.state().views[view].selected_text().is_some_and(|s| !s.is_empty()), "a text selection is active"); + assert!(h.ctx.dragged_id().is_some(), "the primary drag is still held"); + + (h, view, end) +} + +fn selection_wheel_delta(unit: egui::MouseWheelUnit, amount: f32, held: bool, animated: bool) -> f32 { + let (mut h, view, end) = selection_wheel_harness(); + h.state_mut().animation_effects = + if animated { pdfcraft_ui_egui::motion::AnimationEffects::On } else { pdfcraft_ui_egui::motion::AnimationEffects::Off }; + if !held { + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -amount), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.run_steps(40); + before - h.state().views[view].page_screen_rect(0).unwrap().top() +} + +#[test] +fn selection_wheel_preserves_normal_distance_and_reduced_motion() { + for animated in [true, false] { + let normal = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, false, animated); + let held = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, true, animated); + assert!(normal > 1.0); + assert!((normal - held).abs() < 1.0, "animated={animated}: released={normal}, held={held}"); + } + let on = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, true, true); + let off = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, true, false); + assert!((on - off).abs() < 1.0, "on={on}, off={off}"); +} + +#[test] +fn selection_point_scroll_is_not_applied_twice() { + for animated in [true, false] { + let normal = selection_wheel_delta(egui::MouseWheelUnit::Point, 30.0, false, animated); + let held = selection_wheel_delta(egui::MouseWheelUnit::Point, 30.0, true, animated); + eprintln!("Point animated={animated}: released={normal}, held={held}"); + assert!(normal > 1.0, "point wheel must scroll: {normal}"); + assert!((held - 30.0).abs() < 1.0, "animated={animated}: held precision motion={held}, normal={normal}"); + } +} + +#[test] +fn selection_control_wheel_still_zooms() { + for modifiers in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] { + let (mut h, view, _) = selection_wheel_harness(); + let before = h.state().views[view].page_screen_rect(0).unwrap().width(); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().width(); + assert!(after > before + 1.0, "control wheel should zoom: {before} -> {after}"); + } +} + +#[test] +fn selection_wheel_outside_document_does_not_scroll() { + let (mut h, view, _) = selection_wheel_harness(); + h.hover_at(egui::pos2(1398.0, 2.0)); + h.run_steps(2); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + assert!((after - before).abs() < 1.0, "outside wheel moved document: {before} -> {after}"); +} + +#[test] +fn selection_page_wheel_preserves_normal_distance() { + for animated in [true, false] { + let normal = selection_wheel_delta(egui::MouseWheelUnit::Page, 0.1, false, animated); + let held = selection_wheel_delta(egui::MouseWheelUnit::Page, 0.1, true, animated); + assert!(normal > 1.0); + assert!((normal - held).abs() < 1.0, "animated={animated}: released={normal}, held={held}"); + } +} + +#[test] +fn selection_wheel_does_not_scroll_behind_preferences() { + let (mut h, view, _) = selection_wheel_harness(); + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(3); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + assert!((after - before).abs() < 1.0, "modal wheel moved document: {before} -> {after}"); +} + +#[test] +fn selection_mouse_wheel_resumes_after_point_scrolling() { + for delay in [1, 16, 40] { + let (mut h, view, _) = selection_wheel_harness(); + let start = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -30.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(delay); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + eprintln!("Point-to-Line delay={delay}: point={}, line={}", start - before, before - after); + assert!(before - after > 50.0, "Line input must resume after Point: {before} -> {after}"); + } +} From 80eff6775ffc6db825a21dcdddb9734cdc34f698 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:07:15 +0200 Subject: [PATCH 23/70] fix(audit): a rectangle drawn as an m/l path is a cover like re (#1521) * fix(audit): a rectangle drawn as an m/l path is a cover like re (fixes #1510) * docs(parity): a rectangle drawn as an m/l path counts as a cover (#1510) --- crates/audit/README.md | 5 +- crates/audit/src/cover.rs | 114 +++++++++++++++++++++++++++++------ crates/audit/src/tests.rs | 45 ++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 147 insertions(+), 21 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index e13bb30b0d..c8a6a3249f 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -18,8 +18,9 @@ shading and highlights don't either: they're painted before the text, or light a Limits, by design and documented rather than hidden: -- only `re` rectangles closed by a fill count as covers (not arbitrary paths, shadings or - patterns), and a translucent one (`/ca` below 1) is not flagged; +- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose + corners form an axis-aligned rectangle (not other paths, shadings or patterns), and a + translucent one (`/ca` below 1) is not flagged; - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; - annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign. diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index f7737da780..be8afe0a85 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -1,10 +1,11 @@ //! Walk page content streams and collect the opaque filled rectangles they draw. //! //! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque -//! color (`re` + `f`) is the classic way a "redaction" is faked. Graphics state tracked here is -//! deliberately small: the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and -//! the non-stroking alpha from ExtGState (`gs` reading `/ca`). Form XObjects are followed -//! read-only with a depth cap. Anything fancier (arbitrary filled paths, shadings, patterns) is +//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath +//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts +//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`), +//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading +//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is //! out of scope and documented as such. //! //! Paint order: a page's content streams are one stream in pieces (ISO 32000-2 §7.8.2), so they @@ -45,6 +46,58 @@ struct Gs { alpha: f64, } +/// The path under construction, as far as the audit cares about it. +enum Path { + Empty, + /// A `re` rectangle, in user space. + Rect([f64; 4]), + /// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`. + Lines(Vec<[f64; 2]>, bool), + /// Anything else: curves, a second subpath, a CTM change mid-path. + Other, +} + +impl Path { + /// The rectangle a fill of this path paints, if the path is one. + fn rect(self) -> Option<[f64; 4]> { + match self { + Path::Rect(r) => Some(r), + Path::Lines(pts, _) => lines_rect(pts), + Path::Empty | Path::Other => None, + } + } +} + +/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated +/// start point is dropped first; the remaining four corners must alternate horizontal and +/// vertical edges. +fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> { + // CTM products leave rounding noise, so corners compare with a tolerance (in points). + const EPS: f64 = 1e-6; + let same = |a: f64, b: f64| (a - b).abs() <= EPS; + if pts.len() == 5 && same(pts[0][0], pts[4][0]) && same(pts[0][1], pts[4][1]) { + pts.pop(); + } + let [a, b, c, d] = <[[f64; 2]; 4]>::try_from(pts).ok()?; + let fits = |first_horizontal: bool| { + [(a, b), (b, c), (c, d), (d, a)] + .iter() + .enumerate() + .all(|(i, (p, q))| if (i % 2 == 0) == first_horizontal { same(p[1], q[1]) } else { same(p[0], q[0]) }) + }; + if !(fits(true) || fits(false)) { + return None; + } + let (xs, ys) = ([a[0], b[0], c[0], d[0]], [a[1], b[1], c[1], d[1]]); + let r = [ + xs.into_iter().fold(f64::INFINITY, f64::min), + ys.into_iter().fold(f64::INFINITY, f64::min), + xs.into_iter().fold(f64::NEG_INFINITY, f64::max), + ys.into_iter().fold(f64::NEG_INFINITY, f64::max), + ]; + (r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r) +} + impl Default for Gs { fn default() -> Self { Gs { ctm: Matrix::IDENTITY, fill: [0.0, 0.0, 0.0], alpha: 1.0 } @@ -79,8 +132,7 @@ impl<'a> Walker<'a> { depth: usize, at_do: Option<(usize, usize)>, ) -> Result<(), AuditError> { - // A pending `re` rectangle, in the user space active when it was built. - let mut pending: Option<[f64; 4]> = None; + let mut path = Path::Empty; for (i, op) in ops.iter().enumerate() { self.bump()?; // Paint position of this operator: its own `(piece, joined index)` at page level, @@ -95,7 +147,7 @@ impl<'a> Walker<'a> { if let Some(g) = stack.pop() { *gs = g; } - pending = None; + path = Path::Empty; } b"cm" => { let before = gs.ctm; @@ -105,9 +157,9 @@ impl<'a> Walker<'a> { gs.ctm = m.then(&gs.ctm); } // A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the - // pending rectangle, so keep it for the fill (#1333). - if gs.ctm != before { - pending = None; + // pending path, so keep it for the fill (#1333). + if gs.ctm != before && !matches!(path, Path::Empty) { + path = Path::Other; } } b"g" => { @@ -138,7 +190,7 @@ impl<'a> Walker<'a> { } } b"re" => { - pending = op.nums::<4>().and_then(|[x, y, w, h]| { + let rect = op.nums::<4>().and_then(|[x, y, w, h]| { if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 { let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]); (r[2] > r[0] && r[3] > r[1]).then_some(r) @@ -146,18 +198,39 @@ impl<'a> Walker<'a> { None } }); + path = rect.map_or(Path::Other, Path::Rect); + } + b"m" => { + path = match (path, user_point(gs, op)) { + (Path::Empty, Some(p)) => Path::Lines(vec![p], false), + _ => Path::Other, + }; + } + b"l" => { + path = match (path, user_point(gs, op)) { + // Past a repeated start point it can no longer be a rectangle. + (Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => { + pts.push(p); + Path::Lines(pts, false) + } + _ => Path::Other, + }; + } + b"h" => { + path = match path { + Path::Lines(pts, _) => Path::Lines(pts, true), + _ => Path::Other, + }; } b"f" | b"F" | b"f*" | b"B" | b"B*" => { - if let Some(r) = pending.take() + if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect() && gs.alpha >= 1.0 { self.out.push(FilledRect { rect: r, rgb: gs.fill, pos }); } - // Any other path construction clears the pending rectangle. - } - b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"S" | b"s" | b"n" | b"W" | b"W*" => { - pending = None; } + b"S" | b"s" | b"n" => path = Path::Empty, + b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other, b"Do" => { if let Some(name) = op.name(0) { if depth >= MAX_DEPTH { @@ -165,7 +238,7 @@ impl<'a> Walker<'a> { } self.walk_xobject(name, gs, resources, depth, pos)?; } - pending = None; + path = Path::Empty; } _ => {} } @@ -206,6 +279,13 @@ impl<'a> Walker<'a> { } } +/// The `x y` operands of `m`/`l`, mapped to user space. +fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> { + let [x, y] = op.nums::<2>().filter(|v| v.iter().all(|n| n.is_finite()))?; + let (x, y) = gs.ctm.apply(x, y); + Some([x, y]) +} + /// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque. fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { let alpha = resources diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 102f3fb1f0..be98d72842 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -227,3 +227,48 @@ fn self_drawing_form_fails_the_audit() { let result = audit_redactions(&open(b.build())); assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } + +#[test] +fn rectangle_drawn_as_a_path_covers_text_like_re() { + // The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or + // only by the fill itself), or in a scaled user space: all are faux redactions (#1510). + let covers: [&[u8]; 5] = [ + b"0 g\n100 688 220 32 re f\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n", + b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n", + b"0 g\nq 2 0 0 2 0 0 cm 50 344 m 160 344 l 160 360 l 50 360 l h f Q\n", + ]; + for cover in covers { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec(); + content.extend_from_slice(cover); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover)); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); + } +} + +#[test] +fn non_rectangular_path_is_not_a_cover() { + // Each path's bounding box covers the text, but the shape itself does not hide it all. + let covers: [&[u8]; 5] = [ + // A triangle. + b"0 g\n100 688 m 320 688 l 320 720 l h f\n", + // A triangle, then a line from its start point after `h`: not a fourth corner. + b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n", + // A trapezoid: four corners, one slanted side. + b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l h f\n", + // A rectangle rotated 5 degrees about the text by the CTM. + b"0 g\nq 0.9962 0.0872 -0.0872 0.9962 62.15 -15.63 cm 100 688 m 320 688 l 320 720 l 100 720 l h f Q\n", + // Two subpaths filled together: even-odd leaves the inner one as a hole. + b"0 g\n90 680 m 330 680 l 330 730 l 90 730 l h 100 688 m 320 688 l 320 720 l 100 720 l h f*\n", + ]; + for cover in covers { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec(); + content.extend_from_slice(cover); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover)); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7d8e8703c5..f874d52f88 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5788,8 +5788,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/automation/tests/automation.rs::auditing_faux_redactions_through_tools"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, 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)." +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::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"] +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)." [[feature]] id = "protect.remove-hidden-on-close" From a4126d34e7e6da8d7a7d65105e92705f491ab0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mahmud=20Emre=20BA=C5=9EPEHL=C4=B0VAN?= <93977436+Mahmud-Baspehlivan@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:07:22 +0300 Subject: [PATCH 24/70] docs: merge the duplicated Auto paragraph in localization.md (#1525) Two language additions each edited the "Auto follows the system language" paragraph, and both versions ended up in the file: one mentioned Turkish, the other Slovenian, and neither mentioned both. Keep one paragraph that lists both. Co-authored-by: Claude Opus 5.5 --- docs/localization.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/localization.md b/docs/localization.md index 5f27bdb78e..05240cafbf 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -4,8 +4,7 @@ Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma else Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: From 13fbb2f6fbf2fe60a917512ab9df169335741383 Mon Sep 17 00:00:00 2001 From: bmh129 Date: Mon, 12 Oct 2026 23:07:32 -0500 Subject: [PATCH 25/70] M0: label local builds in --version, About and the package release (#1529) A build compiled with PDFCRAFT_BUILD_LABEL=