use super::*;
use crate::pdf_native::dictionary;
fn exponential(c0: Vec<f32>, c1: Vec<f32>, exponent: f32) -> LoObject {
dictionary! {
"FunctionType" => 2,
"Domain" => vec![0.into(), 1.into()],
"C0" => c0.into_iter().map(LoObject::from).collect::<Vec<_>>(),
"C1" => c1.into_iter().map(LoObject::from).collect::<Vec<_>>(),
"N" => exponent,
}
.into()
}
fn red_blue() -> LoObject {
exponential(vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0], 1.0)
}
fn axial(function: LoObject, coords: [f32; 4], extend: [bool; 2]) -> LoDictionary {
dictionary! {
"ShadingType" => 2,
"ColorSpace" => "DeviceRGB",
"Coords" => coords.into_iter().map(LoObject::from).collect::<Vec<_>>(),
"Function" => function,
"Extend" => extend.into_iter().map(LoObject::Boolean).collect::<Vec<_>>(),
}
}
fn pdf(mut doc: LoDocument, resources: LoDictionary, content: &str) -> Vec<u8> {
let pages = doc.new_object_id();
let stream = doc.add_object(LoStream::new(
LoDictionary::new(),
content.as_bytes().to_vec(),
));
let page = doc.add_object(dictionary! {
"Type"=>"Page","Parent"=>pages,"MediaBox"=>vec![0.into(),0.into(),100.into(),100.into()],
"Resources"=>resources,"Contents"=>stream,
});
doc.objects.insert(
pages,
dictionary! {"Type"=>"Pages","Kids"=>vec![page.into()],"Count"=>1}.into(),
);
let root = doc.add_object(dictionary! {"Type"=>"Catalog","Pages"=>pages});
doc.trailer.set("Root", root);
doc.compress();
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
bytes
}
fn image(bytes: &[u8]) -> crate::image_native::RgbaImage {
let png = pdf_bytes_to_png_pages(bytes, 72, None, false).expect("finalized gradient preview");
assert_eq!(png.len(), 1);
crate::image_native::load_from_memory(&png[0])
.unwrap()
.into_rgba8()
}
fn simple(shading: LoDictionary, content: &str) -> crate::image_native::RgbaImage {
image(&pdf(
LoDocument::with_version("1.7"),
dictionary! {"Shading"=>dictionary! {"G"=>shading}},
content,
))
}
fn color(image: &crate::image_native::RgbaImage, x: u32, y: u32, expected: [u8; 3], tolerance: u8) {
let actual = image.get_pixel(x, y).0;
assert!(
actual[..3]
.iter()
.zip(expected)
.all(|(a, b)| a.abs_diff(b) <= tolerance),
"pixel ({x},{y}): {actual:?}, expected {expected:?}"
);
}
#[test]
fn finalized_axial_obeys_clip_bbox_and_nonextended_domain() {
let mut shading = axial(red_blue(), [20.0, 0.0, 80.0, 0.0], [false, false]);
shading.set("BBox", vec![0.into(), 20.into(), 100.into(), 80.into()]);
shading.set("Background", vec![0.into(), 1.into(), 0.into()]);
let image = simple(shading, "0 0 70 100 re W n /G sh");
color(&image, 10, 50, [255, 255, 255], 0);
color(&image, 50, 10, [255, 255, 255], 0);
color(&image, 75, 50, [255, 255, 255], 0);
color(&image, 50, 50, [125, 0, 130], 2);
color(&image, 21, 50, [249, 0, 6], 2);
}
#[test]
fn finalized_axial_extends_across_the_clip_after_translation_and_scaling() {
let shading = axial(red_blue(), [0.0, 0.0, 100.0, 0.0], [true, true]);
let image = simple(shading, "0 0 100 100 re W n 0.5 0 0 0.5 50 10 cm /G sh");
color(&image, 10, 50, [255, 0, 0], 0);
color(&image, 75, 50, [125, 0, 130], 2);
color(&image, 99, 5, [3, 0, 252], 2);
}
#[test]
fn finalized_axial_shear_preserves_the_gradient_coordinate_field() {
let image = simple(
axial(red_blue(), [0.0, 0.0, 100.0, 0.0], [true, true]),
"2 0 1 1 0 0 cm /G sh",
);
color(&image, 70, 50, [228, 0, 27], 2);
color(&image, 20, 50, [255, 0, 0], 0);
}
#[test]
fn finalized_gradient_preserves_stitched_hard_stops_and_narrow_bands() {
let constant = |rgb: Vec<f32>| exponential(rgb.clone(), rgb, 1.0);
let function = dictionary! {
"FunctionType"=>3,"Domain"=>vec![0.into(),1.into()],
"Functions"=>vec![constant(vec![1.0,0.0,0.0]),constant(vec![0.0,1.0,0.0]),constant(vec![0.0,0.0,1.0])],
"Bounds"=>vec![0.49.into(),0.51.into()],"Encode"=>vec![0.into(),1.into(),0.into(),1.into(),0.into(),1.into()],
};
let image = simple(
axial(function.into(), [0.0, 0.0, 100.0, 0.0], [true, true]),
"/G sh",
);
color(&image, 48, 50, [255, 0, 0], 0);
color(&image, 49, 50, [0, 255, 0], 0);
color(&image, 50, 50, [0, 255, 0], 0);
color(&image, 51, 50, [0, 0, 255], 0);
}
#[test]
fn finalized_gradient_evaluates_exponents_function_arrays_and_encode() {
let functions = LoObject::Array(vec![
exponential(vec![0.0], vec![1.0], 2.0),
exponential(vec![0.0], vec![0.0], 1.0),
exponential(vec![1.0], vec![0.0], 1.0),
]);
let image = simple(
axial(functions, [0.0, 0.0, 100.0, 0.0], [true, true]),
"/G sh",
);
color(&image, 50, 50, [65, 0, 126], 2);
let function = dictionary! {"FunctionType"=>3,"Domain"=>vec![2.into(),4.into()],"Functions"=>vec![red_blue()],"Bounds"=>Vec::<LoObject>::new(),"Encode"=>vec![1.into(),0.into()]};
let mut shading = axial(function.into(), [0.0, 0.0, 100.0, 0.0], [true, true]);
shading.set("Domain", vec![2.into(), 4.into()]);
let image = simple(shading, "/G sh");
color(&image, 25, 50, [65, 0, 190], 2);
}
#[test]
fn finalized_radial_preserves_nonzero_start_radius() {
let shading = dictionary! {"ShadingType"=>3,"ColorSpace"=>"DeviceRGB","Coords"=>vec![50.into(),50.into(),10.into(),50.into(),50.into(),40.into()],"Function"=>red_blue(),"Extend"=>vec![true.into(),true.into()]};
let image = simple(shading, "/G sh");
color(&image, 50, 50, [255, 0, 0], 0);
color(&image, 75, 50, [123, 0, 132], 3);
color(&image, 95, 50, [0, 0, 255], 0);
}
#[test]
fn finalized_gradient_resources_are_inherited_by_forms_with_a_bbox() {
let mut doc = LoDocument::with_version("1.7");
let form=doc.add_object(LoStream::new(dictionary! {"Type"=>"XObject","Subtype"=>"Form","BBox"=>vec![20.into(),20.into(),80.into(),80.into()],"Matrix"=>vec![1.into(),0.into(),0.into(),1.into(),5.into(),0.into()]},b"/G sh".to_vec()));
let resources = dictionary! {"Shading"=>dictionary! {"G"=>axial(red_blue(),[0.0,0.0,100.0,0.0],[true,true])},"XObject"=>dictionary! {"Form"=>form}};
let image = image(&pdf(doc, resources, "/Form Do 0 1 0 rg 0 0 10 10 re f"));
color(&image, 10, 50, [255, 255, 255], 0);
color(&image, 50, 50, [139, 0, 116], 2);
color(&image, 90, 50, [255, 255, 255], 0);
color(&image, 5, 95, [0, 255, 0], 0);
}
#[test]
fn finalized_html_gradient_keeps_the_real_chart_fill() {
let engine = crate::FullBleed::builder().build().unwrap();
let pdf=engine.render_to_buffer("<div></div>","@page {size:100pt 100pt;margin:10pt} div {width:80pt;height:50pt;background:linear-gradient(180deg,#d9bc77,#a9b884)}").unwrap();
let image = image(&pdf);
color(&image, 50, 20, [207, 187, 122], 3);
color(&image, 50, 50, [178, 185, 130], 3);
color(&image, 50, 80, [255, 255, 255], 0);
}
#[test]
fn finalized_html_gradient_respects_the_luminosity_soft_mask() {
let engine = crate::FullBleed::builder().build().unwrap();
let pdf=engine.render_to_buffer("<div></div>","@page {size:100pt 100pt;margin:0} body {background:#00ff00} div {width:100pt;height:100pt;background:linear-gradient(90deg,rgba(255,0,0,.5),rgba(0,0,255,.5))}").unwrap();
let image = image(&pdf);
color(&image, 50, 50, [63, 128, 64], 3);
}
#[test]
fn finalized_gradient_rejects_recursive_functions_without_hanging() {
let mut doc = LoDocument::with_version("1.7");
let id = doc.new_object_id();
doc.objects.insert(id,dictionary! {"FunctionType"=>3,"Domain"=>vec![0.into(),1.into()],"Functions"=>vec![id.into()],"Bounds"=>Vec::<LoObject>::new(),"Encode"=>vec![0.into(),1.into()]}.into());
let resources = dictionary! {"Shading"=>dictionary! {"G"=>axial(id.into(),[0.0,0.0,100.0,0.0],[true,true])}};
let bytes = pdf(doc, resources, "/G sh");
let error = pdf_bytes_to_png_pages(&bytes, 72, None, false)
.unwrap_err()
.to_string();
assert!(error.contains("function nesting"), "{error}");
}
#[test]
fn finalized_shading_preserves_the_current_path_and_fill_color() {
let image = simple(
axial(red_blue(), [0.0, 0.0, 100.0, 0.0], [true, true]),
"0 1 0 rg 10 10 20 20 re /G sh f",
);
color(&image, 20, 80, [0, 255, 0], 0);
color(&image, 50, 50, [126, 0, 129], 2);
}
#[test]
fn finalized_hard_radial_gradient_retains_its_rings() {
let engine = crate::FullBleed::builder().build().unwrap();
let pdf=engine.render_to_buffer("<div></div>","@page {size:100pt 100pt;margin:0} div {width:100pt;height:100pt;background:radial-gradient(circle 40pt at 50pt 50pt,red 0%,red 50%,blue 50%,blue 100%)}").unwrap();
let image = image(&pdf);
color(&image, 50, 50, [255, 0, 0], 0);
color(&image, 65, 50, [255, 0, 0], 0);
color(&image, 80, 50, [0, 0, 255], 0);
}
fn masked(subtype: &str, mask_content: &str, content: &str) -> crate::image_native::RgbaImage {
let mut doc = LoDocument::with_version("1.7");
let gray = axial(
exponential(vec![0.0, 0.0, 0.0], vec![1.0, 1.0, 1.0], 1.0),
[0.0, 0.0, 100.0, 0.0],
[true, true],
);
let form=doc.add_object(LoStream::new(dictionary! {
"Type"=>"XObject","Subtype"=>"Form","BBox"=>vec![0.into(),0.into(),100.into(),100.into()],
"Group"=>dictionary! {"S"=>"Transparency","CS"=>"DeviceRGB","I"=>true},
"Resources"=>dictionary! {"Shading"=>dictionary! {"M"=>gray},"ExtGState"=>dictionary! {"Half"=>dictionary! {"ca"=>0.5}}},
},mask_content.as_bytes().to_vec()));
let resources = dictionary! {
"Shading"=>dictionary! {"G"=>axial(exponential(vec![0.0,0.0,1.0],vec![0.0,0.0,1.0],1.0),[0.0,0.0,100.0,0.0],[true,true])},
"ExtGState"=>dictionary! {
"Half"=>dictionary! {"ca"=>0.5},
"Mask"=>dictionary! {"SMask"=>dictionary! {"S"=>LoObject::Name(subtype.as_bytes().to_vec()),"G"=>form}},
"Clear"=>dictionary! {"SMask"=>"None"},
},
};
image(&pdf(doc, resources, content))
}
#[test]
fn finalized_soft_mask_uses_pdf_luminosity_and_preserves_partial_extgstate() {
let image = masked(
"Luminosity",
"1 0 0 rg 0 0 100 100 re f",
"/Half gs /Mask gs /G sh",
);
color(&image, 50, 50, [217, 217, 255], 1);
}
#[test]
fn finalized_alpha_mask_uses_alpha_instead_of_color() {
let image = masked(
"Alpha",
"/Half gs 0 0 0 rg 0 0 100 100 re f",
"/Mask gs /G sh",
);
color(&image, 50, 50, [127, 127, 255], 1);
}
#[test]
fn finalized_soft_mask_keeps_the_ctm_from_its_gs_operator() {
let image = masked("Luminosity", "/M sh", "/Mask gs 1 0 0 1 20 0 cm /G sh");
color(&image, 25, 50, [190, 190, 255], 2);
color(&image, 75, 50, [62, 62, 255], 2);
}
#[test]
fn finalized_soft_mask_can_be_cleared_and_restored_with_q_q() {
let image = masked(
"Luminosity",
"/M sh",
"/Mask gs q /Clear gs 0 0 30 100 re W n /G sh Q q 30 0 30 100 re W n /G sh Q /Clear gs 60 0 40 100 re W n /G sh",
);
color(&image, 10, 50, [0, 0, 255], 0);
color(&image, 45, 50, [139, 139, 255], 2);
color(&image, 80, 50, [0, 0, 255], 0);
}
#[test]
fn finalized_varying_alpha_gradient_keeps_premultiplied_interpolation() {
let engine = crate::FullBleed::builder().build().unwrap();
let pdf=engine.render_to_buffer("<div></div>","@page {size:100pt 100pt;margin:0} body {background:#00ff00} div {width:100pt;height:100pt;background:linear-gradient(90deg,rgba(255,0,0,0),rgba(0,0,255,1))}").unwrap();
let image = image(&pdf);
color(&image, 25, 50, [0, 190, 65], 2);
color(&image, 75, 50, [0, 62, 193], 2);
}
#[test]
fn finalized_gradient_reports_unsupported_function_types() {
let function = dictionary! {"FunctionType"=>4,"Domain"=>vec![0.into(),1.into()]};
let bytes = pdf(
LoDocument::with_version("1.7"),
dictionary! {"Shading"=>dictionary! {"G"=>axial(function.into(),[0.0,0.0,100.0,0.0],[true,true])}},
"/G sh",
);
let error = pdf_bytes_to_png_pages(&bytes, 72, None, false)
.unwrap_err()
.to_string();
assert!(
error.contains("unsupported gradient FunctionType 4"),
"{error}"
);
}
#[test]
fn finalized_radial_cone_uses_the_last_circle_and_leaves_outside_unpainted() {
let shading = dictionary! {"ShadingType"=>3,"ColorSpace"=>"DeviceRGB","Coords"=>vec![20.into(),50.into(),0.into(),80.into(),50.into(),15.into()],"Function"=>red_blue(),"Extend"=>vec![true.into(),true.into()]};
let image = simple(shading, "/G sh");
color(&image, 35, 50, [167, 0, 88], 2);
color(&image, 50, 50, [82, 0, 173], 2);
color(&image, 75, 50, [0, 0, 255], 0);
color(&image, 50, 20, [255, 255, 255], 0);
}