diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index 186935b9f2..df0b697ff9 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -3,13 +3,16 @@ use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; 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, have none: -/// the text paints in its RGB `/Color`. +/// 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 = match (d.get(b"Gray").map(|g| doc.resolve(g)).and_then(|g| g.as_f64()), d.get(b"CMYK")) { - (Some(g), _) => Ink::Gray(g), - (None, Some(c)) => Ink::Cmyk(nums(doc, Some(c)).try_into().ok()?), - (None, None) => return None, + let ink = if let Some(g) = d.get(b"Gray") { + Ink::Gray(doc.resolve(g).as_f64()?) + } else { + let cmyk = doc.resolve(d.get(b"CMYK")?); + let parts: Vec = cmyk.as_array()?.iter().map(Object::as_f64).collect::>()?; + Ink::Cmyk(parts.try_into().ok()?) }; Some(ink).filter(|i| i.valid()) } @@ -48,6 +51,10 @@ fn parse(doc: &Document, d: &Dict) -> Option { }; // 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" => { diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs index 43480774a3..24101b2eea 100644 --- a/crates/edit/src/added/types.rs +++ b/crates/edit/src/added/types.rs @@ -106,6 +106,16 @@ impl Ink { 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), @@ -166,8 +176,11 @@ impl AddedText { 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`. + /// 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(); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 8aadcef420..1edcb65c25 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1120,7 +1120,7 @@ fn a_hand_edited_ink_reads_back_only_when_it_is_the_stored_colours_look() { 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()); - let read = |entries: &[(&[u8], Object)]| { + 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(); @@ -1130,11 +1130,15 @@ fn a_hand_edited_ink_reads_back_only_when_it_is_the_stored_colours_look() { 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.ink + 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]))); + // 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]))]), @@ -1142,6 +1146,17 @@ fn a_hand_edited_ink_reads_back_only_when_it_is_the_stored_colours_look() { ("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(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]))]), ] { @@ -1149,6 +1164,40 @@ fn a_hand_edited_ink_reads_back_only_when_it_is_the_stored_colours_look() { } } +/// #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() {