//! PdfPage - A single page in a PDF document //! //! Pages contain content streams with drawing operations, //! and track resources (fonts, images) used on the page. //! //! Based on: fpdf2/fpdf/output.py (PDFPage class) const std = @import("std"); const ContentStream = @import("content_stream.zig").ContentStream; const RenderStyle = @import("content_stream.zig").RenderStyle; const Color = @import("graphics/color.zig").Color; const ExtGState = @import("graphics/extgstate.zig").ExtGState; const gradient_mod = @import("graphics/gradient.zig"); const LinearGradient = gradient_mod.LinearGradient; const RadialGradient = gradient_mod.RadialGradient; const GradientData = gradient_mod.GradientData; const Font = @import("fonts/type1.zig").Font; const PageSize = @import("objects/base.zig").PageSize; const ImageInfo = @import("images/image_info.zig").ImageInfo; const Link = @import("links.zig").Link; const Code128 = @import("barcodes/code128.zig").Code128; const QRCode = @import("barcodes/qr.zig").QRCode; /// Text alignment options pub const Align = enum { /// Left alignment (default) left, /// Center alignment center, /// Right alignment right, }; /// Border specification for cells pub const Border = packed struct { left: bool = false, top: bool = false, right: bool = false, bottom: bool = false, pub const none = Border{}; pub const all = Border{ .left = true, .top = true, .right = true, .bottom = true }; pub fn fromInt(val: u4) Border { return @bitCast(val); } }; /// 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, /// Page dimensions in points width: f32, height: f32, /// Content stream for this page content: ContentStream, /// Current graphics state state: GraphicsState, /// 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), /// Links on this page (for annotations) links: std.ArrayListUnmanaged(Link), /// Extended graphics states used (for transparency/opacity) extgstates: std.ArrayListUnmanaged(ExtGState), /// Gradients used on this page gradients: std.ArrayListUnmanaged(GradientData), const Self = @This(); /// Graphics state for the page pub const GraphicsState = struct { /// Current font font: Font = .helvetica, /// Current font size in points font_size: f32 = 12, /// Stroke color (for lines and outlines) stroke_color: Color = Color.black, /// Fill color (for fills and text) fill_color: Color = Color.black, /// Line width line_width: f32 = 1.0, /// Current X position x: f32 = 0, /// Current Y position y: f32 = 0, /// Left margin left_margin: f32 = 28.35, // 10mm default /// Right margin right_margin: f32 = 28.35, // 10mm default /// Top margin top_margin: f32 = 28.35, // 10mm default /// Cell margin (horizontal padding inside cells) cell_margin: f32 = 1.0, /// Fill opacity (0.0 = transparent, 1.0 = opaque) fill_opacity: f32 = 1.0, /// Stroke opacity (0.0 = transparent, 1.0 = opaque) stroke_opacity: f32 = 1.0, }; // ========================================================================= // Initialization // ========================================================================= /// Creates a new page with a standard size. pub fn init(allocator: std.mem.Allocator, size: PageSize) Self { const dims = size.dimensions(); return initCustom(allocator, dims.width, dims.height); } /// Creates a new page with custom dimensions (in points). pub fn initCustom(allocator: std.mem.Allocator, width: f32, height: f32) Self { return .{ .allocator = allocator, .width = width, .height = height, .content = ContentStream.init(allocator), .state = .{}, .fonts_used = std.AutoHashMap(Font, void).init(allocator), .images_used = .{}, .links = .{}, .extgstates = .{}, .gradients = .{}, }; } /// Frees all resources. pub fn deinit(self: *Self) void { self.content.deinit(); self.fonts_used.deinit(); self.images_used.deinit(self.allocator); self.links.deinit(self.allocator); self.extgstates.deinit(self.allocator); self.gradients.deinit(self.allocator); } // ========================================================================= // Font Operations // ========================================================================= /// Sets the current font and size. pub fn setFont(self: *Self, font: Font, size: f32) !void { self.state.font = font; self.state.font_size = size; try self.fonts_used.put(font, {}); } /// Gets the current font. pub fn getFont(self: *const Self) Font { return self.state.font; } /// Gets the current font size. pub fn getFontSize(self: *const Self) f32 { return self.state.font_size; } // ========================================================================= // Color Operations // ========================================================================= /// Sets the fill color (used for text and shape fills). pub fn setFillColor(self: *Self, color: Color) void { self.state.fill_color = color; } /// Sets the stroke color (used for lines and shape outlines). pub fn setStrokeColor(self: *Self, color: Color) void { self.state.stroke_color = color; } /// Sets the text color (alias for setFillColor). pub fn setTextColor(self: *Self, color: Color) void { self.setFillColor(color); } // ========================================================================= // Opacity / Transparency Operations // ========================================================================= /// Sets the fill opacity (alpha) for subsequent fill operations. /// 0.0 = fully transparent, 1.0 = fully opaque. pub fn setFillOpacity(self: *Self, opacity: f32) !void { const clamped = std.math.clamp(opacity, 0.0, 1.0); self.state.fill_opacity = clamped; try self.applyOpacity(); } /// Sets the stroke opacity (alpha) for subsequent stroke operations. /// 0.0 = fully transparent, 1.0 = fully opaque. pub fn setStrokeOpacity(self: *Self, opacity: f32) !void { const clamped = std.math.clamp(opacity, 0.0, 1.0); self.state.stroke_opacity = clamped; try self.applyOpacity(); } /// Sets both fill and stroke opacity at once. /// 0.0 = fully transparent, 1.0 = fully opaque. pub fn setOpacity(self: *Self, opacity: f32) !void { const clamped = std.math.clamp(opacity, 0.0, 1.0); self.state.fill_opacity = clamped; self.state.stroke_opacity = clamped; try self.applyOpacity(); } /// Internal: applies the current opacity state by registering/using an ExtGState. fn applyOpacity(self: *Self) !void { // Don't emit ExtGState if fully opaque (default) if (self.state.fill_opacity >= 1.0 and self.state.stroke_opacity >= 1.0) { return; } const state = ExtGState.init(self.state.fill_opacity, self.state.stroke_opacity); // Check if we already have this state registered var state_index: usize = self.extgstates.items.len; for (self.extgstates.items, 0..) |existing, i| { if (existing.eql(&state)) { state_index = i; break; } } // If not found, add it if (state_index == self.extgstates.items.len) { try self.extgstates.append(self.allocator, state); } // Write the gs operator to content stream try self.content.writeFmt("/GS{d} gs\n", .{state_index}); } /// Returns the ExtGStates used on this page. pub fn getExtGStates(self: *const Self) []const ExtGState { return self.extgstates.items; } // ========================================================================= // Gradient Operations // ========================================================================= /// Fills a rectangle with a linear gradient. pub fn linearGradientRect(self: *Self, x: f32, y: f32, w: f32, h: f32, start_color: Color, end_color: Color, direction: GradientDirection) !void { const grad = switch (direction) { .horizontal => LinearGradient.horizontal(x, y, w, h, start_color, end_color), .vertical => LinearGradient.vertical(x, y, w, h, start_color, end_color), .diagonal => LinearGradient.diagonal(x, y, w, h, start_color, end_color), }; const grad_idx = self.gradients.items.len; try self.gradients.append(self.allocator, GradientData.fromLinear(grad)); // Draw rectangle with gradient shading try self.content.saveState(); // Clip to rectangle try self.content.rectangle(x, y, w, h); try self.content.clip(); try self.content.endPath(); // Apply shading try self.content.writeFmt("/Sh{d} sh\n", .{grad_idx}); try self.content.restoreState(); } /// Fills a circle with a radial gradient. pub fn radialGradientCircle(self: *Self, cx: f32, cy: f32, radius: f32, center_color: Color, edge_color: Color) !void { const grad = RadialGradient.simple(cx, cy, radius, center_color, edge_color); const grad_idx = self.gradients.items.len; try self.gradients.append(self.allocator, GradientData.fromRadial(grad)); // Draw circle with gradient shading try self.content.saveState(); // Clip to circle (using ellipse path) try self.drawEllipsePath(cx, cy, radius, radius); try self.content.clip(); try self.content.endPath(); // Apply shading try self.content.writeFmt("/Sh{d} sh\n", .{grad_idx}); try self.content.restoreState(); } /// Fills an ellipse with a radial gradient. pub fn radialGradientEllipse(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32, center_color: Color, edge_color: Color) !void { // Use the larger radius for the gradient const max_r = @max(rx, ry); const grad = RadialGradient.simple(cx, cy, max_r, center_color, edge_color); const grad_idx = self.gradients.items.len; try self.gradients.append(self.allocator, GradientData.fromRadial(grad)); // Draw ellipse with gradient shading try self.content.saveState(); // Clip to ellipse try self.drawEllipsePath(cx, cy, rx, ry); try self.content.clip(); try self.content.endPath(); // Apply shading try self.content.writeFmt("/Sh{d} sh\n", .{grad_idx}); try self.content.restoreState(); } /// Draws an ellipse path without stroking/filling (for clipping). fn drawEllipsePath(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32) !void { const k: f32 = 0.5522847498; const kx = k * rx; const ky = k * ry; try self.content.moveTo(cx + rx, cy); try self.content.curveTo(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry); try self.content.curveTo(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy); try self.content.curveTo(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry); try self.content.curveTo(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy); try self.content.closePath(); } /// Returns the gradients used on this page. pub fn getGradients(self: *const Self) []const GradientData { return self.gradients.items; } /// Direction for linear gradients pub const GradientDirection = enum { horizontal, vertical, diagonal, }; // ========================================================================= // Line Style Operations // ========================================================================= /// Sets the line width. pub fn setLineWidth(self: *Self, width: f32) !void { self.state.line_width = width; try self.content.setLineWidth(width); } // ========================================================================= // Graphics State / Transformations // ========================================================================= /// Saves the current graphics state (colors, line width, transformations). /// Must be paired with a corresponding restoreState() call. pub fn saveState(self: *Self) !void { try self.content.saveState(); } /// Restores the previously saved graphics state. /// Must be paired with a preceding saveState() call. pub fn restoreState(self: *Self) !void { try self.content.restoreState(); } /// Applies a rotation transformation around a point. /// Angle is in degrees, positive is counterclockwise. /// Call saveState() before and restoreState() after to limit the transformation scope. pub fn rotate(self: *Self, angle_deg: f32, cx: f32, cy: f32) !void { const pi = std.math.pi; const angle_rad = angle_deg * pi / 180.0; const cos_a = @cos(angle_rad); const sin_a = @sin(angle_rad); // Translate to origin, rotate, translate back // Combined matrix: [cos -sin sin cos cx-cx*cos+cy*sin cy-cx*sin-cy*cos] const e = cx - cx * cos_a + cy * sin_a; const f = cy - cx * sin_a - cy * cos_a; try self.content.transform(cos_a, sin_a, -sin_a, cos_a, e, f); } /// Applies a simple rotation around the origin (0, 0). /// Angle is in degrees, positive is counterclockwise. pub fn rotateAroundOrigin(self: *Self, angle_deg: f32) !void { const pi = std.math.pi; const angle_rad = angle_deg * pi / 180.0; const cos_a = @cos(angle_rad); const sin_a = @sin(angle_rad); try self.content.transform(cos_a, sin_a, -sin_a, cos_a, 0, 0); } /// Applies a scale transformation relative to a point. /// sx, sy are scale factors (1.0 = no change, 2.0 = double size). pub fn scale(self: *Self, sx: f32, sy: f32, cx: f32, cy: f32) !void { // Translate to origin, scale, translate back const e = cx - cx * sx; const f = cy - cy * sy; try self.content.transform(sx, 0, 0, sy, e, f); } /// Applies a scale transformation from the origin. pub fn scaleFromOrigin(self: *Self, sx: f32, sy: f32) !void { try self.content.transform(sx, 0, 0, sy, 0, 0); } /// Applies a translation (shift) transformation. pub fn translate(self: *Self, tx: f32, ty: f32) !void { try self.content.transform(1, 0, 0, 1, tx, ty); } /// Applies a skew (shear) transformation. /// Angles are in degrees. /// - skew_x: Skew angle in the X direction (positive tilts right) /// - skew_y: Skew angle in the Y direction (positive tilts up) pub fn skew(self: *Self, skew_x_deg: f32, skew_y_deg: f32) !void { const pi = std.math.pi; const tan_x = @tan(skew_x_deg * pi / 180.0); const tan_y = @tan(skew_y_deg * pi / 180.0); try self.content.transform(1, tan_y, tan_x, 1, 0, 0); } /// Applies a custom transformation matrix. /// The matrix is [a b c d e f] representing: /// | a c e | /// | b d f | /// | 0 0 1 | pub fn transform(self: *Self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) !void { try self.content.transform(a, b, c, d, e, f); } // ========================================================================= // Position Operations // ========================================================================= /// Sets the current position. pub fn setXY(self: *Self, x: f32, y: f32) void { self.state.x = x; self.state.y = y; } /// Sets the current X position. pub fn setX(self: *Self, x: f32) void { self.state.x = x; } /// Sets the current Y position. pub fn setY(self: *Self, y: f32) void { self.state.y = y; } /// Gets the current X position. pub fn getX(self: *const Self) f32 { return self.state.x; } /// Gets the current Y position. pub fn getY(self: *const Self) f32 { return self.state.y; } // ========================================================================= // Text Operations // ========================================================================= /// Draws text at the specified position. /// Note: PDF coordinates are from bottom-left, Y increases upward. pub fn drawText(self: *Self, x: f32, y: f32, str: []const u8) !void { try self.state.fill_color.writeFillColor(&self.content); try self.content.text(x, y, self.state.font.pdfName(), self.state.font_size, str); try self.fonts_used.put(self.state.font, {}); } /// Draws text at the current position and updates the position. pub fn writeText(self: *Self, str: []const u8) !void { try self.drawText(self.state.x, self.state.y, str); // Update X position (approximate based on font metrics) self.state.x += self.state.font.stringWidth(str, self.state.font_size); } /// Returns the width of the given string in current font and size. pub fn getStringWidth(self: *const Self, str: []const u8) f32 { return self.state.font.stringWidth(str, self.state.font_size); } /// Returns the effective width available for content (page width minus margins). pub fn getEffectiveWidth(self: *const Self) f32 { return self.width - self.state.left_margin - self.state.right_margin; } /// Sets page margins. pub fn setMargins(self: *Self, left: f32, top: f32, right: f32) void { self.state.left_margin = left; self.state.top_margin = top; self.state.right_margin = right; } /// Sets the cell margin (horizontal padding inside cells). pub fn setCellMargin(self: *Self, margin: f32) void { self.state.cell_margin = margin; } /// Performs a line break. The current X position goes back to the left margin. /// Y position moves down by the given height (or current font size if null). pub fn ln(self: *Self, h: ?f32) void { self.state.x = self.state.left_margin; self.state.y -= h orelse self.state.font_size; } /// Prints a cell (rectangular area) with optional borders, background and text. /// This is the main method for outputting text in a structured way. /// /// Parameters: /// - w: Cell width. If 0, extends to right margin. If null, fits text width. /// - h: Cell height. If null, uses current font size. /// - str: Text to print. /// - border: Border specification. /// - align_h: Horizontal alignment (left, center, right). /// - fill: If true, fills the cell background with current fill color. /// - move_to: Where to move after the cell (right, next_line, below). pub fn cell( self: *Self, w: ?f32, h: ?f32, str: []const u8, border: Border, align_h: Align, fill: bool, ) !void { try self.cellAdvanced(w, h, str, border, align_h, fill, .right); } /// Cell position after rendering pub const CellPosition = enum { /// Move to the right of the cell right, /// Move to the beginning of the next line next_line, /// Stay below the cell (same X, next line Y) below, }; /// Advanced cell function with position control. pub fn cellAdvanced( self: *Self, w_opt: ?f32, h_opt: ?f32, str: []const u8, border: Border, align_h: Align, fill: bool, move_to: CellPosition, ) !void { const k = self.state.font_size; // Base unit const h = h_opt orelse k; // Calculate width var w: f32 = undefined; if (w_opt) |width| { if (width == 0) { // Extend to right margin w = self.width - self.state.right_margin - self.state.x; } else { w = width; } } else { // Fit to text width + cell margins w = self.getStringWidth(str) + 2 * self.state.cell_margin; } const x = self.state.x; const y = self.state.y; // Fill background if (fill) { try self.state.fill_color.writeFillColor(&self.content); try self.content.rect(x, y - h, w, h, .fill); } // Draw borders if (border.left or border.top or border.right or border.bottom) { try self.state.stroke_color.writeStrokeColor(&self.content); if (border.left) { try self.content.line(x, y, x, y - h); } if (border.top) { try self.content.line(x, y, x + w, y); } if (border.right) { try self.content.line(x + w, y, x + w, y - h); } if (border.bottom) { try self.content.line(x, y - h, x + w, y - h); } } // Draw text if (str.len > 0) { const text_width = self.getStringWidth(str); // Calculate X position based on alignment const text_x = switch (align_h) { .left => x + self.state.cell_margin, .center => x + (w - text_width) / 2, .right => x + w - self.state.cell_margin - text_width, }; // Y position: vertically centered in cell // PDF text baseline is at the given Y, so we need to adjust const text_y = y - h + (h - self.state.font_size) / 2 + self.state.font_size * 0.8; try self.state.fill_color.writeFillColor(&self.content); try self.content.text(text_x, text_y, self.state.font.pdfName(), self.state.font_size, str); try self.fonts_used.put(self.state.font, {}); } // Update position switch (move_to) { .right => { self.state.x = x + w; }, .next_line => { self.state.x = self.state.left_margin; self.state.y = y - h; }, .below => { self.state.y = y - h; }, } } /// Multi-cell: prints text with automatic line breaks. /// Text is wrapped at the cell width and multiple lines are stacked. /// /// Parameters: /// - w: Cell width. If 0, extends to right margin. /// - h: Height of each line. If null, uses current font size. /// - str: Text to print (can contain \n for explicit line breaks). /// - border: Border specification (applied to the whole block). /// - align_h: Horizontal alignment. /// - fill: If true, fills each line's background. pub fn multiCell( self: *Self, w_param: f32, h_opt: ?f32, str: []const u8, border: Border, align_h: Align, fill: bool, ) !void { const h = h_opt orelse self.state.font_size; const w = if (w_param == 0) self.width - self.state.right_margin - self.state.x else w_param; // Available width for text (minus cell margins) const text_width = w - 2 * self.state.cell_margin; const start_x = self.state.x; const start_y = self.state.y; var current_y = start_y; var is_first_line = true; var is_last_line = false; // Process text line by line (splitting on explicit newlines and word wrap) var remaining = str; while (remaining.len > 0) { // Find next explicit newline const newline_pos = std.mem.indexOf(u8, remaining, "\n"); // Get the current paragraph (up to newline or end) const paragraph = if (newline_pos) |pos| remaining[0..pos] else remaining; // Wrap this paragraph var para_remaining = paragraph; while (para_remaining.len > 0 or (newline_pos != null and para_remaining.len == 0)) { // Find how much text fits on this line const line = self.wrapLine(para_remaining, text_width); // Check if this is the last line const next_remaining = if (line.len < para_remaining.len) std.mem.trimLeft(u8, para_remaining[line.len..], " ") else ""; is_last_line = next_remaining.len == 0 and (newline_pos == null or newline_pos.? + 1 >= remaining.len); // Determine borders for this line var line_border = Border.none; if (border.left) line_border.left = true; if (border.right) line_border.right = true; if (border.top and is_first_line) line_border.top = true; if (border.bottom and is_last_line) line_border.bottom = true; // Print this line self.state.x = start_x; try self.cellAdvanced(w, h, line, line_border, align_h, fill, .next_line); current_y = self.state.y; is_first_line = false; para_remaining = next_remaining; // Handle empty paragraph (just a newline) if (para_remaining.len == 0 and line.len == 0) break; } // Move past the newline if (newline_pos) |pos| { remaining = if (pos + 1 < remaining.len) remaining[pos + 1 ..] else ""; } else { remaining = ""; } } } /// Wraps text to fit within the given width, breaking at word boundaries. /// Returns the portion of text that fits on one line. fn wrapLine(self: *const Self, text: []const u8, max_width: f32) []const u8 { if (text.len == 0) return text; // Check if entire text fits if (self.getStringWidth(text) <= max_width) { return text; } // Find the last space that allows text to fit var last_space: ?usize = null; var i: usize = 0; var current_width: f32 = 0; while (i < text.len) { // charWidth returns units of 1/1000 of font size, convert to points const char_width_units = self.state.font.charWidth(text[i]); const char_width = @as(f32, @floatFromInt(char_width_units)) * self.state.font_size / 1000.0; current_width += char_width; if (text[i] == ' ') { if (current_width <= max_width) { last_space = i; } } if (current_width > max_width) { // If we found a space, break there if (last_space) |space_pos| { return text[0..space_pos]; } // No space found, break at current position (word is too long) return if (i > 0) text[0..i] else text[0..1]; } i += 1; } return text; } // ========================================================================= // Graphics Operations // ========================================================================= /// Draws a line from (x1, y1) to (x2, y2). pub fn drawLine(self: *Self, x1: f32, y1: f32, x2: f32, y2: f32) !void { try self.state.stroke_color.writeStrokeColor(&self.content); try self.content.line(x1, y1, x2, y2); } /// Draws a rectangle outline. pub fn drawRect(self: *Self, x: f32, y: f32, w: f32, h: f32) !void { try self.state.stroke_color.writeStrokeColor(&self.content); try self.content.rect(x, y, w, h, .stroke); } /// Fills a rectangle. pub fn fillRect(self: *Self, x: f32, y: f32, w: f32, h: f32) !void { try self.state.fill_color.writeFillColor(&self.content); try self.content.rect(x, y, w, h, .fill); } /// Draws a filled rectangle with stroke. pub fn drawFilledRect(self: *Self, x: f32, y: f32, w: f32, h: f32) !void { try self.state.fill_color.writeFillColor(&self.content); try self.state.stroke_color.writeStrokeColor(&self.content); try self.content.rect(x, y, w, h, .fill_stroke); } /// Draws a rectangle with the specified style. pub fn rect(self: *Self, x: f32, y: f32, w: f32, h: f32, style: RenderStyle) !void { switch (style) { .stroke => { try self.state.stroke_color.writeStrokeColor(&self.content); }, .fill => { try self.state.fill_color.writeFillColor(&self.content); }, .fill_stroke => { try self.state.fill_color.writeFillColor(&self.content); try self.state.stroke_color.writeStrokeColor(&self.content); }, } try self.content.rect(x, y, w, h, style); } // ========================================================================= // Bezier Curve Operations // ========================================================================= /// Draws a cubic Bezier curve from (x0, y0) to (x3, y3) using control points. /// The curve starts at (x0, y0), bends toward (x1, y1) and (x2, y2), /// and ends at (x3, y3). pub fn drawBezier(self: *Self, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) !void { try self.state.stroke_color.writeStrokeColor(&self.content); try self.content.moveTo(x0, y0); try self.content.curveTo(x1, y1, x2, y2, x3, y3); try self.content.stroke(); } /// Draws a quadratic Bezier curve from (x0, y0) to (x2, y2) using one control point. /// Converts to cubic Bezier internally (PDF only supports cubic curves). pub fn drawQuadBezier(self: *Self, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32) !void { // Convert quadratic to cubic: control points are 2/3 of the way from endpoints to the quad control point const cx1 = x0 + 2.0 / 3.0 * (x1 - x0); const cy1 = y0 + 2.0 / 3.0 * (y1 - y0); const cx2 = x2 + 2.0 / 3.0 * (x1 - x2); const cy2 = y2 + 2.0 / 3.0 * (y1 - y2); try self.drawBezier(x0, y0, cx1, cy1, cx2, cy2, x2, y2); } /// Draws an ellipse at the specified center point. pub fn drawEllipse(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32) !void { try self.ellipse(cx, cy, rx, ry, .stroke); } /// Fills an ellipse at the specified center point. pub fn fillEllipse(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32) !void { try self.ellipse(cx, cy, rx, ry, .fill); } /// Draws an ellipse with the specified style. /// Uses cubic Bezier curves to approximate the ellipse (4 arcs). pub fn ellipse(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32, style: RenderStyle) !void { // Magic number for cubic Bezier approximation of circular arc // k = 4/3 * tan(pi/8) ≈ 0.5522847498 const k: f32 = 0.5522847498; const kx = k * rx; const ky = k * ry; switch (style) { .stroke => try self.state.stroke_color.writeStrokeColor(&self.content), .fill => try self.state.fill_color.writeFillColor(&self.content), .fill_stroke => { try self.state.fill_color.writeFillColor(&self.content); try self.state.stroke_color.writeStrokeColor(&self.content); }, } // Start at rightmost point try self.content.moveTo(cx + rx, cy); // Top-right quadrant (right to top) try self.content.curveTo(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry); // Top-left quadrant (top to left) try self.content.curveTo(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy); // Bottom-left quadrant (left to bottom) try self.content.curveTo(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry); // Bottom-right quadrant (bottom to right) try self.content.curveTo(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy); try self.content.closePath(); switch (style) { .stroke => try self.content.stroke(), .fill => try self.content.fill(), .fill_stroke => try self.content.fillAndStroke(), } } /// Draws a circle at the specified center point. pub fn drawCircle(self: *Self, cx: f32, cy: f32, r: f32) !void { try self.ellipse(cx, cy, r, r, .stroke); } /// Fills a circle at the specified center point. pub fn fillCircle(self: *Self, cx: f32, cy: f32, r: f32) !void { try self.ellipse(cx, cy, r, r, .fill); } /// Draws a circle with the specified style. pub fn circle(self: *Self, cx: f32, cy: f32, r: f32, style: RenderStyle) !void { try self.ellipse(cx, cy, r, r, style); } /// Draws an arc (portion of an ellipse). /// Angles are in degrees, counterclockwise from the positive X axis. pub fn drawArc(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32, start_deg: f32, end_deg: f32) !void { try self.state.stroke_color.writeStrokeColor(&self.content); try self.arcPath(cx, cy, rx, ry, start_deg, end_deg); try self.content.stroke(); } /// Builds an arc path using cubic Bezier curves. /// Internal function used by drawArc and other arc methods. fn arcPath(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32, start_deg: f32, end_deg: f32) !void { const pi = std.math.pi; const start_rad = start_deg * pi / 180.0; const end_rad = end_deg * pi / 180.0; // For large arcs, split into multiple segments (max 90 degrees each) var current = start_rad; var first = true; while (current < end_rad) { var segment_end = current + pi / 2.0; if (segment_end > end_rad) segment_end = end_rad; try self.arcSegment(cx, cy, rx, ry, current, segment_end, first); first = false; current = segment_end; } } /// Draws a single arc segment (up to 90 degrees) using cubic Bezier. fn arcSegment(self: *Self, cx: f32, cy: f32, rx: f32, ry: f32, start_rad: f32, end_rad: f32, move_to: bool) !void { const cos_start = @cos(start_rad); const sin_start = @sin(start_rad); const cos_end = @cos(end_rad); const sin_end = @sin(end_rad); // Start and end points const x0 = cx + rx * cos_start; const y0 = cy + ry * sin_start; const x3 = cx + rx * cos_end; const y3 = cy + ry * sin_end; // Control point distance factor for cubic Bezier approximation const angle = end_rad - start_rad; const alpha = @sin(angle) * (@sqrt(4.0 + 3.0 * @tan(angle / 2.0) * @tan(angle / 2.0)) - 1.0) / 3.0; // Control points const x1 = x0 - alpha * rx * sin_start; const y1 = y0 + alpha * ry * cos_start; const x2 = x3 + alpha * rx * sin_end; const y2 = y3 - alpha * ry * cos_end; if (move_to) { try self.content.moveTo(x0, y0); } try self.content.curveTo(x1, y1, x2, y2, x3, y3); } // ========================================================================= // 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 factor to fit within box const scale_w = max_w / img_w; const scale_h = max_h / img_h; const scale_factor = @min(scale_w, scale_h); const w = img_w * scale_factor; const h = img_h * scale_factor; 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 // ========================================================================= /// Returns the content stream as bytes. pub fn getContent(self: *const Self) []const u8 { return self.content.getContent(); } /// Returns the list of fonts used on this page. pub fn getFontsUsed(self: *const Self) []const Font { var fonts: std.ArrayListUnmanaged(Font) = .{}; var iter = self.fonts_used.keyIterator(); while (iter.next()) |font| { fonts.append(self.allocator, font.*) catch {}; } return fonts.toOwnedSlice(self.allocator) catch &[_]Font{}; } // ========================================================================= // Link Drawing (Visual Style) // ========================================================================= /// Draws text styled as a link (blue and underlined). /// Note: This is visual styling only. For clickable links in the PDF, /// link annotations need to be added separately. /// /// Returns the width of the drawn text for annotation placement. pub fn drawLink(self: *Self, x: f32, y: f32, text: []const u8) !f32 { // Save current colors const saved_fill = self.state.fill_color; const saved_stroke = self.state.stroke_color; // Set link color (blue) const link_color = Color.rgb(0, 102, 204); self.setFillColor(link_color); self.setStrokeColor(link_color); // Draw text try self.drawText(x, y, text); // Calculate text width const text_width = self.getStringWidth(text); // Draw underline const underline_y = y - 2; // Slightly below text baseline try self.setLineWidth(0.5); try self.drawLine(x, underline_y, x + text_width, underline_y); // Restore colors self.setFillColor(saved_fill); self.setStrokeColor(saved_stroke); return text_width; } /// Draws text styled as a link at the current position. /// Advances the X position after drawing. pub fn writeLink(self: *Self, text: []const u8) !f32 { const width = try self.drawLink(self.state.x, self.state.y, text); self.state.x += width; return width; } /// Adds a clickable URL link annotation to the page. /// The link will be clickable in PDF viewers. /// /// Parameters: /// - url: The target URL (e.g., "https://example.com") /// - x, y: Position of the link area (bottom-left corner) /// - width, height: Size of the clickable area pub fn addUrlLink(self: *Self, url: []const u8, x: f32, y: f32, width: f32, height: f32) !void { try self.links.append(self.allocator, .{ .link_type = .url, .target = .{ .url = url }, .rect = .{ .x = x, .y = y, .width = width, .height = height }, }); } /// Adds a clickable internal link (jump to page) annotation. /// /// Parameters: /// - page_num: Target page number (0-based) /// - x, y: Position of the link area /// - width, height: Size of the clickable area pub fn addInternalLink(self: *Self, page_num: usize, x: f32, y: f32, width: f32, height: f32) !void { try self.links.append(self.allocator, .{ .link_type = .internal, .target = .{ .internal = page_num }, .rect = .{ .x = x, .y = y, .width = width, .height = height }, }); } /// Draws text as a clickable URL link (visual + annotation). /// Combines drawLink visual styling with an actual clickable annotation. /// /// Returns the width of the link text. pub fn urlLink(self: *Self, x: f32, y: f32, text: []const u8, url: []const u8) !f32 { const width = try self.drawLink(x, y, text); const height = self.state.font_size; try self.addUrlLink(url, x, y - 2, width, height + 4); return width; } /// Draws text as a clickable URL link at the current position. /// Advances the X position after drawing. pub fn writeUrlLink(self: *Self, text: []const u8, url: []const u8) !f32 { const width = try self.urlLink(self.state.x, self.state.y, text, url); self.state.x += width; return width; } /// Returns the list of links on this page. pub fn getLinks(self: *const Self) []const Link { return self.links.items; } // ========================================================================= // Barcode Operations // ========================================================================= /// Draws a Code128 barcode at the specified position. /// /// Parameters: /// - x: X position of barcode (left edge) /// - y: Y position of barcode (bottom edge) /// - text: Text to encode /// - height: Height of the barcode bars /// - module_width: Width of each module (narrow bar unit) pub fn drawCode128(self: *Self, x: f32, y: f32, text: []const u8, height: f32, module_width: f32) !void { const bars = try Code128.encode(self.allocator, text); defer self.allocator.free(bars); // Set fill color for bars (black) try self.state.fill_color.writeFillColor(&self.content); // Draw each bar var current_x = x; for (bars) |bar| { if (bar == 1) { // Draw black bar try self.content.rect(current_x, y, module_width, height, .fill); } current_x += module_width; } } /// Draws a Code128 barcode with text label below. /// /// Parameters: /// - x: X position of barcode (left edge) /// - y: Y position of barcode (bottom of bars, text will be below) /// - text: Text to encode /// - height: Height of the barcode bars /// - module_width: Width of each module /// - show_text: Whether to show the text below the barcode pub fn drawCode128WithText(self: *Self, x: f32, y: f32, text: []const u8, height: f32, module_width: f32, show_text: bool) !void { // Draw barcode try self.drawCode128(x, y, text, height, module_width); // Optionally draw text below if (show_text) { const bars = try Code128.encode(self.allocator, text); defer self.allocator.free(bars); const barcode_width = @as(f32, @floatFromInt(bars.len)) * module_width; const text_width = self.getStringWidth(text); const text_x = x + (barcode_width - text_width) / 2; const text_y = y - self.state.font_size - 2; try self.state.fill_color.writeFillColor(&self.content); try self.content.text(text_x, text_y, self.state.font.pdfName(), self.state.font_size, text); try self.fonts_used.put(self.state.font, {}); } } /// Draws a QR Code at the specified position. /// /// Parameters: /// - x: X position of QR code (left edge) /// - y: Y position of QR code (bottom edge) /// - text: Text to encode /// - size: Size of the QR code (width and height) /// - ec: Error correction level (L, M, Q, H) pub fn drawQRCode(self: *Self, x: f32, y: f32, text: []const u8, size: f32, ec: QRCode.ErrorCorrection) !void { var qr = try QRCode.encode(self.allocator, text, ec); defer qr.deinit(); const module_size = size / @as(f32, @floatFromInt(qr.size)); // Set fill color for modules (black) try self.state.fill_color.writeFillColor(&self.content); // Draw each dark module for (0..qr.size) |row| { for (0..qr.size) |col| { if (qr.get(col, row)) { const mx = x + @as(f32, @floatFromInt(col)) * module_size; // QR code y is top-down, PDF is bottom-up const my = y + size - @as(f32, @floatFromInt(row + 1)) * module_size; try self.content.rect(mx, my, module_size, module_size, .fill); } } } } }; // ============================================================================= // Tests // ============================================================================= test "Page init" { const allocator = std.testing.allocator; var page = Page.init(allocator, .a4); defer page.deinit(); try std.testing.expectApproxEqAbs(@as(f32, 595.28), page.width, 0.01); try std.testing.expectApproxEqAbs(@as(f32, 841.89), page.height, 0.01); } test "Page setFont" { const allocator = std.testing.allocator; var page = Page.init(allocator, .a4); defer page.deinit(); try page.setFont(.helvetica_bold, 24); try std.testing.expectEqual(Font.helvetica_bold, page.getFont()); try std.testing.expectEqual(@as(f32, 24), page.getFontSize()); } test "Page drawText" { const allocator = std.testing.allocator; var page = Page.init(allocator, .a4); defer page.deinit(); try page.setFont(.helvetica, 12); try page.drawText(100, 700, "Hello World"); const content = page.getContent(); try std.testing.expect(content.len > 0); try std.testing.expect(std.mem.indexOf(u8, content, "BT") != null); try std.testing.expect(std.mem.indexOf(u8, content, "Hello World") != null); try std.testing.expect(std.mem.indexOf(u8, content, "ET") != null); } test "Page graphics" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setLineWidth(2.0); try pg.drawLine(0, 0, 100, 100); try pg.drawRect(50, 50, 100, 50); pg.setFillColor(Color.light_gray); try pg.fillRect(200, 200, 50, 50); const content = pg.getContent(); try std.testing.expect(content.len > 0); } test "Page cell" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); pg.setXY(50, 800); // Simple cell with border try pg.cell(100, 20, "Hello", Border.all, .left, false); // Cell should move position to the right try std.testing.expectApproxEqAbs(@as(f32, 150), pg.getX(), 0.01); const content = pg.getContent(); try std.testing.expect(std.mem.indexOf(u8, content, "Hello") != null); } test "Page cell with fill" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); pg.setXY(50, 800); pg.setFillColor(Color.light_gray); try pg.cell(100, 20, "Filled", Border.all, .center, true); const content = pg.getContent(); // Should have rectangle fill command try std.testing.expect(std.mem.indexOf(u8, content, "re") != null); try std.testing.expect(std.mem.indexOf(u8, content, "f") != null); } test "Page ln" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); pg.setXY(100, 800); pg.ln(12); try std.testing.expectApproxEqAbs(pg.state.left_margin, pg.getX(), 0.01); try std.testing.expectApproxEqAbs(@as(f32, 788), pg.getY(), 0.01); } test "Page getStringWidth" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); const width = pg.getStringWidth("Hello"); try std.testing.expect(width > 0); try std.testing.expect(width < 100); } test "Page wrapLine" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); // Short text that fits const short = pg.wrapLine("Hello", 100); try std.testing.expectEqualStrings("Hello", short); // Long text that needs wrapping const long_text = "This is a very long text that should be wrapped"; const wrapped = pg.wrapLine(long_text, 100); try std.testing.expect(wrapped.len < long_text.len); try std.testing.expect(wrapped.len > 0); } test "Page multiCell" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); pg.setXY(50, 800); try pg.multiCell(200, null, "This is a test of the multiCell function with word wrapping.", Border.all, .left, false); const content = pg.getContent(); try std.testing.expect(content.len > 0); // Y should have moved down try std.testing.expect(pg.getY() < 800); } test "Border" { try std.testing.expectEqual(false, Border.none.left); try std.testing.expectEqual(false, Border.none.top); try std.testing.expectEqual(true, Border.all.left); try std.testing.expectEqual(true, Border.all.top); try std.testing.expectEqual(true, Border.all.right); try std.testing.expectEqual(true, Border.all.bottom); } test "Border fromInt" { const border = Border.fromInt(0b1111); try std.testing.expectEqual(true, border.left); try std.testing.expectEqual(true, border.top); try std.testing.expectEqual(true, border.right); try std.testing.expectEqual(true, border.bottom); const partial = Border.fromInt(0b0101); try std.testing.expectEqual(true, partial.left); try std.testing.expectEqual(false, partial.top); try std.testing.expectEqual(true, partial.right); try std.testing.expectEqual(false, partial.bottom); } test "Page cell alignment" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); // Test left alignment pg.setXY(50, 800); try pg.cell(100, 20, "Left", Border.none, .left, false); // Test center alignment pg.setXY(50, 780); try pg.cell(100, 20, "Center", Border.none, .center, false); // Test right alignment pg.setXY(50, 760); try pg.cell(100, 20, "Right", Border.none, .right, false); const content = pg.getContent(); try std.testing.expect(std.mem.indexOf(u8, content, "Left") != null); try std.testing.expect(std.mem.indexOf(u8, content, "Center") != null); try std.testing.expect(std.mem.indexOf(u8, content, "Right") != null); } test "Page cell zero width extends to margin" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); pg.setMargins(50, 50, 50); pg.setXY(50, 800); try pg.cell(0, 20, "Full width", Border.all, .center, false); // X should now be at right margin const expected_x = pg.width - pg.state.right_margin; try std.testing.expectApproxEqAbs(expected_x, pg.getX(), 0.01); } test "Page cellAdvanced positions" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); pg.setMargins(50, 50, 50); pg.setXY(50, 800); // Test move_to: right (default) try pg.cellAdvanced(100, 20, "A", Border.none, .left, false, .right); try std.testing.expectApproxEqAbs(@as(f32, 150), pg.getX(), 0.01); try std.testing.expectApproxEqAbs(@as(f32, 800), pg.getY(), 0.01); // Test move_to: next_line pg.setXY(50, 800); try pg.cellAdvanced(100, 20, "B", Border.none, .left, false, .next_line); try std.testing.expectApproxEqAbs(@as(f32, 50), pg.getX(), 0.01); try std.testing.expectApproxEqAbs(@as(f32, 780), pg.getY(), 0.01); // Test move_to: below pg.setXY(100, 800); try pg.cellAdvanced(100, 20, "C", Border.none, .left, false, .below); try std.testing.expectApproxEqAbs(@as(f32, 100), pg.getX(), 0.01); try std.testing.expectApproxEqAbs(@as(f32, 780), pg.getY(), 0.01); } test "Page margins" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); pg.setMargins(30, 40, 50); try std.testing.expectApproxEqAbs(@as(f32, 30), pg.state.left_margin, 0.01); try std.testing.expectApproxEqAbs(@as(f32, 40), pg.state.top_margin, 0.01); try std.testing.expectApproxEqAbs(@as(f32, 50), pg.state.right_margin, 0.01); const effective = pg.getEffectiveWidth(); try std.testing.expectApproxEqAbs(pg.width - 30 - 50, effective, 0.01); } test "Page multiCell with explicit newlines" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); pg.setXY(50, 800); try pg.multiCell(200, 15, "Line 1\nLine 2\nLine 3", Border.none, .left, false); // Y should have moved down by 3 lines (approx 45 points) try std.testing.expect(pg.getY() < 760); } test "Page writeText updates position" { const allocator = std.testing.allocator; var pg = Page.init(allocator, .a4); defer pg.deinit(); try pg.setFont(.helvetica, 12); pg.setXY(50, 800); const start_x = pg.getX(); try pg.writeText("Hello"); const end_x = pg.getX(); // X should have increased by the width of "Hello" try std.testing.expect(end_x > start_x); }