From 1811d56c1d280da7a6dd040ddc5608a3b1889c57 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 20:02:38 +0800 Subject: [PATCH 1/8] Add text-only editable PPTX export option --- README.md | 1 + README_EN.md | 1 + backend/controllers/export_controller.py | 16 +- backend/services/export_service.py | 58 ++++++- backend/services/task_manager.py | 8 +- .../unit/test_editable_pptx_equations.py | 53 ++++++- docs/features/export.mdx | 2 + docs/zh/features/export.mdx | 2 + frontend/e2e/editable-export-failure.spec.ts | 144 +++++++++++++++++- frontend/src/api/endpoints.ts | 8 +- frontend/src/pages/SlidePreview.tsx | 27 +++- 11 files changed, 302 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 423a8681a..72e9a574c 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ ### 5. 可自由编辑的pptx导出(Beta迭代中) - **导出图像为高还原度、背景干净的、可自由编辑图像和文字的PPT页面** +- 可在导出前勾选「仅文字层可编辑」,保留整页原图作为背景,只让识别出的文字变成可编辑文本框(默认不勾选)。 - 相关更新见 https://github.com/Anionex/banana-slides/issues/121 diff --git a/README_EN.md b/README_EN.md index 4892a0727..844dfa05a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -153,6 +153,7 @@ No longer restricted by complex menu buttons, issue modification commands direct ### 5. Freely Editable PPTX Export (In Beta) - **Export images as high-fidelity, clean-background PPT pages with freely editable images and text** +- Enable **Only Text Layers Editable** before export to keep the full slide image as the background and make only detected text editable. This option is off by default. - For related updates, see https://github.com/Anionex/banana-slides/issues/121 diff --git a/backend/controllers/export_controller.py b/backend/controllers/export_controller.py index 4f9f98b10..6dea1824c 100644 --- a/backend/controllers/export_controller.py +++ b/backend/controllers/export_controller.py @@ -384,7 +384,8 @@ def export_editable_pptx(project_id): "filename": "optional_custom_name.pptx", "page_ids": ["id1", "id2"], // 可选,要导出的页面ID列表(不提供则导出所有) "max_depth": 1, // 可选,递归深度(默认1=不递归,2=递归一层) - "max_workers": 4 // 可选,并发数(默认4) + "max_workers": 4, // 可选,并发数(默认4) + "text_only": false // 可选,仅让文字层可编辑,默认false } Returns: @@ -433,6 +434,7 @@ def export_editable_pptx(project_id): # max_depth 语义:1=只处理表层不递归,2=递归一层(处理图片/图表中的子元素) max_depth = data.get('max_depth', 1) # 默认不递归,与测试脚本一致 max_workers = data.get('max_workers', 4) + text_only = data.get('text_only', False) # Validate parameters # max_depth >= 1: 至少处理表层元素 @@ -441,6 +443,9 @@ def export_editable_pptx(project_id): if not isinstance(max_workers, int) or max_workers < 1 or max_workers > 16: return bad_request("max_workers must be an integer between 1 and 16") + + if not isinstance(text_only, bool): + return bad_request("text_only must be a boolean") # Create task record task = Task( @@ -451,7 +456,10 @@ def export_editable_pptx(project_id): db.session.add(task) db.session.commit() - logger.info(f"Created export task {task.id} for project {project_id} (recursive analysis: depth={max_depth}, workers={max_workers})") + logger.info( + f"Created export task {task.id} for project {project_id} " + f"(recursive analysis: depth={max_depth}, workers={max_workers}, text_only={text_only})" + ) # Get services from services.file_service import FileService @@ -488,6 +496,7 @@ def export_editable_pptx(project_id): export_extractor_method=export_extractor_method, export_inpaint_method=export_inpaint_method, enable_icon_subject_extraction=enable_icon_subject_extraction, + text_only=text_only, app=app ) @@ -498,7 +507,8 @@ def export_editable_pptx(project_id): "task_id": task.id, "method": "recursive_analysis", "max_depth": max_depth, - "max_workers": max_workers + "max_workers": max_workers, + "text_only": text_only }, message="Export task created (using recursive analysis)" ) diff --git a/backend/services/export_service.py b/backend/services/export_service.py index 6865fc612..3e49e986c 100644 --- a/backend/services/export_service.py +++ b/backend/services/export_service.py @@ -196,6 +196,22 @@ def _get_page_size_inches(aspect_ratio: str = '16:9', base: float = 10.0) -> Tup class ExportService: """Service for exporting presentations""" + EDITABLE_TEXT_ELEMENT_TYPES = { + 'text', + 'title', + 'list', + 'paragraph', + 'header', + 'footer', + 'heading', + 'table_cell', + 'table_caption', + 'image_caption', + 'equation', + 'interline_equation', + 'inline_equation', + } + PPTX_TRANSITION_EFFECTS = { 'fade', 'page_turn', @@ -1259,6 +1275,7 @@ def create_editable_pptx_with_recursive_analysis( export_extractor_method: str = 'hybrid', # 组件提取方法: mineru, hybrid export_inpaint_method: str = 'hybrid', # 背景修复方法: generative, baidu, hybrid enable_icon_subject_extraction: bool = False, # 是否对小尺寸图标走百度智能抠图 + text_only: bool = False, # 是否保留整页原图背景,仅叠加可编辑文字层 fail_fast: bool = True # 是否在遇到错误时立即停止(False则收集警告继续) ) -> Tuple[Optional[bytes], ExportWarnings]: """ @@ -1284,6 +1301,8 @@ def create_editable_pptx_with_recursive_analysis( 可通过 TextAttributeExtractorFactory.create_caption_model_extractor() 创建 export_extractor_method: 组件提取方法 ('mineru' 或 'hybrid',默认 'hybrid') export_inpaint_method: 背景修复方法 ('generative', 'baidu', 'hybrid',默认 'hybrid') + text_only: 仅文字层可编辑。为 True 时使用原始整页图片作为背景, + 跳过图片/图表/表格背景等非文字元素,只叠加识别出的文字元素。 fail_fast: 是否在遇到错误时立即停止(默认 True)。设为 False 则收集警告继续导出。 Returns: @@ -1292,6 +1311,7 @@ def create_editable_pptx_with_recursive_analysis( - warnings: ExportWarnings 对象,包含所有警告信息 """ from services.image_editability import ServiceConfig, ImageEditabilityService + from services.image_editability.inpaint_providers import InpaintProviderRegistry from utils.pptx_builder import PPTXBuilder # 初始化警告收集器 @@ -1328,8 +1348,10 @@ def report_progress(step: str, message: str, percent: int): max_depth=max_depth, extractor_method=export_extractor_method, inpaint_method=export_inpaint_method, - enable_icon_subject_extraction=enable_icon_subject_extraction, + enable_icon_subject_extraction=enable_icon_subject_extraction and not text_only, ) + if text_only: + config.inpaint_registry = InpaintProviderRegistry() editability_service = ImageEditabilityService(config) # 2. 并发处理所有页面,生成EditableImage结构 @@ -1410,12 +1432,14 @@ def report_progress(step: str, message: str, percent: int): # 创建空白幻灯片 slide = builder.add_blank_slide() - # 添加背景图(参考原实现,使用slide.shapes.add_picture) - if editable_img.clean_background and os.path.exists(editable_img.clean_background): - logger.info(f" 添加clean background: {editable_img.clean_background}") + # 添加背景图:text_only 模式保留整页原图,只叠加文字层。 + background_path = editable_img.image_path if text_only else editable_img.clean_background + if background_path and os.path.exists(background_path): + background_label = "原图背景" if text_only else "clean background" + logger.info(f" 添加{background_label}: {background_path}") try: slide.shapes.add_picture( - editable_img.clean_background, + background_path, left=0, top=0, width=builder.prs.slide_width, @@ -1454,6 +1478,7 @@ def report_progress(step: str, message: str, percent: int): depth=0, text_styles_cache=text_styles_cache, # 使用预提取的样式缓存 warnings=warnings, # 收集警告 + text_only=text_only, fail_fast=fail_fast # 传递 fail_fast 参数 ) @@ -1492,6 +1517,7 @@ def _add_editable_elements_to_slide( depth: int = 0, text_styles_cache: Dict[str, Any] = None, # 预提取的文本样式缓存,key为element_id warnings: 'ExportWarnings' = None, # 警告收集器 + text_only: bool = False, # 是否只添加文本类元素 fail_fast: bool = False # 是否在遇到错误时立即停止 ): """ @@ -1598,8 +1624,26 @@ def add_text_or_formula(elem, text, bbox_list, text_level='default', align='left logger.info(f"{' ' * depth} 添加元素: type={elem_type}, bbox={bbox_list}, content={elem.content[:30] if elem.content else None}, image_path={elem.image_path}, 使用{'全局' if depth > 0 else '局部'}坐标") + if text_only and elem_type not in ExportService.EDITABLE_TEXT_ELEMENT_TYPES: + if elem.children: + ExportService._add_editable_elements_to_slide( + builder=builder, + slide=slide, + elements=elem.children, + scale_x=scale_x, + scale_y=scale_y, + depth=depth + 1, + text_styles_cache=text_styles_cache, + warnings=warnings, + text_only=text_only, + fail_fast=fail_fast + ) + else: + logger.debug(f"{' ' * depth} text_only 跳过非文字元素: {elem_type}") + continue + # 根据类型添加元素(参考原实现的_add_mineru_text_to_slide和_add_mineru_image_to_slide) - if elem_type in ['text', 'title', 'list', 'paragraph', 'header', 'footer', 'heading', 'table_caption', 'image_caption', 'equation', 'interline_equation', 'inline_equation']: + if elem_type in ExportService.EDITABLE_TEXT_ELEMENT_TYPES - {'table_cell'}: # 添加文本(参考_add_mineru_text_to_slide) if elem.content: text = elem.content.strip() @@ -1673,6 +1717,7 @@ def add_text_or_formula(elem, text, bbox_list, text_level='default', align='left depth=depth + 1, text_styles_cache=text_styles_cache, warnings=warnings, + text_only=text_only, fail_fast=fail_fast ) else: @@ -1738,6 +1783,7 @@ def add_text_or_formula(elem, text, bbox_list, text_level='default', align='left depth=depth + 1, text_styles_cache=text_styles_cache, warnings=warnings, + text_only=text_only, fail_fast=fail_fast ) else: diff --git a/backend/services/task_manager.py b/backend/services/task_manager.py index 4cea657e7..cdaa8b6c7 100644 --- a/backend/services/task_manager.py +++ b/backend/services/task_manager.py @@ -1758,6 +1758,7 @@ def export_editable_pptx_with_recursive_analysis_task( export_extractor_method: str = 'hybrid', export_inpaint_method: str = 'hybrid', enable_icon_subject_extraction: bool = True, + text_only: bool = False, app=None ): """ @@ -1781,9 +1782,10 @@ def export_editable_pptx_with_recursive_analysis_task( max_workers: 并发处理数 export_extractor_method: 组件提取方法 ('mineru' 或 'hybrid') export_inpaint_method: 背景修复方法 ('generative', 'baidu', 'hybrid') + text_only: 是否仅叠加可编辑文字层,保留整页原图作为背景 app: Flask应用实例 """ - logger.info(f"🚀 Task {task_id} started: export_editable_pptx_with_recursive_analysis (project={project_id}, depth={max_depth}, workers={max_workers}, extractor={export_extractor_method}, inpaint={export_inpaint_method}, icon_subject_extraction={enable_icon_subject_extraction})") + logger.info(f"🚀 Task {task_id} started: export_editable_pptx_with_recursive_analysis (project={project_id}, depth={max_depth}, workers={max_workers}, extractor={export_extractor_method}, inpaint={export_inpaint_method}, icon_subject_extraction={enable_icon_subject_extraction}, text_only={text_only})") if app is None: raise ValueError("Flask app instance must be provided") @@ -1906,7 +1908,7 @@ def progress_callback(step: str, message: str, percent: int): progress_callback("准备", "文字属性提取器已初始化", 5) # Step 3: 调用导出方法(使用项目的导出设置) - logger.info(f"Step 3: 创建可编辑PPTX (extractor={export_extractor_method}, inpaint={export_inpaint_method}, fail_fast={fail_fast})...") + logger.info(f"Step 3: 创建可编辑PPTX (extractor={export_extractor_method}, inpaint={export_inpaint_method}, text_only={text_only}, fail_fast={fail_fast})...") progress_callback("配置", f"提取方法: {export_extractor_method}, 背景修复: {export_inpaint_method}", 6) _, export_warnings = ExportService.create_editable_pptx_with_recursive_analysis( @@ -1921,6 +1923,7 @@ def progress_callback(step: str, message: str, percent: int): export_extractor_method=export_extractor_method, export_inpaint_method=export_inpaint_method, enable_icon_subject_extraction=enable_icon_subject_extraction, + text_only=text_only, fail_fast=fail_fast ) @@ -1954,6 +1957,7 @@ def progress_callback(step: str, message: str, percent: int): "filename": filename, "method": "recursive_analysis", "max_depth": max_depth, + "text_only": text_only, "warnings": warning_messages, # 单独的警告列表 "warning_details": export_warnings.to_dict() if export_warnings else {} # 详细警告信息 }) diff --git a/backend/tests/unit/test_editable_pptx_equations.py b/backend/tests/unit/test_editable_pptx_equations.py index 0f68e594c..20a205ff5 100644 --- a/backend/tests/unit/test_editable_pptx_equations.py +++ b/backend/tests/unit/test_editable_pptx_equations.py @@ -13,6 +13,15 @@ def _slide_xml(pptx_path): return archive.read("ppt/slides/slide1.xml").decode("utf-8") +def _pptx_media_payloads(pptx_path): + with ZipFile(pptx_path) as archive: + return [ + archive.read(name) + for name in archive.namelist() + if name.startswith("ppt/media/") + ] + + def test_builder_writes_native_omml_equation_instead_of_raw_tex(tmp_path): builder = PPTXBuilder() builder.create_presentation() @@ -288,6 +297,49 @@ def test_editable_export_prefers_latex_text_segments_for_formula_source(tmp_path assert r"\geq" not in slide_xml +def test_editable_export_text_only_keeps_original_background_and_text_layers(tmp_path): + original = tmp_path / "original.png" + clean_background = tmp_path / "clean.png" + chart_image = tmp_path / "chart.png" + Image.new("RGB", (300, 120), "red").save(original) + Image.new("RGB", (300, 120), "blue").save(clean_background) + Image.new("RGB", (120, 60), "green").save(chart_image) + output = tmp_path / "text-only.pptx" + + title = _EditableElement("text", "Editable title") + title.element_id = "title" + chart_label = _EditableElement("text", "Chart label") + chart_label.element_id = "chart_label" + chart_label.bbox = _BBox(20, 20, 140, 50) + chart_label.bbox_global = chart_label.bbox + chart = _EditableElement("figure", "") + chart.element_id = "chart" + chart.image_path = str(chart_image) + chart.children = [chart_label] + + editable_image = _EditableImage(str(original), [title, chart]) + editable_image.clean_background = str(clean_background) + + ExportService.create_editable_pptx_with_recursive_analysis( + editable_images=[editable_image], + output_file=str(output), + slide_width_pixels=300, + slide_height_pixels=120, + text_only=True, + fail_fast=True, + ) + + slide_xml = _slide_xml(output) + media_payloads = _pptx_media_payloads(output) + + assert "Editable title" in slide_xml + assert "Chart label" in slide_xml + assert len(media_payloads) == 1 + assert original.read_bytes() in media_payloads + assert clean_background.read_bytes() not in media_payloads + assert chart_image.read_bytes() not in media_payloads + + def test_equation_metadata_without_latex_content_stays_plain_text(tmp_path): background = tmp_path / "slide.png" Image.new("RGB", (300, 120), "white").save(background) @@ -309,4 +361,3 @@ def test_equation_metadata_without_latex_content_stays_plain_text(tmp_path): slide_xml = _slide_xml(output) assert " For best extraction results, configure `BAIDU_API_KEY` in your environment. See [Configuration](/configuration#baidu-api-key). diff --git a/docs/zh/features/export.mdx b/docs/zh/features/export.mdx index 0af2439f4..af75088bf 100644 --- a/docs/zh/features/export.mdx +++ b/docs/zh/features/export.mdx @@ -43,6 +43,8 @@ description: "导出为 PPTX、PDF 或图片" 提取过程保留字号、颜色、加粗样式、文字定位和表格内容,但受限于 OCR 精度和当前模型能力,复杂排版可能存在偏差,与原图效果有出入。 +导出前的二级面板可勾选「仅文字层可编辑」,默认关闭。开启后会保留整页原图作为背景,只叠加识别出的可编辑文字框,适合优先保持视觉还原度、只需要修改文字内容的场景。 + 配置 `BAIDU_API_KEY` 可获得最佳提取效果,详见[配置说明](/zh/configuration#百度-api-key)。 diff --git a/frontend/e2e/editable-export-failure.spec.ts b/frontend/e2e/editable-export-failure.spec.ts index f77287e9e..366ea0eaf 100644 --- a/frontend/e2e/editable-export-failure.spec.ts +++ b/frontend/e2e/editable-export-failure.spec.ts @@ -1,6 +1,145 @@ import { expect, test } from '@playwright/test'; test.describe('Editable export failure UI', () => { + test('sends editable PPTX text-only option only when checked', async ({ page }) => { + const projectId = 'mock-editable-export-text-only'; + const requestBodies: any[] = []; + + await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true')); + + await page.route(url => new URL(url).pathname.startsWith('/api/'), async route => { + const url = new URL(route.request().url()); + + if (url.pathname === '/api/access-code/check') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { enabled: false } }), + }); + } + + if (url.pathname === `/api/projects/${projectId}`) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + project_id: projectId, + id: projectId, + status: 'COMPLETED', + template_style: 'default', + enable_icon_subject_extraction: true, + export_allow_partial: false, + pages: [ + { + id: 'p1', + page_id: 'p1', + order_index: 0, + generated_image_path: '/files/mock/slide-1.png', + outline_content: { title: 'Slide 1', points: [] }, + description_content: { text: 'desc' }, + status: 'COMPLETED', + }, + ], + }, + }), + }); + } + + if (url.pathname === `/api/projects/${projectId}/export/editable-pptx`) { + requestBodies.push(route.request().postDataJSON()); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { task_id: `editable-export-task-${requestBodies.length}` }, + }), + }); + } + + if (url.pathname.startsWith(`/api/projects/${projectId}/tasks/editable-export-task-`)) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + task_id: url.pathname.split('/').pop(), + task_type: 'EXPORT_EDITABLE_PPTX', + status: 'COMPLETED', + progress: { percent: 100, download_url: '/files/mock/text-only.pptx' }, + }, + }), + }); + } + + if (url.pathname === '/api/settings') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) }); + } + + if (url.pathname === `/api/projects/${projectId}/exports`) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { files: [] } }), + }); + } + + if (url.pathname === '/api/output-language') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { language: 'zh' } }), + }); + } + + if (url.pathname === '/api/user-templates') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { templates: [] } }), + }); + } + + if (url.pathname.includes('/image-versions')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { versions: [] } }), + }); + } + + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) }); + }); + + await page.route('**/files/**', async route => { + await route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.alloc(256) }); + }); + + await page.goto(`/project/${projectId}/preview`); + await page.waitForFunction(() => document.body.innerText.length > 50, { timeout: 15000 }); + + await page.locator('button:has-text("导出")').first().click(); + await page.getByRole('button', { name: /导出可编辑 PPTX/ }).click(); + const textOnlyCheckbox = page.getByRole('checkbox', { name: /仅文字层可编辑/ }); + await expect(textOnlyCheckbox).not.toBeChecked(); + await page.getByRole('button', { name: '开始导出' }).click(); + + await expect.poll(() => requestBodies.length, { timeout: 10000 }).toBe(1); + expect(requestBodies[0].text_only).toBe(false); + + await page.locator('button:has-text("导出")').first().click(); + await page.getByRole('button', { name: /导出可编辑 PPTX/ }).click(); + await expect(textOnlyCheckbox).not.toBeChecked(); + await textOnlyCheckbox.check(); + await page.getByRole('button', { name: '开始导出' }).click(); + + await expect.poll(() => requestBodies.length, { timeout: 10000 }).toBe(2); + expect(requestBodies[1].text_only).toBe(true); + }); + test('shows normalized task panel error when style extraction fails', async ({ page }) => { const projectId = 'mock-editable-export-failure'; let pollCount = 0; @@ -141,9 +280,10 @@ test.describe('Editable export failure UI', () => { .poll(() => pollCount, { timeout: 10000 }) .toBeGreaterThan(0); - await page.getByRole('button', { name: /^1$/ }).click(); + const exportTasksButton = page.getByLabel('导出任务'); + await exportTasksButton.click(); await expect(page.getByText('当前图片样式提取模型不支持图片输入')).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('button', { name: /^1$/ })).toBeVisible({ timeout: 10000 }); + await expect(exportTasksButton).toBeVisible({ timeout: 10000 }); }); test('shows codex relogin toast for oauth 401 failures and keeps it visible for 5s', async ({ page }) => { diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index e278676a1..685414f11 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -747,13 +747,17 @@ export const exportImages = async ( export const exportEditablePPTX = async ( projectId: string, filename?: string, - pageIds?: string[] + pageIds?: string[], + options?: { + textOnly?: boolean; + } ): Promise> => { const response = await apiClient.post< ApiResponse<{ task_id: string }> >(`/api/projects/${projectId}/export/editable-pptx`, { filename, - page_ids: pageIds + page_ids: pageIds, + text_only: options?.textOnly ?? false }); return response.data; }; diff --git a/frontend/src/pages/SlidePreview.tsx b/frontend/src/pages/SlidePreview.tsx index 75b39e517..9917706b5 100644 --- a/frontend/src/pages/SlidePreview.tsx +++ b/frontend/src/pages/SlidePreview.tsx @@ -77,6 +77,8 @@ const previewI18n = { editablePptxIconTransparent: "图标透明背景", editablePptxIconTransparentDesc: "对识别为图标的图片调用本地 RMBG-2.0 模型抠出透明背景,避免原 PPT 底色与新底色冲突。", editablePptxModelHint: "首次启用会下载约 512MB 模型到 ~/.cache/banana-slides/models/,CPU 推理对内存要求较高,建议机器有 ≥ 16GB 可用内存。", + editablePptxTextOnly: "仅文字层可编辑", + editablePptxTextOnlyDesc: "保留整页原图作为背景,只把识别出的文字叠加为可编辑文本框。", editablePptxRangeLabel: "导出范围", editablePptxRangeAll: "全部 {{count}} 页", editablePptxRangePages: "第 {{pages}} 页(共 {{count}} 页)", @@ -208,6 +210,8 @@ const previewI18n = { editablePptxIconTransparent: "Icon Transparent Background", editablePptxIconTransparentDesc: "Run images classified as icons through the local RMBG-2.0 model to produce transparent-background PNGs, avoiding background color clashes.", editablePptxModelHint: "First use downloads a ~512MB model to ~/.cache/banana-slides/models/. CPU inference is memory-intensive; recommended: ≥16GB free memory.", + editablePptxTextOnly: "Only Text Layers Editable", + editablePptxTextOnlyDesc: "Keep the full slide image as the background and overlay only detected text as editable text boxes.", editablePptxRangeLabel: "Export range", editablePptxRangeAll: "All {{count}} pages", editablePptxRangePages: "Pages {{pages}} ({{count}} total)", @@ -429,6 +433,7 @@ export const SlidePreview: React.FC = () => { const [showVideoExportDialog, setShowVideoExportDialog] = useState(false); const [showEditablePptxDialog, setShowEditablePptxDialog] = useState(false); const [editablePptxDialogIconTransparent, setEditablePptxDialogIconTransparent] = useState(true); + const [editablePptxDialogTextOnly, setEditablePptxDialogTextOnly] = useState(false); const [pptxTransitionsEnabled, setPptxTransitionsEnabled] = useState(false); const [pptxTransitionEffects, setPptxTransitionEffects] = useState(['fade']); const [videoEnableKenBurns, setVideoEnableKenBurns] = useState(false); @@ -1276,6 +1281,7 @@ export const SlidePreview: React.FC = () => { options?: { pptxTransitionEnabled?: boolean; pptxTransitionEffects?: PptxTransitionEffect[]; + editableTextOnly?: boolean; }, ) => { setShowExportMenu(false); @@ -1321,7 +1327,9 @@ export const SlidePreview: React.FC = () => { show({ message: t('slidePreview.exportStarted'), type: 'success' }); - const response = await apiExportEditablePPTX(projectId, undefined, pageIds); + const response = await apiExportEditablePPTX(projectId, undefined, pageIds, { + textOnly: options?.editableTextOnly ?? false, + }); const taskId = response.data?.task_id; if (taskId) { @@ -1847,6 +1855,7 @@ export const SlidePreview: React.FC = () => { onClick={() => { setShowExportMenu(false); setEditablePptxDialogIconTransparent(currentProject?.enable_icon_subject_extraction ?? true); + setEditablePptxDialogTextOnly(false); setShowEditablePptxDialog(true); }} disabled={!exportRangeHasAllImages} @@ -2285,6 +2294,18 @@ export const SlidePreview: React.FC = () => { )} + + setEditablePptxDialogTextOnly(e.target.checked)} + className="w-4 h-4 mt-0.5 rounded border-gray-300 text-banana-500 focus:ring-banana-500" + /> + + {t('preview.editablePptxTextOnly')} + {t('preview.editablePptxTextOnlyDesc')} + + {(() => { const totalPages = currentProject?.pages?.length ?? 0; const isPartial = isMultiSelectMode && selectedPageIds.size > 0; @@ -2328,7 +2349,9 @@ export const SlidePreview: React.FC = () => { return; } } - handleExport('editable-pptx'); + handleExport('editable-pptx', { + editableTextOnly: editablePptxDialogTextOnly, + }); }} className="px-4 py-2 text-sm bg-banana-500 text-white rounded-lg hover:bg-banana-600 transition-colors" > From c177142b23a6ae87c106701c56ad330ca97804ab Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 20:10:44 +0800 Subject: [PATCH 2/8] Address editable export review feedback --- backend/services/export_service.py | 2 +- backend/services/image_editability/service.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/services/export_service.py b/backend/services/export_service.py index 3e49e986c..e90156d2e 100644 --- a/backend/services/export_service.py +++ b/backend/services/export_service.py @@ -1643,7 +1643,7 @@ def add_text_or_formula(elem, text, bbox_list, text_level='default', align='left continue # 根据类型添加元素(参考原实现的_add_mineru_text_to_slide和_add_mineru_image_to_slide) - if elem_type in ExportService.EDITABLE_TEXT_ELEMENT_TYPES - {'table_cell'}: + if elem_type in ExportService.EDITABLE_TEXT_ELEMENT_TYPES and elem_type != 'table_cell': # 添加文本(参考_add_mineru_text_to_slide) if elem.content: text = elem.content.strip() diff --git a/backend/services/image_editability/service.py b/backend/services/image_editability/service.py index ef26fbac8..f215f95b0 100644 --- a/backend/services/image_editability/service.py +++ b/backend/services/image_editability/service.py @@ -73,6 +73,7 @@ def __init__(self, config: ServiceConfig): extractors = self._extractor_registry.get_all_extractors() inpaint_providers = self._inpaint_registry.get_all_providers() + self._has_inpaint_providers = bool(inpaint_providers) logger.info( f"ImageEditabilityService: {len(extractors)} extractors, " f"{len(inpaint_providers)} inpaint providers, " @@ -162,7 +163,7 @@ def make_image_editable( # 3. 生成clean background(根据元素类型选择重绘方法) clean_background = None - if self._inpaint_registry and elements: + if self._has_inpaint_providers and elements: clean_background = self._generate_clean_background( image_path=image_path, elements=elements, From 56f2366de3d9d5a1a19acaa6a2f817f3beb9ec74 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 20:15:02 +0800 Subject: [PATCH 3/8] Harden text-only element traversal --- backend/services/export_service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/services/export_service.py b/backend/services/export_service.py index e90156d2e..62f87f801 100644 --- a/backend/services/export_service.py +++ b/backend/services/export_service.py @@ -1625,11 +1625,12 @@ def add_text_or_formula(elem, text, bbox_list, text_level='default', align='left logger.info(f"{' ' * depth} 添加元素: type={elem_type}, bbox={bbox_list}, content={elem.content[:30] if elem.content else None}, image_path={elem.image_path}, 使用{'全局' if depth > 0 else '局部'}坐标") if text_only and elem_type not in ExportService.EDITABLE_TEXT_ELEMENT_TYPES: - if elem.children: + children = getattr(elem, 'children', None) + if children: ExportService._add_editable_elements_to_slide( builder=builder, slide=slide, - elements=elem.children, + elements=children, scale_x=scale_x, scale_y=scale_y, depth=depth + 1, From 08440a62b4639aecc5b21609367bf3d3632c0220 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 20:20:29 +0800 Subject: [PATCH 4/8] Mark editable export task failed on submit errors --- backend/controllers/export_controller.py | 38 +++++++++------- .../test_editable_pptx_export_controller.py | 43 +++++++++++++++++++ 2 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 backend/tests/unit/test_editable_pptx_export_controller.py diff --git a/backend/controllers/export_controller.py b/backend/controllers/export_controller.py index 6dea1824c..32c7292d4 100644 --- a/backend/controllers/export_controller.py +++ b/backend/controllers/export_controller.py @@ -8,6 +8,7 @@ import time import zipfile from pathlib import Path +from datetime import datetime from flask import Blueprint, request, current_app from werkzeug.utils import secure_filename @@ -484,21 +485,28 @@ def export_editable_pptx(project_id): ) # 使用递归分析任务(不需要 ai_service,使用 ImageEditabilityService) - task_manager.submit_task( - task.id, - export_editable_pptx_with_recursive_analysis_task, - project_id=project_id, - filename=filename, - file_service=file_service, - page_ids=selected_page_ids if selected_page_ids else None, - max_depth=max_depth, - max_workers=max_workers, - export_extractor_method=export_extractor_method, - export_inpaint_method=export_inpaint_method, - enable_icon_subject_extraction=enable_icon_subject_extraction, - text_only=text_only, - app=app - ) + try: + task_manager.submit_task( + task.id, + export_editable_pptx_with_recursive_analysis_task, + project_id=project_id, + filename=filename, + file_service=file_service, + page_ids=selected_page_ids if selected_page_ids else None, + max_depth=max_depth, + max_workers=max_workers, + export_extractor_method=export_extractor_method, + export_inpaint_method=export_inpaint_method, + enable_icon_subject_extraction=enable_icon_subject_extraction, + text_only=text_only, + app=app + ) + except Exception as submission_err: + task.status = 'FAILED' + task.error_message = f"Task submission failed: {submission_err}" + task.completed_at = datetime.utcnow() + db.session.commit() + raise logger.info(f"Submitted recursive export task {task.id} to task manager") diff --git a/backend/tests/unit/test_editable_pptx_export_controller.py b/backend/tests/unit/test_editable_pptx_export_controller.py new file mode 100644 index 000000000..97bc32560 --- /dev/null +++ b/backend/tests/unit/test_editable_pptx_export_controller.py @@ -0,0 +1,43 @@ +from models import Page, Project, Task, db + + +def test_editable_pptx_export_marks_task_failed_when_submit_fails(client, app, monkeypatch): + from services import task_manager as tm + + with app.app_context(): + project = Project( + idea_prompt='editable export submit failure', + creation_type='idea', + status='COMPLETED', + ) + db.session.add(project) + db.session.flush() + page = Page( + project_id=project.id, + order_index=0, + generated_image_path='pages/slide.png', + status='COMPLETED', + ) + db.session.add(page) + db.session.commit() + project_id = project.id + + def _fail_submit(*args, **kwargs): + raise RuntimeError('editable export queue full') + + monkeypatch.setattr(tm.task_manager, 'submit_task', _fail_submit) + + response = client.post( + f'/api/projects/{project_id}/export/editable-pptx', + json={'text_only': True}, + ) + + assert response.status_code == 500 + with app.app_context(): + task = Task.query.filter_by( + project_id=project_id, + task_type='EXPORT_EDITABLE_PPTX', + ).one() + assert task.status == 'FAILED' + assert 'Task submission failed: editable export queue full' in task.error_message + assert task.completed_at is not None From 9d9724ef0296b43064e29b7a749a705854bca256 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 20:53:01 +0800 Subject: [PATCH 5/8] Verify text-only editable export backgrounds --- README.md | 2 +- README_EN.md | 2 +- backend/services/export_service.py | 323 +++++++++++++++++- backend/services/prompts.py | 42 +++ .../unit/test_editable_pptx_equations.py | 136 +++++++- docs/features/export.mdx | 2 +- docs/zh/features/export.mdx | 2 +- 7 files changed, 495 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 72e9a574c..6f573da4d 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ ### 5. 可自由编辑的pptx导出(Beta迭代中) - **导出图像为高还原度、背景干净的、可自由编辑图像和文字的PPT页面** -- 可在导出前勾选「仅文字层可编辑」,保留整页原图作为背景,只让识别出的文字变成可编辑文本框(默认不勾选)。 +- 可在导出前勾选「仅文字层可编辑」,生成只抹除文字的底图并让识别出的文字变成可编辑文本框;系统会用 VLM 复核底图文字是否抹除,失败时自动重试并选择漏抹最少的版本(默认不勾选)。 - 相关更新见 https://github.com/Anionex/banana-slides/issues/121 diff --git a/README_EN.md b/README_EN.md index 844dfa05a..d28a677b8 100644 --- a/README_EN.md +++ b/README_EN.md @@ -153,7 +153,7 @@ No longer restricted by complex menu buttons, issue modification commands direct ### 5. Freely Editable PPTX Export (In Beta) - **Export images as high-fidelity, clean-background PPT pages with freely editable images and text** -- Enable **Only Text Layers Editable** before export to keep the full slide image as the background and make only detected text editable. This option is off by default. +- Enable **Only Text Layers Editable** before export to generate a text-erased background while making only detected text editable. VLM verification checks whether the background still contains text; Banana Slides retries failed erasures and keeps the candidate with the fewest missed text regions. This option is off by default. - For related updates, see https://github.com/Anionex/banana-slides/issues/121 diff --git a/backend/services/export_service.py b/backend/services/export_service.py index 62f87f801..bf4d9c824 100644 --- a/backend/services/export_service.py +++ b/backend/services/export_service.py @@ -11,6 +11,7 @@ import tempfile import base64 import hashlib +import shutil from datetime import datetime, timezone from pathlib import Path from typing import List, Dict, Any, Optional, Tuple @@ -211,6 +212,8 @@ class ExportService: 'interline_equation', 'inline_equation', } + TEXT_ONLY_BACKGROUND_MAX_ATTEMPTS = 3 + TEXT_ONLY_BACKGROUND_EXPAND_PIXELS = (2, 6, 10) PPTX_TRANSITION_EFFECTS = { 'fade', @@ -838,6 +841,295 @@ def _collect_text_elements_for_extraction( text_items.extend(child_items) return text_items + + @staticmethod + def _bbox_to_list(bbox) -> List[int]: + """Convert a BBox-like object to an integer list.""" + return [ + int(round(bbox.x0)), + int(round(bbox.y0)), + int(round(bbox.x1)), + int(round(bbox.y1)), + ] + + @staticmethod + def _collect_text_only_background_targets(elements: List, depth: int = 0) -> List[Dict[str, Any]]: + """ + Collect only the text elements that should be erased from the text-only + export background. Non-text containers are traversed but never erased. + """ + targets = [] + + for elem in elements: + elem_type = getattr(elem, 'element_type', None) + bbox = getattr(elem, 'bbox_global', None) if depth > 0 else getattr(elem, 'bbox', None) + if depth > 0 and bbox is None: + bbox = getattr(elem, 'bbox', None) + + if elem_type in ExportService.EDITABLE_TEXT_ELEMENT_TYPES and bbox is not None: + content = (getattr(elem, 'content', None) or '').strip() + if content and getattr(bbox, 'area', 0) > 0: + targets.append({ + 'element_id': getattr(elem, 'element_id', ''), + 'element_type': elem_type, + 'content': content, + 'bbox': ExportService._bbox_to_list(bbox), + }) + + children = getattr(elem, 'children', None) + if children: + targets.extend( + ExportService._collect_text_only_background_targets( + elements=children, + depth=depth + 1 + ) + ) + + return targets + + @staticmethod + def _create_text_only_verification_image( + original_path: str, + candidate_path: str, + output_path: str + ) -> str: + """Create a side-by-side image: original on the left, candidate on the right.""" + with Image.open(original_path) as original_img, Image.open(candidate_path) as candidate_img: + original = original_img.convert('RGB') + candidate = candidate_img.convert('RGB').resize(original.size) + width, height = original.size + header_h = max(36, int(height * 0.035)) + canvas = Image.new('RGB', (width * 2, height + header_h), 'white') + canvas.paste(original, (0, header_h)) + canvas.paste(candidate, (width, header_h)) + + try: + from PIL import ImageDraw + draw = ImageDraw.Draw(canvas) + draw.text((12, 10), "LEFT: original", fill=(0, 0, 0)) + draw.text((width + 12, 10), "RIGHT: candidate background", fill=(0, 0, 0)) + except Exception: + logger.debug("Failed to draw verification labels", exc_info=True) + + canvas.save(output_path) + + return output_path + + @staticmethod + def _parse_text_only_background_verification( + result: Any, + fallback_missed_count: int + ) -> Dict[str, Any]: + """Normalize VLM verification output into comparable fields.""" + if isinstance(result, list): + result = result[0] if result else {} + if not isinstance(result, dict): + result = {} + + missed_texts = result.get('missed_texts') or [] + unwanted_changes = result.get('unwanted_changes') or [] + try: + missed_count = int(result.get('missed_text_count', len(missed_texts))) + except (TypeError, ValueError): + missed_count = len(missed_texts) if isinstance(missed_texts, list) else fallback_missed_count + try: + unwanted_change_count = int(result.get('unwanted_change_count', len(unwanted_changes))) + except (TypeError, ValueError): + unwanted_change_count = len(unwanted_changes) if isinstance(unwanted_changes, list) else 0 + + success = bool(result.get('success')) and missed_count == 0 + return { + 'success': success, + 'missed_text_count': max(0, missed_count), + 'missed_texts': missed_texts if isinstance(missed_texts, list) else [], + 'unwanted_change_count': max(0, unwanted_change_count), + 'unwanted_changes': unwanted_changes if isinstance(unwanted_changes, list) else [], + 'notes': result.get('notes', ''), + 'raw': result, + } + + @staticmethod + def _verify_text_only_background( + ai_service, + original_path: str, + candidate_path: str, + text_targets: List[Dict[str, Any]], + output_dir: Path, + attempt_index: int + ) -> Dict[str, Any]: + """Ask the caption/VLM provider to verify that target text was erased.""" + if ai_service is None: + raise ValueError("text-only background verification requires an AI service") + + from services.prompts import get_text_only_background_verification_prompt + + comparison_path = output_dir / f"text_only_verify_attempt_{attempt_index}.png" + ExportService._create_text_only_verification_image( + original_path=original_path, + candidate_path=candidate_path, + output_path=str(comparison_path) + ) + + elements_json = json.dumps(text_targets, ensure_ascii=False, indent=2) + result = ai_service.generate_json_with_image( + prompt=get_text_only_background_verification_prompt(elements_json), + image_path=str(comparison_path), + thinking_budget=1000 + ) + return ExportService._parse_text_only_background_verification( + result, + fallback_missed_count=len(text_targets) + ) + + @staticmethod + def _generate_text_only_clean_background( + editable_img, + inpaint_provider, + ai_service, + warnings: ExportWarnings, + verifier=None, + max_attempts: Optional[int] = None + ) -> Optional[str]: + """ + Generate a background for text-only editable export by erasing only the + editable text targets. VLM verifies each candidate; if none fully pass, + pick the candidate with the fewest missed text targets. + """ + text_targets = ExportService._collect_text_only_background_targets(editable_img.elements) + if not text_targets: + logger.info("text_only 背景修复:没有文字目标,使用原图背景") + return None + if inpaint_provider is None: + warnings.add_warning("text_only 底图修复缺少 inpaint provider,已回退到原图背景") + return None + + attempts = max_attempts or ExportService.TEXT_ONLY_BACKGROUND_MAX_ATTEMPTS + attempts = max(1, min(attempts, len(ExportService.TEXT_ONLY_BACKGROUND_EXPAND_PIXELS))) + original_path = editable_img.image_path + output_dir = Path(original_path).parent / 'text_only_background' + output_dir.mkdir(parents=True, exist_ok=True) + + bboxes = [target['bbox'] for target in text_targets] + types = [target['element_type'] for target in text_targets] + best_candidate = None + best_score = None + verification_errors = [] + + with Image.open(original_path) as source_img: + source = source_img.convert('RGB') + for attempt_index in range(attempts): + expand_pixels = ExportService.TEXT_ONLY_BACKGROUND_EXPAND_PIXELS[attempt_index] + logger.info( + "text_only 底图修复尝试 %s/%s: targets=%s expand=%s", + attempt_index + 1, + attempts, + len(text_targets), + expand_pixels + ) + candidate_img = inpaint_provider.inpaint_regions( + image=source.copy(), + bboxes=bboxes, + types=types, + expand_pixels=expand_pixels, + save_mask_path=str(output_dir / f'text_only_mask_attempt_{attempt_index + 1}.png') + ) + if candidate_img is None: + verification_errors.append(f"attempt {attempt_index + 1}: inpaint returned empty") + continue + + candidate_path = output_dir / f"text_only_background_attempt_{attempt_index + 1}.png" + candidate_img.save(str(candidate_path)) + + try: + if verifier: + verification = verifier( + original_path=original_path, + candidate_path=str(candidate_path), + text_targets=text_targets, + attempt_index=attempt_index + 1 + ) + else: + verification = ExportService._verify_text_only_background( + ai_service=ai_service, + original_path=original_path, + candidate_path=str(candidate_path), + text_targets=text_targets, + output_dir=output_dir, + attempt_index=attempt_index + 1 + ) + verification = ExportService._parse_text_only_background_verification( + verification, + fallback_missed_count=len(text_targets) + ) + except Exception as e: + logger.warning("text_only 底图 VLM 验证失败: %s", e, exc_info=True) + verification_errors.append(f"attempt {attempt_index + 1}: {e}") + verification = { + 'success': False, + 'missed_text_count': len(text_targets), + 'unwanted_change_count': 0, + 'notes': str(e), + } + + score = ( + verification['missed_text_count'], + verification.get('unwanted_change_count', 0), + attempt_index, + ) + logger.info( + "text_only 底图验证结果: success=%s missed=%s unwanted=%s", + verification['success'], + verification['missed_text_count'], + verification.get('unwanted_change_count', 0) + ) + if best_score is None or score < best_score: + best_score = score + best_candidate = (candidate_path, verification) + if verification['success']: + break + + if best_candidate is None: + if verification_errors: + warnings.add_warning(f"text_only 底图修复失败,已回退到原图背景:{verification_errors[-1]}") + return None + + chosen_path, chosen_verification = best_candidate + final_path = output_dir / 'text_only_clean_background.png' + shutil.copyfile(chosen_path, final_path) + if not hasattr(editable_img, 'metadata') or editable_img.metadata is None: + editable_img.metadata = {} + editable_img.metadata['text_only_background_verification'] = chosen_verification + + if not chosen_verification.get('success'): + warnings.add_warning( + "text_only 底图 VLM 验证未完全通过," + f"已选择漏抹最少版本(漏抹 {chosen_verification.get('missed_text_count', 0)} 处)" + ) + + return str(final_path) + + @staticmethod + def _generate_text_only_clean_backgrounds( + editable_images: List, + inpaint_registry, + ai_service, + warnings: ExportWarnings, + verifier=None + ) -> None: + """Generate text-erased backgrounds for all text-only export pages.""" + if inpaint_registry is None: + return + inpaint_provider = inpaint_registry.get_provider('text') + for editable_img in editable_images: + clean_background = ExportService._generate_text_only_clean_background( + editable_img=editable_img, + inpaint_provider=inpaint_provider, + ai_service=ai_service, + warnings=warnings, + verifier=verifier + ) + if clean_background: + editable_img.clean_background = clean_background @staticmethod def _batch_extract_text_styles( @@ -1275,8 +1567,10 @@ def create_editable_pptx_with_recursive_analysis( export_extractor_method: str = 'hybrid', # 组件提取方法: mineru, hybrid export_inpaint_method: str = 'hybrid', # 背景修复方法: generative, baidu, hybrid enable_icon_subject_extraction: bool = False, # 是否对小尺寸图标走百度智能抠图 - text_only: bool = False, # 是否保留整页原图背景,仅叠加可编辑文字层 - fail_fast: bool = True # 是否在遇到错误时立即停止(False则收集警告继续) + text_only: bool = False, # 是否仅让文字层可编辑,底图只抹除文字目标 + fail_fast: bool = True, # 是否在遇到错误时立即停止(False则收集警告继续) + text_only_background_verifier = None, # 测试/定制用:验证 text-only 底图候选 + text_only_inpaint_registry = None # 测试/定制用:提供 text-only 底图修复 provider ) -> Tuple[Optional[bytes], ExportWarnings]: """ 使用递归图片可编辑化服务创建可编辑PPTX @@ -1301,8 +1595,8 @@ def create_editable_pptx_with_recursive_analysis( 可通过 TextAttributeExtractorFactory.create_caption_model_extractor() 创建 export_extractor_method: 组件提取方法 ('mineru' 或 'hybrid',默认 'hybrid') export_inpaint_method: 背景修复方法 ('generative', 'baidu', 'hybrid',默认 'hybrid') - text_only: 仅文字层可编辑。为 True 时使用原始整页图片作为背景, - 跳过图片/图表/表格背景等非文字元素,只叠加识别出的文字元素。 + text_only: 仅文字层可编辑。为 True 时只抹除底图中的文字目标, + 保留图片/图表/图标等非文字元素,并只叠加识别出的文字元素。 fail_fast: 是否在遇到错误时立即停止(默认 True)。设为 False 则收集警告继续导出。 Returns: @@ -1351,6 +1645,7 @@ def report_progress(step: str, message: str, percent: int): enable_icon_subject_extraction=enable_icon_subject_extraction and not text_only, ) if text_only: + text_only_inpaint_registry = text_only_inpaint_registry or config.inpaint_registry config.inpaint_registry = InpaintProviderRegistry() editability_service = ImageEditabilityService(config) @@ -1380,6 +1675,18 @@ def report_progress(step: str, message: str, percent: int): raise editable_images = results + + if text_only and text_only_inpaint_registry is not None: + report_progress("底图修复", "开始生成仅文字抹除底图并进行 VLM 复核...", 41) + ai_service = getattr(text_attribute_extractor, 'ai_service', None) if text_attribute_extractor else None + ExportService._generate_text_only_clean_backgrounds( + editable_images=editable_images, + inpaint_registry=text_only_inpaint_registry, + ai_service=ai_service, + warnings=warnings, + verifier=text_only_background_verifier + ) + report_progress("底图修复", "完成仅文字抹除底图生成", 44) # 2.5. 使用混合策略提取所有文本元素的样式(如果提供了提取器) # 混合策略:全局识别(粗体/斜体/下划线/对齐)+ 单个裁剪识别(颜色) @@ -1432,10 +1739,12 @@ def report_progress(step: str, message: str, percent: int): # 创建空白幻灯片 slide = builder.add_blank_slide() - # 添加背景图:text_only 模式保留整页原图,只叠加文字层。 - background_path = editable_img.image_path if text_only else editable_img.clean_background + # 添加背景图:text_only 模式优先使用只抹除文字目标后的底图。 + background_path = editable_img.clean_background + if text_only and (not background_path or not os.path.exists(background_path)): + background_path = editable_img.image_path if background_path and os.path.exists(background_path): - background_label = "原图背景" if text_only else "clean background" + background_label = "文字抹除底图" if text_only and background_path != editable_img.image_path else "clean background" logger.info(f" 添加{background_label}: {background_path}") try: slide.shapes.add_picture( diff --git a/backend/services/prompts.py b/backend/services/prompts.py index 2b40015df..aefcb1fd0 100644 --- a/backend/services/prompts.py +++ b/backend/services/prompts.py @@ -1094,6 +1094,48 @@ def get_batch_text_attribute_extraction_prompt(text_elements_json: str) -> str: return prompt +def get_text_only_background_verification_prompt(text_elements_json: str) -> str: + """生成仅文字层可编辑导出底图修复质量验证 prompt。""" + prompt = f"""你是一位严谨的 PPT 导出质检员。图片由左右两部分组成:左侧是原始幻灯片,右侧是修复后的底图候选。 + +目标:右侧底图必须移除指定文字区域里的文字内容,同时保留图标、照片、图表、装饰形状、背景纹理等非文字视觉元素。 + +需要移除的文字元素如下(bbox 是原始幻灯片像素坐标): + +```json +{text_elements_json} +``` + +请对比左右两侧,重点检查右侧候选图: +1. 指定文字是否仍有可读残留、明显字形、笔画、阴影或轮廓。 +2. 非文字视觉元素是否被误删或明显损坏。 +3. 背景修复是否留下严重痕迹。 + +只返回 JSON 对象,不要包含其他文字: +```json +{{ + "success": true, + "missed_text_count": 0, + "missed_texts": [ + {{"element_id": "xxx", "content": "仍可读的文字", "reason": "说明"}} + ], + "unwanted_change_count": 0, + "unwanted_changes": [ + {{"reason": "说明"}} + ], + "notes": "简短说明" +}} +``` + +判断规则: +- 只要有任意指定文字仍可读,success 必须为 false,并计入 missed_text_count。 +- 如果右侧已经看不出指定文字,即使局部有轻微修复纹理,也可以 success=true。 +- 不要把左侧原图里的文字算作遗漏,只检查右侧候选图。 +""" + + return prompt + + def get_ppt_page_content_extraction_prompt(markdown_text: str, language: str = None) -> str: """从 fileparser 解析出的 markdown 文本中提取页面内容(title, points, description)""" prompt = f"""\ diff --git a/backend/tests/unit/test_editable_pptx_equations.py b/backend/tests/unit/test_editable_pptx_equations.py index 20a205ff5..9a137a80b 100644 --- a/backend/tests/unit/test_editable_pptx_equations.py +++ b/backend/tests/unit/test_editable_pptx_equations.py @@ -230,10 +230,38 @@ class _EditableImage: width = 300 height = 120 clean_background = None + metadata = None def __init__(self, image_path, elements): self.image_path = image_path self.elements = elements + self.clean_background = None + self.metadata = {} + + +class _FakeTextOnlyInpaintProvider: + def __init__(self, colors): + self.colors = colors + self.calls = [] + self.output_paths = [] + + def inpaint_regions(self, image, bboxes, types=None, **kwargs): + self.calls.append({ + "bboxes": bboxes, + "types": types, + "expand_pixels": kwargs.get("expand_pixels"), + }) + color = self.colors[len(self.calls) - 1] + return Image.new("RGB", image.size, color) + + +class _FakeInpaintRegistry: + def __init__(self, provider): + self.provider = provider + + def get_provider(self, element_type): + assert element_type == "text" + return self.provider def test_editable_export_renders_latex_text_content_as_native_omml(tmp_path): @@ -297,7 +325,7 @@ def test_editable_export_prefers_latex_text_segments_for_formula_source(tmp_path assert r"\geq" not in slide_xml -def test_editable_export_text_only_keeps_original_background_and_text_layers(tmp_path): +def test_editable_export_text_only_uses_clean_background_and_text_layers(tmp_path): original = tmp_path / "original.png" clean_background = tmp_path / "clean.png" chart_image = tmp_path / "chart.png" @@ -335,9 +363,111 @@ def test_editable_export_text_only_keeps_original_background_and_text_layers(tmp assert "Editable title" in slide_xml assert "Chart label" in slide_xml assert len(media_payloads) == 1 - assert original.read_bytes() in media_payloads - assert clean_background.read_bytes() not in media_payloads + assert clean_background.read_bytes() in media_payloads + assert original.read_bytes() not in media_payloads + assert chart_image.read_bytes() not in media_payloads + + +def test_text_only_background_retries_until_vlm_confirms_erasure(tmp_path): + original = tmp_path / "original.png" + chart_image = tmp_path / "chart.png" + Image.new("RGB", (300, 120), "white").save(original) + Image.new("RGB", (120, 60), "green").save(chart_image) + output = tmp_path / "text-only-verified.pptx" + + title = _EditableElement("text", "Editable title") + title.element_id = "title" + chart_label = _EditableElement("text", "Chart label") + chart_label.element_id = "chart_label" + chart_label.bbox = _BBox(20, 20, 140, 50) + chart_label.bbox_global = chart_label.bbox + chart = _EditableElement("figure", "") + chart.element_id = "chart" + chart.image_path = str(chart_image) + chart.children = [chart_label] + editable_image = _EditableImage(str(original), [title, chart]) + + provider = _FakeTextOnlyInpaintProvider([ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + ]) + verifier_results = [ + {"success": False, "missed_text_count": 2}, + {"success": False, "missed_text_count": 1}, + {"success": True, "missed_text_count": 0}, + ] + + def verifier(**kwargs): + return verifier_results[kwargs["attempt_index"] - 1] + + _, warnings = ExportService.create_editable_pptx_with_recursive_analysis( + editable_images=[editable_image], + output_file=str(output), + slide_width_pixels=300, + slide_height_pixels=120, + text_only=True, + fail_fast=True, + text_only_inpaint_registry=_FakeInpaintRegistry(provider), + text_only_background_verifier=verifier, + ) + + media_payloads = _pptx_media_payloads(output) + final_background = tmp_path / "text_only_background" / "text_only_clean_background.png" + + assert len(provider.calls) == 3 + assert provider.calls[0]["bboxes"] == [[40, 30, 260, 90], [20, 20, 140, 50]] + assert provider.calls[0]["types"] == ["text", "text"] + assert len(media_payloads) == 1 + assert final_background.read_bytes() in media_payloads + assert original.read_bytes() not in media_payloads assert chart_image.read_bytes() not in media_payloads + assert not warnings.has_warnings() + assert editable_image.metadata["text_only_background_verification"]["success"] is True + + +def test_text_only_background_picks_least_missed_candidate_after_retries(tmp_path): + original = tmp_path / "original.png" + Image.new("RGB", (300, 120), "white").save(original) + output = tmp_path / "text-only-best-effort.pptx" + editable_image = _EditableImage(str(original), [_EditableElement("text", "Editable title")]) + + provider = _FakeTextOnlyInpaintProvider([ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + ]) + verifier_results = [ + {"success": False, "missed_text_count": 3}, + {"success": False, "missed_text_count": 1}, + {"success": False, "missed_text_count": 2}, + ] + + def verifier(**kwargs): + return verifier_results[kwargs["attempt_index"] - 1] + + _, warnings = ExportService.create_editable_pptx_with_recursive_analysis( + editable_images=[editable_image], + output_file=str(output), + slide_width_pixels=300, + slide_height_pixels=120, + text_only=True, + fail_fast=True, + text_only_inpaint_registry=_FakeInpaintRegistry(provider), + text_only_background_verifier=verifier, + ) + + media_payloads = _pptx_media_payloads(output) + second_attempt = tmp_path / "text_only_background" / "text_only_background_attempt_2.png" + final_background = tmp_path / "text_only_background" / "text_only_clean_background.png" + + assert len(provider.calls) == 3 + assert second_attempt.read_bytes() == final_background.read_bytes() + assert len(media_payloads) == 1 + assert final_background.read_bytes() in media_payloads + assert warnings.has_warnings() + assert "漏抹 1 处" in warnings.other_warnings[0] + assert editable_image.metadata["text_only_background_verification"]["missed_text_count"] == 1 def test_equation_metadata_without_latex_content_stays_plain_text(tmp_path): diff --git a/docs/features/export.mdx b/docs/features/export.mdx index b44265bc9..25b46dc87 100644 --- a/docs/features/export.mdx +++ b/docs/features/export.mdx @@ -43,7 +43,7 @@ Defaults to 16:9. You can change the ratio when creating a project or in **Proje The extraction process preserves font size, color, bold styling, text positioning, and table content. However, due to OCR accuracy limits and current model capabilities, complex layouts may have discrepancies from the original slide appearance. -In the export dialog, you can enable **Only Text Layers Editable**. It is off by default. When enabled, Banana Slides keeps the full slide image as the background and overlays only detected text as editable text boxes, which is useful when visual fidelity matters most and you only need to edit copy. +In the export dialog, you can enable **Only Text Layers Editable**. It is off by default. When enabled, Banana Slides generates a background that erases only detected text while preserving icons, images, charts, and other non-text visuals, then overlays the detected text as editable text boxes. VLM verification checks whether the background still contains text; if text remains, Banana Slides retries the erasure and keeps the candidate with the fewest missed text regions when all retries fail. For best extraction results, configure `BAIDU_API_KEY` in your environment. See [Configuration](/configuration#baidu-api-key). diff --git a/docs/zh/features/export.mdx b/docs/zh/features/export.mdx index af75088bf..4bc44f0fa 100644 --- a/docs/zh/features/export.mdx +++ b/docs/zh/features/export.mdx @@ -43,7 +43,7 @@ description: "导出为 PPTX、PDF 或图片" 提取过程保留字号、颜色、加粗样式、文字定位和表格内容,但受限于 OCR 精度和当前模型能力,复杂排版可能存在偏差,与原图效果有出入。 -导出前的二级面板可勾选「仅文字层可编辑」,默认关闭。开启后会保留整页原图作为背景,只叠加识别出的可编辑文字框,适合优先保持视觉还原度、只需要修改文字内容的场景。 +导出前的二级面板可勾选「仅文字层可编辑」,默认关闭。开启后会生成只抹除文字的底图,保留图标、图片、图表等非文字视觉元素,并叠加识别出的可编辑文字框。系统会用 VLM 复核底图文字是否抹除;若仍有文字残留会自动重试,多次失败时选择漏抹最少的底图版本。 配置 `BAIDU_API_KEY` 可获得最佳提取效果,详见[配置说明](/zh/configuration#百度-api-key)。 From d7ab7a3b686b663dea2a99bc3fc35a7a52eb3f95 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 20:58:18 +0800 Subject: [PATCH 6/8] Address text-only background review feedback --- backend/services/export_service.py | 22 ++++++++++---- backend/services/image_editability/service.py | 2 +- .../unit/test_editable_pptx_equations.py | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/backend/services/export_service.py b/backend/services/export_service.py index bf4d9c824..96edb11e9 100644 --- a/backend/services/export_service.py +++ b/backend/services/export_service.py @@ -1004,7 +1004,10 @@ def _generate_text_only_clean_background( return None attempts = max_attempts or ExportService.TEXT_ONLY_BACKGROUND_MAX_ATTEMPTS - attempts = max(1, min(attempts, len(ExportService.TEXT_ONLY_BACKGROUND_EXPAND_PIXELS))) + if ai_service is None and verifier is None: + attempts = 1 + else: + attempts = max(1, min(attempts, len(ExportService.TEXT_ONLY_BACKGROUND_EXPAND_PIXELS))) original_path = editable_img.image_path output_dir = Path(original_path).parent / 'text_only_background' output_dir.mkdir(parents=True, exist_ok=True) @@ -1048,7 +1051,11 @@ def _generate_text_only_clean_background( text_targets=text_targets, attempt_index=attempt_index + 1 ) - else: + verification = ExportService._parse_text_only_background_verification( + verification, + fallback_missed_count=len(text_targets) + ) + elif ai_service: verification = ExportService._verify_text_only_background( ai_service=ai_service, original_path=original_path, @@ -1057,10 +1064,13 @@ def _generate_text_only_clean_background( output_dir=output_dir, attempt_index=attempt_index + 1 ) - verification = ExportService._parse_text_only_background_verification( - verification, - fallback_missed_count=len(text_targets) - ) + else: + verification = { + 'success': True, + 'missed_text_count': 0, + 'unwanted_change_count': 0, + 'notes': 'Skipped VLM verification (no AI service available)', + } except Exception as e: logger.warning("text_only 底图 VLM 验证失败: %s", e, exc_info=True) verification_errors.append(f"attempt {attempt_index + 1}: {e}") diff --git a/backend/services/image_editability/service.py b/backend/services/image_editability/service.py index f215f95b0..c96c96d60 100644 --- a/backend/services/image_editability/service.py +++ b/backend/services/image_editability/service.py @@ -72,7 +72,7 @@ def __init__(self, config: ServiceConfig): self._enable_icon_subject_extraction = config.enable_icon_subject_extraction extractors = self._extractor_registry.get_all_extractors() - inpaint_providers = self._inpaint_registry.get_all_providers() + inpaint_providers = self._inpaint_registry.get_all_providers() if self._inpaint_registry else [] self._has_inpaint_providers = bool(inpaint_providers) logger.info( f"ImageEditabilityService: {len(extractors)} extractors, " diff --git a/backend/tests/unit/test_editable_pptx_equations.py b/backend/tests/unit/test_editable_pptx_equations.py index 9a137a80b..dfacb0ec0 100644 --- a/backend/tests/unit/test_editable_pptx_equations.py +++ b/backend/tests/unit/test_editable_pptx_equations.py @@ -470,6 +470,35 @@ def verifier(**kwargs): assert editable_image.metadata["text_only_background_verification"]["missed_text_count"] == 1 +def test_text_only_background_without_vlm_only_inpaints_once(tmp_path): + original = tmp_path / "original.png" + Image.new("RGB", (300, 120), "white").save(original) + output = tmp_path / "text-only-no-vlm.pptx" + editable_image = _EditableImage(str(original), [_EditableElement("text", "Editable title")]) + provider = _FakeTextOnlyInpaintProvider([ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + ]) + + _, warnings = ExportService.create_editable_pptx_with_recursive_analysis( + editable_images=[editable_image], + output_file=str(output), + slide_width_pixels=300, + slide_height_pixels=120, + text_only=True, + fail_fast=True, + text_only_inpaint_registry=_FakeInpaintRegistry(provider), + ) + + assert len(provider.calls) == 1 + assert provider.calls[0]["expand_pixels"] == 2 + assert not warnings.has_warnings() + assert editable_image.metadata["text_only_background_verification"]["notes"] == ( + "Skipped VLM verification (no AI service available)" + ) + + def test_equation_metadata_without_latex_content_stays_plain_text(tmp_path): background = tmp_path / "slide.png" Image.new("RGB", (300, 120), "white").save(background) From 5ae3a291d04c927db99025ce267b6ddff8d27784 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 21:04:38 +0800 Subject: [PATCH 7/8] Close text-only background image handles --- backend/services/export_service.py | 200 ++++++++++++++++------------- 1 file changed, 111 insertions(+), 89 deletions(-) diff --git a/backend/services/export_service.py b/backend/services/export_service.py index 96edb11e9..9bde69b16 100644 --- a/backend/services/export_service.py +++ b/backend/services/export_service.py @@ -895,23 +895,33 @@ def _create_text_only_verification_image( ) -> str: """Create a side-by-side image: original on the left, candidate on the right.""" with Image.open(original_path) as original_img, Image.open(candidate_path) as candidate_img: - original = original_img.convert('RGB') - candidate = candidate_img.convert('RGB').resize(original.size) - width, height = original.size - header_h = max(36, int(height * 0.035)) - canvas = Image.new('RGB', (width * 2, height + header_h), 'white') - canvas.paste(original, (0, header_h)) - canvas.paste(candidate, (width, header_h)) - - try: - from PIL import ImageDraw - draw = ImageDraw.Draw(canvas) - draw.text((12, 10), "LEFT: original", fill=(0, 0, 0)) - draw.text((width + 12, 10), "RIGHT: candidate background", fill=(0, 0, 0)) - except Exception: - logger.debug("Failed to draw verification labels", exc_info=True) - - canvas.save(output_path) + with original_img.convert('RGB') as original: + candidate_rgb = candidate_img.convert('RGB') + try: + candidate = candidate_rgb.resize(original.size) + try: + width, height = original.size + header_h = max(36, int(height * 0.035)) + canvas = Image.new('RGB', (width * 2, height + header_h), 'white') + try: + canvas.paste(original, (0, header_h)) + canvas.paste(candidate, (width, header_h)) + + try: + from PIL import ImageDraw + draw = ImageDraw.Draw(canvas) + draw.text((12, 10), "LEFT: original", fill=(0, 0, 0)) + draw.text((width + 12, 10), "RIGHT: candidate background", fill=(0, 0, 0)) + except Exception: + logger.debug("Failed to draw verification labels", exc_info=True) + + canvas.save(output_path) + finally: + canvas.close() + finally: + candidate.close() + finally: + candidate_rgb.close() return output_path @@ -1020,83 +1030,95 @@ def _generate_text_only_clean_background( with Image.open(original_path) as source_img: source = source_img.convert('RGB') - for attempt_index in range(attempts): - expand_pixels = ExportService.TEXT_ONLY_BACKGROUND_EXPAND_PIXELS[attempt_index] - logger.info( - "text_only 底图修复尝试 %s/%s: targets=%s expand=%s", - attempt_index + 1, - attempts, - len(text_targets), - expand_pixels - ) - candidate_img = inpaint_provider.inpaint_regions( - image=source.copy(), - bboxes=bboxes, - types=types, - expand_pixels=expand_pixels, - save_mask_path=str(output_dir / f'text_only_mask_attempt_{attempt_index + 1}.png') - ) - if candidate_img is None: - verification_errors.append(f"attempt {attempt_index + 1}: inpaint returned empty") - continue - - candidate_path = output_dir / f"text_only_background_attempt_{attempt_index + 1}.png" - candidate_img.save(str(candidate_path)) - - try: - if verifier: - verification = verifier( - original_path=original_path, - candidate_path=str(candidate_path), - text_targets=text_targets, - attempt_index=attempt_index + 1 - ) - verification = ExportService._parse_text_only_background_verification( - verification, - fallback_missed_count=len(text_targets) - ) - elif ai_service: - verification = ExportService._verify_text_only_background( - ai_service=ai_service, - original_path=original_path, - candidate_path=str(candidate_path), - text_targets=text_targets, - output_dir=output_dir, - attempt_index=attempt_index + 1 + try: + for attempt_index in range(attempts): + expand_pixels = ExportService.TEXT_ONLY_BACKGROUND_EXPAND_PIXELS[attempt_index] + logger.info( + "text_only 底图修复尝试 %s/%s: targets=%s expand=%s", + attempt_index + 1, + attempts, + len(text_targets), + expand_pixels + ) + candidate_img = None + source_copy = source.copy() + try: + candidate_img = inpaint_provider.inpaint_regions( + image=source_copy, + bboxes=bboxes, + types=types, + expand_pixels=expand_pixels, + save_mask_path=str(output_dir / f'text_only_mask_attempt_{attempt_index + 1}.png') ) - else: + finally: + if candidate_img is not source_copy: + source_copy.close() + if candidate_img is None: + verification_errors.append(f"attempt {attempt_index + 1}: inpaint returned empty") + continue + + candidate_path = output_dir / f"text_only_background_attempt_{attempt_index + 1}.png" + try: + candidate_img.save(str(candidate_path)) + finally: + candidate_img.close() + + try: + if verifier: + verification = verifier( + original_path=original_path, + candidate_path=str(candidate_path), + text_targets=text_targets, + attempt_index=attempt_index + 1 + ) + verification = ExportService._parse_text_only_background_verification( + verification, + fallback_missed_count=len(text_targets) + ) + elif ai_service: + verification = ExportService._verify_text_only_background( + ai_service=ai_service, + original_path=original_path, + candidate_path=str(candidate_path), + text_targets=text_targets, + output_dir=output_dir, + attempt_index=attempt_index + 1 + ) + else: + verification = { + 'success': True, + 'missed_text_count': 0, + 'unwanted_change_count': 0, + 'notes': 'Skipped VLM verification (no AI service available)', + } + except Exception as e: + logger.warning("text_only 底图 VLM 验证失败: %s", e, exc_info=True) + verification_errors.append(f"attempt {attempt_index + 1}: {e}") verification = { - 'success': True, - 'missed_text_count': 0, + 'success': False, + 'missed_text_count': len(text_targets), 'unwanted_change_count': 0, - 'notes': 'Skipped VLM verification (no AI service available)', + 'notes': str(e), } - except Exception as e: - logger.warning("text_only 底图 VLM 验证失败: %s", e, exc_info=True) - verification_errors.append(f"attempt {attempt_index + 1}: {e}") - verification = { - 'success': False, - 'missed_text_count': len(text_targets), - 'unwanted_change_count': 0, - 'notes': str(e), - } - score = ( - verification['missed_text_count'], - verification.get('unwanted_change_count', 0), - attempt_index, - ) - logger.info( - "text_only 底图验证结果: success=%s missed=%s unwanted=%s", - verification['success'], - verification['missed_text_count'], - verification.get('unwanted_change_count', 0) - ) - if best_score is None or score < best_score: - best_score = score - best_candidate = (candidate_path, verification) - if verification['success']: - break + score = ( + verification['missed_text_count'], + verification.get('unwanted_change_count', 0), + attempt_index, + ) + logger.info( + "text_only 底图验证结果: success=%s missed=%s unwanted=%s", + verification['success'], + verification['missed_text_count'], + verification.get('unwanted_change_count', 0) + ) + if best_score is None or score < best_score: + best_score = score + best_candidate = (candidate_path, verification) + if verification['success']: + break + finally: + source.close() if best_candidate is None: if verification_errors: From 33551ed4d7f5f6c647ee713e17b4238692c151d8 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 3 Jul 2026 22:43:53 +0800 Subject: [PATCH 8/8] Fix text-only export background isolation --- backend/controllers/project_controller.py | 1 + backend/controllers/settings_controller.py | 1 + backend/services/export_service.py | 2 +- backend/services/file_parser_service.py | 27 ++++++------- .../services/image_editability/factories.py | 14 +++++-- .../unit/test_editable_pptx_equations.py | 39 +++++++++++++++++-- .../tests/unit/test_file_parser_service.py | 27 +++++++++++++ 7 files changed, 87 insertions(+), 24 deletions(-) diff --git a/backend/controllers/project_controller.py b/backend/controllers/project_controller.py index 7ce3cff6e..aafcc95a6 100644 --- a/backend/controllers/project_controller.py +++ b/backend/controllers/project_controller.py @@ -1567,6 +1567,7 @@ def create_ppt_renovation_project(): image_caption_model=current_app.config['IMAGE_CAPTION_MODEL'], provider_format=current_app.config.get('AI_PROVIDER_FORMAT', 'gemini'), lazyllm_image_caption_source=current_app.config.get('IMAGE_CAPTION_MODEL_SOURCE', 'doubao'), + upload_folder=current_app.config['UPLOAD_FOLDER'], ) app = current_app._get_current_object() diff --git a/backend/controllers/settings_controller.py b/backend/controllers/settings_controller.py index 5eb3abbdc..a5c8d40d9 100644 --- a/backend/controllers/settings_controller.py +++ b/backend/controllers/settings_controller.py @@ -909,6 +909,7 @@ def _create_file_parser(): Config, 'IMAGE_CAPTION_MODEL_SOURCE', None ), provider_format=caption_format, + upload_folder=current_app.config.get("UPLOAD_FOLDER", Config.UPLOAD_FOLDER), ) diff --git a/backend/services/export_service.py b/backend/services/export_service.py index 9bde69b16..9735d3e4a 100644 --- a/backend/services/export_service.py +++ b/backend/services/export_service.py @@ -1019,7 +1019,7 @@ def _generate_text_only_clean_background( else: attempts = max(1, min(attempts, len(ExportService.TEXT_ONLY_BACKGROUND_EXPAND_PIXELS))) original_path = editable_img.image_path - output_dir = Path(original_path).parent / 'text_only_background' + output_dir = Path(original_path).parent / 'text_only_background' / Path(original_path).stem output_dir.mkdir(parents=True, exist_ok=True) bboxes = [target['bbox'] for target in text_targets] diff --git a/backend/services/file_parser_service.py b/backend/services/file_parser_service.py index 0e6826bf4..fc95992cc 100644 --- a/backend/services/file_parser_service.py +++ b/backend/services/file_parser_service.py @@ -9,6 +9,7 @@ import io import requests import tempfile +from pathlib import Path from typing import Optional, List from concurrent.futures import ThreadPoolExecutor, as_completed from PIL import Image @@ -58,6 +59,7 @@ def __init__(self, mineru_token: str, mineru_api_base: str = "https://mineru.net lazyllm_image_caption_source: str = "", provider_format: str = None, mineru_model_version: str = "vlm", + upload_folder: Optional[str] = None, ): """ Initialize the file parser service @@ -73,6 +75,7 @@ def __init__(self, mineru_token: str, mineru_api_base: str = "https://mineru.net lazyllm_image_caption_source: image caption model provider for lazyllm provider_format: AI provider format ('gemini' or 'openai'). If not provided, reads from environment variable. mineru_model_version: MinerU model version ('vlm' or 'pipeline'). Default is 'vlm'. + upload_folder: Folder used by the Flask file server for parsed MinerU assets. """ self.mineru_token = mineru_token self.mineru_api_base = mineru_api_base @@ -83,6 +86,11 @@ def __init__(self, mineru_token: str, mineru_api_base: str = "https://mineru.net self._image_caption_model = image_caption_model self._provider_format = _get_ai_provider_format(provider_format) self._caption_provider = None + if upload_folder: + self._upload_folder = Path(upload_folder).expanduser().resolve() + else: + current_file = Path(__file__).resolve() + self._upload_folder = current_file.parent.parent.parent / 'uploads' def _get_caption_provider(self): """Lazily initialize caption provider via the provider factory""" @@ -379,18 +387,8 @@ def _download_markdown(self, zip_url: str) -> tuple[Optional[str], Optional[str] import uuid extract_id = str(uuid.uuid4())[:8] - # Get upload folder from Flask config (we'll need to pass this) - # For now, use a hardcoded path relative to project root - import os - from pathlib import Path - - # Navigate to project root (assuming this file is in backend/services/) - current_file = Path(__file__).resolve() - backend_dir = current_file.parent.parent - project_root = backend_dir.parent - # Create directory for mineru extracts - mineru_storage = project_root / 'uploads' / 'mineru_files' / extract_id + mineru_storage = self._upload_folder / 'mineru_files' / extract_id mineru_storage.mkdir(parents=True, exist_ok=True) logger.info(f"Extracting ZIP to: {mineru_storage}") @@ -440,8 +438,7 @@ def _download_markdown(self, zip_url: str) -> tuple[Optional[str], Optional[str] logger.error(error_msg) return None, None, error_msg - @staticmethod - def extract_header_footer_from_layout(extract_id: str) -> str: + def extract_header_footer_from_layout(self, extract_id: str) -> str: """ 从 MinerU layout.json 的 discarded_blocks 中提取页眉页脚文本。 @@ -454,9 +451,7 @@ def extract_header_footer_from_layout(extract_id: str) -> str: import json from pathlib import Path - current_file = Path(__file__).resolve() - project_root = current_file.parent.parent.parent - mineru_dir = project_root / 'uploads' / 'mineru_files' / extract_id + mineru_dir = self._upload_folder / 'mineru_files' / extract_id layout_file = mineru_dir / 'layout.json' if not layout_file.exists(): diff --git a/backend/services/image_editability/factories.py b/backend/services/image_editability/factories.py index 1605f23ac..59a8f748b 100644 --- a/backend/services/image_editability/factories.py +++ b/backend/services/image_editability/factories.py @@ -308,7 +308,7 @@ def create_default_provider(inpainting_service: Optional[Any] = None) -> Optiona def create_generative_edit_provider( ai_service: Optional[Any] = None, aspect_ratio: str = "16:9", - resolution: str = "2K" + resolution: Optional[str] = None ) -> InpaintProvider: """ 创建基于生成式大模型的Inpaint提供者 @@ -319,7 +319,7 @@ def create_generative_edit_provider( Args: ai_service: AIService实例(可选,如果不提供则自动获取) aspect_ratio: 目标宽高比 - resolution: 目标分辨率 + resolution: 目标分辨率;未提供时跟随当前应用的 DEFAULT_RESOLUTION Returns: GenerativeEditInpaintProvider实例 @@ -330,6 +330,12 @@ def create_generative_edit_provider( if ai_service is None: from services.ai_service_manager import get_ai_service ai_service = get_ai_service() + if resolution is None: + try: + from flask import current_app + resolution = current_app.config.get('DEFAULT_RESOLUTION', '2K') + except RuntimeError: + resolution = '2K' logger.info("创建GenerativeEditInpaintProvider") return GenerativeEditInpaintProvider(ai_service, aspect_ratio, resolution) @@ -587,7 +593,8 @@ def from_defaults( # 创建MinerU解析服务 parser_service = FileParserService( mineru_token=mineru_token, - mineru_api_base=mineru_api_base + mineru_api_base=mineru_api_base, + upload_folder=str(upload_path), ) # 创建提取器注册表 @@ -763,4 +770,3 @@ def create_text_attribute_registry( logger.info("创建TextAttributeExtractorRegistry") return registry - diff --git a/backend/tests/unit/test_editable_pptx_equations.py b/backend/tests/unit/test_editable_pptx_equations.py index dfacb0ec0..0abcf39ea 100644 --- a/backend/tests/unit/test_editable_pptx_equations.py +++ b/backend/tests/unit/test_editable_pptx_equations.py @@ -413,7 +413,7 @@ def verifier(**kwargs): ) media_payloads = _pptx_media_payloads(output) - final_background = tmp_path / "text_only_background" / "text_only_clean_background.png" + final_background = tmp_path / "text_only_background" / original.stem / "text_only_clean_background.png" assert len(provider.calls) == 3 assert provider.calls[0]["bboxes"] == [[40, 30, 260, 90], [20, 20, 140, 50]] @@ -458,8 +458,8 @@ def verifier(**kwargs): ) media_payloads = _pptx_media_payloads(output) - second_attempt = tmp_path / "text_only_background" / "text_only_background_attempt_2.png" - final_background = tmp_path / "text_only_background" / "text_only_clean_background.png" + second_attempt = tmp_path / "text_only_background" / original.stem / "text_only_background_attempt_2.png" + final_background = tmp_path / "text_only_background" / original.stem / "text_only_clean_background.png" assert len(provider.calls) == 3 assert second_attempt.read_bytes() == final_background.read_bytes() @@ -499,6 +499,39 @@ def test_text_only_background_without_vlm_only_inpaints_once(tmp_path): ) +def test_text_only_backgrounds_are_unique_per_slide_image(tmp_path): + first = tmp_path / "slide_a.png" + second = tmp_path / "slide_b.png" + Image.new("RGB", (300, 120), "white").save(first) + Image.new("RGB", (300, 120), "white").save(second) + output = tmp_path / "text-only-unique-backgrounds.pptx" + first_editable = _EditableImage(str(first), [_EditableElement("text", "First")]) + second_editable = _EditableImage(str(second), [_EditableElement("text", "Second")]) + provider = _FakeTextOnlyInpaintProvider([ + (255, 0, 0), + (0, 255, 0), + ]) + + ExportService.create_editable_pptx_with_recursive_analysis( + editable_images=[first_editable, second_editable], + output_file=str(output), + slide_width_pixels=300, + slide_height_pixels=120, + text_only=True, + fail_fast=True, + text_only_inpaint_registry=_FakeInpaintRegistry(provider), + ) + + first_background = tmp_path / "text_only_background" / first.stem / "text_only_clean_background.png" + second_background = tmp_path / "text_only_background" / second.stem / "text_only_clean_background.png" + media_payloads = _pptx_media_payloads(output) + + assert first_background.read_bytes() != second_background.read_bytes() + assert first_background.read_bytes() in media_payloads + assert second_background.read_bytes() in media_payloads + assert len(media_payloads) == 2 + + def test_equation_metadata_without_latex_content_stays_plain_text(tmp_path): background = tmp_path / "slide.png" Image.new("RGB", (300, 120), "white").save(background) diff --git a/backend/tests/unit/test_file_parser_service.py b/backend/tests/unit/test_file_parser_service.py index ff264e279..2b9de92d6 100644 --- a/backend/tests/unit/test_file_parser_service.py +++ b/backend/tests/unit/test_file_parser_service.py @@ -4,6 +4,8 @@ import os import tempfile +import zipfile +import io from pathlib import Path from unittest.mock import MagicMock, patch @@ -90,3 +92,28 @@ def test_generate_single_caption_vertex_uses_provider_factory(): finally: if os.path.exists(image_path): os.remove(image_path) + + +def test_download_markdown_uses_configured_upload_folder(tmp_path): + """MinerU extracted files must land under the configured upload folder.""" + zip_bytes = io.BytesIO() + with zipfile.ZipFile(zip_bytes, 'w') as archive: + archive.writestr('full.md', 'hello') + zip_bytes.seek(0) + + response = MagicMock() + response.content = zip_bytes.getvalue() + response.raise_for_status.return_value = None + + service = FileParserService( + mineru_token='test-token', + provider_format='openai', + upload_folder=str(tmp_path), + ) + + with patch('services.file_parser_service.requests.get', return_value=response): + markdown, extract_id, error = service._download_markdown('https://example.com/result.zip') + + assert error is None + assert markdown == 'hello' + assert (tmp_path / 'mineru_files' / extract_id / 'full.md').exists()