diff --git a/core/renderer.py b/core/renderer.py index 05388f7..1638d1a 100644 --- a/core/renderer.py +++ b/core/renderer.py @@ -4,6 +4,8 @@ from typing import Optional, Union, Tuple, BinaryIO from openpyxl import load_workbook from openpyxl.worksheet.worksheet import Worksheet +from openpyxl.cell.cell import MergedCell +from openpyxl.utils import get_column_letter from PIL import Image, ImageDraw, ImageFont # Suppress warnings @@ -23,22 +25,31 @@ class ExcelRenderer: """ 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() + # Cache for loaded fonts to avoid reloading for same size + self.font_cache = {} + + def _get_font(self, is_bold: bool, size: int) -> ImageFont.FreeTypeFont: + """ + Get font with specific properties, using cache. + """ + key = (is_bold, size) + if key in self.font_cache: + return self.font_cache[key] + + font_path = self.font_path_bold if is_bold else self.font_path_regular try: - self.font_bold = ImageFont.truetype(self.font_path_bold, 12) + font = ImageFont.truetype(font_path, size) except OSError: + # Fallback try: - self.font_bold = ImageFont.truetype("arialbd.ttf", 12) + fallback_name = "arialbd.ttf" if is_bold else "arial.ttf" + font = ImageFont.truetype(fallback_name, size) except OSError: - self.font_bold = ImageFont.load_default() + font = ImageFont.load_default() + + self.font_cache[key] = font + return font def render_to_bytes(self, sheet_name: Optional[str] = None, dpi: int = 200, padding: int = 20) -> bytes: """ @@ -78,7 +89,7 @@ class ExcelRenderer: img_width = 2 * padding for col in range(1, max_col + 1): - col_letter = sheet.cell(row=1, column=col).column_letter + col_letter = get_column_letter(col) # Get column width (approximate conversion) col_dim = sheet.column_dimensions[col_letter] col_width_excel = col_dim.width if col_dim.width else 10 @@ -101,21 +112,50 @@ class ExcelRenderer: current_x += width col_x_positions.append(current_x) + # Parse merged cells ranges + # Format: {(min_row, min_col): (max_row, max_col)} + merged_ranges = {} + for merged_range in sheet.merged_cells.ranges: + merged_ranges[(merged_range.min_row, merged_range.min_col)] = (merged_range.max_row, merged_range.max_col) + # 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 + # Check if this cell is the top-left of a merged range + if (row, col) in merged_ranges: + max_r, max_c = merged_ranges[(row, col)] + + x1 = col_x_positions[col - 1] + y1 = padding + (row - 1) * cell_height + # Use the max_col of the merged range to determine x2 + x2 = col_x_positions[max_c] + # Use the max_row of the merged range to determine y2 + y2 = padding + max_r * cell_height + + self._draw_cell(draw, cell, x1, y1, x2, y2) + + # Skip if it is a merged cell but NOT the top-left (already handled above or by MergedCell check) + elif isinstance(cell, MergedCell): + continue + + else: + # Normal cell + 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) + self._draw_cell(draw, cell, x1, y1, x2, y2) return img def _draw_cell(self, draw: ImageDraw.ImageDraw, cell, x1, y1, x2, y2): + # Skip MergedCells that are not the top-left cell + if isinstance(cell, MergedCell): + return + # Background color fill_color = cell.fill.start_color.rgb bg_color = self._parse_color(fill_color, default=(255, 255, 255)) @@ -134,7 +174,9 @@ class ExcelRenderer: # Font handling is_bold = cell.font and cell.font.bold - current_font = self.font_bold if is_bold else self.font_regular + # Excel font size is in points. 12 is default. + font_size = int(cell.font.sz) if (cell.font and cell.font.sz) else 12 + current_font = self._get_font(is_bold, font_size) # Font color font_color_hex = cell.font.color.rgb if (cell.font and cell.font.color) else None @@ -145,7 +187,7 @@ class ExcelRenderer: 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) + self._draw_text(draw, text, x1, y1, x2, y2, current_font, text_color, h_align, v_align, font_size) 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): @@ -175,7 +217,7 @@ class ExcelRenderer: return str(value) return str(value) - def _draw_text(self, draw, text, x1, y1, x2, y2, font, color, h_align, v_align): + def _draw_text(self, draw, text, x1, y1, x2, y2, font, color, h_align, v_align, font_size): # Calculate available width max_width = x2 - x1 - 10 text_width = draw.textlength(text, font=font) @@ -198,8 +240,8 @@ class ExcelRenderer: text_x = x1 + 5 # Vertical Position (Approximate, using fixed height) - # Assuming font size 12 approx height 12-15 pixels - font_height = 12 + # Use font_size as a proxy for height (approximation) + font_height = font_size if v_align == 'top': text_y = y1 + 5 elif v_align == 'bottom': diff --git a/tests/kshj_gt1767064690.xlsx b/tests/kshj_gt1767064690.xlsx new file mode 100755 index 0000000..9343eeb Binary files /dev/null and b/tests/kshj_gt1767064690.xlsx differ diff --git a/tests/test_font_size.py b/tests/test_font_size.py new file mode 100644 index 0000000..f11872d --- /dev/null +++ b/tests/test_font_size.py @@ -0,0 +1,50 @@ +import pytest +from openpyxl import Workbook +from openpyxl.styles import Font +import io +from core.renderer import ExcelRenderer +from PIL import Image + +@pytest.fixture +def font_size_excel_bytes(): + wb = Workbook() + ws = wb.active + ws.title = "FontTest" + + # Standard size + ws['A1'] = "Standard 12" + ws['A1'].font = Font(size=12) + + # Large size + ws['A2'] = "Large 20" + ws['A2'].font = Font(size=20) + + # Small size + ws['A3'] = "Small 8" + ws['A3'].font = Font(size=8) + + out = io.BytesIO() + wb.save(out) + out.seek(0) + return out.getvalue() + +def test_font_size_rendering(font_size_excel_bytes): + """ + Test that rendering handles different font sizes without crashing. + Note: Visual verification is hard in unit tests, but we can ensure + the code paths for dynamic font loading are executed. + """ + renderer = ExcelRenderer(font_size_excel_bytes) + + try: + img_bytes = renderer.render_to_bytes(sheet_name="FontTest") + except Exception as e: + pytest.fail(f"Rendering failed with error: {e}") + + assert isinstance(img_bytes, bytes) + img = Image.open(io.BytesIO(img_bytes)) + assert img.format == "PNG" + + # We can also inspect the internal cache to see if different fonts were loaded + # (Accessing private attribute for testing purpose) + assert len(renderer.font_cache) >= 3, "Should have cached at least 3 different font configurations" diff --git a/tests/test_merged_cells.py b/tests/test_merged_cells.py new file mode 100644 index 0000000..d8d650a --- /dev/null +++ b/tests/test_merged_cells.py @@ -0,0 +1,46 @@ +import pytest +from openpyxl import Workbook +from openpyxl.styles import Alignment +import io +from core.renderer import ExcelRenderer +from PIL import Image + +@pytest.fixture +def merged_cell_excel_bytes(): + wb = Workbook() + ws = wb.active + ws.title = "MergedTest" + + # Merge A1:B2 + ws.merge_cells('A1:B2') + cell = ws['A1'] + cell.value = "Merged Content" + cell.alignment = Alignment(horizontal='center', vertical='center') + + # Add some other content + ws['C1'] = "C1" + ws['C2'] = "C2" + ws['A3'] = "A3" + + out = io.BytesIO() + wb.save(out) + out.seek(0) + return out.getvalue() + +def test_merged_cell_rendering(merged_cell_excel_bytes): + """ + Test that rendering an Excel file with merged cells does not raise an AttributeError. + Specifically checking for 'MergedCell' object has no attribute 'column_letter'. + """ + renderer = ExcelRenderer(merged_cell_excel_bytes) + + try: + img_bytes = renderer.render_to_bytes(sheet_name="MergedTest") + except AttributeError as e: + pytest.fail(f"Rendering failed with AttributeError: {e}") + except Exception as e: + pytest.fail(f"Rendering failed with unexpected error: {e}") + + assert isinstance(img_bytes, bytes) + img = Image.open(io.BytesIO(img_bytes)) + assert img.format == "PNG"