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>
470 lines
16 KiB
Zig
470 lines
16 KiB
Zig
//! Markdown - Styled text rendering with Markdown-like syntax
|
|
//!
|
|
//! Parses a subset of Markdown syntax and renders it to PDF.
|
|
//!
|
|
//! Supported syntax:
|
|
//! - **bold** or __bold__
|
|
//! - *italic* or _italic_
|
|
//! - ***bold italic*** or ___bold italic___
|
|
//! - ~~strikethrough~~
|
|
//! - [link text](url)
|
|
//! - # Heading 1
|
|
//! - ## Heading 2
|
|
//! - ### Heading 3
|
|
//! - - list item (bullet)
|
|
//! - 1. numbered list
|
|
|
|
const std = @import("std");
|
|
const Font = @import("../fonts/mod.zig").Font;
|
|
|
|
/// Style flags for text spans
|
|
pub const SpanStyle = packed struct {
|
|
bold: bool = false,
|
|
italic: bool = false,
|
|
underline: bool = false,
|
|
strikethrough: bool = false,
|
|
|
|
pub const none: SpanStyle = .{};
|
|
pub const bold_style: SpanStyle = .{ .bold = true };
|
|
pub const italic_style: SpanStyle = .{ .italic = true };
|
|
pub const bold_italic: SpanStyle = .{ .bold = true, .italic = true };
|
|
};
|
|
|
|
/// A span of styled text
|
|
pub const TextSpan = struct {
|
|
text: []const u8,
|
|
style: SpanStyle = .{},
|
|
url: ?[]const u8 = null, // For links
|
|
font_size: ?f32 = null, // Override font size (for headings)
|
|
color: ?u24 = null, // Override color (RGB hex)
|
|
};
|
|
|
|
/// Markdown text renderer
|
|
pub const MarkdownRenderer = struct {
|
|
allocator: std.mem.Allocator,
|
|
|
|
/// Parsed lines
|
|
lines: std.ArrayListUnmanaged(Line),
|
|
|
|
pub const Line = struct {
|
|
/// Spans for this line (allocated)
|
|
spans: []TextSpan,
|
|
line_type: LineType = .paragraph,
|
|
indent: u8 = 0,
|
|
};
|
|
|
|
pub const LineType = enum {
|
|
paragraph,
|
|
heading1,
|
|
heading2,
|
|
heading3,
|
|
bullet,
|
|
numbered,
|
|
blank,
|
|
};
|
|
|
|
const Self = @This();
|
|
|
|
pub fn init(allocator: std.mem.Allocator) Self {
|
|
return .{
|
|
.allocator = allocator,
|
|
.lines = .{},
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Self) void {
|
|
for (self.lines.items) |line| {
|
|
self.allocator.free(line.spans);
|
|
}
|
|
self.lines.deinit(self.allocator);
|
|
}
|
|
|
|
/// Parse markdown text into styled spans
|
|
pub fn parse(self: *Self, markdown_text: []const u8) !void {
|
|
// Split into lines
|
|
var line_iter = std.mem.splitScalar(u8, markdown_text, '\n');
|
|
|
|
while (line_iter.next()) |line| {
|
|
try self.parseLine(line);
|
|
}
|
|
}
|
|
|
|
fn parseLine(self: *Self, line: []const u8) !void {
|
|
const trimmed = std.mem.trim(u8, line, " \t\r");
|
|
|
|
// Empty line
|
|
if (trimmed.len == 0) {
|
|
const empty_spans = try self.allocator.alloc(TextSpan, 0);
|
|
try self.lines.append(self.allocator, .{
|
|
.spans = empty_spans,
|
|
.line_type = .blank,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check for headings
|
|
if (trimmed.len >= 4 and std.mem.startsWith(u8, trimmed, "### ")) {
|
|
const content = trimmed[4..];
|
|
const spans = try self.parseInlineStyles(content, .{ .bold = true }, 18);
|
|
|
|
try self.lines.append(self.allocator, .{
|
|
.spans = spans,
|
|
.line_type = .heading3,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (trimmed.len >= 3 and std.mem.startsWith(u8, trimmed, "## ")) {
|
|
const content = trimmed[3..];
|
|
const spans = try self.parseInlineStyles(content, .{ .bold = true }, 20);
|
|
|
|
try self.lines.append(self.allocator, .{
|
|
.spans = spans,
|
|
.line_type = .heading2,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (trimmed.len >= 2 and trimmed[0] == '#' and trimmed[1] == ' ') {
|
|
const content = trimmed[2..];
|
|
const spans = try self.parseInlineStyles(content, .{ .bold = true }, 24);
|
|
|
|
try self.lines.append(self.allocator, .{
|
|
.spans = spans,
|
|
.line_type = .heading1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check for bullet list
|
|
if (trimmed.len >= 2 and (trimmed[0] == '-' or trimmed[0] == '*') and trimmed[1] == ' ') {
|
|
const content = trimmed[2..];
|
|
const spans = try self.parseInlineStyles(content, .{}, null);
|
|
|
|
try self.lines.append(self.allocator, .{
|
|
.spans = spans,
|
|
.line_type = .bullet,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check for numbered list (1. 2. etc.)
|
|
if (trimmed.len >= 3) {
|
|
var digit_end: usize = 0;
|
|
while (digit_end < trimmed.len and std.ascii.isDigit(trimmed[digit_end])) {
|
|
digit_end += 1;
|
|
}
|
|
if (digit_end > 0 and digit_end + 1 < trimmed.len and trimmed[digit_end] == '.' and trimmed[digit_end + 1] == ' ') {
|
|
const content = trimmed[digit_end + 2 ..];
|
|
const spans = try self.parseInlineStyles(content, .{}, null);
|
|
|
|
try self.lines.append(self.allocator, .{
|
|
.spans = spans,
|
|
.line_type = .numbered,
|
|
.indent = @intCast(digit_end),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Regular paragraph
|
|
const spans = try self.parseInlineStyles(trimmed, .{}, null);
|
|
|
|
try self.lines.append(self.allocator, .{
|
|
.spans = spans,
|
|
.line_type = .paragraph,
|
|
});
|
|
}
|
|
|
|
fn parseInlineStyles(self: *Self, text: []const u8, base_style: SpanStyle, font_size_override: ?f32) ![]TextSpan {
|
|
var temp_spans = std.ArrayListUnmanaged(TextSpan){};
|
|
defer temp_spans.deinit(self.allocator);
|
|
|
|
var i: usize = 0;
|
|
var current_start: usize = 0;
|
|
var current_style = base_style;
|
|
|
|
while (i < text.len) {
|
|
// Check for bold+italic (*** or ___)
|
|
if (i + 2 < text.len and ((text[i] == '*' and text[i + 1] == '*' and text[i + 2] == '*') or
|
|
(text[i] == '_' and text[i + 1] == '_' and text[i + 2] == '_')))
|
|
{
|
|
// Flush current text
|
|
if (i > current_start) {
|
|
try temp_spans.append(self.allocator, .{
|
|
.text = text[current_start..i],
|
|
.style = current_style,
|
|
.font_size = font_size_override,
|
|
});
|
|
}
|
|
current_style.bold = !current_style.bold;
|
|
current_style.italic = !current_style.italic;
|
|
i += 3;
|
|
current_start = i;
|
|
continue;
|
|
}
|
|
|
|
// Check for bold (** or __)
|
|
if (i + 1 < text.len and ((text[i] == '*' and text[i + 1] == '*') or
|
|
(text[i] == '_' and text[i + 1] == '_')))
|
|
{
|
|
// Flush current text
|
|
if (i > current_start) {
|
|
try temp_spans.append(self.allocator, .{
|
|
.text = text[current_start..i],
|
|
.style = current_style,
|
|
.font_size = font_size_override,
|
|
});
|
|
}
|
|
current_style.bold = !current_style.bold;
|
|
i += 2;
|
|
current_start = i;
|
|
continue;
|
|
}
|
|
|
|
// Check for strikethrough (~~)
|
|
if (i + 1 < text.len and text[i] == '~' and text[i + 1] == '~') {
|
|
// Flush current text
|
|
if (i > current_start) {
|
|
try temp_spans.append(self.allocator, .{
|
|
.text = text[current_start..i],
|
|
.style = current_style,
|
|
.font_size = font_size_override,
|
|
});
|
|
}
|
|
current_style.strikethrough = !current_style.strikethrough;
|
|
i += 2;
|
|
current_start = i;
|
|
continue;
|
|
}
|
|
|
|
// Check for italic (* or _) - but not if part of ** or __
|
|
if ((text[i] == '*' or text[i] == '_')) {
|
|
const next_is_same = i + 1 < text.len and text[i + 1] == text[i];
|
|
if (!next_is_same) {
|
|
// Flush current text
|
|
if (i > current_start) {
|
|
try temp_spans.append(self.allocator, .{
|
|
.text = text[current_start..i],
|
|
.style = current_style,
|
|
.font_size = font_size_override,
|
|
});
|
|
}
|
|
current_style.italic = !current_style.italic;
|
|
i += 1;
|
|
current_start = i;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check for link [text](url)
|
|
if (text[i] == '[') {
|
|
// Find closing ]
|
|
var j = i + 1;
|
|
while (j < text.len and text[j] != ']') : (j += 1) {}
|
|
|
|
if (j + 1 < text.len and text[j] == ']' and text[j + 1] == '(') {
|
|
// Find closing )
|
|
var k = j + 2;
|
|
while (k < text.len and text[k] != ')') : (k += 1) {}
|
|
|
|
if (k < text.len and text[k] == ')') {
|
|
// Flush current text
|
|
if (i > current_start) {
|
|
try temp_spans.append(self.allocator, .{
|
|
.text = text[current_start..i],
|
|
.style = current_style,
|
|
.font_size = font_size_override,
|
|
});
|
|
}
|
|
|
|
// Add link span
|
|
const link_text = text[i + 1 .. j];
|
|
const url = text[j + 2 .. k];
|
|
try temp_spans.append(self.allocator, .{
|
|
.text = link_text,
|
|
.style = .{ .underline = true },
|
|
.url = url,
|
|
.font_size = font_size_override,
|
|
.color = 0x0000FF, // Blue for links
|
|
});
|
|
|
|
i = k + 1;
|
|
current_start = i;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
i += 1;
|
|
}
|
|
|
|
// Flush remaining text
|
|
if (i > current_start) {
|
|
try temp_spans.append(self.allocator, .{
|
|
.text = text[current_start..i],
|
|
.style = current_style,
|
|
.font_size = font_size_override,
|
|
});
|
|
}
|
|
|
|
// Allocate and copy to owned slice
|
|
const result = try self.allocator.alloc(TextSpan, temp_spans.items.len);
|
|
@memcpy(result, temp_spans.items);
|
|
return result;
|
|
}
|
|
|
|
/// Get font for a given style
|
|
pub fn fontForStyle(style: SpanStyle) Font {
|
|
if (style.bold and style.italic) {
|
|
return .helvetica_bold_oblique;
|
|
} else if (style.bold) {
|
|
return .helvetica_bold;
|
|
} else if (style.italic) {
|
|
return .helvetica_oblique;
|
|
}
|
|
return .helvetica;
|
|
}
|
|
|
|
/// Calculate the width of a line in points
|
|
pub fn lineWidth(line: Line, base_font_size: f32) f32 {
|
|
var width: f32 = 0;
|
|
for (line.spans) |span| {
|
|
const font = fontForStyle(span.style);
|
|
const font_size = span.font_size orelse base_font_size;
|
|
width += font.stringWidth(span.text, font_size);
|
|
}
|
|
return width;
|
|
}
|
|
|
|
/// Get the lines
|
|
pub fn getLines(self: *const Self) []const Line {
|
|
return self.lines.items;
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "MarkdownRenderer init and deinit" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var renderer = MarkdownRenderer.init(allocator);
|
|
defer renderer.deinit();
|
|
}
|
|
|
|
test "MarkdownRenderer parse plain text" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var renderer = MarkdownRenderer.init(allocator);
|
|
defer renderer.deinit();
|
|
|
|
try renderer.parse("Hello, world!");
|
|
|
|
const lines = renderer.getLines();
|
|
try std.testing.expectEqual(@as(usize, 1), lines.len);
|
|
try std.testing.expectEqual(MarkdownRenderer.LineType.paragraph, lines[0].line_type);
|
|
try std.testing.expectEqual(@as(usize, 1), lines[0].spans.len);
|
|
try std.testing.expectEqualStrings("Hello, world!", lines[0].spans[0].text);
|
|
}
|
|
|
|
test "MarkdownRenderer parse bold" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var renderer = MarkdownRenderer.init(allocator);
|
|
defer renderer.deinit();
|
|
|
|
try renderer.parse("This is **bold** text");
|
|
|
|
const lines = renderer.getLines();
|
|
try std.testing.expectEqual(@as(usize, 1), lines.len);
|
|
try std.testing.expectEqual(@as(usize, 3), lines[0].spans.len);
|
|
|
|
// "This is "
|
|
try std.testing.expectEqual(false, lines[0].spans[0].style.bold);
|
|
|
|
// "bold"
|
|
try std.testing.expectEqual(true, lines[0].spans[1].style.bold);
|
|
try std.testing.expectEqualStrings("bold", lines[0].spans[1].text);
|
|
|
|
// " text"
|
|
try std.testing.expectEqual(false, lines[0].spans[2].style.bold);
|
|
}
|
|
|
|
test "MarkdownRenderer parse italic" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var renderer = MarkdownRenderer.init(allocator);
|
|
defer renderer.deinit();
|
|
|
|
try renderer.parse("This is *italic* text");
|
|
|
|
const lines = renderer.getLines();
|
|
try std.testing.expectEqual(@as(usize, 1), lines.len);
|
|
try std.testing.expectEqual(@as(usize, 3), lines[0].spans.len);
|
|
|
|
try std.testing.expectEqual(true, lines[0].spans[1].style.italic);
|
|
try std.testing.expectEqualStrings("italic", lines[0].spans[1].text);
|
|
}
|
|
|
|
test "MarkdownRenderer parse headings" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var renderer = MarkdownRenderer.init(allocator);
|
|
defer renderer.deinit();
|
|
|
|
try renderer.parse("# Heading 1\n## Heading 2\n### Heading 3");
|
|
|
|
const lines = renderer.getLines();
|
|
try std.testing.expectEqual(@as(usize, 3), lines.len);
|
|
try std.testing.expectEqual(MarkdownRenderer.LineType.heading1, lines[0].line_type);
|
|
try std.testing.expectEqual(MarkdownRenderer.LineType.heading2, lines[1].line_type);
|
|
try std.testing.expectEqual(MarkdownRenderer.LineType.heading3, lines[2].line_type);
|
|
}
|
|
|
|
test "MarkdownRenderer parse bullet list" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var renderer = MarkdownRenderer.init(allocator);
|
|
defer renderer.deinit();
|
|
|
|
try renderer.parse("- Item 1\n- Item 2");
|
|
|
|
const lines = renderer.getLines();
|
|
try std.testing.expectEqual(@as(usize, 2), lines.len);
|
|
try std.testing.expectEqual(MarkdownRenderer.LineType.bullet, lines[0].line_type);
|
|
try std.testing.expectEqual(MarkdownRenderer.LineType.bullet, lines[1].line_type);
|
|
}
|
|
|
|
test "MarkdownRenderer parse link" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var renderer = MarkdownRenderer.init(allocator);
|
|
defer renderer.deinit();
|
|
|
|
try renderer.parse("Click [here](https://example.com) for more");
|
|
|
|
const lines = renderer.getLines();
|
|
try std.testing.expectEqual(@as(usize, 1), lines.len);
|
|
try std.testing.expectEqual(@as(usize, 3), lines[0].spans.len);
|
|
|
|
// Link span
|
|
try std.testing.expectEqualStrings("here", lines[0].spans[1].text);
|
|
try std.testing.expectEqualStrings("https://example.com", lines[0].spans[1].url.?);
|
|
try std.testing.expectEqual(true, lines[0].spans[1].style.underline);
|
|
}
|
|
|
|
test "MarkdownRenderer fontForStyle" {
|
|
const bold_font = MarkdownRenderer.fontForStyle(.{ .bold = true });
|
|
try std.testing.expectEqual(Font.helvetica_bold, bold_font);
|
|
|
|
const italic_font = MarkdownRenderer.fontForStyle(.{ .italic = true });
|
|
try std.testing.expectEqual(Font.helvetica_oblique, italic_font);
|
|
|
|
const bold_italic_font = MarkdownRenderer.fontForStyle(.{ .bold = true, .italic = true });
|
|
try std.testing.expectEqual(Font.helvetica_bold_oblique, bold_italic_font);
|
|
|
|
const normal_font = MarkdownRenderer.fontForStyle(.{});
|
|
try std.testing.expectEqual(Font.helvetica, normal_font);
|
|
}
|