diff --git a/build.zig b/build.zig index 74b793b..ba9e0d4 100644 --- a/build.zig +++ b/build.zig @@ -80,4 +80,23 @@ pub fn build(b: *std.Build) void { run_text_demo.step.dependOn(b.getInstallStep()); const text_demo_step = b.step("text_demo", "Run text demo example"); 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); } diff --git a/examples/image_demo.zig b/examples/image_demo.zig new file mode 100644 index 0000000..7bb7e53 --- /dev/null +++ b/examples/image_demo.zig @@ -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", .{}); +} diff --git a/src/content_stream.zig b/src/content_stream.zig index 8f413ec..df478fc 100644 --- a/src/content_stream.zig +++ b/src/content_stream.zig @@ -408,6 +408,18 @@ pub const ContentStream = struct { try self.showText(str); 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(); + } }; // ============================================================================= diff --git a/src/images/image_info.zig b/src/images/image_info.zig new file mode 100644 index 0000000..7ef9344 --- /dev/null +++ b/src/images/image_info.zig @@ -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); +} diff --git a/src/images/jpeg.zig b/src/images/jpeg.zig new file mode 100644 index 0000000..52173f6 --- /dev/null +++ b/src/images/jpeg.zig @@ -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); +} diff --git a/src/images/mod.zig b/src/images/mod.zig new file mode 100644 index 0000000..dcfd229 --- /dev/null +++ b/src/images/mod.zig @@ -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); +} diff --git a/src/images/png.zig b/src/images/png.zig new file mode 100644 index 0000000..3a69e79 --- /dev/null +++ b/src/images/png.zig @@ -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); +} diff --git a/src/output/producer.zig b/src/output/producer.zig index 3f32bf5..c0724b9 100644 --- a/src/output/producer.zig +++ b/src/output/producer.zig @@ -9,6 +9,13 @@ const std = @import("std"); const base = @import("../objects/base.zig"); 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 pub const PageData = struct { @@ -16,6 +23,7 @@ pub const PageData = struct { height: f32, content: []const u8, fonts_used: []const Font, + images_used: []const ImageData = &[_]ImageData{}, }; /// Generates a complete PDF document. @@ -43,6 +51,11 @@ pub const OutputProducer = struct { /// Generates a complete PDF from the given pages. 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.obj_offsets.clearRetainingCapacity(); self.current_obj_id = 0; @@ -76,12 +89,14 @@ pub const OutputProducer = struct { // 2 = Pages (root) // 3 = Info (optional) // 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 pages_root_id: u32 = 2; const info_id: u32 = 3; 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 try self.beginObject(catalog_id); @@ -143,6 +158,42 @@ pub const OutputProducer = struct { 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 for (pages, 0..) |page, i| { 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.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"); diff --git a/src/page.zig b/src/page.zig index 4fe3a3d..7ba9c54 100644 --- a/src/page.zig +++ b/src/page.zig @@ -11,6 +11,7 @@ const RenderStyle = @import("content_stream.zig").RenderStyle; const Color = @import("graphics/color.zig").Color; const Font = @import("fonts/type1.zig").Font; const PageSize = @import("objects/base.zig").PageSize; +const ImageInfo = @import("images/image_info.zig").ImageInfo; /// Text alignment options 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. pub const Page = struct { allocator: std.mem.Allocator, @@ -54,6 +63,9 @@ pub const Page = struct { /// Fonts used on this page (for resource dictionary) fonts_used: std.AutoHashMap(Font, void), + /// Images used on this page (for resource dictionary) + images_used: std.ArrayListUnmanaged(ImageRef), + const Self = @This(); /// Graphics state for the page @@ -101,6 +113,7 @@ pub const Page = struct { .content = ContentStream.init(allocator), .state = .{}, .fonts_used = std.AutoHashMap(Font, void).init(allocator), + .images_used = .{}, }; } @@ -108,6 +121,7 @@ pub const Page = struct { pub fn deinit(self: *Self) void { self.content.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); } + // ========================================================================= + // 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 // ========================================================================= diff --git a/src/pdf.zig b/src/pdf.zig index 89f1c67..4c8c784 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -16,6 +16,8 @@ const Unit = @import("objects/base.zig").Unit; const OutputProducer = @import("output/producer.zig").OutputProducer; const PageData = @import("output/producer.zig").PageData; 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. /// @@ -36,6 +38,9 @@ pub const Pdf = struct { /// All pages in the document pages: std.ArrayListUnmanaged(Page), + /// All images in the document + images: std.ArrayListUnmanaged(ImageInfo), + /// Document metadata title: ?[]const u8 = null, author: ?[]const u8 = null, @@ -76,6 +81,7 @@ pub const Pdf = struct { return .{ .allocator = allocator, .pages = .{}, + .images = .{}, .default_page_size = options.page_size, .default_orientation = options.orientation, .unit = options.unit, @@ -88,6 +94,12 @@ pub const Pdf = struct { 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); } // ========================================================================= @@ -155,6 +167,47 @@ pub const Pdf = struct { 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 // ========================================================================= @@ -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 var producer = OutputProducer.init(self.allocator); defer producer.deinit(); - return try producer.generate(page_data.items, .{ + return try producer.generateWithImages(page_data.items, .{ .title = self.title, .author = self.author, .subject = self.subject, .creator = self.creator, - }); + }, image_ptrs.items); } /// Saves the document to a file. diff --git a/src/root.zig b/src/root.zig index be1cfe0..d23be8b 100644 --- a/src/root.zig +++ b/src/root.zig @@ -65,6 +65,11 @@ pub const Unit = objects.Unit; pub const output = @import("output/mod.zig"); 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) // ============================================================================= @@ -223,4 +228,8 @@ comptime { _ = @import("fonts/type1.zig"); _ = @import("objects/base.zig"); _ = @import("output/producer.zig"); + _ = @import("images/mod.zig"); + _ = @import("images/image_info.zig"); + _ = @import("images/jpeg.zig"); + _ = @import("images/png.zig"); }