From abc8db580c8da89b369de2772769718e71a08de8 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Tue, 30 Dec 2025 11:55:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=201.=20=E5=A2=9E=E5=8A=A0=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E5=90=88=E5=B9=B6=E5=8D=95=E5=85=83=E6=A0=BC=E7=9A=84?= =?UTF-8?q?excel=E8=BD=AC=E6=8D=A2=202.=E8=BD=AC=E6=8D=A2=E6=97=B6?= =?UTF-8?q?=E4=BD=BF=E7=94=A8excel=E5=AD=97=E4=BD=93=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/renderer.py | 86 ++++++++++++++++++++++++++--------- tests/kshj_gt1767064690.xlsx | Bin 0 -> 11024 bytes tests/test_font_size.py | 50 ++++++++++++++++++++ tests/test_merged_cells.py | 46 +++++++++++++++++++ 4 files changed, 160 insertions(+), 22 deletions(-) create mode 100755 tests/kshj_gt1767064690.xlsx create mode 100644 tests/test_font_size.py create mode 100644 tests/test_merged_cells.py 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 0000000000000000000000000000000000000000..9343eeb2072322e50d031acc3d1710b8f9320714 GIT binary patch literal 11024 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 literal 0 HcmV?d00001 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"