zcatpdf/src/pdf.zig
reugenio f09922076f refactor: Rename zpdf to zcatpdf for consistency with zcat* family
- Renamed all references from zpdf to zcatpdf
- Module import: @import("zcatpdf")
- Consistent with zcatui, zcatgui naming convention
- All lowercase per Zig standards

Note: Directory rename (zpdf -> zcatpdf) and Forgejo repo rename
should be done manually after this commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 02:10:57 +01:00

566 lines
20 KiB
Zig

//! Pdf - Main facade for PDF document creation
//!
//! This is the main entry point for creating PDF documents.
//! Provides a high-level API similar to fpdf2's FPDF class.
//!
//! Based on: fpdf2/fpdf/fpdf.py (FPDF class)
const std = @import("std");
const Page = @import("page.zig").Page;
const ContentStream = @import("content_stream.zig").ContentStream;
const Color = @import("graphics/color.zig").Color;
const Font = @import("fonts/type1.zig").Font;
const TrueTypeFont = @import("fonts/ttf.zig").TrueTypeFont;
const PageSize = @import("objects/base.zig").PageSize;
const Orientation = @import("objects/base.zig").Orientation;
const Unit = @import("objects/base.zig").Unit;
const OutputProducer = @import("output/producer.zig").OutputProducer;
const PageData = @import("output/producer.zig").PageData;
const ExtGStateData = @import("output/producer.zig").ExtGStateData;
const GradientOutputData = @import("output/producer.zig").GradientOutputData;
const producer_GradientType = @import("output/producer.zig").GradientType;
const GradientData = @import("graphics/gradient.zig").GradientData;
const page_GradientType = @import("graphics/gradient.zig").GradientType;
const DocumentMetadata = @import("output/producer.zig").DocumentMetadata;
const CompressionOptions = @import("output/producer.zig").CompressionOptions;
const ImageInfo = @import("images/image_info.zig").ImageInfo;
const jpeg = @import("images/jpeg.zig");
const png = @import("images/png.zig");
const images_mod = @import("images/mod.zig");
const Outline = @import("outline.zig").Outline;
/// Configuration constants for zcatpdf
pub const Config = struct {
/// Maximum file size for image loading (default: 10MB)
pub const max_image_file_size: usize = 10 * 1024 * 1024;
/// Maximum decompression buffer size (default: 100MB)
pub const max_decompression_size: usize = 100 * 1024 * 1024;
};
/// A PDF document builder.
///
/// Example usage:
/// ```zig
/// var pdf = Pdf.init(allocator, .{});
/// defer pdf.deinit();
///
/// var page = try pdf.addPage(.{});
/// try page.setFont(.helvetica_bold, 24);
/// try page.drawText(50, 750, "Hello, World!");
///
/// try pdf.save("hello.pdf");
/// ```
pub const Pdf = struct {
allocator: std.mem.Allocator,
/// All pages in the document
pages: std.ArrayListUnmanaged(Page),
/// All images in the document
images: std.ArrayListUnmanaged(ImageInfo),
/// TrueType fonts loaded
ttf_fonts: std.ArrayListUnmanaged(TrueTypeFont),
/// TTF font data (raw bytes, need to keep alive)
ttf_data: std.ArrayListUnmanaged([]u8),
/// Document outline (bookmarks)
outline: Outline,
/// Document metadata
title: ?[]const u8 = null,
author: ?[]const u8 = null,
subject: ?[]const u8 = null,
creator: ?[]const u8 = null,
/// Default page settings
default_page_size: PageSize,
default_orientation: Orientation,
unit: Unit,
/// Compression settings
compression: CompressionOptions,
const Self = @This();
/// Options for creating a PDF document.
pub const Options = struct {
/// Default page size
page_size: PageSize = .a4,
/// Default page orientation
orientation: Orientation = .portrait,
/// Unit of measurement for user coordinates
unit: Unit = .pt,
/// Compression options for PDF streams
compression: CompressionOptions = .{},
};
/// Options for adding a page.
pub const PageOptions = struct {
/// Page size (uses document default if null)
size: ?PageSize = null,
/// Page orientation (uses document default if null)
orientation: ?Orientation = null,
};
// =========================================================================
// Initialization
// =========================================================================
/// Creates a new PDF document.
pub fn init(allocator: std.mem.Allocator, options: Options) Self {
return .{
.allocator = allocator,
.pages = .{},
.images = .{},
.ttf_fonts = .{},
.ttf_data = .{},
.outline = Outline.init(allocator),
.default_page_size = options.page_size,
.default_orientation = options.orientation,
.unit = options.unit,
.compression = options.compression,
};
}
/// Sets the compression level (0-12, where 0=disabled, 6=default, 12=max).
pub fn setCompressionLevel(self: *Self, level: i32) void {
if (level <= 0) {
self.compression.enabled = false;
} else {
self.compression.enabled = true;
self.compression.level = @min(level, 12);
}
}
/// Enables or disables stream compression.
pub fn setCompression(self: *Self, enabled: bool) void {
self.compression.enabled = enabled;
}
/// Frees all resources.
pub fn deinit(self: *Self) void {
for (self.pages.items) |*page| {
page.deinit();
}
self.pages.deinit(self.allocator);
// Free image data if owned
for (self.images.items) |*img| {
img.deinit(self.allocator);
}
self.images.deinit(self.allocator);
// Free TTF fonts
for (self.ttf_fonts.items) |*font| {
font.deinit();
}
self.ttf_fonts.deinit(self.allocator);
// Free TTF raw data
for (self.ttf_data.items) |data| {
self.allocator.free(data);
}
self.ttf_data.deinit(self.allocator);
self.outline.deinit();
}
// =========================================================================
// Document Metadata
// =========================================================================
/// Sets the document title.
pub fn setTitle(self: *Self, title: []const u8) void {
self.title = title;
}
/// Sets the document author.
pub fn setAuthor(self: *Self, author: []const u8) void {
self.author = author;
}
/// Sets the document subject.
pub fn setSubject(self: *Self, subject: []const u8) void {
self.subject = subject;
}
/// Sets the document creator.
pub fn setCreator(self: *Self, creator: []const u8) void {
self.creator = creator;
}
// =========================================================================
// Page Management
// =========================================================================
/// Adds a new page to the document.
pub fn addPage(self: *Self, options: PageOptions) !*Page {
const size = options.size orelse self.default_page_size;
const orientation = options.orientation orelse self.default_orientation;
const dims = size.dimensions();
const width = if (orientation == .landscape) dims.height else dims.width;
const height = if (orientation == .landscape) dims.width else dims.height;
const page = Page.initCustom(self.allocator, width, height);
try self.pages.append(self.allocator, page);
return &self.pages.items[self.pages.items.len - 1];
}
/// Adds a new page with custom dimensions (in the document's unit).
pub fn addPageCustom(self: *Self, width: f32, height: f32) !*Page {
const width_pt = self.unit.toPoints(width);
const height_pt = self.unit.toPoints(height);
const page = Page.initCustom(self.allocator, width_pt, height_pt);
try self.pages.append(self.allocator, page);
return &self.pages.items[self.pages.items.len - 1];
}
/// Returns the number of pages.
pub fn pageCount(self: *const Self) usize {
return self.pages.items.len;
}
/// Returns a page by index (0-based).
pub fn getPage(self: *Self, index: usize) ?*Page {
if (index < self.pages.items.len) {
return &self.pages.items[index];
}
return null;
}
// =========================================================================
// Image Management
// =========================================================================
/// Adds a JPEG image from raw data and returns its index.
/// The image data is stored and will be embedded in the PDF.
pub fn addJpegImage(self: *Self, jpeg_data: []const u8) !usize {
const info = try jpeg.parse(jpeg_data);
try self.images.append(self.allocator, info);
return self.images.items.len - 1;
}
/// Adds a JPEG image from a file and returns its index.
pub fn addJpegImageFromFile(self: *Self, path: []const u8) !usize {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const data = try file.readToEndAlloc(self.allocator, Config.max_image_file_size);
var info = try jpeg.parse(data);
// Mark as owned since we allocated the data
info.data = data;
info.owns_data = true;
try self.images.append(self.allocator, info);
return self.images.items.len - 1;
}
/// Adds a PNG image from raw data and returns its index.
/// Supports PNG with alpha channel (transparency).
pub fn addPngImage(self: *Self, png_data: []const u8) !usize {
const info = try png.parse(self.allocator, png_data);
try self.images.append(self.allocator, info);
return self.images.items.len - 1;
}
/// Adds a PNG image from a file and returns its index.
/// Supports PNG with alpha channel (transparency).
pub fn addPngImageFromFile(self: *Self, path: []const u8) !usize {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const data = try file.readToEndAlloc(self.allocator, Config.max_image_file_size);
defer self.allocator.free(data);
const info = try png.parse(self.allocator, data);
try self.images.append(self.allocator, info);
return self.images.items.len - 1;
}
/// Adds an image from file, auto-detecting format (JPEG or PNG).
pub fn addImageFromFile(self: *Self, path: []const u8) !usize {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const data = try file.readToEndAlloc(self.allocator, Config.max_image_file_size);
const format = images_mod.detectFormat(data) orelse {
self.allocator.free(data);
return error.UnsupportedImageFormat;
};
switch (format) {
.jpeg => {
var info = try jpeg.parse(data);
info.data = data;
info.owns_data = true;
try self.images.append(self.allocator, info);
},
.png => {
defer self.allocator.free(data);
const info = try png.parse(self.allocator, data);
try self.images.append(self.allocator, info);
},
}
return self.images.items.len - 1;
}
/// Returns the ImageInfo for an image by index.
pub fn getImage(self: *const Self, index: usize) ?*const ImageInfo {
if (index < self.images.items.len) {
return &self.images.items[index];
}
return null;
}
/// Returns the number of images.
pub fn imageCount(self: *const Self) usize {
return self.images.items.len;
}
// =========================================================================
// TrueType Font Management
// =========================================================================
/// Adds a TrueType font from raw data and returns its index.
/// The font can then be used with page.setTtfFont(index, size).
pub fn addTtfFont(self: *Self, ttf_data: []const u8) !usize {
const font = try TrueTypeFont.parse(self.allocator, ttf_data);
try self.ttf_fonts.append(self.allocator, font);
return self.ttf_fonts.items.len - 1;
}
/// Adds a TrueType font from a file and returns its index.
pub fn addTtfFontFromFile(self: *Self, path: []const u8) !usize {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const data = try file.readToEndAlloc(self.allocator, Config.max_image_file_size);
try self.ttf_data.append(self.allocator, data);
const font = try TrueTypeFont.parse(self.allocator, data);
try self.ttf_fonts.append(self.allocator, font);
return self.ttf_fonts.items.len - 1;
}
/// Returns the TrueTypeFont for a font by index.
pub fn getTtfFont(self: *const Self, index: usize) ?*const TrueTypeFont {
if (index < self.ttf_fonts.items.len) {
return &self.ttf_fonts.items[index];
}
return null;
}
/// Returns the number of TTF fonts loaded.
pub fn ttfFontCount(self: *const Self) usize {
return self.ttf_fonts.items.len;
}
// =========================================================================
// Bookmarks / Outline
// =========================================================================
/// Adds a top-level bookmark pointing to a page.
pub fn addBookmark(self: *Self, title: []const u8, page: usize) !void {
try self.outline.addBookmark(title, page);
}
/// Adds a bookmark with a specific Y position on the page.
pub fn addBookmarkAt(self: *Self, title: []const u8, page: usize, y: f32) !void {
try self.outline.addBookmarkAt(title, page, y);
}
/// Adds a nested bookmark (child).
pub fn addBookmarkChild(self: *Self, title: []const u8, page: usize, level: u8) !void {
try self.outline.addChild(title, page, level);
}
/// Adds a nested bookmark with Y position.
pub fn addBookmarkChildAt(self: *Self, title: []const u8, page: usize, y: f32, level: u8) !void {
try self.outline.addChildAt(title, page, y, level);
}
/// Returns the number of bookmarks.
pub fn bookmarkCount(self: *const Self) usize {
return self.outline.count();
}
// =========================================================================
// Output
// =========================================================================
/// Renders the document to a byte buffer.
pub fn render(self: *Self) ![]u8 {
// Collect page data
var page_data: std.ArrayListUnmanaged(PageData) = .{};
defer page_data.deinit(self.allocator);
// Keep track of all slices to free them after generation
var font_slices: std.ArrayListUnmanaged([]Font) = .{};
var extgstate_slices: std.ArrayListUnmanaged([]ExtGStateData) = .{};
var gradient_slices: std.ArrayListUnmanaged([]GradientOutputData) = .{};
defer {
for (font_slices.items) |slice| {
self.allocator.free(slice);
}
font_slices.deinit(self.allocator);
for (extgstate_slices.items) |slice| {
self.allocator.free(slice);
}
extgstate_slices.deinit(self.allocator);
for (gradient_slices.items) |slice| {
self.allocator.free(slice);
}
gradient_slices.deinit(self.allocator);
}
for (self.pages.items) |*page| {
// Get fonts used - convert to owned slice
var fonts: std.ArrayListUnmanaged(Font) = .{};
var iter = page.fonts_used.keyIterator();
while (iter.next()) |font| {
try fonts.append(self.allocator, font.*);
}
const fonts_slice = try fonts.toOwnedSlice(self.allocator);
try font_slices.append(self.allocator, fonts_slice);
// Get ExtGStates used - convert to owned slice
var extgstates: std.ArrayListUnmanaged(ExtGStateData) = .{};
for (page.getExtGStates()) |gs| {
try extgstates.append(self.allocator, .{
.fill_opacity = gs.fill_opacity,
.stroke_opacity = gs.stroke_opacity,
});
}
const extgstates_slice = try extgstates.toOwnedSlice(self.allocator);
try extgstate_slices.append(self.allocator, extgstates_slice);
// Get Gradients used - convert to output format
var gradients: std.ArrayListUnmanaged(GradientOutputData) = .{};
for (page.getGradients()) |grad| {
// Convert GradientData to GradientOutputData
const start_color = grad.start_color.toRgbFloats();
const end_color = grad.end_color.toRgbFloats();
try gradients.append(self.allocator, .{
.gradient_type = if (grad.gradient_type == page_GradientType.linear)
producer_GradientType.linear
else
producer_GradientType.radial,
.coords = grad.coords,
.start_color = .{ start_color.r, start_color.g, start_color.b },
.end_color = .{ end_color.r, end_color.g, end_color.b },
});
}
const gradients_slice = try gradients.toOwnedSlice(self.allocator);
try gradient_slices.append(self.allocator, gradients_slice);
try page_data.append(self.allocator, .{
.width = page.width,
.height = page.height,
.content = page.getContent(),
.fonts_used = fonts_slice,
.links = page.getLinks(),
.extgstates = extgstates_slice,
.gradients = gradients_slice,
});
}
// Collect image pointers
var image_ptrs: std.ArrayListUnmanaged(*const ImageInfo) = .{};
defer image_ptrs.deinit(self.allocator);
for (self.images.items) |*img| {
try image_ptrs.append(self.allocator, img);
}
// Generate PDF
var producer = OutputProducer.initWithCompression(self.allocator, self.compression);
defer producer.deinit();
return try producer.generateFull(page_data.items, .{
.title = self.title,
.author = self.author,
.subject = self.subject,
.creator = self.creator,
}, image_ptrs.items, self.outline.getItems());
}
/// Saves the document to a file.
pub fn save(self: *Self, path: []const u8) !void {
const data = try self.render();
defer self.allocator.free(data);
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
try file.writeAll(data);
}
/// Outputs the document and returns the bytes.
pub fn output(self: *Self) ![]u8 {
return try self.render();
}
};
// =============================================================================
// Tests
// =============================================================================
test "Pdf init" {
const allocator = std.testing.allocator;
var pdf = Pdf.init(allocator, .{});
defer pdf.deinit();
try std.testing.expectEqual(@as(usize, 0), pdf.pageCount());
}
test "Pdf addPage" {
const allocator = std.testing.allocator;
var pdf = Pdf.init(allocator, .{});
defer pdf.deinit();
_ = try pdf.addPage(.{});
try std.testing.expectEqual(@as(usize, 1), pdf.pageCount());
_ = try pdf.addPage(.{ .size = .letter });
try std.testing.expectEqual(@as(usize, 2), pdf.pageCount());
}
test "Pdf metadata" {
const allocator = std.testing.allocator;
var pdf = Pdf.init(allocator, .{});
defer pdf.deinit();
pdf.setTitle("Test Document");
pdf.setAuthor("Test Author");
try std.testing.expectEqualStrings("Test Document", pdf.title.?);
try std.testing.expectEqualStrings("Test Author", pdf.author.?);
}
test "Pdf render" {
const allocator = std.testing.allocator;
var pdf = Pdf.init(allocator, .{});
defer pdf.deinit();
var page = try pdf.addPage(.{});
try page.setFont(.helvetica, 12);
try page.drawText(100, 700, "Hello");
const output = try pdf.render();
defer allocator.free(output);
try std.testing.expect(std.mem.startsWith(u8, output, "%PDF-1.4"));
try std.testing.expect(std.mem.endsWith(u8, output, "%%EOF\n"));
}