diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 9a026069..6a82fbc1 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -170,6 +170,10 @@ fn color(d: &Dict, key: &[u8]) -> Option> { }) } +fn interior_color(d: &Dict, fallback: Option) -> Option { + color(d, b"IC").flatten().or(fallback) +} + fn border_width(d: &Dict) -> f64 { if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) { return w.max(0.0); @@ -369,7 +373,7 @@ pub fn build(d: &Dict) -> Option { } } b"Square" | b"Circle" => { - let fill = color(d, b"IC")?; + let fill = interior_color(d, None); if stroke.is_none() && fill.is_none() { return Some(form(rect, c.as_bytes(), res)); } @@ -403,7 +407,7 @@ pub fn build(d: &Dict) -> Option { if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { return None; } - let fill = color(d, b"IC")?.unwrap_or(col); + let fill = interior_color(d, Some(col)).unwrap_or(col); c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) { @@ -417,7 +421,7 @@ pub fn build(d: &Dict) -> Option { let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); let col = stroke; let closed = subtype == b"Polygon"; - let fill = if closed { color(d, b"IC")? } else { None }; + let fill = if closed { interior_color(d, None) } else { None }; let ends = if subtype == b"PolyLine" { match d.get(b"LE") { None => None, @@ -461,7 +465,7 @@ pub fn build(d: &Dict) -> Option { let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = color(d, b"IC")?.unwrap_or(col); + let fill = interior_color(d, Some(col)).unwrap_or(col); c.push_str(&format!("[] 0 d\n{}", rg(fill))); if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { return None; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 5cd9614a..e7091c51 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -787,6 +787,51 @@ fn line_dict(coords: [f64; 4], ending: &str) -> pdfcraft_cos::Dict { d } +#[test] +fn malformed_optional_interior_color_does_not_hide_shape_strokes() { + let mut square = pdfcraft_cos::Dict::new(); + square.set(b"Subtype".to_vec(), Object::name("Square")); + square.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 40.0, 30.0].into_iter().map(Object::Real).collect())); + square.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect())); + let malformed = Object::Array(vec![Object::Real(1.0), Object::Real(0.0)]); + square.set(b"IC".to_vec(), malformed.clone()); + let mut circle = square.clone(); + circle.set(b"Subtype".to_vec(), Object::name("Circle")); + + let mut polygon = pdfcraft_cos::Dict::new(); + polygon.set(b"Subtype".to_vec(), Object::name("Polygon")); + polygon.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 40.0, 30.0].into_iter().map(Object::Real).collect())); + polygon.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect())); + polygon.set(b"Vertices".to_vec(), Object::Array([0.0, 0.0, 40.0, 0.0, 20.0, 30.0].into_iter().map(Object::Real).collect())); + polygon.set(b"IC".to_vec(), malformed.clone()); + + let mut line = line_dict([0.0, 0.0, 40.0, 30.0], "None"); + line.set(b"IC".to_vec(), malformed.clone()); + let mut polyline = pdfcraft_cos::Dict::new(); + polyline.set(b"Subtype".to_vec(), Object::name("PolyLine")); + polyline.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 40.0, 30.0].into_iter().map(Object::Real).collect())); + polyline.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect())); + polyline.set(b"Vertices".to_vec(), Object::Array([0.0, 0.0, 40.0, 0.0, 20.0, 30.0].into_iter().map(Object::Real).collect())); + polyline.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name("None")])); + polyline.set(b"IC".to_vec(), malformed); + + for (name, annotation) in [("Square", square), ("Circle", circle), ("Polygon", polygon), ("Line", line), ("PolyLine", polyline)] { + assert!(appearance::build(&annotation).is_some(), "malformed optional /IC must not hide a valid {name} stroke"); + } + + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 50 40] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0] /IC [1 0] >>", + ]); + let display = with_missing_appearances(&doc).expect("the valid square gets a fallback appearance"); + let rendered = list(&display, 0).remove(0); + let content = ap_content(&display, &rendered); + assert!(content.contains("1 0 0 RG") && content.contains(" re\nS\n"), "the stroke remains drawable without a fill: {content}"); + assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged"); +} + #[test] fn every_line_ending_is_drawn() { let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];