feat: 添加图片适配模式支持
- 支持四种图片适配模式:stretch、contain、cover、center - 支持背景色填充功能(contain 和 center 模式) - 支持文档级 DPI 配置(metadata.dpi) - PPTX 渲染器集成 Pillow 实现高质量图片处理 - HTML 渲染器使用 CSS object-fit 实现相同效果 - 添加完整的单元测试、集成测试和端到端测试 - 更新 README 文档和架构文档 - 模块化设计:utils/image_utils.py 图片处理工具模块 - 添加图片配置验证器:validators/image_config.py - 向后兼容:未指定 fit 时默认使用 stretch 模式
This commit is contained in:
@@ -8,13 +8,18 @@ from pathlib import Path
|
||||
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
|
||||
|
||||
|
||||
# 固定 DPI 用于单位转换
|
||||
DPI = 96
|
||||
|
||||
|
||||
class HtmlRenderer:
|
||||
"""HTML 渲染器,将元素渲染为 HTML"""
|
||||
|
||||
def __init__(self, dpi=96):
|
||||
"""
|
||||
初始化 HTML 渲染器
|
||||
|
||||
Args:
|
||||
dpi: DPI 配置,默认 96
|
||||
"""
|
||||
self.dpi = dpi
|
||||
|
||||
def render_slide(self, slide_data, index, base_path):
|
||||
"""
|
||||
渲染单个幻灯片为 HTML
|
||||
@@ -68,10 +73,10 @@ class HtmlRenderer:
|
||||
str: HTML 代码
|
||||
"""
|
||||
style = f"""
|
||||
left: {elem.box[0] * DPI}px;
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
left: {elem.box[0] * self.dpi}px;
|
||||
top: {elem.box[1] * self.dpi}px;
|
||||
width: {elem.box[2] * self.dpi}px;
|
||||
height: {elem.box[3] * self.dpi}px;
|
||||
font-size: {elem.font.get("size", 16)}pt;
|
||||
color: {elem.font.get("color", "#000000")};
|
||||
text-align: {elem.font.get("align", "left")};
|
||||
@@ -105,10 +110,10 @@ class HtmlRenderer:
|
||||
}.get(elem.shape, "0")
|
||||
|
||||
style = f"""
|
||||
left: {elem.box[0] * DPI}px;
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
left: {elem.box[0] * self.dpi}px;
|
||||
top: {elem.box[1] * self.dpi}px;
|
||||
width: {elem.box[2] * self.dpi}px;
|
||||
height: {elem.box[3] * self.dpi}px;
|
||||
background: {elem.fill if elem.fill else "transparent"};
|
||||
border-radius: {border_radius};
|
||||
"""
|
||||
@@ -131,8 +136,8 @@ class HtmlRenderer:
|
||||
str: HTML 代码
|
||||
"""
|
||||
table_style = f"""
|
||||
left: {elem.position[0] * DPI}px;
|
||||
top: {elem.position[1] * DPI}px;
|
||||
left: {elem.position[0] * self.dpi}px;
|
||||
top: {elem.position[1] * self.dpi}px;
|
||||
"""
|
||||
|
||||
rows_html = ""
|
||||
@@ -166,11 +171,46 @@ class HtmlRenderer:
|
||||
"""
|
||||
img_path = Path(base_path) / elem.src if base_path else Path(elem.src)
|
||||
|
||||
# 获取 fit 模式,默认为 stretch
|
||||
fit = elem.fit if elem.fit else 'stretch'
|
||||
|
||||
# fit 模式到 CSS object-fit 的映射
|
||||
object_fit_map = {
|
||||
'stretch': 'fill',
|
||||
'contain': 'contain',
|
||||
'cover': 'cover',
|
||||
'center': 'none'
|
||||
}
|
||||
object_fit = object_fit_map.get(fit, 'fill')
|
||||
|
||||
# 基础样式
|
||||
style = f"""
|
||||
left: {elem.box[0] * DPI}px;
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
left: {elem.box[0] * self.dpi}px;
|
||||
top: {elem.box[1] * self.dpi}px;
|
||||
width: {elem.box[2] * self.dpi}px;
|
||||
height: {elem.box[3] * self.dpi}px;
|
||||
object-fit: {object_fit};
|
||||
object-position: center;
|
||||
"""
|
||||
|
||||
return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'
|
||||
# 如果有背景色,需要创建包装容器
|
||||
if elem.background:
|
||||
container_style = f"""
|
||||
position: absolute;
|
||||
left: {elem.box[0] * self.dpi}px;
|
||||
top: {elem.box[1] * self.dpi}px;
|
||||
width: {elem.box[2] * self.dpi}px;
|
||||
height: {elem.box[3] * self.dpi}px;
|
||||
background-color: {elem.background};
|
||||
"""
|
||||
img_style = f"""
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: {object_fit};
|
||||
object-position: center;
|
||||
"""
|
||||
return f'''<div class="element image-container" style="{container_style}">
|
||||
<img class="image-element" src="file://{img_path.absolute()}" style="{img_style}">
|
||||
</div>'''
|
||||
else:
|
||||
return f'<img class="element image-element" src="file://{img_path.absolute()}" style="{style}">'
|
||||
|
||||
@@ -10,23 +10,28 @@ from pptx.util import Inches, Pt
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.dml.color import RGBColor
|
||||
from PIL import Image
|
||||
import tempfile
|
||||
|
||||
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from utils import hex_to_rgb
|
||||
from utils.image_utils import inches_to_pixels, apply_fit_mode
|
||||
|
||||
|
||||
class PptxGenerator:
|
||||
"""PPTX 生成器,封装 python-pptx 操作"""
|
||||
|
||||
def __init__(self, size='16:9'):
|
||||
def __init__(self, size='16:9', dpi=96):
|
||||
"""
|
||||
初始化 PPTX 生成器
|
||||
|
||||
Args:
|
||||
size: 幻灯片尺寸("16:9" 或 "4:3")
|
||||
dpi: DPI 配置,默认 96
|
||||
"""
|
||||
self.prs = PptxPresentation()
|
||||
self.dpi = dpi
|
||||
|
||||
# 设置幻灯片尺寸
|
||||
if size == '16:9':
|
||||
@@ -148,9 +153,39 @@ class PptxGenerator:
|
||||
# 获取位置和尺寸
|
||||
x, y, w, h = [Inches(v) for v in elem.box]
|
||||
|
||||
# 添加图片
|
||||
# 获取 fit 模式,默认为 stretch
|
||||
fit = elem.fit if elem.fit else 'stretch'
|
||||
|
||||
try:
|
||||
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
|
||||
# stretch 模式:直接使用 python-pptx 的原生处理
|
||||
if fit == 'stretch':
|
||||
slide.shapes.add_picture(str(img_path), x, y, width=w, height=h)
|
||||
else:
|
||||
# 其他模式:使用 Pillow 处理图片
|
||||
# 打开图片
|
||||
with Image.open(img_path) as img:
|
||||
# 转换 box 尺寸为像素
|
||||
box_width_px = int(inches_to_pixels(elem.box[2], self.dpi))
|
||||
box_height_px = int(inches_to_pixels(elem.box[3], self.dpi))
|
||||
box_size = (box_width_px, box_height_px)
|
||||
|
||||
# 应用 fit 模式
|
||||
processed_img = apply_fit_mode(
|
||||
img, box_size, fit, elem.background
|
||||
)
|
||||
|
||||
# 保存处理后的图片到临时文件
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
processed_img.save(tmp.name, 'PNG')
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# 添加处理后的图片到幻灯片
|
||||
slide.shapes.add_picture(tmp_path, x, y, width=w, height=h)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
except Exception as e:
|
||||
raise YAMLError(f"添加图片失败: {img_path}: {str(e)}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user