fix: 1. 增加存在合并单元格的excel转换 2.转换时使用excel字体设置

This commit is contained in:
fuzhongyun 2025-12-30 11:55:36 +08:00
parent 5442377f20
commit abc8db580c
4 changed files with 160 additions and 22 deletions

View File

@ -4,6 +4,8 @@ from typing import Optional, Union, Tuple, BinaryIO
from openpyxl import load_workbook from openpyxl import load_workbook
from openpyxl.worksheet.worksheet import Worksheet 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 from PIL import Image, ImageDraw, ImageFont
# Suppress warnings # Suppress warnings
@ -23,22 +25,31 @@ class ExcelRenderer:
""" """
Load fonts with fallback mechanisms. Load fonts with fallback mechanisms.
""" """
try: # Cache for loaded fonts to avoid reloading for same size
self.font_regular = ImageFont.truetype(self.font_path_regular, 12) self.font_cache = {}
except OSError:
# Fallback to default if custom font not found def _get_font(self, is_bold: bool, size: int) -> ImageFont.FreeTypeFont:
try: """
self.font_regular = ImageFont.truetype("arial.ttf", 12) Get font with specific properties, using cache.
except OSError: """
self.font_regular = ImageFont.load_default() 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: try:
self.font_bold = ImageFont.truetype(self.font_path_bold, 12) font = ImageFont.truetype(font_path, size)
except OSError: except OSError:
# Fallback
try: 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: 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: 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 img_width = 2 * padding
for col in range(1, max_col + 1): 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) # Get column width (approximate conversion)
col_dim = sheet.column_dimensions[col_letter] col_dim = sheet.column_dimensions[col_letter]
col_width_excel = col_dim.width if col_dim.width else 10 col_width_excel = col_dim.width if col_dim.width else 10
@ -101,21 +112,50 @@ class ExcelRenderer:
current_x += width current_x += width
col_x_positions.append(current_x) 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 # Draw cells
for row in range(1, max_row + 1): for row in range(1, max_row + 1):
for col in range(1, max_col + 1): for col in range(1, max_col + 1):
cell = sheet.cell(row=row, column=col) cell = sheet.cell(row=row, column=col)
x1 = col_x_positions[col - 1] # Check if this cell is the top-left of a merged range
y1 = padding + (row - 1) * cell_height if (row, col) in merged_ranges:
x2 = col_x_positions[col] max_r, max_c = merged_ranges[(row, col)]
y2 = y1 + cell_height
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 return img
def _draw_cell(self, draw: ImageDraw.ImageDraw, cell, x1, y1, x2, y2): 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 # Background color
fill_color = cell.fill.start_color.rgb fill_color = cell.fill.start_color.rgb
bg_color = self._parse_color(fill_color, default=(255, 255, 255)) bg_color = self._parse_color(fill_color, default=(255, 255, 255))
@ -134,7 +174,9 @@ class ExcelRenderer:
# Font handling # Font handling
is_bold = cell.font and cell.font.bold 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
font_color_hex = cell.font.color.rgb if (cell.font and cell.font.color) else None 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' v_align = cell.alignment.vertical if (cell.alignment and cell.alignment.vertical) else 'center'
# Text rendering with simple truncation # 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]: 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): 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)
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 # Calculate available width
max_width = x2 - x1 - 10 max_width = x2 - x1 - 10
text_width = draw.textlength(text, font=font) text_width = draw.textlength(text, font=font)
@ -198,8 +240,8 @@ class ExcelRenderer:
text_x = x1 + 5 text_x = x1 + 5
# Vertical Position (Approximate, using fixed height) # Vertical Position (Approximate, using fixed height)
# Assuming font size 12 approx height 12-15 pixels # Use font_size as a proxy for height (approximation)
font_height = 12 font_height = font_size
if v_align == 'top': if v_align == 'top':
text_y = y1 + 5 text_y = y1 + 5
elif v_align == 'bottom': elif v_align == 'bottom':

BIN
tests/kshj_gt1767064690.xlsx Executable file

Binary file not shown.

50
tests/test_font_size.py Normal file
View File

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

View File

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