zcatpdf/src/markdown/markdown.zig
reugenio 3826cbaed4 Release v1.0 - Feature Complete PDF Generation Library
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>
2025-12-09 02:01:17 +01:00

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);
}