Major features added since v0.5: - PNG support with alpha/transparency (soft masks) - FlateDecode compression via libdeflate-zig - Bookmarks/Outline for document navigation - Bezier curves, circles, ellipses, arcs - Transformations (rotate, scale, translate, skew) - Transparency/opacity (fill and stroke alpha) - Linear and radial gradients (Shading Patterns) - Code128 (1D) and QR Code (2D) barcodes - TrueType font parsing (metrics, glyph widths) - RC4 encryption module (40/128-bit) - AcroForms module (TextField, CheckBox) - SVG import (basic shapes and paths) - Template system (reusable layouts) - Markdown styling (bold, italic, links, headings, lists) Documentation: - README.md: Complete API reference with code examples - FUTURE_IMPROVEMENTS.md: Detailed roadmap for future development - CLAUDE.md: Updated to v1.0 release status Stats: - 125+ unit tests passing - 16 demo examples - 46 source files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1556 lines
55 KiB
Zig
1556 lines
55 KiB
Zig
//! 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);
|
|
}
|