ppt-web / src /landppt /services /ppt_image_processor.py
26fwyzpz6f-max
Clean deploy without binary files
6aecb2e
Raw
History Blame Contribute Delete
103 kB
"""
PPT图片处理器
负责在PPT生成过程中处理图片相关逻辑,包括本地图片选择、网络图片搜索、AI图片生成
支持多图片处理,由AI决定每种来源的图片数量
"""
import logging
from typing import Dict, Any, Optional, List
import aiohttp
import json
import asyncio
from pathlib import Path
import re
from ..ai import get_role_provider
from ..core.config import ai_config
from .models.slide_image_info import (
SlideImageInfo, SlideImagesCollection, SlideImageRequirements,
ImageRequirement, ImageSource, ImagePurpose
)
from .image.models import ImageSourceType
from .prompt_asset_service import strip_base64_image_payloads_for_prompt
from .prompts.system_prompts import SystemPrompts
logger = logging.getLogger(__name__)
class PPTImageProcessor:
"""PPT图片处理器"""
def __init__(self, image_service=None, ai_provider=None, user_id: Optional[int] = None, provider_override: Optional[str] = None):
self.image_service = image_service
self.ai_provider = ai_provider
self.user_id = user_id
self.provider_override = provider_override
self._base_url = None
# 搜索缓存,避免重复搜索
self._search_cache = {}
self._search_lock = asyncio.Lock()
async def _text_completion(self, *, prompt: str, **kwargs):
"""调用角色为图片分析的模型"""
# 优先使用用户数据库配置获取模型设置
if self.user_id is not None:
from .db_config_service import get_user_role_provider
provider, role_settings = await get_user_role_provider(
self.user_id, "image_prompt", provider_override=self.provider_override
)
if role_settings.get("model"):
kwargs.setdefault("model", role_settings["model"])
elif self.ai_provider:
provider = self.ai_provider
if "model" not in kwargs:
role_settings = ai_config.get_model_config_for_role("image_prompt", provider_override=self.provider_override)
if role_settings.get("model"):
kwargs["model"] = role_settings["model"]
else:
provider, role_settings = get_role_provider("image_prompt", provider_override=self.provider_override)
if role_settings.get("model"):
kwargs.setdefault("model", role_settings["model"])
provider_name = getattr(provider, "provider", None)
if hasattr(provider_name, "value"):
provider_name = provider_name.value
provider_name = provider_name or provider.__class__.__name__
model_name = kwargs.get("model") or role_settings.get("model") if 'role_settings' in locals() else kwargs.get("model")
if model_name == "deepseek-ai/DeepSeek-V4":
model_name = "deepseek-ai/DeepSeek-V4-Flash"
kwargs["model"] = model_name
logger.warning(
"一键配图 image_prompt 模型 deepseek-ai/DeepSeek-V4 在 SiliconFlow 不可用,已自动切换为 %s",
model_name,
)
logger.info(
"一键配图 image_prompt 配置: provider=%s, model=%s, provider_override=%s, user_id=%s",
provider_name,
model_name or "<EMPTY>",
self.provider_override or "<NONE>",
self.user_id,
)
prompt = SystemPrompts.with_text_cache_prefix(prompt)
return await provider.text_completion(prompt=prompt, **kwargs)
def _get_base_url(self) -> str:
"""获取基础URL,用于构建绝对图片链接"""
from .url_service import get_current_base_url
return get_current_base_url()
def _build_absolute_image_url(self, relative_path: str) -> str:
"""构建绝对图片URL"""
from .url_service import build_absolute_url
return build_absolute_url(relative_path)
def _get_enabled_image_sources(self, image_config: Dict[str, Any]) -> List[ImageSource]:
"""获取启用的图像来源"""
enabled_sources = []
if image_config.get('enable_local_images', True):
enabled_sources.append(ImageSource.LOCAL)
if image_config.get('enable_network_search', False):
enabled_sources.append(ImageSource.NETWORK)
if image_config.get('enable_ai_generation', False):
enabled_sources.append(ImageSource.AI_GENERATED)
return enabled_sources
def _normalize_network_search_provider(self, provider: Optional[str]) -> str:
"""Normalize provider string from config/UI."""
if not provider:
return ""
value = str(provider).strip().lower()
aliases = {
"pixbay": "pixabay",
"pxabay": "pixabay",
"unspalsh": "unsplash",
}
return aliases.get(value, value)
def _is_network_provider_configured(self, provider: str, image_config: Dict[str, Any]) -> bool:
"""Check whether the given provider has usable credentials in the provided config."""
provider = self._normalize_network_search_provider(provider)
if provider == "unsplash":
key = image_config.get("unsplash_access_key")
return bool(key and str(key).strip())
if provider == "pixabay":
key = image_config.get("pixabay_api_key")
return bool(key and str(key).strip())
if provider == "searxng":
host = image_config.get("searxng_host")
return bool(host and str(host).strip())
return False
def _select_ai_image_provider(self, image_config: Dict[str, Any]) -> str:
"""
Pick the AI image provider from user image config.
`default_ai_image_provider` is the explicit switch used by PPT image generation.
If it is missing, fall back to a configured provider instead of always using DALL-E.
"""
desired = str(image_config.get("default_ai_image_provider") or "").strip().lower()
aliases = {
"dall-e": "dalle",
"dalle3": "dalle",
"dall-e-3": "dalle",
"stable-diffusion": "stable_diffusion",
"sd": "stable_diffusion",
"openai": "openai_image",
"gpt-image": "openai_image",
"silicon": "siliconflow",
"sf": "siliconflow",
}
if desired:
return aliases.get(desired, desired)
provider_key_map = {
"siliconflow": "siliconflow_api_key",
"openai_image": "openai_image_api_key",
"gemini": "gemini_image_api_key",
"stable_diffusion": "stability_api_key",
"pollinations": "pollinations_api_key",
"dalle": "openai_api_key_image",
}
for provider, key_name in provider_key_map.items():
key = image_config.get(key_name)
if key and str(key).strip():
logger.warning(
"未配置 default_ai_image_provider,自动使用已配置的AI图片提供商: %s",
provider,
)
return provider
logger.warning("未配置 default_ai_image_provider 且没有检测到可用图片提供商,回退到 dalle")
return "dalle"
def _select_network_search_provider(self, image_config: Dict[str, Any]) -> Optional[str]:
"""
Pick a working network search provider based on DB config:
- Prefer `default_network_search_provider` when configured.
- Otherwise fall back to any configured provider.
"""
desired = self._normalize_network_search_provider(image_config.get("default_network_search_provider"))
if desired and self._is_network_provider_configured(desired, image_config):
return desired
for candidate in ("pixabay", "unsplash", "searxng"):
if self._is_network_provider_configured(candidate, image_config):
return candidate
return None
def _clamp_requirement_count(
self,
source: ImageSource,
raw_count: Any,
image_config: Dict[str, Any],
remaining_total: int,
) -> int:
"""将AI返回的图片数量限制在服务端配置范围内。"""
try:
count = int(raw_count)
except (TypeError, ValueError):
count = 0
if count <= 0 or remaining_total <= 0:
return 0
per_source_limits = {
ImageSource.LOCAL: int(image_config.get('max_local_images_per_slide', 2) or 2),
ImageSource.NETWORK: int(image_config.get('max_network_images_per_slide', 2) or 2),
ImageSource.AI_GENERATED: int(image_config.get('max_ai_images_per_slide', 1) or 1),
}
return max(0, min(count, per_source_limits.get(source, count), remaining_total))
def _clean_compact_text(self, text: Any, max_length: int = 160) -> str:
"""压缩文本,便于作为搜索词或兜底提示词片段。"""
value = str(text or "").strip()
value = re.sub(r"[\r\n\t]+", " ", value)
value = re.sub(r"\s+", " ", value)
value = value.strip(" -_,。,.;;::")
if len(value) > max_length:
value = value[:max_length].rsplit(" ", 1)[0] or value[:max_length]
return value
def _build_fallback_search_keywords(
self,
slide_title: str,
slide_content: str,
project_topic: str,
project_scenario: str,
requirement: Optional[ImageRequirement] = None,
max_length: int = 90,
) -> str:
"""不额外调用LLM的关键词兜底。"""
parts = [
requirement.description if requirement else "",
slide_title,
project_topic,
project_scenario,
]
combined = " ".join(self._clean_compact_text(part, 40) for part in parts if part)
combined = re.sub(r"[^\w\u4e00-\u9fff\s-]+", " ", combined)
combined = re.sub(r"\s+", " ", combined).strip()
return self._truncate_search_query(combined or slide_title or project_topic or "presentation", max_length)
def _get_planned_search_keywords(
self,
requirement: ImageRequirement,
slide_title: str,
slide_content: str,
project_topic: str,
project_scenario: str,
max_length: int = 90,
) -> str:
"""优先使用一次图片规划返回的关键词,缺失时本地兜底,不再追加LLM调用。"""
keywords = self._clean_compact_text(requirement.search_keywords, max_length)
if not keywords:
keywords = self._build_fallback_search_keywords(
slide_title,
slide_content,
project_topic,
project_scenario,
requirement,
max_length=max_length,
)
return keywords
def _build_fallback_generation_prompt(
self,
slide_title: str,
slide_content: str,
project_topic: str,
project_scenario: str,
requirement: Optional[ImageRequirement],
image_index: int,
) -> str:
"""不额外调用LLM的AI图片提示词兜底。"""
purpose = requirement.purpose.value if requirement else "illustration"
description = requirement.description if requirement else ""
topic = self._clean_compact_text(project_topic, 80)
title = self._clean_compact_text(slide_title, 80)
desc = self._clean_compact_text(description or slide_content, 140)
return (
"Professional presentation visual, clean modern composition, "
f"topic: {topic}, slide: {title}, purpose: {purpose}, "
f"visual brief: {desc}, image {image_index}, no text, no watermark, "
"high quality, suitable for a 16:9 business slide."
)
def _parse_planned_dimensions(
self,
requirement_data: Dict[str, Any],
provider_key: str,
image_config: Dict[str, Any],
) -> tuple:
"""从一次规划结果中解析尺寸,缺失或非法时使用提供商首选尺寸。"""
planned_size = (
requirement_data.get("size")
or requirement_data.get("dimensions")
or {
"width": requirement_data.get("width"),
"height": requirement_data.get("height"),
}
)
parsed = self._normalize_resolution_value(planned_size)
options = self._get_resolution_options(provider_key, image_config)
if not options:
options = [(1792, 1024), (1024, 1792), (1024, 1024)]
if parsed and parsed in options:
return parsed
return options[0]
def _build_dimension_options_for_prompt(self, provider_key: str, image_config: Dict[str, Any]) -> str:
"""构建AI可选尺寸说明,供一次规划同时选择尺寸。"""
options = self._get_resolution_options(provider_key, image_config) or [(1792, 1024), (1024, 1792), (1024, 1024)]
option_lines = []
for idx, (w, h) in enumerate(options[:6], start=1):
aspect = w / h
if aspect > 1.1:
orientation = "横向"
elif aspect < 0.9:
orientation = "竖向"
else:
orientation = "正方形"
option_lines.append(f"{idx}. {w}x{h}{orientation})")
return "\n".join(option_lines)
async def process_slide_image(self, slide_data: Dict[str, Any], confirmed_requirements: Dict[str, Any],
page_number: int, total_pages: int, template_html: str = "") -> Optional[SlideImagesCollection]:
"""处理幻灯片多图片生成/搜索/选择逻辑"""
try:
# 检查是否启用图片生成服务 - 从用户数据库配置读取(非环境变量)
from .db_config_service import get_db_config_service
db_config_service = get_db_config_service()
image_config = await db_config_service.get_config_by_category('image_service', user_id=self.user_id)
enable_image_service = image_config.get('enable_image_service', False)
logger.info(f"图片服务配置 (user_id={self.user_id}): enable_image_service={enable_image_service}")
if not enable_image_service:
logger.info(f"第{page_number}页: 图片生成服务未启用,跳过图片处理")
return None
# 获取项目信息
project_topic = confirmed_requirements.get('project_topic', '')
project_scenario = confirmed_requirements.get('project_scenario', 'general')
slide_title = slide_data.get('title', f'第{page_number}页')
slide_content = slide_data.get('content_points', [])
slide_content_text = '\n'.join(slide_content) if isinstance(slide_content, list) else str(slide_content)
# 检查启用的图片来源
enabled_sources = self._get_enabled_image_sources(image_config)
if not enabled_sources:
logger.info(f"第{page_number}页没有启用任何图片来源,跳过图片处理")
return None
# 让AI分析并决定图片需求(只考虑启用的来源)
image_requirements = await self._ai_analyze_image_requirements(
slide_data, project_topic, project_scenario, page_number, total_pages, template_html, enabled_sources, image_config
)
if not image_requirements or not image_requirements.requirements:
logger.info(f"AI判断第{page_number}页不需要添加图片,跳过图片处理")
return None
logger.info(f"第{page_number}页图片需求: 总计{image_requirements.total_images_needed}张图片")
# 创建图片集合
images_collection = SlideImagesCollection(page_number=page_number, images=[])
# 根据需求处理各种来源的图片
for requirement in image_requirements.requirements:
if requirement.source == ImageSource.LOCAL and ImageSource.LOCAL in enabled_sources:
local_images = await self._process_local_images(
requirement, project_topic, project_scenario, slide_title, slide_content_text
)
images_collection.images.extend(local_images)
elif requirement.source == ImageSource.NETWORK and ImageSource.NETWORK in enabled_sources:
network_images = await self._process_network_images(
requirement, project_topic, project_scenario, slide_title, slide_content_text, image_config
)
images_collection.images.extend(network_images)
elif requirement.source == ImageSource.AI_GENERATED and ImageSource.AI_GENERATED in enabled_sources:
ai_images = await self._process_ai_generated_images(
requirement, project_topic, project_scenario, slide_title, slide_content_text,
image_config, page_number, total_pages, template_html
)
images_collection.images.extend(ai_images)
# 重新计算统计信息
images_collection.__post_init__()
if images_collection.total_count > 0:
logger.info(f"第{page_number}页成功处理{images_collection.total_count}张图片: "
f"本地{images_collection.local_count}张, "
f"网络{images_collection.network_count}张, "
f"AI生成{images_collection.ai_generated_count}张")
return images_collection
else:
logger.info(f"第{page_number}页未能获取到任何图片")
return None
except Exception as e:
logger.error(f"处理幻灯片图片失败: {e}")
return None
async def _ai_analyze_image_requirements(self, slide_data: Dict[str, Any], project_topic: str,
project_scenario: str, page_number: int, total_pages: int,
template_html: str = "", enabled_sources: List[ImageSource] = None,
image_config: Dict[str, Any] = None) -> Optional[SlideImageRequirements]:
"""使用AI分析幻灯片的图片需求"""
# 提取幻灯片内容信息
slide_title = slide_data.get('title', '')
slide_content = slide_data.get('content_points', [])
slide_content_text = '\n'.join(slide_content) if isinstance(slide_content, list) else str(slide_content)
content_length = len(slide_content_text.strip())
content_points_count = len(slide_content) if isinstance(slide_content, list) else 0
# 处理启用的来源和配置限制
if not enabled_sources:
enabled_sources = [ImageSource.LOCAL, ImageSource.NETWORK, ImageSource.AI_GENERATED]
if not image_config:
image_config = {}
max_retries = 3
for attempt in range(max_retries):
try:
# 获取各来源的最大数量限制
max_local = image_config.get('max_local_images_per_slide', 2)
max_network = image_config.get('max_network_images_per_slide', 2)
max_ai = image_config.get('max_ai_images_per_slide', 1)
max_total = image_config.get('max_total_images_per_slide', 3)
default_ai_provider = self._select_ai_image_provider(image_config)
ai_dimension_options = self._build_dimension_options_for_prompt(default_ai_provider, image_config)
# 构建启用来源的说明
enabled_sources_desc = []
if ImageSource.LOCAL in enabled_sources:
enabled_sources_desc.append(f"local: 本地图床中的图片,适合通用性图片 (最多{max_local}张)")
if ImageSource.NETWORK in enabled_sources:
enabled_sources_desc.append(f"network: 网络搜索图片,适合特定主题的高质量图片 (最多{max_network}张)")
if ImageSource.AI_GENERATED in enabled_sources:
enabled_sources_desc.append(
f"ai_generated: AI生成图片,适合定制化、创意性图片 (最多{max_ai}张,"
f"默认提供商{default_ai_provider})"
)
# 构建包含模板HTML的提示词
template_context = ""
if template_html.strip():
template_excerpt = strip_base64_image_payloads_for_prompt(template_html)[:500]
template_context = f"""
当前PPT模板HTML参考:
{template_excerpt}...
"""
prompt = f"""作为专业的PPT设计师,请一次性完成以下幻灯片的图片规划。先判断该页面内容是否需要或适合配图,如果不需要或不适合配图则返回0;如果需要,请同时给出图片来源、数量、搜索关键词、AI生成尺寸和AI生成提示词。
【项目信息】
- 主题:{project_topic}
- 场景:{project_scenario}
- 当前页:{page_number}/{total_pages}
【幻灯片内容】
- 标题:{slide_title}
- 内容要点数量:{content_points_count}
- 内容字数:{content_length}
- 具体内容:
{slide_content_text}
{template_context}
【可用图片来源及限制】
{chr(10).join(enabled_sources_desc)}
【AI生成图片可选尺寸】
当前AI图片提供商:{default_ai_provider}
{ai_dimension_options}
【图片用途说明】
1. decoration: 装饰性图片,美化页面
2. illustration: 说明性图片,辅助理解内容
3. background: 背景图片,营造氛围
4. icon: 图标,简化表达
5. chart_support: 图表辅助,支持数据展示
6. content_visual: 内容可视化,直观展示概念
【配图适用性判断标准】
请首先判断该页面是否需要或适合配图,考虑以下因素:
1. 内容类型:纯文字列表、目录页、致谢页等通常不需要配图
2. 内容密度:文字过多的页面可能不适合添加图片
3. 页面功能:导航页、索引页、参考文献页等功能性页面通常不需要配图
4. 内容抽象度:过于抽象或概念性的内容可能不适合配图
5. 版面空间:内容已经很满的页面不适合再添加图片
【不适合配图的典型情况】
- 纯文字列表或条目
- 目录、索引、导航页面
- 致谢、参考文献页面
- 纯数据表格页面
- 文字密集的详细说明页面
- 过于抽象的理论概念页面
【分析要求】
如果判断适合配图,请综合考虑以下因素来决定图片需求:
1. 内容复杂度:复杂内容需要更多说明性图片
2. 页面类型:封面页、章节页通常需要装饰性图片
3. 视觉平衡:文字密集的页面需要图片调节
4. 主题匹配:根据主题选择合适的图片来源
5. 设计风格:根据模板风格决定图片类型
【重要限制】
- 总图片数量不能超过{max_total}
- 只能使用已启用的图片来源
- 每种来源都有数量限制,请严格遵守
- 只允许从“AI生成图片可选尺寸”中选择尺寸
- 对 local/network 需求必须直接给出 search_keywords,后续不会再调用LLM生成关键词
- 对 ai_generated 需求必须直接给出 width、height 和 generation_prompts,后续不会再调用LLM选择尺寸或生成提示词
- generation_prompts 必须是英文,每张图片一个提示词,长度不超过120词,避免文字、Logo、水印
请以JSON格式返回分析结果,格式如下:
{{
"needs_images": true/false,
"total_images": 数字,
"requirements": [
{{
"source": "仅限已启用的来源",
"count": 数字,
"purpose": "decoration/illustration/background/icon/chart_support/content_visual",
"description": "具体需求描述",
"priority": 1-5,
"search_keywords": "local/network使用的3-6个关键词;ai_generated可为空",
"width": 1792,
"height": 1024,
"generation_prompts": ["仅ai_generated必填,数组长度等于count,每项为英文图片生成提示词"]
}}
],
"reasoning": "分析理由,包括是否适合配图的判断依据"
}}
【重要要求】:
- 如果不需要或不适合配图,设置needs_images为false,total_images为0,requirements为空数组
- 每种来源可以有多个需求项,支持不同用途
- 优先级1-5,5为最高优先级
- 严格遵守数量限制,避免页面过于拥挤
- local/network的search_keywords要具体、可搜索;中文项目优先中文关键词,英文项目优先英文关键词
- ai_generated的generation_prompts要可直接提交给图片生成服务
- 必须返回有效的JSON格式,不要添加任何解释文字
- 不要使用markdown代码块包装
- 确保所有字符串值都用双引号包围
- 确保布尔值使用true/false(小写)
请直接返回纯JSON格式的结果:"""
response = await self._text_completion(
prompt=prompt,
temperature=0.7
)
# 解析AI响应
# 清理AI响应内容
raw_content = response.content.strip()
logger.debug(f"AI原始响应内容: {raw_content}")
# 尝试提取JSON部分
json_content = self._extract_json_from_response(raw_content)
if not json_content:
logger.error(f"无法从AI响应中提取有效JSON: {raw_content}")
raise json.JSONDecodeError("无法提取有效JSON", raw_content, 0)
result = json.loads(json_content)
if not result.get('needs_images', False) or result.get('total_images', 0) == 0:
reasoning = result.get('reasoning', '未提供理由')
logger.info(f"AI判断第{page_number}页不需要或不适合配图: {reasoning}")
return None
# 创建需求对象
requirements = SlideImageRequirements(page_number=page_number, requirements=[])
remaining_total = int(max_total or 0)
for req_data in result.get('requirements', []):
source = ImageSource(req_data['source'])
if source not in enabled_sources:
logger.warning(f"AI返回未启用的图片来源,已忽略: {source.value}")
continue
count = self._clamp_requirement_count(
source,
req_data.get('count', 0),
image_config,
remaining_total,
)
if count <= 0:
continue
width = None
height = None
generation_prompts = None
if source == ImageSource.AI_GENERATED:
width, height = self._parse_planned_dimensions(
req_data,
default_ai_provider,
image_config,
)
raw_prompts = req_data.get("generation_prompts") or req_data.get("image_prompts") or []
if isinstance(raw_prompts, str):
raw_prompts = [raw_prompts]
generation_prompts = [
self._clean_compact_text(prompt, 900)
for prompt in raw_prompts
if self._clean_compact_text(prompt, 900)
][:count]
while len(generation_prompts) < count:
generation_prompts.append(
self._build_fallback_generation_prompt(
slide_title,
slide_content_text,
project_topic,
project_scenario,
None,
len(generation_prompts) + 1,
)
)
search_keywords = self._clean_compact_text(req_data.get('search_keywords'), 160)
if source in (ImageSource.LOCAL, ImageSource.NETWORK) and not search_keywords:
search_keywords = self._build_fallback_search_keywords(
slide_title,
slide_content_text,
project_topic,
project_scenario,
None,
)
requirement = ImageRequirement(
source=source,
count=count,
purpose=ImagePurpose(req_data['purpose']),
description=req_data['description'],
priority=req_data.get('priority', 1),
search_keywords=search_keywords or None,
width=width,
height=height,
generation_prompts=generation_prompts,
)
requirements.add_requirement(requirement)
remaining_total -= count
if remaining_total <= 0:
break
logger.info(f"AI分析第{page_number}页图片需求: {result.get('reasoning', '')}")
return requirements
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.warning(f"第{attempt + 1}次尝试解析AI图片需求分析结果失败: {e}")
logger.debug(f"AI响应内容: {response.content}")
if attempt < max_retries - 1:
logger.info(f"等待1秒后进行第{attempt + 2}次重试...")
import asyncio
await asyncio.sleep(1)
continue
else:
logger.error(f"所有{max_retries}次尝试都失败,放弃图片需求分析")
return None
except Exception as e:
logger.warning(f"第{attempt + 1}次尝试AI分析图片需求失败: {e}")
if attempt < max_retries - 1:
logger.info(f"等待1秒后进行第{attempt + 2}次重试...")
import asyncio
await asyncio.sleep(1)
continue
else:
logger.error(f"所有{max_retries}次尝试都失败,放弃图片需求分析")
return None
# 如果所有重试都失败了
logger.error("AI分析图片需求失败,已达到最大重试次数")
return None
def _extract_json_from_response(self, content: str) -> Optional[str]:
"""从AI响应中提取JSON内容"""
try:
# 移除可能的 think 内容
content = content.split("</think>")[-1]
# 移除可能的markdown代码块标记
content = content.strip()
# 如果内容被```json包围,提取其中的内容
if content.startswith('```json') and content.endswith('```'):
content = content[7:-3].strip()
elif content.startswith('```') and content.endswith('```'):
content = content[3:-3].strip()
# 查找第一个{和最后一个}
start_idx = content.find('{')
end_idx = content.rfind('}')
if start_idx != -1 and end_idx != -1 and start_idx < end_idx:
json_content = content[start_idx:end_idx + 1]
# 验证是否为有效JSON
json.loads(json_content)
return json_content
# 如果直接是JSON格式
json.loads(content)
return content
except (json.JSONDecodeError, ValueError):
pass
return None
async def _process_local_images(self, requirement: ImageRequirement, project_topic: str,
project_scenario: str, slide_title: str, slide_content: str) -> List[SlideImageInfo]:
"""处理本地图片需求"""
images = []
try:
if not self.image_service:
logger.warning("图片服务未初始化")
return images
# 获取本地图片库信息
cache_stats = await self.image_service.get_cache_stats()
total_images = 0
if 'categories' in cache_stats:
for _, count in cache_stats['categories'].items():
total_images += count
if total_images == 0:
logger.info("本地图片库为空,跳过本地图片选择")
return images
# 优先复用一次图片规划中的关键词,避免为本地图片再调用LLM。
search_keywords = self._get_planned_search_keywords(
requirement,
slide_title,
slide_content,
project_topic,
project_scenario,
max_length=90,
)
if not search_keywords:
logger.warning("无法生成本地搜索关键词")
return images
# 搜索并选择多张图片
selected_images = await self._search_multiple_local_images(search_keywords, requirement.count)
for image_id in selected_images:
relative_url = f"/api/image/view/{image_id}"
absolute_url = self._build_absolute_image_url(relative_url)
# 获取图片详细信息
image_info = await self._get_local_image_details(image_id)
slide_image = SlideImageInfo(
image_id=image_id,
absolute_url=absolute_url,
source=ImageSource.LOCAL,
purpose=requirement.purpose,
content_description=requirement.description,
search_keywords=search_keywords,
alt_text=image_info.get('title', ''),
title=image_info.get('title', ''),
width=image_info.get('width'),
height=image_info.get('height'),
file_size=image_info.get('file_size'),
format=image_info.get('format')
)
images.append(slide_image)
logger.info(f"成功选择{len(images)}张本地图片")
return images
except Exception as e:
logger.error(f"处理本地图片失败: {e}")
return images
async def _process_network_images(self, requirement: ImageRequirement, project_topic: str,
project_scenario: str, slide_title: str, slide_content: str,
image_config: Dict[str, Any]) -> List[SlideImageInfo]:
"""处理网络图片需求"""
images = []
try:
desired_provider = self._normalize_network_search_provider(
image_config.get("default_network_search_provider") or "unsplash"
)
provider = self._select_network_search_provider(image_config)
# 检查是否有可用的网络搜索提供商
if not provider:
logger.warning("没有配置可用的网络搜索提供商")
# 添加详细的配置检查信息
logger.warning(f"默认网络搜索提供商: {desired_provider}")
logger.warning(f"Unsplash API Key: {'已配置' if image_config.get('unsplash_access_key') else '未配置'}")
logger.warning(f"Pixabay API Key: {'已配置' if image_config.get('pixabay_api_key') else '未配置'}")
logger.warning(f"SearXNG Host: {'已配置' if image_config.get('searxng_host') else '未配置'}")
return images
if provider != desired_provider:
logger.warning(f"默认网络搜索提供商'{desired_provider}'不可用,降级使用'{provider}'")
# 优先复用一次图片规划中的关键词,避免为网络搜索再调用LLM。
max_length = 100 if provider == 'pixabay' else 200
search_query = self._get_planned_search_keywords(
requirement,
slide_title,
slide_content,
project_topic,
project_scenario,
max_length=max_length,
)
if not search_query:
logger.warning("无法生成搜索关键词")
return images
# logger.info(f"网络搜索关键词: {search_query}")
# 搜索更多图片以便在下载失败时有备选
search_count = min(requirement.count * 3, 20) # 搜索3倍数量,但不超过20张
# logger.info(f"开始网络搜索,关键词: {search_query}, 搜索数量: {search_count}")
network_images = await self._search_images_directly(
search_query, search_count, image_config=image_config, default_provider=provider
)
# logger.info(f"网络搜索返回 {len(network_images)} 张图片")
# 下载网络图片到本地缓存文件夹,带重试机制
successful_downloads = 0
image_index = 0
while successful_downloads < requirement.count and image_index < len(network_images):
image_data = network_images[image_index]
image_index += 1
try:
# 生成有意义的图片标题
meaningful_title = self._generate_meaningful_image_title(image_data, slide_title, successful_downloads + 1)
# 下载图片到本地缓存,带重试机制
cached_image_info = await self._download_network_image_to_cache_with_retry(image_data, meaningful_title)
if cached_image_info:
slide_image = SlideImageInfo(
image_id=cached_image_info['image_id'],
absolute_url=cached_image_info['absolute_url'],
source=ImageSource.NETWORK,
purpose=requirement.purpose,
content_description=requirement.description,
search_keywords=search_query,
alt_text=image_data.get('tags', ''),
title=f"网络图片 {successful_downloads + 1}",
width=image_data.get('imageWidth'),
height=image_data.get('imageHeight'),
format=cached_image_info.get('format', 'jpg')
)
images.append(slide_image)
successful_downloads += 1
logger.info(f"网络图片缓存成功: {cached_image_info['absolute_url']}")
else:
logger.warning(f"网络图片缓存失败,尝试下一张图片")
except Exception as e:
logger.error(f"处理网络图片失败: {e},尝试下一张图片")
continue
logger.info(f"成功获取{len(images)}张网络图片")
return images
except Exception as e:
logger.error(f"处理网络图片失败: {e}")
return images
def _has_network_search_providers(self, image_config: Dict[str, Any]) -> bool:
"""检查是否有可用的网络搜索提供商"""
return bool(self._select_network_search_provider(image_config))
async def _search_images_with_service(self, query: str, count: int) -> List[Dict[str, Any]]:
"""使用图片服务搜索图片"""
# 创建搜索缓存键
search_key = f"{query}_{count}"
# 检查缓存
async with self._search_lock:
if search_key in self._search_cache:
logger.debug(f"使用缓存的搜索结果: {query}")
return self._search_cache[search_key]
try:
# 检查图片服务是否可用
if not self.image_service:
logger.error("图片服务未初始化,无法使用图片服务搜索")
return []
image_service = self.image_service
from .image.models import ImageSearchRequest
# 创建搜索请求
search_request = ImageSearchRequest(
query=query,
per_page=max(3, min(count * 2, 20)), # 搜索更多以便筛选,确保>=3
page=1
)
# 执行搜索
search_result = await image_service.search_images(search_request)
# 转换为旧格式以兼容现有代码
images = []
for image_info in search_result.images[:count]:
image_data = {
'id': image_info.image_id,
'webformatURL': image_info.original_url,
'largeImageURL': image_info.original_url,
'tags': ', '.join([tag.name for tag in (image_info.tags or [])]),
'user': image_info.author or 'Unknown',
'pageURL': image_info.source_url or '',
'imageWidth': image_info.metadata.width if image_info.metadata else 0,
'imageHeight': image_info.metadata.height if image_info.metadata else 0
}
images.append(image_data)
# 缓存结果
async with self._search_lock:
self._search_cache[search_key] = images
# 限制缓存大小,避免内存泄漏
if len(self._search_cache) > 50:
# 删除最旧的缓存项
oldest_key = next(iter(self._search_cache))
del self._search_cache[oldest_key]
return images
except Exception as e:
logger.error(f"使用图片服务搜索失败: {e}")
return []
async def _process_ai_generated_images(self, requirement: ImageRequirement, project_topic: str,
project_scenario: str, slide_title: str, slide_content: str,
image_config: Dict[str, Any], page_number: int, total_pages: int,
template_html: str = "") -> List[SlideImageInfo]:
"""处理AI生成图片需求"""
images = []
try:
if not self.image_service:
logger.warning("图片服务未初始化")
return images
# 重新加载用户特定的图片提供者配置(从数据库读取API密钥)
if self.user_id is not None:
await self.image_service.reload_providers_for_user(self.user_id)
logger.debug(f"已为用户 {self.user_id} 重新加载图片提供者配置")
# 获取默认AI图片提供商
default_provider = self._select_ai_image_provider(image_config)
logger.info(f"使用AI图片提供商: {default_provider}")
# 尺寸和提示词来自一次图片规划;缺失时使用本地兜底,避免继续调用LLM。
if requirement.width and requirement.height:
width, height = int(requirement.width), int(requirement.height)
else:
resolution_options = self._get_resolution_options(default_provider, image_config)
width, height = (resolution_options[0] if resolution_options else (1792, 1024))
planned_prompts = [
self._clean_compact_text(prompt, 900)
for prompt in (requirement.generation_prompts or [])
if self._clean_compact_text(prompt, 900)
]
# 为每张图片生成不同的提示词
for i in range(requirement.count):
image_prompt = (
planned_prompts[i]
if i < len(planned_prompts)
else self._build_fallback_generation_prompt(
slide_title,
slide_content,
project_topic,
project_scenario,
requirement,
i + 1,
)
)
if not image_prompt:
logger.warning(f"无法生成第{i+1}张图片的提示词")
continue
# 创建图片生成请求
from .image.models import ImageGenerationRequest, ImageProvider
# 解析提供商
provider = ImageProvider.DALLE
if default_provider == 'siliconflow':
provider = ImageProvider.SILICONFLOW
elif default_provider == 'stable_diffusion':
provider = ImageProvider.STABLE_DIFFUSION
elif default_provider == 'gemini':
provider = ImageProvider.GEMINI
elif default_provider == 'openai_image':
provider = ImageProvider.OPENAI_IMAGE
elif default_provider == 'pollinations':
provider = ImageProvider.POLLINATIONS
generation_request = ImageGenerationRequest(
prompt=image_prompt,
provider=provider,
width=width,
height=height,
quality="standard"
)
# 生成图片
result = await self.image_service.generate_image(generation_request)
if result.success and result.image_info:
from .url_service import build_image_url
absolute_url = build_image_url(
result.image_info.image_id,
width=result.image_info.metadata.width,
height=result.image_info.metadata.height,
)
slide_image = SlideImageInfo(
image_id=result.image_info.image_id,
absolute_url=absolute_url,
source=ImageSource.AI_GENERATED,
purpose=requirement.purpose,
content_description=requirement.description,
generation_prompt=image_prompt,
alt_text=f"AI生成图片 {i+1}",
title=f"AI生成图片 {i+1}",
width=width,
height=height,
format=getattr(result.image_info, 'format', 'png')
)
images.append(slide_image)
logger.info(f"AI生成第{i+1}张图片成功: {absolute_url}")
else:
logger.error(f"AI生成第{i+1}张图片失败: {result.message}")
logger.info(f"成功生成{len(images)}张AI图片")
return images
except Exception as e:
logger.error(f"处理AI生成图片失败: {e}")
return images
async def _search_multiple_local_images(self, keywords: str, count: int) -> List[str]:
"""搜索多张本地图片"""
try:
if not self.image_service:
return []
# 获取所有本地图片
gallery_result = await self.image_service.list_cached_images(page=1, per_page=100)
if not gallery_result.get('images'):
return []
# 将关键词分割成列表
keyword_list = keywords.lower().split()
# 计算所有图片的匹配分数
scored_images = []
for img in gallery_result['images']:
score = self._calculate_image_match_score(img, keyword_list)
if score > 0:
scored_images.append((img.get('image_id'), score))
# 按分数排序并选择前N张
scored_images.sort(key=lambda x: x[1], reverse=True)
selected_images = [img_id for img_id, _ in scored_images[:count]]
logger.info(f"从{len(gallery_result['images'])}张本地图片中选择了{len(selected_images)}张")
return selected_images
except Exception as e:
logger.error(f"搜索多张本地图片失败: {e}")
return []
async def _search_images_directly(
self,
query: str,
count: int,
*,
image_config: Optional[Dict[str, Any]] = None,
default_provider: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""使用配置的默认网络搜索提供商搜索图片"""
# 创建搜索缓存键
search_key = f"direct_{query}_{count}"
# 检查缓存
async with self._search_lock:
if search_key in self._search_cache:
logger.debug(f"使用缓存的直接搜索结果: {query}")
return self._search_cache[search_key]
try:
from .image.models import ImageSearchRequest
from .image.config.image_config import ImageServiceConfig
# 获取配置
config_manager = ImageServiceConfig()
config = config_manager.get_config()
provider_name = self._normalize_network_search_provider(
default_provider
or (image_config or {}).get("default_network_search_provider")
or "unsplash"
)
logger.debug(f"使用默认网络搜索提供商: {provider_name}")
# 根据配置的默认提供商创建相应的提供者
provider = None
if provider_name == 'pixabay':
pixabay_config = dict(config.get('pixabay', {}) or {})
pixabay_config['api_key'] = (image_config or {}).get('pixabay_api_key') or pixabay_config.get('api_key')
if not pixabay_config.get('api_key'):
logger.warning("Pixabay API key not configured")
return []
from .image.providers.pixabay_provider import PixabaySearchProvider
provider = PixabaySearchProvider(pixabay_config)
elif provider_name == 'searxng':
searxng_config = dict(config.get('searxng', {}) or {})
searxng_config['host'] = (image_config or {}).get('searxng_host') or searxng_config.get('host')
if not searxng_config.get('host'):
logger.warning("SearXNG host not configured")
return []
from .image.providers.searxng_image_provider import SearXNGSearchProvider
provider = SearXNGSearchProvider(searxng_config)
else: # 默认使用unsplash
unsplash_config = dict(config.get('unsplash', {}) or {})
unsplash_config['api_key'] = (image_config or {}).get('unsplash_access_key') or unsplash_config.get('api_key')
if not unsplash_config.get('api_key'):
logger.warning("Unsplash API key not configured")
return []
from .image.providers.unsplash_provider import UnsplashSearchProvider
provider = UnsplashSearchProvider(unsplash_config)
if not provider:
logger.error("无法创建网络搜索提供商")
return []
# 创建搜索请求
# 根据不同提供商调整per_page参数
if provider_name == 'pixabay':
# Pixabay API 要求 per_page 范围为 3-200
per_page = max(3, min(count, 200))
else:
# 其他提供商使用更宽松的限制
per_page = max(1, min(count, 50))
search_request = ImageSearchRequest(
query=query,
per_page=per_page,
page=1
)
# 执行搜索
search_result = await provider.search(search_request)
# 转换为旧格式以兼容现有代码
images = []
if search_result and search_result.images:
for image_info in search_result.images[:count]:
image_data = {
'id': image_info.image_id,
'webformatURL': image_info.original_url,
'largeImageURL': image_info.original_url,
'tags': ', '.join([tag.name for tag in (image_info.tags or [])]),
'user': image_info.author or 'Unknown',
'pageURL': image_info.source_url or '',
'imageWidth': image_info.metadata.width if image_info.metadata else 0,
'imageHeight': image_info.metadata.height if image_info.metadata else 0
}
images.append(image_data)
logger.debug(f"转换图片{len(images)}: {image_info.title[:50] if image_info.title else 'N/A'}...")
# 缓存结果
async with self._search_lock:
self._search_cache[search_key] = images
# 限制缓存大小
if len(self._search_cache) > 50:
oldest_key = next(iter(self._search_cache))
del self._search_cache[oldest_key]
logger.info(f"直接搜索获得{len(images)}张图片: {query}")
return images
except Exception as e:
logger.error(f"直接搜索失败: {e}")
import traceback
logger.error(f"搜索异常详情: {traceback.format_exc()}")
return []
async def _download_network_image_to_cache(self, image_data: Dict[str, Any], title: str) -> Optional[Dict[str, Any]]:
"""下载网络图片并上传到图床系统"""
try:
# 检查图片服务是否可用
if not self.image_service:
logger.error("图片服务未初始化,无法下载网络图片到缓存")
return None
# 获取图片URL
image_url = (image_data.get('webformatURL') or
image_data.get('url') or
image_data.get('largeImageURL') or
image_data.get('original_url'))
if not image_url:
logger.warning(f"网络图片URL为空,图片数据: {image_data}")
return None
# 下载图片数据
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status == 200:
image_data_bytes = await response.read()
# 获取文件扩展名
content_type = response.headers.get('content-type', 'image/jpeg')
if 'jpeg' in content_type or 'jpg' in content_type:
file_extension = 'jpg'
elif 'png' in content_type:
file_extension = 'png'
elif 'webp' in content_type:
file_extension = 'webp'
else:
file_extension = 'jpg' # 默认
# 创建上传请求
from .image.models import ImageUploadRequest
# 生成更好的描述和标签
description, tags = self._generate_image_metadata(image_data, title)
upload_request = ImageUploadRequest(
filename=f"{title}.{file_extension}",
content_type=content_type,
file_size=len(image_data_bytes),
title=title,
description=description,
tags=tags,
category="network_search",
source_type=ImageSourceType.WEB_SEARCH,
original_url=image_url
)
# 上传到图床系统
result = await self.image_service.upload_image(upload_request, image_data_bytes)
if result.success and result.image_info:
# 构建图床API的绝对URL
from .url_service import build_image_url
absolute_url = build_image_url(
result.image_info.image_id,
width=result.image_info.metadata.width,
height=result.image_info.metadata.height,
)
return {
'image_id': result.image_info.image_id,
'absolute_url': absolute_url,
'format': result.image_info.metadata.format.value,
'width': result.image_info.metadata.width,
'height': result.image_info.metadata.height
}
else:
logger.error(f"上传网络图片到图床失败: {result.message}")
return None
else:
logger.error(f"下载网络图片失败,状态码: {response.status}")
return None
except Exception as e:
logger.error(f"下载网络图片到图床失败: {e}")
return None
async def _download_network_image_to_cache_with_retry(self, image_data: Dict[str, Any], title: str, max_retries: int = 3) -> Optional[Dict[str, Any]]:
"""下载网络图片并上传到图床系统,带重试机制"""
for attempt in range(max_retries):
try:
result = await self._download_network_image_to_cache(image_data, title)
if result:
return result
else:
logger.warning(f"第{attempt + 1}次下载网络图片失败,准备重试")
if attempt < max_retries - 1:
# 等待一段时间后重试
await asyncio.sleep(1 * (attempt + 1)) # 递增等待时间
continue
except Exception as e:
logger.warning(f"第{attempt + 1}次下载网络图片异常: {e}")
if attempt < max_retries - 1:
await asyncio.sleep(1 * (attempt + 1)) # 递增等待时间
continue
else:
logger.error(f"所有{max_retries}次下载尝试都失败")
return None
def _generate_meaningful_image_title(self, image_data: Dict[str, Any], slide_title: str, index: int) -> str:
"""生成有意义的图片标题"""
try:
# 获取图片标签或描述
tags = image_data.get('tags', '')
description = image_data.get('description', '')
# 清理幻灯片标题,移除特殊字符
clean_slide_title = ''.join(c for c in slide_title if c.isalnum() or c in ' -_')
clean_slide_title = clean_slide_title.strip().replace(' ', '_')
# 如果有标签,使用前2个标签
if tags:
if isinstance(tags, str):
tag_list = [tag.strip() for tag in tags.split(',')[:2] if tag.strip()]
elif isinstance(tags, list):
tag_list = [str(tag).strip() for tag in tags[:2] if str(tag).strip()]
else:
tag_list = []
if tag_list:
# 清理标签
clean_tags = []
for tag in tag_list:
clean_tag = ''.join(c for c in tag if c.isalnum() or c in ' -_')
clean_tag = clean_tag.strip().replace(' ', '_')
if clean_tag and len(clean_tag) > 1:
clean_tags.append(clean_tag)
if clean_tags:
tags_part = '_'.join(clean_tags)
# 组合:幻灯片标题_标签_序号
if clean_slide_title:
title = f"{clean_slide_title}_{tags_part}_{index}"
else:
title = f"slide_{tags_part}_{index}"
# 限制长度
max_length = 60
if len(title) > max_length:
title = title[:max_length].rstrip('_')
return title
# 如果有描述但没有标签
if description:
clean_desc = ''.join(c for c in description[:20] if c.isalnum() or c in ' -_')
clean_desc = clean_desc.strip().replace(' ', '_')
if clean_desc:
if clean_slide_title:
return f"{clean_slide_title}_{clean_desc}_{index}"
else:
return f"slide_{clean_desc}_{index}"
# 默认命名
if clean_slide_title:
return f"{clean_slide_title}_image_{index}"
else:
return f"slide_image_{index}"
except Exception as e:
logger.warning(f"生成有意义图片标题失败: {e}")
# 回退到简单命名
return f"slide_image_{index}"
def _generate_image_metadata(self, image_data: Dict[str, Any], title: str) -> tuple[str, list]:
"""生成图片的描述和标签"""
try:
# 获取图片来源信息
source_info = ""
if 'user' in image_data:
source_info = f"作者: {image_data['user']}"
elif 'author' in image_data:
source_info = f"作者: {image_data['author']}"
# 获取图片统计信息
stats_info = ""
if 'views' in image_data or 'downloads' in image_data or 'likes' in image_data:
stats = []
if 'views' in image_data:
stats.append(f"浏览: {image_data['views']}")
if 'downloads' in image_data:
stats.append(f"下载: {image_data['downloads']}")
if 'likes' in image_data:
stats.append(f"点赞: {image_data['likes']}")
stats_info = " | ".join(stats)
# 获取尺寸信息
size_info = ""
width = image_data.get('webformatWidth') or image_data.get('imageWidth') or image_data.get('width')
height = image_data.get('webformatHeight') or image_data.get('imageHeight') or image_data.get('height')
if width and height:
size_info = f"尺寸: {width}x{height}"
# 组合描述
description_parts = [f"网络搜索图片: {title}"]
if source_info:
description_parts.append(source_info)
if size_info:
description_parts.append(size_info)
if stats_info:
description_parts.append(stats_info)
description = " | ".join(description_parts)
# 处理标签
tags = []
raw_tags = image_data.get('tags', '')
if raw_tags:
if isinstance(raw_tags, str):
# 分割字符串标签
tag_list = [tag.strip() for tag in raw_tags.replace(',', ' ').split() if tag.strip()]
elif isinstance(raw_tags, list):
# 处理列表标签
tag_list = [str(tag).strip() for tag in raw_tags if str(tag).strip()]
else:
tag_list = []
# 清理和去重标签
seen_tags = set()
for tag in tag_list:
clean_tag = tag.lower().strip()
if clean_tag and len(clean_tag) > 1 and clean_tag not in seen_tags:
seen_tags.add(clean_tag)
tags.append(clean_tag)
# 限制标签数量
tags = tags[:10]
# 添加默认标签
default_tags = ['网络图片', 'ppt素材']
for default_tag in default_tags:
if default_tag not in tags:
tags.append(default_tag)
return description, tags
except Exception as e:
logger.warning(f"生成图片元数据失败: {e}")
# 回退到简单元数据
simple_description = f"网络搜索图片: {title}"
simple_tags = ['网络图片', 'ppt素材']
return simple_description, simple_tags
async def _get_local_image_details(self, image_id: str) -> Dict[str, Any]:
"""获取本地图片详细信息"""
try:
if not self.image_service:
return {}
# 这里可以调用图片服务的方法获取详细信息
# 暂时返回基本信息
return {
'title': f'本地图片 {image_id}',
'width': None,
'height': None,
'file_size': None,
'format': None
}
except Exception as e:
logger.error(f"获取本地图片详细信息失败: {e}")
return {}
def _calculate_image_match_score(self, img: Dict[str, Any], keyword_list: List[str]) -> int:
"""计算图片匹配分数"""
score = 0
# 处理标题、文件名、标签
title = (img.get('title') or '').lower()
filename = (img.get('filename') or '').lower()
tags = img.get('tags', [])
if tags and len(tags) > 0 and hasattr(tags[0], 'name'):
tag_names = [tag.name.lower() for tag in tags]
else:
tag_names = [str(tag).lower() for tag in tags if tag]
# 标题匹配(权重最高)
for keyword in keyword_list:
if keyword in title:
score += 3
# 标签匹配(权重中等)
for keyword in keyword_list:
for tag in tag_names:
if keyword in tag or tag in keyword:
score += 2
break # 每个关键词只匹配一次
# 文件名匹配(权重较低)
for keyword in keyword_list:
if keyword in filename:
score += 1
return score
async def _ai_generate_local_search_keywords(self, slide_title: str, slide_content: str,
project_topic: str, project_scenario: str,
requirement: ImageRequirement = None) -> Optional[str]:
"""使用AI生成本地图片搜索关键词"""
try:
# 构建需求信息
requirement_info = ""
if requirement:
requirement_info = f"""
图片需求信息:
- 用途:{requirement.purpose.value}
- 描述:{requirement.description}
- 优先级:{requirement.priority}
"""
prompt = f"""作为专业的图片搜索专家,请为以下PPT幻灯片生成本地图片搜索关键词。
项目主题:{project_topic}
项目场景:{project_scenario}
幻灯片标题:{slide_title}
幻灯片内容:{slide_content}
{requirement_info}
要求:
1. 生成3-5个中英文关键词,用空格分隔
2. 关键词要准确描述所需图片的内容和主题
3. 考虑项目场景和图片用途,选择合适的图片风格
4. 优先选择具体的视觉元素和概念
5. 适合在本地图片库中进行标题、描述、标签匹配
示例格式:商务 会议 图表 business chart
请只回复关键词,不要其他内容:"""
response = await self._text_completion(
prompt=prompt,
temperature=0.5
)
search_keywords = response.content.strip()
logger.info(f"AI生成本地搜索关键词: {search_keywords}")
return search_keywords
except Exception as e:
logger.error(f"AI生成本地搜索关键词失败: {e}")
return None
async def _search_local_images_by_keywords(self, keywords: str) -> Optional[str]:
"""使用关键词搜索本地图片,返回相关度最高的图片ID"""
try:
if not self.image_service:
logger.warning("图片服务未初始化")
return None
# 将关键词分割成列表
keyword_list = keywords.lower().split()
# 获取所有本地图片
gallery_result = await self.image_service.list_cached_images(page=1, per_page=100)
if not gallery_result.get('images'):
return None
best_match = None
best_score = 0
for img in gallery_result['images']:
score = 0
image_id = img.get('image_id')
# 计算匹配分数
title = (img.get('title') or '').lower()
filename = (img.get('filename') or '').lower()
# 处理标签
tags = img.get('tags', [])
if tags and len(tags) > 0 and hasattr(tags[0], 'name'):
tag_names = [tag.name.lower() for tag in tags]
else:
tag_names = [str(tag).lower() for tag in tags if tag]
# 标题匹配(权重最高)
title_matches = 0
for keyword in keyword_list:
if keyword in title:
score += 3
title_matches += 1
# 标签匹配(权重中等)
tag_matches = 0
for keyword in keyword_list:
for tag in tag_names:
if keyword in tag or tag in keyword:
score += 2
tag_matches += 1
break # 每个关键词只匹配一次
# 文件名匹配(权重较低)
filename_matches = 0
for keyword in keyword_list:
if keyword in filename:
score += 1
filename_matches += 1
# 记录详细匹配信息
if score > 0:
logger.debug(f"图片 {image_id} 匹配分数: {score} (标题:{title_matches}, 标签:{tag_matches}, 文件名:{filename_matches})")
# 更新最佳匹配
if score > best_score:
best_score = score
best_match = image_id
logger.debug(f"更新最佳匹配: {best_match}, 新分数: {best_score}")
if best_match and best_score > 0:
logger.info(f"找到最佳匹配图片: {best_match}, 分数: {best_score}")
return best_match
else:
logger.info("未找到匹配的本地图片")
return None
except Exception as e:
logger.error(f"本地图片搜索失败: {e}")
return None
def _truncate_search_query(self, query: str, max_length: int = 100) -> str:
"""截断搜索查询以符合API限制,保持单词完整性"""
if not query or len(query) <= max_length:
return query
# 在最大长度内找到最后一个空格
truncated = query[:max_length]
last_space = truncated.rfind(' ')
if last_space > 0:
# 在最后一个空格处截断,保持单词完整
return truncated[:last_space]
else:
# 如果没有空格,直接截断
return truncated
async def _ai_generate_search_query(self, slide_title: str, slide_content: str,
project_topic: str, project_scenario: str,
requirement: ImageRequirement = None,
default_provider: Optional[str] = None) -> Optional[str]:
"""使用AI生成网络搜索关键词"""
try:
# 检测项目语言
project_language = self._detect_project_language(project_topic, slide_title, slide_content)
# 构建需求信息
requirement_info = ""
if requirement:
requirement_info = f"""
图片需求信息:
- 用途:{requirement.purpose.value}
- 描述:{requirement.description}
- 优先级:{requirement.priority}
"""
# 根据项目语言生成不同的提示词
if project_language == "zh":
language_instruction = "中文关键词"
example_format = "商务 会议 演示 图表"
search_instruction = "生成3-5个中文关键词,用空格分隔"
else:
language_instruction = "英文关键词"
example_format = "business meeting presentation chart"
search_instruction = "生成3-5个英文关键词,用空格分隔"
prompt = f"""作为专业的图片搜索专家,请为以下PPT幻灯片生成最佳的{language_instruction}
项目主题:{project_topic}
项目场景:{project_scenario}
幻灯片标题:{slide_title}
幻灯片内容:{slide_content}
{requirement_info}
要求:
1. {search_instruction},总长度不超过80个字符
2. 关键词要准确描述所需图片的内容和用途
3. 考虑项目场景和图片用途,选择合适的图片风格
4. 避免过于抽象的词汇,优先选择具体的视觉元素
5. 确保关键词适合在网络图片库中搜索
示例格式:{example_format}
请只回复关键词,不要其他内容:"""
response = await self._text_completion(
prompt=prompt,
temperature=0.5
)
search_query = response.content.strip()
# 根据不同提供商截断查询
provider_name = self._normalize_network_search_provider(default_provider) or "unsplash"
# Pixabay API的100字符限制,其他提供商使用更宽松的限制
max_length = 100 if provider_name == 'pixabay' else 200
truncated_query = self._truncate_search_query(search_query, max_length)
if len(search_query) > max_length:
logger.warning(f"搜索关键词过长,已截断: '{search_query}' -> '{truncated_query}'")
# logger.info(f"AI生成搜索关键词: {truncated_query}")
return truncated_query
except Exception as e:
logger.error(f"AI生成搜索关键词失败: {e}")
return None
def _detect_project_language(self, project_topic: str, slide_title: str, slide_content: str) -> str:
"""检测项目语言"""
# 合并所有文本内容
combined_text = f"{project_topic} {slide_title} {slide_content}"
# 检查是否包含中文字符
chinese_pattern = r'[\u4e00-\u9fff]'
if re.search(chinese_pattern, combined_text):
return "zh"
else:
return "en"
def _normalize_resolution_value(self, value: Any) -> Optional[tuple]:
"""将尺寸值规范化为(width, height)元组"""
if isinstance(value, str):
match = re.match(r"(\d+)\s*[x×]\s*(\d+)", value.strip())
if match:
try:
return int(match.group(1)), int(match.group(2))
except (TypeError, ValueError):
return None
if isinstance(value, dict):
width = value.get('width') or value.get('w')
height = value.get('height') or value.get('h')
try:
if width and height:
return int(width), int(height)
except (TypeError, ValueError):
return None
if isinstance(value, (list, tuple)) and len(value) >= 2:
try:
return int(value[0]), int(value[1])
except (TypeError, ValueError):
return None
return None
def _get_resolution_options(self, provider: str, image_config: Dict[str, Any]) -> List[tuple]:
"""获取指定提供商的可用分辨率列表(已去重、按配置优先顺序)"""
provider_key = (provider or image_config.get('default_ai_image_provider') or 'dalle').lower()
default_presets = {
'dalle': ["1792x1024", "1024x1792", "1024x1024"],
'openai_image': ["1536x1024", "1024x1536", "1024x1024"],
'siliconflow': ["1024x1024", "1024x2048", "1536x1024", "2048x1152", "1152x2048"],
'gemini': ["1024x1024", "1344x768", "768x1344"],
'pollinations': ["1024x1024", "1344x768", "768x1344", "1536x1024", "1024x1536"],
'default': ["1792x1024", "1024x1792", "1024x1024"]
}
presets = image_config.get('ai_image_resolution_presets')
parsed_presets = {}
if isinstance(presets, str) and presets.strip():
try:
parsed_presets = json.loads(presets)
except Exception as e:
logger.warning(f"Failed to parse ai_image_resolution_presets: {e}")
elif isinstance(presets, dict):
parsed_presets = presets
options: List[tuple] = []
provider_presets = parsed_presets.get(provider_key) if isinstance(parsed_presets, dict) else None
if provider_presets:
if not isinstance(provider_presets, list):
provider_presets = [provider_presets]
for value in provider_presets:
normalized = self._normalize_resolution_value(value)
if normalized:
options.append(normalized)
# 如果没有自定义预设,使用默认预设
if not options:
fallback_presets = default_presets.get(provider_key) or default_presets['default']
for value in fallback_presets:
normalized = self._normalize_resolution_value(value)
if normalized:
options.append(normalized)
# 允许从单值配置中注入优先尺寸(如dalle_image_size),添加到列表开头
provider_size_keys = {
'dalle': 'dalle_image_size',
'siliconflow': 'siliconflow_image_size',
}
size_key = provider_size_keys.get(provider_key)
if size_key and image_config.get(size_key):
normalized_size = self._normalize_resolution_value(image_config.get(size_key))
if normalized_size and normalized_size not in options:
options.insert(0, normalized_size)
# 去重并保持顺序
unique_options = []
for opt in options:
if opt not in unique_options:
unique_options.append(opt)
return unique_options
async def _ai_decide_image_dimensions(self, slide_title: str, slide_content: str,
project_topic: str, project_scenario: str,
requirement: ImageRequirement = None,
provider: Optional[str] = None,
image_config: Optional[Dict[str, Any]] = None) -> tuple:
"""使用AI决定图片的最佳尺寸"""
try:
config = image_config or {}
provider_key = (provider or config.get('default_ai_image_provider') or 'dalle').lower()
available_dimensions = self._get_resolution_options(provider_key, config)
if not available_dimensions:
available_dimensions = [(1792, 1024), (1024, 1792), (1024, 1024)]
# 限制选项数量,避免提示过长
if len(available_dimensions) > 6:
available_dimensions = available_dimensions[:6]
if len(available_dimensions) == 1:
selected_dimensions = available_dimensions[0]
logger.info(f"仅有一个可用尺寸,直接使用: {selected_dimensions[0]}x{selected_dimensions[1]} (提供商: {provider_key})")
return selected_dimensions
# 构建需求信息
requirement_info = ""
if requirement:
requirement_info = f"""
图片需求信息:
- 用途:{requirement.purpose.value}
- 描述:{requirement.description}
- 优先级:{requirement.priority}
"""
option_lines = []
for idx, (w, h) in enumerate(available_dimensions, start=1):
aspect = w / h
if aspect > 1.1:
orientation = "横向"
use_case = "横向展示、背景或宽屏内容"
elif aspect < 0.9:
orientation = "竖向"
use_case = "人物肖像、竖版海报或移动端展示"
else:
orientation = "正方形"
use_case = "产品展示、图标或社交媒体"
option_lines.append(f"{idx}. {w}x{h} ({orientation},适合{use_case})")
prompt = f"""作为专业的PPT设计师,请根据以下信息为图片选择最佳的尺寸规格。
项目信息:
- 主题:{project_topic}
- 场景:{project_scenario}
幻灯片信息:
- 标题:{slide_title}
- 内容:{slide_content}
{requirement_info}
可选尺寸规格:
当前图片提供商:{provider_key}
{chr(10).join(option_lines)}
请根据内容特点、用途和展示效果选择最合适的尺寸。
要求:
1. 考虑内容的视觉特点(横向/竖向/方形更适合)
2. 考虑图片用途(背景/装饰/说明/图标等)
3. 考虑PPT演示的整体效果
4. 只回复对应的数字编号或尺寸值(如 1792x1024),不要其他内容"""
response = await self._text_completion(
prompt=prompt,
temperature=0.3
)
choice_text = response.content.strip()
# 先尝试解析显式的尺寸值
selected_dimensions = available_dimensions[0]
size_match = re.search(r"(\d+)\s*[x×]\s*(\d+)", choice_text)
if size_match:
candidate = (int(size_match.group(1)), int(size_match.group(2)))
for dims in available_dimensions:
if dims == candidate:
selected_dimensions = dims
break
# 如果未匹配到尺寸值,再尝试编号
if selected_dimensions == available_dimensions[0]:
index_match = re.search(r"(\d+)", choice_text)
if index_match:
idx = int(index_match.group(1))
if 1 <= idx <= len(available_dimensions):
selected_dimensions = available_dimensions[idx - 1]
logger.info(f"AI选择图片尺寸: {selected_dimensions[0]}x{selected_dimensions[1]} (响应: {choice_text}, 提供商: {provider_key})")
return selected_dimensions
except Exception as e:
logger.error(f"AI决定图片尺寸失败: {e}")
fallback = available_dimensions[0] if 'available_dimensions' in locals() and available_dimensions else (1792, 1024)
return fallback # 默认尺寸
async def _ai_generate_image_prompt(self, slide_title: str, slide_content: str, project_topic: str,
project_scenario: str, page_number: int, total_pages: int,
template_html: str = "", requirement: ImageRequirement = None,
image_index: int = 1) -> Optional[str]:
"""使用AI生成图片生成提示词"""
try:
# 构建包含模板HTML的提示词
template_context = ""
if template_html.strip():
template_excerpt = strip_base64_image_payloads_for_prompt(template_html)[:500]
template_context = f"""
当前PPT模板HTML参考:
{template_excerpt}...
"""
# 构建需求信息
requirement_info = ""
if requirement:
requirement_info = f"""
图片需求信息:
- 用途:{requirement.purpose.value}
- 描述:{requirement.description}
- 优先级:{requirement.priority}
- 当前是第{image_index}张图片
"""
prompt = f"""作为专业的AI图片生成提示词专家,请为以下PPT幻灯片生成高质量的英文图片生成提示词。
项目信息:
- 主题:{project_topic}
- 场景:{project_scenario}
- 当前页:{page_number}/{total_pages}
幻灯片信息:
- 标题:{slide_title}
- 内容:{slide_content}
{requirement_info}
{template_context}
要求:
1. 生成详细的英文提示词,描述所需图片的视觉内容
2. 根据项目场景、图片用途和模板风格选择合适的风格
3. 包含具体的视觉元素描述,确保与模板风格协调
4. 确保图片适合PPT演示使用,符合指定用途
5. 考虑16:9或4:3的横向构图
6. 避免包含文字内容
7. 如果是多张图片中的一张,确保风格一致但内容有所区别
风格指导:
- business: professional, clean, modern office, corporate style
- technology: futuristic, digital, high-tech, innovation
- education: clear, informative, academic, learning environment
- general: clean, modern, professional presentation style
用途指导:
- decoration: 装饰性,美观、和谐、不抢夺主要内容焦点
- illustration: 说明性,直观、清晰、辅助理解内容
- background: 背景性,淡雅、不干扰前景内容
- icon: 图标性,简洁、符号化、易识别
- chart_support: 图表辅助,数据可视化、专业、清晰
- content_visual: 内容可视化,概念具象化、生动、准确
请生成一个完整的英文提示词(不超过120词),直接输出提示词,不要添加任何其他内容"""
response = await self._text_completion(
prompt=prompt,
temperature=0.7
)
image_prompt = response.content.strip()
logger.info(f"AI生成第{image_index}张图片提示词: {image_prompt}")
return image_prompt
except Exception as e:
logger.error(f"AI生成图片提示词失败: {e}")
return None
async def _ai_should_add_image(self, slide_data: Dict[str, Any], project_topic: str,
project_scenario: str, page_number: int, total_pages: int) -> bool:
"""使用AI判断该页是否需要或适合插入图片"""
try:
# 提取幻灯片内容信息
slide_title = slide_data.get('title', '')
slide_content = slide_data.get('content_points', [])
slide_content_text = '\n'.join(slide_content) if isinstance(slide_content, list) else str(slide_content)
content_length = len(slide_content_text.strip())
content_points_count = len(slide_content) if isinstance(slide_content, list) else 0
prompt = f"""作为专业的PPT设计师,请根据以下标准判断该幻灯片是否需要插入配图:
【项目信息】
- 主题:{project_topic}
- 场景:{project_scenario}
- 当前页:{page_number}/{total_pages}
【幻灯片内容】
- 标题:{slide_title}
- 内容要点数量:{content_points_count}
- 内容字数:{content_length}
- 具体内容:
{slide_content_text}
【判断标准】
请综合考虑以下因素:
1. 内容丰富程度:
- 内容过少(<50字或<3个要点):建议添加图片增强视觉效果
- 内容适中(50-200字,3-6个要点):根据内容性质判断
- 内容丰富(>200字或>6个要点):通常不需要额外图片
2. 理解难度:
- 抽象概念、复杂流程、技术原理:需要图片辅助理解
- 数据统计、对比分析:适合图表或图示
- 简单陈述、常识内容:通常不需要图片
3. 内容类型:
- 封面页、章节页:通常需要装饰性图片
- 总结页、结论页:根据内容量判断
- 纯文字列表:可能需要图片平衡版面
- 已有充实内容的页面:通常不需要额外图片
4. 视觉平衡:
- 页面显得空旷:需要图片填充
- 文字密集:不建议添加图片
- 版面协调:根据整体设计需要
请基于以上标准进行专业判断,只回复"是"或"否":"""
response = await self._text_completion(
prompt=prompt,
temperature=0.7
)
# logger.info(f"AI判断是否需要图片的回复: {response.content}")
decision = response.content.strip().lower()
should_add = decision in ['是', 'yes', 'true', '需要', '适合']
logger.info(f"AI判断第{page_number}页是否需要图片: {decision} -> {should_add}")
return should_add
except Exception as e:
logger.error(f"AI判断是否添加图片失败: {e}")
# 出错时默认不添加图片,避免不必要的处理
return False
async def _insert_images_into_slide(self, slide_html: str, images_collection: SlideImagesCollection, slide_title: str) -> str:
"""AI智能将生成的图片插入到幻灯片HTML中"""
try:
if not images_collection or not images_collection.images:
logger.warning("没有图片需要插入")
return slide_html
# 准备图片信息
images_info = []
for i, image in enumerate(images_collection.images):
image_info = {
"index": i + 1,
"url": image.absolute_url,
"description": image.content_description or f"配图{i+1}",
"alt_text": image.alt_text or f"配图{i+1}",
"title": image.title or f"AI生成配图{i+1}",
"source": image.source.value,
"width": image.width,
"height": image.height
}
images_info.append(image_info)
# 构建AI提示词
prompt = f"""作为专业的网页设计师,请分析以下幻灯片HTML结构,并智能地将提供的图片融入到页面内。
幻灯片标题:{slide_title}
当前HTML结构:
```html
{slide_html}
```
需要插入的图片信息:
{images_info}
要求:
- 请在HTML中合理使用这些图片资源
- 图片地址已经是绝对地址,可以直接使用
- 根据图片用途、内容描述和实际尺寸选择合适的位置和样式
- 充分利用图片的尺寸信息(宽度x高度)来优化布局设计
- 根据图片文件大小和格式选择合适的显示策略
- 确保图片与页面内容和设计风格协调
- 可以使用CSS对图片进行适当的样式调整(大小、位置、边框等)
**重要输出格式要求**:
- 必须使用markdown代码块格式返回HTML代码
- 格式:```html\\n[HTML代码]\\n```
- HTML代码必须以<!DOCTYPE html>开始,以</html>结束
- 不要在代码块前后添加任何解释文字
- **页眉页脚保持原样**
"""
# 调用AI进行智能插入。该步骤会让模型重写整页HTML,可能较慢;设置超时避免一键配图一直卡住。
logger.info(
"开始AI智能插入图片到幻灯片: title=%s, image_count=%s, html_length=%s",
slide_title,
len(images_collection.images),
len(slide_html or ""),
)
try:
response = await asyncio.wait_for(
self._text_completion(
prompt=prompt,
temperature=0.3,
),
timeout=45,
)
logger.info("AI智能插入图片响应完成: title=%s", slide_title)
except asyncio.TimeoutError:
logger.warning("AI智能插入图片超时,改用默认插入逻辑: title=%s", slide_title)
return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title)
# 提取markdown代码块中的HTML内容
updated_html = self._extract_html_from_markdown_response(response.content.strip())
if not updated_html:
logger.warning("无法从AI响应中提取HTML内容,使用默认插入逻辑")
return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title)
# 验证返回的HTML是否有效
if self._validate_html_structure(updated_html):
logger.info(f"AI成功插入{len(images_collection.images)}张图片到幻灯片中")
return updated_html
else:
logger.warning("AI返回的HTML结构无效,使用默认插入逻辑")
return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title)
except Exception as e:
logger.error(f"AI智能插入图片失败: {e}")
logger.info("回退到默认插入逻辑")
return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title)
def _extract_html_from_markdown_response(self, response_content: str) -> str:
"""从AI响应中提取markdown代码块中的HTML内容"""
try:
import re
# 查找markdown代码块 ```html ... ```
html_pattern = r'```html\s*\n(.*?)\n```'
match = re.search(html_pattern, response_content, re.DOTALL | re.IGNORECASE)
if match:
html_content = match.group(1).strip()
logger.debug(f"成功提取HTML内容,长度: {len(html_content)} 字符")
return html_content
# 如果没找到标准格式,尝试查找其他可能的格式
# 查找 ```html ... ``` (不区分大小写)
html_pattern2 = r'```(?:html|HTML)\s*\n?(.*?)\n?```'
match2 = re.search(html_pattern2, response_content, re.DOTALL)
if match2:
html_content = match2.group(1).strip()
logger.debug(f"使用备用模式提取HTML内容,长度: {len(html_content)} 字符")
return html_content
# 如果还是没找到,尝试查找任何代码块
code_pattern = r'```\s*\n?(.*?)\n?```'
match3 = re.search(code_pattern, response_content, re.DOTALL)
if match3:
potential_html = match3.group(1).strip()
# 检查是否看起来像HTML
if ('<!DOCTYPE html>' in potential_html or
'<html' in potential_html or
'<div' in potential_html or
'<body' in potential_html):
logger.debug(f"从通用代码块中提取HTML内容,长度: {len(potential_html)} 字符")
return potential_html
# 最后尝试:如果响应内容本身就是HTML(没有代码块包装)
if ('<!DOCTYPE html>' in response_content or
'<html' in response_content or
('<div' in response_content and '</' in response_content)):
logger.debug("响应内容本身就是HTML格式")
return response_content.strip()
logger.warning("无法从AI响应中提取HTML内容")
logger.debug(f"AI响应内容预览: {response_content[:200]}...")
return ""
except Exception as e:
logger.error(f"提取HTML内容失败: {e}")
return ""
def _validate_html_structure(self, html: str) -> bool:
"""验证HTML结构是否有效"""
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
# 检查基本结构 - 至少要有一个容器元素
container_elements = soup.find_all(['body', 'div', 'section', 'main', 'article'])
if not container_elements:
return False
# 检查是否包含图片元素
img_elements = soup.find_all('img')
if not img_elements:
return False
# 检查HTML长度是否合理(不能太短或太长)
if len(html.strip()) < 50 or len(html.strip()) > 50000:
return False
# 检查图片元素是否有有效的src属性
valid_images = 0
for img in img_elements:
src = img.get('src', '').strip()
if src and (src.startswith('http') or src.startswith('/') or src.startswith('data:')):
valid_images += 1
if valid_images == 0:
return False
return True
except Exception as e:
logger.error(f"HTML验证失败: {e}")
return False
async def _insert_images_with_default_logic(self, slide_html: str, images_collection: SlideImagesCollection, slide_title: str) -> str:
"""使用默认逻辑插入图片(备用方案)"""
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(slide_html, 'html.parser')
# 查找合适的插入位置
# 1. 优先查找现有的图片容器
img_containers = soup.find_all(['div', 'section'], class_=lambda x: x and any(
keyword in x.lower() for keyword in ['image', 'img', 'picture', 'photo', 'visual']
))
# 2. 查找内容区域
content_areas = soup.find_all(['div', 'section'], class_=lambda x: x and any(
keyword in x.lower() for keyword in ['content', 'main', 'body', 'text']
))
# 3. 查找标题后的位置
title_elements = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
inserted_count = 0
for i, image in enumerate(images_collection.images):
if inserted_count >= 3: # 最多插入3张图片
break
# 创建图片元素
img_element = soup.new_tag('img')
img_element['src'] = image.absolute_url
img_element['alt'] = image.alt_text or f"配图{i+1}"
img_element['title'] = image.title or f"AI生成配图{i+1}"
img_element['style'] = "max-width: 100%; height: auto; border-radius: 8px; margin: 10px 0;"
# 创建图片容器
img_container = soup.new_tag('div')
img_container['class'] = 'auto-generated-image-container'
img_container['style'] = "text-align: center; margin: 20px 0; padding: 10px;"
img_container.append(img_element)
# 添加图片说明
if image.content_description:
caption = soup.new_tag('p')
caption['style'] = "font-size: 0.9em; color: #666; margin-top: 8px; font-style: italic;"
caption.string = image.content_description
img_container.append(caption)
# 选择插入位置
inserted = False
# 方法1: 插入到现有图片容器中
if img_containers and not inserted:
target_container = img_containers[min(i, len(img_containers) - 1)]
target_container.clear()
target_container.append(img_container)
inserted = True
logger.info(f"图片{i+1}插入到现有图片容器中")
# 方法2: 插入到内容区域
elif content_areas and not inserted:
target_area = content_areas[0]
# 在内容区域的末尾插入
target_area.append(img_container)
inserted = True
logger.info(f"图片{i+1}插入到内容区域")
# 方法3: 插入到标题后
elif title_elements and not inserted:
title_element = title_elements[0]
title_element.insert_after(img_container)
inserted = True
logger.info(f"图片{i+1}插入到标题后")
# 方法4: 插入到body末尾
elif not inserted:
body = soup.find('body')
if body:
body.append(img_container)
inserted = True
logger.info(f"图片{i+1}插入到body末尾")
if inserted:
inserted_count += 1
logger.info(f"默认逻辑成功插入{inserted_count}张图片到幻灯片中")
return str(soup)
except Exception as e:
logger.error(f"默认插入图片逻辑失败: {e}")
return slide_html