diff --git a/man_card/card_generator.py b/man_card/card_generator.py new file mode 100644 index 0000000..96ab364 --- /dev/null +++ b/man_card/card_generator.py @@ -0,0 +1,448 @@ +"""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))