Initial upload: man-card CLI tool with PDF/PNG generation, templates, and tests
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-01-31 21:39:50 +00:00
parent 58c62e12ac
commit a56552a68e

448
man_card/card_generator.py Normal file
View File

@@ -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))