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 <noreply@anthropic.com>
This commit is contained in:
Osvaldo DuranandClaude Opus 5.5 committed 2026-11-01 18:53:50 -05:00
1 parent 0cba87ae6f
commit d06154ac80
63 files changed
+2091 -9

No files matched your search

+1
View File
@@ -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)?,
+39
View File
@@ -86,6 +86,45 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duple
}
impl Automation {
/// Print production ▸ Convert colours: every colour on the pages in gray, rgb or cmyk.
pub(crate) fn convert_colours(&mut self, a: &Args) -> Result<Value> {
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<String> = 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<Value> {
+10
View File
@@ -138,6 +138,16 @@ pub fn tools() -> Vec<ToolDef> {
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"])),
+25
View File
@@ -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");
+230
View File
@@ -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<Space>,
hival: usize,
table: Vec<u8>,
},
/// 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<Space>,
exp: Option<(Vec<f64>, Vec<f64>, 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<f64> {
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<f64> = (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<f64> = 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],
}
}
+492
View File
@@ -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<Stream> + '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<f64> {
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<Space>,
stroke: Option<Space>,
stack: Vec<(Option<Space>, Option<Space>)>,
}
/// 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<Vec<u8>>, usize) {
let mut ops: Vec<Op> = parse(data).ops;
let mut count = 0usize;
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
let State { fill, stroke, stack } = state;
let numbers = |op: &Op| -> Vec<f64> { 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<Stream> {
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<ObjRef, ObjRef>,
shared: HashSet<ObjRef>,
}
/// 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<ObjRef> {
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut todo: Vec<Object> = Vec::new();
let xobjects_of = |res: Option<&Object>, todo: &mut Vec<Object>| {
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<Dict> {
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<Converted, EditError> {
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<Option<Vec<u8>>> = 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<ObjRef> = 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<ObjRef> = 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)
}
+4
View File
@@ -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,
+69
View File
@@ -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<Vec<u8>>,
}
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<Self> {
let refs: Vec<Object> = 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<Option<Vec<u8>>>) -> Result<bool, EditError> {
if new.iter().all(Option::is_none) {
return Ok(false);
}
let mut refs: Vec<Object> = 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)
}
}
+61
View File
@@ -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::<Vec<_>>().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.
@@ -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::<Vec<_>>().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<ObjRef> {
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<Stream> {
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}");
}
@@ -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::<Vec<_>>().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<Stream> {
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 <FF0000>] >>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");
}
@@ -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::<Vec<_>>().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<ObjRef> {
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<Stream> {
None
}
/// One page drawing `streams`, with an indexed colour space `/CS0` (0 = red, 1 = green).
fn page_with(streams: &[&str]) -> Document {
let refs: Vec<String> = (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 <FF000000FF00>] >> >> /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");
}
+5
View File
@@ -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;
+1 -1
View File
@@ -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")),
+1
View File
@@ -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"),
+5
View File
@@ -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<usize>,
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
+1
View File
@@ -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"),
+20
View File
@@ -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).
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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,
+18
View File
@@ -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<Vec<u8>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
+58
View File
@@ -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(&params).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<u8> {
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
+70
View File
@@ -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<Stream> {
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<u8> = 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::*;
+1 -1
View File
@@ -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 {
+26
View File
@@ -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());
}
+1
View File
@@ -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(),
}
}
+1
View File
@@ -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");
+1
View File
@@ -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");
+11
View File
@@ -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()
{
@@ -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<DocId>,
}
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<usize> {
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]);
}
}
+15
View File
@@ -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 النماذج والتوقيعات
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -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 Формуляри и подписи
Can't render this file because it contains an unexpected character in line 8 and column 16.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 3.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+15
View File
@@ -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 Φόρμες και υπογραφές
Can't render this file because it contains an unexpected character in line 490 and column 9.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 47.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+15
View File
@@ -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 आज
Can't render this file because it contains an unexpected character in line 875 and column 12.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 485 and column 12.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -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 フォームと署名
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -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 Пішіндер және қолтаңбалар
Can't render this file because it contains an unexpected character in line 885 and column 9.
+15
View File
@@ -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 오늘
Can't render this file because it contains an unexpected character in line 875 and column 12.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 408 and column 9.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 3 and column 110.
+15
View File
@@ -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 Формы и подписи
Can't render this file because it contains an unexpected character in line 9 and column 16.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+15
View File
@@ -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 ఫారమ్‌లు & సంతకాలు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 884 and column 9.
+15
View File
@@ -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 Форми й підписи
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -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
Can't render this file because it contains an unexpected character in line 495 and column 12.
+15
View File
@@ -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 表单和签名
Can't render this file because it contains an unexpected character in line 9 and column 16.
+15
View File
@@ -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 表單與簽名
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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).
+2
View File
@@ -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.
+36
View File
@@ -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]
+5 -5
View File
@@ -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"