fix: 1. 增加存在合并单元格的excel转换 2.转换时使用excel字体设置
This commit is contained in:
parent
5442377f20
commit
abc8db580c
|
|
@ -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':
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue