use zenith_core::{AssetKind, AssetProvider, BytesAssetProvider, FontProvider, default_provider};
use zenith_scene::{
Color, FilterSpec, FitMode, GradientPaint, GradientStop, Paint, Rect, Scene, SceneCommand,
SceneGlyph,
};
use super::render_pdf;
fn providers() -> (impl FontProvider, impl AssetProvider) {
(default_provider(), BytesAssetProvider::new())
}
fn a_font_id(fonts: &dyn FontProvider) -> String {
fonts
.all_faces()
.first()
.map(|f| f.id.clone())
.expect("bundled fonts must be present")
}
fn render(scene: &Scene) -> Vec<u8> {
let (fonts, assets) = providers();
render_pdf(scene, &fonts, &assets)
}
#[test]
fn pdf_starts_and_ends_with_markers() {
let mut scene = Scene::new(100.0, 80.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 80.0,
paint: Paint::solid(Color::srgb(10, 20, 30, 255)),
});
let bytes = render(&scene);
assert!(!bytes.is_empty(), "PDF must be non-empty");
assert!(
bytes.starts_with(b"%PDF-"),
"PDF must start with %PDF- marker"
);
let tail_start = bytes.len().saturating_sub(8);
let tail = &bytes[tail_start..];
assert!(
tail.windows(5).any(|w| w == b"%%EOF"),
"PDF must end with %%EOF; tail was {:?}",
String::from_utf8_lossy(tail)
);
}
#[test]
fn render_is_byte_identical_across_runs() {
let fonts = default_provider();
let font_id = a_font_id(&fonts);
let mut scene = Scene::new(120.0, 60.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 120.0,
h: 60.0,
paint: Paint::solid(Color::srgb(240, 240, 240, 255)),
});
scene.commands.push(SceneCommand::DrawGlyphRun {
x: 10.0,
y: 40.0,
font_id,
font_size: 24.0,
color: Color::srgb(0, 0, 0, 255),
stroke_color: None,
stroke_width: None,
link: None,
selectable: true,
glyphs: vec![
SceneGlyph {
glyph_id: 5,
dx: 0.0,
dy: 0.0,
text: String::new(),
},
SceneGlyph {
glyph_id: 8,
dx: 14.0,
dy: 0.0,
text: String::new(),
},
],
});
let a = render(&scene);
let b = render(&scene);
assert_eq!(a, b, "two renders of the same scene must be byte-identical");
}
#[test]
fn cmyk_color_emits_device_cmyk_operator() {
let mut scene = Scene::new(50.0, 50.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 50.0,
h: 50.0,
paint: Paint::solid(Color::cmyk(59.0, 85.0, 0.0, 7.0, 97, 36, 237)),
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains(" k\n") || text.contains(" k "),
"CMYK fill must emit a DeviceCMYK `k` operator"
);
}
#[test]
fn srgb_color_emits_device_rgb_operator() {
let mut scene = Scene::new(50.0, 50.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 50.0,
h: 50.0,
paint: Paint::solid(Color::srgb(200, 100, 50, 255)),
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains(" rg\n") || text.contains(" rg "),
"sRGB fill must emit a DeviceRGB `rg` operator"
);
assert!(
!text.contains(" k\n"),
"sRGB-only scene must not emit a DeviceCMYK `k` operator"
);
}
#[test]
fn bleed_scene_has_distinct_trim_box() {
let mut scene = Scene::new(110.0, 90.0);
scene.trim = Some(Rect {
x: 5.0,
y: 5.0,
w: 100.0,
h: 80.0,
});
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 110.0,
h: 90.0,
paint: Paint::solid(Color::srgb(0, 0, 0, 255)),
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/TrimBox"), "page must carry a /TrimBox");
assert!(text.contains("/BleedBox"), "page must carry a /BleedBox");
assert!(text.contains("/CropBox"), "page must carry a /CropBox");
assert!(
text.contains("/TrimBox [5 5 105 85]"),
"TrimBox must differ from MediaBox; got document:\n{}",
&text[..text.len().min(1200)]
);
}
#[test]
fn non_bleed_scene_has_equal_boxes() {
let mut scene = Scene::new(64.0, 48.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 48.0,
paint: Paint::solid(Color::srgb(1, 2, 3, 255)),
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/MediaBox [0 0 64 48]"));
assert!(text.contains("/TrimBox [0 0 64 48]"));
assert!(text.contains("/BleedBox [0 0 64 48]"));
assert!(text.contains("/CropBox [0 0 64 48]"));
}
#[test]
fn glyph_run_emits_path_fill_ops() {
let fonts = default_provider();
let font_id = a_font_id(&fonts);
let mut scene = Scene::new(200.0, 60.0);
scene.commands.push(SceneCommand::DrawGlyphRun {
x: 10.0,
y: 40.0,
font_id,
font_size: 32.0,
color: Color::srgb(0, 0, 0, 255),
stroke_color: None,
stroke_width: None,
link: None,
selectable: false,
glyphs: vec![SceneGlyph {
glyph_id: 36, dx: 0.0,
dy: 0.0,
text: String::new(),
}],
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Type0"),
"non-selectable run must not embed a Type0 font"
);
assert!(
text.contains(" c\n"),
"glyph outline must emit cubic-curve `c` ops"
);
assert!(
text.contains("\nf\n") || text.contains(" f\n"),
"glyph run must emit a fill `f` op"
);
}
#[test]
fn svg_asset_emits_vector_paths_and_shading_not_raster() {
const LOGO_SVG: &[u8] = b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
<defs><linearGradient id='g' gradientUnits='userSpaceOnUse' x1='0' y1='0' x2='100' y2='100'>\
<stop offset='0' stop-color='#24b6dd'/><stop offset='1' stop-color='#0860a0'/>\
</linearGradient></defs>\
<polygon points='0,0 100,0 0,100' fill='url(#g)'/>\
<polygon points='100,0 100,100 0,100' fill='#051b3e'/></svg>";
let fonts = default_provider();
let mut assets = BytesAssetProvider::new();
assets.register("asset.logo", AssetKind::Svg, std::sync::Arc::from(LOGO_SVG));
let mut scene = Scene::new(200.0, 200.0);
scene.commands.push(SceneCommand::DrawImage {
x: 20.0,
y: 20.0,
w: 160.0,
h: 160.0,
asset_id: "asset.logo".to_string(),
fit: FitMode::Contain,
pos_x: 50.0,
pos_y: 50.0,
opacity: 1.0,
clip_shape: None,
src_rect: None,
});
let bytes = render_pdf(&scene, &fonts, &assets);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("\nf\n") || text.contains(" f\n"),
"SVG solid polygon must emit a nonzero fill `f` op"
);
assert!(
text.contains("/sh0 sh"),
"SVG linear gradient must emit an axial shading `/sh0 sh`"
);
assert!(
!text.contains("/Subtype /Image"),
"SVG must be vector, not a rasterized image XObject"
);
}
#[test]
fn gradient_scene_renders_shading() {
let mut scene = Scene::new(100.0, 100.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 100.0,
paint: Paint::Gradient(GradientPaint {
angle_deg: 90.0,
stops: vec![
GradientStop {
offset: 0.0,
color: Color::srgb(255, 0, 0, 255),
},
GradientStop {
offset: 0.5,
color: Color::srgb(0, 255, 0, 255),
},
GradientStop {
offset: 1.0,
color: Color::srgb(0, 0, 255, 255),
},
],
radial: false,
center_x: None,
center_y: None,
radius_frac: None,
}),
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/ShadingType 2"),
"linear gradient must emit an axial (Type 2) shading"
);
assert!(
text.contains("/FunctionType 3"),
"a 3-stop gradient must use a Type 3 stitching function"
);
assert!(text.contains(" sh\n"), "shading must be painted with `sh`");
}
#[test]
fn unfiltered_fill_rect_embeds_no_image() {
let mut scene = Scene::new(60.0, 40.0);
scene.commands.push(SceneCommand::FillRect {
x: 10.0,
y: 10.0,
w: 30.0,
h: 20.0,
paint: Paint::solid(Color::srgb(200, 60, 40, 255)),
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Subtype /Image"),
"an unfiltered fill rect must not embed any image XObject"
);
}
#[test]
fn filtered_fill_rect_embeds_image_xobject() {
let mut scene = Scene::new(60.0, 40.0);
scene.commands.push(SceneCommand::BeginFilter {
filters: vec![FilterSpec::Grayscale(1.0)],
});
scene.commands.push(SceneCommand::FillRect {
x: 10.0,
y: 10.0,
w: 30.0,
h: 20.0,
paint: Paint::solid(Color::srgb(200, 60, 40, 255)),
});
scene.commands.push(SceneCommand::EndFilter);
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Subtype /Image"),
"a filtered fill rect must rasterize+embed an image XObject"
);
assert!(
text.contains("/im0"),
"the embedded filter image must be named im0 in page resources"
);
}
#[test]
fn filtered_region_render_is_byte_identical() {
let mut scene = Scene::new(80.0, 50.0);
scene.commands.push(SceneCommand::BeginFilter {
filters: vec![FilterSpec::Sepia(1.0)],
});
scene.commands.push(SceneCommand::FillRect {
x: 5.0,
y: 5.0,
w: 40.0,
h: 30.0,
paint: Paint::solid(Color::srgb(20, 140, 200, 255)),
});
scene.commands.push(SceneCommand::EndFilter);
let a = render(&scene);
let b = render(&scene);
assert_eq!(
a, b,
"two renders of a filtered scene must be byte-identical"
);
}
#[test]
fn multi_page_pdf_has_one_page_object_per_scene() {
use super::render_pdf_multi;
let (fonts, assets) = providers();
let mut p1 = Scene::new(100.0, 80.0);
p1.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 80.0,
paint: Paint::solid(Color::srgb(10, 20, 30, 255)),
});
let mut p2 = Scene::new(120.0, 60.0);
p2.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 120.0,
h: 60.0,
paint: Paint::solid(Color::srgb(200, 100, 50, 255)),
});
let bytes = render_pdf_multi(&[p1, p2], &fonts, &assets);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Count 2"),
"two-scene PDF must declare /Count 2"
);
let mediaboxes = text.matches("/MediaBox").count();
assert_eq!(mediaboxes, 2, "each page must carry its own MediaBox");
assert!(
text.contains("/MediaBox [0 0 100 80]"),
"page 1 box missing"
);
assert!(
text.contains("/MediaBox [0 0 120 60]"),
"page 2 box missing"
);
}
#[test]
fn single_scene_multi_is_byte_identical_to_render_pdf() {
use super::render_pdf_multi;
let (fonts, assets) = providers();
let mut scene = Scene::new(64.0, 48.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 48.0,
paint: Paint::solid(Color::srgb(1, 2, 3, 255)),
});
let via_wrapper = render_pdf(&scene, &fonts, &assets);
let via_multi = render_pdf_multi(std::slice::from_ref(&scene), &fonts, &assets);
assert_eq!(
via_wrapper, via_multi,
"single-scene multi-page path must be byte-identical to render_pdf"
);
}
#[test]
fn selectable_glyph_run_emits_embedded_text() {
let fonts = default_provider();
let font_id = a_font_id(&fonts);
let mut scene = Scene::new(200.0, 60.0);
scene.commands.push(SceneCommand::DrawGlyphRun {
x: 10.0,
y: 40.0,
font_id,
font_size: 32.0,
color: Color::srgb(0, 0, 0, 255),
stroke_color: None,
stroke_width: None,
link: None,
selectable: true,
glyphs: vec![SceneGlyph {
glyph_id: 36,
dx: 0.0,
dy: 0.0,
text: "A".to_owned(),
}],
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Type0"),
"selectable run must embed a Type0 font"
);
assert!(
text.contains("/CIDFontType2"),
"selectable run must embed a CIDFontType2 descendant"
);
assert!(
text.contains("/ToUnicode"),
"selectable run must carry a ToUnicode CMap for extraction"
);
assert!(
text.contains("beginbfchar"),
"ToUnicode CMap must contain a bfchar block"
);
}
#[test]
fn selectable_link_run_emits_link_annotation() {
let fonts = default_provider();
let font_id = a_font_id(&fonts);
let mut scene = Scene::new(200.0, 60.0);
scene.commands.push(SceneCommand::DrawGlyphRun {
x: 10.0,
y: 40.0,
font_id,
font_size: 32.0,
color: Color::srgb(0, 0, 0, 255),
stroke_color: None,
stroke_width: None,
link: Some("https://zenith.example/".to_owned()),
selectable: true,
glyphs: vec![SceneGlyph {
glyph_id: 36,
dx: 0.0,
dy: 0.0,
text: "A".to_owned(),
}],
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Link"),
"a linked run must emit a /Link annotation"
);
assert!(
text.contains("/URI"),
"the link annotation must carry a /URI action"
);
assert!(
text.contains("https://zenith.example/"),
"the link URL must appear in the annotation"
);
}
#[test]
fn non_selectable_run_has_no_font_or_link() {
let fonts = default_provider();
let font_id = a_font_id(&fonts);
let mut scene = Scene::new(200.0, 60.0);
scene.commands.push(SceneCommand::DrawGlyphRun {
x: 10.0,
y: 40.0,
font_id,
font_size: 32.0,
color: Color::srgb(0, 0, 0, 255),
stroke_color: None,
stroke_width: None,
link: Some("https://zenith.example/".to_owned()),
selectable: false,
glyphs: vec![SceneGlyph {
glyph_id: 36,
dx: 0.0,
dy: 0.0,
text: "A".to_owned(),
}],
});
let bytes = render(&scene);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("/Type0"),
"non-selectable run must not embed a font"
);
assert!(
!text.contains("/Link"),
"non-selectable run must not emit a link annotation"
);
}
#[test]
fn textless_scene_identical_under_subset_options() {
use super::{PdfOptions, render_pdf_with};
let (fonts, assets) = providers();
let mut scene = Scene::new(40.0, 40.0);
scene.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 40.0,
h: 40.0,
paint: Paint::solid(Color::srgb(9, 9, 9, 255)),
});
let subset = render_pdf_with(&scene, &fonts, &assets, PdfOptions { subset: true });
let full = render_pdf_with(&scene, &fonts, &assets, PdfOptions { subset: false });
let plain = render(&scene);
assert_eq!(
subset, full,
"textless scene must not depend on the subset option"
);
assert_eq!(
subset, plain,
"textless scene must match the default render"
);
assert!(
!String::from_utf8_lossy(&subset).contains("/Type0"),
"a textless scene must embed no fonts"
);
}