"""Card generation module for PDF and PNG output.""" import math from io import BytesIO from typing import Optional from PIL import Image, ImageDraw, ImageFont from fpdf import FPDF from .templates import Template, TemplateLoader from .man_parser import CommandInfo, Option class PDFCardGenerator: """Generates PDF reference cards.""" PAGE_SIZES = { "a4": (210, 297), "letter": (215.9, 279.4) } def __init__(self, template: Optional[Template] = None): self.template = template or TemplateLoader().load("default") def generate(self, command_info: CommandInfo, output_path: str, page_size: str = "a4") -> None: """Generate a PDF card for the given command.""" width_mm, height_mm = self.PAGE_SIZES.get(page_size.lower(), self.PAGE_SIZES["a4"]) pdf = FPDF(unit="mm", format=(width_mm, height_mm)) pdf.set_auto_page_break(auto=True, margin=10) pdf.add_page() y = self._render_header(pdf, command_info.name, width_mm) y = self._render_synopsis(pdf, command_info.synopsis, y, width_mm) y = self._render_description(pdf, command_info.description, y, width_mm) y = self._render_options(pdf, command_info.options, y, width_mm) self._render_examples(pdf, command_info.examples, y, width_mm, height_mm) pdf.output(output_path) def _render_header(self, pdf: FPDF, name: str, width_mm: float) -> float: """Render the card header.""" layout = self.template.layout colors = self.template.colors pdf.set_fill_color(*self._hex_to_rgb(colors.get("header_bg", "#2E3440"))) pdf.rect(0, 0, width_mm, layout.get("header_height", 60), "F") pdf.set_text_color(*self._hex_to_rgb(colors.get("header_text", "#FFFFFF"))) font_config = self.template.fonts.get("header", ("Helvetica", 24, "B")) family = font_config[0] if len(font_config) >= 1 else "Helvetica" size = font_config[1] if len(font_config) >= 2 else 24 style = font_config[2] if len(font_config) >= 3 else "B" pdf.set_font(family, style=style, size=size) pdf.set_xy(0, 10) pdf.cell(width_mm, 30, f" {name}", align="L", ln=True) return layout.get("header_height", 60) + 10 def _render_synopsis(self, pdf: FPDF, synopsis: str, y: float, width_mm: float) -> float: """Render the synopsis section.""" if not synopsis: return y colors = self.template.colors spacing = self.template.spacing pdf.set_text_color(*self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) font_config = self.template.fonts.get("section", ("Helvetica", 14, "B")) family = font_config[0] if len(font_config) >= 1 else "Helvetica" size = font_config[1] if len(font_config) >= 2 else 14 style = font_config[2] if len(font_config) >= 3 else "B" pdf.set_font(family, style=style, size=size) pdf.set_xy(10, y) pdf.cell(width_mm - 20, 10, "SYNOPSIS", ln=True) y += spacing.get("section_padding", 15) pdf.set_text_color(*self._hex_to_rgb(colors.get("option_flag", "#A3BE8C"))) font_config = self.template.fonts.get("synopsis", ("Courier", 11, "")) family = font_config[0] if len(font_config) >= 1 else "Courier" size = font_config[1] if len(font_config) >= 2 else 11 style = font_config[2] if len(font_config) >= 3 else "" pdf.set_font(family, style=style, size=size) synopsis_wrapped = self._wrap_text(pdf, synopsis, width_mm - 40) for line in synopsis_wrapped: pdf.set_xy(15, y) pdf.cell(width_mm - 30, 8, line, ln=True) y += spacing.get("line_height", 14) return y + spacing.get("section_spacing", 20) def _render_description(self, pdf: FPDF, description: str, y: float, width_mm: float) -> float: """Render the description section.""" if not description: return y colors = self.template.colors spacing = self.template.spacing pdf.set_text_color(*self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) font_config = self.template.fonts.get("section", ("Helvetica", 14, "B")) family = font_config[0] if len(font_config) >= 1 else "Helvetica" size = font_config[1] if len(font_config) >= 2 else 14 style = font_config[2] if len(font_config) >= 3 else "B" pdf.set_font(family, style=style, size=size) pdf.set_xy(10, y) pdf.cell(width_mm - 20, 10, "DESCRIPTION", ln=True) y += spacing.get("section_padding", 15) pdf.set_text_color(*self._hex_to_rgb(colors.get("option_desc", "#ECEFF4"))) font_config = self.template.fonts.get("body", ("Helvetica", 10, "")) family = font_config[0] if len(font_config) >= 1 else "Helvetica" size = font_config[1] if len(font_config) >= 2 else 10 style = font_config[2] if len(font_config) >= 3 else "" pdf.set_font(family, style=style, size=size) desc_wrapped = self._wrap_text(pdf, description, width_mm - 40) for line in desc_wrapped: pdf.set_xy(15, y) pdf.cell(width_mm - 30, 6, line, ln=True) y += spacing.get("line_height", 14) - 4 return y + spacing.get("section_spacing", 20) def _render_options(self, pdf: FPDF, options: list[Option], y: float, width_mm: float) -> float: """Render the options section.""" if not options: return y colors = self.template.colors layout = self.template.layout spacing = self.template.spacing pdf.set_text_color(*self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) font_config = self.template.fonts.get("section", ("Helvetica", 14, "B")) family = font_config[0] if len(font_config) >= 1 else "Helvetica" size = font_config[1] if len(font_config) >= 2 else 14 style = font_config[2] if len(font_config) >= 3 else "B" pdf.set_font(family, style=style, size=size) pdf.set_xy(10, y) pdf.cell(width_mm - 20, 10, "OPTIONS", ln=True) y += spacing.get("section_padding", 15) columns = layout.get("columns", 2) col_width = (width_mm - 40) / columns current_col = 0 current_y = y for opt in options: if current_col >= columns: pdf.ln() current_y = pdf.get_y() current_col = 0 x = 15 + (current_col * col_width) pdf.set_xy(x, current_y) pdf.set_text_color(*self._hex_to_rgb(colors.get("option_flag", "#A3BE8C"))) font_config = self.template.fonts.get("option_flag", ("Courier", 10, "B")) family = font_config[0] if len(font_config) >= 1 else "Courier" size = font_config[1] if len(font_config) >= 2 else 10 style = font_config[2] if len(font_config) >= 3 else "B" pdf.set_font(family, style=style, size=size) pdf.cell(col_width - 5, 6, opt.flag, ln=False) pdf.set_xy(x + col_width - 5, current_y) pdf.set_text_color(*self._hex_to_rgb(colors.get("option_desc", "#ECEFF4"))) font_config = self.template.fonts.get("option_desc", ("Helvetica", 9, "")) family = font_config[0] if len(font_config) >= 1 else "Helvetica" size = font_config[1] if len(font_config) >= 2 else 9 style = font_config[2] if len(font_config) >= 3 else "" pdf.set_font(family, style=style, size=size) desc_wrapped = self._wrap_text(pdf, opt.description, col_width - 5) desc_y = pdf.get_y() + 6 for i, line in enumerate(desc_wrapped): pdf.set_xy(x + col_width - 5, desc_y + (i * (spacing.get("line_height", 14) - 4))) pdf.cell(col_width - 5, 6, line, ln=True) current_y = max(current_y + 20, desc_y + len(desc_wrapped) * (spacing.get("line_height", 14) - 4)) current_col += 1 return current_y + spacing.get("section_spacing", 20) def _render_examples(self, pdf: FPDF, examples: list[str], y: float, width_mm: float, height_mm: float) -> None: """Render the examples section.""" if not examples: return colors = self.template.colors spacing = self.template.spacing pdf.set_text_color(*self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) font_config = self.template.fonts.get("section", ("Helvetica", 14, "B")) family = font_config[0] if len(font_config) >= 1 else "Helvetica" size = font_config[1] if len(font_config) >= 2 else 14 style = font_config[2] if len(font_config) >= 3 else "B" pdf.set_font(family, style=style, size=size) pdf.set_xy(10, y) pdf.cell(width_mm - 20, 10, "EXAMPLES", ln=True) y += spacing.get("section_padding", 15) pdf.set_text_color(*self._hex_to_rgb(colors.get("option_flag", "#A3BE8C"))) font_config = self.template.fonts.get("synopsis", ("Courier", 11, "")) family = font_config[0] if len(font_config) >= 1 else "Courier" size = font_config[1] if len(font_config) >= 2 else 11 style = font_config[2] if len(font_config) >= 3 else "" pdf.set_font(family, style=style, size=size) for example in examples[:5]: if y > height_mm - 30: pdf.add_page() y = 20 example_wrapped = self._wrap_text(pdf, example, width_mm - 40) for line in example_wrapped: pdf.set_xy(15, y) pdf.cell(width_mm - 30, 6, line, ln=True) y += spacing.get("line_height", 14) - 4 y += 5 def _wrap_text(self, pdf: FPDF, text: str, max_width: float) -> list[str]: """Wrap text to fit within max_width.""" words = text.split(' ') lines = [] current_line = "" for word in words: test_line = current_line + (" " if current_line else "") + word if pdf.get_string_width(test_line) <= max_width: current_line = test_line else: if current_line: lines.append(current_line) current_line = word if current_line: lines.append(current_line) return lines if lines else [text[:50] + "..."] def _hex_to_rgb(self, hex_color: str) -> tuple: """Convert hex color to RGB tuple.""" hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) class PNGCardGenerator: """Generates PNG reference cards.""" DPI_SIZES = { 72: 1.0, 150: 2.083, 300: 4.167 } def __init__(self, template: Optional[Template] = None): self.template = template or TemplateLoader().load("default") def generate(self, command_info: CommandInfo, output_path: str, dpi: int = 150) -> None: """Generate a PNG card for the given command.""" scale = self.DPI_SIZES.get(dpi, 2.083) layout = self.template.layout card_width = int(595 * scale) card_height = int(842 * scale) image = Image.new("RGB", (card_width, card_height), self._hex_to_rgb(self.template.colors.get("background", "#FFFFFF"))) draw = ImageDraw.Draw(image) margin = int(layout.get("margin", 30) * scale) header_height = int(layout.get("header_height", 60) * scale) self._render_header(draw, command_info.name, card_width, header_height, scale) y = self._render_synopsis(draw, command_info.synopsis, margin, header_height + 20, card_width, scale) y = self._render_description(draw, command_info.description, margin, y, card_width, scale) y = self._render_options(draw, command_info.options, margin, y, card_width, scale) self._render_examples(draw, command_info.examples, margin, y, card_width, card_height, scale) image.save(output_path, "PNG", dpi=(dpi, dpi)) def _render_header(self, draw: ImageDraw.ImageDraw, name: str, width: int, height: int, scale: float) -> None: """Render the card header.""" colors = self.template.colors header_bg = self._hex_to_rgb(colors.get("header_bg", "#2E3440")) header_text = self._hex_to_rgb(colors.get("header_text", "#FFFFFF")) draw.rectangle([(0, 0), (width, height)], fill=header_bg) try: font_size = int(24 * scale) font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size) except IOError: font = ImageFont.load_default() draw.text((int(20 * scale), int(15 * scale)), f" {name}", fill=header_text, font=font) def _render_synopsis(self, draw: ImageDraw.ImageDraw, synopsis: str, x: int, y: int, width: int, scale: float) -> int: """Render the synopsis section.""" if not synopsis: return y colors = self.template.colors spacing = self.template.spacing draw.text((x, y), "SYNOPSIS", fill=self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) y += int(spacing.get("section_padding", 15) * scale) try: font_size = int(11 * scale) font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", font_size) except (IOError, OSError): font = ImageFont.load_default() wrapped = self._wrap_text(synopsis, width - int(40 * scale), font, draw) for line in wrapped: draw.text((int(x + 5 * scale), y), line, fill=self._hex_to_rgb(colors.get("option_flag", "#A3BE8C")), font=font) y += int(spacing.get("line_height", 14) * scale) return y + int(spacing.get("section_spacing", 20) * scale) def _render_description(self, draw: ImageDraw.ImageDraw, description: str, x: int, y: int, width: int, scale: float) -> int: """Render the description section.""" if not description: return y colors = self.template.colors spacing = self.template.spacing draw.text((x, y), "DESCRIPTION", fill=self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) y += int(spacing.get("section_padding", 15) * scale) try: font_size = int(10 * scale) font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size) except (IOError, OSError): font = ImageFont.load_default() wrapped = self._wrap_text(description, width - int(40 * scale), font, draw) for line in wrapped: draw.text((int(x + 5 * scale), y), line, fill=self._hex_to_rgb(colors.get("option_desc", "#ECEFF4")), font=font) y += int((spacing.get("line_height", 14) - 4) * scale) return y + int(spacing.get("section_spacing", 20) * scale) def _render_options(self, draw: ImageDraw.ImageDraw, options: list[Option], x: int, y: int, width: int, scale: float) -> int: """Render the options section.""" if not options: return y colors = self.template.colors layout = self.template.layout spacing = self.template.spacing draw.text((x, y), "OPTIONS", fill=self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) y += int(spacing.get("section_padding", 15) * scale) columns = layout.get("columns", 2) col_width = (width - int(40 * scale)) // columns try: flag_font_size = int(10 * scale) flag_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", flag_font_size) desc_font_size = int(9 * scale) desc_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", desc_font_size) except (IOError, OSError): flag_font = ImageFont.load_default() desc_font = ImageFont.load_default() current_col = 0 start_y = y for opt in options: col_x = x + (current_col * col_width) draw.text((col_x, y), opt.flag, fill=self._hex_to_rgb(colors.get("option_flag", "#A3BE8C")), font=flag_font) wrapped = self._wrap_text(opt.description, col_width - int(10 * scale), desc_font, draw) desc_y = y + int(15 * scale) for i, line in enumerate(wrapped): draw.text((col_x, desc_y + (i * int(12 * scale))), line, fill=self._hex_to_rgb(colors.get("option_desc", "#ECEFF4")), font=desc_font) line_height = int(20 * scale) y = max(y + line_height, desc_y + len(wrapped) * int(12 * scale)) current_col += 1 if current_col >= columns: current_col = 0 return y + int(spacing.get("section_spacing", 20) * scale) def _render_examples(self, draw: ImageDraw.ImageDraw, examples: list[str], x: int, y: int, width: int, height: int, scale: float) -> None: """Render the examples section.""" if not examples: return colors = self.template.colors spacing = self.template.spacing draw.text((x, y), "EXAMPLES", fill=self._hex_to_rgb(colors.get("section_title", "#88C0D0"))) y += int(spacing.get("section_padding", 15) * scale) try: font_size = int(11 * scale) font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", font_size) except (IOError, OSError): font = ImageFont.load_default() for example in examples[:5]: if y > height - int(50 * scale): break wrapped = self._wrap_text(example, width - int(40 * scale), font, draw) for line in wrapped: draw.text((int(x + 5 * scale), y), line, fill=self._hex_to_rgb(colors.get("option_flag", "#A3BE8C")), font=font) y += int((spacing.get("line_height", 14) - 4) * scale) y += int(5 * scale) def _wrap_text(self, text: str, max_width: int, font: ImageFont.FreeTypeFont, draw: ImageDraw.ImageDraw) -> list[str]: """Wrap text to fit within max_width.""" words = text.split(' ') lines = [] current_line = "" for word in words: test_line = current_line + (" " if current_line else "") + word if draw.textlength(test_line, font=font) <= max_width: current_line = test_line else: if current_line: lines.append(current_line) current_line = word if current_line: lines.append(current_line) return lines if lines else [text[:50] + "..."] def _hex_to_rgb(self, hex_color: str) -> tuple: """Convert hex color to RGB tuple.""" hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))