use std::fmt::Write as _;
pub use crate::core::{Affine2D, Path, color::Rgba};
use crate::core::PathSegment;
use crate::render::{GraphicsContext, Renderer};
#[derive(Debug, Clone)]
pub struct PdfRenderer {
content: String,
width: f64,
height: f64,
dpi: f64,
ext_gstates: Vec<(f64, f64)>,
}
impl PdfRenderer {
#[must_use]
pub fn new(width_px: f64, height_px: f64, dpi: f64) -> Self {
Self {
content: String::new(),
width: width_px,
height: height_px,
dpi,
ext_gstates: Vec::new(),
}
}
#[must_use]
pub fn dpi(&self) -> f64 {
self.dpi
}
fn build_path_ops(&mut self, path: &Path, transform: &Affine2D) -> bool {
let map = |p: [f64; 2]| -> (f64, f64) { transform.transform_point((p[0], p[1])) };
let mut any = false;
for seg in path.iter_segments() {
match seg {
PathSegment::MoveTo(p) => {
let (x, y) = map(p);
let _ = writeln!(self.content, "{} {} m", fmt_f(x), fmt_f(y));
any = true;
}
PathSegment::LineTo(p) => {
let (x, y) = map(p);
let _ = writeln!(self.content, "{} {} l", fmt_f(x), fmt_f(y));
any = true;
}
PathSegment::Quad(c, e) => {
let (cx, cy) = map(c);
let (ex, ey) = map(e);
let _ = writeln!(
self.content,
"{} {} {} {} {} {} c",
fmt_f(cx),
fmt_f(cy),
fmt_f(cx),
fmt_f(cy),
fmt_f(ex),
fmt_f(ey),
);
any = true;
}
PathSegment::Cubic(c1, c2, e) => {
let (c1x, c1y) = map(c1);
let (c2x, c2y) = map(c2);
let (ex, ey) = map(e);
let _ = writeln!(
self.content,
"{} {} {} {} {} {} c",
fmt_f(c1x),
fmt_f(c1y),
fmt_f(c2x),
fmt_f(c2y),
fmt_f(ex),
fmt_f(ey),
);
any = true;
}
PathSegment::Close => {
self.content.push_str("h\n");
}
}
}
any
}
fn push_ext_gstate(&mut self, fill_alpha: f64, stroke_alpha: f64) -> Option<String> {
let fa = fill_alpha.clamp(0.0, 1.0);
let sa = stroke_alpha.clamp(0.0, 1.0);
if fa >= 1.0 && sa >= 1.0 {
return None;
}
let idx = self
.ext_gstates
.iter()
.position(|&(f, s)| f == fa && s == sa)
.unwrap_or_else(|| {
self.ext_gstates.push((fa, sa));
self.ext_gstates.len() - 1
});
Some(format!("GS{idx}"))
}
#[must_use]
pub fn finish(&self) -> Vec<u8> {
let content = self.content.as_bytes();
let mut ext_gstate_dict = String::new();
for (i, &(fa, sa)) in self.ext_gstates.iter().enumerate() {
let _ = write!(
ext_gstate_dict,
"/GS{i} << /Type /ExtGState /ca {} /CA {} >> ",
fmt_f(fa),
fmt_f(sa)
);
}
let resources = if ext_gstate_dict.is_empty() {
"<< >>".to_string()
} else {
format!("<< /ExtGState << {ext_gstate_dict}>> >>")
};
let objects: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources 5 0 R >>",
fmt_f(self.width),
fmt_f(self.height)
),
String::new(), resources,
];
let mut out: Vec<u8> = Vec::new();
out.extend_from_slice(b"%PDF-1.7\n");
out.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
let mut offsets: Vec<usize> = vec![0; objects.len() + 1];
for (i, body) in objects.iter().enumerate() {
let obj_num = i + 1;
offsets[obj_num] = out.len();
if obj_num == 4 {
let header = format!("4 0 obj\n<< /Length {} >>\nstream\n", content.len());
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(content);
out.extend_from_slice(b"\nendstream\nendobj\n");
} else {
let obj = format!("{obj_num} 0 obj\n{body}\nendobj\n");
out.extend_from_slice(obj.as_bytes());
}
}
let xref_pos = out.len();
let count = objects.len() + 1; let mut xref = format!("xref\n0 {count}\n0000000000 65535 f \n");
for offset in offsets.iter().skip(1) {
let _ = writeln!(xref, "{offset:010} 00000 n ");
}
out.extend_from_slice(xref.as_bytes());
let trailer =
format!("trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n");
out.extend_from_slice(trailer.as_bytes());
out
}
#[must_use]
pub fn into_pdf(self) -> Vec<u8> {
self.finish()
}
pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> std::io::Result<()> {
std::fs::write(path, self.finish())
}
}
fn fmt_f(v: f64) -> String {
if !v.is_finite() {
return "0".to_string();
}
let mut s = format!("{v:.4}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if s == "-0" {
s = "0".to_string();
}
s
}
fn effective_opacity(rgba: Rgba, alpha: Option<f64>) -> f64 {
(rgba.a * alpha.unwrap_or(1.0)).clamp(0.0, 1.0)
}
impl Renderer for PdfRenderer {
fn draw_path(
&mut self,
gc: &GraphicsContext,
path: &Path,
transform: &Affine2D,
fill: Option<Rgba>,
) {
let saved_len = self.content.len();
let any = self.build_path_ops(path, transform);
if !any {
self.content.truncate(saved_len);
return;
}
let width = self.points_to_pixels(gc.line_width);
let do_stroke = gc.stroke.is_some() && width > 0.0;
let do_fill = fill.is_some();
let fill_alpha = fill.map_or(1.0, |f| effective_opacity(f, gc.alpha));
let stroke_alpha = gc.stroke.map_or(1.0, |s| effective_opacity(s, gc.alpha));
let ops = self.content.split_off(saved_len);
let mut block = String::new();
if let Some(gs) = self.push_ext_gstate(fill_alpha, stroke_alpha) {
let _ = writeln!(block, "/{gs} gs");
}
if let Some(face) = fill {
let _ = writeln!(
block,
"{} {} {} rg",
fmt_f(face.r),
fmt_f(face.g),
fmt_f(face.b)
);
}
if let Some(stroke) = gc.stroke.filter(|_| do_stroke) {
let _ = writeln!(
block,
"{} {} {} RG",
fmt_f(stroke.r),
fmt_f(stroke.g),
fmt_f(stroke.b)
);
let _ = writeln!(block, "{} w", fmt_f(width));
}
block.push_str(&ops);
let paint = match (do_fill, do_stroke) {
(true, true) => "B",
(true, false) => "f",
(false, true) => "S",
(false, false) => "n", };
let _ = writeln!(block, "{paint}");
self.content.push_str("q\n");
if let Some(clip) = gc.clip_rect {
let _ = writeln!(
self.content,
"{} {} {} {} re W n",
fmt_f(clip.xmin()),
fmt_f(clip.ymin()),
fmt_f(clip.width()),
fmt_f(clip.height())
);
}
self.content.push_str(&block);
self.content.push_str("Q\n");
}
fn canvas_size(&self) -> (f64, f64) {
(self.width, self.height)
}
fn points_to_pixels(&self, points: f64) -> f64 {
points * self.dpi / 72.0
}
fn decoration_scale(&self) -> f64 {
self.dpi / 100.0
}
fn flipy(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn find_startxref(pdf: &[u8]) -> usize {
let marker = b"startxref\n";
let mut idx = None;
let mut from = 0;
while let Some(rel) = find_bytes(&pdf[from..], marker) {
idx = Some(from + rel);
from += rel + 1;
}
let start = idx.expect("startxref present") + marker.len();
let end = start
+ pdf[start..]
.iter()
.position(|&b| b == b'\n')
.expect("newline after startxref offset");
std::str::from_utf8(&pdf[start..end])
.expect("offset digits are ascii")
.parse()
.expect("startxref offset parses")
}
#[test]
fn header_and_trailer_are_well_formed() {
let r = PdfRenderer::new(100.0, 100.0, 72.0);
let pdf = r.finish();
assert!(pdf.starts_with(b"%PDF"), "must start with %PDF");
assert!(pdf.ends_with(b"%%EOF\n"), "must end with %%EOF");
}
#[test]
fn exactly_one_page() {
let r = PdfRenderer::new(100.0, 100.0, 72.0);
let pdf = r.finish();
let s = String::from_utf8_lossy(&pdf);
assert_eq!(
s.matches("/Type /Page").count() - s.matches("/Type /Pages").count(),
1,
"exactly one /Type /Page object"
);
}
#[test]
fn startxref_points_at_xref_keyword() {
let mut r = PdfRenderer::new(120.0, 80.0, 72.0);
r.draw_path(
&GraphicsContext::new(),
&Path::unit_rectangle(),
&Affine2D::from_scale(40.0, 40.0).translate(10.0, 10.0),
Some(Rgba::RED),
);
let pdf = r.finish();
let offset = find_startxref(&pdf);
assert_eq!(
&pdf[offset..offset + 4],
b"xref",
"startxref offset must point at the xref keyword"
);
}
#[test]
fn filled_path_emits_fill_operator_and_color() {
let mut r = PdfRenderer::new(100.0, 100.0, 72.0);
r.draw_path(
&GraphicsContext::new(),
&Path::unit_rectangle(),
&Affine2D::from_scale(60.0, 60.0).translate(20.0, 20.0),
Some(Rgba::RED),
);
let pdf = String::from_utf8_lossy(&r.finish()).into_owned();
assert!(pdf.contains("1 0 0 rg"), "red nonstroking color: {pdf}");
assert!(
pdf.contains("\nf\n") || pdf.contains("\nB\n"),
"fill or fill+stroke operator present: {pdf}"
);
assert!(pdf.contains(" m\n"), "moveto operator present: {pdf}");
}
#[test]
fn stroked_path_emits_stroke_operator_and_width() {
let mut r = PdfRenderer::new(100.0, 100.0, 72.0);
let line = Path::from_polyline(&[[10.0, 50.0], [90.0, 50.0]]);
let gc = GraphicsContext::new()
.with_stroke(Rgba::BLACK)
.with_line_width(4.0);
r.draw_path(&gc, &line, &Affine2D::identity(), None);
let pdf = String::from_utf8_lossy(&r.finish()).into_owned();
assert!(pdf.contains("0 0 0 RG"), "black stroking color: {pdf}");
assert!(pdf.contains("4 w"), "line width 4: {pdf}");
assert!(pdf.contains("\nS\n"), "stroke operator present: {pdf}");
}
#[test]
fn fill_and_stroke_uses_b_operator() {
let mut r = PdfRenderer::new(100.0, 100.0, 72.0);
let gc = GraphicsContext::new().with_stroke(Rgba::BLACK);
r.draw_path(
&gc,
&Path::unit_rectangle(),
&Affine2D::from_scale(50.0, 50.0),
Some(Rgba::BLUE),
);
let pdf = String::from_utf8_lossy(&r.finish()).into_owned();
assert!(pdf.contains("\nB\n"), "fill+stroke uses B: {pdf}");
}
#[test]
fn alpha_emits_ext_gstate() {
let mut r = PdfRenderer::new(100.0, 100.0, 72.0);
let gc = GraphicsContext::new().with_alpha(0.5);
r.draw_path(
&gc,
&Path::unit_rectangle(),
&Affine2D::from_scale(50.0, 50.0),
Some(Rgba::RED),
);
let pdf = String::from_utf8_lossy(&r.finish()).into_owned();
assert!(pdf.contains("/ExtGState"), "ExtGState resource: {pdf}");
assert!(pdf.contains("/ca 0.5"), "fill alpha 0.5: {pdf}");
assert!(pdf.contains("/GS0 gs"), "applies the gstate: {pdf}");
}
#[test]
fn capabilities_match_dpi_and_no_flip() {
let r = PdfRenderer::new(10.0, 20.0, 144.0);
assert!(!r.flipy(), "PDF user space is y-up, no flip");
assert_eq!(r.canvas_size(), (10.0, 20.0));
assert_eq!(r.points_to_pixels(3.0), 6.0);
}
#[test]
fn empty_path_emits_nothing() {
let mut r = PdfRenderer::new(50.0, 50.0, 72.0);
r.draw_path(
&GraphicsContext::new(),
&Path::new(vec![], None),
&Affine2D::identity(),
Some(Rgba::RED),
);
assert!(r.content.is_empty(), "empty path must not emit content");
}
#[test]
fn xref_offsets_point_at_object_headers() {
let mut r = PdfRenderer::new(80.0, 60.0, 72.0);
r.draw_path(
&GraphicsContext::new(),
&Path::unit_rectangle(),
&Affine2D::from_scale(30.0, 30.0),
Some(Rgba::GREEN),
);
let pdf = r.finish();
let xref_pos = find_startxref(&pdf);
let xref_text = std::str::from_utf8(&pdf[xref_pos..]).expect("xref section is ascii");
for n in 1..=5usize {
let needle = format!("{n} 0 obj");
let pos = find_bytes(&pdf, needle.as_bytes()).expect("object header present");
let line = xref_text
.lines()
.nth(n + 2) .expect("xref entry line");
let offset: usize = line[..10].parse().expect("offset parses");
assert_eq!(offset, pos, "xref offset for object {n} must match header");
}
}
}