Add text: an ink is read only from numbers and kept as painted (review)

A stored /CMYK with a non-numeric entry was read from its numbers alone, so
[0 /Bad 0 0 1] became 100 K; a /Gray that isn't a number with a /CMYK beside
it fell through to the CMYK. Both now give no ink, and the text paints in
its /Color.

g and k are written to the thousandth, as rg is, so an ink set or read with
finer digits painted other than what the item, its stored /Gray or /CMYK and
content_list held. The ink is now kept as painted, with its look, when set
and when read back. RGB text is unchanged.
This commit is contained in:
Zinadin Zidan committed 2026-11-07 02:08:46 +06:00
1 parent 7e8551e4bc
commit c44896df95
3 files changed
+78 -9

No files matched your search

+13 -6
View File
@@ -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<Ink> {
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<f64> = cmyk.as_array()?.iter().map(Object::as_f64).collect::<Option<_>>()?;
Ink::Cmyk(parts.try_into().ok()?)
};
Some(ink).filter(|i| i.valid())
}
@@ -48,6 +51,10 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
};
// 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" => {
+14 -1
View File
@@ -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<Ink>) {
let ink = ink.map(|i| if i.valid() { i.painted() } else { i });
if let Some(i) = ink {
self.color = i.rgb();
}
+51 -2
View File
@@ -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::<Vec<_>>());
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() {