excel2pic/core/renderer.py

211 lines
7.7 KiB
Python

import io
import warnings
from typing import Optional, Union, Tuple, BinaryIO
from openpyxl import load_workbook
from openpyxl.worksheet.worksheet import Worksheet
from PIL import Image, ImageDraw, ImageFont
# Suppress warnings
warnings.filterwarnings('ignore')
class ExcelRenderer:
def __init__(self, file_content: Union[bytes, BinaryIO], font_path_regular: str = "assets/simsun.ttc", font_path_bold: str = "assets/simhei.ttf"):
"""
Initialize the renderer with Excel file content and font paths.
"""
self.file_content = file_content if isinstance(file_content, io.BytesIO) else io.BytesIO(file_content)
self.font_path_regular = font_path_regular
self.font_path_bold = font_path_bold
self._load_fonts()
def _load_fonts(self):
"""
Load fonts with fallback mechanisms.
"""
try:
self.font_regular = ImageFont.truetype(self.font_path_regular, 12)
except OSError:
# Fallback to default if custom font not found
try:
self.font_regular = ImageFont.truetype("arial.ttf", 12)
except OSError:
self.font_regular = ImageFont.load_default()
try:
self.font_bold = ImageFont.truetype(self.font_path_bold, 12)
except OSError:
try:
self.font_bold = ImageFont.truetype("arialbd.ttf", 12)
except OSError:
self.font_bold = ImageFont.load_default()
def render_to_bytes(self, sheet_name: Optional[str] = None, dpi: int = 200, padding: int = 20) -> bytes:
"""
Render the specified sheet to a PNG image and return bytes.
"""
img = self._render_image(sheet_name, padding)
output = io.BytesIO()
img.save(output, format='PNG', dpi=(dpi, dpi))
output.seek(0)
return output.getvalue()
def _render_image(self, sheet_name: Optional[str], padding: int) -> Image.Image:
"""
Internal method to draw the Excel sheet onto a PIL Image.
"""
wb = load_workbook(self.file_content, data_only=True)
if sheet_name is None:
sheet = wb.active
else:
if sheet_name in wb.sheetnames:
sheet = wb[sheet_name]
else:
raise ValueError(f"Sheet '{sheet_name}' not found. Available sheets: {wb.sheetnames}")
return self._draw_sheet(sheet, padding)
def _draw_sheet(self, sheet: Worksheet, padding: int) -> Image.Image:
cell_height = 40 # Default cell height
max_row = sheet.max_row
max_col = sheet.max_column
# Calculate column widths and image dimensions
col_widths_pixels = []
img_width = 2 * padding
for col in range(1, max_col + 1):
col_letter = sheet.cell(row=1, column=col).column_letter
# Get column width (approximate conversion)
col_dim = sheet.column_dimensions[col_letter]
col_width_excel = col_dim.width if col_dim.width else 10
# Excel width to pixels (approximate factor ~7 + padding)
width_px = int(col_width_excel * 7) + 5
col_widths_pixels.append(width_px)
img_width += width_px
img_height = max_row * cell_height + 2 * padding
# Create image
img = Image.new('RGB', (img_width, img_height), color='white')
draw = ImageDraw.Draw(img)
# Pre-calculate column x-positions
col_x_positions = [padding]
current_x = padding
for width in col_widths_pixels:
current_x += width
col_x_positions.append(current_x)
# Draw cells
for row in range(1, max_row + 1):
for col in range(1, max_col + 1):
cell = sheet.cell(row=row, column=col)
x1 = col_x_positions[col - 1]
y1 = padding + (row - 1) * cell_height
x2 = col_x_positions[col]
y2 = y1 + cell_height
self._draw_cell(draw, cell, x1, y1, x2, y2)
return img
def _draw_cell(self, draw: ImageDraw.ImageDraw, cell, x1, y1, x2, y2):
# Background color
fill_color = cell.fill.start_color.rgb
bg_color = self._parse_color(fill_color, default=(255, 255, 255))
# Draw background and border
draw.rectangle([x1, y1, x2, y2], fill=bg_color, outline=(200, 200, 200))
# Content
cell_value = cell.value
if cell_value is None:
return
text = self._format_cell_value(cell, cell_value)
if not text:
return
# Font handling
is_bold = cell.font and cell.font.bold
current_font = self.font_bold if is_bold else self.font_regular
# Font color
font_color_hex = cell.font.color.rgb if (cell.font and cell.font.color) else None
text_color = self._parse_color(font_color_hex, default=(0, 0, 0))
# Alignment
h_align = cell.alignment.horizontal if (cell.alignment and cell.alignment.horizontal) else 'left'
v_align = cell.alignment.vertical if (cell.alignment and cell.alignment.vertical) else 'center'
# Text rendering with simple truncation
self._draw_text(draw, text, x1, y1, x2, y2, current_font, text_color, h_align, v_align)
def _parse_color(self, color_code, default=(0, 0, 0)) -> Tuple[int, int, int]:
if not color_code or color_code == '00000000' or not isinstance(color_code, str):
return default
# Handle ARGB (Excel often uses this)
if len(color_code) > 6:
# Strip alpha if present (usually first 2 chars for ARGB)
# Example: FF000000 -> 000000 (Black), FFFFFFFF -> FFFFFF (White)
# Note: This is a simplification.
if color_code.startswith('FF') or len(color_code) == 8:
color_code = color_code[2:]
try:
return tuple(int(color_code[i:i + 2], 16) for i in (0, 2, 4))
except:
return default
def _format_cell_value(self, cell, value) -> str:
if isinstance(value, (int, float)):
# Simple number formatting
if cell.number_format:
if '0.00' in str(cell.number_format):
return format(value, '.2f')
elif '0.0' in str(cell.number_format):
return format(value, '.1f')
return str(value)
return str(value)
def _draw_text(self, draw, text, x1, y1, x2, y2, font, color, h_align, v_align):
# Calculate available width
max_width = x2 - x1 - 10
text_width = draw.textlength(text, font=font)
# Simple truncation if too long
if text_width > max_width and len(text) > 3:
# Estimate chars that fit
char_ratio = max_width / text_width
keep_chars = int(len(text) * char_ratio) - 2
if keep_chars > 0:
text = text[:keep_chars] + "..."
text_width = draw.textlength(text, font=font) # Re-measure
# Horizontal Position
if h_align == 'center':
text_x = x1 + (x2 - x1 - text_width) / 2
elif h_align == 'right':
text_x = x2 - text_width - 5
else: # left
text_x = x1 + 5
# Vertical Position (Approximate, using fixed height)
# Assuming font size 12 approx height 12-15 pixels
font_height = 12
if v_align == 'top':
text_y = y1 + 5
elif v_align == 'bottom':
text_y = y2 - font_height - 5
else: # center
text_y = y1 + (y2 - y1 - font_height) / 2
draw.text((text_x, text_y), text, fill=color, font=font)