diff --git a/crates/automation/README.md b/crates/automation/README.md
index db5b988394..2fbbff8ccb 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -26,6 +26,11 @@ its undo/redo history.
`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry
follows the box-level `align`; legacy `null` entries remain accepted with the same meaning.
+`page_add_text` and `content_update` take one of `color` (`#RRGGBB` or a name, painted in
+DeviceRGB), `gray` (0 black to 1 white, DeviceGray `g`) and `cmyk` (`[c, m, y, k]`, each 0 to 1,
+DeviceCMYK `k`), so text added to a print file can be 100 K black. `content_list` reports `gray` or
+`cmyk` for such text.
+
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
## Conventions
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
index 6b25c31f61..1f26dcc26a 100644
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -2,7 +2,7 @@
//! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the
//! displayed page (y down), like every other tool.
-use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign};
+use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign, TextInk};
use serde_json::{Value, json};
use crate::comments::parse_color;
@@ -30,6 +30,27 @@ fn rect_arg(a: &Args, key: &str) -> Result> {
.transpose()
}
+const INK_ARGS: &str = "give one of color (#RRGGBB or a name), gray (0 black to 1 white) and cmyk ([c, m, y, k], each 0 to 1)";
+
+/// `gray` or `cmyk`: the text painted in DeviceGray or DeviceCMYK rather than RGB. At most one of
+/// them and `color`.
+fn ink_arg(a: &Args) -> Result > {
+ if ["color", "gray", "cmyk"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
+ return Err(bad(INK_ARGS));
+ }
+ let unit = |v: Option, key: &str| v.filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad(format!("{key} takes numbers from 0 to 1")));
+ if let Some(g) = a.get("gray") {
+ return Ok(Some(TextInk::Gray(unit(g.as_f64(), "gray")?)));
+ }
+ let Some(c) = a.get("cmyk") else { return Ok(None) };
+ let parts = c.as_array().filter(|p| p.len() == 4).ok_or_else(|| bad("cmyk must be [c, m, y, k]"))?;
+ let mut cmyk = [0.0; 4];
+ for (out, v) in cmyk.iter_mut().zip(parts) {
+ *out = unit(v.as_f64(), "cmyk")?;
+ }
+ Ok(Some(TextInk::Cmyk(cmyk)))
+}
+
/// Apply the style arguments to `t`.
fn style(a: &Args, t: &mut AddedText) -> Result<()> {
if let Some(s) = a.opt_num("size")? {
@@ -49,8 +70,12 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
if let Some(i) = a.opt_bool("italic")? {
t.italic = i;
}
- if let Some(c) = a.opt_str("color")? {
- t.color = parse_color(c)?;
+ if let Some(ink) = ink_arg(a)? {
+ t.set_ink(Some(ink));
+ } else if let Some(c) = a.opt_str("color")? {
+ // An RGB colour paints in RGB, even one that is the ink's look.
+ t.set_ink(None);
+ t.set_color(parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
t.align = parse_align(al)?;
@@ -124,6 +149,12 @@ impl Automation {
v["bold"] = json!(t.bold);
v["italic"] = json!(t.italic);
v["char_spacing"] = json!(t.char_spacing);
+ // Text painted in a print colour space says so; RGB text has neither.
+ match t.painted_ink() {
+ Some(TextInk::Gray(g)) => v["gray"] = json!(g),
+ Some(TextInk::Cmyk(c)) => v["cmyk"] = json!(c),
+ None => {}
+ }
}
AddedContent::Image(_) => v["type"] = json!("image"),
}
@@ -205,8 +236,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
- if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
- return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
+ if ["font", "size", "bold", "italic", "color", "gray", "cmyk", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
+ return Err(bad("font, size, bold, italic, color, gray, cmyk, align, aligns and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 30d9c51384..1f381102a8 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -719,13 +719,13 @@ pub fn tools() -> Vec {
}),
&["doc"],
)),
- t("content_list", "List added content", "Text and images added with page_add_text/page_add_image (or Edit a PDF ▸ Add content), per page with a 1-based index, rect (points from the top-left of the page), and text style.")
+ t("content_list", "List added content", "Text and images added with page_add_text/page_add_image (or Edit a PDF ▸ Add content), per page with a 1-based index, rect (points from the top-left of the page), and text style; text painted in gray or CMYK also has gray (its level) or cmyk ([c, m, y, k]).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"page_add_text",
"Add text to a page",
- "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
+ "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name, painted in RGB) or, for print, gray (0 black to 1 white, painted in DeviceGray) or cmyk ([c, m, y, k], each 0 to 1, painted in DeviceCMYK; [0, 0, 0, 1] is 100 K black), one of the three, align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
)
.cmd("edit.text")
.with(schema(
@@ -736,6 +736,8 @@ pub fn tools() -> Vec {
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
+ "gray": { "type": "number", "minimum": 0, "maximum": 1, "description": "DeviceGray level, 0 black to 1 white (instead of color)." },
+ "cmyk": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 4, "maxItems": 4, "description": "DeviceCMYK [c, m, y, k], each 0 to 1 (instead of color); [0, 0, 0, 1] is 100 K black." },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
@@ -752,13 +754,15 @@ pub fn tools() -> Vec {
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }),
&["doc", "page", "path"],
)),
- t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
+ t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color or gray or cmyk as page_add_text takes them, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
.with(schema(
json!({
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "text": { "type": "string" },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
+ "gray": { "type": "number", "minimum": 0, "maximum": 1, "description": "DeviceGray level, 0 black to 1 white (instead of color)." },
+ "cmyk": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 4, "maxItems": 4, "description": "DeviceCMYK [c, m, y, k], each 0 to 1 (instead of color); [0, 0, 0, 1] is 100 K black." },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index bbbcef2528..dc5dec0521 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -5215,6 +5215,83 @@ fn adding_content_through_tools() {
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
}
+/// #6440: `page_add_text` paints text in CMYK or gray for a print file, so its black is 100 K
+/// (`0 0 0 1 k`) rather than DeviceRGB; `content_update` restyles it either way.
+#[test]
+fn added_text_takes_a_cmyk_or_gray_ink_through_tools() {
+ let dir = workdir("content-ink");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "PROOF", "at": [20, 20], "cmyk": [0, 0, 0, 1] }));
+ ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Note", "at": [20, 80], "gray": 0.5 }));
+ let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
+ assert_eq!((&items[0]["cmyk"], &items[1]["gray"]), (&json!([0.0, 0.0, 0.0, 1.0]), &json!(0.5)));
+ // The item streams of the saved file.
+ let saved_items = |a: &mut Automation, name: &str| -> Vec {
+ ok(a, "doc_save", json!({ "doc": doc, "path": name }));
+ let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap();
+ let page = &pdfcraft_model::pages(&saved)[0];
+ let list = saved.resolve(page.dict.get(b"Contents").unwrap()).as_array().cloned().unwrap();
+ list.iter()
+ .filter_map(|c| match &*saved.resolve(c) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"PCMark") == Some(&b"Added"[..]) => {
+ Some(String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
+ }
+ _ => None,
+ })
+ .collect()
+ };
+ let streams = saved_items(&mut a, "ink.pdf");
+ assert!(streams[0].contains("0 0 0 1 k") && !streams[0].contains("rg"), "{}", streams[0]);
+ assert!(streams[1].contains("0.5 g") && !streams[1].contains("rg"), "{}", streams[1]);
+ // Restyled: to RGB with color, and back to CMYK.
+ ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "color": "#000000" }));
+ ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "cmyk": [1, 0, 0, 0] }));
+ let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
+ assert_eq!((&items[0]["cmyk"], items[1].get("gray")), (&json!([1.0, 0.0, 0.0, 0.0]), None));
+ let streams = saved_items(&mut a, "restyled.pdf");
+ assert!(streams[0].contains("1 0 0 0 k") && streams[1].contains("0 0 0 rg"), "{streams:?}");
+ // One colour at a time, four numbers from 0 to 1.
+ for bad in [
+ json!({ "color": "red", "cmyk": [0, 0, 0, 1] }),
+ json!({ "gray": 0.5, "cmyk": [0, 0, 0, 1] }),
+ json!({ "gray": 1.5 }),
+ json!({ "cmyk": [0, 0, 1] }),
+ json!({ "cmyk": [0, 0, 0, 1, 0] }),
+ json!({ "cmyk": [0, "x", 0, 1] }),
+ json!({ "cmyk": [0, 0, 0, -0.1] }),
+ ] {
+ let mut args = json!({ "doc": doc, "page": 1, "text": "x", "at": [20, 200] });
+ args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
+ assert!(matches!(a.call("page_add_text", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
+ }
+ assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 2, "nothing refused was added");
+ // content_update refuses them too, an image takes no ink, and a refused update changes nothing.
+ let mut png = Vec::new();
+ {
+ let mut enc = png::Encoder::new(&mut png, 40, 20);
+ enc.set_color(png::ColorType::Rgb);
+ let mut w = enc.write_header().unwrap();
+ w.write_image_data(&[200u8; 2400]).unwrap();
+ }
+ std::fs::write(dir.join("logo.png"), png).unwrap();
+ ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] }));
+ let before = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
+ for (index, bad) in [
+ (1, json!({ "gray": 0.5, "cmyk": [0, 0, 0, 1] })),
+ (2, json!({ "color": "red", "gray": 0.5 })),
+ (1, json!({ "size": 30, "cmyk": [0, 0, 0, 2] })),
+ (2, json!({ "bold": true, "gray": -1 })),
+ (3, json!({ "gray": 0.5 })),
+ (3, json!({ "cmyk": [0, 0, 0, 1] })),
+ ] {
+ let mut args = json!({ "doc": doc, "page": 1, "index": index });
+ args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
+ assert!(matches!(a.call("content_update", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
+ }
+ assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"], before, "a refused update changed nothing");
+}
+
/// Edit ▸ Copy and Paste headlessly (#2636): an added text box or image, as it is, onto its own
/// page beside it, onto a point, and into another document.
#[test]
diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs
index a603a37060..0fc2aa7359 100644
--- a/crates/edit/src/added.rs
+++ b/crates/edit/src/added.rs
@@ -31,7 +31,7 @@ use layout::Piece;
pub(crate) use layout::WRAP_CALLS;
pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width};
pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content};
-pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan};
+pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan};
#[derive(Clone, Debug, PartialEq)]
pub enum Content {
diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs
index 9404f939b1..f3f9b55859 100644
--- a/crates/edit/src/added/draw.rs
+++ b/crates/edit/src/added/draw.rs
@@ -1,7 +1,7 @@
use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped,
};
-use super::types::{AddedText, Align, Family};
+use super::types::{AddedText, Align, Family, Ink};
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
@@ -105,8 +105,7 @@ pub(super) fn draw_laid_shaped(
fonts.set(name.clone().into_bytes(), Object::Ref(font));
names.push(name);
}
- let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
- out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
+ out.extend(format!("BT {}\n", t.fill_op()).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
@@ -293,8 +292,7 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
let r = text_rect(t);
- let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
- out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
+ out.extend(format!("BT /{name} {} Tf {}\n", n(t.size), t.fill_op()).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
@@ -355,6 +353,12 @@ pub(super) fn params(c: &Content) -> Dict {
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
d.set(b"Size".to_vec(), Object::Real(t.size));
d.set(b"Color".to_vec(), arr(&t.color));
+ // The ink beside its RGB look, which a reader that knows no ink keeps showing.
+ match t.painted_ink() {
+ Some(Ink::Gray(g)) => d.set(b"Gray".to_vec(), Object::Real(g)),
+ Some(Ink::Cmyk(c)) => d.set(b"CMYK".to_vec(), arr(&c)),
+ None => {}
+ }
d.set(b"Align".to_vec(), Object::Int(align_code(t.align)));
// Each paragraph's own alignment, or null where it follows /Align.
let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align);
diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs
index 7dae55596f..3555910f04 100644
--- a/crates/edit/src/added/store.rs
+++ b/crates/edit/src/added/store.rs
@@ -1,8 +1,33 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
-use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
+use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan, TAG};
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged};
+/// A text item's gray or CMYK ink. Items written before inks, and an ink out of range or not
+/// made only of numbers (a `/Gray` name, a `/CMYK` entry that isn't a number), have none: the
+/// text paints in its RGB `/Color`.
+fn ink(doc: &Document, d: &Dict) -> Option {
+ let ink = if let Some(g) = d.get(b"Gray") {
+ Ink::Gray(read_value(doc, g, Object::as_f64)?)
+ } else {
+ // Four entries or none: a longer array is never copied or read through.
+ read_value(doc, d.get(b"CMYK")?, |cmyk| {
+ let [c, m, y, k] = cmyk.as_array()?.as_slice() else { return None };
+ Some(Ink::Cmyk([c.as_f64()?, m.as_f64()?, y.as_f64()?, k.as_f64()?]))
+ })?
+ };
+ Some(ink).filter(|i| i.valid())
+}
+
+/// `f` of `o`, a reference followed. A direct value is read where it is, never copied, so a
+/// large one a file puts there costs nothing to look at.
+fn read_value(doc: &Document, o: &Object, f: impl FnOnce(&Object) -> T) -> T {
+ match o {
+ Object::Ref(_) => f(&doc.resolve(o)),
+ direct => f(direct),
+ }
+}
+
fn parse(doc: &Document, d: &Dict) -> Option {
let r = nums(doc, d.get(b"Rect"));
let rect: [f64; 4] = r.try_into().ok()?;
@@ -21,7 +46,7 @@ fn parse(doc: &Document, d: &Dict) -> Option {
.map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect())
})
.unwrap_or_default();
- Some(Content::Text(AddedText {
+ let mut t = AddedText {
rect,
text,
family,
@@ -29,11 +54,19 @@ fn parse(doc: &Document, d: &Dict) -> Option {
italic,
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
+ ink: ink(doc, d),
align,
aligns: crate::paragraph_align::tidy(aligns, align),
// Items written before character spacing have none.
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
- }))
+ };
+ // An ink whose look isn't the stored colour (a file edited by hand) is none.
+ t.drop_stale_ink();
+ // One that stands is the ink as painted, finer digits dropped, and its look.
+ if let Some(i) = t.ink {
+ t.set_ink(Some(i));
+ }
+ Some(Content::Text(t))
}
b"Image" => {
let crop = nums(doc, d.get(b"Crop"));
@@ -138,6 +171,9 @@ fn validate(c: &Content) -> Result<(), EditError> {
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
)));
}
+ if t.ink.is_some_and(|i| !i.valid()) {
+ return Err(EditError::Invalid("a gray level and each CMYK value must be from 0 to 1".into()));
+ }
if (r[2] - r[0]).abs() < 1.0 {
return Err(EditError::Invalid("the text box is too narrow".into()));
}
diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs
index 0caaaa863b..24101b2eea 100644
--- a/crates/edit/src/added/types.rs
+++ b/crates/edit/src/added/types.rs
@@ -1,5 +1,5 @@
use super::layout::norm;
-use super::{Content, MarkKind, ObjRef, Std14};
+use super::{Content, MarkKind, ObjRef, Std14, n};
pub(super) const TAG: &str = "Added";
@@ -90,6 +90,60 @@ pub enum Align {
Justify,
}
+/// A device colour added text is painted in instead of RGB, for print files: a text's black in
+/// CMYK stays 100 K rather than a DeviceRGB black the printer separates into rich black.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Ink {
+ /// A DeviceGray level, from 0 (black) to 1 (white): painted with `g`.
+ Gray(f64),
+ /// DeviceCMYK `[c, m, y, k]`, each from 0 to 1 (`[0, 0, 0, 1]` is 100 K): painted with `k`.
+ Cmyk([f64; 4]),
+}
+
+impl Ink {
+ /// Whether every component is a number from 0 to 1.
+ pub fn valid(self) -> bool {
+ self.components().iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))
+ }
+
+ /// The ink as its operator paints it: each component to the thousandth that `g` and `k` are
+ /// written with (as RGB's `rg`), so the item, what it reads back as and the page agree.
+ pub fn painted(self) -> Ink {
+ let round = |v: f64| (v * 1000.0).round() / 1000.0;
+ match self {
+ Ink::Gray(g) => Ink::Gray(round(g)),
+ Ink::Cmyk(c) => Ink::Cmyk(c.map(round)),
+ }
+ }
+
+ fn components(&self) -> &[f64] {
+ match self {
+ Ink::Gray(g) => std::slice::from_ref(g),
+ Ink::Cmyk(c) => c,
+ }
+ }
+
+ /// How the ink looks on screen: gray is the same level in each of red, green and blue
+ /// (ISO 32000-2 §10.4.2.2), CMYK takes each colour plus black from 1 (§10.4.2.5).
+ pub fn rgb(self) -> [f64; 3] {
+ match self {
+ Ink::Gray(g) => [g.clamp(0.0, 1.0); 3],
+ Ink::Cmyk([c, m, y, k]) => [c, m, y].map(|v| 1.0 - (v + k).clamp(0.0, 1.0)),
+ }
+ }
+
+ /// The operator that makes this ink the fill colour, without a line end.
+ pub(super) fn fill_op(self) -> String {
+ match self {
+ Ink::Gray(g) => format!("{} g", n(g.clamp(0.0, 1.0))),
+ Ink::Cmyk(c) => {
+ let [c, m, y, k] = c.map(|v| v.clamp(0.0, 1.0));
+ format!("{} {} {} {} k", n(c), n(m), n(y), n(k))
+ }
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq)]
pub struct AddedText {
/// The text box in display space; its width wraps the text and its top is the first line's
@@ -100,7 +154,14 @@ pub struct AddedText {
pub bold: bool,
pub italic: bool,
pub size: f64,
+ /// The RGB colour: what the text is painted in, unless it has an [`AddedText::ink`] this is
+ /// the look of.
pub color: [f64; 3],
+ /// A gray or CMYK ink the text is painted in, with `color` its look ([`Ink::rgb`]). Set it
+ /// with [`AddedText::set_ink`] and the colour with [`AddedText::set_color`], which drops an
+ /// ink the colour no longer shows; one left behind by setting `color` directly paints nothing
+ /// ([`AddedText::painted_ink`]) until [`AddedText::drop_stale_ink`] forgets it.
+ pub ink: Option,
pub align: Align,
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
pub char_spacing: f64,
@@ -114,6 +175,44 @@ impl AddedText {
pub fn paragraph_align(&self, i: usize) -> Align {
crate::paragraph_align::align_of(&self.aligns, i, self.align)
}
+
+ /// Paint the text in `ink` (its look becomes [`AddedText::color`]), or in RGB with `None`. An
+ /// ink in range is kept as painted ([`Ink::painted`]); one out of range stays as given, for
+ /// saving to refuse.
+ pub fn set_ink(&mut self, ink: Option) {
+ let ink = ink.map(|i| if i.valid() { i.painted() } else { i });
+ if let Some(i) = ink {
+ self.color = i.rgb();
+ }
+ self.ink = ink;
+ }
+
+ /// Paint the text in RGB `rgb`. Its ink stays only when `rgb` is the ink's own look; once
+ /// gone it stays gone, so picking that look again later paints in RGB.
+ pub fn set_color(&mut self, rgb: [f64; 3]) {
+ self.color = rgb;
+ self.drop_stale_ink();
+ }
+
+ /// Forget an ink `color` no longer shows, so two styles that paint the same compare equal.
+ pub fn drop_stale_ink(&mut self) {
+ self.ink = self.painted_ink();
+ }
+
+ /// The ink the text is painted in, or `None` for RGB: the one it has while `color` is still
+ /// its look.
+ pub fn painted_ink(&self) -> Option {
+ // Within rounding: a look read back from a file, or from a colour picker, is the same.
+ self.ink.filter(|i| i.rgb().iter().zip(self.color).all(|(a, b)| (a - b).abs() < 1e-6))
+ }
+
+ /// The operator that sets the text's fill colour, without a line end.
+ pub(super) fn fill_op(&self) -> String {
+ match self.painted_ink() {
+ Some(ink) => ink.fill_op(),
+ None => crate::marks::rgb(self.color),
+ }
+ }
}
impl Default for AddedText {
@@ -126,6 +225,7 @@ impl Default for AddedText {
italic: false,
size: 12.0,
color: [0.0; 3],
+ ink: None,
align: Align::Left,
aligns: Vec::new(),
char_spacing: 0.0,
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index 2a0add8d12..0cf1886afd 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -61,8 +61,8 @@ pub use text::{
};
pub mod added;
pub use added::{
- Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added,
- scan_markers, update_content,
+ Added, AddedImage, AddedText, Align, Content, Family, Ink, MarkerScan, add_content, delete_content, first_undrawable, isolate_content,
+ list_added, scan_markers, update_content,
};
mod apply;
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 22fbd6e8ae..49ab8abbc7 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -1048,6 +1048,164 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
assert!(marks_present(&doc).is_empty());
}
+/// #6440: added text in a print file can be gray or CMYK, painted with `g` / `k`, so its black
+/// stays 100 K. The ink survives save and reopen; another colour drops it for good.
+#[test]
+fn gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured() {
+ use crate::added::Ink;
+ let item_stream = |doc: &Document, i: usize| {
+ let obj = list_added(doc)[i].obj;
+ let Object::Stream(s) = &*doc.get(obj) else { panic!("an item is a stream") };
+ String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
+ };
+ let mut doc = fixture();
+ let base = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), size: 14.0, ..AddedText::default() };
+ let mut black = base.clone();
+ black.set_ink(Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])));
+ assert_eq!(black.color, [0.0; 3], "100 K looks black on screen");
+ let mut gray = AddedText { rect: [72.0, 400.0, 300.0, 500.0], ..base.clone() };
+ gray.set_ink(Some(Ink::Gray(0.25)));
+ add_content(&mut doc, 0, &Content::Text(black)).unwrap();
+ add_content(&mut doc, 0, &Content::Text(gray)).unwrap();
+ add_content(&mut doc, 0, &Content::Text(AddedText { rect: [72.0, 200.0, 300.0, 300.0], ..base.clone() })).unwrap();
+
+ let doc = reopen(&doc);
+ let (k, g, rgb) = (item_stream(&doc, 0), item_stream(&doc, 1), item_stream(&doc, 2));
+ assert!(k.contains("/PCFHelvetica 14 Tf 0 0 0 1 k\n") && !k.contains("rg"), "{k}");
+ assert!(g.contains("/PCFHelvetica 14 Tf 0.25 g\n") && !g.contains("rg"), "{g}");
+ assert!(rgb.contains("/PCFHelvetica 14 Tf 0 0 0 rg\n"), "RGB text paints as before: {rgb}");
+ let inks: Vec> = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.painted_ink() } else { None }).collect();
+ assert_eq!(inks, [Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])), Some(Ink::Gray(0.25)), None]);
+
+ // Another colour drops the ink, and picking the ink's look again later doesn't bring it back;
+ // picking that look while the ink is there keeps it.
+ let Content::Text(cmyk) = list_added(&doc)[0].content.clone() else { panic!() };
+ let mut same = cmyk.clone();
+ same.set_color([0.0; 3]);
+ assert_eq!(same, cmyk, "black on 100 K stays 100 K");
+ let mut recoloured = cmyk.clone();
+ recoloured.set_color([1.0, 0.0, 0.0]);
+ assert_eq!(recoloured.ink, None);
+ recoloured.set_color([0.0; 3]);
+ assert_eq!((recoloured.ink, recoloured.painted_ink()), (None, None), "the ink stays gone");
+
+ // Set directly, a colour paints in RGB too, and the item reads back without the ink: what a
+ // draft with its stale ink dropped compares equal to.
+ let mut doc = doc;
+ let red = AddedText { color: [1.0, 0.0, 0.0], ..cmyk };
+ update_content(&mut doc, 0, 0, &Content::Text(red.clone())).unwrap();
+ let stream = item_stream(&doc, 0);
+ assert!(stream.contains("1 0 0 rg") && !stream.contains(" k\n"), "{stream}");
+ let mut settled = red;
+ settled.drop_stale_ink();
+ for doc in [&doc, &reopen(&doc)] {
+ let Content::Text(back) = list_added(doc)[0].content.clone() else { panic!() };
+ // The stored box is the text's own height.
+ assert_eq!(back, AddedText { rect: back.rect, ..settled.clone() });
+ }
+
+ // CMYK looks as ISO 32000-2 §10.4.2.5 maps it to RGB; an ink out of range is refused.
+ assert_eq!(Ink::Cmyk([0.5, 0.0, 1.0, 0.25]).rgb(), [0.25, 0.75, 0.0]);
+ let mut wrong = base;
+ wrong.ink = Some(Ink::Gray(1.5));
+ assert!(add_content(&mut doc, 0, &Content::Text(wrong)).is_err());
+}
+
+/// #6440: a file edited by hand paints its added text in RGB unless the stored gray or CMYK is
+/// in range, the right length and still the look of `/Color`; nothing in it panics.
+#[test]
+fn a_hand_edited_ink_reads_back_only_when_it_is_the_stored_colours_look() {
+ use crate::added::Ink;
+ let mut doc = fixture();
+ add_content(&mut doc, 0, &Content::Text(AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), ..AddedText::default() })).unwrap();
+ let obj = list_added(&doc)[0].obj;
+ let real = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
+ // A `/CMYK` far longer than four numbers, as a hostile file might store it: an ink first.
+ let long = real(&[0.0, 0.0, 0.0, 1.0].repeat(50_000));
+ let long_ref = Object::Ref(doc.add(long.clone()));
+ let ink_ref = Object::Ref(doc.add(real(&[0.0, 0.0, 0.0, 1.0])));
+ let read_text = |entries: &[(&[u8], Object)]| {
+ let mut edited = doc.clone();
+ let Object::Stream(mut s) = (*edited.get(obj)).clone() else { panic!("an item is a stream") };
+ let mut params = s.dict.get(b"PCAdded").and_then(|p| p.as_dict().cloned()).unwrap();
+ for (key, value) in entries {
+ params.set(key.to_vec(), value.clone());
+ }
+ s.dict.set(b"PCAdded".to_vec(), Object::Dict(params));
+ edited.set(obj, Object::Stream(s));
+ let Content::Text(t) = list_added(&edited)[0].content.clone() else { panic!() };
+ t
+ };
+ let read = |entries: &[(&[u8], Object)]| read_text(entries).ink;
+ // As PDFCraft writes it: the ink stands.
+ assert_eq!(read(&[(b"Color", real(&[0.5; 3])), (b"Gray", Object::Real(0.5))]), Some(Ink::Gray(0.5)));
+ assert_eq!(read(&[(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))]), Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])));
+ assert_eq!(read(&[(b"Color", real(&[0.0; 3])), (b"CMYK", ink_ref)]), Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])), "by reference");
+ // Finer than it paints, it reads back as painted (`0.5 g`), its look with it.
+ let fine = read_text(&[(b"Color", real(&[0.50049; 3])), (b"Gray", Object::Real(0.50049))]);
+ assert_eq!((fine.ink, fine.color), (Some(Ink::Gray(0.5)), [0.5; 3]));
+ for (what, entries) in [
+ ("a colour the ink isn't the look of", vec![(&b"Color"[..], real(&[1.0, 0.0, 0.0])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))]),
+ ("three CMYK numbers", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 1.0]))]),
+ ("five CMYK numbers", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0, 0.0]))]),
+ ("a long CMYK", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", long.clone())]),
+ ("a long CMYK by reference", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", long_ref.clone())]),
+ ("gray a long array", vec![(b"Color", real(&[0.0; 3])), (b"Gray", long.clone())]),
+ ("CMYK not an array", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", Object::Real(1.0))]),
+ ("gray out of range", vec![(b"Color", real(&[1.0; 3])), (b"Gray", Object::Real(2.0))]),
+ ("gray not a number", vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::name("Black"))]),
+ (
+ "a CMYK entry not a number",
+ vec![
+ (b"Color", real(&[0.0; 3])),
+ (b"CMYK", Object::Array(vec![Object::Real(0.0), Object::name("Bad"), Object::Real(0.0), Object::Real(1.0)])),
+ ],
+ ),
+ (
+ "gray not a number beside a CMYK",
+ vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::name("Black")), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))],
+ ),
+ ("gray NaN", vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::Real(f64::NAN))]),
+ ("CMYK infinite", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, f64::INFINITY]))]),
+ ] {
+ assert_eq!(read(&entries), None, "{what}");
+ }
+}
+
+/// #6440: an ink finer than the thousandth `g` and `k` are written to is kept as painted, so the
+/// item, its stored `/CMYK` and what it reads back as are what the page shows.
+#[test]
+fn an_ink_is_kept_as_it_is_painted() {
+ use crate::added::Ink;
+ let mut doc = fixture();
+ let mut t = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), size: 14.0, ..AddedText::default() };
+ t.set_ink(Some(Ink::Cmyk([0.12345, 0.00049, 0.0, 0.99951])));
+ let painted = Ink::Cmyk([0.123, 0.0, 0.0, 1.0]);
+ assert_eq!((t.ink, t.color), (Some(painted), painted.rgb()));
+ add_content(&mut doc, 0, &Content::Text(t)).unwrap();
+ let doc = reopen(&doc);
+ let item = &list_added(&doc)[0];
+ let Object::Stream(s) = &*doc.get(item.obj) else { panic!("an item is a stream") };
+ let stream = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
+ assert!(
+ stream.contains(
+ "/PCFHelvetica 14 Tf 0.123 0 0 1 k
+"
+ ),
+ "{stream}"
+ );
+ let stored = s
+ .dict
+ .get(b"PCAdded")
+ .and_then(|p| p.as_dict())
+ .and_then(|p| p.get(b"CMYK"))
+ .and_then(Object::as_array)
+ .map(|a| a.iter().filter_map(Object::as_f64).collect::>());
+ assert_eq!(stored, Some(vec![0.123, 0.0, 0.0, 1.0]));
+ let Content::Text(back) = &item.content else { panic!() };
+ assert_eq!(back.painted_ink(), Some(painted));
+}
+
/// #4464: a page left with one added item draws that item and nothing else.
#[test]
fn an_isolated_item_is_all_that_is_left_on_its_page() {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 99b2fdac69..378f9483b2 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -93,7 +93,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, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk,
+ MarkKind, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 5b6fb01b9e..c109a0bc80 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4182,6 +4182,7 @@ fn pasted_added_content_keeps_its_style_and_picture() {
italic: true,
size: 12.0,
color: [0.8, 0.1, 0.2],
+ ink: None,
align: TextAlign::Center,
aligns: Vec::new(),
char_spacing: 0.0,
@@ -8112,6 +8113,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() {
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
+ ink: None,
align: TextAlign::Left,
aligns: Vec::new(),
char_spacing: 0.0,
@@ -8153,6 +8155,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
+ ink: None,
align: TextAlign::Left,
aligns: Vec::new(),
char_spacing: 0.0,
diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs
index 76886ff54f..c0e35329c0 100644
--- a/crates/ui-egui/src/editing/tests.rs
+++ b/crates/ui-egui/src/editing/tests.rs
@@ -391,3 +391,66 @@ fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alo
assert!(app.save_active(SaveTarget::InPlace));
let _ = std::fs::remove_dir_all(&dir);
}
+
+/// #6440: recolouring gray or CMYK text in its open box drops the ink at once. Finishing the box
+/// then adds no empty Undo step (the draft equals the item the page reads back), picking the
+/// ink's own look again in the same box paints RGB rather than bringing the ink back, and new
+/// boxes don't take the ink.
+#[test]
+fn recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step() {
+ // 100 K text, open in its box.
+ let open = || {
+ let mut app = crate::PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app.open_bytes("a.pdf", None, one_page_pdf()).unwrap();
+ let mut text = pdfcraft_engine::AddedText { rect: [20.0, 200.0, 180.0, 220.0], text: "Proof".into(), ..Default::default() };
+ text.set_ink(Some(pdfcraft_engine::TextInk::Cmyk([0.0, 0.0, 0.0, 1.0])));
+ assert!(app.apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
+ let stored = item(&app);
+ assert!(stored.painted_ink().is_some());
+ app.views[0].content.draft = Some(crate::content_ui::TextDraft {
+ page: 0,
+ index: Some(0),
+ rect: stored.rect,
+ text: stored.text.clone(),
+ style: stored,
+ focus: true,
+ selection: None,
+ typing_from: None,
+ });
+ app
+ };
+ fn item(app: &crate::PdfCraftApp) -> pdfcraft_engine::AddedText {
+ match &app.session.get(app.views[0].id).unwrap().added()[0].content {
+ pdfcraft_engine::AddedContent::Text(t) => t.clone(),
+ other => panic!("{other:?}"),
+ }
+ }
+ // The box's style with another colour, set as the Format panel hands it over.
+ let recolour = |app: &mut crate::PdfCraftApp, color: [f64; 3]| {
+ let style = app.views[0].content.draft.as_ref().map(|d| d.style.clone()).unwrap();
+ app.restyle_draft(0, pdfcraft_engine::AddedText { color, ..style });
+ };
+ let finish = |app: &mut crate::PdfCraftApp| {
+ let added = app.session.get(app.views[0].id).unwrap().added().to_vec();
+ app.views[0].content.finish_draft(&added)
+ };
+
+ // Red, then Done.
+ let mut app = open();
+ recolour(&mut app, [1.0, 0.0, 0.0]);
+ assert_eq!(item(&app).ink, None, "the page paints red in RGB");
+ let finished = finish(&mut app);
+ assert!(finished.is_none(), "an empty Undo step: {finished:?}");
+ assert_eq!(app.text_style.ink, None, "new boxes don't take the ink");
+
+ // Red, then black in the same box: RGB black, not the 100 K back.
+ let mut app = open();
+ recolour(&mut app, [1.0, 0.0, 0.0]);
+ recolour(&mut app, [0.0; 3]);
+ let now = item(&app);
+ assert_eq!((now.ink, now.color), (None, [0.0; 3]));
+ assert_eq!(app.text_style.ink, None);
+ let finished = finish(&mut app);
+ assert!(finished.is_none(), "an empty Undo step: {finished:?}");
+}
diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs
index 2c2fb7a09a..883009df8e 100644
--- a/crates/ui-egui/src/panels/edit.rs
+++ b/crates/ui-egui/src/panels/edit.rs
@@ -406,7 +406,11 @@ impl PdfCraftApp {
/// style at once and stays open. Text already on the page is updated with what has been typed,
/// as one undoable change; new text waits until it is finished. Text typed after this styling
/// starts from it too.
- pub(crate) fn restyle_draft(&mut self, index: usize, style: pdfcraft_engine::AddedText) {
+ pub(crate) fn restyle_draft(&mut self, index: usize, mut style: pdfcraft_engine::AddedText) {
+ // A gray or CMYK ink the colour no longer shows goes now (#6440): kept, the draft would
+ // differ from the item the page reads back without it, and a later pick of the ink's
+ // look would bring it back.
+ style.drop_stale_ink();
let Some(view) = self.views.get_mut(index) else { return };
// Text the standard fonts can't draw stays in the box, unwritten, until it is corrected (#125).
let blocked = view.content.blocked().is_some();
diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs
index 99ffc054e1..412563b026 100644
--- a/crates/ui-egui/src/panels/format_text_ui.rs
+++ b/crates/ui-egui/src/panels/format_text_ui.rs
@@ -266,7 +266,8 @@ pub(crate) fn panel(
let swatch = colour_button(ui, t, look.color);
egui::Popup::menu(&swatch).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) {
- look.color = picked;
+ // Drops a gray or CMYK ink the new colour isn't the look of (#6440).
+ look.set_color(picked);
ui.close();
}
});
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 0972e2cf6a..450ba06260 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -2047,8 +2047,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.text"]
tools = ["page_add_text", "content_update"]
-evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"]
-notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
+evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders", "crates/edit/src/tests.rs::gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured", "crates/automation/tests/it/automation.rs::added_text_takes_a_cmyk_or_gray_ink_through_tools", "crates/ui-egui/src/editing/tests.rs::recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step"]
+notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. The colour is RGB, or for print files gray or CMYK (#6440: page_add_text / content_update gray or cmyk, painted with g / k so black can be 100 K, kept through save and reopen; choosing another colour later, also from the Format panel, drops the ink for good and paints in RGB, while choosing the colour the ink already shows (black on 100 K) keeps it; content_update color always paints in RGB). Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
[[feature]]
id = "edit.find-replace"