feat: v0.3 - Image support (JPEG embedding)
Phase 3 - Images: - JPEG parser with direct DCT passthrough (no re-encoding) - PNG metadata extraction (full embedding pending) - Page.image() for drawing images at position - Page.imageFit() for auto-scaling with aspect ratio - Pdf.addJpegImage() / addJpegImageFromFile() - XObject generation in OutputProducer New modules: - src/images/mod.zig - Image module exports - src/images/image_info.zig - ImageInfo struct - src/images/jpeg.zig - JPEG parser - src/images/png.zig - PNG metadata parser New example: - examples/image_demo.zig - Image embedding demo Stats: - 66 unit tests passing - 4 working examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2996289953
commit
f9189253d7
11 changed files with 1125 additions and 4 deletions
19
build.zig
19
build.zig
|
|
@ -80,4 +80,23 @@ pub fn build(b: *std.Build) void {
|
||||||
run_text_demo.step.dependOn(b.getInstallStep());
|
run_text_demo.step.dependOn(b.getInstallStep());
|
||||||
const text_demo_step = b.step("text_demo", "Run text demo example");
|
const text_demo_step = b.step("text_demo", "Run text demo example");
|
||||||
text_demo_step.dependOn(&run_text_demo.step);
|
text_demo_step.dependOn(&run_text_demo.step);
|
||||||
|
|
||||||
|
// Example: image_demo
|
||||||
|
const image_demo_exe = b.addExecutable(.{
|
||||||
|
.name = "image_demo",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("examples/image_demo.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "zpdf", .module = zpdf_mod },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
b.installArtifact(image_demo_exe);
|
||||||
|
|
||||||
|
const run_image_demo = b.addRunArtifact(image_demo_exe);
|
||||||
|
run_image_demo.step.dependOn(b.getInstallStep());
|
||||||
|
const image_demo_step = b.step("image_demo", "Run image demo example");
|
||||||
|
image_demo_step.dependOn(&run_image_demo.step);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
196
examples/image_demo.zig
Normal file
196
examples/image_demo.zig
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
//! Image Demo - Demonstrates JPEG image embedding
|
||||||
|
//!
|
||||||
|
//! Usage: ./image_demo [path_to_jpeg]
|
||||||
|
//! If no path is provided, creates a simple PDF with text explaining the feature.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const pdf = @import("zpdf");
|
||||||
|
|
||||||
|
pub fn main() !void {
|
||||||
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||||
|
defer _ = gpa.deinit();
|
||||||
|
const allocator = gpa.allocator();
|
||||||
|
|
||||||
|
std.debug.print("zpdf - Image Demo\n", .{});
|
||||||
|
|
||||||
|
// Get command line args
|
||||||
|
const args = try std.process.argsAlloc(allocator);
|
||||||
|
defer std.process.argsFree(allocator, args);
|
||||||
|
|
||||||
|
var doc = pdf.Pdf.init(allocator, .{});
|
||||||
|
defer doc.deinit();
|
||||||
|
|
||||||
|
doc.setTitle("Image Demo");
|
||||||
|
doc.setAuthor("zpdf");
|
||||||
|
|
||||||
|
var page = try doc.addPage(.{});
|
||||||
|
page.setMargins(50, 50, 50);
|
||||||
|
page.setXY(50, 800);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
try page.setFont(.helvetica_bold, 24);
|
||||||
|
page.setFillColor(pdf.Color.rgb(41, 98, 255));
|
||||||
|
try page.cell(0, 30, "Image Demo - JPEG Support", pdf.Border.none, .center, false);
|
||||||
|
page.ln(40);
|
||||||
|
|
||||||
|
// Check if an image path was provided
|
||||||
|
if (args.len > 1) {
|
||||||
|
const image_path = args[1];
|
||||||
|
std.debug.print("Loading image: {s}\n", .{image_path});
|
||||||
|
|
||||||
|
// Try to load the image
|
||||||
|
const image_index = doc.addJpegImageFromFile(image_path) catch |err| {
|
||||||
|
std.debug.print("Error loading image: {any}\n", .{err});
|
||||||
|
|
||||||
|
try page.setFont(.helvetica, 12);
|
||||||
|
page.setFillColor(pdf.Color.red);
|
||||||
|
try page.cell(0, 20, "Error: Could not load image file", pdf.Border.none, .left, false);
|
||||||
|
page.ln(25);
|
||||||
|
|
||||||
|
try page.setFont(.helvetica, 10);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
const long_text =
|
||||||
|
\\Make sure the file exists and is a valid JPEG image.
|
||||||
|
\\
|
||||||
|
\\Supported features:
|
||||||
|
\\- JPEG/JPG images (RGB and Grayscale)
|
||||||
|
\\- Direct embedding (no re-encoding)
|
||||||
|
\\- Automatic dimension detection
|
||||||
|
;
|
||||||
|
try page.multiCell(450, null, long_text, pdf.Border.none, .left, false);
|
||||||
|
|
||||||
|
const filename = "image_demo.pdf";
|
||||||
|
try doc.save(filename);
|
||||||
|
std.debug.print("Created: {s}\n", .{filename});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get image info
|
||||||
|
const img_info = doc.getImage(image_index).?;
|
||||||
|
|
||||||
|
// Show image info
|
||||||
|
try page.setFont(.helvetica_bold, 14);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
try page.cell(0, 20, "Image Information:", pdf.Border.none, .left, false);
|
||||||
|
page.ln(25);
|
||||||
|
|
||||||
|
try page.setFont(.helvetica, 11);
|
||||||
|
|
||||||
|
// Display image properties
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
const size_text = std.fmt.bufPrint(&buf, "Dimensions: {d} x {d} pixels", .{ img_info.width, img_info.height }) catch "Error";
|
||||||
|
try page.cell(0, 16, size_text, pdf.Border.none, .left, false);
|
||||||
|
page.ln(18);
|
||||||
|
|
||||||
|
const color_text = std.fmt.bufPrint(&buf, "Color Space: {s}", .{img_info.color_space.pdfName()}) catch "Error";
|
||||||
|
try page.cell(0, 16, color_text, pdf.Border.none, .left, false);
|
||||||
|
page.ln(18);
|
||||||
|
|
||||||
|
const bpc_text = std.fmt.bufPrint(&buf, "Bits per Component: {d}", .{img_info.bits_per_component}) catch "Error";
|
||||||
|
try page.cell(0, 16, bpc_text, pdf.Border.none, .left, false);
|
||||||
|
page.ln(30);
|
||||||
|
|
||||||
|
// Draw the image
|
||||||
|
try page.setFont(.helvetica_bold, 14);
|
||||||
|
try page.cell(0, 20, "Image Preview:", pdf.Border.none, .left, false);
|
||||||
|
page.ln(25);
|
||||||
|
|
||||||
|
// Calculate size to fit in available space (max 400x400)
|
||||||
|
const max_w: f32 = 400;
|
||||||
|
const max_h: f32 = 400;
|
||||||
|
const img_w: f32 = @floatFromInt(img_info.width);
|
||||||
|
const img_h: f32 = @floatFromInt(img_info.height);
|
||||||
|
const scale = @min(max_w / img_w, max_h / img_h, 1.0);
|
||||||
|
const display_w = img_w * scale;
|
||||||
|
const display_h = img_h * scale;
|
||||||
|
|
||||||
|
// Draw border around image area
|
||||||
|
const img_x = page.getX();
|
||||||
|
const img_y = page.getY() - display_h;
|
||||||
|
|
||||||
|
page.setStrokeColor(pdf.Color.light_gray);
|
||||||
|
try page.drawRect(img_x - 2, img_y - 2, display_w + 4, display_h + 4);
|
||||||
|
|
||||||
|
// Draw the image
|
||||||
|
try page.image(image_index, img_info, img_x, img_y, display_w, display_h);
|
||||||
|
|
||||||
|
std.debug.print("Image embedded: {d}x{d} pixels, displayed at {d:.0}x{d:.0} points\n", .{ img_info.width, img_info.height, display_w, display_h });
|
||||||
|
} else {
|
||||||
|
// No image provided - show instructions
|
||||||
|
try page.setFont(.helvetica_bold, 14);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
try page.cell(0, 20, "How to Use Images:", pdf.Border.none, .left, false);
|
||||||
|
page.ln(25);
|
||||||
|
|
||||||
|
try page.setFont(.helvetica, 11);
|
||||||
|
const instructions =
|
||||||
|
\\To include a JPEG image in your PDF:
|
||||||
|
\\
|
||||||
|
\\1. Load the image file:
|
||||||
|
\\ const img_idx = try doc.addJpegImageFromFile("photo.jpg");
|
||||||
|
\\
|
||||||
|
\\2. Get the image info:
|
||||||
|
\\ const info = doc.getImage(img_idx).?;
|
||||||
|
\\
|
||||||
|
\\3. Draw on a page:
|
||||||
|
\\ try page.image(img_idx, info, x, y, width, height);
|
||||||
|
\\
|
||||||
|
\\Or use imageFit to auto-scale:
|
||||||
|
\\ try page.imageFit(img_idx, info, x, y, max_w, max_h);
|
||||||
|
\\
|
||||||
|
\\Run this example with a JPEG file path:
|
||||||
|
\\ ./image_demo photo.jpg
|
||||||
|
;
|
||||||
|
try page.multiCell(450, null, instructions, pdf.Border.all, .left, false);
|
||||||
|
|
||||||
|
page.ln(30);
|
||||||
|
|
||||||
|
// Supported features
|
||||||
|
try page.setFont(.helvetica_bold, 14);
|
||||||
|
try page.cell(0, 20, "Supported Features:", pdf.Border.none, .left, false);
|
||||||
|
page.ln(25);
|
||||||
|
|
||||||
|
try page.setFont(.helvetica, 11);
|
||||||
|
page.setFillColor(pdf.Color.rgb(0, 128, 0));
|
||||||
|
try page.cell(20, 16, "[OK]", pdf.Border.none, .left, false);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
try page.cell(0, 16, "JPEG images (RGB, Grayscale, CMYK)", pdf.Border.none, .left, false);
|
||||||
|
page.ln(18);
|
||||||
|
|
||||||
|
page.setFillColor(pdf.Color.rgb(0, 128, 0));
|
||||||
|
try page.cell(20, 16, "[OK]", pdf.Border.none, .left, false);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
try page.cell(0, 16, "Direct passthrough (no re-encoding)", pdf.Border.none, .left, false);
|
||||||
|
page.ln(18);
|
||||||
|
|
||||||
|
page.setFillColor(pdf.Color.rgb(0, 128, 0));
|
||||||
|
try page.cell(20, 16, "[OK]", pdf.Border.none, .left, false);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
try page.cell(0, 16, "Automatic dimension detection", pdf.Border.none, .left, false);
|
||||||
|
page.ln(18);
|
||||||
|
|
||||||
|
page.setFillColor(pdf.Color.rgb(0, 128, 0));
|
||||||
|
try page.cell(20, 16, "[OK]", pdf.Border.none, .left, false);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
try page.cell(0, 16, "Aspect ratio preservation", pdf.Border.none, .left, false);
|
||||||
|
page.ln(18);
|
||||||
|
|
||||||
|
page.setFillColor(pdf.Color.rgb(255, 165, 0));
|
||||||
|
try page.cell(20, 16, "[--]", pdf.Border.none, .left, false);
|
||||||
|
page.setFillColor(pdf.Color.black);
|
||||||
|
try page.cell(0, 16, "PNG images (metadata only, not yet embedded)", pdf.Border.none, .left, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
page.setXY(50, 50);
|
||||||
|
try page.setFont(.helvetica, 9);
|
||||||
|
page.setFillColor(pdf.Color.medium_gray);
|
||||||
|
try page.cell(0, 15, "Generated with zpdf - Pure Zig PDF Library", pdf.Border.none, .center, false);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const filename = "image_demo.pdf";
|
||||||
|
try doc.save(filename);
|
||||||
|
|
||||||
|
std.debug.print("Created: {s}\n", .{filename});
|
||||||
|
std.debug.print("Done!\n", .{});
|
||||||
|
}
|
||||||
|
|
@ -408,6 +408,18 @@ pub const ContentStream = struct {
|
||||||
try self.showText(str);
|
try self.showText(str);
|
||||||
try self.endText();
|
try self.endText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Draws an XObject image at position (x, y) with size (w, h)
|
||||||
|
/// PDF command: q w 0 0 h x y cm /Ii Do Q
|
||||||
|
pub fn image(self: *Self, image_index: usize, x: f32, y: f32, w: f32, h: f32) !void {
|
||||||
|
try self.saveState();
|
||||||
|
// Transformation matrix: scale and translate
|
||||||
|
// [w 0 0 h x y] cm
|
||||||
|
try self.writeFmt("{d:.2} 0 0 {d:.2} {d:.2} {d:.2} cm\n", .{ w, h, x, y });
|
||||||
|
// Draw XObject image
|
||||||
|
try self.writeFmt("/I{d} Do\n", .{image_index});
|
||||||
|
try self.restoreState();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
151
src/images/image_info.zig
Normal file
151
src/images/image_info.zig
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
//! ImageInfo - Parsed image metadata and data for PDF embedding
|
||||||
|
//!
|
||||||
|
//! This structure holds all the information needed to embed an image
|
||||||
|
//! as an XObject in a PDF document.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Image format
|
||||||
|
pub const ImageFormat = enum {
|
||||||
|
jpeg,
|
||||||
|
png,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Color space for image
|
||||||
|
pub const ColorSpace = enum {
|
||||||
|
device_gray,
|
||||||
|
device_rgb,
|
||||||
|
device_cmyk,
|
||||||
|
|
||||||
|
pub fn pdfName(self: ColorSpace) []const u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.device_gray => "DeviceGray",
|
||||||
|
.device_rgb => "DeviceRGB",
|
||||||
|
.device_cmyk => "DeviceCMYK",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn components(self: ColorSpace) u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.device_gray => 1,
|
||||||
|
.device_rgb => 3,
|
||||||
|
.device_cmyk => 4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// PDF filter for image data compression
|
||||||
|
pub const ImageFilter = enum {
|
||||||
|
/// DCT (JPEG) compression - used for JPEG images
|
||||||
|
dct_decode,
|
||||||
|
/// Flate (zlib) compression - used for PNG and other images
|
||||||
|
flate_decode,
|
||||||
|
|
||||||
|
pub fn pdfName(self: ImageFilter) []const u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.dct_decode => "DCTDecode",
|
||||||
|
.flate_decode => "FlateDecode",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parsed image information ready for PDF embedding
|
||||||
|
pub const ImageInfo = struct {
|
||||||
|
/// Image width in pixels
|
||||||
|
width: u32,
|
||||||
|
/// Image height in pixels
|
||||||
|
height: u32,
|
||||||
|
/// Color space
|
||||||
|
color_space: ColorSpace,
|
||||||
|
/// Bits per component (usually 8)
|
||||||
|
bits_per_component: u8,
|
||||||
|
/// PDF filter for the image data
|
||||||
|
filter: ImageFilter,
|
||||||
|
/// Raw image data (compressed for JPEG, may need processing for PNG)
|
||||||
|
data: []const u8,
|
||||||
|
/// Soft mask (alpha channel) data, if present
|
||||||
|
soft_mask: ?[]const u8,
|
||||||
|
/// Whether this struct owns the data (should free on deinit)
|
||||||
|
owns_data: bool,
|
||||||
|
/// Whether CMYK needs inversion (some JPEG CMYK images)
|
||||||
|
invert_cmyk: bool,
|
||||||
|
/// Original format
|
||||||
|
format: ImageFormat,
|
||||||
|
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
/// Free resources if we own them
|
||||||
|
pub fn deinit(self: *Self, allocator: std.mem.Allocator) void {
|
||||||
|
if (self.owns_data) {
|
||||||
|
allocator.free(self.data);
|
||||||
|
if (self.soft_mask) |mask| {
|
||||||
|
allocator.free(mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate aspect ratio (width / height)
|
||||||
|
pub fn aspectRatio(self: *const Self) f32 {
|
||||||
|
return @as(f32, @floatFromInt(self.width)) / @as(f32, @floatFromInt(self.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of color components
|
||||||
|
pub fn colorComponents(self: *const Self) u8 {
|
||||||
|
return self.color_space.components();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if image has transparency
|
||||||
|
pub fn hasAlpha(self: *const Self) bool {
|
||||||
|
return self.soft_mask != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate PDF decode parameters string
|
||||||
|
pub fn decodeParams(self: *const Self, buf: []u8) []const u8 {
|
||||||
|
if (self.filter == .flate_decode) {
|
||||||
|
const result = std.fmt.bufPrint(buf, "/Predictor 15 /Colors {d} /Columns {d} /BitsPerComponent {d}", .{
|
||||||
|
self.colorComponents(),
|
||||||
|
self.width,
|
||||||
|
self.bits_per_component,
|
||||||
|
}) catch return "";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
test "ColorSpace properties" {
|
||||||
|
try std.testing.expectEqualStrings("DeviceRGB", ColorSpace.device_rgb.pdfName());
|
||||||
|
try std.testing.expectEqual(@as(u8, 3), ColorSpace.device_rgb.components());
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("DeviceGray", ColorSpace.device_gray.pdfName());
|
||||||
|
try std.testing.expectEqual(@as(u8, 1), ColorSpace.device_gray.components());
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("DeviceCMYK", ColorSpace.device_cmyk.pdfName());
|
||||||
|
try std.testing.expectEqual(@as(u8, 4), ColorSpace.device_cmyk.components());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ImageFilter pdfName" {
|
||||||
|
try std.testing.expectEqualStrings("DCTDecode", ImageFilter.dct_decode.pdfName());
|
||||||
|
try std.testing.expectEqualStrings("FlateDecode", ImageFilter.flate_decode.pdfName());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ImageInfo aspectRatio" {
|
||||||
|
const info = ImageInfo{
|
||||||
|
.width = 800,
|
||||||
|
.height = 600,
|
||||||
|
.color_space = .device_rgb,
|
||||||
|
.bits_per_component = 8,
|
||||||
|
.filter = .dct_decode,
|
||||||
|
.data = &[_]u8{},
|
||||||
|
.soft_mask = null,
|
||||||
|
.owns_data = false,
|
||||||
|
.invert_cmyk = false,
|
||||||
|
.format = .jpeg,
|
||||||
|
};
|
||||||
|
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f32, 1.333), info.aspectRatio(), 0.01);
|
||||||
|
}
|
||||||
248
src/images/jpeg.zig
Normal file
248
src/images/jpeg.zig
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
//! JPEG image parser for PDF embedding
|
||||||
|
//!
|
||||||
|
//! JPEG images can be embedded directly in PDF using DCTDecode filter.
|
||||||
|
//! This parser extracts the necessary metadata (dimensions, color space)
|
||||||
|
//! from the JPEG header without decoding the image data.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const ImageInfo = @import("image_info.zig").ImageInfo;
|
||||||
|
const ColorSpace = @import("image_info.zig").ColorSpace;
|
||||||
|
const ImageFilter = @import("image_info.zig").ImageFilter;
|
||||||
|
const ImageFormat = @import("image_info.zig").ImageFormat;
|
||||||
|
|
||||||
|
/// JPEG marker bytes
|
||||||
|
const JPEG_MARKERS = struct {
|
||||||
|
const SOI: u8 = 0xD8; // Start of Image
|
||||||
|
const EOI: u8 = 0xD9; // End of Image
|
||||||
|
const SOS: u8 = 0xDA; // Start of Scan
|
||||||
|
const DQT: u8 = 0xDB; // Define Quantization Table
|
||||||
|
const DNL: u8 = 0xDC; // Define Number of Lines
|
||||||
|
const DRI: u8 = 0xDD; // Define Restart Interval
|
||||||
|
const DHT: u8 = 0xC4; // Define Huffman Table
|
||||||
|
const DAC: u8 = 0xCC; // Define Arithmetic Coding
|
||||||
|
const APP0: u8 = 0xE0; // Application-specific marker 0 (JFIF)
|
||||||
|
const APP1: u8 = 0xE1; // Application-specific marker 1 (EXIF)
|
||||||
|
const APP2: u8 = 0xE2; // Application-specific marker 2 (ICC)
|
||||||
|
const APP14: u8 = 0xEE; // Application-specific marker 14 (Adobe)
|
||||||
|
const COM: u8 = 0xFE; // Comment
|
||||||
|
|
||||||
|
// Start of Frame markers (we need these for image dimensions)
|
||||||
|
const SOF0: u8 = 0xC0; // Baseline DCT
|
||||||
|
const SOF1: u8 = 0xC1; // Extended sequential DCT
|
||||||
|
const SOF2: u8 = 0xC2; // Progressive DCT
|
||||||
|
const SOF3: u8 = 0xC3; // Lossless
|
||||||
|
const SOF5: u8 = 0xC5; // Differential sequential DCT
|
||||||
|
const SOF6: u8 = 0xC6; // Differential progressive DCT
|
||||||
|
const SOF7: u8 = 0xC7; // Differential lossless
|
||||||
|
const SOF9: u8 = 0xC9; // Extended sequential DCT, arithmetic
|
||||||
|
const SOF10: u8 = 0xCA; // Progressive DCT, arithmetic
|
||||||
|
const SOF11: u8 = 0xCB; // Lossless, arithmetic
|
||||||
|
const SOF13: u8 = 0xCD; // Differential sequential DCT, arithmetic
|
||||||
|
const SOF14: u8 = 0xCE; // Differential progressive DCT, arithmetic
|
||||||
|
const SOF15: u8 = 0xCF; // Differential lossless, arithmetic
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const JpegError = error{
|
||||||
|
InvalidSignature,
|
||||||
|
UnexpectedEndOfData,
|
||||||
|
NoFrameFound,
|
||||||
|
UnsupportedColorSpace,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse JPEG image data and extract metadata for PDF embedding.
|
||||||
|
/// The JPEG data is embedded directly without re-encoding.
|
||||||
|
pub fn parse(data: []const u8) JpegError!ImageInfo {
|
||||||
|
// Validate JPEG signature: FF D8 FF
|
||||||
|
if (data.len < 4) return JpegError.InvalidSignature;
|
||||||
|
if (data[0] != 0xFF or data[1] != JPEG_MARKERS.SOI or data[2] != 0xFF) {
|
||||||
|
return JpegError.InvalidSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
var width: u32 = 0;
|
||||||
|
var height: u32 = 0;
|
||||||
|
var components: u8 = 0;
|
||||||
|
var bits_per_component: u8 = 8;
|
||||||
|
var found_frame = false;
|
||||||
|
var is_adobe_cmyk = false;
|
||||||
|
|
||||||
|
// Parse JPEG markers
|
||||||
|
var pos: usize = 2;
|
||||||
|
while (pos < data.len - 1) {
|
||||||
|
// Find marker (FF xx)
|
||||||
|
if (data[pos] != 0xFF) {
|
||||||
|
pos += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip padding FF bytes
|
||||||
|
while (pos < data.len and data[pos] == 0xFF) {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos >= data.len) break;
|
||||||
|
|
||||||
|
const marker = data[pos];
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
// Check for SOF (Start of Frame) markers
|
||||||
|
if (isSOFMarker(marker)) {
|
||||||
|
if (pos + 7 > data.len) return JpegError.UnexpectedEndOfData;
|
||||||
|
|
||||||
|
// Skip length bytes
|
||||||
|
pos += 2;
|
||||||
|
|
||||||
|
// Read frame data
|
||||||
|
bits_per_component = data[pos];
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
height = (@as(u32, data[pos]) << 8) | @as(u32, data[pos + 1]);
|
||||||
|
pos += 2;
|
||||||
|
|
||||||
|
width = (@as(u32, data[pos]) << 8) | @as(u32, data[pos + 1]);
|
||||||
|
pos += 2;
|
||||||
|
|
||||||
|
components = data[pos];
|
||||||
|
found_frame = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Adobe APP14 marker (indicates CMYK handling)
|
||||||
|
if (marker == JPEG_MARKERS.APP14) {
|
||||||
|
if (pos + 2 > data.len) return JpegError.UnexpectedEndOfData;
|
||||||
|
const len = (@as(u16, data[pos]) << 8) | @as(u16, data[pos + 1]);
|
||||||
|
|
||||||
|
// Check for "Adobe" string
|
||||||
|
if (len >= 12 and pos + 12 <= data.len) {
|
||||||
|
if (std.mem.eql(u8, data[pos + 2 .. pos + 7], "Adobe")) {
|
||||||
|
is_adobe_cmyk = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += len;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip other markers with length
|
||||||
|
if (marker != JPEG_MARKERS.SOI and marker != JPEG_MARKERS.EOI and
|
||||||
|
marker != 0x00 and (marker < 0xD0 or marker > 0xD7))
|
||||||
|
{
|
||||||
|
if (pos + 2 > data.len) return JpegError.UnexpectedEndOfData;
|
||||||
|
const len = (@as(u16, data[pos]) << 8) | @as(u16, data[pos + 1]);
|
||||||
|
pos += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found_frame) return JpegError.NoFrameFound;
|
||||||
|
|
||||||
|
// Determine color space from component count
|
||||||
|
const color_space: ColorSpace = switch (components) {
|
||||||
|
1 => .device_gray,
|
||||||
|
3 => .device_rgb,
|
||||||
|
4 => .device_cmyk,
|
||||||
|
else => return JpegError.UnsupportedColorSpace,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ImageInfo{
|
||||||
|
.width = width,
|
||||||
|
.height = height,
|
||||||
|
.color_space = color_space,
|
||||||
|
.bits_per_component = bits_per_component,
|
||||||
|
.filter = .dct_decode,
|
||||||
|
.data = data, // Direct passthrough - JPEG data is used as-is
|
||||||
|
.soft_mask = null, // JPEG doesn't support alpha
|
||||||
|
.owns_data = false, // We don't allocate, caller owns the data
|
||||||
|
.invert_cmyk = is_adobe_cmyk and color_space == .device_cmyk,
|
||||||
|
.format = .jpeg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if marker is a Start of Frame marker
|
||||||
|
fn isSOFMarker(marker: u8) bool {
|
||||||
|
return switch (marker) {
|
||||||
|
JPEG_MARKERS.SOF0,
|
||||||
|
JPEG_MARKERS.SOF1,
|
||||||
|
JPEG_MARKERS.SOF2,
|
||||||
|
JPEG_MARKERS.SOF3,
|
||||||
|
JPEG_MARKERS.SOF5,
|
||||||
|
JPEG_MARKERS.SOF6,
|
||||||
|
JPEG_MARKERS.SOF7,
|
||||||
|
JPEG_MARKERS.SOF9,
|
||||||
|
JPEG_MARKERS.SOF10,
|
||||||
|
JPEG_MARKERS.SOF11,
|
||||||
|
JPEG_MARKERS.SOF13,
|
||||||
|
JPEG_MARKERS.SOF14,
|
||||||
|
JPEG_MARKERS.SOF15,
|
||||||
|
=> true,
|
||||||
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
test "parse valid JPEG header" {
|
||||||
|
// Minimal valid JPEG with SOF0 marker
|
||||||
|
// FF D8 FF E0 [JFIF APP0] FF C0 [SOF0 frame]
|
||||||
|
const jpeg_data = [_]u8{
|
||||||
|
0xFF, 0xD8, // SOI
|
||||||
|
0xFF, 0xE0, // APP0
|
||||||
|
0x00, 0x10, // Length 16
|
||||||
|
'J', 'F', 'I', 'F', 0x00, // JFIF identifier
|
||||||
|
0x01, 0x01, // Version
|
||||||
|
0x00, // Units
|
||||||
|
0x00, 0x01, // X density
|
||||||
|
0x00, 0x01, // Y density
|
||||||
|
0x00, 0x00, // Thumbnail
|
||||||
|
0xFF, 0xC0, // SOF0
|
||||||
|
0x00, 0x0B, // Length 11
|
||||||
|
0x08, // Bits per component
|
||||||
|
0x00, 0x64, // Height: 100
|
||||||
|
0x00, 0xC8, // Width: 200
|
||||||
|
0x03, // Components: 3 (RGB)
|
||||||
|
0x01, 0x22, 0x00, // Component 1
|
||||||
|
0x02, 0x11, 0x01, // Component 2
|
||||||
|
0x03, 0x11, 0x01, // Component 3
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = try parse(&jpeg_data);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(u32, 200), info.width);
|
||||||
|
try std.testing.expectEqual(@as(u32, 100), info.height);
|
||||||
|
try std.testing.expectEqual(ColorSpace.device_rgb, info.color_space);
|
||||||
|
try std.testing.expectEqual(@as(u8, 8), info.bits_per_component);
|
||||||
|
try std.testing.expectEqual(ImageFilter.dct_decode, info.filter);
|
||||||
|
try std.testing.expect(info.soft_mask == null);
|
||||||
|
try std.testing.expectEqual(false, info.owns_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse grayscale JPEG" {
|
||||||
|
const jpeg_data = [_]u8{
|
||||||
|
0xFF, 0xD8, // SOI
|
||||||
|
0xFF, 0xC0, // SOF0 (directly, no APP0)
|
||||||
|
0x00, 0x08, // Length 8
|
||||||
|
0x08, // Bits per component
|
||||||
|
0x00, 0x32, // Height: 50
|
||||||
|
0x00, 0x50, // Width: 80
|
||||||
|
0x01, // Components: 1 (Grayscale)
|
||||||
|
0x01, 0x11, 0x00, // Component 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = try parse(&jpeg_data);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(u32, 80), info.width);
|
||||||
|
try std.testing.expectEqual(@as(u32, 50), info.height);
|
||||||
|
try std.testing.expectEqual(ColorSpace.device_gray, info.color_space);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "invalid JPEG signature" {
|
||||||
|
const invalid_data = [_]u8{ 0x89, 0x50, 0x4E, 0x47 }; // PNG signature
|
||||||
|
const result = parse(&invalid_data);
|
||||||
|
try std.testing.expectError(JpegError.InvalidSignature, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "JPEG too short" {
|
||||||
|
const short_data = [_]u8{ 0xFF, 0xD8 };
|
||||||
|
const result = parse(&short_data);
|
||||||
|
try std.testing.expectError(JpegError.InvalidSignature, result);
|
||||||
|
}
|
||||||
50
src/images/mod.zig
Normal file
50
src/images/mod.zig
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
//! Image module for zpdf
|
||||||
|
//!
|
||||||
|
//! Provides image parsing and embedding support for PDF generation.
|
||||||
|
//! Supports JPEG (direct embedding) and PNG (with alpha support).
|
||||||
|
|
||||||
|
pub const jpeg = @import("jpeg.zig");
|
||||||
|
pub const png = @import("png.zig");
|
||||||
|
pub const ImageInfo = @import("image_info.zig").ImageInfo;
|
||||||
|
pub const ImageFormat = @import("image_info.zig").ImageFormat;
|
||||||
|
|
||||||
|
// Re-export common functions
|
||||||
|
pub const parseJpeg = jpeg.parse;
|
||||||
|
pub const parsePng = png.parse;
|
||||||
|
|
||||||
|
/// Detects image format from raw bytes
|
||||||
|
pub fn detectFormat(data: []const u8) ?ImageFormat {
|
||||||
|
if (data.len < 8) return null;
|
||||||
|
|
||||||
|
// JPEG: starts with FF D8 FF
|
||||||
|
if (data[0] == 0xFF and data[1] == 0xD8 and data[2] == 0xFF) {
|
||||||
|
return .jpeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG: starts with 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (data[0] == 0x89 and data[1] == 0x50 and data[2] == 0x4E and data[3] == 0x47 and
|
||||||
|
data[4] == 0x0D and data[5] == 0x0A and data[6] == 0x1A and data[7] == 0x0A)
|
||||||
|
{
|
||||||
|
return .png;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detect JPEG format" {
|
||||||
|
const jpeg_header = [_]u8{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46 };
|
||||||
|
const format = detectFormat(&jpeg_header);
|
||||||
|
try @import("std").testing.expectEqual(ImageFormat.jpeg, format.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detect PNG format" {
|
||||||
|
const png_header = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||||
|
const format = detectFormat(&png_header);
|
||||||
|
try @import("std").testing.expectEqual(ImageFormat.png, format.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detect unknown format" {
|
||||||
|
const unknown = [_]u8{ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 };
|
||||||
|
const format = detectFormat(&unknown);
|
||||||
|
try @import("std").testing.expect(format == null);
|
||||||
|
}
|
||||||
207
src/images/png.zig
Normal file
207
src/images/png.zig
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
//! PNG image parser for PDF embedding
|
||||||
|
//!
|
||||||
|
//! PNG images need to be decoded and re-encoded for PDF.
|
||||||
|
//! - RGB/Grayscale data is compressed with FlateDecode
|
||||||
|
//! - Alpha channel (if present) is stored as a separate soft mask
|
||||||
|
//!
|
||||||
|
//! NOTE: Full PNG parsing requires complex zlib decompression.
|
||||||
|
//! This module provides metadata extraction from PNG headers.
|
||||||
|
//! For full PNG support with alpha, consider using external tools
|
||||||
|
//! to convert PNG to JPEG first, or implement full PNG decompression.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const ImageInfo = @import("image_info.zig").ImageInfo;
|
||||||
|
const ColorSpace = @import("image_info.zig").ColorSpace;
|
||||||
|
const ImageFilter = @import("image_info.zig").ImageFilter;
|
||||||
|
const ImageFormat = @import("image_info.zig").ImageFormat;
|
||||||
|
|
||||||
|
pub const PngError = error{
|
||||||
|
InvalidSignature,
|
||||||
|
UnexpectedEndOfData,
|
||||||
|
InvalidChunk,
|
||||||
|
UnsupportedColorType,
|
||||||
|
UnsupportedBitDepth,
|
||||||
|
UnsupportedInterlace,
|
||||||
|
InvalidIHDR,
|
||||||
|
NotImplemented,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// PNG color types
|
||||||
|
pub const ColorType = enum(u8) {
|
||||||
|
grayscale = 0,
|
||||||
|
rgb = 2,
|
||||||
|
indexed = 3,
|
||||||
|
grayscale_alpha = 4,
|
||||||
|
rgba = 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// PNG chunk header
|
||||||
|
const ChunkHeader = struct {
|
||||||
|
length: u32,
|
||||||
|
chunk_type: [4]u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// PNG metadata extracted from header (without full decompression)
|
||||||
|
pub const PngMetadata = struct {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
bit_depth: u8,
|
||||||
|
color_type: ColorType,
|
||||||
|
has_alpha: bool,
|
||||||
|
channels: u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Extract PNG metadata without full decompression.
|
||||||
|
/// This is useful for getting image dimensions before processing.
|
||||||
|
pub fn parseMetadata(data: []const u8) PngError!PngMetadata {
|
||||||
|
// Validate PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (data.len < 8) return PngError.InvalidSignature;
|
||||||
|
const png_sig = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||||
|
if (!std.mem.eql(u8, data[0..8], &png_sig)) {
|
||||||
|
return PngError.InvalidSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse IHDR chunk (must be first)
|
||||||
|
const pos: usize = 8;
|
||||||
|
|
||||||
|
const ihdr = readChunkHeader(data, pos) orelse return PngError.InvalidIHDR;
|
||||||
|
if (!std.mem.eql(u8, &ihdr.chunk_type, "IHDR")) return PngError.InvalidIHDR;
|
||||||
|
if (ihdr.length != 13) return PngError.InvalidIHDR;
|
||||||
|
|
||||||
|
const header_pos = pos + 8; // Skip length and type
|
||||||
|
|
||||||
|
if (header_pos + 13 > data.len) return PngError.UnexpectedEndOfData;
|
||||||
|
|
||||||
|
const width = readU32BE(data, header_pos);
|
||||||
|
const height = readU32BE(data, header_pos + 4);
|
||||||
|
const bit_depth = data[header_pos + 8];
|
||||||
|
const color_type_raw = data[header_pos + 9];
|
||||||
|
const interlace = data[header_pos + 12];
|
||||||
|
|
||||||
|
// Validate parameters
|
||||||
|
if (bit_depth != 8 and bit_depth != 16) return PngError.UnsupportedBitDepth;
|
||||||
|
if (interlace != 0) return PngError.UnsupportedInterlace;
|
||||||
|
|
||||||
|
const color_type: ColorType = std.meta.intToEnum(ColorType, color_type_raw) catch {
|
||||||
|
return PngError.UnsupportedColorType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const channels: u8 = switch (color_type) {
|
||||||
|
.grayscale => 1,
|
||||||
|
.rgb => 3,
|
||||||
|
.grayscale_alpha => 2,
|
||||||
|
.rgba => 4,
|
||||||
|
.indexed => 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const has_alpha = (color_type == .rgba or color_type == .grayscale_alpha);
|
||||||
|
|
||||||
|
return PngMetadata{
|
||||||
|
.width = width,
|
||||||
|
.height = height,
|
||||||
|
.bit_depth = bit_depth,
|
||||||
|
.color_type = color_type,
|
||||||
|
.has_alpha = has_alpha,
|
||||||
|
.channels = channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse PNG image and prepare for PDF embedding.
|
||||||
|
/// NOTE: Full PNG parsing is not yet implemented.
|
||||||
|
/// Returns NotImplemented error - use JPEG images instead.
|
||||||
|
pub fn parse(allocator: std.mem.Allocator, data: []const u8) PngError!ImageInfo {
|
||||||
|
_ = allocator;
|
||||||
|
|
||||||
|
// Get metadata to validate the PNG
|
||||||
|
_ = try parseMetadata(data);
|
||||||
|
|
||||||
|
// Full PNG decompression not yet implemented
|
||||||
|
// PNG requires zlib decompression, unfiltering, and re-compression
|
||||||
|
// For now, recommend converting PNG to JPEG externally
|
||||||
|
return PngError.NotImplemented;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read big-endian u32
|
||||||
|
fn readU32BE(data: []const u8, pos: usize) u32 {
|
||||||
|
return (@as(u32, data[pos]) << 24) |
|
||||||
|
(@as(u32, data[pos + 1]) << 16) |
|
||||||
|
(@as(u32, data[pos + 2]) << 8) |
|
||||||
|
@as(u32, data[pos + 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read chunk header
|
||||||
|
fn readChunkHeader(data: []const u8, pos: usize) ?ChunkHeader {
|
||||||
|
if (pos + 8 > data.len) return null;
|
||||||
|
return ChunkHeader{
|
||||||
|
.length = readU32BE(data, pos),
|
||||||
|
.chunk_type = data[pos + 4 ..][0..4].*,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
test "read chunk header" {
|
||||||
|
const data = [_]u8{
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // Length: 13
|
||||||
|
'I', 'H', 'D', 'R', // Type: IHDR
|
||||||
|
};
|
||||||
|
const header = readChunkHeader(&data, 0);
|
||||||
|
try std.testing.expect(header != null);
|
||||||
|
try std.testing.expectEqual(@as(u32, 13), header.?.length);
|
||||||
|
try std.testing.expectEqualStrings("IHDR", &header.?.chunk_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "invalid PNG signature" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const invalid_data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xE0 }; // JPEG signature
|
||||||
|
const result = parse(allocator, &invalid_data);
|
||||||
|
try std.testing.expectError(PngError.InvalidSignature, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse PNG metadata" {
|
||||||
|
// Minimal valid PNG header
|
||||||
|
const png_data = [_]u8{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // IHDR length: 13
|
||||||
|
'I', 'H', 'D', 'R', // IHDR type
|
||||||
|
0x00, 0x00, 0x00, 0x64, // Width: 100
|
||||||
|
0x00, 0x00, 0x00, 0xC8, // Height: 200
|
||||||
|
0x08, // Bit depth: 8
|
||||||
|
0x02, // Color type: RGB
|
||||||
|
0x00, // Compression: deflate
|
||||||
|
0x00, // Filter: adaptive
|
||||||
|
0x00, // Interlace: none
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = try parseMetadata(&png_data);
|
||||||
|
try std.testing.expectEqual(@as(u32, 100), meta.width);
|
||||||
|
try std.testing.expectEqual(@as(u32, 200), meta.height);
|
||||||
|
try std.testing.expectEqual(@as(u8, 8), meta.bit_depth);
|
||||||
|
try std.testing.expectEqual(ColorType.rgb, meta.color_type);
|
||||||
|
try std.testing.expectEqual(false, meta.has_alpha);
|
||||||
|
try std.testing.expectEqual(@as(u8, 3), meta.channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse PNG with alpha" {
|
||||||
|
const png_data = [_]u8{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // IHDR length: 13
|
||||||
|
'I', 'H', 'D', 'R', // IHDR type
|
||||||
|
0x00, 0x00, 0x00, 0x20, // Width: 32
|
||||||
|
0x00, 0x00, 0x00, 0x20, // Height: 32
|
||||||
|
0x08, // Bit depth: 8
|
||||||
|
0x06, // Color type: RGBA
|
||||||
|
0x00, // Compression: deflate
|
||||||
|
0x00, // Filter: adaptive
|
||||||
|
0x00, // Interlace: none
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = try parseMetadata(&png_data);
|
||||||
|
try std.testing.expectEqual(@as(u32, 32), meta.width);
|
||||||
|
try std.testing.expectEqual(@as(u32, 32), meta.height);
|
||||||
|
try std.testing.expectEqual(ColorType.rgba, meta.color_type);
|
||||||
|
try std.testing.expectEqual(true, meta.has_alpha);
|
||||||
|
try std.testing.expectEqual(@as(u8, 4), meta.channels);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,13 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const base = @import("../objects/base.zig");
|
const base = @import("../objects/base.zig");
|
||||||
const Font = @import("../fonts/type1.zig").Font;
|
const Font = @import("../fonts/type1.zig").Font;
|
||||||
|
const ImageInfo = @import("../images/image_info.zig").ImageInfo;
|
||||||
|
|
||||||
|
/// Image reference for serialization
|
||||||
|
pub const ImageData = struct {
|
||||||
|
index: usize,
|
||||||
|
info: *const ImageInfo,
|
||||||
|
};
|
||||||
|
|
||||||
/// Page data ready for serialization
|
/// Page data ready for serialization
|
||||||
pub const PageData = struct {
|
pub const PageData = struct {
|
||||||
|
|
@ -16,6 +23,7 @@ pub const PageData = struct {
|
||||||
height: f32,
|
height: f32,
|
||||||
content: []const u8,
|
content: []const u8,
|
||||||
fonts_used: []const Font,
|
fonts_used: []const Font,
|
||||||
|
images_used: []const ImageData = &[_]ImageData{},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Generates a complete PDF document.
|
/// Generates a complete PDF document.
|
||||||
|
|
@ -43,6 +51,11 @@ pub const OutputProducer = struct {
|
||||||
|
|
||||||
/// Generates a complete PDF from the given pages.
|
/// Generates a complete PDF from the given pages.
|
||||||
pub fn generate(self: *Self, pages: []const PageData, metadata: DocumentMetadata) ![]u8 {
|
pub fn generate(self: *Self, pages: []const PageData, metadata: DocumentMetadata) ![]u8 {
|
||||||
|
return self.generateWithImages(pages, metadata, &[_]*const ImageInfo{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a complete PDF from the given pages with images.
|
||||||
|
pub fn generateWithImages(self: *Self, pages: []const PageData, metadata: DocumentMetadata, images: []const *const ImageInfo) ![]u8 {
|
||||||
self.buffer.clearRetainingCapacity();
|
self.buffer.clearRetainingCapacity();
|
||||||
self.obj_offsets.clearRetainingCapacity();
|
self.obj_offsets.clearRetainingCapacity();
|
||||||
self.current_obj_id = 0;
|
self.current_obj_id = 0;
|
||||||
|
|
@ -76,12 +89,14 @@ pub const OutputProducer = struct {
|
||||||
// 2 = Pages (root)
|
// 2 = Pages (root)
|
||||||
// 3 = Info (optional)
|
// 3 = Info (optional)
|
||||||
// 4..4+num_fonts-1 = Font objects
|
// 4..4+num_fonts-1 = Font objects
|
||||||
// 4+num_fonts..4+num_fonts+num_pages*2-1 = Page + Content objects
|
// 4+num_fonts..4+num_fonts+num_images-1 = Image XObjects
|
||||||
|
// 4+num_fonts+num_images.. = Page + Content objects
|
||||||
const catalog_id: u32 = 1;
|
const catalog_id: u32 = 1;
|
||||||
const pages_root_id: u32 = 2;
|
const pages_root_id: u32 = 2;
|
||||||
const info_id: u32 = 3;
|
const info_id: u32 = 3;
|
||||||
const first_font_id: u32 = 4;
|
const first_font_id: u32 = 4;
|
||||||
const first_page_id: u32 = first_font_id + @as(u32, @intCast(fonts.len));
|
const first_image_id: u32 = first_font_id + @as(u32, @intCast(fonts.len));
|
||||||
|
const first_page_id: u32 = first_image_id + @as(u32, @intCast(images.len));
|
||||||
|
|
||||||
// Object 1: Catalog
|
// Object 1: Catalog
|
||||||
try self.beginObject(catalog_id);
|
try self.beginObject(catalog_id);
|
||||||
|
|
@ -143,6 +158,42 @@ pub const OutputProducer = struct {
|
||||||
try self.endObject();
|
try self.endObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Image XObject objects
|
||||||
|
for (images, 0..) |img, i| {
|
||||||
|
const img_id = first_image_id + @as(u32, @intCast(i));
|
||||||
|
try self.beginObject(img_id);
|
||||||
|
try writer.writeAll("<< /Type /XObject\n");
|
||||||
|
try writer.writeAll("/Subtype /Image\n");
|
||||||
|
try writer.print("/Width {d}\n", .{img.width});
|
||||||
|
try writer.print("/Height {d}\n", .{img.height});
|
||||||
|
try writer.print("/ColorSpace /{s}\n", .{img.color_space.pdfName()});
|
||||||
|
try writer.print("/BitsPerComponent {d}\n", .{img.bits_per_component});
|
||||||
|
try writer.print("/Filter /{s}\n", .{img.filter.pdfName()});
|
||||||
|
|
||||||
|
// Decode parameters for FlateDecode
|
||||||
|
if (img.filter == .flate_decode) {
|
||||||
|
try writer.writeAll("/DecodeParms << ");
|
||||||
|
var buf: [128]u8 = undefined;
|
||||||
|
const params = img.decodeParams(&buf);
|
||||||
|
if (params.len > 0) {
|
||||||
|
try writer.writeAll(params);
|
||||||
|
}
|
||||||
|
try writer.writeAll(" >>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CMYK inversion
|
||||||
|
if (img.invert_cmyk) {
|
||||||
|
try writer.writeAll("/Decode [1 0 1 0 1 0 1 0]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
try writer.print("/Length {d}\n", .{img.data.len});
|
||||||
|
try writer.writeAll(">>\n");
|
||||||
|
try writer.writeAll("stream\n");
|
||||||
|
try writer.writeAll(img.data);
|
||||||
|
try writer.writeAll("\nendstream\n");
|
||||||
|
try self.endObject();
|
||||||
|
}
|
||||||
|
|
||||||
// Page and Content objects
|
// Page and Content objects
|
||||||
for (pages, 0..) |page, i| {
|
for (pages, 0..) |page, i| {
|
||||||
const page_obj_id = first_page_id + @as(u32, @intCast(i * 2));
|
const page_obj_id = first_page_id + @as(u32, @intCast(i * 2));
|
||||||
|
|
@ -163,6 +214,16 @@ pub const OutputProducer = struct {
|
||||||
try writer.print(" /{s} {d} 0 R\n", .{ font.pdfName(), font_id });
|
try writer.print(" /{s} {d} 0 R\n", .{ font.pdfName(), font_id });
|
||||||
}
|
}
|
||||||
try writer.writeAll(" >>\n");
|
try writer.writeAll(" >>\n");
|
||||||
|
|
||||||
|
// XObject resources for images
|
||||||
|
if (images.len > 0) {
|
||||||
|
try writer.writeAll(" /XObject <<\n");
|
||||||
|
for (0..images.len) |img_idx| {
|
||||||
|
const img_obj_id = first_image_id + @as(u32, @intCast(img_idx));
|
||||||
|
try writer.print(" /I{d} {d} 0 R\n", .{ img_idx, img_obj_id });
|
||||||
|
}
|
||||||
|
try writer.writeAll(" >>\n");
|
||||||
|
}
|
||||||
try writer.writeAll(">>\n");
|
try writer.writeAll(">>\n");
|
||||||
|
|
||||||
try writer.writeAll(">>\n");
|
try writer.writeAll(">>\n");
|
||||||
|
|
|
||||||
107
src/page.zig
107
src/page.zig
|
|
@ -11,6 +11,7 @@ const RenderStyle = @import("content_stream.zig").RenderStyle;
|
||||||
const Color = @import("graphics/color.zig").Color;
|
const Color = @import("graphics/color.zig").Color;
|
||||||
const Font = @import("fonts/type1.zig").Font;
|
const Font = @import("fonts/type1.zig").Font;
|
||||||
const PageSize = @import("objects/base.zig").PageSize;
|
const PageSize = @import("objects/base.zig").PageSize;
|
||||||
|
const ImageInfo = @import("images/image_info.zig").ImageInfo;
|
||||||
|
|
||||||
/// Text alignment options
|
/// Text alignment options
|
||||||
pub const Align = enum {
|
pub const Align = enum {
|
||||||
|
|
@ -37,6 +38,14 @@ pub const Border = packed struct {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Reference to an image used on a page
|
||||||
|
pub const ImageRef = struct {
|
||||||
|
/// Index in the document's image list
|
||||||
|
index: usize,
|
||||||
|
/// Image info pointer
|
||||||
|
info: *const ImageInfo,
|
||||||
|
};
|
||||||
|
|
||||||
/// A single page in a PDF document.
|
/// A single page in a PDF document.
|
||||||
pub const Page = struct {
|
pub const Page = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -54,6 +63,9 @@ pub const Page = struct {
|
||||||
/// Fonts used on this page (for resource dictionary)
|
/// Fonts used on this page (for resource dictionary)
|
||||||
fonts_used: std.AutoHashMap(Font, void),
|
fonts_used: std.AutoHashMap(Font, void),
|
||||||
|
|
||||||
|
/// Images used on this page (for resource dictionary)
|
||||||
|
images_used: std.ArrayListUnmanaged(ImageRef),
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
|
|
||||||
/// Graphics state for the page
|
/// Graphics state for the page
|
||||||
|
|
@ -101,6 +113,7 @@ pub const Page = struct {
|
||||||
.content = ContentStream.init(allocator),
|
.content = ContentStream.init(allocator),
|
||||||
.state = .{},
|
.state = .{},
|
||||||
.fonts_used = std.AutoHashMap(Font, void).init(allocator),
|
.fonts_used = std.AutoHashMap(Font, void).init(allocator),
|
||||||
|
.images_used = .{},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +121,7 @@ pub const Page = struct {
|
||||||
pub fn deinit(self: *Self) void {
|
pub fn deinit(self: *Self) void {
|
||||||
self.content.deinit();
|
self.content.deinit();
|
||||||
self.fonts_used.deinit();
|
self.fonts_used.deinit();
|
||||||
|
self.images_used.deinit(self.allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -531,6 +545,99 @@ pub const Page = struct {
|
||||||
try self.content.rect(x, y, w, h, style);
|
try self.content.rect(x, y, w, h, style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Image Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Draws an image at the specified position.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// - image_index: Index of the image in the document's image list
|
||||||
|
/// - info: Pointer to the ImageInfo structure
|
||||||
|
/// - x: X position (or null to use current X)
|
||||||
|
/// - y: Y position (or null to use current Y)
|
||||||
|
/// - w: Width (0 = auto from aspect ratio, null = original size)
|
||||||
|
/// - h: Height (0 = auto from aspect ratio, null = original size)
|
||||||
|
///
|
||||||
|
/// If both w and h are 0 or null, the image is rendered at 72 DPI.
|
||||||
|
/// If one dimension is 0, it's calculated to maintain aspect ratio.
|
||||||
|
pub fn image(
|
||||||
|
self: *Self,
|
||||||
|
image_index: usize,
|
||||||
|
info: *const ImageInfo,
|
||||||
|
x_opt: ?f32,
|
||||||
|
y_opt: ?f32,
|
||||||
|
w_opt: ?f32,
|
||||||
|
h_opt: ?f32,
|
||||||
|
) !void {
|
||||||
|
// Get position
|
||||||
|
const x = x_opt orelse self.state.x;
|
||||||
|
const y = y_opt orelse self.state.y;
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
const img_w: f32 = @floatFromInt(info.width);
|
||||||
|
const img_h: f32 = @floatFromInt(info.height);
|
||||||
|
|
||||||
|
var w = w_opt orelse img_w;
|
||||||
|
var h = h_opt orelse img_h;
|
||||||
|
|
||||||
|
// Handle auto-sizing
|
||||||
|
if (w == 0 and h == 0) {
|
||||||
|
// Default: 72 DPI (1 pixel = 1 point)
|
||||||
|
w = img_w;
|
||||||
|
h = img_h;
|
||||||
|
} else if (w == 0) {
|
||||||
|
// Calculate width from height maintaining aspect ratio
|
||||||
|
w = h * img_w / img_h;
|
||||||
|
} else if (h == 0) {
|
||||||
|
// Calculate height from width maintaining aspect ratio
|
||||||
|
h = w * img_h / img_w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register image usage
|
||||||
|
try self.images_used.append(self.allocator, .{
|
||||||
|
.index = image_index,
|
||||||
|
.info = info,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write image command to content stream
|
||||||
|
// Format: q w 0 0 h x y cm /Ii Do Q
|
||||||
|
// where i is the image index
|
||||||
|
try self.content.image(image_index, x, y, w, h);
|
||||||
|
|
||||||
|
// Update position (move to right of image)
|
||||||
|
self.state.x = x + w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws an image with automatic sizing to fit within a box while maintaining aspect ratio.
|
||||||
|
pub fn imageFit(
|
||||||
|
self: *Self,
|
||||||
|
image_index: usize,
|
||||||
|
info: *const ImageInfo,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
max_w: f32,
|
||||||
|
max_h: f32,
|
||||||
|
) !void {
|
||||||
|
const img_w: f32 = @floatFromInt(info.width);
|
||||||
|
const img_h: f32 = @floatFromInt(info.height);
|
||||||
|
|
||||||
|
// Calculate scale to fit within box
|
||||||
|
const scale_w = max_w / img_w;
|
||||||
|
const scale_h = max_h / img_h;
|
||||||
|
const scale = @min(scale_w, scale_h);
|
||||||
|
|
||||||
|
const w = img_w * scale;
|
||||||
|
const h = img_h * scale;
|
||||||
|
|
||||||
|
try self.image(image_index, info, x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the list of images used on this page.
|
||||||
|
pub fn getImagesUsed(self: *const Self) []const ImageRef {
|
||||||
|
return self.images_used.items;
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Content Access
|
// Content Access
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
65
src/pdf.zig
65
src/pdf.zig
|
|
@ -16,6 +16,8 @@ const Unit = @import("objects/base.zig").Unit;
|
||||||
const OutputProducer = @import("output/producer.zig").OutputProducer;
|
const OutputProducer = @import("output/producer.zig").OutputProducer;
|
||||||
const PageData = @import("output/producer.zig").PageData;
|
const PageData = @import("output/producer.zig").PageData;
|
||||||
const DocumentMetadata = @import("output/producer.zig").DocumentMetadata;
|
const DocumentMetadata = @import("output/producer.zig").DocumentMetadata;
|
||||||
|
const ImageInfo = @import("images/image_info.zig").ImageInfo;
|
||||||
|
const jpeg = @import("images/jpeg.zig");
|
||||||
|
|
||||||
/// A PDF document builder.
|
/// A PDF document builder.
|
||||||
///
|
///
|
||||||
|
|
@ -36,6 +38,9 @@ pub const Pdf = struct {
|
||||||
/// All pages in the document
|
/// All pages in the document
|
||||||
pages: std.ArrayListUnmanaged(Page),
|
pages: std.ArrayListUnmanaged(Page),
|
||||||
|
|
||||||
|
/// All images in the document
|
||||||
|
images: std.ArrayListUnmanaged(ImageInfo),
|
||||||
|
|
||||||
/// Document metadata
|
/// Document metadata
|
||||||
title: ?[]const u8 = null,
|
title: ?[]const u8 = null,
|
||||||
author: ?[]const u8 = null,
|
author: ?[]const u8 = null,
|
||||||
|
|
@ -76,6 +81,7 @@ pub const Pdf = struct {
|
||||||
return .{
|
return .{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.pages = .{},
|
.pages = .{},
|
||||||
|
.images = .{},
|
||||||
.default_page_size = options.page_size,
|
.default_page_size = options.page_size,
|
||||||
.default_orientation = options.orientation,
|
.default_orientation = options.orientation,
|
||||||
.unit = options.unit,
|
.unit = options.unit,
|
||||||
|
|
@ -88,6 +94,12 @@ pub const Pdf = struct {
|
||||||
page.deinit();
|
page.deinit();
|
||||||
}
|
}
|
||||||
self.pages.deinit(self.allocator);
|
self.pages.deinit(self.allocator);
|
||||||
|
|
||||||
|
// Free image data if owned
|
||||||
|
for (self.images.items) |*img| {
|
||||||
|
img.deinit(self.allocator);
|
||||||
|
}
|
||||||
|
self.images.deinit(self.allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -155,6 +167,47 @@ pub const Pdf = struct {
|
||||||
return null;
|
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, 10 * 1024 * 1024); // 10MB max
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Output
|
// Output
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -192,16 +245,24 @@ pub const Pdf = struct {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Generate PDF
|
||||||
var producer = OutputProducer.init(self.allocator);
|
var producer = OutputProducer.init(self.allocator);
|
||||||
defer producer.deinit();
|
defer producer.deinit();
|
||||||
|
|
||||||
return try producer.generate(page_data.items, .{
|
return try producer.generateWithImages(page_data.items, .{
|
||||||
.title = self.title,
|
.title = self.title,
|
||||||
.author = self.author,
|
.author = self.author,
|
||||||
.subject = self.subject,
|
.subject = self.subject,
|
||||||
.creator = self.creator,
|
.creator = self.creator,
|
||||||
});
|
}, image_ptrs.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the document to a file.
|
/// Saves the document to a file.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,11 @@ pub const Unit = objects.Unit;
|
||||||
pub const output = @import("output/mod.zig");
|
pub const output = @import("output/mod.zig");
|
||||||
pub const OutputProducer = output.OutputProducer;
|
pub const OutputProducer = output.OutputProducer;
|
||||||
|
|
||||||
|
/// Images (JPEG, PNG)
|
||||||
|
pub const images = @import("images/mod.zig");
|
||||||
|
pub const ImageInfo = images.ImageInfo;
|
||||||
|
pub const ImageFormat = images.ImageFormat;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Backwards Compatibility - Old API (Document)
|
// Backwards Compatibility - Old API (Document)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -223,4 +228,8 @@ comptime {
|
||||||
_ = @import("fonts/type1.zig");
|
_ = @import("fonts/type1.zig");
|
||||||
_ = @import("objects/base.zig");
|
_ = @import("objects/base.zig");
|
||||||
_ = @import("output/producer.zig");
|
_ = @import("output/producer.zig");
|
||||||
|
_ = @import("images/mod.zig");
|
||||||
|
_ = @import("images/image_info.zig");
|
||||||
|
_ = @import("images/jpeg.zig");
|
||||||
|
_ = @import("images/png.zig");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue