mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:17:44 -06:00
+4








a6d48707f7
* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 '<stem> p<N> img<M>.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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Edit OCR text: name the cached text-blocks tuple (clippy), rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * M12: preview text structure proposals for auto-tagging * Keep read-only auto-tag preview separate from unavailable command --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Francesco Clemeno <checcomeno@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: umesh429 <umesh.ghanti@gmail.com> 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 <earlvanze@gmail.com> Co-authored-by: LabDennis <dennis@leistenschneider.pro> Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> Co-authored-by: Pratham Bhanushali <134723366+Morpheus004@users.noreply.github.com>
475 lines
20 KiB
Rust
475 lines
20 KiB
Rust
//! 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<u32>,
|
||
}
|
||
|
||
#[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<PageBoxes>,
|
||
pub images: Vec<DrawnImage>,
|
||
pub output_intents: Vec<String>,
|
||
pub fonts_not_embedded: Vec<String>,
|
||
pub pdfa_issues: Vec<Issue>,
|
||
pub findings: Vec<Finding>,
|
||
/// 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<PageBoxes> {
|
||
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<DrawnImage>, 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<f64> {
|
||
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);
|
||
}
|
||
}
|