use oxideav_core::vector::{FillRule, Group, ImageRef, Node, PathNode, VectorFrame};
use crate::error::PdfError;
use crate::objects::Document;
use crate::operators::{
concat_matrix, emit_clip_marker, emit_path, paint, restore, save, set_ext_gstate,
set_fill_paint, set_stroke_paint, set_stroke_style, OpBuf, PaintMode,
};
use crate::page::build_page;
use crate::resources::ResourceCollector;
pub fn write_pdf(frame: &VectorFrame) -> Result<Vec<u8>, PdfError> {
let mut op = OpBuf::new();
let mut resources = ResourceCollector::new();
emit_group(&mut op, &frame.root, &mut resources);
let content = op.into_bytes();
let mut doc = Document::new();
let _ = build_page(&mut doc, frame, content, &resources);
let mut out = Vec::with_capacity(2048);
doc.write_to(&mut out)?;
Ok(out)
}
fn emit_group(op: &mut OpBuf, group: &Group, resources: &mut ResourceCollector) {
save(op);
if !group.transform.is_identity() {
concat_matrix(op, &group.transform);
}
if (group.opacity - 1.0).abs() > 1e-6 {
let name = resources.add_opacity(group.opacity);
set_ext_gstate(op, &name);
}
if let Some(clip) = &group.clip {
emit_path(op, clip);
emit_clip_marker(op, FillRule::NonZero);
}
for child in &group.children {
emit_node(op, child, resources);
}
restore(op);
}
fn emit_node(op: &mut OpBuf, node: &Node, resources: &mut ResourceCollector) {
match node {
Node::Group(g) => emit_group(op, g, resources),
Node::Path(p) => emit_path_node(op, p, resources),
Node::Image(img) => emit_image(op, img, resources),
_ => {}
}
}
fn emit_path_node(op: &mut OpBuf, node: &PathNode, resources: &mut ResourceCollector) {
if node.path.commands.is_empty() {
return;
}
if node.fill.is_none() && node.stroke.is_none() {
return;
}
save(op);
if let Some(stroke) = &node.stroke {
set_stroke_style(op, stroke, resources);
}
if let Some(fill) = &node.fill {
set_fill_paint(op, fill, resources);
}
emit_path(op, &node.path);
let mode = match (node.fill.is_some(), node.stroke.is_some()) {
(true, true) => PaintMode::FillStroke,
(true, false) => PaintMode::Fill,
(false, true) => PaintMode::Stroke,
(false, false) => PaintMode::None,
};
paint(op, mode, node.fill_rule);
restore(op);
}
fn emit_image(op: &mut OpBuf, image: &ImageRef, resources: &mut ResourceCollector) {
let width = image.bounds.width.round() as u32;
let height = image.bounds.height.round() as u32;
let Some(name) = resources.add_rgba_image(&image.frame, width, height) else {
return;
};
save(op);
if !image.transform.is_identity() {
concat_matrix(op, &image.transform);
}
let bx = image.bounds.x as f32;
let by = image.bounds.y as f32;
let bw = image.bounds.width as f32;
let bh = image.bounds.height as f32;
let ctm = oxideav_core::vector::Transform2D {
a: bw,
b: 0.0,
c: 0.0,
d: -bh,
e: bx,
f: by + bh,
};
concat_matrix(op, &ctm);
let mut bytes = Vec::with_capacity(name.len() + 5);
bytes.push(b'/');
bytes.extend_from_slice(name.as_bytes());
bytes.extend_from_slice(b" Do\n");
op.append_raw(&bytes);
restore(op);
}
#[cfg(test)]
mod tests {
use super::*;
use oxideav_core::time::TimeBase;
use oxideav_core::vector::{Group, Paint, Path, PathCommand, Point, Rgba};
fn rect_frame(width: f32, height: f32, color: Rgba) -> VectorFrame {
let mut p = Path::new();
p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
p.commands
.push(PathCommand::LineTo(Point::new(width - 10.0, 10.0)));
p.commands
.push(PathCommand::LineTo(Point::new(width - 10.0, height - 10.0)));
p.commands
.push(PathCommand::LineTo(Point::new(10.0, height - 10.0)));
p.commands.push(PathCommand::Close);
VectorFrame {
width,
height,
view_box: None,
root: Group {
children: vec![Node::Path(PathNode {
path: p,
fill: Some(Paint::Solid(color)),
stroke: None,
fill_rule: FillRule::NonZero,
})],
..Group::default()
},
pts: None,
time_base: TimeBase::new(1, 1),
}
}
#[test]
fn write_pdf_starts_with_header_and_ends_with_eof() {
let frame = rect_frame(200.0, 100.0, Rgba::opaque(255, 128, 0));
let bytes = write_pdf(&frame).unwrap();
assert!(bytes.starts_with(b"%PDF-1.4\n"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn write_pdf_emits_basic_path_operators() {
let frame = rect_frame(200.0, 100.0, Rgba::opaque(0, 0, 0));
let bytes = write_pdf(&frame).unwrap();
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains(" m\n"));
assert!(s.contains(" l\n"));
assert!(s.contains("h\n"));
assert!(s.contains("f\n"));
}
#[test]
fn empty_root_still_produces_valid_skeleton() {
let frame = VectorFrame {
width: 10.0,
height: 10.0,
view_box: None,
root: Group::default(),
pts: None,
time_base: TimeBase::new(1, 1),
};
let bytes = write_pdf(&frame).unwrap();
assert!(bytes.starts_with(b"%PDF-1.4\n"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
}