- 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>
566 lines
20 KiB
Zig
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"));
|
|
}
|