Merge #1998: Print production: Convert colours (grayscale, RGB, CMYK by the device formulas)

# Conflicts:
#	crates/automation/tests/it/automation.rs
#	crates/edit/src/lib.rs
#	crates/edit/src/tests.rs
#	crates/engine/src/commands.rs
#	crates/engine/src/lib.rs
#	crates/optimize/tests/optimize.rs
#	crates/ui-egui/src/app.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/state.rs
#	crates/ui-egui/tests/it/editing.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:17:38 +07:00
commit a4a600573e
33 files changed
+1586 -9

No files matched your search

+1
View File
@@ -767,6 +767,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
@@ -171,6 +171,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
@@ -11228,6 +11228,31 @@ fn printer_marks_through_tools() {
));
}
/// 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}");
}
/// Fix hairlines through the tools: counts what changed, and reports zero without an edit.
#[test]
fn fix_hairlines_through_tools() {
+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)
}
+3
View File
@@ -66,8 +66,11 @@ pub use text::{
};
pub mod printer_marks;
pub use printer_marks::{PrinterMarks, add_printer_marks};
mod colour;
pub mod convert_colours;
pub mod hairlines;
mod page_streams;
pub use convert_colours::{ColourTarget, ConvertColours, Converted as ColoursConverted, ImageRecolour, convert_colours};
pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines};
pub mod added;
pub use added::{
+61
View File
@@ -9305,6 +9305,67 @@ fn printer_marks_are_drawn_outside_the_trim_box_and_removable() {
assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight: 0.0, ..PrinterMarks::default() }, false).is_err());
}
/// 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}");
}
/// Fix hairlines: zero-width strokes and strokes thinner than the threshold on
/// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider
/// lines, and glyphs and patterns unless asked, are left alone.
@@ -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", Ready),
item("Fix hairlines", "minus", "prepress.hairlines", Ready),
+1
View File
@@ -408,6 +408,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"),
c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"),
c("prepress.convert_colors", "Convert colours…", None, None, Modification, "palette"),
c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"),
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
+5
View File
@@ -375,6 +375,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
@@ -253,6 +253,7 @@ impl Edit {
Edit::FixHairlines { .. } => Label::of("Fix hairlines"),
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
@@ -91,6 +91,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 { .. } => {
@@ -138,6 +143,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::FixHairlines { .. }
| Edit::AddAttachment { .. }
| Edit::DeleteAttachment { .. }
| Edit::ConvertColours { .. }
| Edit::AutoTagText
| Edit::SetAltText { .. }
| Edit::AutoTagFormFields
@@ -611,6 +617,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 strikeout is grouped under the caret, and XFDF names that link by
// the caret's `/NM` (#2666).
+1
View File
@@ -97,6 +97,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. }
| Edit::ConvertColours { .. }
| Edit::FixHairlines { .. }
// Recognized text is an invisible text layer stamped on its page, with its font in the
// page's resources (#6310): a full re-inspection made applying one word cost seconds on
+2 -2
View File
@@ -96,8 +96,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, Hairlines, HairlinesFixed,
HeaderFooter, Ink as TextInk, MarkKind, PrinterMarks, Watermark,
Added, AddedImage, AddedText, Align as TextAlign, Background, ColourTarget, ColoursConverted, Content as AddedContent, ConvertColours,
Family as FontFamily, Hairlines, HairlinesFixed, HeaderFooter, Ink as TextInk, MarkKind, PrinterMarks, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
+18
View File
@@ -116,6 +116,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()
}
/// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many
/// pages, without changing the document (the edit itself refuses when there is nothing to fix).
pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed {
+58
View File
@@ -2364,6 +2364,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();
@@ -10014,6 +10037,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};
+71
View File
@@ -1615,6 +1615,77 @@ 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);
// One picture a call: `pixels` itself refuses one past MAX_PIXELS, so no shared allowance is kept here.
let px = pixels(doc, s, n, &mut ImageBudget::with_work(u64::MAX))?;
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
@@ -42,7 +42,7 @@ pub use audit::{SpaceCategory, SpaceUse, audit_space};
use pdfcraft_cos::{Dict, Document, ImageBudget, 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};
pub use pool::give_way_to_other_programs;
pub use samples::{NotDecoded, Samples, decode_samples, encode_samples};
+26
View File
@@ -4684,6 +4684,32 @@ fn dictionary_destinations_broken_named_destinations_and_both_dest_tables() {
assert!(left_old.contains(b"old_ok") && !left_old.contains(b"broken_old"), "{left_old:?}");
}
/// 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());
}
/// A one-bit stencil of `w` × `h`, solid but for a hole in its middle.
fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef {
let row = (w as usize).div_ceil(8);
+1
View File
@@ -334,6 +334,7 @@ impl PdfCraftApp {
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(),
printer_marks_draft: Default::default(),
convert_colours_draft: Default::default(),
hairlines_draft: Default::default(),
}
}
+1
View File
@@ -143,6 +143,7 @@ impl PdfCraftApp {
self.execute("page.number");
Some(Dialog::NumberPages)
}
"convert-colours" => Some(Dialog::ConvertColours),
"fix-hairlines" => {
// Through the command, so it is refused with no document or none that
// can be modified, as the menu is.
+1
View File
@@ -219,6 +219,7 @@ impl PdfCraftApp {
self.protect_draft = Default::default();
self.dialog = Some(Dialog::Protect);
}
"prepress.convert_colors" => self.dialog = Some(Dialog::ConvertColours),
"prepress.hairlines" => self.dialog = Some(Dialog::FixHairlines),
"protect.remove" => {
if self.apply_edit(Edit::RemoveProtection) {
+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 hairlines_ui;
pub(super) mod marks_ui;
@@ -46,6 +47,7 @@ struct Pending {
protect_now: bool,
boxes_now: bool,
printer_marks_now: bool,
colours_now: bool,
fix_hairlines_now: bool,
marks_now: bool,
export_now: bool,
@@ -456,6 +458,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
req.close = go || 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::FixHairlines => {
let (go, cancel) = crate::hairlines_ui::body(ui, app, &t);
req.fix_hairlines_now = go;
@@ -1996,6 +2004,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
if req.printer_marks_now {
app.add_printer_marks();
}
if req.colours_now {
app.convert_colours();
}
if req.fix_hairlines_now {
app.fix_hairlines();
}
@@ -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]);
}
}
+2
View File
@@ -110,6 +110,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::hairlines_ui;
use dialogs::marks_ui;
@@ -720,6 +721,7 @@ pub struct PdfCraftApp {
pub number_draft: NumberDraft,
pub(crate) word_count_draft: word_count_ui::Draft,
pub printer_marks_draft: printer_marks_ui::PrinterMarksDraft,
pub convert_colours_draft: convert_colours_ui::ConvertColoursDraft,
pub hairlines_draft: hairlines_ui::HairlinesDraft,
/// Protect Using Password dialog state.
pub protect_draft: protect::ProtectDraft,
+2
View File
@@ -111,6 +111,8 @@ pub enum Dialog {
NumberPages,
/// Print production ▸ Add printer marks.
PrinterMarks,
/// Print production ▸ Convert colours.
ConvertColours,
/// Print production ▸ Fix hairlines.
FixHairlines,
/// Organize pages ▸ Page properties (the tab order of the selected pages).
+36
View File
@@ -9615,6 +9615,42 @@ fn printer_marks_are_added_from_the_dialog_and_removed() {
assert_eq!(doc.can_undo(), Some("Remove printer marks"));
}
/// 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");
}
/// Print production ▸ Fix hairlines: the dialog widens zero-width and too-thin
/// strokes and says how many; with nothing to fix it says so and changes nothing.
#[test]
+5 -5
View File
@@ -8522,11 +8522,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"