From d06154ac803bce50d12dee639b22e197f11fa775 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:53:50 -0500 Subject: [PATCH] Print production: Convert colours (grayscale, RGB, CMYK by the device formulas) Every colour operator in the pages' content and the Form XObjects it draws is rewritten in the target space by the standard device formulas, source colours read through the space in force (edit/src/colour.rs: device, ICCBased, CalRGB/ CalGray, Lab, Indexed, Separation/DeviceN through a Type 2 tint). Preserve black, pictures too (optimize::recolour_image: 8-bit gray/RGB re-encoded, JPEG stays JPEG, CMYK lossless), comments' and fields' appearances optionally. Edit::ConvertColours (one undo step; refuses when nothing changes, so Session::count_colours counts first), the Convert colours dialog, command prepress.convert_colors, tool doc_convert_colours, rows in every language (RGB and CMYK allowed unchanged in the Ukrainian and Kazakh checks), parity print.convert-colors shipped. Carries the review fixes: - ATTRIBUTION.toml: the language files' checksums after the new rows - Convert colours: keep RGB and CMYK as-is in the Czech and Turkish menu-label checks - Convert colours: CMYK black start, shared objects copied, streams kept apart, guarded count - Convert colours tests: drop an unused helper - Format with rustfmt - i18n(ui): carry this PR's new wordings in the catalogues that must cover them - Parity: evidence paths follow the tests moved into tests/it - fix(colours): comment permission for appearances; page range per document - fix(convert-colours): say what it is not, and what it leaves behind Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/dispatch.rs | 1 + crates/automation/src/printing.rs | 39 ++ crates/automation/src/tools.rs | 10 + crates/automation/tests/it/automation.rs | 25 + crates/edit/src/colour.rs | 230 ++++++++ crates/edit/src/convert_colours.rs | 492 ++++++++++++++++++ crates/edit/src/lib.rs | 4 + crates/edit/src/page_streams.rs | 69 +++ crates/edit/src/tests.rs | 61 +++ .../tests/it/convert_colours_cmyk_initial.rs | 71 +++ .../edit/tests/it/convert_colours_shared.rs | 144 +++++ .../edit/tests/it/convert_colours_streams.rs | 100 ++++ crates/edit/tests/it/main.rs | 5 + crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/edit.rs | 5 + crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 20 + crates/engine/src/history.rs | 3 +- crates/engine/src/lib.rs | 3 +- crates/engine/src/session/files.rs | 18 + crates/engine/src/tests.rs | 58 +++ crates/optimize/src/images.rs | 70 +++ crates/optimize/src/lib.rs | 2 +- crates/optimize/tests/optimize.rs | 26 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 11 + .../ui-egui/src/dialogs/convert_colours_ui.rs | 141 +++++ crates/ui-egui/src/i18n/ar.tsv | 15 + crates/ui-egui/src/i18n/bg.tsv | 15 + crates/ui-egui/src/i18n/ca.tsv | 15 + crates/ui-egui/src/i18n/cs.tsv | 15 + crates/ui-egui/src/i18n/de.tsv | 15 + crates/ui-egui/src/i18n/el.tsv | 15 + crates/ui-egui/src/i18n/es.tsv | 15 + crates/ui-egui/src/i18n/fi.tsv | 15 + crates/ui-egui/src/i18n/fr.tsv | 15 + crates/ui-egui/src/i18n/hi.tsv | 15 + crates/ui-egui/src/i18n/hu.tsv | 15 + crates/ui-egui/src/i18n/id.tsv | 15 + crates/ui-egui/src/i18n/it.tsv | 15 + crates/ui-egui/src/i18n/ja.tsv | 15 + crates/ui-egui/src/i18n/kk.tsv | 15 + crates/ui-egui/src/i18n/ko.tsv | 15 + crates/ui-egui/src/i18n/lt.tsv | 15 + crates/ui-egui/src/i18n/pl.tsv | 15 + crates/ui-egui/src/i18n/pt-br.tsv | 15 + crates/ui-egui/src/i18n/ro.tsv | 15 + crates/ui-egui/src/i18n/ru.tsv | 15 + crates/ui-egui/src/i18n/sl.tsv | 15 + crates/ui-egui/src/i18n/sq.tsv | 15 + crates/ui-egui/src/i18n/te.tsv | 15 + crates/ui-egui/src/i18n/tr.tsv | 15 + crates/ui-egui/src/i18n/uk.tsv | 15 + crates/ui-egui/src/i18n/vi.tsv | 15 + crates/ui-egui/src/i18n/zh-hans.tsv | 15 + crates/ui-egui/src/i18n/zh-hant.tsv | 15 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/state.rs | 2 + crates/ui-egui/tests/it/editing.rs | 36 ++ parity/acrobat-features.toml | 10 +- 63 files changed, 2091 insertions(+), 9 deletions(-) create mode 100644 crates/edit/src/colour.rs create mode 100644 crates/edit/src/convert_colours.rs create mode 100644 crates/edit/src/page_streams.rs create mode 100644 crates/edit/tests/it/convert_colours_cmyk_initial.rs create mode 100644 crates/edit/tests/it/convert_colours_shared.rs create mode 100644 crates/edit/tests/it/convert_colours_streams.rs create mode 100644 crates/edit/tests/it/main.rs create mode 100644 crates/ui-egui/src/dialogs/convert_colours_ui.rs diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 8bc23ff280..f3e6ca25dc 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -753,6 +753,7 @@ impl Automation { let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; self.apply(&a, edit)? } + "doc_convert_colours" => self.convert_colours(&a)?, "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..d11a4b0b14 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -86,6 +86,45 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let target = match a.opt_str("to")?.unwrap_or("gray") { + "gray" | "grayscale" => pdfcraft_engine::ColourTarget::Gray, + "rgb" => pdfcraft_engine::ColourTarget::Rgb, + "cmyk" => pdfcraft_engine::ColourTarget::Cmyk, + other => return Err(ToolError::InvalidArgs(format!("unknown target {other:?} (gray, rgb, cmyk)"))), + }; + let settings = pdfcraft_engine::ConvertColours { + target, + preserve_black: a.opt_bool("preserve_black")?.unwrap_or(true), + images: a.opt_bool("images")?.unwrap_or(true), + annotations: a.opt_bool("annotations")?.unwrap_or(false), + }; + let id = self.doc(a)?.id; + let found = self.session.count_colours(id, &pages, &settings); + if found.colours == 0 && found.images == 0 { + return Ok(json!({ "colours": 0, "images": 0, "images_left": found.images_left, "pages": 0, "loses_conformance": [] })); + } + // PDF/X has no declaration of its own here: it always carries an output intent. + let declared = self.doc(a)?.standards(); + let pdfa = declared.pdfa.map(|(part, level)| format!("PDF/A-{part}{}", level.to_lowercase())); + let loses: Vec = pdfa.into_iter().chain(declared.output_intents.iter().map(|i| format!("output intent {i}"))).collect(); + let r = self.apply(a, pdfcraft_engine::Edit::ConvertColours { pages, settings })?; + Ok(json!({ + "colours": found.colours, + "images": found.images, + "images_left": found.images_left, + "pages": found.pages, + "loses_conformance": loses, + "undo": r["undo"], + })) + } + /// `printers`: the system's printers, the default marked, and each one's two-sided support /// where the spooler reports it. pub(crate) fn printers(&self) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 866138899d..40741e7e9b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -138,6 +138,16 @@ pub fn tools() -> Vec { t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") .destructive() .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), + t("doc_convert_colours", "Convert colours", "Print production: every colour operator in the pages' content and the artwork it draws rewritten in the target space (to: gray, rgb or cmyk) by the standard device formulas (device spaces, ICC by component count, Lab, Indexed, Separation and DeviceN tints; patterns, shadings and inline images stay). Not a prepress separation: no ICC profile and no output intent is used. Spot colours are converted, not kept: exact through a Type 2 tint, otherwise a gray of their darkness; /None stays. preserve_black (default true) keeps pure black as 100% K / gray 0; images (default true) re-encodes 8-bit gray and RGB pictures (JPEG stays JPEG, compressed again at quality 80; CMYK written lossless; soft masks kept), and leaves indexed, CMYK, masked and other pictures as they are; annotations also converts the pages' comment and field appearances. Returns how many colours and pictures changed on how many pages, images_left (pictures not in the target that stayed), and loses_conformance (the PDF/A level and output intents the file declared: it may no longer meet them, and still declares them; standards_remove_id takes that off). Undoable.") + .cmd("prepress.convert_colors") + .with(schema( + json!({ + "doc": doc(), "pages": pages("to convert (default all)"), + "to": { "type": "string", "enum": ["gray", "rgb", "cmyk"] }, + "preserve_black": { "type": "boolean" }, "images": { "type": "boolean" }, "annotations": { "type": "boolean" }, + }), + &["doc"], + )), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0d73dbb900..39c06b0ee2 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9457,6 +9457,31 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// Convert colours through the tools: counts, converts, reports nothing to do. +#[test] +fn convert_colours_through_tools() { + let dir = workdir("convert-colours"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The fixture sets no colour of its own: nothing to convert yet. + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray" })); + assert_eq!((r["colours"].as_u64(), r["undo"].is_null()), (Some(0), true), "{r}"); + // Red text added as page content converts. + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Red", "at": [20, 20], "color": "#FF0000" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray", "pages": [1] })); + assert!(r["colours"].as_u64().unwrap_or(0) >= 1 && r["pages"].as_u64() == Some(1), "{r}"); + assert_eq!(r["undo"], "Convert colours"); + assert_eq!(r["loses_conformance"], json!([]), "{r}"); + assert!(matches!(a.call("doc_convert_colours", &json!({ "doc": doc, "to": "lab" })), Err(ToolError::InvalidArgs(_)))); + // A file that declares a standard is told what it may have lost. + ok(&mut a, "pdfa_convert", json!({ "doc": doc, "level": "3b" })); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Blue", "at": [20, 40], "color": "#0000FF" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "cmyk", "pages": [1] })); + let loses = r["loses_conformance"].as_array().unwrap(); + assert_eq!(loses[0], "PDF/A-3b", "{r}"); + assert!(loses[1].as_str().unwrap().starts_with("output intent "), "{r}"); +} + #[test] fn attaching_and_deleting_files_through_the_tools() { let dir = workdir("attach"); diff --git a/crates/edit/src/colour.rs b/crates/edit/src/colour.rs new file mode 100644 index 0000000000..7eaab55384 --- /dev/null +++ b/crates/edit/src/colour.rs @@ -0,0 +1,230 @@ +//! Colours as sRGB through the colour space in force, for Convert colours: `g`/`rg`/`k`, and +//! `cs` + `sc`/`scn` through the space named. Device spaces, ICCBased by its component count (or +//! its Alternate), CalRGB and CalGray as their device kin, Lab through CIE XYZ with the +//! dictionary's white point, Indexed through its table, Separation and DeviceN through a Type 2 +//! (exponential) tint transform into the alternate space; a sampled or PostScript tint function, +//! or a pattern, falls back to a plain darkness so nothing reads as the wrong hue. + +use pdfcraft_cos::{Dict, Document, Object}; + +/// A colour space as far as a fill colour needs it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + Lab { + white: [f64; 3], + range: [f64; 4], + }, + Indexed { + base: Box, + hival: usize, + table: Vec, + }, + /// Separation and DeviceN: the number of inputs, the alternate space, and the tint + /// transform when it is a Type 2 function (C0, C1, N). + Tint { + inputs: usize, + alt: Box, + exp: Option<(Vec, Vec, f64)>, + }, + Pattern, +} + +impl Space { + /// How many numbers set a colour in this space. + fn components(&self) -> usize { + match self { + Space::Gray => 1, + Space::Rgb | Space::Lab { .. } => 3, + Space::Cmyk => 4, + Space::Indexed { .. } => 1, + Space::Tint { inputs, .. } => *inputs, + Space::Pattern => 0, + } + } +} + +fn nums(doc: &Document, o: &Object) -> Vec { + doc.resolve(o).as_array().map(|a| a.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()).unwrap_or_default() +} + +/// The colour space an operand of `cs` names: a device space, or a name in the resources' +/// `/ColorSpace` dictionary, or (inside arrays) a family. +pub(crate) fn space_of(doc: &Document, operand: &Object, cs_res: &Dict, depth: u8) -> Space { + if depth > 4 { + return Space::Gray; + } + let resolved = doc.resolve(operand); + match &*resolved { + Object::Name(n) => match n.as_slice() { + b"DeviceGray" | b"G" | b"CalGray" => Space::Gray, + b"DeviceRGB" | b"RGB" | b"CalRGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + b"Pattern" => Space::Pattern, + other => match cs_res.get(other) { + Some(o) => space_of(doc, o, &Dict::default(), depth + 1), + None => Space::Gray, + }, + }, + Object::Array(a) => { + let family = a.first().map(|f| doc.resolve(f)).and_then(|f| f.as_name().map(|n| n.to_vec())).unwrap_or_default(); + match family.as_slice() { + b"ICCBased" => { + let stream = a.get(1).map(|s| doc.resolve(s)); + let dict = stream.as_ref().and_then(|s| match &**s { + Object::Stream(st) => Some(st.dict.clone()), + _ => None, + }); + if let Some(alt) = dict.as_ref().and_then(|d| d.get(b"Alternate").cloned()) { + return space_of(doc, &alt, cs_res, depth + 1); + } + match dict.and_then(|d| d.int(b"N")) { + Some(1) => Space::Gray, + Some(4) => Space::Cmyk, + _ => Space::Rgb, + } + } + b"CalRGB" => Space::Rgb, + b"CalGray" => Space::Gray, + b"Lab" => { + let d = a.get(1).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default(); + let wp = d.get(b"WhitePoint").map(|w| nums(doc, w)).unwrap_or_default(); + let white = if wp.len() == 3 { [wp[0], wp[1], wp[2]] } else { [0.9505, 1.0, 1.089] }; + let r = d.get(b"Range").map(|w| nums(doc, w)).unwrap_or_default(); + let range = if r.len() == 4 { [r[0], r[1], r[2], r[3]] } else { [-100.0, 100.0, -100.0, 100.0] }; + Space::Lab { white, range } + } + b"Indexed" | b"I" => { + let base = a.get(1).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Rgb); + let hival = a.get(2).and_then(|h| doc.resolve(h).as_f64()).unwrap_or(0.0).clamp(0.0, 255.0) as usize; + let table = match a.get(3).map(|t| doc.resolve(t)) { + Some(t) => match &*t { + Object::String(s) => s.bytes.clone(), + Object::Stream(st) => st.decoded().unwrap_or_default(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + Space::Indexed { base: Box::new(base), hival, table } + } + b"Separation" | b"DeviceN" => { + // The `/None` colorant paints nothing (ISO 32000-2, 8.6.6.4): given a colour + // of its own it would appear, so it stays what it is, as a pattern does. + let is_none = |o: &Object| doc.resolve(o).as_name() == Some(b"None"); + let none = a.get(1).is_some_and(|names| { + is_none(names) || doc.resolve(names).as_array().is_some_and(|names| !names.is_empty() && names.iter().all(is_none)) + }); + if none { + return Space::Pattern; + } + let inputs = if family == b"Separation" { + 1 + } else { + a.get(1).map(|n| doc.resolve(n)).and_then(|n| n.as_array().map(Vec::len)).unwrap_or(1).max(1) + }; + let alt = a.get(2).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Gray); + let exp = a.get(3).map(|f| doc.resolve(f)).and_then(|f| { + let d = match &*f { + Object::Dict(d) => d.clone(), + Object::Stream(s) => s.dict.clone(), + _ => return None, + }; + if d.int(b"FunctionType") != Some(2) { + return None; + } + let c0 = d.get(b"C0").map(|v| nums(doc, v)).unwrap_or_else(|| vec![0.0]); + let c1 = d.get(b"C1").map(|v| nums(doc, v)).unwrap_or_else(|| vec![1.0]); + let n = d.get(b"N").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(1.0); + Some((c0, c1, n)) + }); + Space::Tint { inputs, alt: Box::new(alt), exp } + } + b"Pattern" => Space::Pattern, + b"DeviceGray" | b"G" => Space::Gray, + b"DeviceRGB" | b"RGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + _ => Space::Gray, + } + } + _ => Space::Gray, + } +} + +fn clamp01(v: f64) -> f64 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// CMYK to sRGB, the usual multiplicative approximation (no ICC profile). +pub(crate) fn cmyk(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] { + let (c, m, y, k) = (clamp01(c), clamp01(m), clamp01(y), clamp01(k)); + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// CIE L*a*b* (with the dictionary's white point) to sRGB, through XYZ and the D65 sRGB matrix. +fn lab(white: [f64; 3], l: f64, a: f64, b: f64) -> [f64; 3] { + let m = (l + 16.0) / 116.0; + let (ll, mm, nn) = (m + a / 500.0, m, m - b / 200.0); + let g = |x: f64| if x >= 6.0 / 29.0 { x * x * x } else { 108.0 / 841.0 * (x - 4.0 / 29.0) }; + let (xw, yw, zw) = (white[0].max(1e-6), white[1].max(1e-6), white[2].max(1e-6)); + let (x, y, z) = (xw * g(ll), yw * g(mm), zw * g(nn)); + // Scale the white point to D65 before the sRGB matrix (a plain von Kries over XYZ). + let (x, y, z) = (x * 0.9505 / xw, y / yw, z * 1.089 / zw); + let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z]; + lin.map(|c| { + let c = clamp01(c); + if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } + }) +} + +/// The sRGB of `values` in `space` (missing values read as 0; a pattern reads as black). +pub(crate) fn rgb(space: &Space, values: &[f64]) -> [f64; 3] { + let v = |i: usize| values.get(i).copied().unwrap_or(0.0); + match space { + Space::Gray => { + let g = clamp01(v(0)); + [g, g, g] + } + Space::Rgb => [clamp01(v(0)), clamp01(v(1)), clamp01(v(2))], + Space::Cmyk => cmyk(v(0), v(1), v(2), v(3)), + Space::Lab { white, range } => { + let l = v(0).clamp(0.0, 100.0); + let a = v(1).clamp(range[0].min(range[1]), range[1].max(range[0])); + let b = v(2).clamp(range[2].min(range[3]), range[3].max(range[2])); + lab(*white, l, a, b) + } + Space::Indexed { base, hival, table } => { + let n = base.components().max(1); + let i = (v(0).max(0.0).round() as usize).min(*hival); + let vals: Vec = (0..n).map(|k| table.get(i * n + k).map(|b| *b as f64 / 255.0).unwrap_or(0.0)).collect(); + // Lab tables hold the L*, a*, b* range in bytes; the others are 0..1 already. + match &**base { + Space::Lab { range, .. } => { + let scaled = [ + vals[0] * 100.0, + range[0] + vals.get(1).copied().unwrap_or(0.0) * (range[1] - range[0]), + range[2] + vals.get(2).copied().unwrap_or(0.0) * (range[3] - range[2]), + ]; + rgb(base, &scaled) + } + _ => rgb(base, &vals), + } + } + Space::Tint { inputs, alt, exp } => match exp { + Some((c0, c1, n)) => { + let t = clamp01(v(0)); + let k = if *n == 1.0 { t } else { t.powf(*n) }; + let out: Vec = c0.iter().zip(c1.iter()).map(|(a, b)| a + k * (b - a)).collect(); + rgb(alt, &out) + } + // No usable transform: the darkness of the strongest tint, in the alternate space's ink. + None => { + let t = (0..*inputs).map(v).fold(0.0, f64::max); + let g = 1.0 - clamp01(t); + [g, g, g] + } + }, + Space::Pattern => [0.0, 0.0, 0.0], + } +} diff --git a/crates/edit/src/convert_colours.rs b/crates/edit/src/convert_colours.rs new file mode 100644 index 0000000000..7134e537d8 --- /dev/null +++ b/crates/edit/src/convert_colours.rs @@ -0,0 +1,492 @@ +//! Convert colours (Acrobat's Print production ▸ Convert Colors): every colour +//! operator in the page content and the artwork it draws is rewritten in a target space - +//! grayscale, RGB or CMYK - by the standard device formulas (no ICC profile is applied), black +//! preserved on request; pictures are re-encoded through a helper the caller supplies. +//! +//! This is not a prepress separation. No ICC profile, rendering intent or output intent is +//! used, so the numbers are not calibrated for any press, and the catalog's `/OutputIntents` +//! stay as they were. A PDF/A or PDF/X file may no longer conform afterwards, and still says it +//! does: nothing here changes what it declares (the `doc_convert_colours` tool lists what it +//! declared; `standards_remove_id` takes that off). +//! +//! Spot colours are converted, not kept: Separation and DeviceN through a Type 2 tint transform +//! into their alternate space, any other tint transform as a gray of the strongest tint's +//! darkness (the hue is lost). `/All` converts like any other; `/None` paints nothing and stays. +//! Patterns, shadings, inline images and transparency group spaces stay as they are. +//! +//! Soft masks are not touched: a picture keeps the one it has. The helper leaves a picture alone +//! when it cannot re-encode it (indexed, CMYK, not 8-bit, an explicit mask, a matted soft mask, +//! JPEG 2000 ...); those that are not in the target already are counted in +//! [`Converted::images_left`]. + +use pdfcraft_content::{Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use std::collections::{HashMap, HashSet}; + +use crate::colour::{Space, rgb, space_of}; +use crate::page_streams::PageStreams; +use crate::{EditError, check, page_list}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColourTarget { + Gray, + Rgb, + Cmyk, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvertColours { + pub target: ColourTarget, + /// Pure black stays 100% K (or gray 0) rather than a mix. + pub preserve_black: bool, + /// Re-encode pictures too (through the caller's helper). + pub images: bool, + /// The appearance streams of the pages' comments and form fields too. + pub annotations: bool, +} + +impl Default for ConvertColours { + fn default() -> Self { + Self { target: ColourTarget::Gray, preserve_black: true, images: true, annotations: false } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Converted { + /// Colour operators rewritten. + pub colours: usize, + /// Pictures re-encoded. + pub images: usize, + /// Pictures not in the target space that could not be re-encoded and are left as they were. + pub images_left: usize, + /// Pages on which something changed. + pub pages: usize, +} + +/// Re-encode one image stream in the target space, if it can be: the caller's (the optimizer +/// knows how to decode and encode pictures; the edit crate does not). +pub type ImageRecolour<'a> = dyn FnMut(&Document, &Stream, ColourTarget) -> Option + 'a; + +fn luma(c: [f64; 3]) -> f64 { + (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]).clamp(0.0, 1.0) +} + +/// sRGB → the target's components. +pub fn components(target: ColourTarget, c: [f64; 3], preserve_black: bool) -> Vec { + let black = c.iter().all(|v| *v < 0.02); + match target { + ColourTarget::Gray => vec![if black && preserve_black { 0.0 } else { luma(c) }], + ColourTarget::Rgb => c.to_vec(), + ColourTarget::Cmyk => { + if black && preserve_black { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let k = 1.0 - c.iter().cloned().fold(0.0, f64::max); + if k >= 0.999 { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let f = |v: f64| ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0); + vec![f(c[0]), f(c[1]), f(c[2]), k] + } + } +} + +fn op_for(target: ColourTarget, stroke: bool) -> &'static str { + match (target, stroke) { + (ColourTarget::Gray, false) => "g", + (ColourTarget::Gray, true) => "G", + (ColourTarget::Rgb, false) => "rg", + (ColourTarget::Rgb, true) => "RG", + (ColourTarget::Cmyk, false) => "k", + (ColourTarget::Cmyk, true) => "K", + } +} + +/// Whether a `cs` operand names DeviceCMYK itself (directly or through a `/ColorSpace` +/// resource), whose initial colour is black, unlike an ICC CMYK space's all-zero white. +fn device_cmyk(doc: &Document, operand: &Object, cs_res: &Dict) -> bool { + let is_device = |o: &Object| match &*doc.resolve(o) { + Object::Name(n) => matches!(n.as_slice(), b"DeviceCMYK" | b"CMYK"), + Object::Array(a) => a.len() == 1 && a.first().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceCMYK" | b"CMYK")), + _ => false, + }; + match operand.as_name() { + Some(n) => is_device(operand) || cs_res.get(n).is_some_and(is_device), + None => is_device(operand), + } +} + +fn r3(v: f64) -> Object { + Object::Real((v * 1000.0).round() / 1000.0) +} + +/// The current fill and stroke spaces (None = a device space set by g/rg/k, which the +/// converted operator sets again), with the q/Q stack: what one content stream leaves for the +/// next on the same page. +#[derive(Clone, Debug, Default)] +struct State { + fill: Option, + stroke: Option, + stack: Vec<(Option, Option)>, +} + +/// Rewrite one content stream's colour operators, starting from `state` and leaving in it the +/// state at the stream's end. `cs_res` is its `/ColorSpace` resource dictionary. Returns the +/// new bytes when anything changed, and how many operators. +fn rewrite(data: &[u8], state: &mut State, doc: &Document, cs_res: &Dict, settings: &ConvertColours) -> (Option>, usize) { + let mut ops: Vec = parse(data).ops; + let mut count = 0usize; + let mut out: Vec = Vec::with_capacity(ops.len()); + let State { fill, stroke, stack } = state; + let numbers = |op: &Op| -> Vec { op.operands.iter().filter_map(Object::as_f64).collect() }; + let convert = |c: [f64; 3], is_stroke: bool| -> Op { + Op::new(op_for(settings.target, is_stroke), components(settings.target, c, settings.preserve_black).into_iter().map(r3).collect()) + }; + for op in ops.drain(..) { + match op.op.as_slice() { + b"q" => { + stack.push((fill.clone(), stroke.clone())); + if stack.len() > 512 { + stack.remove(0); + } + out.push(op); + } + b"Q" => { + if let Some((f, s)) = stack.pop() { + *fill = f; + *stroke = s; + } + out.push(op); + } + b"cs" | b"CS" => { + let is_stroke = op.op.as_slice() == b"CS"; + let space = op.operands.first().map(|o| space_of(doc, o, cs_res, 0)).unwrap_or(Space::Gray); + let pattern = space == Space::Pattern; + if is_stroke { + *stroke = Some(space); + } else { + *fill = Some(space); + } + if pattern { + // Patterns stay what they are (their own content is not converted here). + out.push(op); + } else { + // The space's initial colour, now in the target: black (a tint at full, + // DeviceCMYK's 0 0 0 1, ISO 32000-1 8.6.5.5). + let space = if is_stroke { stroke.clone() } else { fill.clone() }.unwrap_or(Space::Gray); + let initial = match space { + Space::Tint { .. } => rgb(&space, &[1.0; 8]), + Space::Cmyk if op.operands.first().is_some_and(|o| device_cmyk(doc, o, cs_res)) => rgb(&space, &[0.0, 0.0, 0.0, 1.0]), + _ => rgb(&space, &[]), + }; + out.push(convert(initial, is_stroke)); + count += 1; + } + } + b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = match op.op.to_ascii_lowercase().as_slice() { + b"g" => Space::Gray, + b"rg" => Space::Rgb, + _ => Space::Cmyk, + }; + let already = matches!( + (settings.target, &space), + (ColourTarget::Gray, Space::Gray) | (ColourTarget::Rgb, Space::Rgb) | (ColourTarget::Cmyk, Space::Cmyk) + ); + if is_stroke { + *stroke = None; + } else { + *fill = None; + } + if already { + out.push(op); + } else { + let c = rgb(&space, &numbers(&op)); + out.push(convert(c, is_stroke)); + count += 1; + } + } + b"sc" | b"scn" | b"SC" | b"SCN" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = if is_stroke { stroke.clone() } else { fill.clone() }; + match space { + Some(Space::Pattern) | None if op.operands.iter().any(|o| matches!(o, Object::Name(_))) => out.push(op), + None => { + // sc after g/rg/k: the device space those set; read the numbers by count. + let n = numbers(&op); + let space = match n.len() { + 1 => Space::Gray, + 4 => Space::Cmyk, + _ => Space::Rgb, + }; + out.push(convert(rgb(&space, &n), is_stroke)); + count += 1; + } + Some(Space::Pattern) => out.push(op), + Some(space) => { + out.push(convert(rgb(&space, &numbers(&op)), is_stroke)); + count += 1; + } + } + } + _ => out.push(op), + } + } + ((count > 0).then(|| serialize_ops(&out)), count) +} + +/// Whether `space`'s colours are `target`'s (an indexed space's are its base's). +fn in_target(space: &Space, target: ColourTarget) -> bool { + match (space, target) { + (Space::Gray, ColourTarget::Gray) | (Space::Rgb, ColourTarget::Rgb) | (Space::Cmyk, ColourTarget::Cmyk) => true, + (Space::Indexed { base, .. }, _) => in_target(base, target), + _ => false, + } +} + +/// Whether the picture `s` is outside `target`: a stencil mask has no colours of its own (it +/// paints with the fill colour, which is converted). +fn needs_converting(doc: &Document, s: &Stream, target: ColourTarget) -> bool { + let stencil = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); + !stencil && !s.dict.get(b"ColorSpace").is_some_and(|cs| in_target(&space_of(doc, cs, &Dict::default(), 0), target)) +} + +fn stream_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +fn cs_resources(doc: &Document, resources: Option<&Dict>) -> Dict { + resources.and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default() +} + +/// What a run keeps across pages: each XObject already handled, and the object that now +/// stands for it (itself, or the converted copy made because a page not being converted draws +/// it too); and the XObjects those other pages draw. +struct Run { + done: HashMap, + shared: HashSet, +} + +/// The XObjects (forms and images, and those the forms draw), and appearance streams, that +/// the pages not in `pages` draw: converting one of those in place would change those pages. +fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut todo: Vec = Vec::new(); + let xobjects_of = |res: Option<&Object>, todo: &mut Vec| { + if let Some(xo) = res + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x))) + .and_then(|x| x.as_dict().cloned()) + { + todo.extend(xo.iter().map(|(_, o)| o.clone())); + } + }; + for (i, page) in page_list(doc).into_iter().enumerate() { + if pages.contains(&i) { + continue; + } + xobjects_of(page.dict.get(b"Resources"), &mut todo); + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots { + let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue }; + let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue }; + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())), + _ => todo.push(v.clone()), + } + } + } + } + while let Some(o) = todo.pop() { + let Some(r) = o.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let res = match &*doc.get(r) { + Object::Stream(s) => s.dict.get(b"Resources").cloned(), + _ => None, + }; + xobjects_of(res.as_ref(), &mut todo); + } + seen +} + +/// Store a converted XObject: in place, or, when another page draws `r` too, as a new object +/// the caller points to instead. Returns the object that now stands for `r`. +fn put(doc: &mut Document, run: &mut Run, r: ObjRef, obj: Object) -> ObjRef { + let to = if run.shared.contains(&r) { + doc.add(obj) + } else { + doc.set(r, obj); + r + }; + run.done.insert(r, to); + to +} + +/// Convert the XObjects of `resources`: forms rewritten (and their own XObjects after them), +/// images through `recolour`. Each object is converted once; one that a page not being +/// converted draws is converted as a copy, and the returned `/XObject` dictionary, when there +/// is one, points at the copies (the caller puts it in `resources`' place). When `may_copy` is +/// false the caller cannot do that, so such objects are left alone. +fn convert_xobjects( + doc: &mut Document, + resources: Option<&Dict>, + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, + run: &mut Run, + result: &mut Converted, + may_copy: bool, +) -> Option { + let xo = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; + let mut repointed = xo.clone(); + let mut changed = false; + for (name, o) in xo.iter() { + let Some(r) = o.as_ref() else { continue }; + if let Some(&to) = run.done.get(&r) { + if to != r { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + continue; + } + if !may_copy && run.shared.contains(&r) { + continue; + } + // Marked before converting, so a form that draws itself ends. + run.done.insert(r, r); + let Some(s) = stream_of(doc, r) else { continue }; + let stored = match s.dict.name(b"Subtype") { + Some(b"Form") => { + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let cs = cs_resources(doc, res.as_ref()); + let (new, n) = match s.decoded() { + Ok(data) => rewrite(&data, &mut State::default(), doc, &cs, settings), + Err(_) => (None, 0), + }; + let inner = convert_xobjects(doc, res.as_ref(), settings, recolour, run, result, true); + if new.is_none() && inner.is_none() { + None + } else { + let mut form = s.clone(); + if let Some(xo) = inner { + let mut res = res.unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + } + if let Some(bytes) = new { + let mut dict = form.dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms"] { + dict.remove(k); + } + form = Stream::flate(dict, &bytes); + result.colours += n; + } + Some(put(doc, run, r, Object::Stream(form))) + } + } + Some(b"Image") if settings.images => match recolour(doc, &s, settings.target) { + Some(new) => { + result.images += 1; + Some(put(doc, run, r, Object::Stream(new))) + } + None => { + result.images_left += usize::from(needs_converting(doc, &s, settings.target)); + None + } + }, + _ => None, + }; + if let Some(to) = stored + && to != r + { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + } + changed.then_some(repointed) +} + +/// Convert the colours on `pages` (0-based). An object that pages not in `pages` also draw is +/// converted as a copy for these pages, so the others keep their colours. +pub fn convert_colours( + doc: &mut Document, + pages: &[usize], + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, +) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + let mut result = Converted::default(); + let mut run = Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages) }; + for &i in pages { + let Some(page) = page_list(doc).get(i).cloned() else { continue }; + let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let cs = cs_resources(doc, resources.as_ref()); + let before = (result.colours, result.images); + // Each content stream on its own (state carries across them); a page with a stream + // that cannot be read keeps its content as it is. + if let Some(streams) = PageStreams::read(doc, &page.dict) { + let mut state = State::default(); + let mut new: Vec>> = Vec::with_capacity(streams.data.len()); + for data in &streams.data { + let (bytes, n) = rewrite(data, &mut state, doc, &cs, settings); + result.colours += n; + new.push(bytes); + } + streams.write(doc, page.obj, new)?; + } + if let Some(xo) = convert_xobjects(doc, resources.as_ref(), settings, recolour, &mut run, &mut result, true) { + // This page's own resources point at the copies; a resource dictionary other pages + // share is not changed. + let mut res = resources.clone().unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + if settings.annotations { + let annots: Vec = page + .dict + .get(b"Annots") + .map(|a| doc.resolve(a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|a| a.as_ref()) + .collect(); + for a in annots { + let Some(ap) = + doc.get(a).as_dict().and_then(|d| d.get(b"AP").cloned()).map(|ap| doc.resolve(&ap)).and_then(|ap| ap.as_dict().cloned()) + else { + continue; + }; + // Every appearance stream (and the states of a /N dictionary) as a form. + let mut streams: Vec = Vec::new(); + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Stream(_) => streams.extend(v.as_ref()), + Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())), + _ => {} + } + } + let mut fake = Dict::new(); + let mut xo = Dict::new(); + for (k, r) in streams.iter().enumerate() { + xo.set(format!("A{k}").into_bytes(), Object::Ref(*r)); + } + fake.set(b"XObject".to_vec(), Object::Dict(xo)); + // An appearance stream other pages draw is left alone (there is no resource + // dictionary here to point at a copy). + convert_xobjects(doc, Some(&fake), settings, recolour, &mut run, &mut result, false); + } + } + if (result.colours, result.images) != before { + result.pages += 1; + } + } + Ok(result) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index aa11617c14..1dff30af3c 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -54,6 +54,10 @@ pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; +mod colour; +pub mod convert_colours; +mod page_streams; +pub use convert_colours::{ColourTarget, ConvertColours, Converted as ColoursConverted, ImageRecolour, convert_colours}; pub mod added; pub use added::{ Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added, diff --git a/crates/edit/src/page_streams.rs b/crates/edit/src/page_streams.rs new file mode 100644 index 0000000000..eae00399da --- /dev/null +++ b/crates/edit/src/page_streams.rs @@ -0,0 +1,69 @@ +//! A page's content streams rewritten one at a time: each stream keeps its own dictionary +//! (and so its marks, such as `/PCMark`), and only the streams that changed are replaced. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::EditError; + +/// The decoded content streams of one page, in drawing order. +pub(crate) struct PageStreams { + /// Each stream's object and dictionary. + entries: Vec<(ObjRef, Dict)>, + /// Each stream's decoded data, in the same order. + pub(crate) data: Vec>, +} + +impl PageStreams { + /// The streams of `page_dict`'s `/Contents`. `None` when one of them is not a stream or + /// cannot be decoded: rewriting the others would draw the page differently, so the page is + /// left alone. A page without content has no streams. + pub(crate) fn read(doc: &Document, page_dict: &Dict) -> Option { + let refs: Vec = match page_dict.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + let mut entries = Vec::with_capacity(refs.len()); + let mut data = Vec::with_capacity(refs.len()); + for e in &refs { + let r = e.as_ref()?; + let (dict, bytes) = match &*doc.get(r) { + Object::Stream(s) => (s.dict.clone(), s.decoded().ok()?), + _ => return None, + }; + data.push(bytes); + entries.push((r, dict)); + } + Some(Self { entries, data }) + } + + /// Put `new` in place of the streams that changed (`None` keeps a stream as it is). Each + /// replacement is a new object with the old dictionary (minus its encoding), so a stream + /// another page shares is not touched. Returns whether anything changed. + pub(crate) fn write(self, doc: &mut Document, page: ObjRef, new: Vec>>) -> Result { + if new.iter().all(Option::is_none) { + return Ok(false); + } + let mut refs: Vec = Vec::with_capacity(self.entries.len()); + for (i, (r, dict)) in self.entries.into_iter().enumerate() { + match new.get(i).and_then(Option::as_ref) { + Some(bytes) => { + let mut dict = dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms", b"DL"] { + dict.remove(k); + } + refs.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, bytes))))); + } + None => refs.push(Object::Ref(r)), + } + } + let contents = match <[Object; 1]>::try_from(refs) { + Ok([one]) => one, + Err(many) => Object::Array(many), + }; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(true) + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f7740d60cd..49b2d32132 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7816,6 +7816,67 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Convert colours: device, ICC-based and separation colours in page content +/// and forms become the target's operators; black is preserved; patterns stay; a second run +/// finds nothing. +#[test] +fn colours_are_converted_to_gray_rgb_and_cmyk() { + use crate::convert_colours::{ColourTarget, ConvertColours, convert_colours}; + let mut doc = fixture(); + let page = page_list(&doc)[0].clone(); + // An ICCBased RGB space and a spot colour (Separation to CMYK, Type 2 tint). + let mut icc = Dict::new(); + icc.set(b"N".to_vec(), Object::Int(3)); + let icc_ref = doc.add(Object::Stream(Stream::flate(icc, b"not a real profile"))); + let mut fun = Dict::new(); + fun.set(b"FunctionType".to_vec(), Object::Int(2)); + fun.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])); + fun.set(b"C0".to_vec(), Object::Array(vec![Object::Int(0); 4])); + fun.set(b"C1".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(1), Object::Int(0)])); + fun.set(b"N".to_vec(), Object::Int(1)); + let mut cs = Dict::new(); + cs.set(b"CS0".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(icc_ref)])); + cs.set(b"Spot".to_vec(), Object::Array(vec![Object::name("Separation"), Object::name("Red"), Object::name("DeviceCMYK"), Object::Dict(fun)])); + let mut res = Dict::new(); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + let content = b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S /CS0 cs 0 1 0 scn 0 0 20 20 re f /Spot cs 1 scn 30 30 20 20 re f 0 g 60 60 10 10 re f 0.5 g 70 70 5 5 re f"; + let c = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(c)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + let mut no_images = |_: &Document, _: &Stream, _: ColourTarget| None; + let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::>().join(" "); + // Grayscale. + let mut gray = doc.clone(); + let done = convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!((done.colours, done.pages), (6, 1), "{done:?}"); + let t = flat(&page_content_bytes(&gray, 0)); + assert!(t.contains("0.299 g 10 10 50 50 re f"), "red → its luma: {t}"); + assert!(t.contains("0.114 G 0 0 m 100 100 l S"), "blue stroke: {t}"); + assert!(t.contains("0 g 0 1 0 scn") || t.contains("0.587 g 0 0 20 20 re f"), "ICC green through its space: {t}"); + assert!(t.contains("0.587 g 0 0 20 20 re f"), "{t}"); + assert!(t.contains("0.299 g 30 30 20 20 re f"), "the spot's tint through its CMYK alternate (0 1 1 0 = red): {t}"); + assert!(t.contains("0 g 60 60 10 10 re f") && t.contains("0.5 g 70 70 5 5 re f"), "gray stays gray: {t}"); + assert!(!t.contains("/CS0 cs") && !t.contains("/Spot cs"), "named spaces are gone: {t}"); + assert_eq!(convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0, "nothing left"); + // CMYK with black preserved; without, black is still 100% K by the formula. + let mut cmyk = doc.clone(); + let s = ConvertColours { target: ColourTarget::Cmyk, ..ConvertColours::default() }; + convert_colours(&mut cmyk, &[0], &s, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&cmyk, 0)); + assert!(t.contains("0 1 1 0 k 10 10 50 50 re f"), "red: {t}"); + assert!(t.contains("0 0 0 1 k 60 60 10 10 re f"), "black preserved: {t}"); + assert!(t.contains("0 0 0 0.5 k 70 70 5 5 re f"), "gray 0.5 → 50% K: {t}"); + // RGB: the spot becomes its red. + let mut rgbd = doc.clone(); + convert_colours(&mut rgbd, &[0], &ConvertColours { target: ColourTarget::Rgb, ..ConvertColours::default() }, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&rgbd, 0)); + assert!(t.contains("1 0 0 rg 30 30 20 20 re f"), "{t}"); + assert!(t.contains("1 0 0 rg 10 10 50 50 re f"), "already RGB, kept: {t}"); +} + /// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup /// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, /// TrapNet, Watermark), which it used to bake into the page and delete with their data. diff --git a/crates/edit/tests/it/convert_colours_cmyk_initial.rs b/crates/edit/tests/it/convert_colours_cmyk_initial.rs new file mode 100644 index 0000000000..277aaaaf20 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_cmyk_initial.rs @@ -0,0 +1,71 @@ +//! Convert colours: `/DeviceCMYK cs` starts at black (0 0 0 1, ISO 32000-1 8.6.5.5), so the +//! converted operator is black too, not the white that four zeros would be. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +fn page_with(content: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + &format!("<< >>stream:{content}"), + ]) +} + +#[test] +fn device_cmyk_starts_at_black() { + let mut doc = page_with("/DeviceCMYK cs 0 0 10 10 re f /DeviceCMYK CS 0 0 m 10 10 l S"); + let s = ConvertColours { preserve_black: false, ..ConvertColours::default() }; + let done = convert_colours(&mut doc, &[0], &s, &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + let t = stream_text(&doc, contents(&doc, 0)[0]); + assert!(t.contains("0 g 0 0 10 10 re f") && t.contains("0 G 0 0 m 10 10 l S"), "black, not white: {t}"); +} diff --git a/crates/edit/tests/it/convert_colours_shared.rs b/crates/edit/tests/it/convert_colours_shared.rs new file mode 100644 index 0000000000..bd373011d6 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_shared.rs @@ -0,0 +1,144 @@ +//! Convert colours on some pages leaves the others as they were: a form or picture they share +//! is converted as a copy for the pages being converted, and the resource dictionary they +//! share is not changed. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// Two pages sharing one resource dictionary (inherited from the page tree) whose form draws +/// red, and a form inside it that draws blue. +fn two_pages() -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] /Resources 5 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /XObject << /Fm1 7 0 R >> >>", + "<< >>stream:/Fm1 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm2 8 0 R >> >> >>stream:1 0 0 rg 0 0 10 10 re f /Fm2 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>stream:0 0 1 rg 0 0 5 5 re f", + ]) +} + +fn form_of(doc: &Document, page: usize, name: &[u8]) -> ObjRef { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap() +} + +fn inner_form(doc: &Document, outer: ObjRef) -> ObjRef { + match &*doc.get(outer) { + Object::Stream(s) => { + let res = doc.resolve(s.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(b"Fm2").and_then(Object::as_ref).unwrap() + } + _ => panic!("not a stream"), + } +} + +#[test] +fn a_shared_form_is_converted_as_a_copy() { + let mut doc = two_pages(); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + // The other page still draws the original forms, in their colours. + let kept = form_of(&doc, 1, b"Fm1"); + assert!(stream_text(&doc, kept).contains("1 0 0 rg 0 0 10 10 re f"), "{}", stream_text(&doc, kept)); + assert!(stream_text(&doc, inner_form(&doc, kept)).contains("0 0 1 rg 0 0 5 5 re f")); + // The converted page draws copies, the inner one too. + let copy = form_of(&doc, 0, b"Fm1"); + assert_ne!(copy, kept); + assert!(stream_text(&doc, copy).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, copy)); + assert!(stream_text(&doc, inner_form(&doc, copy)).contains("0.114 g 0 0 5 5 re f")); +} + +#[test] +fn converting_every_page_changes_the_forms_in_place() { + let mut doc = two_pages(); + let before = form_of(&doc, 0, b"Fm1"); + convert_colours(&mut doc, &[0, 1], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(form_of(&doc, 0, b"Fm1"), before); + assert_eq!(form_of(&doc, 1, b"Fm1"), before); + assert!(stream_text(&doc, before).contains("0.299 g 0 0 10 10 re f")); +} + +/// One page of `content` with `res` as its resources, followed by `more` objects from 5 up. +fn page_of(res: &str, content: &str, more: &[&str]) -> Document { + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources {res} >>"); + let content = format!("<< >>stream:{content}"); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", page.as_str(), content.as_str()]; + objs.extend_from_slice(more); + pdf(&objs) +} + +/// The `/None` colorant paints nothing; a colour of its own would make it appear. +#[test] +fn the_none_colorant_stays() { + let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>"; + let res = format!( + "<< /ColorSpace << /S [/Separation /None /DeviceCMYK {tint}] /D [/DeviceN [/None /None] /DeviceCMYK {tint}] /Real [/Separation /Red /DeviceCMYK {tint}] >> >>" + ); + let content = "/S cs 1 scn 0 0 9 9 re f /D cs 1 1 scn 0 0 9 9 re f"; + let mut doc = page_of(&res, content, &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0); + assert_eq!(stream_text(&doc, ObjRef { num: 4, generation: 0 }), content); + let mut doc = page_of(&res, "/Real cs 1 scn 0 0 9 9 re f", &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 2, "a real spot is converted"); +} + +/// Pictures the helper cannot re-encode are counted, unless they are in the target already or +/// have no colours of their own. +#[test] +fn pictures_left_alone_are_counted() { + const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1"; + let indexed = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 0 ] >>stream:x"); + let gray_table = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceGray 0 <80>] >>stream:x"); + let gray = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace /DeviceGray >>stream:x"); + let stencil = format!("<< {IMAGE} /BitsPerComponent 1 /ImageMask true >>stream:x"); + let res = "<< /XObject << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >>"; + let mut doc = page_of(res, "/A Do /B Do /C Do /D Do", &[&indexed, &gray_table, &gray, &stencil]); + let mut none = doc.clone(); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().images_left, 1); + let s = ConvertColours { images: false, ..ConvertColours::default() }; + assert_eq!(convert_colours(&mut none, &[0], &s, &mut no_images).unwrap().images_left, 0, "pictures were not asked for"); +} diff --git a/crates/edit/tests/it/convert_colours_streams.rs b/crates/edit/tests/it/convert_colours_streams.rs new file mode 100644 index 0000000000..edad0a32e8 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_streams.rs @@ -0,0 +1,100 @@ +//! Convert colours rewrites each of a page's content streams on its own: a stream keeps its +//! dictionary (its `/PCMark` among it), unchanged streams stay as they are, the colour space +//! set in one stream holds in the next, and a page with an unreadable stream is left alone. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// One page drawing `streams`, with an indexed colour space `/CS0` (0 = red, 1 = green). +fn page_with(streams: &[&str]) -> Document { + let refs: Vec = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << /CS0 [/Indexed /DeviceRGB 1 ] >> >> /Contents [{}] >>", + refs.join(" ") + ); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", &page]; + objs.extend_from_slice(streams); + pdf(&objs) +} + +#[test] +fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() { + let mut doc = page_with(&["<< >>stream:0.5 g 0 0 10 10 re f", "<< /PCMark /Redaction >>stream:1 0 0 rg 0 0 10 10 re f"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 1); + let after = contents(&doc, 0); + assert_eq!(after.len(), 2, "still two streams"); + assert_eq!(after[0], before[0], "the unchanged stream is the same object"); + let mark = match &*doc.get(after[1]) { + Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec), + _ => None, + }; + assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept"); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f")); +} + +#[test] +fn the_colour_space_carries_from_one_stream_to_the_next() { + // Index 0 of the space set in the first stream is red; read as a gray, it would be black. + let mut doc = page_with(&["<< >>stream:/CS0 cs", "<< >>stream:0 sc 0 0 10 10 re f"]); + convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + let after = contents(&doc, 0); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, after[1])); +} + +#[test] +fn a_page_with_an_unreadable_stream_is_left_alone() { + let mut doc = page_with(&["<< >>stream:1 0 0 rg 0 0 10 10 re f", "<< /Filter /DCTDecode >>stream:not a picture"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 0); + assert_eq!(contents(&doc, 0), before, "no stream is dropped"); +} diff --git a/crates/edit/tests/it/main.rs b/crates/edit/tests/it/main.rs new file mode 100644 index 0000000000..b64db49da1 --- /dev/null +++ b/crates/edit/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod convert_colours_cmyk_initial; +mod convert_colours_shared; +mod convert_colours_streams; diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e479232bbb..6746a41fa7 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -409,7 +409,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("Output preview", "layers", "prepress.output_preview", Planned("M11")), item("Preflight", "file-check", "preflight.run", Planned("M11")), - item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), + item("Convert colours", "palette", "prepress.convert_colors", Ready), item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0647726452..d482da896e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -397,6 +397,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.convert_colors", "Convert colours…", None, None, Modification, "palette"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..da88048160 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -317,6 +317,11 @@ pub enum Edit { DeleteAttachment { key: String, }, + /// Print production ▸ Convert colours: every colour on the pages in grayscale, RGB or CMYK. + ConvertColours { + pages: Vec, + settings: pdfcraft_edit::ConvertColours, + }, /// Build a tagged structure tree for an untagged, text-only document. AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5343804ef3..b024ff7032 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -240,6 +240,7 @@ impl Edit { Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::ConvertColours { .. } => Label::of("Convert colours"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::AutoTagFormFields => Label::of("Tag form fields"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 5ed70aed7a..14ded42d55 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -85,6 +85,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Err(EditError::NotPermitted("changing security")) } } + // Converting the appearances of comments and form fields changes them too, which + // content changes alone do not allow. + Edit::ConvertColours { settings, .. } if settings.annotations && !(p.modify() && p.annotate()) => { + Err(EditError::NotPermitted(if p.modify() { "comments" } else { "changes to the document" })) + } // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted // document it is a change of security (#3921). Edit::ConvertPdfA { .. } => { @@ -128,6 +133,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::SetInitialView(_) | Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } + | Edit::ConvertColours { .. } | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::AutoTagFormFields @@ -524,6 +530,20 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ConvertColours { pages, settings } => { + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + let done = pdfcraft_edit::convert_colours(doc, pages, settings, &mut recolour)?; + if done.colours == 0 && done.images == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("nothing on those pages needed converting".into()))); + } + } Edit::ReplaceText { page, quads, text, author, strike, caret } => { // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by // the strikeout's `/NM` (#2666). diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..f70f12a5c0 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -86,7 +86,8 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, + | Edit::RemoveMarks { .. } + | Edit::ConvertColours { .. } => Scope::Content, Edit::Batch { edits, .. } => { let mut scopes = edits.iter().map(scope_of); let first = scopes.next().unwrap_or(Scope::Full); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..b0bc160635 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -86,7 +86,8 @@ pub use pdfcraft_edit::added::{ }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, ColourTarget, ColoursConverted, Content as AddedContent, ConvertColours, + Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 34d75e5c82..284997ff2c 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -114,6 +114,24 @@ impl Session { .map_err(EditError::Write) } + /// What Convert colours would change on `pages` of `id`, without changing the document. + pub fn count_colours(&self, id: DocId, pages: &[usize], settings: &pdfcraft_edit::ConvertColours) -> pdfcraft_edit::ColoursConverted { + let Some(doc) = self.get(id) else { return Default::default() }; + let Some(editor) = doc.editor.as_ref() else { return Default::default() }; + let mut cos = editor.cos.clone(); + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + // The preview runs the image decoders over the document's own bytes; a panic in one is + // a count of nothing, not a crash. + guard(|| pdfcraft_edit::convert_colours(&mut cos, pages, settings, &mut recolour).unwrap_or_default()).unwrap_or_default() + } + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a2bae4ff9..3cc2b408e8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2200,6 +2200,29 @@ fn highlight_multiplies_over_text() { assert!(bg[0] > 240 && bg[1] > 240 && bg[2] < 30, "{bg:?}"); } +/// Converting the appearances of comments and form fields needs the permission to comment; +/// converting the page alone needs only the permission to modify. +#[test] +fn converting_colours_of_comments_needs_the_comment_permission() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let params = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "own", + permissions: !(1 << 5), + encrypt_metadata: true, + seed: [7; 32], + }; + cos.set_encryption(¶ms).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("locked.pdf", None, Arc::new(bytes), None).unwrap(); + let with = ConvertColours { annotations: true, ..ConvertColours::default() }; + assert_eq!(s.apply(id, Edit::ConvertColours { pages: vec![0], settings: with }), Err(EditError::NotPermitted("comments"))); + let without = s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }); + assert!(!matches!(without, Err(EditError::NotPermitted(_))), "{without:?}"); +} + #[test] fn comment_permission_is_enforced() { let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); @@ -8581,6 +8604,41 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } +/// Convert colours re-encodes pictures too (RGB → gray, the JPEG staying JPEG) and is one undo +/// step; a document with nothing to convert refuses rather than leaving an empty step. +#[test] +fn converting_colours_recolours_pictures() { + let mut s = Session::new(); + let mut px = image::RgbImage::new(8, 8); + for p in px.pixels_mut() { + *p = image::Rgb([200, 40, 40]); + } + let mut jpg = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(px).write_to(&mut jpg, image::ImageFormat::Jpeg).unwrap(); + let pic = s.create_from_images(&[("p.jpg".into(), jpg.into_inner())]).unwrap(); + let id = s.open("p.pdf", None, pic, None).unwrap(); + // The picture's colour space and filter, read from the document's current bytes. + let picture = |s: &Session| -> (String, String) { + let doc = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + doc.scan_objects() + .find_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => Some(( + String::from_utf8_lossy(st.dict.name(b"ColorSpace").unwrap_or_default()).into_owned(), + String::from_utf8_lossy(st.dict.name(b"Filter").unwrap_or_default()).into_owned(), + )), + _ => None, + }) + .expect("the picture") + }; + assert_eq!(picture(&s).0, "DeviceRGB"); + s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Convert colours")); + assert_eq!(picture(&s), ("DeviceGray".to_string(), "DCTDecode".to_string())); + assert!(matches!(s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }), Err(EditError::Edit(_)))); + s.undo(id).unwrap(); + assert_eq!(picture(&s).0, "DeviceRGB"); +} + /// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). fn phone_jpeg() -> Vec { use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 613cc3f26f..0cb710111b 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1305,6 +1305,76 @@ fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { Ok(()) } +/// The space a picture is re-encoded in by [`recolour_image`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageTarget { + Gray, + Rgb, + Cmyk, +} + +/// Re-encode an 8-bit gray or RGB picture (Flate or JPEG, no mask, no decode array) in +/// `target`: gray and RGB pictures keep their compression (JPEG stays JPEG, compressed a second +/// time at quality 80, so with some loss), CMYK is written lossless. A soft mask stays as it is, +/// and so does a picture whose soft mask has a `/Matte`, which is a colour in the picture's own +/// space. `None` when the picture is not one this handles, or already in the target space. +pub fn recolour_image(doc: &Document, s: &Stream, target: ImageTarget) -> Option { + let d = &s.dict; + if d.contains(b"Mask") || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) || d.int(b"BitsPerComponent") != Some(8) { + return None; + } + if d.get(b"SMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Stream(m) if m.dict.contains(b"Matte"))) { + return None; + } + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, 3)) && d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, 1)) { + return None; + } + let n = components(doc, d.get(b"ColorSpace")?)?; + let want = match target { + ImageTarget::Gray => 1, + ImageTarget::Rgb => 3, + ImageTarget::Cmyk => 4, + }; + if n == want { + return None; + } + let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + let px = pixels(doc, s, n)?; + let was_dct = is_dct(s)?; + let out: Vec = match (n, target) { + (3, ImageTarget::Gray) => { + px.as_chunks::<3>().0.iter().map(|p| ((299 * p[0] as u32 + 587 * p[1] as u32 + 114 * p[2] as u32) / 1000) as u8).collect() + } + (1, ImageTarget::Rgb) => px.iter().flat_map(|g| [*g, *g, *g]).collect(), + (1, ImageTarget::Cmyk) => px.iter().flat_map(|g| [0, 0, 0, 255 - *g]).collect(), + (3, ImageTarget::Cmyk) => px + .as_chunks::<3>() + .0 + .iter() + .flat_map(|p| { + let k = 255 - p.iter().copied().max().unwrap_or(0) as u32; + if k >= 255 { + return [0u8, 0, 0, 255]; + } + let f = |v: u8| (((255 - v as u32 - k) * 255) / (255 - k)).min(255) as u8; + [f(p[0]), f(p[1]), f(p[2]), k as u8] + }) + .collect(), + _ => return None, + }; + let mut dict = d.clone(); + dict.set( + b"ColorSpace".to_vec(), + Object::name(match target { + ImageTarget::Gray => "DeviceGray", + ImageTarget::Rgb => "DeviceRGB", + ImageTarget::Cmyk => "DeviceCMYK", + }), + ); + let compression = if want == 4 { Compression::Flate } else { Compression::Retain }; + image_stream(&dict, &out, want, w, h, compression, was_dct) +} + #[cfg(test)] mod dimension_tests { use super::*; diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 4c8845c82d..552c0a84b9 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -39,7 +39,7 @@ pub use audit::{SpaceCategory, SpaceUse, audit_space}; use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; use pdfcraft_filters::{Filter, Params}; -pub use images::effective_resolutions; +pub use images::{ImageTarget, effective_resolutions, recolour_image}; #[derive(Debug, thiserror::Error)] pub enum OptimizeError { diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 5bfa74aa8b..b384d34b62 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3741,3 +3741,29 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin assert_eq!(render(&saved), before, "local halftone {side}x{side}"); } } + +/// Recolouring keeps a picture's soft mask, but not a picture whose mask has a `/Matte`: that is +/// a colour in the picture's own space. +#[test] +fn recolouring_keeps_soft_masks_and_leaves_matted_pictures() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + use pdfcraft_optimize::{ImageTarget, recolour_image}; + let mut doc = Document::new_empty(); + let picture = |mask| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(1)); + d.set(b"Height".to_vec(), Object::Int(1)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::flate(d, &[255, 0, 0]) + }; + let plain = doc.add(Object::Stream(Stream::flate(Dict::new(), &[128]))); + let new = recolour_image(&doc, &picture(plain), ImageTarget::Gray).unwrap(); + assert_eq!(new.dict.get(b"SMask"), Some(&Object::Ref(plain))); + let mut matted = Dict::new(); + matted.set(b"Matte".to_vec(), Object::Array(vec![Object::Int(0); 3])); + let matted = doc.add(Object::Stream(Stream::flate(matted, &[128]))); + assert!(recolour_image(&doc, &picture(matted), ImageTarget::Gray).is_none()); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 43d0066e63..e8f5b86fc3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -295,6 +295,7 @@ impl PdfCraftApp { replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, word_count_draft: word_count_ui::Draft::default(), + convert_colours_draft: Default::default(), } } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index e4078efd13..d43c1d7de7 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -135,6 +135,7 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "convert-colours" => Some(Dialog::ConvertColours), "page-properties" => { // Same path as the panel, so the selected pages and their order are seeded. self.execute("page.properties"); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index f072b7febe..316e773971 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -209,6 +209,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "prepress.convert_colors" => self.dialog = Some(Dialog::ConvertColours), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 42bed83657..4da20bb40d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,7 @@ use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, wid pub(super) mod appearance; pub mod bookmark_styles_ui; pub(super) mod comment_props; +pub(super) mod convert_colours_ui; pub(super) mod credits; pub(super) mod marks_ui; pub(super) mod pageboxes; @@ -39,6 +40,7 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + colours_now: bool, marks_now: bool, export_now: bool, props_now: bool, @@ -423,6 +425,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::ConvertColours => { + let (go, cancel) = crate::convert_colours_ui::body(ui, app, &t); + req.colours_now = go; + req.close = go || cancel; + return false; + } Dialog::BookmarksFromTextStyles => { req.close = crate::bookmark_styles_ui::body(ui, app, &t); return false; @@ -1922,6 +1930,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.colours_now { + app.convert_colours(); + } if req.apply_number && let Some(edit) = req.number_now.take() { diff --git a/crates/ui-egui/src/dialogs/convert_colours_ui.rs b/crates/ui-egui/src/dialogs/convert_colours_ui.rs new file mode 100644 index 0000000000..3c1ad198b0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/convert_colours_ui.rs @@ -0,0 +1,141 @@ +//! Print production ▸ Convert colours (Acrobat's Convert Colors): the target +//! space, what to convert, the pages. Convert counts first and says what changed. + +use pdfcraft_engine::{ColourTarget, ConvertColours, DocId, Edit}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvertColoursDraft { + pub settings: ConvertColours, + pub range: Range, + /// The document `range` was chosen for. + pub doc: Option, +} + +impl Default for ConvertColoursDraft { + fn default() -> Self { + Self { settings: ConvertColours::default(), range: Range::All, doc: None } + } +} + +impl ConvertColoursDraft { + /// A range chosen for another document (pages 4–5 of a long one) means nothing on this one. + pub fn for_document(&mut self, id: DocId) { + if self.doc != Some(id) { + self.range = Range::All; + self.doc = Some(id); + } + } + + pub fn pages(&self, current: usize, count: usize) -> Vec { + match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + } + } +} + +/// The dialog: (convert, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let d = &mut app.convert_colours_draft; + d.for_document(id); + ui.label(egui::RichText::new(tl!("Convert colours")).font(theme::semibold(18.0))); + ui.add_space(8.0); + widgets::section_title(ui, tl!("Convert to")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Gray, tl!("Grayscale")); + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Rgb, tl!("RGB")); + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Cmyk, tl!("CMYK")); + }); + ui.label(egui::RichText::new(tl!("By the standard device formulas; profiles are not applied.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Options")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.preserve_black, tl!("Preserve black"))); + ui.label(egui::RichText::new(tl!("pure black stays 100% K or gray 0 instead of a mix")).small().color(t.text_muted)); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.images, tl!("Pictures too"))); + ui.label(egui::RichText::new(tl!("re-encoded in the target space; JPEGs stay JPEG")).small().color(t.text_muted)); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.annotations, tl!("Comments and form fields"))); + ui.label(egui::RichText::new(tl!("their appearances as well as the page")).small().color(t.text_muted)); + }); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + let from = ui.add(widgets::radio(on, tl!("From"))); + if from.clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))).labelled_by(from.id); + let to = ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))).labelled_by(to.id); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(count > 0, |ui| widgets::pill_button(ui, tl!("Convert"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Convert: count first (the edit refuses when nothing changes), then apply and say. + pub(crate) fn convert_colours(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let count = self.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let pages = self.convert_colours_draft.pages(self.views[i].current, count); + let settings = self.convert_colours_draft.settings.clone(); + let found = self.session.count_colours(id, &pages, &settings); + if found.colours == 0 && found.images == 0 { + self.notify_tr("Nothing on those pages needed converting"); + return; + } + if self.apply_edit(Edit::ConvertColours { pages, settings }) { + self.notify_fmt( + "Converted {c} colours and {i} pictures on {p} pages", + &[("c", &found.colours.to_string()), ("i", &found.images.to_string()), ("p", &found.pages.to_string())], + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_page_range_does_not_follow_the_user_to_another_document() { + let (a, b) = (DocId(1), DocId(2)); + let mut d = ConvertColoursDraft::default(); + d.for_document(a); + d.range = Range::Pages(4, 5); + d.for_document(a); + assert_eq!(d.range, Range::Pages(4, 5), "same document keeps its range"); + d.for_document(b); + assert_eq!(d.range, Range::All); + assert_eq!(d.pages(0, 1), vec![0]); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1b08929307..a5bf201cb5 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2815,6 +2815,21 @@ certificate Version الإصدار Edit this PDF تحرير هذا الملف PDF Export this PDF تصدير هذا الملف PDF Find text or tools البحث عن نص أو أدوات + By the standard device formulas; profiles are not applied. بالمعادلات القياسية للأجهزة؛ لا تُطبَّق ملفات التعريف. + CMYK CMYK + Comments and form fields التعليقات وحقول النماذج + Convert colours تحويل الألوان + Convert colours… تحويل الألوان… + Convert to التحويل إلى + Converted {c} colours and {i} pictures on {p} pages تم تحويل {c} لونًا و{i} صورة في {p} صفحة + Grayscale تدرج الرمادي + Nothing on those pages needed converting لا شيء في تلك الصفحات يحتاج إلى تحويل + Pictures too الصور أيضًا + Preserve black الحفاظ على الأسود + RGB RGB + pure black stays 100% K or gray 0 instead of a mix يبقى الأسود الخالص 100% K أو رمادي 0 بدلًا من مزيج + re-encoded in the target space; JPEGs stay JPEG يُعاد ترميزها في الفضاء الهدف؛ تبقى صور JPEG بصيغة JPEG + their appearances as well as the page مظاهرها إلى جانب الصفحة Create & Edit الإنشاء والتحرير Review & Compare المراجعة والمقارنة Forms & Signatures النماذج والتوقيعات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e269febf18..7cd603a517 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2502,6 +2502,21 @@ animation effects Off Изключени Edit this PDF Редактирай този PDF Export this PDF Експортирай този PDF Find text or tools Търсене на текст или инструменти + By the standard device formulas; profiles are not applied. По стандартните формули за устройства; профили не се прилагат. + CMYK CMYK + Comments and form fields Коментари и полета на формуляри + Convert colours Преобразуване на цветовете + Convert colours… Преобразуване на цветовете… + Convert to Преобразуване в + Converted {c} colours and {i} pictures on {p} pages Преобразувани {c} цвята и {i} изображения на {p} страници + Grayscale Нюанси на сивото + Nothing on those pages needed converting Нищо на тези страници не се нуждаеше от преобразуване + Pictures too И изображенията + Preserve black Запазване на черното + RGB RGB + pure black stays 100% K or gray 0 instead of a mix чистото черно остава 100% K или сиво 0 вместо смес + re-encoded in the target space; JPEGs stay JPEG прекодирани в целевото пространство; JPEG остават JPEG + their appearances as well as the page техният вид, както и страницата Create & Edit Създаване и редактиране Review & Compare Преглед и сравнение Forms & Signatures Формуляри и подписи diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 7ff0f83fee..a799a1eba3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2846,6 +2846,21 @@ theme Appearance Aspecte Unpin tool Desenganxa l'eina Arrange image Organitza la imatge Crop Escapça + By the standard device formulas; profiles are not applied. Segons les fórmules estàndard del dispositiu; no s'apliquen perfils. + CMYK CMYK + Comments and form fields Comentaris i camps de formulari + Convert colours Converteix els colors + Convert colours… Converteix els colors… + Convert to Converteix a + Converted {c} colours and {i} pictures on {p} pages S'han convertit {c} colors i {i} imatges en {p} pàgines + Grayscale Escala de grisos + Nothing on those pages needed converting No hi havia res a convertir en aquestes pàgines + Pictures too També les imatges + Preserve black Conserva el negre + RGB RGB + pure black stays 100% K or gray 0 instead of a mix el negre pur es manté al 100 % K o gris 0 en lloc d'una barreja + re-encoded in the target space; JPEGs stay JPEG es recodifiquen a l'espai de destinació; els JPEG continuen sent JPEG + their appearances as well as the page tant les seves aparences com la pàgina # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 22f6e3e056..500f3ff586 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2613,6 +2613,21 @@ animation effects Off Vypnuto Edit this PDF Upravit toto PDF Export this PDF Exportovat toto PDF Find text or tools Hledat text nebo nástroje + By the standard device formulas; profiles are not applied. Podle standardních vzorců zařízení; profily se nepoužijí. + CMYK CMYK + Comments and form fields Komentáře a pole formuláře + Convert colours Převést barvy + Convert colours… Převést barvy… + Convert to Převést na + Converted {c} colours and {i} pictures on {p} pages Převedeno {c} barev a {i} obrázků na {p} stránkách + Grayscale Odstíny šedi + Nothing on those pages needed converting Na těchto stránkách nebylo co převádět + Pictures too I obrázky + Preserve black Zachovat černou + RGB RGB + pure black stays 100% K or gray 0 instead of a mix čistá černá zůstane 100 % K nebo šedá 0 místo směsi + re-encoded in the target space; JPEGs stay JPEG překódované do cílového prostoru; JPEG zůstanou JPEG + their appearances as well as the page jejich vzhled i stránka Create & Edit Vytváření a úpravy Review & Compare Revize a porovnání Forms & Signatures Formuláře a podpisy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c9ce7ae36..8019dd7f6f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2540,6 +2540,21 @@ animation effects Off Aus Edit this PDF Dieses PDF bearbeiten Export this PDF Dieses PDF exportieren Find text or tools Text oder Werkzeuge suchen + By the standard device formulas; profiles are not applied. Nach den Standard-Geräteformeln; Profile werden nicht angewendet. + CMYK CMYK + Comments and form fields Kommentare und Formularfelder + Convert colours Farben konvertieren + Convert colours… Farben konvertieren… + Convert to Konvertieren in + Converted {c} colours and {i} pictures on {p} pages {c} Farben und {i} Bilder auf {p} Seiten konvertiert + Grayscale Graustufen + Nothing on those pages needed converting Auf diesen Seiten war nichts zu konvertieren + Pictures too Auch Bilder + Preserve black Schwarz beibehalten + RGB RGB + pure black stays 100% K or gray 0 instead of a mix reines Schwarz bleibt 100 % K oder Grau 0 statt einer Mischung + re-encoded in the target space; JPEGs stay JPEG im Zielfarbraum neu kodiert; JPEGs bleiben JPEG + their appearances as well as the page ihre Darstellungen sowie die Seite Create & Edit Erstellen & Bearbeiten Review & Compare Prüfen & Vergleichen Forms & Signatures Formulare & Signaturen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d83bf4aef7..ecd37a612b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2546,6 +2546,21 @@ animation effects Off Ανενεργά Edit this PDF Επεξεργασία αυτού του PDF Export this PDF Εξαγωγή αυτού του PDF Find text or tools Εύρεση κειμένου ή εργαλείων + By the standard device formulas; profiles are not applied. Με τους τυπικούς τύπους συσκευών· δεν εφαρμόζονται προφίλ. + CMYK CMYK + Comments and form fields Σχόλια και πεδία φόρμας + Convert colours Μετατροπή χρωμάτων + Convert colours… Μετατροπή χρωμάτων… + Convert to Μετατροπή σε + Converted {c} colours and {i} pictures on {p} pages Μετατράπηκαν {c} χρώματα και {i} εικόνες σε {p} σελίδες + Grayscale Κλίμακα του γκρι + Nothing on those pages needed converting Τίποτα σε αυτές τις σελίδες δεν χρειαζόταν μετατροπή + Pictures too Και οι εικόνες + Preserve black Διατήρηση του μαύρου + RGB RGB + pure black stays 100% K or gray 0 instead of a mix το καθαρό μαύρο μένει 100% K ή γκρι 0 αντί για μείγμα + re-encoded in the target space; JPEGs stay JPEG επανακωδικοποιούνται στον χώρο προορισμού· τα JPEG μένουν JPEG + their appearances as well as the page η εμφάνισή τους καθώς και η σελίδα Create & Edit Δημιουργία και επεξεργασία Review & Compare Αναθεώρηση και σύγκριση Forms & Signatures Φόρμες και υπογραφές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 131d2dacaf..4584a27e16 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2709,6 +2709,21 @@ animation effects Off Desactivados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Buscar texto o herramientas + By the standard device formulas; profiles are not applied. Con las fórmulas estándar de dispositivo; no se aplican perfiles. + CMYK CMYK + Comments and form fields Comentarios y campos de formulario + Convert colours Convertir colores + Convert colours… Convertir colores… + Convert to Convertir a + Converted {c} colours and {i} pictures on {p} pages Se convirtieron {c} colores y {i} imágenes en {p} páginas + Grayscale Escala de grises + Nothing on those pages needed converting Nada en esas páginas necesitaba conversión + Pictures too Las imágenes también + Preserve black Conservar el negro + RGB RGB + pure black stays 100% K or gray 0 instead of a mix el negro puro se queda en K 100 % o gris 0 en vez de una mezcla + re-encoded in the target space; JPEGs stay JPEG recodificadas en el espacio de destino; los JPEG siguen siendo JPEG + their appearances as well as the page sus apariencias además de la página Attach a file… Adjuntar un archivo… Delete attachment Eliminar adjunto Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8c1a9baab9..b5442609b6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2856,6 +2856,21 @@ theme Appearance Teema Unpin tool Irrota työkalu Arrange image Järjestä kuva Crop Rajaa + By the standard device formulas; profiles are not applied. Laitteiden vakiokaavoilla; profiileja ei käytetä. + CMYK CMYK + Comments and form fields Kommentit ja lomakekentät + Convert colours Muunna värit + Convert colours… Muunna värit… + Convert to Muunna muotoon + Converted {c} colours and {i} pictures on {p} pages Muunnettu {c} väriä ja {i} kuvaa {p} sivulla + Grayscale Harmaasävy + Nothing on those pages needed converting Näillä sivuilla ei ollut muunnettavaa + Pictures too Myös kuvat + Preserve black Säilytä musta + RGB RGB + pure black stays 100% K or gray 0 instead of a mix puhdas musta pysyy 100 % K:na tai harmaana 0:na sekoituksen sijaan + re-encoded in the target space; JPEGs stay JPEG koodattu uudelleen kohdeavaruuteen; JPEG-kuvat pysyvät JPEG-muodossa + their appearances as well as the page niiden ulkoasut sivun lisäksi # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c22cf9d81..4c04072930 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2539,6 +2539,21 @@ animation effects Off Désactivés Edit this PDF Modifier ce PDF Export this PDF Exporter ce PDF Find text or tools Rechercher du texte ou des outils + By the standard device formulas; profiles are not applied. Selon les formules standard des périphériques ; aucun profil n'est appliqué. + CMYK CMJN + Comments and form fields Commentaires et champs de formulaire + Convert colours Convertir les couleurs + Convert colours… Convertir les couleurs… + Convert to Convertir en + Converted {c} colours and {i} pictures on {p} pages {c} couleurs et {i} images converties sur {p} pages + Grayscale Niveaux de gris + Nothing on those pages needed converting Rien sur ces pages ne nécessitait de conversion + Pictures too Les images aussi + Preserve black Conserver le noir + RGB RVB + pure black stays 100% K or gray 0 instead of a mix le noir pur reste à 100 % K ou gris 0 au lieu d'un mélange + re-encoded in the target space; JPEGs stay JPEG réencodées dans l'espace cible ; les JPEG restent JPEG + their appearances as well as the page leurs apparences en plus de la page Create & Edit Créer et modifier Review & Compare Réviser et comparer Forms & Signatures Formulaires et signatures diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 52c5450bb3..eb5645871b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2816,6 +2816,21 @@ theme Appearance स्वरूप Remove crop क्रॉप हटाएँ Send backward पीछे भेजें Send to back सबसे पीछे भेजें + By the standard device formulas; profiles are not applied. मानक डिवाइस सूत्रों के अनुसार; प्रोफ़ाइल लागू नहीं की जातीं। + CMYK CMYK + Comments and form fields टिप्पणियाँ और फ़ॉर्म फ़ील्ड + Convert colours रंग रूपांतरित करें + Convert colours… रंग रूपांतरित करें… + Convert to इसमें रूपांतरित करें + Converted {c} colours and {i} pictures on {p} pages {p} पृष्ठों पर {c} रंग और {i} चित्र रूपांतरित किए गए + Grayscale ग्रेस्केल + Nothing on those pages needed converting इन पृष्ठों पर रूपांतरित करने के लिए कुछ नहीं था + Pictures too चित्र भी + Preserve black काला बनाए रखें + RGB RGB + pure black stays 100% K or gray 0 instead of a mix शुद्ध काला मिश्रण के बजाय 100% K या ग्रे 0 रहता है + re-encoded in the target space; JPEGs stay JPEG लक्ष्य स्पेस में दोबारा एन्कोड; JPEG, JPEG ही रहते हैं + their appearances as well as the page पृष्ठ के साथ उनके स्वरूप भी # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 47d9c24b40..db918ed4a0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2539,6 +2539,21 @@ animation effects Off Ki Edit this PDF Ezen PDF szerkesztése Export this PDF Ezen PDF exportálása Find text or tools Szöveg vagy eszközök keresése + By the standard device formulas; profiles are not applied. A szabványos eszközképletek szerint; profilok nincsenek alkalmazva. + CMYK CMYK + Comments and form fields Megjegyzések és űrlapmezők + Convert colours Színek átalakítása + Convert colours… Színek átalakítása… + Convert to Átalakítás erre + Converted {c} colours and {i} pictures on {p} pages {c} szín és {i} kép átalakítva {p} oldalon + Grayscale Szürkeárnyalat + Nothing on those pages needed converting Ezeken az oldalakon nem kellett semmit átalakítani + Pictures too A képek is + Preserve black Fekete megtartása + RGB RGB + pure black stays 100% K or gray 0 instead of a mix a tiszta fekete 100% K vagy 0 szürke marad keverék helyett + re-encoded in the target space; JPEGs stay JPEG újrakódolva a célszíntérben; a JPEG JPEG marad + their appearances as well as the page a megjelenésük és az oldal is Create & Edit Létrehozás és szerkesztés Review & Compare Áttekintés és összehasonlítás Forms & Signatures Űrlapok és aláírások diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89152f62a4..f937fdf071 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2817,6 +2817,21 @@ theme Appearance Tampilan Remove crop Hapus pangkasan Send backward Mundur Selangkah Send to back Kirim ke Belakang + By the standard device formulas; profiles are not applied. Menurut rumus perangkat standar; profil tidak diterapkan. + CMYK CMYK + Comments and form fields Komentar dan kolom formulir + Convert colours Konversi warna + Convert colours… Konversi warna… + Convert to Konversi ke + Converted {c} colours and {i} pictures on {p} pages Mengonversi {c} warna dan {i} gambar pada {p} halaman + Grayscale Skala abu-abu + Nothing on those pages needed converting Tidak ada yang perlu dikonversi di halaman-halaman itu + Pictures too Gambar juga + Preserve black Pertahankan hitam + RGB RGB + pure black stays 100% K or gray 0 instead of a mix hitam murni tetap 100% K atau abu-abu 0, bukan campuran + re-encoded in the target space; JPEGs stay JPEG dienkode ulang di ruang target; JPEG tetap JPEG + their appearances as well as the page tampilannya serta halamannya # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0cae8f3e98..f899a385da 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2751,6 +2751,21 @@ certificate SHA-256 digest Impronta SHA-256 Edit this PDF Modifica questo PDF Export this PDF Esporta questo PDF Find text or tools Trova testo o strumenti + By the standard device formulas; profiles are not applied. Con le formule standard dei dispositivi; i profili non vengono applicati. + CMYK CMYK + Comments and form fields Commenti e campi modulo + Convert colours Converti colori + Convert colours… Converti colori… + Convert to Converti in + Converted {c} colours and {i} pictures on {p} pages Convertiti {c} colori e {i} immagini in {p} pagine + Grayscale Scala di grigi + Nothing on those pages needed converting Niente in queste pagine richiedeva conversione + Pictures too Anche le immagini + Preserve black Mantieni il nero + RGB RGB + pure black stays 100% K or gray 0 instead of a mix il nero puro resta al 100% K o grigio 0 invece di una miscela + re-encoded in the target space; JPEGs stay JPEG ricodificate nello spazio di destinazione; i JPEG restano JPEG + their appearances as well as the page i loro aspetti oltre alla pagina Create & Edit Crea e modifica Review & Compare Revisione e confronto Forms & Signatures Moduli e firme diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abc616b817..196cc6eaa2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2539,6 +2539,21 @@ animation effects Off オフ Edit this PDF この PDF を編集 Export this PDF この PDF を書き出す Find text or tools テキストまたはツールを検索 + By the standard device formulas; profiles are not applied. 標準のデバイス式で変換します。プロファイルは適用しません。 + CMYK CMYK + Comments and form fields 注釈とフォームフィールド + Convert colours カラーを変換 + Convert colours… カラーを変換… + Convert to 変換先 + Converted {c} colours and {i} pictures on {p} pages {p} ページで {c} 色と {i} 個の画像を変換しました + Grayscale グレースケール + Nothing on those pages needed converting これらのページには変換が必要なものはありません + Pictures too 画像も + Preserve black 黒を保持 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 純粋な黒は混色にせず K 100% またはグレー 0 のままにします + re-encoded in the target space; JPEGs stay JPEG 変換先の色空間で再エンコード。JPEG は JPEG のまま + their appearances as well as the page ページに加えてその表示 Create & Edit 作成と編集 Review & Compare レビューと比較 Forms & Signatures フォームと署名 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc6a2d783a..acd892865a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2537,6 +2537,21 @@ animation effects Off Өшірулі Edit this PDF Осы PDF файлын түзету Export this PDF Осы PDF файлын экспорттау Find text or tools Мәтінді немесе құралдарды табу + By the standard device formulas; profiles are not applied. Құрылғылардың стандартты формулаларымен; профильдер қолданылмайды. + CMYK CMYK + Comments and form fields Түсініктемелер мен пішін өрістері + Convert colours Түстерді түрлендіру + Convert colours… Түстерді түрлендіру… + Convert to Мынаған түрлендіру + Converted {c} colours and {i} pictures on {p} pages {p} бетте {c} түс және {i} сурет түрлендірілді + Grayscale Сұр реңктер + Nothing on those pages needed converting Бұл беттерде түрлендіретін ештеңе болмады + Pictures too Суреттер де + Preserve black Қараны сақтау + RGB RGB + pure black stays 100% K or gray 0 instead of a mix таза қара қоспа емес, 100% K немесе 0 сұр болып қалады + re-encoded in the target space; JPEGs stay JPEG мақсатты кеңістікте қайта кодталады; JPEG JPEG болып қалады + their appearances as well as the page олардың көрінісі және бет Create & Edit Жасау және өңдеу Review & Compare Шолу және салыстыру Forms & Signatures Пішіндер және қолтаңбалар diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 20315b5fcf..2f34a60c01 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2816,6 +2816,21 @@ theme Appearance 모양 Remove crop 자르기 제거 Send backward 뒤로 보내기 Send to back 맨 뒤로 보내기 + By the standard device formulas; profiles are not applied. 표준 장치 공식을 사용하며, 프로파일은 적용되지 않습니다. + CMYK CMYK + Comments and form fields 주석 및 양식 필드 + Convert colours 색상 변환 + Convert colours… 색상 변환… + Convert to 변환 대상 + Converted {c} colours and {i} pictures on {p} pages {p}페이지의 색상 {c}개와 이미지 {i}개를 변환했습니다 + Grayscale 회색조 + Nothing on those pages needed converting 해당 페이지에는 변환할 항목이 없습니다 + Pictures too 이미지도 포함 + Preserve black 검정 유지 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 순수한 검정은 혼합색 대신 100% K 또는 회색 0으로 유지됩니다 + re-encoded in the target space; JPEGs stay JPEG 대상 공간에서 다시 인코딩되며, JPEG는 JPEG로 유지됩니다 + their appearances as well as the page 페이지뿐 아니라 모양도 포함 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index fd7f431154..0e4a22b6ab 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2809,6 +2809,21 @@ theme Appearance Išvaizda Edit this PDF Redaguoti šį PDF Export this PDF Eksportuoti šį PDF Find text or tools Rasti tekstą arba įrankius + By the standard device formulas; profiles are not applied. Pagal standartines įrenginių formules; profiliai netaikomi. + CMYK CMYK + Comments and form fields Komentarai ir formos laukai + Convert colours Konvertuoti spalvas + Convert colours… Konvertuoti spalvas… + Convert to Konvertuoti į + Converted {c} colours and {i} pictures on {p} pages Konvertuota {c} spalvų ir {i} paveikslėlių {p} puslapiuose + Grayscale Pilki atspalviai + Nothing on those pages needed converting Šiuose puslapiuose nebuvo ko konvertuoti + Pictures too Ir paveikslėliai + Preserve black Išlaikyti juodą + RGB RGB + pure black stays 100% K or gray 0 instead of a mix gryna juoda lieka 100 % K arba pilka 0, o ne mišinys + re-encoded in the target space; JPEGs stay JPEG perkoduoti tiksliojoje erdvėje; JPEG lieka JPEG + their appearances as well as the page jų išvaizda ir puslapis Attach a file… Pridėti failą… Delete attachment Ištrinti priedą Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 208dd1c61e..e16f10d4b8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2515,6 +2515,21 @@ animation effects Off Wyłączone Edit this PDF Edytuj ten plik PDF Export this PDF Eksportuj ten plik PDF Find text or tools Znajdź tekst lub narzędzia + By the standard device formulas; profiles are not applied. Według standardowych wzorów urządzeń; profile nie są stosowane. + CMYK CMYK + Comments and form fields Komentarze i pola formularza + Convert colours Konwertuj kolory + Convert colours… Konwertuj kolory… + Convert to Konwertuj na + Converted {c} colours and {i} pictures on {p} pages Przekonwertowano {c} kolorów i {i} obrazów na {p} stronach + Grayscale Skala szarości + Nothing on those pages needed converting Na tych stronach nie było nic do konwersji + Pictures too Także obrazy + Preserve black Zachowaj czerń + RGB RGB + pure black stays 100% K or gray 0 instead of a mix czysta czerń zostaje 100% K lub szarością 0 zamiast mieszanki + re-encoded in the target space; JPEGs stay JPEG przekodowane w przestrzeni docelowej; JPEG zostają JPEG + their appearances as well as the page ich wygląd oraz strona Create & Edit Tworzenie i edycja Review & Compare Recenzja i porównanie Forms & Signatures Formularze i podpisy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a172a03879..ee5363bc8c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2552,6 +2552,21 @@ animation effects Off Desativados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Localizar texto ou ferramentas + By the standard device formulas; profiles are not applied. Pelas fórmulas padrão de dispositivo; perfis não são aplicados. + CMYK CMYK + Comments and form fields Comentários e campos de formulário + Convert colours Converter cores + Convert colours… Converter cores… + Convert to Converter para + Converted {c} colours and {i} pictures on {p} pages {c} cores e {i} imagens convertidas em {p} páginas + Grayscale Tons de cinza + Nothing on those pages needed converting Nada nessas páginas precisava de conversão + Pictures too Imagens também + Preserve black Preservar o preto + RGB RGB + pure black stays 100% K or gray 0 instead of a mix o preto puro fica em 100% K ou cinza 0 em vez de uma mistura + re-encoded in the target space; JPEGs stay JPEG recodificadas no espaço de destino; JPEGs continuam JPEG + their appearances as well as the page suas aparências além da página Create & Edit Criar e editar Review & Compare Revisar e comparar Forms & Signatures Formulários e assinaturas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0d0fbf16b4..31178b9ebc 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2846,6 +2846,21 @@ theme Appearance Aspect Unpin tool Anulează fixarea instrumentului Arrange image Aranjează imaginea Crop Decupare + By the standard device formulas; profiles are not applied. După formulele standard ale dispozitivelor; profilurile nu se aplică. + CMYK CMYK + Comments and form fields Comentarii și câmpuri de formular + Convert colours Conversie culori + Convert colours… Conversie culori… + Convert to Conversie în + Converted {c} colours and {i} pictures on {p} pages S-au convertit {c} culori și {i} imagini pe {p} pagini + Grayscale Tonuri de gri + Nothing on those pages needed converting Nimic de convertit pe aceste pagini + Pictures too Și imaginile + Preserve black Păstrare negru + RGB RGB + pure black stays 100% K or gray 0 instead of a mix negrul pur rămâne 100% K sau gri 0, în loc de amestec + re-encoded in the target space; JPEGs stay JPEG recodate în spațiul țintă; JPEG-urile rămân JPEG + their appearances as well as the page aspectul lor, nu doar pagina # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b45d73ecab..c36e1e236c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2452,6 +2452,21 @@ animation effects Off Выкл Edit this PDF Редактировать этот PDF Export this PDF Экспортировать этот PDF Find text or tools Найти текст или инструменты + By the standard device formulas; profiles are not applied. По стандартным формулам устройств; профили не применяются. + CMYK CMYK + Comments and form fields Комментарии и поля форм + Convert colours Преобразовать цвета + Convert colours… Преобразовать цвета… + Convert to Преобразовать в + Converted {c} colours and {i} pictures on {p} pages Преобразовано цветов: {c}, изображений: {i} на страницах: {p} + Grayscale Оттенки серого + Nothing on those pages needed converting На этих страницах нечего было преобразовывать + Pictures too Изображения тоже + Preserve black Сохранять чёрный + RGB RGB + pure black stays 100% K or gray 0 instead of a mix чистый чёрный остаётся 100% K или серым 0, а не смесью + re-encoded in the target space; JPEGs stay JPEG перекодируются в целевое пространство; JPEG остаются JPEG + their appearances as well as the page их внешний вид, а также страница Create & Edit Создание и редактирование Review & Compare Рецензирование и сравнение Forms & Signatures Формы и подписи diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index c95f6d6bd6..13470e2f61 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2544,6 +2544,21 @@ animation effects Off Izklopljeni Edit this PDF Uredi ta PDF Export this PDF Izvozi ta PDF Find text or tools Poišči besedilo ali orodja + By the standard device formulas; profiles are not applied. Po standardnih formulah naprav; profili se ne uporabijo. + CMYK CMYK + Comments and form fields Komentarji in polja obrazca + Convert colours Pretvori barve + Convert colours… Pretvori barve… + Convert to Pretvori v + Converted {c} colours and {i} pictures on {p} pages Pretvorjenih {c} barv in {i} slik na {p} straneh + Grayscale Sivine + Nothing on those pages needed converting Na teh straneh ni bilo treba ničesar pretvoriti + Pictures too Tudi slike + Preserve black Ohrani črno + RGB RGB + pure black stays 100% K or gray 0 instead of a mix čista črna ostane 100 % K ali siva 0 namesto mešanice + re-encoded in the target space; JPEGs stay JPEG znova kodirane v ciljnem prostoru; JPEG ostanejo JPEG + their appearances as well as the page njihov videz in stran Create & Edit Ustvarjanje in urejanje Review & Compare Pregled in primerjava Forms & Signatures Obrazci in podpisi diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index eb9eed3a0e..f93573010e 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2571,6 +2571,21 @@ animation effects Off Joaktiv Remove crop Hiq prerjen Send backward Kalo prapa Send to back Dërgo mbrapa + By the standard device formulas; profiles are not applied. Sipas formulave standarde të pajisjes; profilet nuk zbatohen. + CMYK CMYK + Comments and form fields Komentet dhe fushat e formularit + Convert colours Konverto ngjyrat + Convert colours… Konverto ngjyrat… + Convert to Konverto në + Converted {c} colours and {i} pictures on {p} pages U konvertuan {c} ngjyra dhe {i} imazhe në {p} faqe + Grayscale Nuanca gri + Nothing on those pages needed converting Në ato faqe nuk kishte gjë për t'u konvertuar + Pictures too Edhe imazhet + Preserve black Ruaj të zezën + RGB RGB + pure black stays 100% K or gray 0 instead of a mix e zeza e pastër mbetet 100% K ose gri 0 në vend të një përzierjeje + re-encoded in the target space; JPEGs stay JPEG rikodohet në hapësirën e synuar; JPEG-et mbeten JPEG + their appearances as well as the page pamjet e tyre si dhe faqja # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 9b81ffb8b9..94a282f223 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2448,6 +2448,21 @@ animation effects Off ఆఫ్ Edit this PDF ఈ PDFను సవరించు Export this PDF ఈ PDFను ఎగుమти చేయి Find text or tools వచనం లేదా సాధనాలను కనుగొనండి + By the standard device formulas; profiles are not applied. ప్రామాణిక పరికర సూత్రాలతో; ప్రొఫైల్‌లు వర్తించవు. + CMYK CMYK + Comments and form fields వ్యాఖ్యలు మరియు ఫారమ్ ఫీల్డ్‌లు + Convert colours రంగులను మార్చు + Convert colours… రంగులను మార్చు… + Convert to దీనికి మార్చు + Converted {c} colours and {i} pictures on {p} pages {p} పేజీలలో {c} రంగులు మరియు {i} చిత్రాలు మార్చబడ్డాయి + Grayscale గ్రేస్కేల్ + Nothing on those pages needed converting ఆ పేజీలలో మార్చవలసినది ఏదీ లేదు + Pictures too చిత్రాలు కూడా + Preserve black నలుపును ఉంచు + RGB RGB + pure black stays 100% K or gray 0 instead of a mix స్వచ్ఛమైన నలుపు మిశ్రమం కాకుండా 100% K లేదా బూడిద 0గా ఉంటుంది + re-encoded in the target space; JPEGs stay JPEG లక్ష్య స్థలంలో మళ్లీ ఎన్‌కోడ్ చేయబడతాయి; JPEGలు JPEGగానే ఉంటాయి + their appearances as well as the page వాటి రూపం మరియు పేజీ Create & Edit సృష్టించు & సవరించు Review & Compare సమీక్షించు & పోల్చు Forms & Signatures ఫారమ్‌లు & సంతకాలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6921a6c33e..eb49c872a8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2535,6 +2535,21 @@ animation effects Off Kapalı Edit this PDF Bu PDF'i düzenle Export this PDF Bu PDF'i dışa aktar Find text or tools Metin veya araç bul + By the standard device formulas; profiles are not applied. Standart cihaz formülleriyle; profiller uygulanmaz. + CMYK CMYK + Comments and form fields Yorumlar ve form alanları + Convert colours Renkleri dönüştür + Convert colours… Renkleri dönüştür… + Convert to Şuna dönüştür + Converted {c} colours and {i} pictures on {p} pages {p} sayfada {c} renk ve {i} resim dönüştürüldü + Grayscale Gri tonlama + Nothing on those pages needed converting Bu sayfalarda dönüştürülecek bir şey yoktu + Pictures too Resimler de + Preserve black Siyahı koru + RGB RGB + pure black stays 100% K or gray 0 instead of a mix saf siyah karışım yerine %100 K veya gri 0 kalır + re-encoded in the target space; JPEGs stay JPEG hedef alanda yeniden kodlanır; JPEG'ler JPEG kalır + their appearances as well as the page görünümleri ve sayfa Create & Edit Oluştur ve Düzenle Review & Compare İncele ve Karşılaştır Forms & Signatures Formlar ve İmzalar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a824769600..5c8e01163e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2561,6 +2561,21 @@ animation effects Off Вимкнено Edit this PDF Редактировать цей PDF Export this PDF Експортувати цей PDF Find text or tools Знайти текст або інструменти + By the standard device formulas; profiles are not applied. За стандартними формулами пристроїв; профілі не застосовуються. + CMYK CMYK + Comments and form fields Коментарі та поля форм + Convert colours Перетворити кольори + Convert colours… Перетворити кольори… + Convert to Перетворити на + Converted {c} colours and {i} pictures on {p} pages Перетворено кольорів: {c}, зображень: {i} на сторінках: {p} + Grayscale Відтінки сірого + Nothing on those pages needed converting На цих сторінках нічого не треба було перетворювати + Pictures too Зображення теж + Preserve black Зберігати чорний + RGB RGB + pure black stays 100% K or gray 0 instead of a mix чистий чорний лишається 100% K або сірим 0, а не сумішшю + re-encoded in the target space; JPEGs stay JPEG перекодовуються в цільовий простір; JPEG лишаються JPEG + their appearances as well as the page їхній вигляд, а також сторінка Create & Edit Створення й редагування Review & Compare Рецензування й порівняння Forms & Signatures Форми й підписи diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e79a8aacc4..c6dc5be6c2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2819,6 +2819,21 @@ key Space Space Edit this PDF Chỉnh sửa PDF này Export this PDF Xuất PDF này Find text or tools Tìm văn bản hoặc công cụ + By the standard device formulas; profiles are not applied. Theo các công thức thiết bị tiêu chuẩn; không áp dụng cấu hình màu. + CMYK CMYK + Comments and form fields Bình luận và các trường biểu mẫu + Convert colours Chuyển đổi màu + Convert colours… Chuyển đổi màu… + Convert to Chuyển sang + Converted {c} colours and {i} pictures on {p} pages Đã chuyển đổi {c} màu và {i} hình ảnh trên {p} trang + Grayscale Thang độ xám + Nothing on those pages needed converting Không có gì trên các trang đó cần chuyển đổi + Pictures too Cả hình ảnh + Preserve black Giữ nguyên màu đen + RGB RGB + pure black stays 100% K or gray 0 instead of a mix màu đen thuần giữ ở 100% K hoặc xám 0 thay vì màu pha trộn + re-encoded in the target space; JPEGs stay JPEG được mã hóa lại trong không gian đích; ảnh JPEG vẫn là JPEG + their appearances as well as the page cả giao diện hiển thị của chúng lẫn trang Attach a file… Đính kèm tệp… Delete attachment Xóa tệp đính kèm Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 96e9c3bb75..c37657d276 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2503,6 +2503,21 @@ animation effects Off 关 Edit this PDF 编辑此 PDF Export this PDF 导出此 PDF Find text or tools 查找文本或工具 + By the standard device formulas; profiles are not applied. 按标准设备公式转换;不应用配置文件。 + CMYK CMYK + Comments and form fields 注释和表单字段 + Convert colours 转换颜色 + Convert colours… 转换颜色… + Convert to 转换为 + Converted {c} colours and {i} pictures on {p} pages 已在 {p} 页上转换 {c} 种颜色和 {i} 张图片 + Grayscale 灰度 + Nothing on those pages needed converting 这些页面上没有需要转换的内容 + Pictures too 图片也一起 + Preserve black 保留黑色 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 纯黑保持为 100% K 或灰度 0,而不是混合色 + re-encoded in the target space; JPEGs stay JPEG 在目标颜色空间中重新编码;JPEG 仍为 JPEG + their appearances as well as the page 连同其外观和页面 Create & Edit 创建和编辑 Review & Compare 审阅和比较 Forms & Signatures 表单和签名 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a1b933b830..ed232c9878 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2489,6 +2489,21 @@ animation effects Off 關 Edit this PDF 編輯此 PDF Export this PDF 匯出此 PDF Find text or tools 尋找文字或工具 + By the standard device formulas; profiles are not applied. 依標準裝置公式轉換;不套用描述檔。 + CMYK CMYK + Comments and form fields 註解和表單欄位 + Convert colours 轉換顏色 + Convert colours… 轉換顏色… + Convert to 轉換為 + Converted {c} colours and {i} pictures on {p} pages 已在 {p} 頁上轉換 {c} 種顏色和 {i} 張圖片 + Grayscale 灰階 + Nothing on those pages needed converting 這些頁面上沒有需要轉換的內容 + Pictures too 圖片也一起 + Preserve black 保留黑色 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 純黑保持為 100% K 或灰階 0,而非混合色 + re-encoded in the target space; JPEGs stay JPEG 在目標色彩空間中重新編碼;JPEG 仍為 JPEG + their appearances as well as the page 連同其外觀和頁面 Create & Edit 建立與編輯 Review & Compare 審閱與比較 Forms & Signatures 表單與簽名 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe0138f27f..9e7a857685 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,7 @@ use canvas::zoom_snap; use dialogs::appearance; pub use dialogs::bookmark_styles_ui; use dialogs::comment_props; +use dialogs::convert_colours_ui; use dialogs::credits; use dialogs::marks_ui; use dialogs::pageboxes; @@ -609,6 +610,7 @@ pub struct PdfCraftApp { /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, + pub convert_colours_draft: convert_colours_ui::ConvertColoursDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, /// New bookmarks from text styles dialog state (#1693). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e3a9c540a6..fe6921de9c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Convert colours. + ConvertColours, /// Organize pages ▸ Page properties (the tab order of the selected pages). PageProps, /// Protect Using Password. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 7b245a5a9e..4c5aeabfdc 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -8034,6 +8034,42 @@ fn test_cropped_image_selection_and_bounding_box() { assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image"); } +/// Print production ▸ Convert colours: the dialog converts the page's colours +/// and says how many; with nothing to convert it says so and changes nothing. +#[test] +fn convert_colours_from_the_dialog() { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let cs = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate( + pdfcraft_cos::Dict::new(), + b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S", + ))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + 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("colours.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + assert!(h.state_mut().execute("prepress.convert_colors")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::ConvertColours)); + h.get_by_label("Grayscale"); + // The mode bar has a "Convert" too: the dialog's button is the last one. + h.query_all_by_label("Convert").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Converted 2 colours and 0 pictures on 1 pages"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Convert colours")); + assert!(h.state_mut().execute("prepress.convert_colors")); + h.run_steps(2); + h.query_all_by_label("Convert").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Nothing on those pages needed converting"); +} + /// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as /// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabfc7823a..aabdc635f3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8406,11 +8406,11 @@ title = "Convert colours" area = "L" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["prepress.convert_colors"] +tools = ["doc_convert_colours"] +evidence = ["crates/edit/src/tests.rs::colours_are_converted_to_gray_rgb_and_cmyk", "crates/engine/src/tests.rs::converting_colours_recolours_pictures", "crates/ui-egui/tests/it/editing.rs::convert_colours_from_the_dialog", "crates/automation/tests/it/automation.rs::convert_colours_through_tools"] +notes = "Print production ▸ Convert colours…: Grayscale, RGB or CMYK by the standard device formulas (no ICC profile applied): every colour operator in the pages' content and the Form XObjects it draws is rewritten (device spaces, ICCBased by component count, CalRGB/CalGray, Lab, Indexed, Separation/DeviceN through a Type 2 tint), black kept 100% K / gray 0 on request, 8-bit gray/RGB pictures re-encoded (JPEG stays JPEG, CMYK written lossless, soft masks kept) and the ones left alone counted, comments' and fields' appearances optionally; all, current or a range of pages; one undo step; tool doc_convert_colours, which also lists the PDF/A level and output intents the file may no longer meet (the dialog does not say so yet). Still missing against Acrobat: conversion through ICC profiles and rendering intents (it is no prepress separation and uses no output intent), keeping spot colours, sampled and PostScript tint transforms (a gray of the tint's darkness), per-object conversion rules, inline images, and indexed, CMYK and masked pictures or decode arrays." [[feature]] id = "print.ink-manager"