zcatpdf/src/page.zig
reugenio f9189253d7 feat: v0.3 - Image support (JPEG embedding)
Phase 3 - Images:
- JPEG parser with direct DCT passthrough (no re-encoding)
- PNG metadata extraction (full embedding pending)
- Page.image() for drawing images at position
- Page.imageFit() for auto-scaling with aspect ratio
- Pdf.addJpegImage() / addJpegImageFromFile()
- XObject generation in OutputProducer

New modules:
- src/images/mod.zig - Image module exports
- src/images/image_info.zig - ImageInfo struct
- src/images/jpeg.zig - JPEG parser
- src/images/png.zig - PNG metadata parser

New example:
- examples/image_demo.zig - Image embedding demo

Stats:
- 66 unit tests passing
- 4 working examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 20:00:56 +01:00

958 lines
31 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 Font = @import("fonts/type1.zig").Font;
const PageSize = @import("objects/base.zig").PageSize;
const ImageInfo = @import("images/image_info.zig").ImageInfo;
/// Text alignment options
pub const Align = enum {
/// 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),
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,
};
// =========================================================================
// 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 = .{},
};
}
/// Frees all resources.
pub fn deinit(self: *Self) void {
self.content.deinit();
self.fonts_used.deinit();
self.images_used.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);
}
// =========================================================================
// 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);
}
// =========================================================================
// 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);
}
// =========================================================================
// Image Operations
// =========================================================================
/// Draws an image at the specified position.
///
/// Parameters:
/// - image_index: Index of the image in the document's image list
/// - info: Pointer to the ImageInfo structure
/// - x: X position (or null to use current X)
/// - y: Y position (or null to use current Y)
/// - w: Width (0 = auto from aspect ratio, null = original size)
/// - h: Height (0 = auto from aspect ratio, null = original size)
///
/// If both w and h are 0 or null, the image is rendered at 72 DPI.
/// If one dimension is 0, it's calculated to maintain aspect ratio.
pub fn image(
self: *Self,
image_index: usize,
info: *const ImageInfo,
x_opt: ?f32,
y_opt: ?f32,
w_opt: ?f32,
h_opt: ?f32,
) !void {
// Get position
const x = x_opt orelse self.state.x;
const y = y_opt orelse self.state.y;
// Calculate dimensions
const img_w: f32 = @floatFromInt(info.width);
const img_h: f32 = @floatFromInt(info.height);
var w = w_opt orelse img_w;
var h = h_opt orelse img_h;
// Handle auto-sizing
if (w == 0 and h == 0) {
// Default: 72 DPI (1 pixel = 1 point)
w = img_w;
h = img_h;
} else if (w == 0) {
// Calculate width from height maintaining aspect ratio
w = h * img_w / img_h;
} else if (h == 0) {
// Calculate height from width maintaining aspect ratio
h = w * img_h / img_w;
}
// Register image usage
try self.images_used.append(self.allocator, .{
.index = image_index,
.info = info,
});
// Write image command to content stream
// Format: q w 0 0 h x y cm /Ii Do Q
// where i is the image index
try self.content.image(image_index, x, y, w, h);
// Update position (move to right of image)
self.state.x = x + w;
}
/// Draws an image with automatic sizing to fit within a box while maintaining aspect ratio.
pub fn imageFit(
self: *Self,
image_index: usize,
info: *const ImageInfo,
x: f32,
y: f32,
max_w: f32,
max_h: f32,
) !void {
const img_w: f32 = @floatFromInt(info.width);
const img_h: f32 = @floatFromInt(info.height);
// Calculate scale to fit within box
const scale_w = max_w / img_w;
const scale_h = max_h / img_h;
const scale = @min(scale_w, scale_h);
const w = img_w * scale;
const h = img_h * scale;
try self.image(image_index, info, x, y, w, h);
}
/// Returns the list of images used on this page.
pub fn getImagesUsed(self: *const Self) []const ImageRef {
return self.images_used.items;
}
// =========================================================================
// Content Access
// =========================================================================
/// 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{};
}
};
// =============================================================================
// 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);
}