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