| """ |
| Export helpers extracted from the legacy web router. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import os |
| import re |
| import tempfile |
| import urllib.parse |
| import urllib.request |
| import zipfile |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional |
|
|
| from fastapi import Request |
| from pydantic import BaseModel |
|
|
| from ...core.config import ai_config |
| from ...services.pyppeteer_pdf_converter import get_pdf_converter |
| from ...utils.thread_pool import run_blocking_io |
| from .support import logger |
|
|
|
|
| def _coerce_bool(value) -> bool: |
| if isinstance(value, bool): |
| return value |
| if value is None: |
| return False |
| return str(value).strip().lower() in {"1", "true", "yes", "on"} |
|
|
|
|
| async def _is_standard_pptx_export_enabled() -> bool: |
| """Return whether Apryse-based standard PPTX export is enabled system-wide.""" |
| feature_enabled = _coerce_bool(getattr(ai_config, "enable_apryse_pptx_export", False)) |
| license_key = str(getattr(ai_config, "apryse_license_key", "") or "").strip() |
|
|
| try: |
| from ...services.db_config_service import get_db_config_service |
|
|
| config_service = get_db_config_service() |
| system_config = await config_service.get_all_config(user_id=None) |
| feature_enabled = _coerce_bool(system_config.get("enable_apryse_pptx_export", feature_enabled)) |
| license_key = str(system_config.get("apryse_license_key") or license_key or "").strip() |
| except Exception as exc: |
| logger.warning("Failed to resolve Apryse PPTX export state from DB config: %s", exc) |
|
|
| return feature_enabled and bool(license_key) |
|
|
|
|
| def _strip_default_port(host: str, scheme: str) -> str: |
| """Strip default port from a host string.""" |
| host = (host or "").strip() |
| scheme = (scheme or "").strip().lower() |
| if not host: |
| return host |
|
|
| try: |
| parsed = urllib.parse.urlsplit(f"{scheme or 'http'}://{host}") |
| hostname = parsed.hostname or host |
| port = parsed.port |
| if port is None: |
| return host |
| if (scheme == "http" and port == 80) or (scheme == "https" and port == 443): |
| if ":" in hostname and not hostname.startswith("["): |
| return f"[{hostname}]" |
| return hostname |
| except Exception: |
| return host |
|
|
| return host |
|
|
|
|
| def _normalize_base_url_candidate(raw_url: Optional[str], default_scheme: str = "https") -> Optional[str]: |
| """Normalize a base-URL candidate to scheme://host[:port].""" |
| value = (str(raw_url).strip() if raw_url is not None else "") |
| if not value: |
| return None |
|
|
| if value.startswith("//"): |
| value = f"{default_scheme}:{value}" |
| elif "://" not in value: |
| value = f"{default_scheme}://{value.lstrip('/')}" |
|
|
| parsed = urllib.parse.urlsplit(value) |
| scheme = (parsed.scheme or default_scheme or "https").strip().lower() |
| host = (parsed.netloc or "").strip() |
| if not host: |
| return None |
|
|
| if "," in host: |
| host = host.split(",", 1)[0].strip() |
| host = _strip_default_port(host, scheme) |
| if not host: |
| return None |
| return f"{scheme}://{host}" |
|
|
|
|
| def _is_loopback_base_url(base_url: Optional[str]) -> bool: |
| """Return True when the URL points at a loopback/local host.""" |
| if not base_url: |
| return True |
| try: |
| parsed = urllib.parse.urlsplit(base_url) |
| hostname = (parsed.hostname or "").strip().lower() |
| return hostname in {"localhost", "127.0.0.1", "0.0.0.0", "::1"} |
| except Exception: |
| return False |
|
|
|
|
| def _resolve_export_base_url(http_request: Optional[Request] = None) -> str: |
| """Resolve the public base URL used by file-based export renderers.""" |
| request_candidates: List[str] = [] |
| config_candidates: List[str] = [] |
|
|
| def add_candidate(target: List[str], raw_url: Optional[str], *, default_scheme: str = "https") -> None: |
| normalized = _normalize_base_url_candidate(raw_url, default_scheme=default_scheme) |
| if normalized and normalized not in target: |
| target.append(normalized) |
|
|
| try: |
| if http_request is not None: |
| headers = http_request.headers |
| request_scheme = (http_request.url.scheme or "https").strip().lower() |
|
|
| add_candidate(request_candidates, headers.get("origin"), default_scheme=request_scheme) |
|
|
| referer = headers.get("referer") |
| if referer: |
| try: |
| referer_parts = urllib.parse.urlsplit(referer) |
| add_candidate(request_candidates, f"{referer_parts.scheme}://{referer_parts.netloc}", default_scheme=request_scheme) |
| except Exception: |
| pass |
|
|
| forwarded_host = (headers.get("x-forwarded-host") or "").strip() |
| forwarded_proto = (headers.get("x-forwarded-proto") or request_scheme).strip().lower() |
| forwarded_port = (headers.get("x-forwarded-port") or "").strip() |
| if "," in forwarded_host: |
| forwarded_host = forwarded_host.split(",", 1)[0].strip() |
| if "," in forwarded_proto: |
| forwarded_proto = forwarded_proto.split(",", 1)[0].strip() |
| if forwarded_host and forwarded_port and ":" not in forwarded_host: |
| forwarded_host = f"{forwarded_host}:{forwarded_port}" |
| add_candidate(request_candidates, forwarded_host, default_scheme=forwarded_proto or request_scheme) |
|
|
| host = (headers.get("host") or http_request.url.netloc or "").strip() |
| if host: |
| add_candidate(request_candidates, host, default_scheme=request_scheme) |
|
|
| if getattr(http_request, "base_url", None): |
| add_candidate(request_candidates, str(http_request.base_url), default_scheme=request_scheme) |
| except Exception: |
| pass |
|
|
| try: |
| from ...services.url_service import get_current_base_url |
|
|
| add_candidate(config_candidates, get_current_base_url()) |
| except Exception: |
| pass |
|
|
| for candidate in request_candidates: |
| if not _is_loopback_base_url(candidate): |
| return candidate |
|
|
| for candidate in config_candidates: |
| if not _is_loopback_base_url(candidate): |
| return candidate |
|
|
| if request_candidates: |
| return request_candidates[0] |
|
|
| raise ValueError("Unable to resolve export base URL from request headers or app configuration") |
|
|
|
|
| def _build_export_app_url(base_url: str, relative_path: str) -> str: |
| """Build an absolute app URL using the resolved export base URL.""" |
| normalized_path = "/" + (relative_path or "").lstrip("/") |
| return urllib.parse.urljoin(f"{base_url.rstrip('/')}/", normalized_path.lstrip("/")) |
|
|
|
|
| _APP_EXPORTABLE_PATH_PREFIXES = ( |
| "/api/image/view/", |
| "/api/image/thumbnail/", |
| "/static/", |
| "/temp/", |
| ) |
|
|
|
|
| def _is_app_exportable_path(path: str) -> bool: |
| normalized_path = "/" + str(path or "").lstrip("/") |
| return any(normalized_path.startswith(prefix) for prefix in _APP_EXPORTABLE_PATH_PREFIXES) |
|
|
|
|
| def _resolve_export_absolute_resource_url(raw_url: str, base_url: str) -> str: |
| """将误写成宿主机 localhost 的应用资源地址改写为导出进程可达地址。""" |
| try: |
| parsed = urllib.parse.urlsplit(raw_url) |
| except Exception: |
| return raw_url |
|
|
| if (parsed.scheme or "").lower() not in {"http", "https"}: |
| return raw_url |
|
|
| hostname = (parsed.hostname or "").strip().lower() |
| if hostname not in {"localhost", "127.0.0.1", "0.0.0.0"}: |
| return raw_url |
|
|
| if not _is_app_exportable_path(parsed.path): |
| return raw_url |
|
|
| relative_path = parsed.path or "/" |
| if parsed.query: |
| relative_path = f"{relative_path}?{parsed.query}" |
| if parsed.fragment: |
| relative_path = f"{relative_path}#{parsed.fragment}" |
| return _build_export_app_url(base_url, relative_path) |
|
|
|
|
| def _file_url_to_path(raw_url: str) -> Optional[Path]: |
| """Convert a file:// URL to a local filesystem path.""" |
| try: |
| parsed = urllib.parse.urlsplit(raw_url) |
| if (parsed.scheme or "").lower() != "file": |
| return None |
|
|
| pathname = urllib.request.url2pathname(parsed.path or "") |
| if parsed.netloc and parsed.netloc.lower() != "localhost": |
| pathname = f"//{parsed.netloc}{pathname}" |
|
|
| if os.name == "nt" and pathname.startswith("/") and re.match(r"^/[A-Za-z]:[/\\\\]", pathname): |
| pathname = pathname[1:] |
|
|
| return Path(pathname).resolve(strict=False) |
| except Exception: |
| return None |
|
|
|
|
| def _resolve_export_file_resource_url(raw_url: str, base_url: str) -> str: |
| """Rewrite app-owned local file URLs to public URLs usable from export renderers.""" |
| local_path = _file_url_to_path(raw_url) |
| if local_path is None: |
| return raw_url |
|
|
| try: |
| static_root = (Path(__file__).resolve().parent.parent / "static").resolve() |
| if local_path.is_relative_to(static_root): |
| relative_path = local_path.relative_to(static_root).as_posix() |
| quoted = urllib.parse.quote(relative_path, safe="/") |
| return _build_export_app_url(base_url, f"/static/{quoted}") |
| except Exception: |
| pass |
|
|
| try: |
| from ...services.image.image_service import get_image_service |
|
|
| image_service = get_image_service() |
| cache_index = getattr(getattr(image_service, "cache_manager", None), "_cache_index", {}) or {} |
| for cache_key, cache_info in cache_index.items(): |
| file_path = getattr(cache_info, "file_path", None) |
| if not file_path: |
| continue |
| try: |
| if Path(file_path).resolve(strict=False) == local_path: |
| quoted_key = urllib.parse.quote(str(cache_key), safe="") |
| return _build_export_app_url(base_url, f"/api/image/view/{quoted_key}") |
| except Exception: |
| continue |
| except Exception: |
| pass |
|
|
| return raw_url |
|
|
|
|
| def _resolve_export_resource_url(raw_url: str, base_url: str) -> str: |
| """Convert export-time relative resource URLs into absolute URLs.""" |
| if not isinstance(raw_url, str): |
| return raw_url |
|
|
| candidate = raw_url.strip() |
| if not candidate: |
| return raw_url |
|
|
| lowered = candidate.lower() |
| if lowered.startswith(("#", "data:", "blob:", "javascript:", "mailto:", "tel:", "about:")): |
| return raw_url |
| if lowered.startswith(("http://", "https://")): |
| return _resolve_export_absolute_resource_url(candidate, base_url) |
| if lowered.startswith("file://"): |
| return _resolve_export_file_resource_url(candidate, base_url) |
| if candidate.startswith("//"): |
| base_scheme = urllib.parse.urlparse(base_url).scheme or "http" |
| return f"{base_scheme}:{candidate}" |
|
|
| joined = urllib.parse.urljoin(f"{base_url.rstrip('/')}/", candidate) |
| return joined or candidate |
|
|
|
|
| def _rewrite_export_css_urls(css_text: str, base_url: str) -> str: |
| """Rewrite url(...) references in CSS so file:// exports can still fetch assets.""" |
| if not isinstance(css_text, str) or "url(" not in css_text.lower(): |
| return css_text |
|
|
| def replace_match(match: re.Match) -> str: |
| prefix = match.group(1) |
| raw_value = (match.group(2) or "").strip() |
| suffix = match.group(3) |
|
|
| quote = "" |
| inner = raw_value |
| if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'): |
| quote = raw_value[0] |
| inner = raw_value[1:-1] |
|
|
| absolute_url = _resolve_export_resource_url(inner, base_url) |
| return f"{prefix}{quote}{absolute_url}{quote}{suffix}" |
|
|
| return re.sub(r"(url\(\s*)([^)]+?)(\s*\))", replace_match, css_text, flags=re.IGNORECASE) |
|
|
|
|
| def _rewrite_export_srcset(srcset_value: str, base_url: str) -> str: |
| """Rewrite each candidate in srcset to an absolute URL.""" |
| if not isinstance(srcset_value, str) or not srcset_value.strip(): |
| return srcset_value |
|
|
| rewritten_candidates: List[str] = [] |
| for candidate in srcset_value.split(","): |
| item = candidate.strip() |
| if not item: |
| continue |
| parts = item.split() |
| if not parts: |
| continue |
| parts[0] = _resolve_export_resource_url(parts[0], base_url) |
| rewritten_candidates.append(" ".join(parts)) |
| return ", ".join(rewritten_candidates) |
|
|
|
|
| def _html_uses_tailwind_utilities(html_content: str) -> bool: |
| """Best-effort detection for Tailwind utility class usage.""" |
| if not isinstance(html_content, str) or "class" not in html_content.lower(): |
| return False |
|
|
| utility_pattern = re.compile( |
| r"^(?:" |
| r"container|sr-only|not-sr-only|block|inline|inline-block|inline-flex|flex|inline-grid|grid|hidden|contents|" |
| r"absolute|relative|fixed|sticky|static|" |
| r"(?:top|right|bottom|left|inset|z)-[\w./\[\]-]+|" |
| r"(?:m|mx|my|mt|mr|mb|ml|p|px|py|pt|pr|pb|pl|w|min-w|max-w|h|min-h|max-h|" |
| r"gap|space-x|space-y|basis|grow|shrink|order|col|row|" |
| r"text|font|leading|tracking|bg|from|via|to|border|rounded|shadow|opacity|" |
| r"items|justify|content|self|place|object|overflow|overscroll|whitespace|break|" |
| r"aspect|ring|fill|stroke|list|underline|line-clamp|animate|duration|delay|ease|" |
| r"scale|rotate|translate|skew)-[\w./:%\[\]-]+|" |
| r"(?:prose|antialiased|subpixel-antialiased|uppercase|lowercase|capitalize|truncate|underline|no-underline|italic|not-italic|" |
| r"pointer-events-none|pointer-events-auto|select-none|select-text|align-middle|align-top|align-bottom)" |
| r")$", |
| re.IGNORECASE, |
| ) |
|
|
| for match in re.finditer(r'class\s*=\s*["\']([^"\']+)["\']', html_content, flags=re.IGNORECASE): |
| class_value = match.group(1) or "" |
| for token in re.split(r"\s+", class_value.strip()): |
| if token and utility_pattern.match(token): |
| return True |
| return False |
|
|
|
|
| def _strip_unused_tailwind_cdn(html_content: str) -> str: |
| """Remove Tailwind Play CDN when the document does not appear to use Tailwind utilities.""" |
| if not isinstance(html_content, str) or "cdn.tailwindcss.com" not in html_content.lower(): |
| return html_content |
| if _html_uses_tailwind_utilities(html_content): |
| return html_content |
|
|
| cleaned = re.sub( |
| r'<script\b[^>]*src=["\']https://cdn\.tailwindcss\.com(?:/)?[^"\']*["\'][^>]*>\s*</script>', |
| '', |
| html_content, |
| flags=re.IGNORECASE | re.DOTALL, |
| ) |
| if cleaned != html_content: |
| cleaned = re.sub( |
| r'<script\b(?![^>]*\bsrc=)[^>]*>\s*tailwind\.config\s*=.*?</script>', |
| '', |
| cleaned, |
| flags=re.IGNORECASE | re.DOTALL, |
| ) |
| return cleaned |
|
|
|
|
| def _prepare_html_for_file_based_export(html_content: str, base_url: str) -> str: |
| """Normalize resource URLs before rendering HTML from a local temp file.""" |
| if not isinstance(html_content, str) or not html_content.strip(): |
| return html_content |
|
|
| prepared = _strip_unused_tailwind_cdn(html_content) |
| normalized_base_url = base_url.rstrip("/") |
| base_href = f"{normalized_base_url}/" |
|
|
| if re.search(r"<base\b", prepared, flags=re.IGNORECASE): |
| prepared = re.sub( |
| r"(<base\b[^>]*\bhref\s*=\s*)(['\"])(.*?)(\2)", |
| lambda match: f"{match.group(1)}{match.group(2)}{base_href}{match.group(2)}", |
| prepared, |
| count=1, |
| flags=re.IGNORECASE | re.DOTALL, |
| ) |
| elif re.search(r"<head\b[^>]*>", prepared, flags=re.IGNORECASE): |
| prepared = re.sub( |
| r"(<head\b[^>]*>)", |
| lambda match: f'{match.group(1)}<base href="{base_href}">', |
| prepared, |
| count=1, |
| flags=re.IGNORECASE, |
| ) |
|
|
| def rewrite_attr(attr_name: str, transform) -> None: |
| nonlocal prepared |
| attr_token = re.escape(attr_name) |
| quoted_pattern = rf"((?<![\w:-]){attr_token}\s*=\s*)([\"'])(.*?)(\2)" |
| unquoted_pattern = rf"((?<![\w:-]){attr_token}\s*=\s*)(?![\"'])([^\s>]+)" |
|
|
| prepared = re.sub( |
| quoted_pattern, |
| lambda match: f"{match.group(1)}{match.group(2)}{transform(match.group(3), normalized_base_url)}{match.group(2)}", |
| prepared, |
| flags=re.IGNORECASE | re.DOTALL, |
| ) |
| prepared = re.sub( |
| unquoted_pattern, |
| lambda match: f"{match.group(1)}{transform(match.group(2), normalized_base_url)}", |
| prepared, |
| flags=re.IGNORECASE, |
| ) |
|
|
| for attr in ("src", "href", "poster", "data-src", "data-href", "xlink:href"): |
| rewrite_attr(attr, _resolve_export_resource_url) |
|
|
| rewrite_attr("srcset", _rewrite_export_srcset) |
| rewrite_attr("style", _rewrite_export_css_urls) |
|
|
| prepared = re.sub( |
| r"(<style\b[^>]*>)(.*?)(</style>)", |
| lambda match: f"{match.group(1)}{_rewrite_export_css_urls(match.group(2), normalized_base_url)}{match.group(3)}", |
| prepared, |
| flags=re.IGNORECASE | re.DOTALL, |
| ) |
|
|
| return prepared |
|
|
|
|
| class ImagePPTXExportRequest(BaseModel): |
| slides: Optional[List[Dict[str, Any]]] = None |
| images: Optional[List[Dict[str, Any]]] = None |
|
|
|
|
| ImagePPTXExportRequest.model_rebuild() |
|
|
|
|
| def _generate_html_export_sync(project) -> bytes: |
| """同步生成HTML导出文件(在线程池中运行)""" |
| with tempfile.TemporaryDirectory() as temp_dir: |
| temp_path = Path(temp_dir) |
|
|
| |
| slide_files = [] |
| for i, slide in enumerate(project.slides_data): |
| slide_filename = f"slide_{i+1}.html" |
| slide_files.append(slide_filename) |
|
|
| |
| slide_html = _generate_individual_slide_html_sync(slide, i+1, len(project.slides_data), project.topic) |
|
|
| slide_path = temp_path / slide_filename |
| with open(slide_path, 'w', encoding='utf-8') as f: |
| f.write(slide_html) |
|
|
| |
| index_html = _generate_slideshow_index_sync(project, slide_files) |
| index_path = temp_path / "index.html" |
| with open(index_path, 'w', encoding='utf-8') as f: |
| f.write(index_html) |
|
|
| |
| zip_filename = f"{project.topic}_PPT.zip" |
| zip_path = temp_path / zip_filename |
|
|
| with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: |
| |
| zipf.write(index_path, "index.html") |
|
|
| |
| for slide_file in slide_files: |
| slide_path = temp_path / slide_file |
| zipf.write(slide_path, slide_file) |
|
|
| |
| with open(zip_path, 'rb') as f: |
| return f.read() |
|
|
|
|
| def _generate_individual_slide_html_sync(slide, slide_number: int, total_slides: int, topic: str) -> str: |
| """同步生成单个幻灯片HTML(在线程池中运行)""" |
| slide_html = slide.get('html_content', '') |
| slide_title = slide.get('title', f'第{slide_number}页') |
|
|
| |
| import re |
| if slide_html.strip().lower().startswith('<!doctype') or slide_html.strip().lower().startswith('<html'): |
| |
| return _enhance_complete_html_with_navigation(slide_html, slide_number, total_slides, topic, slide_title) |
| else: |
| |
| slide_content = slide_html |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{topic} - {slide_title}</title> |
| <style> |
| body {{ |
| margin: 0; |
| padding: 0; |
| font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; |
| background: #f5f5f5; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| min-height: 100vh; |
| }} |
| .slide-container {{ |
| width: 90vw; |
| height: 90vh; |
| background: white; |
| border-radius: 10px; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.1); |
| overflow: hidden; |
| position: relative; |
| }} |
| .slide-content {{ |
| width: 100%; |
| height: 100%; |
| padding: 20px; |
| box-sizing: border-box; |
| }} |
| .slide-number {{ |
| position: absolute; |
| bottom: 20px; |
| right: 20px; |
| background: rgba(0,0,0,0.7); |
| color: white; |
| padding: 5px 10px; |
| border-radius: 5px; |
| font-size: 14px; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="slide-container"> |
| <div class="slide-content"> |
| {slide_content} |
| </div> |
| <div class="slide-number">{slide_number} / {total_slides}</div> |
| </div> |
| </body> |
| </html>""" |
|
|
|
|
| def _generate_slideshow_index_sync(project, slide_files: list) -> str: |
| """同步生成幻灯片索引页面(在线程池中运行)""" |
| slides_list = "" |
| for i, slide_file in enumerate(slide_files): |
| slide = project.slides_data[i] |
| slide_title = slide.get('title', f'第{i+1}页') |
| slides_list += f""" |
| <div class="slide-item" onclick="openSlide('{slide_file}')"> |
| <div class="slide-preview"> |
| <div class="slide-number">{i+1}</div> |
| <div class="slide-title">{slide_title}</div> |
| </div> |
| </div>""" |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{project.topic} - PPT放映</title> |
| <style> |
| body {{ |
| margin: 0; |
| padding: 0; |
| font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| }} |
| .header {{ |
| text-align: center; |
| padding: 40px 20px; |
| color: white; |
| }} |
| .header h1 {{ |
| margin: 0; |
| font-size: 2.5em; |
| font-weight: 300; |
| }} |
| .slides-grid {{ |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
| gap: 20px; |
| padding: 20px; |
| }} |
| .slide-item {{ |
| background: white; |
| border-radius: 10px; |
| padding: 20px; |
| cursor: pointer; |
| transition: all 0.3s ease; |
| box-shadow: 0 4px 15px rgba(0,0,0,0.1); |
| }} |
| .slide-item:hover {{ |
| transform: translateY(-5px); |
| box-shadow: 0 8px 25px rgba(0,0,0,0.2); |
| }} |
| .slide-number {{ |
| background: #007bff; |
| color: white; |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| margin: 0 auto 15px auto; |
| font-weight: bold; |
| }} |
| .slide-title {{ |
| font-size: 1.1em; |
| color: #333; |
| margin: 0; |
| text-align: center; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="header"> |
| <h1>{project.topic}</h1> |
| <p>PPT演示文稿 - 共{len(slide_files)}页</p> |
| </div> |
| <div class="slides-grid"> |
| {slides_list} |
| </div> |
| <script> |
| function openSlide(slideFile) {{ |
| window.open(slideFile, '_blank'); |
| }} |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| async def _generate_combined_html_for_export(project, export_type: str) -> str: |
| """Generate combined HTML for export (PDF or HTML)""" |
| try: |
| if not project.slides_data: |
| raise ValueError("No slides data available") |
|
|
| |
| html_parts = [] |
|
|
| |
| html_parts.append(f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{project.topic} - PPT导出</title> |
| <style> |
| body {{ |
| margin: 0; |
| padding: 0; |
| font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; |
| background: #f5f5f5; |
| }} |
| .slide-container {{ |
| width: 100vw; |
| height: 100vh; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| page-break-after: always; |
| background: white; |
| position: relative; |
| }} |
| .slide-container:last-child {{ |
| page-break-after: avoid; |
| }} |
| .slide-frame {{ |
| width: 90vw; |
| height: 90vh; |
| border: none; |
| border-radius: 10px; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.1); |
| }} |
| .slide-number {{ |
| position: absolute; |
| bottom: 20px; |
| right: 20px; |
| background: rgba(0,0,0,0.7); |
| color: white; |
| padding: 5px 10px; |
| border-radius: 5px; |
| font-size: 14px; |
| }} |
| @media print {{ |
| .slide-container {{ |
| page-break-after: always; |
| width: 100%; |
| height: 100vh; |
| }} |
| }} |
| </style> |
| </head> |
| <body>""") |
|
|
| |
| for i, slide in enumerate(project.slides_data): |
| slide_html = slide.get('html_content', '') |
| if slide_html: |
| |
| if slide_html.strip().lower().startswith('<!doctype') or slide_html.strip().lower().startswith('<html'): |
| |
| import re |
|
|
| |
| style_matches = re.findall(r'<style[^>]*>(.*?)</style>', slide_html, re.DOTALL | re.IGNORECASE) |
| slide_styles = '\n'.join(style_matches) |
|
|
| |
| body_match = re.search(r'<body[^>]*>(.*?)</body>', slide_html, re.DOTALL | re.IGNORECASE) |
| if body_match: |
| slide_content = body_match.group(1) |
| else: |
| slide_content = slide_html |
| else: |
| slide_styles = "" |
| slide_content = slide_html |
|
|
| html_parts.append(f""" |
| <div class="slide-container"> |
| <style> |
| {slide_styles} |
| </style> |
| <div class="slide-frame"> |
| {slide_content} |
| </div> |
| <div class="slide-number">{i + 1} / {len(project.slides_data)}</div> |
| </div>""") |
|
|
| |
| html_parts.append(""" |
| </body> |
| </html>""") |
|
|
| return ''.join(html_parts) |
|
|
| except Exception as e: |
| |
| return f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>导出错误</title> |
| </head> |
| <body> |
| <h1>导出失败</h1> |
| <p>错误信息: {str(e)}</p> |
| <p>请确保PPT已经生成完成后再尝试导出。</p> |
| </body> |
| </html>""" |
|
|
|
|
| async def _generate_individual_slide_html(slide, slide_number: int, total_slides: int, topic: str) -> str: |
| """Generate complete HTML document for individual slide preserving original styles""" |
| slide_html = slide.get('html_content', '') |
| slide_title = slide.get('title', f'第{slide_number}页') |
|
|
| |
| import re |
| if slide_html.strip().lower().startswith('<!doctype') or slide_html.strip().lower().startswith('<html'): |
| |
| return _enhance_complete_html_with_navigation(slide_html, slide_number, total_slides, topic, slide_title) |
| else: |
| |
| slide_content = slide_html |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{topic} - {slide_title}</title> |
| <style> |
| body {{ |
| margin: 0; |
| padding: 0; |
| font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; |
| background: #f5f5f5; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| min-height: 100vh; |
| }} |
| .slide-container {{ |
| width: 90vw; |
| height: 90vh; |
| background: white; |
| border-radius: 10px; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.1); |
| overflow: hidden; |
| position: relative; |
| }} |
| .slide-content {{ |
| width: 100%; |
| height: 100%; |
| padding: 20px; |
| box-sizing: border-box; |
| }} |
| .slide-number {{ |
| position: absolute; |
| bottom: 20px; |
| right: 20px; |
| background: rgba(0,0,0,0.7); |
| color: white; |
| padding: 5px 10px; |
| border-radius: 5px; |
| font-size: 14px; |
| }} |
| .navigation {{ |
| position: fixed; |
| bottom: 20px; |
| left: 50%; |
| transform: translateX(-50%); |
| display: flex; |
| gap: 10px; |
| z-index: 1000; |
| }} |
| .nav-btn {{ |
| background: #007bff; |
| color: white; |
| border: none; |
| padding: 10px 15px; |
| border-radius: 5px; |
| cursor: pointer; |
| text-decoration: none; |
| display: inline-block; |
| }} |
| .nav-btn:hover {{ |
| background: #0056b3; |
| }} |
| .nav-btn:disabled {{ |
| background: #ccc; |
| cursor: not-allowed; |
| }} |
| .fullscreen-btn {{ |
| position: fixed; |
| top: 20px; |
| right: 20px; |
| background: #28a745; |
| color: white; |
| border: none; |
| padding: 10px; |
| border-radius: 5px; |
| cursor: pointer; |
| z-index: 1000; |
| }} |
| .fullscreen-btn:hover {{ |
| background: #1e7e34; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="slide-container"> |
| <div class="slide-content"> |
| {slide_content} |
| </div> |
| <div class="slide-number">{slide_number} / {total_slides}</div> |
| </div> |
| |
| <div class="navigation"> |
| <a href="index.html" class="nav-btn">🏠 返回目录</a> |
| {"" if slide_number <= 1 else f'<a href="slide_{slide_number-1}.html" class="nav-btn">‹ 上一页</a>'} |
| {"" if slide_number >= total_slides else f'<a href="slide_{slide_number+1}.html" class="nav-btn">下一页 ›</a>'} |
| </div> |
| |
| <button class="fullscreen-btn" onclick="toggleFullscreen()" title="全屏显示"> |
| 📺 |
| </button> |
| |
| <script> |
| function toggleFullscreen() {{ |
| if (!document.fullscreenElement) {{ |
| document.documentElement.requestFullscreen(); |
| }} else {{ |
| if (document.exitFullscreen) {{ |
| document.exitFullscreen(); |
| }} |
| }} |
| }} |
| |
| // Keyboard navigation |
| document.addEventListener('keydown', function(e) {{ |
| if (e.key === 'ArrowLeft' && {slide_number} > 1) {{ |
| window.location.href = 'slide_{slide_number-1}.html'; |
| }} else if (e.key === 'ArrowRight' && {slide_number} < {total_slides}) {{ |
| window.location.href = 'slide_{slide_number+1}.html'; |
| }} else if (e.key === 'Escape') {{ |
| window.location.href = 'index.html'; |
| }} |
| }}); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| def _enhance_complete_html_with_navigation(original_html: str, slide_number: int, total_slides: int, topic: str, slide_title: str) -> str: |
| """Enhance complete HTML document with navigation controls""" |
| import re |
|
|
| |
| navigation_css = """ |
| <style> |
| .slide-navigation { |
| position: fixed; |
| bottom: 20px; |
| left: 50%; |
| transform: translateX(-50%); |
| display: flex; |
| gap: 10px; |
| z-index: 10000; |
| background: rgba(0,0,0,0.8); |
| padding: 10px; |
| border-radius: 25px; |
| } |
| .nav-btn { |
| background: #007bff; |
| color: white; |
| border: none; |
| padding: 10px 15px; |
| border-radius: 5px; |
| cursor: pointer; |
| text-decoration: none; |
| display: inline-block; |
| font-size: 14px; |
| } |
| .nav-btn:hover { |
| background: #0056b3; |
| } |
| .fullscreen-btn { |
| position: fixed; |
| top: 20px; |
| right: 20px; |
| background: #28a745; |
| color: white; |
| border: none; |
| padding: 10px; |
| border-radius: 5px; |
| cursor: pointer; |
| z-index: 10000; |
| font-size: 16px; |
| } |
| .fullscreen-btn:hover { |
| background: #1e7e34; |
| } |
| </style>""" |
|
|
| navigation_js = f""" |
| <script> |
| function toggleFullscreen() {{ |
| if (!document.fullscreenElement) {{ |
| document.documentElement.requestFullscreen(); |
| }} else {{ |
| if (document.exitFullscreen) {{ |
| document.exitFullscreen(); |
| }} |
| }} |
| }} |
| |
| // Keyboard navigation |
| document.addEventListener('keydown', function(e) {{ |
| if (e.key === 'ArrowLeft' && {slide_number} > 1) {{ |
| window.location.href = 'slide_{slide_number-1}.html'; |
| }} else if (e.key === 'ArrowRight' && {slide_number} < {total_slides}) {{ |
| window.location.href = 'slide_{slide_number+1}.html'; |
| }} else if (e.key === 'Escape') {{ |
| window.location.href = 'index.html'; |
| }} |
| }}); |
| </script>""" |
|
|
| navigation_html = f""" |
| <div class="slide-navigation"> |
| <a href="index.html" class="nav-btn">🏠 返回目录</a> |
| {"" if slide_number <= 1 else f'<a href="slide_{slide_number-1}.html" class="nav-btn">‹ 上一页</a>'} |
| {"" if slide_number >= total_slides else f'<a href="slide_{slide_number+1}.html" class="nav-btn">下一页 ›</a>'} |
| </div> |
| |
| <button class="fullscreen-btn" onclick="toggleFullscreen()" title="全屏显示"> |
| 📺 |
| </button>""" |
|
|
| |
| head_pattern = r'</head>' |
| enhanced_html = re.sub(head_pattern, navigation_css + '\n</head>', original_html, flags=re.IGNORECASE) |
|
|
| |
| body_pattern = r'</body>' |
| enhanced_html = re.sub(body_pattern, navigation_html + '\n' + navigation_js + '\n</body>', enhanced_html, flags=re.IGNORECASE) |
|
|
| return enhanced_html |
|
|
|
|
| async def _generate_pdf_slide_html(slide, slide_number: int, total_slides: int, topic: str) -> str: |
| """Generate PDF-optimized HTML for individual slide without navigation elements""" |
| slide_html = slide.get('html_content', '') |
| slide_title = slide.get('title', f'第{slide_number}页') |
|
|
| |
| import re |
| if slide_html.strip().lower().startswith('<!doctype') or slide_html.strip().lower().startswith('<html'): |
| |
| return _clean_html_for_pdf(slide_html, slide_number, total_slides) |
| else: |
| |
| slide_content = slide_html |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{topic} - {slide_title}</title> |
| <style> |
| * {{ |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| }} |
| |
| html, body {{ |
| width: 100%; |
| height: 100vh; |
| margin: 0; |
| padding: 0; |
| font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; |
| overflow: hidden; |
| }} |
| |
| .slide-container {{ |
| width: 100vw; |
| height: 100vh; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| position: relative; |
| }} |
| |
| .slide-content {{ |
| width: 100%; |
| height: 100%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| position: relative; |
| }} |
| |
| /* Ensure all backgrounds and colors are preserved for PDF */ |
| * {{ |
| -webkit-print-color-adjust: exact !important; |
| print-color-adjust: exact !important; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="slide-container"> |
| <div class="slide-content"> |
| {slide_content} |
| </div> |
| </div> |
| </body> |
| </html>""" |
|
|
|
|
| def _clean_html_for_pdf(original_html: str, slide_number: int, total_slides: int) -> str: |
| """Clean complete HTML document for PDF generation by removing navigation elements""" |
| import re |
|
|
| |
| cleaned_html = original_html |
|
|
| |
| cleaned_html = re.sub(r'<div[^>]*class="[^"]*navigation[^"]*"[^>]*>.*?</div>', '', cleaned_html, flags=re.DOTALL | re.IGNORECASE) |
| cleaned_html = re.sub(r'<button[^>]*class="[^"]*nav[^"]*"[^>]*>.*?</button>', '', cleaned_html, flags=re.DOTALL | re.IGNORECASE) |
| cleaned_html = re.sub(r'<a[^>]*class="[^"]*nav[^"]*"[^>]*>.*?</a>', '', cleaned_html, flags=re.DOTALL | re.IGNORECASE) |
|
|
| |
| cleaned_html = re.sub(r'<button[^>]*fullscreen[^>]*>.*?</button>', '', cleaned_html, flags=re.DOTALL | re.IGNORECASE) |
|
|
| |
| pdf_styles = """ |
| <style> |
| /* PDF optimization styles */ |
| * { |
| -webkit-print-color-adjust: exact !important; |
| print-color-adjust: exact !important; |
| } |
| |
| html, body { |
| width: 100% !important; |
| height: 100vh !important; |
| margin: 0 !important; |
| padding: 0 !important; |
| overflow: hidden !important; |
| } |
| |
| /* Hide any remaining navigation elements */ |
| .navigation, .nav-btn, .fullscreen-btn, .slide-navigation { |
| display: none !important; |
| } |
| </style> |
| """ |
|
|
| |
| head_pattern = r'</head>' |
| cleaned_html = re.sub(head_pattern, pdf_styles + '\n</head>', cleaned_html, flags=re.IGNORECASE) |
|
|
| return cleaned_html |
|
|
|
|
| async def _generate_pdf_with_pyppeteer(project, output_path: str, individual: bool = False) -> bool: |
| """Generate PDF using Pyppeteer (Python)""" |
| try: |
| pdf_converter = get_pdf_converter() |
|
|
| with tempfile.TemporaryDirectory() as temp_dir: |
| temp_path = Path(temp_dir) |
|
|
| |
| |
| html_files = [] |
| for i, slide in enumerate(project.slides_data): |
| |
| slide_html = await _generate_pdf_slide_html( |
| slide, i+1, len(project.slides_data), project.topic |
| ) |
|
|
| html_file = temp_path / f"slide_{i+1}.html" |
| |
| def write_html_file(content, path): |
| with open(path, 'w', encoding='utf-8') as f: |
| f.write(content) |
|
|
| await run_blocking_io(write_html_file, slide_html, str(html_file)) |
| html_files.append(str(html_file)) |
|
|
| |
| pdf_dir = temp_path / "pdfs" |
| await run_blocking_io(pdf_dir.mkdir) |
|
|
| logging.info(f"Starting PDF generation for {len(html_files)} files") |
|
|
| |
| pdf_files = await pdf_converter.convert_multiple_html_to_pdf( |
| html_files, str(pdf_dir), output_path |
| ) |
|
|
| if pdf_files and os.path.exists(output_path): |
| logging.info("Pyppeteer PDF generation successful") |
| return True |
| else: |
| logging.error("Pyppeteer PDF generation failed: No output file created") |
| return False |
|
|
| except Exception as e: |
| logging.error(f"Pyppeteer PDF generation failed: {e}") |
| return False |
|
|
|
|
| async def _generate_combined_html_for_pdf(project) -> str: |
| """Generate combined HTML for PDF export with all slides preserving original styles""" |
| slides_html = "" |
| global_styles = "" |
|
|
| for i, slide in enumerate(project.slides_data): |
| slide_html = slide.get('html_content', '') |
| slide_title = slide.get('title', f'第{i+1}页') |
|
|
| |
| if slide_html.strip().lower().startswith('<!doctype') or slide_html.strip().lower().startswith('<html'): |
| import re |
|
|
| |
| style_matches = re.findall(r'<style[^>]*>(.*?)</style>', slide_html, re.DOTALL | re.IGNORECASE) |
| link_matches = re.findall(r'<link[^>]*rel=["\']stylesheet["\'][^>]*>', slide_html, re.IGNORECASE) |
|
|
| slide_styles = '\n'.join(style_matches) |
| slide_links = '\n'.join(link_matches) |
|
|
| |
| body_match = re.search(r'<body([^>]*)>(.*?)</body>', slide_html, re.DOTALL | re.IGNORECASE) |
| if body_match: |
| body_attrs = body_match.group(1) |
| slide_content = body_match.group(2) |
| |
| if 'style=' in body_attrs: |
| body_style_match = re.search(r'style=["\']([^"\']*)["\']', body_attrs) |
| if body_style_match: |
| slide_styles += f"\n.slide-content {{ {body_style_match.group(1)} }}" |
| else: |
| slide_content = slide_html |
| slide_links = "" |
|
|
| |
| if slide_links and slide_links not in global_styles: |
| global_styles += slide_links + "\n" |
| else: |
| slide_styles = "" |
| slide_content = slide_html |
| slide_links = "" |
|
|
| |
| slides_html += f""" |
| <div class="slide-page" data-slide="{i+1}" style="page-break-before: always; page-break-after: always; page-break-inside: avoid;"> |
| <style> |
| /* Slide {i+1} specific styles */ |
| .slide-page[data-slide="{i+1}"] .slide-content {{ |
| /* Preserve original styling */ |
| }} |
| {slide_styles} |
| </style> |
| <div class="slide-content"> |
| {slide_content} |
| </div> |
| <div class="slide-footer"> |
| <span class="slide-number">{i+1} / {len(project.slides_data)}</span> |
| <span class="slide-title">{slide_title}</span> |
| </div> |
| </div>""" |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{project.topic} - PDF导出</title> |
| {global_styles} |
| <style> |
| /* Reset and base styles */ |
| * {{ |
| box-sizing: border-box; |
| }} |
| |
| body {{ |
| margin: 0; |
| padding: 0; |
| font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; |
| /* Don't force background color - let slides define their own */ |
| }} |
| |
| .slide-page {{ |
| width: 297mm; |
| height: 167mm; |
| margin: 0; |
| padding: 0; |
| page-break-before: always; |
| page-break-after: always; |
| page-break-inside: avoid; |
| position: relative; |
| aspect-ratio: 16/9; |
| /* Don't force background - preserve original slide backgrounds */ |
| overflow: hidden; |
| display: block; |
| box-sizing: border-box; |
| }} |
| |
| .slide-page:first-child {{ |
| page-break-before: avoid; |
| }} |
| |
| .slide-page:last-child {{ |
| page-break-after: avoid; |
| }} |
| |
| .slide-content {{ |
| width: 100%; |
| height: calc(100% - 30px); |
| position: relative; |
| /* Preserve original content styling */ |
| }} |
| |
| .slide-footer {{ |
| position: absolute; |
| bottom: 5mm; |
| right: 10mm; |
| font-size: 10px; |
| color: rgba(255, 255, 255, 0.7); |
| background: rgba(0, 0, 0, 0.3); |
| padding: 2px 8px; |
| border-radius: 3px; |
| z-index: 1000; |
| }} |
| |
| .slide-number {{ |
| font-weight: bold; |
| }} |
| |
| .slide-title {{ |
| margin-left: 8px; |
| opacity: 0.8; |
| }} |
| |
| /* Print-specific styles */ |
| @media print {{ |
| @page {{ |
| size: 297mm 167mm; |
| margin: 0; |
| }} |
| |
| body {{ |
| -webkit-print-color-adjust: exact; |
| print-color-adjust: exact; |
| margin: 0; |
| padding: 0; |
| }} |
| |
| .slide-page {{ |
| page-break-before: always; |
| page-break-after: always; |
| page-break-inside: avoid; |
| -webkit-print-color-adjust: exact; |
| print-color-adjust: exact; |
| width: 297mm; |
| height: 167mm; |
| margin: 0; |
| padding: 0; |
| display: block; |
| }} |
| |
| .slide-page:first-child {{ |
| page-break-before: avoid; |
| }} |
| |
| .slide-page:last-child {{ |
| page-break-after: avoid; |
| }} |
| }} |
| |
| /* Ensure all backgrounds and colors are preserved */ |
| * {{ |
| -webkit-print-color-adjust: exact !important; |
| print-color-adjust: exact !important; |
| }} |
| </style> |
| </head> |
| <body> |
| {slides_html} |
| </body> |
| </html>""" |
|
|
|
|
| async def _generate_slideshow_index(project, slide_files: list) -> str: |
| """Generate slideshow index page""" |
| slides_list = "" |
| for i, slide_file in enumerate(slide_files): |
| slide = project.slides_data[i] |
| slide_title = slide.get('title', f'第{i+1}页') |
| slides_list += f""" |
| <div class="slide-item" onclick="openSlide('{slide_file}')"> |
| <div class="slide-preview"> |
| <div class="slide-number">{i+1}</div> |
| <div class="slide-title">{slide_title}</div> |
| </div> |
| </div>""" |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{project.topic} - PPT放映</title> |
| <style> |
| body {{ |
| margin: 0; |
| padding: 0; |
| font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| }} |
| .header {{ |
| text-align: center; |
| padding: 40px 20px; |
| color: white; |
| }} |
| .header h1 {{ |
| margin: 0; |
| font-size: 2.5em; |
| font-weight: 300; |
| }} |
| .header p {{ |
| margin: 10px 0 0 0; |
| font-size: 1.2em; |
| opacity: 0.9; |
| }} |
| .container {{ |
| max-width: 1200px; |
| margin: 0 auto; |
| padding: 0 20px; |
| }} |
| .slides-grid {{ |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
| gap: 20px; |
| padding: 20px 0; |
| }} |
| .slide-item {{ |
| background: white; |
| border-radius: 10px; |
| padding: 20px; |
| cursor: pointer; |
| transition: all 0.3s ease; |
| box-shadow: 0 4px 15px rgba(0,0,0,0.1); |
| }} |
| .slide-item:hover {{ |
| transform: translateY(-5px); |
| box-shadow: 0 8px 25px rgba(0,0,0,0.2); |
| }} |
| .slide-preview {{ |
| text-align: center; |
| }} |
| .slide-number {{ |
| background: #007bff; |
| color: white; |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| margin: 0 auto 15px auto; |
| font-weight: bold; |
| }} |
| .slide-title {{ |
| font-size: 1.1em; |
| color: #333; |
| margin: 0; |
| }} |
| .controls {{ |
| text-align: center; |
| padding: 40px 20px; |
| }} |
| .btn {{ |
| background: #28a745; |
| color: white; |
| border: none; |
| padding: 15px 30px; |
| border-radius: 25px; |
| font-size: 1.1em; |
| cursor: pointer; |
| margin: 0 10px; |
| transition: all 0.3s ease; |
| text-decoration: none; |
| display: inline-block; |
| }} |
| .btn:hover {{ |
| background: #1e7e34; |
| transform: translateY(-2px); |
| }} |
| .btn-secondary {{ |
| background: #6c757d; |
| }} |
| .btn-secondary:hover {{ |
| background: #545b62; |
| }} |
| @media (max-width: 768px) {{ |
| .slides-grid {{ |
| grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); |
| gap: 15px; |
| }} |
| .header h1 {{ |
| font-size: 2em; |
| }} |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="header"> |
| <h1>{project.topic}</h1> |
| <p>PPT演示文稿 - 共{len(slide_files)}页</p> |
| </div> |
| |
| <div class="container"> |
| <div class="controls"> |
| <button class="btn" onclick="startSlideshow()">🎬 开始放映</button> |
| <button class="btn btn-secondary" onclick="downloadAll()">📦 下载所有文件</button> |
| </div> |
| |
| <div class="slides-grid"> |
| {slides_list} |
| </div> |
| </div> |
| |
| <script> |
| function openSlide(slideFile) {{ |
| window.open(slideFile, '_blank'); |
| }} |
| |
| function startSlideshow() {{ |
| window.open('slide_1.html', '_blank'); |
| }} |
| |
| function downloadAll() {{ |
| alert('所有文件已包含在此ZIP包中'); |
| }} |
| |
| // Keyboard shortcuts |
| document.addEventListener('keydown', function(e) {{ |
| if (e.key === 'Enter' || e.key === ' ') {{ |
| startSlideshow(); |
| }} |
| }}); |
| </script> |
| </body> |
| </html>""" |
|
|