Initial upload: man-card CLI tool with PDF/PNG generation, templates, and tests
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
448
man_card/card_generator.py
Normal file
448
man_card/card_generator.py
Normal 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))
|
||||||
Reference in New Issue
Block a user