From 52e5fbc5f47a9da78f5960d106d6f1543b5f17f9 Mon Sep 17 00:00:00 2001 From: Lriver Date: Mon, 29 Jun 2026 20:26:18 +0800 Subject: [PATCH 01/16] Add editable PPTX OpenSpec plans --- .../add-editable-pptx-export/.openspec.yaml | 2 + .../add-editable-pptx-export/design.md | 150 +++++++++++ .../add-editable-pptx-export/proposal.md | 32 +++ .../specs/editable-pptx-export/spec.md | 174 +++++++++++++ .../changes/add-editable-pptx-export/tasks.md | 55 +++++ .../.openspec.yaml | 2 + .../design.md | 232 ++++++++++++++++++ .../proposal.md | 38 +++ .../generative-editable-pptx-export/spec.md | 213 ++++++++++++++++ .../tasks.md | 87 +++++++ 10 files changed, 985 insertions(+) create mode 100644 openspec/changes/add-editable-pptx-export/.openspec.yaml create mode 100644 openspec/changes/add-editable-pptx-export/design.md create mode 100644 openspec/changes/add-editable-pptx-export/proposal.md create mode 100644 openspec/changes/add-editable-pptx-export/specs/editable-pptx-export/spec.md create mode 100644 openspec/changes/add-editable-pptx-export/tasks.md create mode 100644 openspec/changes/add-generative-editable-pptx-export/.openspec.yaml create mode 100644 openspec/changes/add-generative-editable-pptx-export/design.md create mode 100644 openspec/changes/add-generative-editable-pptx-export/proposal.md create mode 100644 openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md create mode 100644 openspec/changes/add-generative-editable-pptx-export/tasks.md diff --git a/openspec/changes/add-editable-pptx-export/.openspec.yaml b/openspec/changes/add-editable-pptx-export/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/add-editable-pptx-export/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/add-editable-pptx-export/design.md b/openspec/changes/add-editable-pptx-export/design.md new file mode 100644 index 0000000..738b5e6 --- /dev/null +++ b/openspec/changes/add-editable-pptx-export/design.md @@ -0,0 +1,150 @@ +## Context + +AIPPT's current `/api/export` path accepts slide images and exports PDF or PPTX. The PPTX branch creates a blank deck and inserts each slide image as one full-slide picture. This is visually stable but does not satisfy the new requirement that text, major visual layers, and simple layout geometry remain editable after opening the deck in PowerPoint. + +The selected architecture is compositional rather than a wholesale import of any reference project: + +- Use the manifest/finalize/validate pattern from `image-to-editable-ppt-skill`. +- Use deterministic OCR text-box, pixel-to-PPT coordinate, font fitting, and mask/inpaint ideas from `OCRPDF-TO-PPT`. +- Use the layer ordering idea from `slide-alchemy`: cleaned base, native geometry, bitmap visual layers, editable text. +- Replace default prompt-driven asset regeneration with a pluggable image-layer decomposition provider, so the normal path preserves source pixels and avoids regenerating every visual asset. + +Development must follow test-driven development: write or update the failing test for each behavior before implementation, implement the smallest passing change, then run the relevant backend/frontend verification before marking the task complete. + +## Goals / Non-Goals + +**Goals:** + +- Add an explicit editable PPTX export mode while preserving the existing PDF and image-based PPTX behavior. +- Produce a structured intermediate manifest for each deck and page. +- Rebuild text as editable PPT text boxes using AIPPT metadata when available and OCR as a fallback. +- Remove baked text from the background before overlaying editable text, using local deterministic cleanup first. +- Extract movable bitmap layers through a provider interface, with Qwen-style image-layer decomposition as the intended default integration. +- Convert simple geometry into PPT-native shapes when confidence is high. +- Compose the final PPTX deterministically from the manifest and validate the output before returning it. +- Make the pipeline testable without live model credentials by providing fake OCR, layer, and repair providers. + +**Non-Goals:** + +- Perfectly reconstruct every complex visual as a fully native PowerPoint object. +- Make arbitrary bitmap icons editable at the vector-path level. +- Replace the normal `pptx` export behavior with editable export behavior. +- Require Codex, page workers, or a skill runtime in production. +- Make prompt-driven image generation/editing the default decomposition method. + +## Decisions + +### Decision 1: Add `editable_pptx` as a distinct export mode + +The existing `pptx` export remains the stable raster PPTX path. Editable reconstruction is slower, has more dependencies, and can fail validation for specific slides, so it must be requested explicitly through `format: "editable_pptx"` or an equivalent API field. + +Editable export options live under an optional `editable_options` request object so existing callers remain compatible. The first supported option is `fallback_policy`, with `fail` as the default and `raster_pptx` as the only permissive fallback. + +Alternatives considered: + +- Replace `pptx` with editable export. Rejected because existing users may rely on visual fidelity and fast export. +- Add a separate endpoint only. Rejected as the only interface because the frontend already has an export abstraction; a format flag keeps the user-facing flow coherent. A separate internal service/module is still appropriate behind the route. + +### Decision 2: Make the editable manifest the source of truth + +Each run creates a deck manifest plus page manifests. Page manifests store source size, slide size, background asset, images, shapes, text boxes, provenance, validation checks, and fallback decisions. Coordinates are authored in source-image pixels and normalized only during composition. + +Alternatives considered: + +- Directly build PPTX while processing each page. Rejected because it makes validation, retries, previews, and tests harder. +- Reuse one reference project's manifest unchanged. Rejected because AIPPT needs its own API and fallback semantics. + +### Decision 3: Prefer source-preserving decomposition over prompt regeneration + +The default layer extractor is a provider interface that returns RGBA layers with boxes, masks, and confidence metadata. A Qwen-style image-layer model is the intended provider. Prompt-driven image edit/generation is only an explicit fallback provider, never the default splitting path. + +Alternatives considered: + +- Use `gpt-image-2`/image edit to regenerate clean bases and asset sheets. Rejected as default because it increases cost and can drift from the source. +- Use direct source crops for every non-text visual. Rejected as the only method because overlapping content and baked text require masks/layers, but direct crops remain acceptable for validated isolated assets. + +### Decision 4: Text uses metadata-first extraction, OCR fallback, and native PPT boxes + +If AIPPT has slide text metadata from outline/prompt generation, the pipeline uses it as semantic truth and uses OCR/layout detection for position and style hints. If metadata is missing, OCR provides content, boxes, colors, and measured font hints. Pixel text is removed from the background when editable text overlays are used. + +Alternatives considered: + +- OCR only. Rejected because AIPPT often knows the generated text and can avoid OCR character mistakes. +- Keep text as pixels. Rejected because text editability is a core requirement. + +### Decision 5: Background cleanup is tiered and local-first + +The background cleaner builds masks from accepted text boxes and removes baked text with local fill for flat regions, OpenCV inpaint for moderate regions, and an optional configured remote/image-edit fallback for complex cases. The manifest records which strategy was used per page. + +Alternatives considered: + +- Always use image editing. Rejected due to cost and drift. +- Never clean the background. Rejected because editable text overlays would duplicate baked text. + +### Decision 6: Shape fitting is conservative + +Only high-confidence simple geometry becomes native PPT shapes. Supported initial shapes are rectangles, rounded rectangles, ellipses if needed, straight lines, simple fills, strokes, opacity, and basic z-order. Complex illustrations, icons, and uncertain objects remain bitmap layers. + +Alternatives considered: + +- Convert every detected layer to native shapes. Rejected because poor shape fitting is worse than a faithful bitmap layer. +- Keep all non-text visuals as images. Rejected because simple layout objects should be editable when safe. + +### Decision 7: Validation gates the returned editable deck + +The composer emits preview images and structural validation. Validation checks page count, slide dimensions, source pixel coordinate coverage, no full-slide source image plus editable text unless it is a cleaned background, text presence, image asset existence, and basic z-order. If strict validation fails, the API returns a clear error or an explicit fallback result according to request options. + +Alternatives considered: + +- Return best-effort PPTX without validation. Rejected because failures would be hidden until the user opens PowerPoint. + +### Decision 8: Fallback behavior is explicit and default-fail + +Editable export failures do not silently return a raster deck. If `editable_options.fallback_policy` is omitted or set to `fail`, provider failures and validation failures return an error with page-level details. If it is set to `raster_pptx`, the route may return the existing raster PPTX result and include response metadata indicating that editable reconstruction failed and fallback was used. + +Alternatives considered: + +- Always fall back to raster PPTX. Rejected because users may assume the returned deck is editable. +- Never allow fallback. Rejected because a user may prefer a downloadable deck over a hard failure for time-sensitive work. + +### Decision 9: First progress model is synchronous and stage-based + +The first implementation keeps the existing synchronous export request shape. The frontend displays stage-based progress for request start, upload, server processing, response download, and completion/error. Per-page backend progress through jobs or SSE is a future enhancement, not part of this change. + +Alternatives considered: + +- Introduce an async export job API immediately. Rejected for the first implementation because it expands the scope into job persistence, cancellation, polling/SSE, and cleanup semantics. +- Keep the UI with no progress during editable export. Rejected because editable export can take noticeably longer than raster export. + +### Decision 10: Provider configuration is part of the contract + +OCR, layer extraction, and repair providers are selected through backend configuration. Tests use fake providers that require no credentials. Production providers fail fast with a clear configuration error when selected but not configured. The first provider contract covers request timeout, max pages, model/service identifier, and deterministic fake fixture selection. + +Alternatives considered: + +- Instantiate providers directly inside the pipeline. Rejected because it makes testing and fallback behavior brittle. +- Require real provider credentials for all tests. Rejected because CI and local TDD must run without external model access. + +## Risks / Trade-offs + +- [Layer provider is unavailable or too slow] -> Provide fake/local providers for tests, timeouts, clear error messages, and optional fallback to raster PPTX only when explicitly allowed. +- [OCR misses or misreads text] -> Prefer AIPPT metadata for content, keep OCR confidence/provenance, and expose validation warnings. +- [Background cleanup damages visual content] -> Keep original images, write cleaned assets separately, record strategy, and allow page-level fallback. +- [Shape fitting creates inaccurate native objects] -> Use conservative confidence thresholds and keep uncertain layers as bitmap images. +- [Large decks consume memory or time] -> Process pages in bounded workers, write intermediate assets to a job directory, and clean temporary output after response completion. +- [New dependencies are hard to install] -> Encapsulate providers behind interfaces and keep tests runnable with pure fake providers. +- [Users cannot tell whether fallback happened] -> Use default-fail behavior and expose fallback-used metadata when `raster_pptx` fallback is explicitly requested. + +## Migration Plan + +1. Add the new editable export modules without changing existing PDF or `pptx` output. +2. Extend backend and frontend types to expose the new export mode. +3. Add provider configuration with fake providers enabled in tests and real providers opt-in through environment/config. +4. Roll out editable export as an explicit UI option with clear progress and validation errors. +5. Keep rollback simple: disable the editable option and continue serving the existing image-based `pptx` path. + +## Open Questions + +- Which concrete OCR provider should be the first production default: local PaddleOCR, PaddleOCR-VL API, or current AIPPT metadata plus lightweight layout detection? +- Should the first layer provider run locally, remotely, or behind an internal service endpoint? +- Should users be offered a per-page correction UI before exporting, or should the first version rely on manifest/preview validation only? diff --git a/openspec/changes/add-editable-pptx-export/proposal.md b/openspec/changes/add-editable-pptx-export/proposal.md new file mode 100644 index 0000000..ab8236e --- /dev/null +++ b/openspec/changes/add-editable-pptx-export/proposal.md @@ -0,0 +1,32 @@ +## Why + +AIPPT currently exports PPTX decks by placing each generated slide image as a full-slide raster, which preserves visual appearance but leaves the deck largely uneditable. The new editable PPTX export should rebuild each slide into a structured deck with editable text, movable visual layers, and native simple shapes while keeping the existing image-based export path available as the stable fallback. + +## What Changes + +- Add a new editable PPTX export mode beside the existing image-based PPTX export. +- Introduce an editable deck manifest that records source slide pixels, cleaned backgrounds, OCR/native text boxes, bitmap layers, fitted native shapes, provenance, and validation status. +- Build a deterministic reconstruction pipeline that combines: + - AIPPT generation metadata and OCR for editable text. + - Local mask/inpaint background cleanup for baked text removal. + - A pluggable image-layer decomposition provider for visual layer extraction. + - Shape fitting for simple PPT-native objects such as rectangles, rounded rectangles, lines, and basic fills. + - A manifest-driven PPTX composer and preview/validation checks. +- Keep prompt-driven image generation/editing out of the default splitting path; allow it only as an explicitly configured fallback for cases where local repair or decomposition cannot produce usable assets. +- Add backend tests, manifest/schema tests, PPTX structure tests, provider contract tests, and frontend/API tests for the new export mode. + +## Capabilities + +### New Capabilities +- `editable-pptx-export`: Covers exporting AIPPT slide images into a structured, more editable PPTX deck using a manifest-driven reconstruction pipeline with text boxes, bitmap layers, native simple shapes, background cleanup, validation, and deterministic fallback behavior. + +### Modified Capabilities +- None. + +## Impact + +- Backend export API and export route handling. +- New backend modules for editable export orchestration, manifest models, OCR/text extraction, background cleanup, image-layer provider integration, shape fitting, PPTX composition, preview rendering, and validation. +- Optional runtime dependencies for OCR, image processing, PPTX generation/inspection, and layer-provider clients. +- Frontend export controls, progress/status UI, and error display for editable export jobs. +- Test suite expansion across unit, integration, contract, and UI/API behavior. diff --git a/openspec/changes/add-editable-pptx-export/specs/editable-pptx-export/spec.md b/openspec/changes/add-editable-pptx-export/specs/editable-pptx-export/spec.md new file mode 100644 index 0000000..d972d94 --- /dev/null +++ b/openspec/changes/add-editable-pptx-export/specs/editable-pptx-export/spec.md @@ -0,0 +1,174 @@ +## ADDED Requirements + +### Requirement: Explicit editable PPTX export mode +The system SHALL provide an explicit editable PPTX export mode without changing the existing PDF and raster PPTX export behavior. + +#### Scenario: Existing raster PPTX export remains unchanged +- **WHEN** a user exports with format `pptx` +- **THEN** the system SHALL return a PPTX that preserves the existing full-slide image export behavior + +#### Scenario: Editable PPTX export is requested +- **WHEN** a user exports with format `editable_pptx` +- **THEN** the system SHALL run the editable reconstruction pipeline and return a PowerPoint `.pptx` file if validation passes + +#### Scenario: Unsupported export format +- **WHEN** a user submits an export format other than `pdf`, `pptx`, or `editable_pptx` +- **THEN** the system SHALL reject the request with a clear validation error + +### Requirement: Editable export options +The system SHALL support explicit editable export options while preserving backward compatibility for existing export requests. + +#### Scenario: Default fallback policy +- **WHEN** an editable PPTX request omits `editable_options.fallback_policy` +- **THEN** the system SHALL treat the fallback policy as `fail` + +#### Scenario: Raster fallback policy +- **WHEN** an editable PPTX request sets `editable_options.fallback_policy` to `raster_pptx` and editable reconstruction fails +- **THEN** the system SHALL return the existing raster PPTX output and SHALL expose response metadata that editable fallback was used + +#### Scenario: Invalid fallback policy +- **WHEN** an editable PPTX request sets `editable_options.fallback_policy` to any value other than `fail` or `raster_pptx` +- **THEN** the system SHALL reject the request with a clear validation error + +### Requirement: Editable deck manifest +The system SHALL create a deck manifest and one page manifest per slide for every editable PPTX export run. + +#### Scenario: Manifest records page structure +- **WHEN** an editable export run processes a slide +- **THEN** the page manifest SHALL record source image size, slide size, background asset, image layers, native shapes, text boxes, provenance, and validation status + +#### Scenario: Manifest uses source pixel coordinates +- **WHEN** the manifest stores positions for text boxes, image layers, and native shapes +- **THEN** positioned objects SHALL use source-image pixel coordinates before composition + +#### Scenario: Manifest is sufficient for deterministic rebuild +- **WHEN** the PPTX composer receives the deck manifest and referenced page assets +- **THEN** it SHALL rebuild the same slide ordering, object ordering, and slide dimensions without re-running OCR, layer extraction, or background cleanup + +### Requirement: Text reconstruction +The system SHALL rebuild slide text as editable PowerPoint text boxes. + +#### Scenario: Text metadata is supplied +- **WHEN** a slide includes optional text metadata +- **THEN** each text item SHALL accept `text`, `role`, `order`, and optional `style_hint` fields, and the slide SHALL accept an optional `slide_id` + +#### Scenario: AIPPT text metadata is available +- **WHEN** a slide includes AIPPT-generated text metadata and OCR detects a matching layout region +- **THEN** the system SHALL use the AIPPT metadata as the text content and OCR/layout data as position and style hints + +#### Scenario: AIPPT text metadata is missing +- **WHEN** a slide has no usable text metadata +- **THEN** the system SHALL use OCR results to create editable text boxes with text content, position, font size, color, alignment, and confidence metadata + +#### Scenario: Text overlays are composed +- **WHEN** editable text boxes are included in the final PPTX +- **THEN** each text box SHALL be editable in PowerPoint and SHALL be placed above background and visual layers + +### Requirement: Background cleanup for editable text +The system SHALL avoid duplicated baked text when editable text overlays are produced. + +#### Scenario: Text regions are accepted +- **WHEN** the text extraction stage accepts text boxes for a page +- **THEN** the background cleanup stage SHALL create masks from those regions and produce a cleaned background candidate + +#### Scenario: Local cleanup is sufficient +- **WHEN** a masked text region is classified as flat or locally repairable +- **THEN** the system SHALL use local fill or local inpaint before attempting any remote or generative repair + +#### Scenario: Cleanup strategy is recorded +- **WHEN** a page background is cleaned or preserved +- **THEN** the page manifest SHALL record the background strategy and any fallback decision used for that page + +### Requirement: Visual layer extraction +The system SHALL extract non-text visual content into movable PPT image layers using a pluggable provider interface. + +#### Scenario: Layer provider succeeds +- **WHEN** the configured layer provider returns RGBA layers with valid bounds +- **THEN** the system SHALL add those layers to the page manifest as positioned image assets with provenance + +#### Scenario: Text-like layers overlap OCR text +- **WHEN** a returned image layer overlaps an accepted text box and is classified as baked text +- **THEN** the system SHALL exclude that layer from visual image assets so editable text is not duplicated + +#### Scenario: Layer provider is unavailable +- **WHEN** the layer provider is unavailable or times out +- **THEN** the system SHALL fail the editable export with a clear recoverable error unless the request explicitly allows fallback behavior + +### Requirement: Provider configuration +The system SHALL select OCR, layer extraction, and repair providers through backend configuration and SHALL support credential-free fake providers for tests. + +#### Scenario: Fake providers are configured +- **WHEN** tests configure fake OCR, fake layer, and fake repair providers +- **THEN** the editable export pipeline SHALL run without external model credentials or network calls + +#### Scenario: Production provider is missing configuration +- **WHEN** a production provider is selected but required configuration is missing +- **THEN** the system SHALL fail before processing pages with a clear provider configuration error + +#### Scenario: Provider timeout is reached +- **WHEN** a configured provider exceeds its timeout +- **THEN** the system SHALL stop the editable pipeline and apply the request fallback policy + +### Requirement: Native simple shape fitting +The system SHALL convert high-confidence simple visual layers into native PowerPoint shapes. + +#### Scenario: Simple rectangle is detected +- **WHEN** a visual layer is classified as a high-confidence rectangle or rounded rectangle +- **THEN** the system SHALL represent it as a native PPT shape with position, size, fill, stroke, opacity, and corner radius when available + +#### Scenario: Simple line is detected +- **WHEN** a visual layer is classified as a high-confidence straight line +- **THEN** the system SHALL represent it as a native PPT line or equivalent native shape with endpoints, stroke color, and width + +#### Scenario: Shape confidence is low +- **WHEN** a visual layer cannot be fitted to a supported native shape above the configured confidence threshold +- **THEN** the system SHALL preserve it as a bitmap image layer rather than emitting an inaccurate native shape + +### Requirement: PPTX composition +The system SHALL compose the editable PPTX from manifest data in a deterministic layer order. + +#### Scenario: Page is composed +- **WHEN** the composer builds a page from its manifest +- **THEN** it SHALL place objects in this order: cleaned background, native shapes, bitmap image layers, editable text boxes + +#### Scenario: Slide aspect ratio is 16:9 +- **WHEN** the export request uses aspect ratio `16:9` +- **THEN** the editable PPTX SHALL use the same 16:9 slide dimensions as the existing raster PPTX export + +#### Scenario: Slide aspect ratio is 4:3 +- **WHEN** the export request uses aspect ratio `4:3` +- **THEN** the editable PPTX SHALL use the same 4:3 slide dimensions as the existing raster PPTX export + +### Requirement: Validation and preview +The system SHALL validate editable PPTX output before returning it. + +#### Scenario: Validation passes +- **WHEN** the composed PPTX passes structural validation +- **THEN** the system SHALL return the PPTX and record validation success in the run artifacts + +#### Scenario: Preview artifact is recorded +- **WHEN** the composed PPTX is validated +- **THEN** the system SHALL create or record a deterministic preview artifact for each page in the run artifacts + +#### Scenario: Validation finds unsafe full-slide source overlap +- **WHEN** a page contains a full-slide uncleaned source image plus editable text overlays +- **THEN** validation SHALL fail with a message explaining that baked text would overlap editable text + +#### Scenario: Required text is missing +- **WHEN** accepted text content from metadata or OCR is absent from the composed PPTX +- **THEN** validation SHALL fail and report the affected page and text item + +### Requirement: Frontend export experience +The system SHALL expose editable PPTX export from the frontend with distinct labeling, progress, and errors. + +#### Scenario: User selects editable PPTX +- **WHEN** slides are exportable and the user chooses editable PPTX export +- **THEN** the frontend SHALL send the editable export format and current slide images, slide order, aspect ratio, and available text metadata to the backend + +#### Scenario: Editable export reports progress +- **WHEN** the editable export is running +- **THEN** the frontend SHALL display stage-based export progress without blocking existing slide viewing or project state + +#### Scenario: Editable export fails +- **WHEN** the backend returns an editable export validation or provider error +- **THEN** the frontend SHALL show the error message and SHALL keep the existing deck state unchanged diff --git a/openspec/changes/add-editable-pptx-export/tasks.md b/openspec/changes/add-editable-pptx-export/tasks.md new file mode 100644 index 0000000..87e12f0 --- /dev/null +++ b/openspec/changes/add-editable-pptx-export/tasks.md @@ -0,0 +1,55 @@ +## 1. Export Contract And TDD Baseline + +- [ ] 1.1 Add failing backend tests for `editable_pptx` request validation, invalid format rejection, and unchanged `pptx` raster behavior; implement the API model/route changes; pass `python -m pytest tests/test_export_pptx_ratio.py tests/test_editable_pptx_export_contract.py`. +- [ ] 1.2 Add failing frontend tests for the new export format type, request body, filename/MIME handling, and existing `pdf`/`pptx` behavior; implement type/service updates; pass `cd web && npm run test -- ExportConsistency.property.test.tsx`. +- [ ] 1.3 Add failing tests that export requests can carry slide order, aspect ratio, optional `slide_id`, and text metadata items with `text`, `role`, `order`, and optional `style_hint`; implement backward-compatible request serialization; pass the backend and frontend export contract tests. +- [ ] 1.4 Add failing tests for `editable_options.fallback_policy` defaulting to `fail`, accepting `raster_pptx`, and rejecting invalid values; implement request parsing and response metadata for fallback-used results; pass export contract tests. + +## 2. Manifest Schema And Job Artifacts + +- [ ] 2.1 Add failing tests for deck/page manifest models, required fields, source pixel coordinates, provenance, and validation status; implement manifest dataclasses or Pydantic models; pass `python -m pytest tests/test_editable_pptx_manifest.py`. +- [ ] 2.2 Add failing tests for deterministic job directory creation, per-page asset paths, cleanup-safe temp handling, and manifest JSON round-trip; implement the job artifact writer; pass `python -m pytest tests/test_editable_pptx_job_artifacts.py`. +- [ ] 2.3 Add failing tests that a manifest can be rebuilt without re-running OCR, layer extraction, or cleanup providers; implement provider-output persistence; pass manifest/job artifact tests. + +## 3. Text Extraction And Background Cleanup + +- [ ] 3.1 Add failing tests for metadata-first text extraction with OCR layout/style hints; implement the text extraction service and fake OCR provider; pass `python -m pytest tests/test_editable_pptx_text_extractor.py`. +- [ ] 3.2 Add failing tests for OCR-only fallback, confidence/provenance recording, font-size hints, color/alignment mapping, and CJK-safe font defaults; implement fallback extraction; pass text extractor tests. +- [ ] 3.3 Add failing tests for text mask creation from boxes/polygons and source-pixel coordinate handling; implement mask generation; pass `python -m pytest tests/test_editable_pptx_background_cleaner.py`. +- [ ] 3.4 Add failing tests for local fill, local inpaint fallback, strategy recording, and no default generative repair; implement tiered background cleanup; pass background cleaner tests. + +## 4. Layer Extraction And Shape Fitting + +- [ ] 4.1 Add failing provider contract tests for configured fake providers, production-provider missing configuration errors, RGBA layer outputs, bounds, confidence, timeout/error handling, and fake provider fixtures; implement the provider configuration and layer provider interface; pass `python -m pytest tests/test_editable_pptx_layer_provider.py`. +- [ ] 4.2 Add failing tests that text-like layers overlapping accepted text boxes are excluded from visual image assets; implement overlap filtering and provenance updates; pass layer provider tests. +- [ ] 4.3 Add failing tests for high-confidence rectangle, rounded-rectangle, and line conversion into native shape specs; implement conservative shape fitting; pass `python -m pytest tests/test_editable_pptx_shape_fitter.py`. +- [ ] 4.4 Add failing tests that low-confidence or complex layers remain bitmap image layers; implement confidence thresholds and fallback classification; pass shape fitter tests. + +## 5. PPTX Composer And Validator + +- [ ] 5.1 Add failing tests for pixel-to-slide coordinate conversion for both `16:9` and `4:3`; implement composer coordinate normalization; pass `python -m pytest tests/test_editable_pptx_composer.py`. +- [ ] 5.2 Add failing tests that composed slides use the required z-order: cleaned background, native shapes, bitmap image layers, editable text boxes; implement manifest-driven PPTX composition; pass composer tests. +- [ ] 5.3 Add failing tests that text boxes are editable PowerPoint text objects and simple shapes are native PPT shapes; implement PPTX object creation and XML/package inspection helpers; pass composer tests. +- [ ] 5.4 Add failing tests for validation success, unsafe full-slide source plus text rejection, missing required text detection, missing asset detection, and page count checks; implement the validator; pass `python -m pytest tests/test_editable_pptx_validator.py`. +- [ ] 5.5 Add failing tests for deterministic per-page preview artifact creation or recording from manifests using fake assets; implement preview generation or preview stubs suitable for CI; pass composer and validator tests. + +## 6. Pipeline Orchestration And API Integration + +- [ ] 6.1 Add failing orchestration tests with fake OCR, fake layer provider, and fake cleaner for a one-slide deck; implement the editable export pipeline; pass `python -m pytest tests/test_editable_pptx_pipeline.py`. +- [ ] 6.2 Add failing orchestration tests for multi-slide ordering, per-page manifest creation, validation aggregation, and artifact cleanup; implement multi-page processing; pass pipeline tests. +- [ ] 6.3 Add failing API route tests that `format: "editable_pptx"` returns a valid PPTX when fake providers pass validation; wire the route to the pipeline; pass `python -m pytest tests/test_editable_pptx_export_route.py`. +- [ ] 6.4 Add failing API route tests for provider timeout, validation failure, default-fail behavior, and explicit `raster_pptx` fallback behavior; implement clear error responses, fallback controls, and fallback-used response metadata; pass export route tests. + +## 7. Frontend Experience + +- [ ] 7.1 Add failing component tests that the export menu exposes editable PPTX as a distinct option without renaming the existing PPTX option; implement the UI control and i18n labels; pass `cd web && npm run test -- Export`. +- [ ] 7.2 Add failing hook/service tests for synchronous stage-based editable export progress, completion filename, fallback-used metadata handling, and error propagation; implement frontend progress/error handling; pass frontend export tests. +- [ ] 7.3 Add failing app-level tests that editable export failure preserves current slides, active project state, and existing edit state; implement non-destructive error handling; pass `cd web && npm run test -- AppProjectLifecycle.test.tsx`. + +## 8. End-To-End Verification And Documentation + +- [ ] 8.1 Add a small deterministic fixture deck/image set for editable export tests; verify the fixture covers text, simple shapes, one complex image layer, 16:9, and 4:3 cases; pass all editable backend tests. +- [ ] 8.2 Run the complete backend verification and fix any regressions using TDD loops; pass `python -m pytest tests`. +- [ ] 8.3 Run the complete frontend verification and fix any regressions using TDD loops; pass `cd web && npm run lint && npm run test && npm run build`. +- [ ] 8.4 Validate the OpenSpec change; pass `openspec status --change add-editable-pptx-export` and `openspec validate add-editable-pptx-export --strict`. +- [ ] 8.5 Update user-facing and developer documentation for editable PPTX configuration, provider setup, fallback behavior, and known limitations; verify docs mention that existing `pptx` remains raster export. diff --git a/openspec/changes/add-generative-editable-pptx-export/.openspec.yaml b/openspec/changes/add-generative-editable-pptx-export/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/add-generative-editable-pptx-export/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/add-generative-editable-pptx-export/design.md b/openspec/changes/add-generative-editable-pptx-export/design.md new file mode 100644 index 0000000..5da907a --- /dev/null +++ b/openspec/changes/add-generative-editable-pptx-export/design.md @@ -0,0 +1,232 @@ +## Context + +AIPPT's current export path can produce PDF and raster PPTX files from generated slide images. The raster PPTX path is visually stable but not meaningfully editable because each slide is one full-slide picture. + +There is already a separate OpenSpec change for an image-layer decomposition approach. This change is intentionally separate: it targets the current constraint where an image-layer model is unavailable and the implementation must rely on OCR, image editing, image generation, deterministic composition, preview validation, and bounded repair. + +The reference projects inform different parts of the architecture: + +- `image-to-editable-ppt-skill` is the primary reference for an effect-first reconstruction workflow: per-page jobs, manifests, OCR hints, image edit/generation for backgrounds and assets, self-checks, finalization, and validation. +- `slide-alchemy` informs reconstruction order and asset taxonomy: clean base, native geometry, PNG assets, editable text. +- `OCRPDF-TO-PPT` informs deterministic text-box export, pixel-to-PPT coordinate mapping, local text masks, local cleanup, and text-editable fallback behavior. + +Development must follow test-driven development. Each task starts by adding or updating a failing test for the expected behavior, then implements the smallest passing change, then runs the relevant verification command before the task is considered complete. + +## Goals / Non-Goals + +**Goals:** + +- Add a distinct `generative_editable_pptx` export mode without changing existing PDF, raster `pptx`, or the separate image-layer OpenSpec change. +- Prefer output quality over model cost by default: validate previews, repair bounded failures, and fail rather than silently returning low-quality editable output. +- Use AIPPT text metadata as semantic truth when available and OCR as the source for layout, style, and fallback text. +- Create both text-clean and base-clean background assets so the system has a high-fidelity text-editable fallback and a full reconstruction base. +- Use image edit/generation providers to create source-guided foreground asset sheets and local repairs. +- Convert high-confidence simple visual elements into native PPT shapes. +- Compose final PPTX files deterministically from manifest artifacts and validate them before returning. +- Keep automated tests independent of real provider credentials through fake providers and fixtures. +- Put provider configuration schema in `config.example.yaml` and real local values in `config.yaml`. + +**Non-Goals:** + +- Do not implement or modify the image-layer decomposition approach in `add-editable-pptx-export`. +- Do not require Codex skills, page workers, or external agent runtimes in production. +- Do not guarantee pixel-perfect reconstruction for every complex generated image. +- Do not convert arbitrary icons or illustrations into editable vector paths. +- Do not make generative reconstruction the default `pptx` export. +- Do not commit real provider credentials. + +## Decisions + +### Decision 1: Use a distinct export format + +The new export format is `generative_editable_pptx`. The existing `pptx` format remains the stable raster export. The separate image-layer change can keep its own `editable_pptx` assumptions without conflict. + +Alternatives considered: + +- Reuse `editable_pptx`. Rejected because that name is already used by the image-layer decomposition proposal and would collapse two materially different implementations into one contract. +- Replace `pptx`. Rejected because users may still need fast, visually stable raster export. + +### Decision 2: Manifest artifacts are the source of truth + +The pipeline writes a deck manifest and page manifests before composition. Manifests store source sizes, slide sizes, provider roles, text boxes, backgrounds, foreground candidates, native shapes, bitmap assets, asset sheets, repair attempts, provenance, and validation status. Positions are authored in source-image pixels and converted during composition. + +This mirrors the strongest engineering idea from `image-to-editable-ppt-skill` while keeping AIPPT-specific schema and provider semantics. + +Alternatives considered: + +- Build PPTX directly during extraction. Rejected because retries, previews, tests, and partial page repairs become hard to reason about. +- Reuse a reference manifest unchanged. Rejected because AIPPT needs its own export request contract, fallback policy, and provider configuration. + +### Decision 3: Configuration is project-native and local-real, test-fake + +`config.example.yaml` documents placeholder provider roles: + +- `api.models.ocr_model` +- `api.models.image_model` +- `api.models.edit_model` +- `editable_pptx.generative.reconstruction` +- `editable_pptx.generative.quality` +- `editable_pptx.generative.timeouts` + +Real local values are read from `config.yaml`. Automated tests use fake providers and fixture images. Live provider verification is an explicit manual gate in the implementation tasks. + +Alternatives considered: + +- Hardcode provider settings. Rejected because provider details vary and secrets must not be committed. +- Require live providers for tests. Rejected because CI and local TDD loops must remain deterministic. + +### Decision 4: Text is metadata-first, OCR-measured, and native in PPT + +AIPPT-generated text metadata is used as semantic truth when it can be matched to OCR/layout regions. OCR provides bounding boxes, font-size hints, style hints, color, and fallback content. Text is always composed as editable PowerPoint text boxes when accepted. + +Alternatives considered: + +- OCR only. Rejected because OCR can misread content that AIPPT already knows. +- Keep text as pixels. Rejected because text editability is a core requirement. + +### Decision 5: Generate two background candidates + +Each page can produce: + +- `text_clean_background`: removes baked text but preserves non-text visuals. This supports high-confidence text-editable fallback. +- `base_clean_background`: removes text and foreground content that will be rebuilt. This supports full editable reconstruction. + +Local fill/OpenCV-style cleanup is preferred for simple text masks. Image edit providers are used for complex background cleanup and for full base generation. + +Alternatives considered: + +- Generate only a base-clean background. Rejected because a failed asset reconstruction would leave no good fallback. +- Generate only a text-clean background. Rejected because full foreground editability requires a clean base underneath rebuilt assets. + +### Decision 6: Foreground planning uses generated background differences plus visual analysis + +Without an image-layer model, the system detects candidate foreground elements by comparing source images to base-clean backgrounds, excluding accepted text masks, and grouping connected/nearby regions. A visual planner can classify candidates into native-shape candidates, bitmap asset candidates, complex whole visuals, duplicates, rejected text-like regions, or uncertain regions. + +This is not true layer decomposition; it is a reconstruction plan that drives asset generation and validation. + +Alternatives considered: + +- Ask the image model to output every asset without local candidate planning. Rejected because prompts become harder to validate and repeated/overlapping objects are easy to miss. +- Directly crop all source regions. Rejected as a default because source crops can include baked text, neighboring objects, and background pollution. + +### Decision 7: Asset sheets are source-guided and repairable + +Bitmap foreground elements are reconstructed through sparse transparent or chroma-key asset sheets. The source page is supplied as visual reference or edit target. The slicer extracts each asset, removes chroma-key backgrounds if needed, checks edge contact and neighboring contamination, and records mapping back to foreground candidates. + +If an asset fails slicing or preview checks, the system retries only the affected page or asset up to the configured repair limit. After the limit, strict mode fails. + +Alternatives considered: + +- Generate one image per asset from the start. Rejected as the default because it increases model calls and loses reuse opportunities. +- Accept imperfect slices. Rejected because effect quality is prioritized over cost. + +### Decision 8: Native shape fitting is conservative + +Only high-confidence rectangles, rounded rectangles, ellipses if enabled, and straight lines are emitted as native PPT shapes. Uncertain visuals remain bitmap assets. + +Alternatives considered: + +- Convert all visual candidates to shapes. Rejected because inaccurate shapes are worse than faithful bitmap assets. +- Keep all visuals as bitmaps. Rejected because simple layout elements should be editable when reliable. + +### Decision 9: Validation gates results and fallback must be explicit + +The default fallback policy is `fail`. Validation checks page count, dimensions, required text, unsafe baked-text overlap, asset existence, object order, native text/shape structure, and preview similarity. If validation fails after bounded repairs, the API returns an error unless the request explicitly permits `text_editable_background` or `raster_pptx` fallback. + +Alternatives considered: + +- Always return best-effort output. Rejected because it hides quality failures. +- Always fallback to raster PPTX. Rejected because it would surprise users requesting editability. + +## Pipeline + +```text +Slide images + optional AIPPT text metadata + | + v +Create job directory and deck/page manifests + | + v +OCR + text matching + | + v +Text masks + | + +--> text-clean background + | + +--> base-clean background + | + v + foreground candidate planning + | + +--------+---------+ + | | + v v + native shape fitting asset-sheet generation + | | + v v + shape specs asset slicing + QA + | | + +--------+---------+ + | + v + page manifest + | + v + deterministic PPTX composition + | + v + preview + validation + repair + | + v + return PPTX or explicit failure/fallback +``` + +## Provider Interfaces + +The implementation should define provider interfaces before concrete clients: + +- `OCRProvider`: returns text content, boxes/polygons, confidence, font-size hints, color, alignment, and style hints. +- `ImageEditProvider`: edits a source image with a prompt and optional mask to produce text-clean backgrounds, base-clean backgrounds, asset sheets, or repairs. +- `ImageGenerationProvider`: generates or regenerates assets when edit-based extraction fails or a visual asset needs clean re-creation. +- `VisualPlanner`: uses source images, generated backgrounds, OCR masks, and optional model analysis to produce foreground candidates and classifications. +- `PreviewRenderer`: renders composed PPTX pages or manifest previews for comparison. +- `Validator`: performs structural and visual quality checks and produces actionable failure reports. + +Each provider must have a fake implementation for automated tests. + +## Testing Strategy + +All implementation tasks follow TDD: + +1. Add a failing unit, integration, contract, or frontend test for the behavior. +2. Implement the smallest change that passes. +3. Run the task-specific verification command listed in `tasks.md`. +4. Keep provider-backed tests deterministic by using fake providers and fixture assets. +5. Run live provider checks only after local provider configuration is available. + +## Risks / Trade-offs + +- [Generated assets drift from the source] -> Use source-guided image editing, asset-sheet QA, preview comparison, and bounded repairs. +- [OCR misreads text] -> Prefer AIPPT metadata for content, use OCR for layout/style, and fail required text validation when confidence is too low. +- [Background editing removes desired visuals] -> Keep original, text-clean, and base-clean assets separately; validate preview similarity; allow text-editable fallback only when explicitly requested. +- [Cost is higher than image-layer decomposition] -> Prioritize quality by default, then optimize through component reuse, page-level retries, caching, and optional fallback policies. +- [Provider availability blocks development] -> Keep all providers behind interfaces and use fake providers in automated tests. +- [Large decks are slow] -> Process pages in bounded workers, write intermediate artifacts to disk, and expose progress stages. + +## Migration Plan + +1. Add the new OpenSpec change and keep the image-layer change untouched. +2. Implement configuration schema and fake provider infrastructure first. +3. Add backend pipeline modules behind the new `generative_editable_pptx` format. +4. Add frontend controls as a distinct high-fidelity editable export option. +5. Validate with deterministic fixtures and fake providers. +6. Request local provider configuration before live OCR/image edit/image generation verification. +7. Keep rollback simple: hide or disable the generative editable export option while leaving existing PDF and raster PPTX exports unchanged. + +## Open Questions + +- Which OCR provider should be the first live target for local verification? +- Which image edit/generation provider should be the first live target for background and asset-sheet generation? +- What initial preview similarity thresholds are strict enough to protect quality without rejecting useful outputs too often? +- Should live provider verification be limited to one-slide fixtures first, or include a small multi-slide deck before the feature is exposed in the UI? diff --git a/openspec/changes/add-generative-editable-pptx-export/proposal.md b/openspec/changes/add-generative-editable-pptx-export/proposal.md new file mode 100644 index 0000000..0655708 --- /dev/null +++ b/openspec/changes/add-generative-editable-pptx-export/proposal.md @@ -0,0 +1,38 @@ +## Why + +AIPPT currently exports PPTX files as full-slide raster images, which preserves appearance but leaves text and visual elements mostly uneditable. Since image-layer decomposition resources are not currently available, this change proposes a separate effect-first editable export path that relies on OCR, image editing, image generation, manifest composition, and validation. + +This change intentionally does not modify the existing `add-editable-pptx-export` OpenSpec change; that change remains available as the future image-layer decomposition approach. + +## What Changes + +- Add a new explicit generative editable PPTX export mode beside the existing raster `pptx` export. +- Introduce a manifest-driven reconstruction pipeline that uses: + - AIPPT slide/text metadata when available, with OCR as the layout/style and fallback text source. + - Image editing to create text-clean and base-clean background assets. + - Image editing or image generation to create source-faithful foreground asset sheets and local repairs. + - Conservative CV/geometry fitting to convert simple layout elements into native PPT shapes. + - Deterministic PPTX composition in the order: cleaned background, native shapes, bitmap assets, editable text boxes. + - Preview, structural validation, and bounded page-level repair attempts before returning the editable deck. +- Extend `config.example.yaml` with placeholder provider configuration for OCR, image editing, image generation, and editable PPTX quality controls. +- Read real provider settings only from local `config.yaml`; automated tests use fake providers and do not require live credentials. +- Keep existing PDF and raster PPTX behavior unchanged. + +## Capabilities + +### New Capabilities + +- `generative-editable-pptx-export`: Covers exporting AIPPT slide images into a structured, editable PPTX through an effect-first generative reconstruction workflow using OCR, image editing/generation, manifest artifacts, deterministic composition, preview validation, and repair loops. + +### Modified Capabilities + +- None. + +## Impact + +- Backend export request/response handling for a distinct generative editable PPTX mode. +- New backend modules for editable export configuration, job artifacts, manifests, OCR text extraction, background generation/cleanup, visual planning, asset-sheet generation, asset slicing, shape fitting, PPTX composition, preview rendering, validation, and orchestration. +- Optional runtime integrations for OCR, image editing, and image generation providers, all behind testable provider interfaces. +- `config.example.yaml` schema updates and local `config.yaml` loading for provider configuration. +- Frontend export controls, progress/error display, and non-destructive failure handling for the new export option. +- Test suite expansion with TDD requirements for every implementation task, including fake providers for CI and manual live-provider verification gates. diff --git a/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md b/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md new file mode 100644 index 0000000..fb840ca --- /dev/null +++ b/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md @@ -0,0 +1,213 @@ +## ADDED Requirements + +### Requirement: Explicit generative editable PPTX export mode +The system SHALL provide a distinct generative editable PPTX export mode without changing existing PDF, raster PPTX, or image-layer editable export behavior. + +#### Scenario: Existing raster PPTX export remains unchanged +- **WHEN** a user exports with format `pptx` +- **THEN** the system SHALL return the existing raster PPTX output that places each slide image as a full-slide picture + +#### Scenario: Generative editable PPTX export is requested +- **WHEN** a user exports with format `generative_editable_pptx` +- **THEN** the system SHALL run the OCR, image editing, image generation, manifest composition, preview, validation, and repair pipeline + +#### Scenario: Image-layer editable export is not modified +- **WHEN** the separate image-layer editable export OpenSpec change exists in the repository +- **THEN** this capability SHALL NOT modify its proposal, design, specs, tasks, request contract, or provider assumptions + +#### Scenario: Unsupported export format +- **WHEN** a user submits an export format other than the supported export formats +- **THEN** the system SHALL reject the request with a clear validation error + +### Requirement: Configuration contract +The system SHALL load generative editable export provider settings from the project configuration files and SHALL keep real provider credentials out of committed artifacts. + +#### Scenario: Example configuration is updated +- **WHEN** the repository is inspected after this capability is implemented +- **THEN** `config.example.yaml` SHALL document placeholder OCR, image edit, image generation, reconstruction, quality, timeout, and retry settings for generative editable PPTX export + +#### Scenario: Local configuration provides real providers +- **WHEN** a developer runs live provider verification +- **THEN** the system SHALL read real provider settings from local `config.yaml` + +#### Scenario: Automated tests run without real providers +- **WHEN** backend or frontend automated tests run in CI or a fresh local test environment +- **THEN** the tests SHALL use fake or fixture providers and SHALL NOT require live OCR, image edit, or image generation credentials + +#### Scenario: Missing live provider configuration +- **WHEN** a live generative editable export is requested without required local provider settings +- **THEN** the system SHALL fail with a clear configuration error that names the missing provider role without exposing secrets + +### Requirement: Generative editable deck manifest +The system SHALL create a deck manifest and one page manifest per slide for every generative editable PPTX export run. + +#### Scenario: Deck manifest records run structure +- **WHEN** a generative editable export run starts +- **THEN** the deck manifest SHALL record slide order, aspect ratio, provider roles, quality settings, fallback policy, page manifest paths, and final validation status + +#### Scenario: Page manifest records reconstruction structure +- **WHEN** a page is processed +- **THEN** the page manifest SHALL record source image size, slide size, text-clean background, base-clean background, chosen background, text boxes, native shapes, bitmap assets, asset sheets, repair attempts, provenance, and validation status + +#### Scenario: Manifest uses source pixel coordinates +- **WHEN** the manifest stores positions for text boxes, bitmap assets, and native shapes +- **THEN** positioned objects SHALL use source-image pixel coordinates before composition + +#### Scenario: Manifest supports deterministic rebuild +- **WHEN** the PPTX composer receives the deck manifest and referenced page assets +- **THEN** it SHALL rebuild slide ordering, object ordering, slide dimensions, text boxes, shapes, and image assets without re-running OCR, image editing, image generation, or repair providers + +### Requirement: Text extraction and reconstruction +The system SHALL rebuild slide text as editable PowerPoint text boxes using AIPPT text metadata first and OCR as the layout, style, and fallback source. + +#### Scenario: AIPPT text metadata is available +- **WHEN** a slide includes AIPPT-generated text metadata and OCR detects matching layout regions +- **THEN** the system SHALL use AIPPT metadata as text content and OCR output as position, size, color, alignment, and style hints + +#### Scenario: AIPPT text metadata is missing or unmatched +- **WHEN** a slide has no usable text metadata for a detected text region +- **THEN** the system SHALL use OCR content, box, confidence, font-size hints, color, and alignment to create an editable text box + +#### Scenario: Text confidence is insufficient +- **WHEN** required text cannot be matched or OCR confidence is below the configured threshold +- **THEN** the page validation SHALL fail unless the request explicitly allows a lower-fidelity fallback + +#### Scenario: Text boxes are composed +- **WHEN** a page manifest includes accepted text boxes +- **THEN** each accepted text item SHALL become an editable PowerPoint text object placed above background, native shapes, and bitmap assets + +### Requirement: Text-clean and base-clean background generation +The system SHALL create separate background candidates for text-only cleanup and full foreground removal. + +#### Scenario: Text-clean background is generated +- **WHEN** a page contains accepted text boxes +- **THEN** the system SHALL create a text-clean background candidate that removes baked text while preserving non-text visual elements as much as possible + +#### Scenario: Base-clean background is generated +- **WHEN** a page is processed for full editable reconstruction +- **THEN** the system SHALL create a base-clean background candidate that preserves theme background, edge decoration, ambient texture, and layout background while removing text and foreground content that will be rebuilt + +#### Scenario: Local cleanup is sufficient for text removal +- **WHEN** a text mask region is classified as flat or locally repairable +- **THEN** the system SHALL use local fill or local inpaint before using remote image editing for that region + +#### Scenario: Background strategy is recorded +- **WHEN** a background candidate is generated, preserved, repaired, or rejected +- **THEN** the page manifest SHALL record the strategy, provider role, prompts or prompt identifiers, input asset references, output asset reference, and validation result + +### Requirement: Foreground planning from generated backgrounds +The system SHALL plan non-text foreground reconstruction using OCR masks, generated backgrounds, image differences, and visual analysis. + +#### Scenario: Foreground candidates are detected +- **WHEN** source and base-clean images are available +- **THEN** the system SHALL produce foreground candidate regions by comparing the source image to the base-clean image and excluding accepted text regions + +#### Scenario: Candidate inventory is classified +- **WHEN** foreground candidates are detected +- **THEN** the system SHALL classify each candidate as native-shape candidate, bitmap asset candidate, complex whole visual, duplicate, rejected text-like region, or uncertain region + +#### Scenario: Repeated components are identified +- **WHEN** multiple foreground candidates appear visually or geometrically equivalent +- **THEN** the system SHALL record component reuse in the manifest so one generated asset can be reused across instances when validation passes + +### Requirement: Asset sheet generation and slicing +The system SHALL reconstruct non-text bitmap foreground content through source-guided image edit or image generation providers. + +#### Scenario: Asset sheet is generated +- **WHEN** one or more bitmap asset candidates are accepted for a page +- **THEN** the system SHALL request a sparse transparent or chroma-key asset sheet that preserves each candidate's visual identity, geometry, stroke, color, proportions, and internal cutouts + +#### Scenario: Asset sheet is sliced +- **WHEN** an asset sheet is generated +- **THEN** the system SHALL slice it into individual image assets, preserve padding, remove chroma-key backgrounds when needed, and record each asset path and source candidate mapping + +#### Scenario: Asset slicing quality fails +- **WHEN** a sliced asset touches the crop edge, contains neighboring objects, contains text that should be editable, or loses required visual content +- **THEN** the system SHALL reject the asset and schedule a bounded repair attempt + +#### Scenario: Asset generation repair is bounded +- **WHEN** an asset generation or slicing failure occurs +- **THEN** the system SHALL retry only the affected page or asset up to the configured repair limit before failing validation or using an explicitly allowed fallback + +### Requirement: Native simple shape reconstruction +The system SHALL convert high-confidence simple layout elements into native PowerPoint shapes. + +#### Scenario: Simple rectangle is detected +- **WHEN** a foreground candidate is classified as a high-confidence rectangle or rounded rectangle +- **THEN** the system SHALL represent it as a native PPT shape with position, size, fill, stroke, opacity, and corner radius when available + +#### Scenario: Simple line is detected +- **WHEN** a foreground candidate is classified as a high-confidence straight line +- **THEN** the system SHALL represent it as a native PPT line or equivalent native shape with endpoints, stroke color, width, and opacity + +#### Scenario: Shape confidence is low +- **WHEN** a candidate cannot be fitted to a supported native shape above the configured confidence threshold +- **THEN** the system SHALL preserve it as a bitmap asset candidate rather than emitting an inaccurate native shape + +### Requirement: PPTX composition +The system SHALL compose the final PPTX deterministically from the deck and page manifests. + +#### Scenario: Page object order is composed +- **WHEN** the composer builds a page from its manifest +- **THEN** it SHALL place objects in this order: chosen cleaned background, native shapes, bitmap assets, editable text boxes + +#### Scenario: Slide aspect ratio is preserved +- **WHEN** the export request uses aspect ratio `16:9` or `4:3` +- **THEN** the generative editable PPTX SHALL use the same slide dimensions as the existing raster PPTX export for that aspect ratio + +#### Scenario: Text and shapes remain native +- **WHEN** the composed PPTX is opened or inspected +- **THEN** accepted text boxes SHALL be PowerPoint text objects and accepted simple shapes SHALL be native PowerPoint shapes + +### Requirement: Preview validation and effect-first quality gates +The system SHALL validate generative editable PPTX output before returning it, prioritizing effect quality over cost savings. + +#### Scenario: Validation passes +- **WHEN** the composed PPTX passes structural checks, required text checks, asset existence checks, z-order checks, and preview comparison thresholds +- **THEN** the system SHALL return the PPTX and record validation success in the run artifacts + +#### Scenario: Unsafe overlap is detected +- **WHEN** a page contains a full-slide uncleaned source image plus editable text overlays +- **THEN** validation SHALL fail with a message explaining that baked text would overlap editable text + +#### Scenario: Required text is missing +- **WHEN** accepted required text from AIPPT metadata or OCR is absent from the composed PPTX +- **THEN** validation SHALL fail and report the affected page and text item + +#### Scenario: Preview differs beyond threshold +- **WHEN** preview comparison against the source slide exceeds configured visual difference thresholds after bounded repair attempts +- **THEN** validation SHALL fail unless the request explicitly allows a lower-fidelity fallback + +### Requirement: Explicit fallback policy +The system SHALL only use lower-fidelity fallback behavior when the request explicitly allows it. + +#### Scenario: Default fallback policy +- **WHEN** a generative editable PPTX request omits fallback policy +- **THEN** the system SHALL use `fail` as the fallback policy + +#### Scenario: Text-editable fallback policy +- **WHEN** fallback policy is `text_editable_background` and full asset reconstruction fails +- **THEN** the system MAY return a PPTX using the text-clean background plus editable text boxes and SHALL record that lower-fidelity fallback was used + +#### Scenario: Raster fallback policy +- **WHEN** fallback policy is `raster_pptx` and generative editable reconstruction fails +- **THEN** the system MAY return the existing raster PPTX output and SHALL record that raster fallback was used + +#### Scenario: Invalid fallback policy +- **WHEN** a request sets fallback policy to any unsupported value +- **THEN** the system SHALL reject the request with a clear validation error + +### Requirement: Frontend export experience +The system SHALL expose generative editable PPTX export from the frontend with distinct labeling, progress, and errors. + +#### Scenario: User selects generative editable PPTX +- **WHEN** slides are exportable and the user chooses the high-fidelity editable PPTX export option +- **THEN** the frontend SHALL send the generative editable export format, current slide images, slide order, aspect ratio, available text metadata, and selected fallback policy to the backend + +#### Scenario: Generative export reports progress +- **WHEN** the generative editable export is running +- **THEN** the frontend SHALL display progress and current stage without blocking existing slide viewing or project state + +#### Scenario: Generative export fails validation +- **WHEN** the backend returns a validation, configuration, provider, or repair-limit error +- **THEN** the frontend SHALL show the error message and SHALL keep the existing deck, slides, and edit state unchanged diff --git a/openspec/changes/add-generative-editable-pptx-export/tasks.md b/openspec/changes/add-generative-editable-pptx-export/tasks.md new file mode 100644 index 0000000..090ca3e --- /dev/null +++ b/openspec/changes/add-generative-editable-pptx-export/tasks.md @@ -0,0 +1,87 @@ +## 1. Export Contract And Configuration + +- [ ] 1.1 Add failing backend tests for `generative_editable_pptx` request validation, unsupported format rejection, and unchanged `pdf`/`pptx` behavior; implement the API contract changes; pass `python -m pytest tests/test_export_pptx_ratio.py tests/test_generative_editable_export_contract.py`. +- [ ] 1.2 Add failing tests for optional slide order, aspect ratio, fallback policy, and text metadata serialization in export requests; implement backward-compatible request models; pass `python -m pytest tests/test_generative_editable_export_contract.py`. +- [ ] 1.3 Add failing tests that `config.example.yaml` exposes placeholder OCR, image edit, image generation, reconstruction, quality, timeout, and retry sections; update the example config; pass `python -m pytest tests/test_generative_editable_config.py`. +- [ ] 1.4 Add failing tests that live provider settings are read from local `config.yaml` and missing settings produce redacted configuration errors; implement config loading and redaction; pass `python -m pytest tests/test_generative_editable_config.py`. +- [ ] 1.5 Add failing tests that automated test mode uses fake provider configuration without live credentials; implement fake-provider config fixtures; pass `python -m pytest tests/test_generative_editable_config.py`. + +## 2. Provider Interfaces And Test Doubles + +- [ ] 2.1 Add failing contract tests for `OCRProvider` outputs including text, bbox/polygon, confidence, font hints, color, alignment, and provenance; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [ ] 2.2 Add failing contract tests for `ImageEditProvider` requests including source image, prompt identifier, optional mask, timeout, and output asset path; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [ ] 2.3 Add failing contract tests for `ImageGenerationProvider` requests including prompt identifier, visual reference metadata, timeout, and output asset path; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [ ] 2.4 Add failing tests for provider timeout, provider error, retryable error, non-retryable error, and secret-safe error messages; implement provider error types and handling; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [ ] 2.5 Add failing tests for fixture image assets used by fake providers, including deterministic output hashes or dimensions; add fixtures; pass `python -m pytest tests/test_generative_editable_providers.py`. + +## 3. Manifest Schema And Job Artifacts + +- [ ] 3.1 Add failing tests for deck manifest fields, page manifest fields, source pixel coordinates, provider roles, fallback policy, provenance, repair attempts, and validation status; implement manifest models; pass `python -m pytest tests/test_generative_editable_manifest.py`. +- [ ] 3.2 Add failing tests for deterministic job directory creation, per-page asset paths, page ordering, and cleanup-safe temp handling; implement job artifact writer; pass `python -m pytest tests/test_generative_editable_job_artifacts.py`. +- [ ] 3.3 Add failing tests that manifests can round-trip through JSON without losing coordinates, provider metadata, asset references, or validation status; implement serialization; pass `python -m pytest tests/test_generative_editable_manifest.py tests/test_generative_editable_job_artifacts.py`. +- [ ] 3.4 Add failing tests that a manifest can be rebuilt without re-running OCR, image edit, image generation, or repair providers; implement provider-output persistence; pass `python -m pytest tests/test_generative_editable_manifest.py tests/test_generative_editable_job_artifacts.py`. + +## 4. Text Extraction And Text Masks + +- [ ] 4.1 Add failing tests for metadata-first text extraction where AIPPT text metadata supplies content and OCR supplies layout/style hints; implement text matching; pass `python -m pytest tests/test_generative_editable_text.py`. +- [ ] 4.2 Add failing tests for OCR-only fallback, confidence recording, font-size hints, CJK-safe default font selection, color mapping, and alignment mapping; implement OCR fallback extraction; pass `python -m pytest tests/test_generative_editable_text.py`. +- [ ] 4.3 Add failing tests for required text validation when metadata text cannot be matched or OCR confidence is below threshold; implement text validation reports; pass `python -m pytest tests/test_generative_editable_text.py`. +- [ ] 4.4 Add failing tests for mask creation from text boxes and polygons, padding, clipping, and source-pixel coordinate handling; implement text mask generation; pass `python -m pytest tests/test_generative_editable_text_masks.py`. + +## 5. Background Generation And Cleanup + +- [ ] 5.1 Add failing tests for local fill and local inpaint selection on flat or locally repairable text mask regions; implement local cleanup strategy; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. +- [ ] 5.2 Add failing tests for `text_clean_background` generation using local cleanup first and image edit only when configured/needed; implement text-clean background creation; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. +- [ ] 5.3 Add failing tests for `base_clean_background` generation through image edit provider with prompt identifiers and source image references recorded; implement base-clean background creation; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. +- [ ] 5.4 Add failing tests that background outputs preserve original assets, write separate cleaned assets, and record strategy/provenance/validation status in the page manifest; implement background manifest updates; pass `python -m pytest tests/test_generative_editable_backgrounds.py tests/test_generative_editable_manifest.py`. + +## 6. Foreground Planning And Native Shape Fitting + +- [ ] 6.1 Add failing tests for foreground candidate detection from source vs base-clean differences while excluding accepted text masks; implement candidate planner; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. +- [ ] 6.2 Add failing tests for candidate classification into native-shape candidate, bitmap asset candidate, complex whole visual, duplicate, rejected text-like region, and uncertain region; implement classification; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. +- [ ] 6.3 Add failing tests for repeated component detection and manifest reuse records; implement component reuse planning; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. +- [ ] 6.4 Add failing tests for high-confidence rectangle, rounded rectangle, ellipse if enabled, and line conversion into native shape specs; implement conservative shape fitting; pass `python -m pytest tests/test_generative_editable_shape_fitter.py`. +- [ ] 6.5 Add failing tests that low-confidence or complex candidates remain bitmap asset candidates rather than inaccurate native shapes; implement thresholds and fallback classification; pass `python -m pytest tests/test_generative_editable_shape_fitter.py`. + +## 7. Asset Sheet Generation, Slicing, And Repair + +- [ ] 7.1 Add failing tests for asset-sheet prompt request creation from accepted bitmap candidates, including source reference, candidate boxes, chroma-key/transparent mode, and provider role; implement asset-sheet request builder; pass `python -m pytest tests/test_generative_editable_assets.py`. +- [ ] 7.2 Add failing tests for asset-sheet slicing, chroma-key removal, alpha preservation, padding preservation, candidate-to-asset mapping, and asset path recording; implement slicer; pass `python -m pytest tests/test_generative_editable_assets.py`. +- [ ] 7.3 Add failing tests for edge-touch, neighboring-object contamination, baked-text contamination, missing-object, and empty-asset rejection; implement asset QA checks; pass `python -m pytest tests/test_generative_editable_assets.py`. +- [ ] 7.4 Add failing tests for bounded repair attempts on failed assets, including page-level retry, single-asset retry, repair-limit failure, and manifest repair history; implement repair orchestration; pass `python -m pytest tests/test_generative_editable_assets.py tests/test_generative_editable_pipeline.py`. + +## 8. PPTX Composer And Preview Renderer + +- [ ] 8.1 Add failing tests for source-pixel to slide-coordinate conversion for `16:9` and `4:3`; implement composer coordinate normalization; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [ ] 8.2 Add failing tests that pages compose in required z-order: chosen cleaned background, native shapes, bitmap assets, editable text boxes; implement manifest-driven composition; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [ ] 8.3 Add failing tests that accepted text boxes are editable PowerPoint text objects and accepted simple shapes are native PowerPoint shapes; implement PPTX object creation and package/XML inspection helpers; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [ ] 8.4 Add failing tests for multi-page deck composition, page order, slide dimensions, image media references, and deterministic rebuild from saved manifests; implement deck composer; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [ ] 8.5 Add failing tests for preview rendering or deterministic preview stubs from manifests using fake assets; implement preview renderer suitable for CI; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. + +## 9. Validation, Fallback, And Pipeline Orchestration + +- [ ] 9.1 Add failing tests for structural validation including page count, dimensions, required text, missing assets, unsafe full-slide source plus text, object order, and validation report shape; implement validator; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. +- [ ] 9.2 Add failing tests for preview similarity threshold pass/fail behavior using deterministic fixture images; implement preview comparison and quality gates; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. +- [ ] 9.3 Add failing tests that default fallback policy is `fail`, validation failure returns a clear error, and no low-fidelity output is silently returned; implement default fallback handling; pass `python -m pytest tests/test_generative_editable_pipeline.py`. +- [ ] 9.4 Add failing tests for explicit `text_editable_background` fallback and explicit `raster_pptx` fallback with response metadata; implement fallback policies; pass `python -m pytest tests/test_generative_editable_pipeline.py tests/test_generative_editable_export_route.py`. +- [ ] 9.5 Add failing orchestration tests for a one-slide deck using fake OCR, fake image edit, fake image generation, fake planner, fake assets, composer, preview, and validator; implement pipeline orchestration; pass `python -m pytest tests/test_generative_editable_pipeline.py`. +- [ ] 9.6 Add failing orchestration tests for multi-slide ordering, bounded page concurrency, per-page manifest creation, repair aggregation, validation aggregation, and artifact cleanup; implement multi-page pipeline behavior; pass `python -m pytest tests/test_generative_editable_pipeline.py`. +- [ ] 9.7 Add failing API route tests that `format: "generative_editable_pptx"` returns a valid PPTX when fake providers pass validation; wire the export route to the pipeline; pass `python -m pytest tests/test_generative_editable_export_route.py`. +- [ ] 9.8 Add failing API route tests for missing provider config, provider timeout, provider failure, validation failure, repair-limit failure, and explicit fallback behavior; implement route error handling; pass `python -m pytest tests/test_generative_editable_export_route.py`. + +## 10. Frontend Experience + +- [ ] 10.1 Add failing frontend tests that the export menu exposes a distinct high-fidelity editable PPTX option without renaming the existing PPTX option; implement UI labels and selection state; pass `cd web && npm run test -- Export`. +- [ ] 10.2 Add failing frontend service tests for `generative_editable_pptx` request body, slide order, aspect ratio, text metadata, fallback policy, filename, and MIME handling; implement service/type updates; pass `cd web && npm run test -- Export`. +- [ ] 10.3 Add failing frontend tests for progress stages, provider/configuration/validation errors, and non-destructive failure handling; implement progress and error UI; pass `cd web && npm run test -- Export AppProjectLifecycle`. +- [ ] 10.4 Run frontend lint and build after the UI changes; fix failures with TDD loops where behavior changes are needed; pass `cd web && npm run lint && npm run build`. + +## 11. Live Provider Verification And Documentation + +- [ ] 11.1 Add a deterministic fixture deck/image set covering text, simple shapes, repeated bitmap assets, one complex visual, text-clean fallback, 16:9, and 4:3; verify it passes fake-provider backend tests; pass `python -m pytest tests/test_generative_editable_*`. +- [ ] 11.2 Before live provider verification, request local `config.yaml` OCR, image edit, and image generation provider values from the developer; run a one-slide live smoke test and record non-secret results in developer docs; pass the smoke test command added for this feature. +- [ ] 11.3 Run a small multi-slide live verification with real providers after the one-slide smoke test passes; verify preview validation, repair behavior, and output PPTX editability manually; record non-secret findings in developer docs. +- [ ] 11.4 Update user-facing and developer documentation for configuration schema, provider roles, quality-first behavior, fallback policies, known limitations, and the distinction from the image-layer OpenSpec change; verify docs do not include real credentials. +- [ ] 11.5 Run complete backend verification and fix regressions through TDD loops; pass `python -m pytest tests`. +- [ ] 11.6 Run complete frontend verification and fix regressions through TDD loops; pass `cd web && npm run lint && npm run test && npm run build`. +- [ ] 11.7 Validate the OpenSpec change; pass `openspec status --change add-generative-editable-pptx-export` and `openspec validate add-generative-editable-pptx-export --strict`. From 39b5e61d8c211ccc6950824c6a32e5a6e7de8db1 Mon Sep 17 00:00:00 2001 From: Lriver Date: Tue, 7 Jul 2026 22:35:02 +0800 Subject: [PATCH 02/16] Add generative editable PPTX module Implements a modular image-to-editable-PPTX reconstruction path with OCR, VLM-first analysis, image-edit/background/assets, manifest composition, validation, export integration, and real verification runners. Adds UI/config support for optional VLM and OCR models, plus stage docs and tests. Current live full-chain validation remains gated by VLM provider quota; reports now expose retry attempts and fallback status instead of silently accepting raster output. --- .gitignore | 1 + README.md | 12 +- README_zh.md | 12 +- api/README.md | 61 +- api/models.py | 38 +- api/profile_resolver.py | 18 +- api/routes/export.py | 365 +- api/routes/generate.py | 26 + config.example.yaml | 44 +- ...editable-pptx-reference-full-chain-plan.md | 573 ++ docs/editable-pptx-reference-pipeline.md | 894 ++ docs/generative-editable-live-verification.md | 48 + docs/generative-editable-pptx.md | 132 + .../design.md | 22 +- .../proposal.md | 2 +- .../generative-editable-pptx-export/spec.md | 18 +- .../tasks.md | 110 +- scripts/run_real_generative_editable_pptx.py | 1749 ++++ scripts/smoke_generative_editable_pptx.py | 254 + src/config_writer.py | 11 +- src/generative_editable_assets.py | 843 ++ src/generative_editable_backgrounds.py | 567 ++ src/generative_editable_composer.py | 370 + src/generative_editable_config.py | 199 + src/generative_editable_foreground_planner.py | 277 + src/generative_editable_job_artifacts.py | 158 + src/generative_editable_manifest.py | 494 ++ src/generative_editable_page_analysis.py | 219 + src/generative_editable_pipeline.py | 5377 ++++++++++++ src/generative_editable_preview_validator.py | 1281 +++ src/generative_editable_providers.py | 1984 +++++ src/generative_editable_shape_fitter.py | 159 + src/generative_editable_text.py | 458 + src/generative_editable_text_candidates.py | 520 ++ src/generative_editable_text_masks.py | 106 + src/generative_editable_vlm_reconstruction.py | 2636 ++++++ src/model_profiles.py | 32 +- tests/generative_editable_fixtures.py | 144 + tests/test_config_writer.py | 40 +- tests/test_generate_route_helpers.py | 35 +- tests/test_generative_editable_assets.py | 853 ++ tests/test_generative_editable_backgrounds.py | 640 ++ tests/test_generative_editable_composer.py | 600 ++ tests/test_generative_editable_config.py | 282 + ...est_generative_editable_export_contract.py | 373 + .../test_generative_editable_export_route.py | 784 ++ tests/test_generative_editable_fixtures.py | 76 + ..._generative_editable_foreground_planner.py | 172 + .../test_generative_editable_job_artifacts.py | 267 + ...test_generative_editable_live_providers.py | 727 ++ tests/test_generative_editable_manifest.py | 489 ++ .../test_generative_editable_page_analysis.py | 53 + tests/test_generative_editable_pipeline.py | 7708 +++++++++++++++++ ...t_generative_editable_preview_validator.py | 1404 +++ tests/test_generative_editable_providers.py | 1019 +++ .../test_generative_editable_shape_fitter.py | 266 + .../test_generative_editable_smoke_script.py | 241 + tests/test_generative_editable_text.py | 712 ++ ...est_generative_editable_text_candidates.py | 129 + tests/test_generative_editable_text_masks.py | 100 + ..._generative_editable_vlm_reconstruction.py | 2871 ++++++ tests/test_model_profiles.py | 45 +- tests/test_profile_resolver.py | 37 +- tests/test_real_generative_editable_runner.py | 2836 ++++++ web/pnpm-lock.yaml | 4205 +++++++++ web/pnpm-workspace.yaml | 2 + web/src/App.tsx | 10 +- web/src/components/ApiConfigForm.tsx | 193 +- web/src/components/ExportButton.tsx | 18 +- web/src/components/RightPanel.tsx | 17 +- .../__tests__/ApiConfigForm.property.test.tsx | 21 +- .../__tests__/AppProjectLifecycle.test.tsx | 51 +- .../__tests__/ExportButton.test.tsx | 24 + .../ExportConsistency.property.test.tsx | 82 +- .../__tests__/RightPanelExport.test.tsx | 37 + web/src/hooks/__tests__/useExport.test.tsx | 49 + web/src/hooks/useEdit.ts | 11 +- web/src/hooks/useExport.ts | 22 +- web/src/i18n.ts | 30 +- .../services/__tests__/exportService.test.ts | 189 + .../__tests__/generateService.test.ts | 36 + .../__tests__/modelProfileService.test.ts | 65 + web/src/services/exportService.ts | 99 +- web/src/services/generateService.ts | 5 +- web/src/services/modelProfileService.ts | 22 +- web/src/types/index.ts | 25 +- web/src/utils/apiConfig.ts | 33 + 87 files changed, 47993 insertions(+), 226 deletions(-) create mode 100644 docs/editable-pptx-reference-full-chain-plan.md create mode 100644 docs/editable-pptx-reference-pipeline.md create mode 100644 docs/generative-editable-live-verification.md create mode 100644 docs/generative-editable-pptx.md create mode 100644 scripts/run_real_generative_editable_pptx.py create mode 100644 scripts/smoke_generative_editable_pptx.py create mode 100644 src/generative_editable_assets.py create mode 100644 src/generative_editable_backgrounds.py create mode 100644 src/generative_editable_composer.py create mode 100644 src/generative_editable_config.py create mode 100644 src/generative_editable_foreground_planner.py create mode 100644 src/generative_editable_job_artifacts.py create mode 100644 src/generative_editable_manifest.py create mode 100644 src/generative_editable_page_analysis.py create mode 100644 src/generative_editable_pipeline.py create mode 100644 src/generative_editable_preview_validator.py create mode 100644 src/generative_editable_providers.py create mode 100644 src/generative_editable_shape_fitter.py create mode 100644 src/generative_editable_text.py create mode 100644 src/generative_editable_text_candidates.py create mode 100644 src/generative_editable_text_masks.py create mode 100644 src/generative_editable_vlm_reconstruction.py create mode 100644 tests/generative_editable_fixtures.py create mode 100644 tests/test_generative_editable_assets.py create mode 100644 tests/test_generative_editable_backgrounds.py create mode 100644 tests/test_generative_editable_composer.py create mode 100644 tests/test_generative_editable_config.py create mode 100644 tests/test_generative_editable_export_contract.py create mode 100644 tests/test_generative_editable_export_route.py create mode 100644 tests/test_generative_editable_fixtures.py create mode 100644 tests/test_generative_editable_foreground_planner.py create mode 100644 tests/test_generative_editable_job_artifacts.py create mode 100644 tests/test_generative_editable_live_providers.py create mode 100644 tests/test_generative_editable_manifest.py create mode 100644 tests/test_generative_editable_page_analysis.py create mode 100644 tests/test_generative_editable_pipeline.py create mode 100644 tests/test_generative_editable_preview_validator.py create mode 100644 tests/test_generative_editable_providers.py create mode 100644 tests/test_generative_editable_shape_fitter.py create mode 100644 tests/test_generative_editable_smoke_script.py create mode 100644 tests/test_generative_editable_text.py create mode 100644 tests/test_generative_editable_text_candidates.py create mode 100644 tests/test_generative_editable_text_masks.py create mode 100644 tests/test_generative_editable_vlm_reconstruction.py create mode 100644 tests/test_real_generative_editable_runner.py create mode 100644 web/pnpm-lock.yaml create mode 100644 web/pnpm-workspace.yaml create mode 100644 web/src/components/__tests__/ExportButton.test.tsx create mode 100644 web/src/components/__tests__/RightPanelExport.test.tsx create mode 100644 web/src/hooks/__tests__/useExport.test.tsx create mode 100644 web/src/services/__tests__/exportService.test.ts create mode 100644 web/src/services/__tests__/generateService.test.ts create mode 100644 web/src/services/__tests__/modelProfileService.test.ts diff --git a/.gitignore b/.gitignore index b2109b8..dfca99e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ node_modules/ output/ test_output/ .playwright-cli/ +.vite/ temp_buid_note/ # 生成的文件 diff --git a/README.md b/README.md index c5145b3..da16311 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ NotebookLM's PPT feature is closer to a one-click result generator, with limited - **Model control**: Configure separate OpenAI-compatible models for text planning, image generation, and image editing - **Local-first config**: Manage model connections through local `config.yaml` or WebUI local API configuration; saved projects and exported files do not include API keys - **Export-ready output**: Export generated decks to PDF/PPTX for presentation or further editing +- **Experimental high-fidelity editable PPTX export**: Rebuild slide images into editable PowerPoint text boxes, conservative native shapes, and positioned bitmap assets through a provider-gated generative editable export path ## ✨ Features @@ -29,6 +30,7 @@ NotebookLM's PPT feature is closer to a one-click result generator, with limited - 🔀 **Three model roles**: Configure `prompt_model`, `image_model`, and `edit_model` separately - 🖼️ **Image result compatibility**: Accepts URLs, Markdown image links, data URLs, `b64_json`, and raw base64 - 💾 **Local multi-project persistence**: Save multiple PPT projects in the browser, including source content, outline, page designs, generated images, and per-slide edit history +- 📤 **Editable PPTX reconstruction**: Export a separate experimental high-fidelity editable PPTX mode after provider verification; see [Generative Editable PPTX Export](docs/generative-editable-pptx.md) ## 🚀 Quick Start @@ -102,6 +104,8 @@ Notes: 6. **Preview & Edit**: Preview generated slides in the right panel and edit a single page when needed 7. **Export**: Export to PDF or PPTX +The export menu keeps the existing raster PPTX option and adds a separate experimental high-fidelity editable PPTX option. The editable mode uses OCR plus image editing/image generation providers and is quality-gated by default; it fails rather than silently returning a low-fidelity deck unless an explicit fallback policy is requested. Strict live provider verification is still pending for the currently tested provider set. See [Generative Editable PPTX Export](docs/generative-editable-pptx.md). + The built-in demo source is `doc/L9.md`. This is a repository-relative path, so a fresh clone can use it directly in the WebUI or CLI examples. ## 📁 Project Structure @@ -123,6 +127,7 @@ OpenNotebookLM-AIPPT/ All configurations are managed in `config.yaml`, including: - API configuration (`prompt_model`, `image_model`, `edit_model`) +- Generative editable PPTX provider roles and quality gates (`ocr_model`, cleanup, asset generation, repair, validation) - PPT default settings (language, style, page count) - Timeout and retry settings @@ -130,21 +135,22 @@ See `config.example.yaml` for detailed configuration examples. ### Using OpenAI Compatible API +The current protocol is OpenAI-compatible `/chat/completions`: text profiles use +chat completions, and image/edit profiles use multimodal chat completions that +return an image URL, data URL, or base64 payload. + ```yaml api: models: prompt_model: - adapter: "openai_chat" model: "gpt-4o" base_url: "https://api.openai.com/v1" api_key: "sk-xxx" image_model: - adapter: "raw_chat_multimodal" model: "gpt-image-2" base_url: "https://api.example.com/v1" api_key: "sk-xxx" edit_model: - adapter: "raw_chat_multimodal" model: "gpt-image-2" base_url: "https://api.example.com/v1" api_key: "sk-xxx" diff --git a/README_zh.md b/README_zh.md index f6a1a93..993f29e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,6 +19,7 @@ NotebookLM 的 PPT 能力更像“一键生成结果”,中间设计过程和 - **模型可控**:文本规划、生图、图片编辑可分别配置不同 OpenAI-compatible 模型 - **本地可跑**:可使用本地 `config.yaml` 或 WebUI 本地 API 配置管理模型连接,项目记录和导出文件不包含 API Key - **结果可导出**:生成后可直接导出 PDF/PPTX,适合继续汇报或二次编辑 +- **实验性高保真可编辑 PPTX 导出**:通过 provider 门禁的生成式可编辑导出链路,把幻灯片图片重建为 PowerPoint 可编辑文本框、保守原生形状和可移动图片资产 ## ✨ 功能特性 @@ -29,6 +30,7 @@ NotebookLM 的 PPT 能力更像“一键生成结果”,中间设计过程和 - 🔀 **三模型角色**:支持 `prompt_model`、`image_model`、`edit_model` 分别配置 - 🖼️ **图像结果兼容**:兼容 URL、Markdown 图片链接、data URL、`b64_json` 和纯 base64 - 💾 **多项目本地留存**:支持在浏览器本地保存多个 PPT 项目,恢复资料、设计大纲、逐页设计、生成图片和单页编辑历史 +- 📤 **可编辑 PPTX 重建**:新增独立的实验性高保真可编辑 PPTX 导出模式,需要通过 provider 验证后使用,详见 [Generative Editable PPTX Export](docs/generative-editable-pptx.md) ## 🚀 快速开始 @@ -102,6 +104,8 @@ AIPPT 会把项目内容和图片资源保存在当前浏览器 Profile 的 Inde 6. **预览编辑**:在右侧面板预览生成的幻灯片,点击可进行单页编辑 7. **导出文件**:选择 PDF 或 PPTX 格式导出 +导出菜单会保留原有栅格 PPTX 选项,并新增独立的实验性高保真可编辑 PPTX 选项。可编辑模式依赖 OCR、图像编辑和图像生成 provider,默认优先保证质量:验证失败时返回错误,不会悄悄降级为低保真 PPTX,除非请求显式允许 fallback。当前测试过的 provider 组合仍未通过严格 live 验证。详见 [Generative Editable PPTX Export](docs/generative-editable-pptx.md)。 + 仓库内置演示资料为 `doc/L9.md`。该路径是仓库相对路径,clone 后可直接用于 WebUI 上传或命令行示例。 ## 📁 项目结构 @@ -123,6 +127,7 @@ OpenNotebookLM-AIPPT/ 所有配置统一在 `config.yaml` 中管理,包括: - API 配置(文本 prompt、生图、编辑三角色模型) +- 生成式可编辑 PPTX provider 角色和质量门禁(OCR、清理、资产生成、修复、验证) - PPT 默认配置(语言、风格、页数) - 超时和重试配置 @@ -130,21 +135,22 @@ OpenNotebookLM-AIPPT/ ### 使用 OpenAI 兼容 API +当前调用协议为 OpenAI-compatible `/chat/completions`:文本模型走 chat +completion,图像/编辑模型走多模态 chat completion,响应需返回图片 URL、data URL +或 base64。 + ```yaml api: models: prompt_model: - adapter: "openai_chat" model: "gpt-4o" base_url: "https://api.openai.com/v1" api_key: "sk-xxx" image_model: - adapter: "raw_chat_multimodal" model: "gpt-image-2" base_url: "https://api.example.com/v1" api_key: "sk-xxx" edit_model: - adapter: "raw_chat_multimodal" model: "gpt-image-2" base_url: "https://api.example.com/v1" api_key: "sk-xxx" diff --git a/api/README.md b/api/README.md index c92138f..c51d670 100644 --- a/api/README.md +++ b/api/README.md @@ -7,7 +7,7 @@ FastAPI 后端服务,为 WebUI 前端提供 PPT 生成、编辑和导出功能 - **文件上传**: 支持 `.md/.txt/.pdf/.docx/.pptx`,统一解析为 Markdown - **PPT 生成**: 基于输入材料生成 PPT 幻灯片,支持流式返回进度 - **图生图编辑**: 对单页幻灯片进行修改 -- **导出功能**: 支持导出为 PDF 和 PPTX 格式 +- **导出功能**: 支持导出为 PDF、栅格 PPTX 和生成式高保真可编辑 PPTX 格式 - **模型路由**: 支持 prompt/image/edit 三角色模型 profile ## API 端点 @@ -44,17 +44,15 @@ Content-Type: application/json "content": "Markdown 内容", "config": { "model_profiles": { - "prompt_model": { + "text_model": { "model": "DeepSeek-V4-Pro", "base_url": "https://api.example.com/v1", - "api_key": "your-text-key", - "adapter": "openai_chat" + "api_key": "your-text-key" }, "image_model": { "model": "gpt-image-2", "base_url": "https://api.example.com/v1", - "api_key": "your-image-key", - "adapter": "raw_chat_multimodal" + "api_key": "your-image-key" } }, "page_count": 10, @@ -81,9 +79,9 @@ Content-Type: application/json "instruction": "修改指令", "config": { "model_profiles": { - "prompt_model": {"model": "DeepSeek-V4-Pro", "base_url": "https://api.example.com/v1", "api_key": "key", "adapter": "openai_chat"}, - "image_model": {"model": "gpt-image-2", "base_url": "https://api.example.com/v1", "api_key": "key", "adapter": "raw_chat_multimodal"}, - "edit_model": {"model": "gpt-image-2", "base_url": "https://api.example.com/v1", "api_key": "key", "adapter": "raw_chat_multimodal"} + "text_model": {"model": "DeepSeek-V4-Pro", "base_url": "https://api.example.com/v1", "api_key": "key"}, + "image_model": {"model": "gpt-image-2", "base_url": "https://api.example.com/v1", "api_key": "key"}, + "edit_model": {"model": "gpt-image-2", "base_url": "https://api.example.com/v1", "api_key": "key"} }, "quality": "1K", "aspect_ratio": "16:9" @@ -115,6 +113,51 @@ Content-Type: application/json 响应: 文件下载 ``` +#### 生成式高保真可编辑 PPTX + +该模式与普通 `pptx` 分开:普通 `pptx` 仍是把每页图片作为整页背景放入 PowerPoint;`generative_editable_pptx` 会通过 OCR、图片清理、前景资产重建、原生形状拟合和验证流程重建可编辑元素。 + +``` +POST /api/export +Content-Type: application/json + +请求体: +{ + "format": "generative_editable_pptx", + "aspect_ratio": "16:9", + "slide_order": ["slide-1", "slide-2"], + "editable_options": { + "fallback_policy": "fail" + }, + "slides": [ + { + "slide_id": "slide-1", + "image_base64": "base64编码的图片", + "text_metadata": [ + { + "text": "Quarterly Plan", + "role": "title", + "order": 1, + "style_hint": {"font_size": 32, "bold": true} + } + ] + } + ] +} +``` + +`fallback_policy` 可选值: +- `fail`:默认。验证失败时返回错误,不返回低保真文件。 +- `text_editable_background`:显式允许使用文本可编辑、背景清理后的 fallback。 +- `raster_pptx`:显式允许降级到现有栅格 PPTX 导出。 + +成功响应仍是 `.pptx` 文件下载。若使用 fallback,响应 header 会包含: +- `X-Generative-Editable-Status` +- `X-Generative-Editable-Fallback-Policy` +- `X-Generative-Editable-Fallback-Used` + +更多配置和限制见 `docs/generative-editable-pptx.md`。 + ## 启动服务器 ### 方式 1: 使用启动脚本 diff --git a/api/models.py b/api/models.py index 27107f3..fb2b9c2 100644 --- a/api/models.py +++ b/api/models.py @@ -2,7 +2,7 @@ Pydantic 请求/响应模型定义 """ -from typing import List, Optional, Literal +from typing import Any, List, Optional, Literal from pydantic import BaseModel, Field @@ -51,18 +51,24 @@ class ModelProfileConfig(BaseModel): model: str = Field(..., description="模型名称") base_url: str = Field(..., description="OpenAI-compatible Base URL") api_key: str = Field(..., description="API Key") - adapter: str = Field("openai_chat", description="适配器") thinking: Optional[Literal["enabled", "disabled"]] = Field( None, description="OpenAI-compatible thinking mode" ) class ModelProfilesConfig(BaseModel): - """三角色模型配置""" + """模型 profile 配置""" - prompt_model: ModelProfileConfig + text_model: Optional[ModelProfileConfig] = None + prompt_model: Optional[ModelProfileConfig] = Field(None, description="旧字段,兼容 text_model") image_model: ModelProfileConfig edit_model: Optional[ModelProfileConfig] = None + VLM: Optional[ModelProfileConfig] = Field( + None, description="可选,多模态理解模型;图片转可编辑 PPTX 时使用" + ) + ocr_model: Optional[ModelProfileConfig] = Field( + None, description="可选,OCR 模型;图片转可编辑 PPTX 时使用" + ) class GenerationConfig(BaseModel): @@ -226,6 +232,7 @@ class SlideData(BaseModel): page_number: int image_base64: str prompt: str + text_metadata: List["ExportTextMetadata"] = Field(default_factory=list) class GenerationProgressEvent(BaseModel): @@ -293,14 +300,35 @@ class ExportSlide(BaseModel): """导出的幻灯片""" image_base64: str + slide_id: Optional[str] = None + text_metadata: List["ExportTextMetadata"] = Field(default_factory=list) + + +class ExportTextMetadata(BaseModel): + """导出时附带的可编辑文本语义信息""" + + text: str + role: str + order: int + style_hint: dict[str, Any] = Field(default_factory=dict) + + +class ExportEditableOptions(BaseModel): + """可编辑导出的附加选项""" + + fallback_policy: Literal["fail", "text_editable_background", "raster_pptx"] = "fail" class ExportRequest(BaseModel): """导出请求""" slides: List[ExportSlide] - format: Literal["pdf", "pptx"] = Field(..., description="导出格式") + format: Literal["pdf", "pptx", "generative_editable_pptx"] = Field( + ..., description="导出格式" + ) aspect_ratio: Literal["16:9", "4:3"] = Field("16:9", description="导出画幅比例") + slide_order: Optional[List[str]] = None + editable_options: ExportEditableOptions = Field(default_factory=ExportEditableOptions) class ExportResponse(BaseModel): diff --git a/api/profile_resolver.py b/api/profile_resolver.py index c6491b4..5a7f40f 100644 --- a/api/profile_resolver.py +++ b/api/profile_resolver.py @@ -15,9 +15,17 @@ def _has_complete_generation_profiles(config: Any) -> bool: profiles = getattr(config, "model_profiles", None) if not profiles: return False - required = (profiles.prompt_model, profiles.image_model) + text_profile = ( + getattr(profiles, "text_model", None) + or getattr(profiles, "prompt_model", None) + or getattr(profiles, "VLM", None) + ) + required = (text_profile, profiles.image_model) return all( - _has_value(profile.model) and _has_value(profile.base_url) and _has_value(profile.api_key) + profile + and _has_value(profile.model) + and _has_value(profile.base_url) + and _has_value(profile.api_key) for profile in required ) @@ -41,7 +49,7 @@ def profiles_from_generation_config(config: Any) -> ModelProfileSet: if _has_complete_legacy_profiles(config): data: Dict[str, Any] = { - "prompt_model": { + "text_model": { "model": config.text.model, "base_url": config.text.base_url, "api_key": config.text.api_key, @@ -68,7 +76,7 @@ def profiles_from_generation_config(config: Any) -> ModelProfileSet: return resolve_model_profiles( { - "prompt_model": { + "text_model": { "model": config.get_text_model(), "base_url": config.get_text_base_url(), "api_key": config.get_text_api_key(), @@ -98,7 +106,7 @@ def profiles_from_edit_config(config: Any) -> ModelProfileSet: return resolve_model_profiles( { - "prompt_model": { + "text_model": { "model": config.model, "base_url": config.base_url, "api_key": config.api_key, diff --git a/api/routes/export.py b/api/routes/export.py index 944aa3f..33c49ad 100644 --- a/api/routes/export.py +++ b/api/routes/export.py @@ -7,6 +7,7 @@ import asyncio import tempfile import shutil +from dataclasses import replace from pathlib import Path from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse @@ -17,6 +18,39 @@ sys.path.insert(0, str(project_root)) from src.exporter import PDFExporter +from src.generative_editable_composer import compose_deck_from_manifests +from src.generative_editable_config import ( + GenerativeEditableConfig, + GenerativeEditableConfigError, + ProviderConfig, + load_generative_editable_config, +) +from src.generative_editable_manifest import read_deck_manifest, read_page_manifest, write_manifest +from src.generative_editable_pipeline import ( + GenerativeEditableFallbackError, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, + finalize_validated_export, + run_generative_editable_pipeline, +) +from src.generative_editable_vlm_reconstruction import ( + OpenAIChatVLMPageAnalysisProvider, + VLMEditablePipelineDependencies, + run_vlm_editable_pptx_pipeline, +) +from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + LocalTesseractOCRProvider, + OpenAIChatImageEditProvider, + OpenAIChatImageGenerationProvider, + OpenAIChatOCRProvider, + ProviderError, + ProviderTimeoutError, +) +from src.model_profiles import load_default_profiles from ..models import ExportRequest router = APIRouter(prefix="/api", tags=["export"]) @@ -73,6 +107,27 @@ async def export_presentation(request: ExportRequest): background=BackgroundTask(shutil.rmtree, temp_dir, ignore_errors=True), ) + elif request.format == "generative_editable_pptx": + output_path = temp_dir / "presentation.generative-editable.pptx" + export_result = await asyncio.to_thread( + _export_generative_editable_pptx, + image_paths, + str(output_path), + aspect_ratio=request.aspect_ratio, + slides=request.slides, + editable_options=getattr(request, "editable_options", None), + slide_order=getattr(request, "slide_order", None), + ) + response_path = getattr(export_result, "output_path", str(output_path)) + + return FileResponse( + path=str(response_path), + media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + filename="presentation.generative-editable.pptx", + headers=_generative_editable_response_headers(export_result), + background=BackgroundTask(shutil.rmtree, temp_dir, ignore_errors=True), + ) + else: raise HTTPException(status_code=400, detail=f"不支持的导出格式: {request.format}") @@ -84,7 +139,7 @@ async def export_presentation(request: ExportRequest): except Exception as e: if "temp_dir" in locals(): shutil.rmtree(temp_dir, ignore_errors=True) - raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + raise _export_http_exception(e) def _export_pptx(image_paths: list, output_path: str, aspect_ratio: str = "16:9"): @@ -123,3 +178,311 @@ def _export_pptx(image_paths: list, output_path: str, aspect_ratio: str = "16:9" # 保存 prs.save(output_path) + + +def _export_generative_editable_pptx( + image_paths: list, + output_path: str, + *, + aspect_ratio: str = "16:9", + slides: list | None = None, + editable_options: object | None = None, + slide_order: list[str] | None = None, +): + """Export a generative editable PPTX through the reconstruction pipeline.""" + config = load_generative_editable_config() + fallback_policy = _editable_fallback_policy(editable_options) + artifact_root = Path(output_path).with_suffix(".artifacts") + job_id = "export" + slide_inputs = _generative_editable_slide_inputs( + image_paths=image_paths, + slides=slides, + slide_order=slide_order, + ) + ordered_image_paths = [slide.image_path for slide in slide_inputs] + fallback_factory = None + if fallback_policy == "raster_pptx": + fallback_factory = lambda: _export_raster_pptx_fallback( + image_paths=ordered_image_paths, + output_path=output_path, + aspect_ratio=aspect_ratio, + ) + elif fallback_policy == "text_editable_background": + fallback_factory = lambda: _export_text_editable_background_fallback( + artifact_root=artifact_root, + job_id=job_id, + output_path=output_path, + ) + + try: + if config.reconstruction_mode == "vlm_first": + try: + return run_vlm_editable_pptx_pipeline( + slides=slide_inputs, + output_path=output_path, + artifact_root=str(artifact_root), + job_id=job_id, + dependencies=_build_vlm_editable_pipeline_dependencies(config), + aspect_ratio=aspect_ratio, + cleanup_artifacts=fallback_policy == "fail", + ) + except GenerativeEditableValidationError as exc: + return finalize_validated_export( + validation_report=exc.validation_report, + output_path=output_path, + fallback_policy=fallback_policy, + fallback_output_factory=fallback_factory, + ) + return run_generative_editable_pipeline( + slides=slide_inputs, + output_path=output_path, + artifact_root=str(artifact_root), + job_id=job_id, + dependencies=_build_generative_editable_pipeline_dependencies(), + aspect_ratio=aspect_ratio, + fallback_policy=fallback_policy, + fallback_output_factory=fallback_factory, + max_page_concurrency=1, + cleanup_artifacts=True, + ) + except Exception: + Path(output_path).unlink(missing_ok=True) + raise + + +def _editable_fallback_policy(editable_options: object | None) -> str: + if editable_options is None: + return "fail" + if isinstance(editable_options, dict): + return str(editable_options.get("fallback_policy") or "fail") + return str(getattr(editable_options, "fallback_policy", "fail") or "fail") + + +def _export_raster_pptx_fallback(*, image_paths: list, output_path: str, aspect_ratio: str) -> str: + _export_pptx(image_paths, output_path, aspect_ratio=aspect_ratio) + return output_path + + +def _export_text_editable_background_fallback(*, artifact_root: Path, job_id: str, output_path: str) -> str: + job_dir = artifact_root / job_id + deck_path = job_dir / "deck.json" + deck = read_deck_manifest(deck_path) + fallback_dir = job_dir / "pages" / "text-editable-background-fallback" + fallback_dir.mkdir(parents=True, exist_ok=True) + page_manifest_paths = [] + for page_ref in deck.page_manifest_paths: + page = read_page_manifest(job_dir / page_ref) + background = page.text_clean_background or page.base_clean_background or page.chosen_background + if not background: + raise RuntimeError("text_clean_background fallback requires a cleaned background artifact") + fallback_page = replace( + page, + chosen_background=background, + native_shapes=[], + bitmap_assets=[], + asset_sheets=[], + repair_attempts=[], + validation_status="pending", + ) + fallback_page_path = fallback_dir / Path(page_ref).name + write_manifest(fallback_page_path, fallback_page) + page_manifest_paths.append(f"pages/text-editable-background-fallback/{fallback_page_path.name}") + fallback_deck = replace( + deck, + page_manifest_paths=page_manifest_paths, + fallback_policy="text_editable_background", + validation_status="pending", + ) + fallback_deck_path = fallback_dir / "deck.json" + write_manifest(fallback_deck_path, fallback_deck) + compose_deck_from_manifests(fallback_deck_path, job_dir, output_path) + return output_path + + +def _export_http_exception(error: Exception) -> HTTPException: + if isinstance(error, GenerativeEditableConfigError): + return HTTPException(status_code=400, detail=f"导出失败: {error}") + if isinstance(error, ProviderTimeoutError): + return HTTPException(status_code=504, detail=f"导出失败: {error}") + if isinstance(error, ProviderError): + return HTTPException(status_code=502, detail=f"导出失败: {error}") + if isinstance(error, GenerativeEditableValidationError): + return HTTPException(status_code=422, detail=f"导出失败: {error}") + if isinstance(error, GenerativeEditableFallbackError): + return HTTPException(status_code=502, detail=f"导出失败: {error}") + return HTTPException(status_code=500, detail=f"导出失败: {error}") + + +def _build_generative_editable_pipeline_dependencies() -> GenerativeEditablePipelineDependencies: + config = load_generative_editable_config() + return GenerativeEditablePipelineDependencies( + ocr_provider=_ocr_provider_for_config(config.ocr, timeout_seconds=config.timeouts.provider_call), + image_edit_provider=_image_edit_provider_for_config(config.clean_base_model), + asset_sheet_image_edit_provider=_image_edit_provider_for_config(config.asset_sheet_model), + repair_image_edit_provider=_image_edit_provider_for_config(config.repair_model), + image_generation_provider=_image_generation_provider_for_config(config.generation_model), + preview_similarity_threshold=config.quality.preview_similarity_threshold, + require_preview_validation=config.quality.require_preview_validation, + max_repair_attempts=config.quality.max_repair_attempts, + provider_timeout_seconds=config.timeouts.provider_call, + provider_max_attempts=config.retries.provider_max_attempts, + provider_retry_backoff_seconds=config.retries.backoff_seconds, + use_aippt_metadata_first=config.use_aippt_metadata_first, + ocr_min_confidence=config.ocr_min_confidence, + ) + + +def _build_vlm_editable_pipeline_dependencies( + config: GenerativeEditableConfig, +) -> VLMEditablePipelineDependencies: + profiles = load_default_profiles() + if not profiles or not profiles.vlm: + raise GenerativeEditableConfigError("Missing VLM model profile for vlm_first reconstruction") + vlm_config = ProviderConfig( + role="VLM", + model=profiles.vlm.model, + base_url=profiles.vlm.base_url, + api_key=profiles.vlm.api_key, + adapter=profiles.vlm.adapter, + ) + return VLMEditablePipelineDependencies( + vlm_provider=OpenAIChatVLMPageAnalysisProvider(vlm_config), + image_edit_provider=_image_edit_provider_for_config(config.clean_base_model), + asset_sheet_image_edit_provider=_image_edit_provider_for_config(config.asset_sheet_model), + ocr_provider=_ocr_provider_for_config(config.ocr, timeout_seconds=config.timeouts.provider_call), + provider_timeout_seconds=config.timeouts.provider_call, + page_timeout_seconds=config.timeouts.page, + provider_max_attempts=config.retries.provider_max_attempts, + provider_retry_backoff_seconds=config.retries.backoff_seconds, + ocr_min_confidence=config.ocr_min_confidence, + preview_similarity_threshold=config.quality.preview_similarity_threshold, + require_preview_validation=config.quality.require_preview_validation, + ) + + +def _build_fake_generative_editable_pipeline_dependencies( + *, + validation_status: str = "passed", + validation_code: str = "preview_similarity_failed", +) -> GenerativeEditablePipelineDependencies: + from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + + config = load_generative_editable_config(use_fake=True) + + def structure_validator(**kwargs): + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + def preview_validator(**kwargs): + if validation_status == "failed": + return ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code=validation_code, + message="forced preview failure", + slide_id=kwargs["slide_id"], + ) + ], + ) + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + return GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + asset_sheet_image_edit_provider=FakeImageEditProvider(config.asset_sheet_model), + repair_image_edit_provider=FakeImageEditProvider(config.repair_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + preview_similarity_threshold=config.quality.preview_similarity_threshold, + require_preview_validation=config.quality.require_preview_validation, + max_repair_attempts=config.quality.max_repair_attempts, + provider_timeout_seconds=config.timeouts.provider_call, + provider_max_attempts=config.retries.provider_max_attempts, + provider_retry_backoff_seconds=config.retries.backoff_seconds, + use_aippt_metadata_first=config.use_aippt_metadata_first, + ocr_min_confidence=config.ocr_min_confidence, + structure_validator=structure_validator, + preview_validator=preview_validator, + ) + + +def _ocr_provider_for_config(config, *, timeout_seconds: int = 180): + if config.provider.startswith("fake_"): + return FakeOCRProvider(config) + if config.provider == "local_tesseract": + return LocalTesseractOCRProvider(config, timeout_seconds=timeout_seconds) + return OpenAIChatOCRProvider(config, timeout_seconds=timeout_seconds) + + +def _image_edit_provider_for_config(config): + if config.provider.startswith("fake_"): + return FakeImageEditProvider(config) + return OpenAIChatImageEditProvider(config) + + +def _image_generation_provider_for_config(config): + if config.provider.startswith("fake_"): + return FakeImageGenerationProvider(config) + return OpenAIChatImageGenerationProvider(config) + + +def _generative_editable_slide_inputs( + *, + image_paths: list, + slides: list | None, + slide_order: list[str] | None, +) -> list[GenerativeEditableSlideInput]: + slide_records = [] + for index, image_path in enumerate(image_paths): + slide = slides[index] if slides is not None and index < len(slides) else None + slide_id = getattr(slide, "slide_id", None) or f"slide-{index + 1}" + text_metadata = [ + item.model_dump() if hasattr(item, "model_dump") else dict(item) + for item in (getattr(slide, "text_metadata", []) if slide is not None else []) + ] + slide_records.append( + GenerativeEditableSlideInput( + slide_id=slide_id, + image_path=str(image_path), + text_metadata=text_metadata, + ) + ) + duplicate_slide_ids = _duplicates([slide.slide_id for slide in slide_records]) + if duplicate_slide_ids: + raise ValueError(f"slide_id values must be unique: {', '.join(duplicate_slide_ids)}") + if not slide_order: + return slide_records + duplicate_order_ids = _duplicates(slide_order) + if duplicate_order_ids: + raise ValueError(f"slide_order values must be unique: {', '.join(duplicate_order_ids)}") + by_id = {slide.slide_id: slide for slide in slide_records} + unknown_order_ids = [slide_id for slide_id in slide_order if slide_id not in by_id] + if unknown_order_ids: + raise ValueError(f"slide_order contains unknown slide_id: {', '.join(unknown_order_ids)}") + ordered = [by_id[slide_id] for slide_id in slide_order] + ordered.extend(slide for slide in slide_records if slide.slide_id not in set(slide_order)) + return ordered + + +def _duplicates(values: list[str]) -> list[str]: + seen = set() + duplicates = [] + for value in values: + if value in seen and value not in duplicates: + duplicates.append(value) + seen.add(value) + return duplicates + + +def _generative_editable_response_headers(export_result: object | None) -> dict[str, str]: + headers = {} + status = getattr(export_result, "status", "") + fallback_used = getattr(export_result, "fallback_used", "") + fallback_policy = getattr(export_result, "fallback_policy", "") + if status: + headers["X-Generative-Editable-Status"] = str(status) + if fallback_policy: + headers["X-Generative-Editable-Fallback-Policy"] = str(fallback_policy) + if fallback_used: + headers["X-Generative-Editable-Fallback-Used"] = str(fallback_used) + return headers diff --git a/api/routes/generate.py b/api/routes/generate.py index e6463fe..29412f7 100644 --- a/api/routes/generate.py +++ b/api/routes/generate.py @@ -86,6 +86,31 @@ def _prompt_data_from_confirmed(request: GenerationRequest, ppt_config: PPTConfi ) +def _slide_text_metadata(slide_prompt) -> list[dict]: + metadata = [] + title = str(getattr(slide_prompt, "title", "") or "").strip() + body = str( + getattr(slide_prompt, "display_content", "") + or getattr(slide_prompt, "content_summary", "") + or "" + ).strip() + + if title: + metadata.append({"text": title, "role": "title", "order": 1, "style_hint": {}}) + + if body and body != title: + metadata.append( + { + "text": body, + "role": "body", + "order": len(metadata) + 1, + "style_hint": {}, + } + ) + + return metadata + + def generate_single_slide_sync( client: ModelRouter, slide_prompt, @@ -199,6 +224,7 @@ async def generate_stream(request: GenerationRequest) -> AsyncGenerator[str, Non "prompt": slide_prompt.prompt if hasattr(slide_prompt, "prompt") else str(slide_prompt), + "text_metadata": _slide_text_metadata(slide_prompt), }, } yield f"data: {json.dumps(slide_data)}\n\n" diff --git a/config.example.yaml b/config.example.yaml index 021a8b2..9d69bbe 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,25 +3,59 @@ # API 配置 api: - # 三角色模型配置。后端会优先读取这里,前端只负责选择/覆盖 profile。 + # 模型配置。后端会优先读取这里,前端只负责选择/覆盖 profile。 + # 调用协议:当前支持 OpenAI-compatible /chat/completions。 + # - text_model: 文本 chat completion;省略时可继承 VLM + # - image_model/edit_model: 多模态 chat completion,响应需返回图片 URL、data URL 或 base64 + # - VLM: 可选,多模态理解模型;仅在整页图片转可编辑 PPTX 时需要 + # - ocr_model: 可选,OCR 模型;仅在整页图片转可编辑 PPTX 时需要,响应需返回结构化 OCR JSON models: - prompt_model: + text_model: api_key: "your-text-api-key" base_url: "https://api.example.com/v1" model: "DeepSeek-V4-Pro" - adapter: "openai_chat" thinking: "disabled" # 可选: "enabled", "disabled" image_model: api_key: "your-image-api-key" base_url: "https://api.example.com/v1" model: "gpt-image-2" - adapter: "raw_chat_multimodal" # 可省略;省略时默认继承 image_model edit_model: api_key: "your-edit-api-key" base_url: "https://api.example.com/v1" model: "gpt-image-2" - adapter: "raw_chat_multimodal" + VLM: + api_key: "your-vlm-api-key" + base_url: "https://api.example.com/v1" + model: "gpt-5.5" + ocr_model: + api_key: "your-ocr-api-key" + base_url: "https://ocr.example.com/v1" + model: "PaddleOCR-VL-1.6" + +# 生成式可编辑 PPTX 导出配置:这里引用上面的模型角色,不直接写密钥 +generative_editable_pptx: + reconstruction: + mode: "vlm_first" + clean_base_model: "edit_model" + asset_sheet_model: "edit_model" + repair_model: "edit_model" + generation_model: "image_model" + ocr: + model: "ocr_model" + use_aippt_metadata_first: true + min_confidence: 0.75 + quality: + max_repair_attempts: 2 + preview_similarity_threshold: 0.92 + require_preview_validation: true + retries: + provider_max_attempts: 2 + repair_max_attempts: 2 + backoff_seconds: 1.0 + timeouts: + provider_call: 180 + page: 600 # PPT 默认配置 ppt: diff --git a/docs/editable-pptx-reference-full-chain-plan.md b/docs/editable-pptx-reference-full-chain-plan.md new file mode 100644 index 0000000..89d0500 --- /dev/null +++ b/docs/editable-pptx-reference-full-chain-plan.md @@ -0,0 +1,573 @@ +# 图片转可编辑 PPTX 全链路方案 + +本文基于本地代码重新梳理两个参考项目: + +- `/Users/lzj/proj/notebook/slide-alchemy` +- `/Users/lzj/proj/notebook/image-to-editable-ppt-skill` + +目标不是直接把某个 skill 塞进 AIPPT,而是提炼出可在 AIPPT 内部实现、可测试、可验收的图片转可编辑 PPTX 方案。 + +## 1. 结论 + +建议 AIPPT 采用 `image-to-editable-ppt-skill` 的工程骨架和验收契约,吸收 `slide-alchemy` 的轻量分层思路与 asset sheet 生成/切分策略。 + +具体判断: + +- `image-to-editable-ppt-skill` 更适合作为主参考:它有完整 CLI 状态机、页面级 manifest、OCR text hints、asset provenance、PPTX 结构校验、禁止整页截图叠文本等硬规则。 +- `slide-alchemy` 更适合作为 prompt/workflow 参考:它的 clean base、元素分类、PNG asset sheet、composition 顺序很清楚,但整体更依赖 agent 执行纪律,确定性状态机较弱。 +- AIPPT 不应依赖 Codex skill 运行时或 page worker 作为产品能力。应把参考项目里的确定性逻辑改造成 AIPPT 内部模块,把模型调用通过现有 `config.yaml` 的 `prompt_model/image_model/edit_model/ocr_model` 接入。 +- 当前没有 Qwen layered model 时,纯模型路径本质是:OCR + 视觉理解/结构分析 + 图像编辑生成 clean base/asset sheet + 本地切图 + PPTX 原生对象重建。 + +## 2. slide-alchemy 是如何做的 + +代码入口和关键文件: + +- `skill/slide-alchemy/SKILL.md` +- `skill/slide-alchemy/references/workflow.md` +- `skill/slide-alchemy/references/element-classification.md` +- `skill/slide-alchemy/references/icon-slicing-qa.md` +- `skill/slide-alchemy/references/text-extraction.md` +- `skill/slide-alchemy/references/compose-spec.md` +- `skill/slide-alchemy/scripts/compose_component_pptx.py` +- `skill/slide-alchemy/scripts/slice_asset_sheet.py` + +### 2.1 全链路 + +slide-alchemy 的固定顺序是: + +1. 渲染或取得每页源图。 +2. 做 base grouping,判断哪些页共享底图。 +3. 调用图像编辑/生成模型生成 clean base,去掉文字、图标、卡片、中心内容,保留背景主题。 +4. 用视觉模型分析非文本视觉元素,产出 `element_analysis.json`。 +5. 分类元素: + - `simple_geometry_svg_ooxml`:线条、矩形、圆角矩形、圆、星形、卡片底、标题条等结构几何。 + - `icon_png`:语义图标、设备、人物、建筑、badge 等。 + - `complex_png_whole`:复杂组合视觉、强渐变/发光/阴影装饰。 +6. 对 PNG 类元素调用图像编辑/生成模型生成 asset sheet,要求高对比纯色背景和大间距。 +7. 本地脚本对 asset sheet 做 chroma key/裁切,生成透明 PNG 资产和 contact sheet。 +8. 用视觉模型提取文本布局,产出 `texts_layout.json`。 +9. 用 `compose_component_pptx.py` 组合 PPTX,层级顺序固定为: + - clean base PNG + - editable geometry + - PNG assets + - editable text boxes +10. 导出 preview,做视觉 QA。 + +### 2.2 模型调用点 + +slide-alchemy 明确要求图像模型参与这些环节: + +- clean base generation/editing。 +- icon PNG asset sheet generation/editing。 +- complex PNG whole asset generation/editing。 +- 需要重生成的视觉资产。 + +文本提取默认依赖视觉模型。它没有把 OCR 作为强状态机入口,但 AIPPT 可以替换为 OCR 优先、视觉模型补漏。 + +### 2.3 PPTX 是如何组装的 + +`compose_component_pptx.py` 使用 `python-pptx`: + +- `base` 用 `slide.shapes.add_picture(...)` 铺满整页。 +- `geometry` 用 `add_shape(...)` 转为 PPT 原生形状,支持 `rect/round_rect/oval/star/line`。 +- `images` 用 `add_picture(...)` 按 bbox 放置透明 PNG。 +- `texts` 用 `add_textbox(...)` 写入 PPT 原生文本框。 + +因此 slide-alchemy 中的线框、卡片、分割线不是图像模型“生成成 PPT 线条”。实际过程是:模型/agent 识别出几何元素的位置、颜色和类型,确定性 composer 再创建 PPT 原生 shape。 + +## 3. image-to-editable-ppt-skill 是如何做的 + +代码入口和关键文件: + +- `skills/image-to-editable-ppt/SKILL.md` +- `skills/image-to-editable-ppt/references/page-decision-tree.md` +- `skills/image-to-editable-ppt/references/manifest-schema.md` +- `skills/image-to-editable-ppt/references/cli-helper.md` +- `skills/image-to-editable-ppt/cli/editppt/runtime/main.py` +- `skills/image-to-editable-ppt/cli/editppt/runtime/build_pptx_from_manifest.py` +- `skills/image-to-editable-ppt/cli/editppt/runtime/validate_pptx.py` +- `skills/image-to-editable-ppt/cli/editppt/runtime/image_gen.py` +- `skills/image-to-editable-ppt/cli/editppt/runtime/process_asset_sheet.py` +- `skills/image-to-editable-ppt/cli/editppt/runtime/split_alpha_components.py` +- `skills/image-to-editable-ppt/cli/editppt/runtime/paddle_text_hints.py` + +### 3.1 全链路 + +image-to-editable-ppt-skill 的固定流程是: + +1. `editppt prepare ` + - 把图片、PDF、图片型 PPTX 归一化成逐页 `pages/page_NNN/source.png`。 + - 写入 `deck_manifest.json`、`page_jobs.json`、`page_request.json`。 + - 生成 `text_hints.json/text_hints.png`。 +2. OCR/text hints + - 有 PaddleOCR token 时走 PaddleOCR-VL。 + - 没有 token 时退化为本地 ink geometry detector,但这只测位置和大小,不识别内容。 +3. `editppt run next` + - 单页走 local page reconstructor。 + - 多页分发 page worker。 +4. 每页按 `page-decision-tree.md` 重建: + - 先判断背景是否需要 image edit repair。 + - 再做 foreground asset separation。 + - 最后重建文本、形状、公式等 PPT native elements。 +5. 图像模型调用: + - `editppt image edit --image source.png` 做 clean base。 + - `editppt image edit --image source.png` 做 source-faithful asset sheet。 + - `editppt image generate` 只用于不需要严格保留源图对象的新支持图。 +6. asset sheet 处理: + - `process_asset_sheet.py` 先调用 chroma helper 去背景。 + - `split_alpha_components.py` 用 alpha 连通域拆分透明 PNG 资产。 + - 产出 split manifest/contact sheet。 +7. 页面 reconstructor 写 `manifest.json`。 +8. `editppt page build` 或 `run record` 用 manifest 构建 `page.pptx`、preview、validation。 +9. `editppt run finalize` 按页读取已 record 的 manifest,生成最终 `.pptx`。 + +### 3.2 manifest 是核心契约 + +`manifest-schema.md` 要求页面 manifest 至少包含: + +- `slide` +- `content_box` +- `source` +- `text_inventory` +- `visual_inventory` +- `background_strategy` +- `quality_checks` +- `text_boxes` +- `shapes` +- `images` +- `asset_provenance` + +所有对象坐标必须是源图像素坐标: + +- 文本和图片:`box_px: [x, y, width, height]` +- 线条:`points_px: [x1, y1, x2, y2]` + +这点对 AIPPT 很关键:模型只需要输出源图坐标,确定性 builder 负责映射到 PPT canvas/content box。 + +### 3.3 PPTX 是如何组装的 + +`build_pptx_from_manifest.py` 不主要依赖 `python-pptx`,而是直接写 OOXML zip 包: + +- `normalize_manifest()` 把源图像素坐标映射到 slide inch/EMU 坐标。 +- `text_box_xml()` 生成 PPT 原生 text box。 +- `shape_xml()` 生成 PPT 原生 shape,支持 line、rect、roundRect、ellipse、custom polygon 等。 +- `image_xml()` 生成图片对象。 +- `slide_xml()` 按 `z_index` 排序写入对象。 +- `write_deck()` 按页面 manifest 生成最终多页 PPTX。 + +这个 builder 比 slide-alchemy 的 `python-pptx` composer 更适合作为 AIPPT 的长期基础,因为它能控制: + +- roundRect radius。 +- measured text fitting。 +- z-index。 +- notes。 +- 直接结构校验。 +- 不依赖 PowerPoint/WPS 交互。 + +### 3.4 校验机制 + +`validate_pptx.py` 做了几类关键检查: + +- PPTX zip/package 是否有效。 +- manifest 是否缺坐标。 +- text/images/shapes 是否有可定位对象。 +- `asset_provenance.source_type` 是否在允许列表内。 +- 是否存在 full-slide `source.png` + editable text overlay 的假可编辑结果。 +- foreground visual object 是否违规使用 crop/fallback/approximation/emoji/native approximation。 +- `quality_checks` 是否完整。 +- roundRect 是否记录真实 source corner radius。 + +这是 AIPPT 当前必须补齐的部分。否则“能打开 PPTX”会误判为“转换成功”。 + +## 4. 两个方案的关键差异 + +| 维度 | slide-alchemy | image-to-editable-ppt-skill | AIPPT 建议 | +| --- | --- | --- | --- | +| 主体形态 | Codex skill + 线性流程 | Codex skill + editppt CLI 状态机 | AIPPT 内部服务模块 | +| 输入归一化 | 依赖 workflow 约定 | `editppt prepare` 强制生成 run/page 结构 | 实现 AIPPT job/page artifact | +| OCR | 视觉文本提取为主 | PaddleOCR-VL hints 优先 | OCR provider 优先,视觉模型补漏 | +| 图像分离 | clean base + asset sheet | clean base + source-faithful asset sheet | 采用 asset sheet,不允许 source crop fallback | +| PPTX 生成 | `python-pptx` composer | 直接 OOXML builder | 优先参考 OOXML builder | +| 校验 | preview/diff 为主 | manifest/provenance/package 结构 gate | 两者都要:结构 gate + preview/diff | +| 多页并发 | clean base 后可分发 subagent | page worker 状态机 | 后端 worker/async queue,不依赖 Codex subagent | + +## 5. AIPPT 落地全链路 + +### 5.1 输入与任务目录 + +新增或改造 AIPPT 的 editable PPTX job 目录: + +```text +run/ + deck_manifest.json + provider_logs.jsonl + pages/ + page_001/ + source.png + page_request.json + ocr_hints.json + ocr_hints_overlay.png + page_analysis.json + clean_base.png + asset_sheet_001.png + asset_sheet_001_alpha.png + split_assets.json + assets/ + manifest.json + page.pptx + preview.png + validation.json + final/ + editable.pptx + report.json +``` + +输入可以先只支持图片列表和现有图片版 PPTX 渲染后的逐页 PNG。后续再扩 PDF/PPTX 渲染。 + +### 5.2 Provider gate + +全链路前必须先跑 gate: + +1. config check:确认 `prompt_model/image_model/edit_model/ocr_model` 都有 `model/base_url/api_key`。 +2. OCR JSON contract:必须返回可解析结构化 OCR 结果。 +3. image edit clean-base probe。 +4. image edit asset-sheet probe。 +5. image generation probe:只作为可选支持,不作为 foreground separation 主路径。 + +如果 OCR 不是结构化 JSON,直接 fail fast。不能静默 fallback 到假 OCR 或本地临时 OCR。 + +### 5.3 OCR 与文本 hints + +AIPPT 应把 OCR 结果标准化成类似 image-to-editable-ppt-skill 的 hints: + +```json +{ + "backend": "configured-ocr-model", + "source": {"width_px": 1672, "height_px": 941}, + "lines": [ + { + "id": "T01", + "text": "核心架构设计:增程、底盘与智能域", + "box_px": [360, 70, 950, 70], + "font_pt": 32, + "font_pt_if_cjk": 32, + "size_group": "title" + } + ] +} +``` + +文本重建规则: + +- 主标题、正文、数字、标签默认生成 PPT 原生文本框。 +- OCR 漏字、错字必须进入 page failure 或 human review,不允许默默生成错字。 +- 同级文本统一字号,避免同一层级字体忽大忽小。 +- 字体名称不强求完全一致,优先保证文字内容、位置、字号、粗细、颜色接近;可默认 `PingFang SC` 或项目配置字体。 + +### 5.4 页面分析 + +调用 `prompt_model` 或视觉理解模型,输入: + +- `source.png` +- `ocr_hints.json` +- 可选 `ocr_hints_overlay.png` + +输出 `page_analysis.json`: + +- background strategy。 +- visual inventory。 +- foreground asset list。 +- native shape candidates。 +- text mapping:OCR line -> text box。 +- asset sheet prompt plan。 + +重点:模型只负责“识别、分类、规划、给坐标”,不直接生成 PPTX。 + +### 5.5 背景处理 + +根据 analysis: + +- 简单纯色/渐变/规则背景:用 PPT native background 或本地生成背景。 +- 复杂背景但无前景遮挡:可保留局部/全底图作为 clean base,但不能包含后续会重建的文字和前景对象。 +- 有遮挡/文字/图标污染的复杂背景:调用 `edit_model` 对 `source.png` 生成 clean base。 + +clean base prompt 必须约束: + +- 保持原图 composition、perspective、object positions、colors、lighting。 +- 只移除将被重建的文字、图标、标签、前景视觉元素。 +- 禁止新对象、新布局、伪文字、模糊补丁、水印。 + +### 5.6 前景图片资产分离 + +采用 image-to-editable-ppt-skill 的强规则: + +- 所有非文本 foreground visual objects 必须通过 image edit asset-sheet workflow 分离。 +- 不允许直接从 source.png 裁剪后当作资产。 +- 不允许把语义 icon 用 native shape 近似替代。 +- 不允许失败后降级成 warning 并继续交付。 + +流程: + +1. 根据 `visual_inventory` 生成 asset sheet prompt。 +2. 调用 `edit_model`: + +```text +Input: source.png +Task: separate exact existing foreground visual objects into a sparse chroma-key asset sheet. +Preserve shape, stroke, color, proportions, texture, shadow, internal spacing. +No text, no watermarks, no replacements, no simplified icons. +``` + +3. 本地去 chroma/alpha。 +4. 用连通域切分资产。 +5. 生成 contact sheet。 +6. asset count/name/order 与 `visual_inventory` reconciliation。 +7. 资产 provenance 标记为 `asset-sheet-separated`。 + +### 5.7 原生形状重建 + +简单几何转 PPT 原生对象: + +- 线条、虚线、分割线。 +- 矩形、圆角矩形、圆、椭圆。 +- 卡片、容器、表格线、普通箭头、流程框。 + +这些对象不需要图像模型重新生成。模型只输出: + +```json +{ + "type": "roundRect", + "box_px": [120, 200, 460, 160], + "fill": "#102A44", + "stroke": "#1677FF", + "stroke_width": 1.2, + "source_corner_radius_px": 12, + "z_index": 20 +} +``` + +然后由 AIPPT builder 生成 PPT 原生 `AUTO_SHAPE/LINE`。 + +### 5.8 页面 manifest + +AIPPT 的 page manifest 应收敛到 image-to-editable-ppt-skill 风格,但字段可按现有 `generative_editable_manifest.py` 适配: + +```json +{ + "slide": {"width": 13.333, "height": 7.5}, + "source": {"width_px": 1672, "height_px": 941, "path": "source.png"}, + "background_strategy": {}, + "text_inventory": [], + "visual_inventory": [], + "quality_checks": {}, + "images": [], + "shapes": [], + "text_boxes": [], + "asset_provenance": [] +} +``` + +硬性要求: + +- 所有 positioned object 必须有 `box_px` 或 `points_px`。 +- 所有 `images[]` 必须有 `asset_provenance`。 +- forbidden provenance:`source_crop`、`fallback`、`approximation`、`direct source crop`。 +- full-slide `source.png` + editable text overlay 必须 fail。 + +### 5.9 PPTX builder + +建议优先迁移/改造 image-to-editable-ppt-skill 的 OOXML builder 思路,而不是继续扩大 `python-pptx` composer: + +- 直接写 OOXML 能更精确控制 z-index、roundRect radius、text body、notes 和 package relationships。 +- WPS/PowerPoint 都能看到对象结构。 +- 可以通过解析 `ppt/slides/slideN.xml` 直接统计 `p:sp`、`p:pic`、`p:txBody`,判断是否真实可编辑。 + +组装层级: + +1. clean base/background。 +2. native shapes。 +3. separated bitmap assets。 +4. native text boxes。 +5. 必要时覆盖层,例如标注/手绘圈。 + +### 5.10 验收与重试 + +每页必须生成结构化 validation: + +- PPTX zip/package 可打开。 +- slide count 正确。 +- 每页不允许只有 1 张 full-slide picture。 +- 每页 text coverage 达标,OCR 失败必须明确记录。 +- simple geometry 尽量是 native shape。 +- foreground visual image 必须来自 asset-sheet-separated。 +- preview 与 source 无明显错位、漏块、重复文字、脏背景。 +- 所有 provider 错误脱敏记录。 + +重试策略: + +- OCR JSON 不合格:调整 OCR prompt/adapter 后重试,仍失败则 fail fast。 +- clean base 有残字/鬼影:重试 clean base。 +- asset sheet 数量不对/切坏/粘连:重试 asset sheet,换 chroma color 或拆小批次。 +- 文本错字:重新 OCR 或视觉模型校正,不能用猜测覆盖。 +- PPTX 结构异常:先修 builder/manifest validator,不能靠人工打开后判断。 + +## 6. 模型用量预估 + +单页典型调用: + +- OCR:1 次。 +- 页面分析/manifest draft:1 次。 +- clean base edit:0-1 次,复杂页通常 1 次。 +- foreground asset sheet edit:1-N 次,复杂页可能 2-4 次。 +- repair/retry:按 QA 结果触发。 + +所以成本确实会高于图片版 PPT 生成。合理预估: + +- 简单页:约 2-3 次模型调用。 +- 中等页:约 4-6 次模型调用。 +- 复杂汽车架构/信息图页:约 6+ 次模型调用,主要花在 clean base、asset sheet 和重试。 + +优化方向: + +- 多页共享 clean base。 +- 一个 asset sheet 承载多个前景对象。 +- OCR 批量化。 +- 只对复杂背景调用 clean base edit。 +- native geometry 不走图像模型。 + +## 7. AIPPT 开发拆分建议 + +### 阶段 1:结构 gate 先修好 + +先保证不能再出现“整张图拖动但报告 passed”的情况。 + +任务: + +- PPTX object inspector:解析 slide XML,统计 full-slide picture、non-full picture、native shape、text box。 +- manifest provenance validator:禁止 source crop/fallback。 +- preview validation report:报告对象统计和失败原因。 +- 测试:给一个只有整页图的 PPTX,必须失败。 + +### 阶段 2:OCR hints 接入 + +任务: + +- 用当前 `ocr_model` 生成结构化 text hints。 +- 标准化 OCR 输出。 +- 将 text hints 注入 page analysis prompt。 +- 测试:非 JSON OCR 响应必须失败;真实 OCR canary 必须产出 text boxes。 + +### 阶段 3:asset sheet 分离 + +任务: + +- asset sheet prompt builder。 +- image edit provider 调用。 +- chroma/alpha 去背景。 +- connected components 切分。 +- contact sheet。 +- asset provenance。 +- 测试:asset sheet 尺寸和源图 bbox 不一致时,仍按 sheet 连通域切图,不允许拿源图 bbox 去切 asset sheet。 + +### 阶段 4:manifest -> PPTX builder + +任务: + +- 将 native text/shape/image 映射成 PPTX。 +- 优先增强现有 composer,或迁移 OOXML builder。 +- 支持 roundRect radius、z-index、measured text fitting。 +- 测试:解析生成 PPTX,确认 shape/text/picture 数量和 manifest 一致。 + +### 阶段 5:真实 1/2/6 页验收 + +任务: + +- 先 slide 3 canary。 +- 再 2 页混合。 +- 最后 6 页 replay-assets。 +- 每轮输出 report、preview、diff、object stats、provider logs。 +- 根据实测迭代 prompt、切图、builder、validator。 + +## 8. 不能接受的降级 + +这些情况应直接 fail,不应标记 passed: + +- 最终页只有整页图片。 +- 整页 source 图片作为底图,上面叠 OCR 文本。 +- foreground asset 直接从 source.png 裁剪。 +- OCR 明显错字、漏主要标题或正文。 +- asset sheet 失败后回退 source crop。 +- 语义图标被 native shape 粗糙近似。 +- preview 有明显残字、重影、漏块、错位。 + +## 9. 推荐最终方案 + +最终方案: + +1. 主流程采用 image-to-editable-ppt-skill 的状态机思想、manifest schema、PPTX OOXML builder、validator/provenance gate。 +2. clean base、元素分类、asset sheet prompt 采用 slide-alchemy 的轻量工作流和分类规则。 +3. AIPPT 内部实现,不依赖 Codex skill、`editppt` 命令或 page worker;可以借鉴代码结构,但要改造成服务端模块和测试。 +4. OCR 使用 AIPPT 配置中的真实 `ocr_model`,不要保留 `.env` fallback。 +5. 图像资产分离在没有 Qwen layered model 时使用 image edit asset sheet;后续有 layered model 后,可以把 `foreground asset separation` 这一层替换为 layered decomposition,但 manifest、builder、validator 不需要推倒重来。 + +## 10. AIPPT 当前问题根因与修复结论 + +基于真实 `output/replay-assets/slide_1.png` 到 `slide_6.png` 回归,旧问题不是模型完全不可用,而是 AIPPT 自己的重建链路存在三个问题: + +1. OCR 结果被过度过滤。 + - page 5 的 OCR provider 实际返回过 22 项文本。 + - 旧过滤逻辑只保留 4 项,导致背景清掉文本后没有足够 text box 重建。 + - 修复后会保留尺寸和文本量足够可信的 approximate OCR,并记录 warning。 + - focused OCR recovery 还会把已经被大标题覆盖的局部候选恢复成重复文本,例如 `落地建议:`;当前已增加重复片段抑制,并把该 visual candidate 标记为 non-blocking。 + +2. source-preserving fast path 错误地清理所有粗糙 OCR 框。 + - 对 approximate OCR 框直接清底再重绘,会把视觉差异从约 9.9% 拉到 15.3%。 + - 当前修复为保真优先:复杂/粗糙 OCR 页使用源视觉背景,叠加低透明度原生 text box,并明确标记 `source_preserving_low_opacity_text_overlay` warning。 + - 该模式不能标记为普通 `passed`,runner 必须返回 `degraded`,避免把“整页源图 + 低透明文本层”误报为完整元素分离成功。 + - 这是降级策略,不等同于高质量元素分层;它解决“文字 OCR 没做/只有整图”的问题,但复杂视觉仍保留在背景图里。 + +3. runner 报告把 warning 当失败,且没有暴露 OCR 全过滤情况。 + - 修复后只有 `severity=error` 的 reconstruction issue 才使 run failed。 + - 当 OCR 返回内容但全部被过滤为幻觉/噪声时,报告 `no_editable_text_after_ocr_filtering` warning,避免 page 6 这种情况静默通过。 + +## 11. 真实回归结果 + +最近一次 6 页真实回归: + +```bash +python scripts/run_real_generative_editable_pptx.py run \ + --isolate-pages \ + --page-wall-timeout 300 \ + --input-glob 'output/replay-assets/slide_[0-9]*.png' \ + --slides 6 \ + --output-dir /private/tmp/aippt-real-replay6-lowopacity-timeout300-20260704-071603 \ + --job-id replay6-lowopacity-timeout300 \ + --provider-timeout 90 +``` + +按当前状态语义复算,结果应为 `status=degraded`:没有 error 级失败,但 page 2/3/5 使用低透明 OCR overlay,page 6 有 OCR 全过滤 warning。 + +每页摘要: + +| Page | PPTX objects | Preview changed ratio | 说明 | +| --- | --- | ---: | --- | +| 1 | `AUTO_SHAPE=15`, `LINE=11`, `PICTURE=1`, `TEXT_BOX=2` | `0.054813` | 原生形状/线条拆分有效 | +| 2 | `PICTURE=1`, `TEXT_BOX=10` | `0.041215` | 低透明 OCR overlay 降级 | +| 3 | `PICTURE=1`, `TEXT_BOX=11` | `0.047467` | 低透明 OCR overlay 降级 | +| 4 | `PICTURE=3`, `TEXT_BOX=9` | `0.059219` | 多图片对象 + 文本框 | +| 5 | `PICTURE=1`, `TEXT_BOX=22` | `0.079666` | 低透明 OCR overlay 降级,接近阈值 | +| 6 | `AUTO_SHAPE=21`, `LINE=6`, `PICTURE=1`, `TEXT_BOX=0` | `0.049679` | OCR 输出为幻觉/重复文本,报告 warning | + +注意: + +- `--page-wall-timeout 180` 下 page 4 会超时;实际 stage 显示 text clean 和 base clean 两次 image edit 已占约 143s,加上 OCR 约 30s 后剩余时间不足。 +- `--page-wall-timeout 300` 可以通过 6 页全量真实回归。 +- 当前效果已经不再是“每页只有整张图”。但 page 2/3/5 属于明确降级,不是完整视觉元素分离;下一步要继续迁移参考项目里的 asset sheet/page-decision-tree,减少 full-slide background 占比。 + +后续 page 5 局部修复: + +- 去掉 focused OCR 重复恢复后,page 5 为 `TEXT_BOX=11`。 +- 真实 canary:`/private/tmp/aippt-real-page5-dedupe-nonblocking-20260704-075418`。 +- 结果:`status=degraded`,preview changed ratio `0.075914`,无 `visual_text_candidate_missing_ocr_text`。 +- 进一步补齐同轮 focused OCR recovery 的去重:两个重叠 visual candidates 如果恢复出同一文本,只保留第一个,后者标为 non-blocking。 +- 修复非 isolated 多页 runner warning 聚合:`warning_pages` 优先使用 issue 自带页码,避免多页非 isolated 模式全部误报为 page 1。 diff --git a/docs/editable-pptx-reference-pipeline.md b/docs/editable-pptx-reference-pipeline.md new file mode 100644 index 0000000..3a1ea91 --- /dev/null +++ b/docs/editable-pptx-reference-pipeline.md @@ -0,0 +1,894 @@ +# slide-alchemy 与 image-to-editable-ppt-skill 转 PPT 全链路溯源 + +项目: + +- `slide-alchemy` +- `image-to-editable-ppt-skill` + +--- + +## 1. slide-alchemy 全链路 + +### 1.1 项目形态 + +`slide-alchemy` 是一个 Codex skill 风格项目,不是一个标准 Python package。它的核心在: + +- workflow 文档约束每一步必须做什么; +- prompt 模板约束模型如何生成 clean base 和 asset sheet; +- Python scripts 负责切图、校验、组合 PPTX。 + +关键文件: + +- `slide-alchemy/skill/slide-alchemy/SKILL.md` +- `slide-alchemy/skill/slide-alchemy/references/workflow.md` +- `slide-alchemy/skill/slide-alchemy/references/element-classification.md` +- `slide-alchemy/skill/slide-alchemy/references/base-prompt-template.md` +- `slide-alchemy/skill/slide-alchemy/references/icon-sheet-prompt-template.md` +- `slide-alchemy/skill/slide-alchemy/references/compose-spec.md` +- `slide-alchemy/skill/slide-alchemy/scripts/slice_asset_sheet.py` +- `slide-alchemy/skill/slide-alchemy/scripts/compose_component_pptx.py` + +### 1.2 总流程 + +`slide-alchemy` 明确要求完整流程,不允许直接把源图塞进 PPT: + +```text +输入 PPT/PDF/截图/图片 + -> 渲染为 source/slide_001.png + -> base grouping + -> 图像模型生成 clean base + -> element_analysis.json + -> 简单几何转 SVG/OOXML 或 PPT shape + -> 图像模型生成 PNG asset sheet + -> 切分 asset sheet 为透明 PNG + -> OCR/视觉模型提取文本布局 + -> compose_spec.json + -> compose_component_pptx.py 组合 PPTX + -> 预览图和视觉 QA +``` + +### 1.3 目录产物 + +它期望的 run 目录大致是: + +```text +run/ + source/ + slide_001.png + base/ + slide_001_base.png + analysis/ + element_analysis.json + texts_layout.json + compose_spec.json + assets/ + svg/ + png/ + contact_sheets/ + out/ + editable.pptx + preview/ +``` + +### 1.4 第一步:source page rendering + +输入可以是图片、PPTX、PDF、扫描页。无论原始格式是什么,先得到每页的源图: + +```text +source/slide_001.png +source/slide_002.png +... +``` + +后续所有坐标都以 source image 的像素坐标为准。 + +### 1.5 第二步:base grouping + +先判断哪些页面共享同一类背景,例如: + +- 封面 base; +- 内容页 base; +- 结束页 base; +- 每页独立 base。 + +这是为了减少 clean base 生成次数,也避免每页背景风格飘。 + +`workflow.md` 要求默认在这里停一次给用户确认,除非用户明确要求 unattended/full automatic。 + +### 1.6 第三步:clean base 生成 + +clean base 是用图像编辑/生成模型生成的,不是本地抹除,也不是源图裁剪。 + +参考模板在: + +`slide-alchemy/skill/slide-alchemy/references/base-prompt-template.md` + +要求模型: + +- 保留背景氛围、边缘装饰、渐变、纹理、光效; +- 移除所有文字、页码、标签、图标、徽章、卡片、框、标题栏、图表、中央内容; +- 自然补全被移除区域; +- 不留下 ghost text、模糊块、水印、伪文字。 + +输出: + +```text +base/slide_001_base.png +``` + +这个 clean base 会作为最终 PPT 的底层全页图片。 + +### 1.7 第四步:元素分析 + +在生成前景资产前,先做 `element_analysis.json`。 + +它包含: + +- `components`:可复用组件定义; +- `instances`:每页每个组件的位置; +- `png_asset_sheet_plan`:哪些组件需要生成 PNG asset sheet。 + +分类规则在: + +`slide-alchemy/skill/slide-alchemy/references/element-classification.md` + +核心分类: + +| 分类 | 含义 | 处理方式 | +|---|---|---| +| `simple_geometry_svg_ooxml` | 线、分割线、圆、圆角矩形、卡片、边框等非语义布局几何 | 转 PPT 原生形状/OOXML | +| `icon_png` | 图标、徽章、设备、小插画、语义 pictogram | 用图像模型生成 PNG asset sheet,再切图 | +| `complex_png_whole` | 复杂插画、发光组合、复杂装饰、拆开会变差的视觉块 | 整体作为 PNG 资产 | + +重点:语义图标即使看起来由简单线条组成,也默认不转 PPT shape,而是走 PNG。因为目标是视觉保真,不是拆到每根线可编辑。 + +### 1.8 第五步:PNG asset sheet 生成 + +需要 PNG 的组件会通过图像模型生成 asset sheet。 + +模板在: + +`slide-alchemy/skill/slide-alchemy/references/icon-sheet-prompt-template.md` + +要求模型: + +- 以源图为视觉参考或编辑目标; +- 重新生成要拆出的 icon/complex visual; +- 用纯色 key-color 背景; +- 每个元素之间留足空白; +- 不包含普通文字、标签、网格线、卡片、框、标题栏; +- 不直接复制裁剪源图像素。 + +典型输出: + +```text +assets/sheets/domain_sheet.png +assets/sheets/hardware_sheet.png +``` + +### 1.9 第六步:asset sheet 切分 + +切图脚本: + +`slide-alchemy/skill/slide-alchemy/scripts/slice_asset_sheet.py` + +它做的事情: + +1. 读取 sheet PNG; +2. 根据 key color 把背景转透明; +3. 根据 JSON crop spec 裁剪每个资产; +4. 默认保留 padding; +5. 输出透明 PNG。 + +脚本核心输入: + +```text +sheet_png +crops_json: [{ "id": "...", "bbox": [x, y, w, h] }] +output_dir +--key-color +--pad +``` + +输出示例: + +```text +assets/png/smart_icon.png +assets/png/display_screen.png +assets/png/speaker_cluster.png +``` + +然后用 contact sheet 和 edge inspection 检查: + +- 是否切掉边缘; +- 是否混入邻近元素; +- 是否有 key color 残留; +- 是否缺失元素; +- 是否把简单线框误切进 PNG。 + +### 1.10 第七步:文本提取 + +文本不放进 PNG,普通可读文本要变成 PPT 原生 text box。 + +`slide-alchemy` 的 text extraction 目标是生成: + +```text +analysis/texts_layout.json +``` + +里面应包含: + +- 文本内容; +- bbox; +- 字号; +- 颜色; +- 粗细; +- 对齐; +- 换行; +- 近似字体。 + +它强调“visual extraction by default”,也就是主要看页面视觉结果,而不是只信 PPT XML 或简单 OCR。 + +### 1.11 第八步:compose spec + +组合规范在: + +`slide-alchemy/skill/slide-alchemy/references/compose-spec.md` + +最小结构: + +```json +{ + "ref_width": 1920, + "ref_height": 1080, + "slide_width_in": 13.333333, + "slide_height_in": 7.5, + "slides": [ + { + "base": "base/slide_001_base.png", + "geometry": [], + "images": [], + "texts": [] + } + ] +} +``` + +其中: + +- `base` 是 clean base; +- `geometry` 是可编辑几何; +- `images` 是切出来的透明 PNG; +- `texts` 是可编辑文本框。 + +### 1.12 第九步:PPTX 组合 + +组合脚本: + +`slide-alchemy/skill/slide-alchemy/scripts/compose_component_pptx.py` + +它使用 `python-pptx`。 + +图层顺序固定: + +```text +1. base PNG +2. geometry +3. images +4. texts +``` + +支持的 geometry: + +- `rect` +- `round_rect` +- `oval` +- `star` +- `line` + +注意:它的 `line` 实现不是 PowerPoint connector,而是用很薄的 rectangle 模拟线。圆角矩形、椭圆、星形等是 PPT native auto shape。 + +### 1.13 第十步:预览与 QA + +最后导出 preview,并用脚本比较: + +- `slide-alchemy/skill/slide-alchemy/scripts/compare_preview.py` +- `slide-alchemy/skill/slide-alchemy/scripts/build_contact_sheet.py` +- `slide-alchemy/skill/slide-alchemy/scripts/inspect_edges.py` + +检查点: + +- clean base 是否过度保留了原文字/元素; +- asset 是否缺失/切边/污染; +- 文本是否溢出; +- 元素是否重复出现; +- 预览是否明显偏移; +- 是否还存在整页截图伪装成可编辑。 + +### 1.14 slide-alchemy 的本质 + +`slide-alchemy` 的本质是: + +```text +图像模型生成干净背景 ++ 图像模型生成前景资产 sheet ++ 程序切成透明 PNG ++ 程序把简单几何转 PPT shape ++ OCR/视觉提取文本后转 PPT text box ++ python-pptx 按 compose spec 叠起来 +``` + +它不是图像分层模型。它的前景元素不是从原图数学分离出来的,而是通过图像编辑/生成模型“重建/再生成”出来,再切图使用。 + +--- + +## 2. image-to-editable-ppt-skill 全链路 + +### 2.1 项目形态 + +`image-to-editable-ppt-skill` 也是 skill,但它比 `slide-alchemy` 多了一个完整 CLI runtime:`editppt`。 + +它不是简单脚本拼 PPT,而是有: + +- run directory; +- page state machine; +- worker prompt; +- page manifest; +- validation; +- finalize; +- PPTX package builder。 + +关键文件: + +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/SKILL.md` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/prompts/page-worker.md` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/cli-helper.md` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/page-decision-tree.md` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/manifest-schema.md` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/prepare_deck_run.py` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/deck_run_state.py` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/image_gen.py` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/process_asset_sheet.py` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/build_pptx_from_manifest.py` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/validate_pptx.py` + +### 2.2 总流程 + +完整流程: + +```text +editppt prepare + -> run dir + -> deck_manifest.json + -> page_jobs.json + -> notes_manifest.json + -> pages/page_001/source.png + -> pages/page_001/page_request.json + -> pages/page_001/text_hints.json + +editppt run next + -> rebuild_page_locally 或 dispatch_pages + +page reconstructor + -> 读 page-decision-tree / manifest-schema / cli-helper + -> 背景识别与 clean base + -> 前景 asset sheet 分离 + -> native text / shapes / formulas + -> manifest.json + -> editppt page build + -> editppt page contact-sheet + -> editppt page validate + -> validation.json + -> page_result.json + +editppt run record + -> 校验 page 输出并记录 + +editppt run finalize + -> 从 page manifest 重建最终 deck + -> deck validation +``` + +### 2.3 prepare 阶段 + +命令: + +```bash +editppt prepare input.png +editppt prepare input.pdf +editppt prepare input1.png input2.png +``` + +实现文件: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/prepare_deck_run.py` + +它会: + +1. 标准化输入; +2. 生成 run 目录; +3. 为每页生成 `source.png`; +4. 计算 slide size; +5. 计算 `content_box`; +6. 写 `page_request.json`; +7. 写 `deck_manifest.json`; +8. 写 `page_jobs.json`; +9. 写 `notes_manifest.json`; +10. 初始化每页 `imagegen-jobs.json`。 + +典型目录: + +```text +run/ + deck_manifest.json + page_jobs.json + run_state.json + notes_manifest.json + input/ + pages/ + page_001/ + source.png + page_request.json + text_hints.json + text_hints.png + imagegen-jobs.json +``` + +`page_request.json` 是页面 worker 的任务边界,包含: + +- page id; +- source image; +- source size; +- slide size; +- content box; +- allowed write scope; +- required outputs; +- image backend contract。 + +### 2.4 page state machine + +实现文件: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/deck_run_state.py` + +核心状态: + +- `pending` +- `dispatched` +- `recorded` +- `accepted` +- `complete` + +`page_jobs.json` 是状态源,不允许手写跳状态。 + +关键原则: + +- `dispatched` 是活跃租约,不能因为慢就 reset; +- `record` 只能记录通过校验的 page; +- `finalize` 只从 recorded page manifests 重建最终 deck; +- failed page 需要明确 reset 后重新跑。 + +### 2.5 page worker 决策流程 + +模板: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/prompts/page-worker.md` + +worker 必须先完整读: + +- `page-decision-tree.md` +- `manifest-schema.md` +- `cli-helper.md` + +然后按固定顺序: + +```text +1. page inventory +2. background recognition and repair +3. foreground asset separation +4. native text / shapes / tables / formulas +5. manifest.json +6. editppt page build +7. editppt page contact-sheet +8. editppt page validate +9. validation.json + page_result.json +``` + +### 2.6 背景处理 + +规则文件: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/page-decision-tree.md` + +背景分三类: + +1. 不需要图像工具的背景 + 例如纯色、简单渐变、普通卡片、线、表格框等,直接用 PPT shape/script 重建。 + +2. 可复用背景区域 + 必须不包含要重建的文字/图标/前景,否则会重复。 + +3. 需要图像工具修复的背景 + 如果复杂背景被文字/图标/前景遮挡,就用: + +```bash +editppt image edit --image pages/page_001/source.png \ + --prompt-file clean-base.prompt.txt \ + --out pages/page_001/assets/clean-base.png +``` + +clean base 要求保留: + +- composition; +- perspective; +- object positions; +- colors; +- lighting; +- textures; +- background identity。 + +移除: + +- readable text; +- labels; +- icons; +- stickers; +- badges; +- hand-drawn marks; +- decorative objects that will be rebuilt。 + +### 2.7 前景资产分离 + +`image-to-editable-ppt-skill` 在这点比 `slide-alchemy` 更强硬: + +所有非文本前景视觉对象都必须走 asset-sheet workflow。 + +包括: + +- foreground photos; +- screenshots; +- icons; +- pictograms; +- logo-like marks; +- badges; +- stickers; +- hand-drawn marks; +- complex arrows; +- devices; +- illustrations。 + +禁止: + +- 用 native shape 近似语义图标; +- 用 emoji 或文本符号替代; +- 直接裁剪 source.png; +- 失败后降级成 warning。 + +命令形态: + +```bash +editppt image edit \ + --image pages/page_001/source.png \ + --prompt-file asset-sheet.prompt.txt \ + --out pages/page_001/assets/asset-sheet.png +``` + +prompt 要求: + +- separate existing objects from source; +- preserve original shapes, strokes, colors, proportions, internal spacing, texture, visual identity; +- flat chroma-key background; +- object count and order match visual inventory; +- no readable text; +- no full cards/panels/charts/page fragments; +- no redraw/beautify/synonym replacement。 + +### 2.8 图像后端实现 + +实现文件: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/image_gen.py` + +它有两条后端路径: + +1. Codex OAuth 优先: + +```text +~/.codex/auth.json +chatgpt.com/backend-api/codex/images/generations +chatgpt.com/backend-api/codex/images/edits +``` + +2. OpenAI-compatible API fallback: + +```text +OPENAI_API_KEY +OPENAI_BASE_URL +/v1/images/generations +/v1/images/edits +``` + +也就是说,`image-to-editable-ppt-skill` 默认不是调用 `chat/completions` 生图,而是走 Codex image endpoint 或 OpenAI Images API endpoint。 + +### 2.9 asset sheet 处理 + +命令: + +```bash +editppt image process-sheet pages/page_001 \ + --job-id icon-sheet \ + --asset-sheet-source assets/asset-sheet.png \ + --assets-dir assets/foreground \ + --asset-names smart_icon,display_screen,speaker_cluster +``` + +实现文件: + +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/process_asset_sheet.py` +- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/_page_artifacts.py` + +处理链: + +```text +asset-sheet.png + -> remove_chroma_key.py + -> imagegen_asset_sheet_alpha.png + -> split_alpha_components.py + -> assets/foreground/*.png + -> split_assets.json +``` + +它支持: + +- 自动从边缘采样 key color; +- soft matte; +- despill; +- connected component splitting; +- min area; +- merge gap; +- square asset padding; +- asset names。 + +### 2.10 文本与 text hints + +`editppt prepare` 会生成: + +```text +text_hints.json +text_hints.png +``` + +`page-decision-tree.md` 要求: + +- 普通可读文本默认变成 native PPT text boxes; +- 不能用隐藏文字、透明文字、1pt 字来冒充; +- 不能把主标题、正文、表格、图例、数字等留在图片里; +- text box 使用 source-pixel `box_px`; +- 字号优先从 text hints 取; +- 同层级文本字号保持一致; +- 公式另走 LaTeX rendering,不用普通文本框硬拼。 + +### 2.11 manifest.json + +规则文件: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/manifest-schema.md` + +`manifest.json` 是页面构建的唯一权威来源。 + +必须包含: + +```json +{ + "slide": {}, + "content_box": {}, + "source": {}, + "text_inventory": [], + "visual_inventory": [], + "background_strategy": {}, + "quality_checks": {}, + "text_boxes": [], + "shapes": [], + "images": [], + "asset_provenance": [] +} +``` + +坐标规则: + +- `text_boxes[].box_px` +- `images[].box_px` +- 非 line 的 `shapes[].box_px` +- line 的 `shapes[].points_px` + +所有坐标都是 `source.png` 像素坐标,runtime 再映射到 `content_box`。 + +`asset_provenance` 对每个图片资产说明来源: + +- `asset-sheet-separated` +- `imagegen` +- `latex-rendered-formula` +- `user-provided` +- `user-approved-rasterization` + +前景视觉对象不允许出现 crop、fallback、approximation 等来源描述。 + +### 2.12 page build + +命令: + +```bash +editppt page build pages/page_001 +``` + +实现文件: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/build_pptx_from_manifest.py` + +它不是简单调用 `python-pptx`,而是直接写 PPTX zip 包里的 XML。 + +它做的事: + +- 将 `box_px` / `points_px` 映射到 slide inch/EMU; +- 写 `ppt/slides/slide1.xml`; +- 写 relationships; +- 写 media; +- 写 content types; +- 写 theme/layout/master; +- 写 notes; +- 支持 page-level PPTX 和 final deck。 + +支持对象: + +- images; +- text boxes; +- lines; +- rectangles; +- rounded rectangles; +- ellipses; +- polygons; +- SVG/image media; +- z-index layering。 + +图层排序: + +```text +shapes/images/text_boxes 根据 z_index 排序 +``` + +与 `slide-alchemy` 不同,它不是只靠 `python-pptx` API,而是更底层地控制 OOXML。 + +### 2.13 page validate + +命令: + +```bash +editppt page validate pages/page_001 +``` + +校验文件: + +`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/validate_pptx.py` + +它检查: + +- PPTX zip 是否正常; +- slide 数量; +- media 数量; +- relationship targets; +- required text 是否出现在 PPTX; +- manifest images 是否都进了 media; +- media hash 是否匹配; +- asset provenance 是否完整; +- positioned objects 是否有坐标; +- full-slide `source.png` + editable text 是否违规; +- foreground asset 是否用了 crop/fallback/approximation; +- quality checks 是否都为 true。 + +这是它比 `slide-alchemy` 更工程化的地方。 + +### 2.14 page_result 与 record + +page reconstructor 需要输出: + +```text +manifest.json +imagegen-jobs.json +page.pptx +preview.png +split_assets_contact.png +validation.json +page_result.json +``` + +`validation.json` 必须有顶层: + +```json +{ "passed": true } +``` + +然后 parent 跑: + +```bash +editppt run record --page page_001 --agent-id +``` + +record 会再次校验 page.pptx 和 manifest。失败就不能进入 final assembly。 + +### 2.15 finalize + +命令: + +```bash +editppt run finalize +``` + +finalize 不拿 page.pptx 拼接,而是重新读取每页 `manifest.json`,从 manifest 重新构建最终 deck。 + +最终输出: + +```text +final/_edited.pptx +``` + +final deck validation 会检查: + +- slide count; +- notes; +- media relationships; +- media hashes; +- page validations; +- full-slide source raster + editable text 违规模式。 + +### 2.16 image-to-editable-ppt-skill 的本质 + +它的本质是: + +```text +editppt prepare 建 run/page 状态 ++ page worker 按三阶段决策 ++ 图像后端生成 clean base ++ 图像后端生成 foreground asset sheet ++ runtime 切透明 PNG ++ OCR/text hints 生成 native text boxes ++ native shapes 重建结构几何 ++ manifest 作为唯一构建源 ++ build_pptx_from_manifest 写 OOXML PPTX ++ validate_pptx 严格拒绝伪可编辑结果 ++ finalize 从 manifest 重建最终 deck +``` + +它同样不是图像分层模型。前景资产仍然来自图像编辑/生成模型生成的 asset sheet,然后程序切分。它比 `slide-alchemy` 多的不是“更先进的视觉模型”,而是更完整的状态机、manifest schema、PPTX builder 和 validation contract。 + +--- + +## 3. 两者核心差异 + +| 项目 | 主要特点 | PPTX 构建方式 | 图像模型用途 | 强项 | 弱项 | +|---|---|---|---|---|---| +| `slide-alchemy` | 轻量 workflow + scripts | `python-pptx` 读 compose spec | clean base、asset sheet | 直观、prompt/workflow 清晰、容易理解 | 缺少强状态机和严格 deck validation | +| `image-to-editable-ppt-skill` | `editppt` runtime + state machine | 直接写 PPTX OOXML package | clean base、asset sheet、可选 generation | 状态、manifest、validation、finalize 很完整 | 依赖 skill/CLI 运行形态,默认图像后端优先 Codex OAuth | + +## 4. 两者共同点 + +它们共同的真实路线都是: + +```text +不是直接图像分层 +不是把原图裁剪成元素 +不是 OCRPDF 那种原图背景叠文字 + +而是: +图像编辑/生成模型生成 clean base ++ 图像编辑/生成模型生成前景 asset sheet ++ 程序切出透明 PNG ++ 程序生成 native text / native shapes ++ 按坐标叠成 PPTX +``` + +因此,页面里可编辑的粒度是: + +- 文本:PPT 原生 text box,可改字; +- 简单结构:PPT 原生 shape,可改线条、颜色、尺寸; +- 复杂视觉/图标/产品图:透明 PNG,可移动、缩放、裁剪、隐藏,但不能改内部线条; +- clean base:全页背景图,不可编辑内部元素。 diff --git a/docs/generative-editable-live-verification.md b/docs/generative-editable-live-verification.md new file mode 100644 index 0000000..23afd75 --- /dev/null +++ b/docs/generative-editable-live-verification.md @@ -0,0 +1,48 @@ +# Generative Editable PPTX Live Verification + +This file records non-secret live verification results for the generative editable PPTX export path. Do not include API keys, bearer tokens, request headers, or private provider payloads. + +## 2026-07-01 + +Environment: +- Local `config.yaml` contains OCR, image edit, and image generation provider settings. +- `scripts/smoke_generative_editable_pptx.py --config-check-only` reads all required roles and prints redacted diagnostics. +- `soffice`, `pdftoppm`, and local `tesseract` are available. + +Commands: + +```bash +/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --config-check-only +/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --provider-check image_edit --output-dir /private/tmp/aippt-provider-image-edit-live-check +/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --provider-check image_generation --output-dir /private/tmp/aippt-provider-image-generation-live-check +/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --slides one --output-dir /private/tmp/aippt-generative-editable-smoke-one +/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --slides multi --output-dir /private/tmp/aippt-generative-editable-smoke-multi +``` + +Results: +- Configuration check passed with redacted provider diagnostics. +- Minimal `image_edit` provider check passed and produced an output image. +- Minimal `image_generation` provider check passed after sending the fixture source slide as a visual reference. +- Strict one-slide smoke passed with `validation_status: "passed"` and wrote `/private/tmp/aippt-generative-editable-smoke-one/smoke-one-16-9.pptx`. +- Strict multi-slide smoke passed with `validation_status: "passed"` and wrote `/private/tmp/aippt-generative-editable-smoke-multi/smoke-multi-16-9.pptx`. +- The local verification used schema-valid local OCR diagnostics plus configured live image edit/generation roles; smoke output redacted provider URLs and API keys. +- During repeated live attempts, the image edit provider intermittently returned upstream `503/524`; retryable 5xx handling and local retry settings were required for a stable pass. +- The final multi-slide PPTX editability spot check found editable `TEXT_BOX` objects on all three pages, editable `AUTO_SHAPE` objects on pages 1 and 2, an editable native `LINE` on page 1, and bitmap `PICTURE` assets for complex visuals/backgrounds. +- Preview validation passed for the final one-slide and multi-slide smoke outputs. +- Repair/source-crop behavior was exercised by the complex visual page: provider-generated complex-visual assets that fail QA can be replaced with exact source crops, preserving output fidelity instead of silently accepting drifted assets. + +Hardening added from the failed live runs: +- Generated slide SSE payloads now carry AIPPT text metadata into the WebUI, so high-fidelity export can use known slide text instead of relying only on OCR content. +- The local `tesseract` OCR provider can be selected explicitly with `provider: "local_tesseract"` for development smoke tests. +- HTTP `429` and `5xx` provider errors are marked retryable, and pipeline provider calls honor `generative_editable_pptx.retries.provider_max_attempts` and `backoff_seconds`. +- Provider diagnostics and smoke config output keep API keys and base URLs redacted. +- Smoke diagnostics support direct OCR, image generation, and image edit provider checks before running the full pipeline. The image generation check now sends the fixture source slide as a visual reference, and the image edit check exercises the clean-base, asset-sheet, and repair edit roles. +- Source-native shape detection now supplements base-clean diff planning, so simple rectangle/rounded-rectangle/line pages can rebuild as native PPT shapes even when the edited base background drifts. +- Source-base diff residual filtering now drops page-edge noise, blank background residuals, and fill-only fragments inside detected source-native shapes before asset-sheet generation. +- Native-only flat-background pages use a deterministic local reconstruction background instead of a hallucinated model-cleaned background. +- Metadata-matching OCR text can use AIPPT text content even when local OCR reports low confidence, while obvious low-confidence pseudo-text from shapes is ignored. +- Complex whole-visual bitmap assets fall back to exact source crops when provider-generated transparent assets fail QA. +- Negative-slope native lines are validated by their endpoint bounding box because `python-pptx` exposes connector direction lossy through `left/top/width/height`. +- Base-clean background generation no longer reuses the text-only edit mask from text cleanup, avoiding contradictory full-background prompts with masked-only edit semantics. +- Low-confidence OCR pseudo-text is retained as a non-blocking warning instead of being silently swallowed; real short low-confidence text remains a blocking validation issue. +- Full-slide source-background safety validation now detects resized near-source backgrounds before allowing editable text over them. diff --git a/docs/generative-editable-pptx.md b/docs/generative-editable-pptx.md new file mode 100644 index 0000000..f67b56b --- /dev/null +++ b/docs/generative-editable-pptx.md @@ -0,0 +1,132 @@ +# Generative Editable PPTX Export + +Generative editable PPTX export rebuilds a generated slide image into a PowerPoint deck with editable text boxes, conservative native shapes, and positioned bitmap assets. It is separate from the existing raster PPTX export and from the image-layer editable PPTX OpenSpec backup plan. + +## When To Use It + +Use **High-fidelity editable PPTX** when a deck needs to be edited in PowerPoint after generation and quality is more important than model cost. The existing **PPTX** option remains a raster export: each generated slide image is placed as a full-slide picture. + +The generative editable path is quality-gated. The default fallback policy is `fail`, so AIPPT returns an error instead of silently giving the user a lower-fidelity PPTX when validation fails. + +## Request Contract + +`POST /api/export` + +```json +{ + "format": "generative_editable_pptx", + "aspect_ratio": "16:9", + "slide_order": ["slide-1", "slide-2"], + "editable_options": { + "fallback_policy": "fail" + }, + "slides": [ + { + "slide_id": "slide-1", + "image_base64": "...", + "text_metadata": [ + { + "text": "Quarterly Plan", + "role": "title", + "order": 1, + "style_hint": { + "font_size": 32, + "bold": true + } + } + ] + } + ] +} +``` + +Supported `fallback_policy` values: + +- `fail`: default. Return an error if validation fails. +- `text_editable_background`: explicitly allow a lower-fidelity PPTX that keeps editable text boxes over the generated text-clean background when full foreground reconstruction fails after artifacts are available. +- `raster_pptx`: explicitly allow falling back to the existing raster PPTX exporter. + +Fallback output is never returned unless the request explicitly permits it. When fallback is used, the response includes `X-Generative-Editable-Fallback-Policy` and `X-Generative-Editable-Fallback-Used` headers. + +## Configuration + +Provider settings live in `config.yaml`. `config.example.yaml` documents the expected shape with placeholder values. + +```yaml +api: + models: + prompt_model: + adapter: "openai_chat" + model: "..." + base_url: "..." + api_key: "..." + image_model: + adapter: "raw_chat_multimodal" + model: "..." + base_url: "..." + api_key: "..." + edit_model: + adapter: "raw_chat_multimodal" + model: "..." + base_url: "..." + api_key: "..." + ocr_model: + provider: "..." + model: "..." + base_url: "..." + api_key: "..." + # For local development smoke tests: + # provider: "local_tesseract" + # model: "eng" + # base_url: "" + # api_key: "" + +generative_editable_pptx: + reconstruction: + mode: "generative" + clean_base_model: "edit_model" + asset_sheet_model: "edit_model" + repair_model: "edit_model" + generation_model: "image_model" + ocr: + model: "ocr_model" + use_aippt_metadata_first: true + min_confidence: 0.75 + quality: + max_repair_attempts: 2 + preview_similarity_threshold: 0.92 + require_preview_validation: true +``` + +Do not commit real provider credentials. Before live verification, populate local `config.yaml` with OCR, image edit, and image generation provider settings, then run a one-slide smoke test before multi-slide verification. + +The current live adapter is an OpenAI-compatible chat-completions multimodal adapter (`openai_chat` or `raw_chat_multimodal`). It sends slide images as `image_url` message parts and expects the provider response to contain an image URL, data URL, or base64 image payload. It is not the official OpenAI Images API edit endpoint. If a provider requires multipart image-edit requests, add a provider-specific adapter behind the existing provider interfaces instead of changing the export contract; unsupported adapter names fail during dependency construction instead of silently using the wrong protocol. + +## Pipeline + +1. Create a job artifact directory and write source images. +2. Run OCR. When `use_aippt_metadata_first` is enabled, AIPPT text metadata is used as semantic text when available; OCR supplies layout, style, color, and fallback text. When disabled, OCR text is used directly. +3. Build text masks from accepted OCR/text boxes. +4. Create `text_clean_background` and `base_clean_background` assets. +5. Plan foreground candidates from source/base-clean differences. +6. Convert high-confidence simple geometry to native PowerPoint shapes. +7. Generate bitmap assets for complex foreground regions through the configured asset-sheet provider and run configured bounded repair for failed assets. Source crops are only available when the pipeline dependency explicitly enables that internal diagnostic fallback; request fallback policies do not silently enable source crops. +8. Compose PPTX using cleaned background, native shapes, bitmap assets, and editable text boxes. +9. Validate structure and preview similarity. Repair bounded issues when possible. +10. Return the validated deck, or an explicit fallback/error. + +## Quality And Cost Notes + +This path uses OCR plus image editing/image generation providers. It can require multiple provider calls per page: cleanup, asset sheet generation, repairs, and validation retries. That is expected; the design optimizes output quality first and leaves cost optimization to caching, component reuse, bounded concurrency, and explicit fallback policies. + +## Limitations + +- Bitmap assets are source-guided through provider calls and local slicing, but generated or repaired assets may not be pixel-identical to the original slide image. +- Native shape conversion is conservative; uncertain regions stay as bitmap assets instead of inaccurate PowerPoint shapes. +- Text editability depends on OCR quality and available AIPPT text metadata. +- Current strict live smoke is not passing with the local provider set recorded on 2026-07-01 because OCR and image-edit upstream calls fail provider validation; see `docs/generative-editable-live-verification.md`. +- Real provider smoke tests require local `config.yaml` settings and are not part of default CI. + +## Relation To Image-Layer Export + +The image-layer editable PPTX plan is a separate backup path for future model resources. Image-layer decomposition can produce movable/scalable/croppable visual layers, but it does not automatically produce native PowerPoint text boxes, editable line styles, or editable rounded rectangles. The generative editable export path composes a PPTX from OCR, native-shape fitting, provider-generated assets, and validation. diff --git a/openspec/changes/add-generative-editable-pptx-export/design.md b/openspec/changes/add-generative-editable-pptx-export/design.md index 5da907a..0ad4e86 100644 --- a/openspec/changes/add-generative-editable-pptx-export/design.md +++ b/openspec/changes/add-generative-editable-pptx-export/design.md @@ -64,9 +64,9 @@ Alternatives considered: - `api.models.ocr_model` - `api.models.image_model` - `api.models.edit_model` -- `editable_pptx.generative.reconstruction` -- `editable_pptx.generative.quality` -- `editable_pptx.generative.timeouts` +- `generative_editable_pptx.reconstruction` +- `generative_editable_pptx.quality` +- `generative_editable_pptx.timeouts` Real local values are read from `config.yaml`. Automated tests use fake providers and fixture images. Live provider verification is an explicit manual gate in the implementation tasks. @@ -131,7 +131,7 @@ Alternatives considered: ### Decision 9: Validation gates results and fallback must be explicit -The default fallback policy is `fail`. Validation checks page count, dimensions, required text, unsafe baked-text overlap, asset existence, object order, native text/shape structure, and preview similarity. If validation fails after bounded repairs, the API returns an error unless the request explicitly permits `text_editable_background` or `raster_pptx` fallback. +The default fallback policy is `fail`. Validation checks page count, dimensions, required text, unsafe baked-text overlap, asset existence, object order, native text/shape structure, and preview similarity. If validation fails after bounded repairs, the API returns an error unless the request explicitly permits `text_editable_background` or `raster_pptx` fallback. When an explicit fallback is permitted and the fallback output can be generated, the API returns that fallback and records fallback-used metadata. If the fallback output itself cannot be generated, the API fails with the original failure and fallback failure details. Alternatives considered: @@ -212,7 +212,7 @@ All implementation tasks follow TDD: - [Background editing removes desired visuals] -> Keep original, text-clean, and base-clean assets separately; validate preview similarity; allow text-editable fallback only when explicitly requested. - [Cost is higher than image-layer decomposition] -> Prioritize quality by default, then optimize through component reuse, page-level retries, caching, and optional fallback policies. - [Provider availability blocks development] -> Keep all providers behind interfaces and use fake providers in automated tests. -- [Large decks are slow] -> Process pages in bounded workers, write intermediate artifacts to disk, and expose progress stages. +- [Large decks are slow] -> Process pages in bounded workers, write intermediate artifacts to disk, keep the first version as a synchronous export with an indeterminate frontend loading state, and reserve stage-based progress for a later job/status API if needed. ## Migration Plan @@ -224,9 +224,11 @@ All implementation tasks follow TDD: 6. Request local provider configuration before live OCR/image edit/image generation verification. 7. Keep rollback simple: hide or disable the generative editable export option while leaving existing PDF and raster PPTX exports unchanged. -## Open Questions +## Live Verification Status -- Which OCR provider should be the first live target for local verification? -- Which image edit/generation provider should be the first live target for background and asset-sheet generation? -- What initial preview similarity thresholds are strict enough to protect quality without rejecting useful outputs too often? -- Should live provider verification be limited to one-slide fixtures first, or include a small multi-slide deck before the feature is exposed in the UI? +- Local verification targets are configured through project `config.yaml`; automated tests continue to use fake providers. +- The initial strict live gate is one-slide first, followed by a small multi-slide deck only after the one-slide run passes. +- The current configured OCR provider did not return schema-valid OCR JSON during strict one-slide smoke verification. +- The current image generation provider returned an upstream `524` during direct provider smoke verification. +- A diagnostic one-slide run with local OCR advanced into asset reconstruction, but the generated asset sheet failed strict asset QA with `edge_touch` and visible source drift. +- The initial preview similarity threshold remains configurable through `generative_editable_pptx.quality.preview_similarity_threshold` and is recorded in the deck manifest for each run. diff --git a/openspec/changes/add-generative-editable-pptx-export/proposal.md b/openspec/changes/add-generative-editable-pptx-export/proposal.md index 0655708..113475b 100644 --- a/openspec/changes/add-generative-editable-pptx-export/proposal.md +++ b/openspec/changes/add-generative-editable-pptx-export/proposal.md @@ -34,5 +34,5 @@ This change intentionally does not modify the existing `add-editable-pptx-export - New backend modules for editable export configuration, job artifacts, manifests, OCR text extraction, background generation/cleanup, visual planning, asset-sheet generation, asset slicing, shape fitting, PPTX composition, preview rendering, validation, and orchestration. - Optional runtime integrations for OCR, image editing, and image generation providers, all behind testable provider interfaces. - `config.example.yaml` schema updates and local `config.yaml` loading for provider configuration. -- Frontend export controls, progress/error display, and non-destructive failure handling for the new export option. +- Frontend export controls, synchronous loading/error display, and non-destructive failure handling for the new export option. - Test suite expansion with TDD requirements for every implementation task, including fake providers for CI and manual live-provider verification gates. diff --git a/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md b/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md index fb840ca..603f5c4 100644 --- a/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md +++ b/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md @@ -187,26 +187,34 @@ The system SHALL only use lower-fidelity fallback behavior when the request expl #### Scenario: Text-editable fallback policy - **WHEN** fallback policy is `text_editable_background` and full asset reconstruction fails -- **THEN** the system MAY return a PPTX using the text-clean background plus editable text boxes and SHALL record that lower-fidelity fallback was used +- **THEN** the system SHALL return a PPTX using the text-clean background plus editable text boxes when that fallback can be generated, and SHALL record that lower-fidelity fallback was used + +#### Scenario: Text-editable fallback fails +- **WHEN** fallback policy is `text_editable_background` and the text-clean background fallback cannot be generated +- **THEN** the system SHALL fail with the original reconstruction failure and the fallback failure reason #### Scenario: Raster fallback policy - **WHEN** fallback policy is `raster_pptx` and generative editable reconstruction fails -- **THEN** the system MAY return the existing raster PPTX output and SHALL record that raster fallback was used +- **THEN** the system SHALL return the existing raster PPTX output when that fallback can be generated, and SHALL record that raster fallback was used + +#### Scenario: Raster fallback fails +- **WHEN** fallback policy is `raster_pptx` and the raster PPTX fallback cannot be generated +- **THEN** the system SHALL fail with the original reconstruction failure and the fallback failure reason #### Scenario: Invalid fallback policy - **WHEN** a request sets fallback policy to any unsupported value - **THEN** the system SHALL reject the request with a clear validation error ### Requirement: Frontend export experience -The system SHALL expose generative editable PPTX export from the frontend with distinct labeling, progress, and errors. +The system SHALL expose generative editable PPTX export from the frontend with distinct labeling, synchronous loading state, and errors. #### Scenario: User selects generative editable PPTX - **WHEN** slides are exportable and the user chooses the high-fidelity editable PPTX export option - **THEN** the frontend SHALL send the generative editable export format, current slide images, slide order, aspect ratio, available text metadata, and selected fallback policy to the backend -#### Scenario: Generative export reports progress +#### Scenario: Generative export shows loading state - **WHEN** the generative editable export is running -- **THEN** the frontend SHALL display progress and current stage without blocking existing slide viewing or project state +- **THEN** the frontend SHALL display an indeterminate loading state without claiming backend stage progress and without blocking existing slide viewing or project state #### Scenario: Generative export fails validation - **WHEN** the backend returns a validation, configuration, provider, or repair-limit error diff --git a/openspec/changes/add-generative-editable-pptx-export/tasks.md b/openspec/changes/add-generative-editable-pptx-export/tasks.md index 090ca3e..1d385c2 100644 --- a/openspec/changes/add-generative-editable-pptx-export/tasks.md +++ b/openspec/changes/add-generative-editable-pptx-export/tasks.md @@ -1,87 +1,87 @@ ## 1. Export Contract And Configuration -- [ ] 1.1 Add failing backend tests for `generative_editable_pptx` request validation, unsupported format rejection, and unchanged `pdf`/`pptx` behavior; implement the API contract changes; pass `python -m pytest tests/test_export_pptx_ratio.py tests/test_generative_editable_export_contract.py`. -- [ ] 1.2 Add failing tests for optional slide order, aspect ratio, fallback policy, and text metadata serialization in export requests; implement backward-compatible request models; pass `python -m pytest tests/test_generative_editable_export_contract.py`. -- [ ] 1.3 Add failing tests that `config.example.yaml` exposes placeholder OCR, image edit, image generation, reconstruction, quality, timeout, and retry sections; update the example config; pass `python -m pytest tests/test_generative_editable_config.py`. -- [ ] 1.4 Add failing tests that live provider settings are read from local `config.yaml` and missing settings produce redacted configuration errors; implement config loading and redaction; pass `python -m pytest tests/test_generative_editable_config.py`. -- [ ] 1.5 Add failing tests that automated test mode uses fake provider configuration without live credentials; implement fake-provider config fixtures; pass `python -m pytest tests/test_generative_editable_config.py`. +- [x] 1.1 Add failing backend tests for `generative_editable_pptx` request validation, unsupported format rejection, and unchanged `pdf`/`pptx` behavior; implement the API contract changes; pass `python -m pytest tests/test_export_pptx_ratio.py tests/test_generative_editable_export_contract.py`. +- [x] 1.2 Add failing tests for optional slide order, aspect ratio, fallback policy, and text metadata serialization in export requests; implement backward-compatible request models; pass `python -m pytest tests/test_generative_editable_export_contract.py`. +- [x] 1.3 Add failing tests that `config.example.yaml` exposes placeholder OCR, image edit, image generation, reconstruction, quality, timeout, and retry sections; update the example config; pass `python -m pytest tests/test_generative_editable_config.py`. +- [x] 1.4 Add failing tests that live provider settings are read from local `config.yaml` and missing settings produce redacted configuration errors; implement config loading and redaction; pass `python -m pytest tests/test_generative_editable_config.py`. +- [x] 1.5 Add failing tests that automated test mode uses fake provider configuration without live credentials; implement fake-provider config fixtures; pass `python -m pytest tests/test_generative_editable_config.py`. ## 2. Provider Interfaces And Test Doubles -- [ ] 2.1 Add failing contract tests for `OCRProvider` outputs including text, bbox/polygon, confidence, font hints, color, alignment, and provenance; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. -- [ ] 2.2 Add failing contract tests for `ImageEditProvider` requests including source image, prompt identifier, optional mask, timeout, and output asset path; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. -- [ ] 2.3 Add failing contract tests for `ImageGenerationProvider` requests including prompt identifier, visual reference metadata, timeout, and output asset path; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. -- [ ] 2.4 Add failing tests for provider timeout, provider error, retryable error, non-retryable error, and secret-safe error messages; implement provider error types and handling; pass `python -m pytest tests/test_generative_editable_providers.py`. -- [ ] 2.5 Add failing tests for fixture image assets used by fake providers, including deterministic output hashes or dimensions; add fixtures; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [x] 2.1 Add failing contract tests for `OCRProvider` outputs including text, bbox/polygon, confidence, font hints, color, alignment, and provenance; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [x] 2.2 Add failing contract tests for `ImageEditProvider` requests including source image, prompt identifier, optional mask, timeout, and output asset path; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [x] 2.3 Add failing contract tests for `ImageGenerationProvider` requests including prompt identifier, visual reference metadata, timeout, and output asset path; implement the interface and fake provider; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [x] 2.4 Add failing tests for provider timeout, provider error, retryable error, non-retryable error, and secret-safe error messages; implement provider error types and handling; pass `python -m pytest tests/test_generative_editable_providers.py`. +- [x] 2.5 Add failing tests for fixture image assets used by fake providers, including deterministic output hashes or dimensions; add fixtures; pass `python -m pytest tests/test_generative_editable_providers.py`. ## 3. Manifest Schema And Job Artifacts -- [ ] 3.1 Add failing tests for deck manifest fields, page manifest fields, source pixel coordinates, provider roles, fallback policy, provenance, repair attempts, and validation status; implement manifest models; pass `python -m pytest tests/test_generative_editable_manifest.py`. -- [ ] 3.2 Add failing tests for deterministic job directory creation, per-page asset paths, page ordering, and cleanup-safe temp handling; implement job artifact writer; pass `python -m pytest tests/test_generative_editable_job_artifacts.py`. -- [ ] 3.3 Add failing tests that manifests can round-trip through JSON without losing coordinates, provider metadata, asset references, or validation status; implement serialization; pass `python -m pytest tests/test_generative_editable_manifest.py tests/test_generative_editable_job_artifacts.py`. -- [ ] 3.4 Add failing tests that a manifest can be rebuilt without re-running OCR, image edit, image generation, or repair providers; implement provider-output persistence; pass `python -m pytest tests/test_generative_editable_manifest.py tests/test_generative_editable_job_artifacts.py`. +- [x] 3.1 Add failing tests for deck manifest fields, page manifest fields, source pixel coordinates, provider roles, fallback policy, provenance, repair attempts, and validation status; implement manifest models; pass `python -m pytest tests/test_generative_editable_manifest.py`. +- [x] 3.2 Add failing tests for deterministic job directory creation, per-page asset paths, page ordering, and cleanup-safe temp handling; implement job artifact writer; pass `python -m pytest tests/test_generative_editable_job_artifacts.py`. +- [x] 3.3 Add failing tests that manifests can round-trip through JSON without losing coordinates, provider metadata, asset references, or validation status; implement serialization; pass `python -m pytest tests/test_generative_editable_manifest.py tests/test_generative_editable_job_artifacts.py`. +- [x] 3.4 Add failing tests that a manifest can be rebuilt without re-running OCR, image edit, image generation, or repair providers; implement provider-output persistence; pass `python -m pytest tests/test_generative_editable_manifest.py tests/test_generative_editable_job_artifacts.py`. ## 4. Text Extraction And Text Masks -- [ ] 4.1 Add failing tests for metadata-first text extraction where AIPPT text metadata supplies content and OCR supplies layout/style hints; implement text matching; pass `python -m pytest tests/test_generative_editable_text.py`. -- [ ] 4.2 Add failing tests for OCR-only fallback, confidence recording, font-size hints, CJK-safe default font selection, color mapping, and alignment mapping; implement OCR fallback extraction; pass `python -m pytest tests/test_generative_editable_text.py`. -- [ ] 4.3 Add failing tests for required text validation when metadata text cannot be matched or OCR confidence is below threshold; implement text validation reports; pass `python -m pytest tests/test_generative_editable_text.py`. -- [ ] 4.4 Add failing tests for mask creation from text boxes and polygons, padding, clipping, and source-pixel coordinate handling; implement text mask generation; pass `python -m pytest tests/test_generative_editable_text_masks.py`. +- [x] 4.1 Add failing tests for metadata-first text extraction where AIPPT text metadata supplies content and OCR supplies layout/style hints; implement text matching; pass `python -m pytest tests/test_generative_editable_text.py`. +- [x] 4.2 Add failing tests for OCR-only fallback, confidence recording, font-size hints, CJK-safe default font selection, color mapping, and alignment mapping; implement OCR fallback extraction; pass `python -m pytest tests/test_generative_editable_text.py`. +- [x] 4.3 Add failing tests for required text validation when metadata text cannot be matched or OCR confidence is below threshold; implement text validation reports; pass `python -m pytest tests/test_generative_editable_text.py`. +- [x] 4.4 Add failing tests for mask creation from text boxes and polygons, padding, clipping, and source-pixel coordinate handling; implement text mask generation; pass `python -m pytest tests/test_generative_editable_text_masks.py`. ## 5. Background Generation And Cleanup -- [ ] 5.1 Add failing tests for local fill and local inpaint selection on flat or locally repairable text mask regions; implement local cleanup strategy; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. -- [ ] 5.2 Add failing tests for `text_clean_background` generation using local cleanup first and image edit only when configured/needed; implement text-clean background creation; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. -- [ ] 5.3 Add failing tests for `base_clean_background` generation through image edit provider with prompt identifiers and source image references recorded; implement base-clean background creation; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. -- [ ] 5.4 Add failing tests that background outputs preserve original assets, write separate cleaned assets, and record strategy/provenance/validation status in the page manifest; implement background manifest updates; pass `python -m pytest tests/test_generative_editable_backgrounds.py tests/test_generative_editable_manifest.py`. +- [x] 5.1 Add failing tests for local fill and local inpaint selection on flat or locally repairable text mask regions; implement local cleanup strategy; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. +- [x] 5.2 Add failing tests for `text_clean_background` generation using local cleanup first and image edit only when configured/needed; implement text-clean background creation; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. +- [x] 5.3 Add failing tests for `base_clean_background` generation through image edit provider with prompt identifiers and source image references recorded; implement base-clean background creation; pass `python -m pytest tests/test_generative_editable_backgrounds.py`. +- [x] 5.4 Add failing tests that background outputs preserve original assets, write separate cleaned assets, and record strategy/provenance/validation status in the page manifest; implement background manifest updates; pass `python -m pytest tests/test_generative_editable_backgrounds.py tests/test_generative_editable_manifest.py`. ## 6. Foreground Planning And Native Shape Fitting -- [ ] 6.1 Add failing tests for foreground candidate detection from source vs base-clean differences while excluding accepted text masks; implement candidate planner; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. -- [ ] 6.2 Add failing tests for candidate classification into native-shape candidate, bitmap asset candidate, complex whole visual, duplicate, rejected text-like region, and uncertain region; implement classification; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. -- [ ] 6.3 Add failing tests for repeated component detection and manifest reuse records; implement component reuse planning; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. -- [ ] 6.4 Add failing tests for high-confidence rectangle, rounded rectangle, ellipse if enabled, and line conversion into native shape specs; implement conservative shape fitting; pass `python -m pytest tests/test_generative_editable_shape_fitter.py`. -- [ ] 6.5 Add failing tests that low-confidence or complex candidates remain bitmap asset candidates rather than inaccurate native shapes; implement thresholds and fallback classification; pass `python -m pytest tests/test_generative_editable_shape_fitter.py`. +- [x] 6.1 Add failing tests for foreground candidate detection from source vs base-clean differences while excluding accepted text masks; implement candidate planner; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. +- [x] 6.2 Add failing tests for candidate classification into native-shape candidate, bitmap asset candidate, complex whole visual, duplicate, rejected text-like region, and uncertain region; implement classification; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. +- [x] 6.3 Add failing tests for repeated component detection and manifest reuse records; implement component reuse planning; pass `python -m pytest tests/test_generative_editable_foreground_planner.py`. +- [x] 6.4 Add failing tests for high-confidence rectangle, rounded rectangle, ellipse if enabled, and line conversion into native shape specs; implement conservative shape fitting; pass `python -m pytest tests/test_generative_editable_shape_fitter.py`. +- [x] 6.5 Add failing tests that low-confidence or complex candidates remain bitmap asset candidates rather than inaccurate native shapes; implement thresholds and fallback classification; pass `python -m pytest tests/test_generative_editable_shape_fitter.py`. ## 7. Asset Sheet Generation, Slicing, And Repair -- [ ] 7.1 Add failing tests for asset-sheet prompt request creation from accepted bitmap candidates, including source reference, candidate boxes, chroma-key/transparent mode, and provider role; implement asset-sheet request builder; pass `python -m pytest tests/test_generative_editable_assets.py`. -- [ ] 7.2 Add failing tests for asset-sheet slicing, chroma-key removal, alpha preservation, padding preservation, candidate-to-asset mapping, and asset path recording; implement slicer; pass `python -m pytest tests/test_generative_editable_assets.py`. -- [ ] 7.3 Add failing tests for edge-touch, neighboring-object contamination, baked-text contamination, missing-object, and empty-asset rejection; implement asset QA checks; pass `python -m pytest tests/test_generative_editable_assets.py`. -- [ ] 7.4 Add failing tests for bounded repair attempts on failed assets, including page-level retry, single-asset retry, repair-limit failure, and manifest repair history; implement repair orchestration; pass `python -m pytest tests/test_generative_editable_assets.py tests/test_generative_editable_pipeline.py`. +- [x] 7.1 Add failing tests for asset-sheet prompt request creation from accepted bitmap candidates, including source reference, candidate boxes, chroma-key/transparent mode, and provider role; implement asset-sheet request builder; pass `python -m pytest tests/test_generative_editable_assets.py`. +- [x] 7.2 Add failing tests for asset-sheet slicing, chroma-key removal, alpha preservation, padding preservation, candidate-to-asset mapping, and asset path recording; implement slicer; pass `python -m pytest tests/test_generative_editable_assets.py`. +- [x] 7.3 Add failing tests for edge-touch, neighboring-object contamination, baked-text contamination, missing-object, and empty-asset rejection; implement asset QA checks; pass `python -m pytest tests/test_generative_editable_assets.py`. +- [x] 7.4 Add failing tests for bounded repair attempts on failed assets, including page-level retry, single-asset retry, repair-limit failure, and manifest repair history; implement repair orchestration; pass `python -m pytest tests/test_generative_editable_assets.py tests/test_generative_editable_pipeline.py`. ## 8. PPTX Composer And Preview Renderer -- [ ] 8.1 Add failing tests for source-pixel to slide-coordinate conversion for `16:9` and `4:3`; implement composer coordinate normalization; pass `python -m pytest tests/test_generative_editable_composer.py`. -- [ ] 8.2 Add failing tests that pages compose in required z-order: chosen cleaned background, native shapes, bitmap assets, editable text boxes; implement manifest-driven composition; pass `python -m pytest tests/test_generative_editable_composer.py`. -- [ ] 8.3 Add failing tests that accepted text boxes are editable PowerPoint text objects and accepted simple shapes are native PowerPoint shapes; implement PPTX object creation and package/XML inspection helpers; pass `python -m pytest tests/test_generative_editable_composer.py`. -- [ ] 8.4 Add failing tests for multi-page deck composition, page order, slide dimensions, image media references, and deterministic rebuild from saved manifests; implement deck composer; pass `python -m pytest tests/test_generative_editable_composer.py`. -- [ ] 8.5 Add failing tests for preview rendering or deterministic preview stubs from manifests using fake assets; implement preview renderer suitable for CI; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. +- [x] 8.1 Add failing tests for source-pixel to slide-coordinate conversion for `16:9` and `4:3`; implement composer coordinate normalization; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [x] 8.2 Add failing tests that pages compose in required z-order: chosen cleaned background, native shapes, bitmap assets, editable text boxes; implement manifest-driven composition; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [x] 8.3 Add failing tests that accepted text boxes are editable PowerPoint text objects and accepted simple shapes are native PowerPoint shapes; implement PPTX object creation and package/XML inspection helpers; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [x] 8.4 Add failing tests for multi-page deck composition, page order, slide dimensions, image media references, and deterministic rebuild from saved manifests; implement deck composer; pass `python -m pytest tests/test_generative_editable_composer.py`. +- [x] 8.5 Add failing tests for preview rendering or deterministic preview stubs from manifests using fake assets; implement preview renderer suitable for CI; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. ## 9. Validation, Fallback, And Pipeline Orchestration -- [ ] 9.1 Add failing tests for structural validation including page count, dimensions, required text, missing assets, unsafe full-slide source plus text, object order, and validation report shape; implement validator; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. -- [ ] 9.2 Add failing tests for preview similarity threshold pass/fail behavior using deterministic fixture images; implement preview comparison and quality gates; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. -- [ ] 9.3 Add failing tests that default fallback policy is `fail`, validation failure returns a clear error, and no low-fidelity output is silently returned; implement default fallback handling; pass `python -m pytest tests/test_generative_editable_pipeline.py`. -- [ ] 9.4 Add failing tests for explicit `text_editable_background` fallback and explicit `raster_pptx` fallback with response metadata; implement fallback policies; pass `python -m pytest tests/test_generative_editable_pipeline.py tests/test_generative_editable_export_route.py`. -- [ ] 9.5 Add failing orchestration tests for a one-slide deck using fake OCR, fake image edit, fake image generation, fake planner, fake assets, composer, preview, and validator; implement pipeline orchestration; pass `python -m pytest tests/test_generative_editable_pipeline.py`. -- [ ] 9.6 Add failing orchestration tests for multi-slide ordering, bounded page concurrency, per-page manifest creation, repair aggregation, validation aggregation, and artifact cleanup; implement multi-page pipeline behavior; pass `python -m pytest tests/test_generative_editable_pipeline.py`. -- [ ] 9.7 Add failing API route tests that `format: "generative_editable_pptx"` returns a valid PPTX when fake providers pass validation; wire the export route to the pipeline; pass `python -m pytest tests/test_generative_editable_export_route.py`. -- [ ] 9.8 Add failing API route tests for missing provider config, provider timeout, provider failure, validation failure, repair-limit failure, and explicit fallback behavior; implement route error handling; pass `python -m pytest tests/test_generative_editable_export_route.py`. +- [x] 9.1 Add failing tests for structural validation including page count, dimensions, required text, missing assets, unsafe full-slide source plus text, object order, and validation report shape; implement validator; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. +- [x] 9.2 Add failing tests for preview similarity threshold pass/fail behavior using deterministic fixture images; implement preview comparison and quality gates; pass `python -m pytest tests/test_generative_editable_preview_validator.py`. +- [x] 9.3 Add failing tests that default fallback policy is `fail`, validation failure returns a clear error, and no low-fidelity output is silently returned; implement default fallback handling; pass `python -m pytest tests/test_generative_editable_pipeline.py`. +- [x] 9.4 Add failing tests for explicit `text_editable_background` fallback, explicit `raster_pptx` fallback, fallback-used response metadata, and fallback-output failure reasons; implement fallback policies; pass `python -m pytest tests/test_generative_editable_pipeline.py tests/test_generative_editable_export_route.py`. +- [x] 9.5 Add failing orchestration tests for a one-slide deck using fake OCR, fake image edit, fake image generation, fake planner, fake assets, composer, preview, and validator; implement pipeline orchestration; pass `python -m pytest tests/test_generative_editable_pipeline.py`. +- [x] 9.6 Add failing orchestration tests for multi-slide ordering, bounded page concurrency, per-page manifest creation, repair aggregation, validation aggregation, and artifact cleanup; implement multi-page pipeline behavior; pass `python -m pytest tests/test_generative_editable_pipeline.py`. +- [x] 9.7 Add failing API route tests that `format: "generative_editable_pptx"` returns a valid PPTX when fake providers pass validation; wire the export route to the pipeline; pass `python -m pytest tests/test_generative_editable_export_route.py`. +- [x] 9.8 Add failing API route tests for missing provider config, provider timeout, provider failure, validation failure, repair-limit failure, and explicit fallback behavior; implement route error handling; pass `python -m pytest tests/test_generative_editable_export_route.py`. ## 10. Frontend Experience -- [ ] 10.1 Add failing frontend tests that the export menu exposes a distinct high-fidelity editable PPTX option without renaming the existing PPTX option; implement UI labels and selection state; pass `cd web && npm run test -- Export`. -- [ ] 10.2 Add failing frontend service tests for `generative_editable_pptx` request body, slide order, aspect ratio, text metadata, fallback policy, filename, and MIME handling; implement service/type updates; pass `cd web && npm run test -- Export`. -- [ ] 10.3 Add failing frontend tests for progress stages, provider/configuration/validation errors, and non-destructive failure handling; implement progress and error UI; pass `cd web && npm run test -- Export AppProjectLifecycle`. -- [ ] 10.4 Run frontend lint and build after the UI changes; fix failures with TDD loops where behavior changes are needed; pass `cd web && npm run lint && npm run build`. +- [x] 10.1 Add failing frontend tests that the export menu exposes a distinct high-fidelity editable PPTX option without renaming the existing PPTX option; implement UI labels and selection state; pass `cd web && npm run test -- Export`. +- [x] 10.2 Add failing frontend service tests for `generative_editable_pptx` request body, slide order, aspect ratio, text metadata, fallback policy, filename, and MIME handling; implement service/type updates; pass `cd web && npm run test -- Export`. +- [x] 10.3 Add failing frontend tests for synchronous indeterminate loading state, no claimed backend stage progress, provider/configuration/validation errors, and non-destructive failure handling; implement loading and error UI; pass `cd web && npm run test -- Export AppProjectLifecycle`. +- [x] 10.4 Run frontend lint and build after the UI changes; fix failures with TDD loops where behavior changes are needed; pass `cd web && npm run lint && npm run build`. ## 11. Live Provider Verification And Documentation -- [ ] 11.1 Add a deterministic fixture deck/image set covering text, simple shapes, repeated bitmap assets, one complex visual, text-clean fallback, 16:9, and 4:3; verify it passes fake-provider backend tests; pass `python -m pytest tests/test_generative_editable_*`. -- [ ] 11.2 Before live provider verification, request local `config.yaml` OCR, image edit, and image generation provider values from the developer; run a one-slide live smoke test and record non-secret results in developer docs; pass the smoke test command added for this feature. -- [ ] 11.3 Run a small multi-slide live verification with real providers after the one-slide smoke test passes; verify preview validation, repair behavior, and output PPTX editability manually; record non-secret findings in developer docs. -- [ ] 11.4 Update user-facing and developer documentation for configuration schema, provider roles, quality-first behavior, fallback policies, known limitations, and the distinction from the image-layer OpenSpec change; verify docs do not include real credentials. -- [ ] 11.5 Run complete backend verification and fix regressions through TDD loops; pass `python -m pytest tests`. -- [ ] 11.6 Run complete frontend verification and fix regressions through TDD loops; pass `cd web && npm run lint && npm run test && npm run build`. -- [ ] 11.7 Validate the OpenSpec change; pass `openspec status --change add-generative-editable-pptx-export` and `openspec validate add-generative-editable-pptx-export --strict`. +- [x] 11.1 Add a deterministic fixture deck/image set covering text, simple shapes, repeated bitmap assets, one complex visual, text-clean fallback, 16:9, and 4:3; verify it passes fake-provider backend tests; pass `python -m pytest tests/test_generative_editable_*`. +- [x] 11.2 Before live provider verification, ask the developer to populate local `config.yaml` with OCR, image edit, and image generation provider settings; verify configuration presence with redacted diagnostics, run a one-slide live smoke test, and record non-secret results in developer docs; pass `python scripts/smoke_generative_editable_pptx.py --config-check-only` and `python scripts/smoke_generative_editable_pptx.py --slides one --output-dir /private/tmp/aippt-generative-editable-smoke-one`. +- [x] 11.3 Run a small multi-slide live verification with real providers after the one-slide smoke test passes; verify preview validation, repair behavior, and output PPTX editability manually; record non-secret findings in developer docs; pass `python scripts/smoke_generative_editable_pptx.py --slides multi --output-dir /private/tmp/aippt-generative-editable-smoke-multi`. +- [x] 11.4 Update user-facing and developer documentation for configuration schema, provider roles, quality-first behavior, fallback policies, known limitations, and the distinction from the image-layer OpenSpec change; verify docs do not include real credentials. +- [x] 11.5 Run complete backend verification and fix regressions through TDD loops; pass `python -m pytest tests`. +- [x] 11.6 Run complete frontend verification and fix regressions through TDD loops; pass `cd web && npm run lint && npm run test && npm run build`. +- [x] 11.7 Validate the OpenSpec change; pass `PATH="/Users/lzj/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH" /opt/homebrew/bin/npx --yes @fission-ai/openspec status --change add-generative-editable-pptx-export` and `PATH="/Users/lzj/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH" /opt/homebrew/bin/npx --yes @fission-ai/openspec validate add-generative-editable-pptx-export --strict`. diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py new file mode 100644 index 0000000..aac761d --- /dev/null +++ b/scripts/run_real_generative_editable_pptx.py @@ -0,0 +1,1749 @@ +#!/usr/bin/env python3 +"""Run real-image generative editable PPTX verification on arbitrary slide images.""" + +from __future__ import annotations + +import argparse +from collections import Counter +from dataclasses import replace +from datetime import datetime +from difflib import SequenceMatcher +import glob +import json +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as ET +import zipfile + +from PIL import Image, ImageChops, ImageDraw, ImageStat +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE_TYPE + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import api.routes.export as export_route # noqa: E402 +from src.generative_editable_config import load_generative_editable_config # noqa: E402 +from src.generative_editable_manifest import read_deck_manifest, read_page_manifest # noqa: E402 +from src.model_profiles import load_default_profiles # noqa: E402 +from src.generative_editable_pipeline import ( # noqa: E402 + GenerativeEditableFallbackError, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, + run_generative_editable_pipeline, + with_provider_retries, +) +from src.generative_editable_vlm_reconstruction import ( # noqa: E402 + FakeVLMPageAnalysisProvider, + OpenAIChatVLMPageAnalysisProvider, + VLMEditablePipelineDependencies, + run_vlm_editable_pptx_pipeline, + with_vlm_provider_retries, +) +from src.generative_editable_preview_validator import ( # noqa: E402 + quality_threshold_to_preview_gates, + render_powerpoint_preview_with_metadata, + validate_preview_similarity, +) +from src.generative_editable_providers import ( # noqa: E402 + FakeImageEditProvider, + ImageEditRequest, + ImageGenerationRequest, + ProviderConfig, + ProviderError, + ProviderTimeoutError, + safe_provider_error_message, +) + + +DEFAULT_REPLAY_GLOB = "output/replay-assets/slide_[0-9]*.png" +GATE_CHOICES = { + "config", + "vlm_analysis", + "ocr", + "image_generation", + "image_edit_clean", + "image_edit_asset", + "image_edit_repair", +} +SLIDE_POSITION_TOLERANCE_EMU = 2 +MAX_TIMEOUT_AUGMENT_PPTX_BYTES = 64 * 1024 * 1024 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + gates_parser = subparsers.add_parser("gates", help="run provider readiness gates") + _add_common_args(gates_parser) + gates_parser.add_argument( + "--provider-gate", + action="append", + choices=sorted(GATE_CHOICES | {"all"}), + default=None, + help="provider gate to run; repeatable; defaults to all gates", + ) + gates_parser.add_argument( + "--provider-max-attempts", + type=int, + default=0, + help="override provider retry attempts for gates; 0 keeps single-attempt gate behavior", + ) + gates_parser.add_argument( + "--provider-retry-backoff", + type=float, + default=-1.0, + help="override provider retry backoff seconds for gates; negative keeps zero backoff", + ) + + run_parser = subparsers.add_parser("run", help="run the editable PPTX pipeline") + _add_common_args(run_parser) + run_parser.add_argument( + "--mode", + choices=["legacy", "vlm_first"], + default="legacy", + help="pipeline mode; legacy uses OCR/CV, vlm_first uses VLM structured analysis", + ) + run_parser.add_argument("--job-id", default="") + run_parser.add_argument( + "--provider-max-attempts", + type=int, + default=0, + help="override configured provider retry attempts for run mode; 0 keeps config", + ) + run_parser.add_argument( + "--provider-retry-backoff", + type=float, + default=-1.0, + help="override configured provider retry backoff seconds for run mode; negative keeps config", + ) + run_parser.add_argument( + "--pipeline-page-timeout", + type=float, + default=0.0, + help="override configured per-page pipeline timeout seconds for VLM-first run mode; 0 keeps config", + ) + run_parser.add_argument( + "--fallback-policy", + choices=["fail", "text_editable_background", "raster_pptx"], + default="fail", + ) + run_parser.add_argument( + "--isolate-pages", + action="store_true", + help="run each input slide in a child process and aggregate page reports", + ) + run_parser.add_argument( + "--page-wall-timeout", + type=int, + default=0, + help="wall-clock timeout in seconds for each isolated page child process", + ) + + args = parser.parse_args(argv) + output_dir = Path(args.output_dir or _default_output_dir()).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + if args.command == "run" and not args.job_id: + args.job_id = _generated_job_id() + try: + if args.command == "gates": + result = _run_gates(args, output_dir) + elif getattr(args, "isolate_pages", False): + result = _run_isolated_pages(args, output_dir) + else: + result = _run_pipeline(args, output_dir) + except Exception as exc: + if args.command == "run" and not getattr(args, "isolate_pages", False): + job_id = args.job_id or "" + try: + input_images = _resolve_input_images(args) + except Exception: + input_images = [] + result = _failed_pipeline_report_from_artifacts( + error=exc, + output_dir=output_dir, + output_path=output_dir / f"{job_id}.pptx" if job_id else None, + artifact_root=output_dir / "artifacts", + job_id=job_id, + input_images=input_images, + ) + else: + result = { + "status": "failed", + "error_type": type(exc).__name__, + "error": safe_provider_error_message(str(exc)), + "output_dir": str(output_dir), + } + _write_json(output_dir / "report.json", result) + print(json.dumps(result, ensure_ascii=False)) + return 1 + print(json.dumps(result, ensure_ascii=False)) + return 0 if result.get("status") == "passed" else 1 + + +def _add_common_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--input-images", nargs="*", default=[]) + parser.add_argument("--input-glob", action="append", default=[]) + parser.add_argument("--slides", type=int, default=0) + parser.add_argument("--aspect-ratio", choices=["16:9", "4:3"], default="16:9") + parser.add_argument("--output-dir", default="") + parser.add_argument("--use-fake", action="store_true") + parser.add_argument("--provider-timeout", type=int, default=60) + + +def _run_gates(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: + images = _resolve_input_images(args) + dependencies = _gate_dependencies( + use_fake=args.use_fake, + provider_timeout=args.provider_timeout, + provider_max_attempts=getattr(args, "provider_max_attempts", 0), + provider_retry_backoff_seconds=getattr(args, "provider_retry_backoff", -1.0), + ) + config = load_generative_editable_config(use_fake=args.use_fake) + requested = _requested_gates(args.provider_gate) + gates = [] + for gate in requested: + gate_dependencies = dependencies["vlm"] if gate == "vlm_analysis" else dependencies["legacy"] + gates.append(_run_one_gate(gate, images, gate_dependencies, output_dir, args.provider_timeout)) + status = "passed" if all(item["status"] == "passed" for item in gates) else "failed" + report = { + "status": status, + "input_images": [str(path) for path in images], + "output_dir": str(output_dir), + "config": _redacted_config_summary(config), + "gates": gates, + } + report_path = output_dir / "provider-gates.json" + _write_json(report_path, report) + return {"status": status, "report_path": str(report_path), "gates": gates} + + +def _run_one_gate( + gate: str, + images: list[Path], + dependencies, + output_dir: Path, + provider_timeout: int, +) -> dict[str, Any]: + attempt_provider = None + try: + if gate == "config": + return {"gate": gate, "status": "passed"} + if gate == "vlm_analysis": + attempt_provider = dependencies.vlm_provider + analysis = dependencies.vlm_provider.analyze_page( + str(images[0]), + timeout_seconds=provider_timeout, + ) + payload = { + "gate": gate, + "status": "passed", + "text_region_count": len(analysis.text_regions), + "bitmap_region_count": len(analysis.bitmap_regions), + "shape_region_count": len(analysis.shape_regions), + "coordinate_space": { + "width": analysis.coordinate_space.width, + "height": analysis.coordinate_space.height, + "unit": analysis.coordinate_space.unit, + }, + } + _add_provider_attempts(payload, attempt_provider) + return payload + if gate == "ocr": + attempt_provider = dependencies.ocr_provider + result = dependencies.ocr_provider.extract_text(str(images[0])) + payload = { + "gate": gate, + "status": "passed", + "provider_role": result.provider_role, + "provider_name": result.provider_name, + "item_count": len(result.items), + "image_size": list(result.image_size), + } + _add_provider_attempts(payload, attempt_provider) + return payload + if gate == "image_generation": + attempt_provider = dependencies.image_generation_provider + output_path = output_dir / "provider-image-generation.png" + result = dependencies.image_generation_provider.generate( + ImageGenerationRequest( + prompt_id="real_gate_image_generation", + prompt="Generate a transparent presentation icon with no text.", + output_asset_path=str(output_path), + asset_root=str(output_dir), + visual_reference={"source_image_path": str(images[0])}, + timeout_seconds=provider_timeout, + ) + ) + payload = _provider_image_gate_result(gate, result.output_asset_path, result) + _add_provider_attempts(payload, attempt_provider) + return payload + if gate.startswith("image_edit_"): + role, provider = _image_edit_provider_for_gate(gate, dependencies) + attempt_provider = provider + source_path = output_dir / f"provider-{gate}-source.png" + _write_tiny_edit_probe_image(source_path) + output_path = output_dir / f"provider-{gate}.png" + result = provider.edit( + ImageEditRequest( + source_image_path=str(source_path), + prompt_id=f"real_gate_{gate}", + prompt="Remove the black word TEST and keep the light background. Return image only.", + output_asset_path=str(output_path), + asset_root=str(output_dir), + timeout_seconds=provider_timeout, + ) + ) + payload = _provider_image_gate_result(gate, result.output_asset_path, result) + payload["role"] = role + _add_provider_attempts(payload, attempt_provider) + return payload + except (ProviderError, ProviderTimeoutError) as exc: + payload = { + "gate": gate, + "status": "failed", + "error_type": type(exc).__name__, + "error": str(exc), + "retryable": bool(getattr(exc, "retryable", False)), + } + _add_provider_error_metadata(payload, exc) + _add_provider_attempts(payload, attempt_provider, error=exc) + return payload + except Exception as exc: + return { + "gate": gate, + "status": "failed", + "error_type": type(exc).__name__, + "error": safe_provider_error_message(str(exc)), + } + raise ValueError(f"unknown provider gate: {gate}") + + +def _run_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: + if getattr(args, "mode", "legacy") == "vlm_first": + return _run_vlm_pipeline(args, output_dir) + images = _resolve_input_images(args) + job_id = args.job_id or _generated_job_id() + artifact_root = output_dir / "artifacts" + output_path = output_dir / f"{job_id}.pptx" + raster_fallback_path = output_dir / f"{job_id}.raster-fallback.pptx" + dependencies = replace( + _with_timeout(_dependencies(use_fake=args.use_fake), args.provider_timeout), + allow_source_crop_asset_fallback=False, + ) + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id=f"slide-{index + 1}", + image_path=str(path), + text_metadata=[], + ) + for index, path in enumerate(images) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id=job_id, + aspect_ratio=args.aspect_ratio, + dependencies=dependencies, + cleanup_artifacts=False, + max_page_concurrency=1, + fallback_policy=args.fallback_policy, + fallback_output_factory=( + lambda: _export_raster_pptx_fallback( + image_paths=images, + output_path=raster_fallback_path, + aspect_ratio=args.aspect_ratio, + ) + ) + if args.fallback_policy == "raster_pptx" + else None, + ) + job_dir = artifact_root / job_id + deck_path = job_dir / "deck.json" + result_output_path = Path(result.output_path) + object_stats = _pptx_object_stats(result_output_path) + reconstruction_issues = _reconstruction_object_issues( + deck_manifest_path=deck_path, + artifact_root=job_dir, + object_stats=object_stats, + ) + preview_reports = _write_preview_reports( + source_images=images, + pptx_path=result_output_path, + deck_manifest_path=deck_path, + artifact_root=job_dir, + similarity_threshold=dependencies.preview_similarity_threshold, + output_dir=output_dir / "previews", + ) + status = _status_with_preview_reports( + _status_with_reconstruction_issues(result.status, reconstruction_issues), + preview_reports, + ) + warning_summary = _warning_summary([{"page": 1, "reconstruction_issues": reconstruction_issues}]) + report = { + "status": status, + "output_path": str(result_output_path), + "generative_output_path": str(output_path), + "artifact_root": str(job_dir), + "deck_manifest_path": str(deck_path), + "input_images": [str(path) for path in images], + "slide_count": len(images), + "fallback_used": result.fallback_used, + "object_stats": object_stats, + "reconstruction_issues": reconstruction_issues, + **warning_summary, + "preview_reports": preview_reports, + } + _augment_result_with_stage_events(report, job_dir) + report_path = output_dir / "report.json" + _write_json(report_path, report) + return { + "status": status, + "output_path": str(result_output_path), + "generative_output_path": str(output_path), + "report_path": str(report_path), + "artifact_root": str(job_dir), + } + + +def _run_vlm_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: + images = _resolve_input_images(args) + job_id = args.job_id or _generated_job_id() + artifact_root = output_dir / "artifacts" + output_path = output_dir / f"{job_id}.pptx" + dependencies = _vlm_dependencies( + use_fake=args.use_fake, + provider_timeout=args.provider_timeout, + provider_max_attempts=getattr(args, "provider_max_attempts", 0), + provider_retry_backoff_seconds=getattr(args, "provider_retry_backoff", -1.0), + page_timeout_seconds=getattr(args, "pipeline_page_timeout", 0.0), + ) + result = run_vlm_editable_pptx_pipeline( + slides=[ + {"slide_id": f"slide-{index + 1}", "image_path": str(path)} + for index, path in enumerate(images) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id=job_id, + aspect_ratio=args.aspect_ratio, + dependencies=dependencies, + cleanup_artifacts=False, + ) + job_dir = artifact_root / job_id + deck_path = job_dir / "deck.json" + result_output_path = Path(result.output_path) + object_stats = _pptx_object_stats(result_output_path) + reconstruction_issues = _reconstruction_object_issues( + deck_manifest_path=deck_path, + artifact_root=job_dir, + object_stats=object_stats, + ) + preview_reports = _write_preview_reports( + source_images=images, + pptx_path=result_output_path, + deck_manifest_path=deck_path, + artifact_root=job_dir, + similarity_threshold=0.92, + output_dir=output_dir / "previews", + ) + status = _status_with_preview_reports( + _status_with_reconstruction_issues(result.status, reconstruction_issues), + preview_reports, + ) + warning_summary = _warning_summary([{"page": 1, "reconstruction_issues": reconstruction_issues}]) + report = { + "status": status, + "mode": "vlm_first", + "output_path": str(result_output_path), + "generative_output_path": str(output_path), + "artifact_root": str(job_dir), + "deck_manifest_path": str(deck_path), + "input_images": [str(path) for path in images], + "slide_count": len(images), + "fallback_used": result.fallback_used, + "object_stats": object_stats, + "reconstruction_issues": reconstruction_issues, + **warning_summary, + "preview_reports": preview_reports, + } + _augment_result_with_stage_events(report, job_dir) + report_path = output_dir / "report.json" + _write_json(report_path, report) + return { + "status": status, + "output_path": str(result_output_path), + "generative_output_path": str(output_path), + "report_path": str(report_path), + "artifact_root": str(job_dir), + } + + +def _export_raster_pptx_fallback( + *, + image_paths: list[Path], + output_path: Path, + aspect_ratio: str, +) -> str: + export_route._export_pptx([str(path) for path in image_paths], str(output_path), aspect_ratio=aspect_ratio) + return str(output_path.resolve()) + + +def _generated_job_id() -> str: + return f"real-editable-{datetime.now().strftime('%Y%m%d%H%M%S')}" + + +def _failed_pipeline_report_from_artifacts( + *, + error: Exception, + output_dir: Path, + output_path: Path | None, + artifact_root: Path, + job_id: str, + input_images: list[Path], +) -> dict[str, Any]: + job_dir = artifact_root / job_id if job_id else artifact_root + report: dict[str, Any] = { + "status": "failed", + "error_type": type(error).__name__, + "error": safe_provider_error_message(str(error)), + "output_dir": str(output_dir), + "artifact_root": str(job_dir), + "input_images": [str(path) for path in input_images], + "slide_count": len(input_images), + } + _add_provider_error_metadata(report, error) + if isinstance(error, (GenerativeEditableValidationError, GenerativeEditableFallbackError)): + report["validation_status"] = error.validation_report.status + report["validation_issues"] = [issue.code for issue in error.validation_report.issues] + deck_path = job_dir / "deck.json" + if deck_path.is_file(): + report["deck_manifest_path"] = str(deck_path) + if output_path is not None and output_path.is_file(): + report["output_path"] = str(output_path) + try: + object_stats = _pptx_object_stats(output_path) + except Exception as exc: + report["object_stats_error"] = safe_provider_error_message(str(exc)) + else: + report["object_stats"] = object_stats + if deck_path.is_file(): + try: + report["reconstruction_issues"] = _reconstruction_object_issues( + deck_manifest_path=deck_path, + artifact_root=job_dir, + object_stats=object_stats, + ) + except Exception as exc: + report["reconstruction_issues_error"] = safe_provider_error_message(str(exc)) + if input_images: + try: + config = load_generative_editable_config() + threshold = config.preview_similarity_threshold + except Exception: + threshold = 0.92 + try: + report["preview_reports"] = _write_preview_reports( + source_images=input_images, + pptx_path=output_path, + deck_manifest_path=deck_path, + artifact_root=job_dir, + similarity_threshold=threshold, + output_dir=output_dir / "previews", + ) + except Exception as exc: + report["preview_reports_error"] = safe_provider_error_message(str(exc)) + _augment_result_with_stage_events(report, job_dir) + return report + + +def _run_isolated_pages(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: + images = _resolve_input_images(args) + job_id = args.job_id or f"real-editable-{datetime.now().strftime('%Y%m%d%H%M%S')}" + page_reports = [] + for index, image in enumerate(images, start=1): + page_dir = output_dir / f"page-{index:02d}" + page_dir.mkdir(parents=True, exist_ok=True) + page_job_id = f"{job_id}-page-{index:02d}" + command = [ + sys.executable, + str(Path(__file__).resolve()), + "run", + "--mode", + getattr(args, "mode", "legacy"), + "--input-images", + str(image), + "--output-dir", + str(page_dir), + "--job-id", + page_job_id, + "--aspect-ratio", + args.aspect_ratio, + "--provider-timeout", + str(args.provider_timeout), + "--provider-max-attempts", + str(getattr(args, "provider_max_attempts", 0)), + "--provider-retry-backoff", + str(getattr(args, "provider_retry_backoff", -1.0)), + "--pipeline-page-timeout", + str(getattr(args, "pipeline_page_timeout", 0.0)), + "--fallback-policy", + args.fallback_policy, + ] + if args.use_fake: + command.append("--use-fake") + page_report_path = page_dir / "report.json" + page_output_path = page_dir / f"{page_job_id}.pptx" + page_artifact_root = page_dir / "artifacts" / page_job_id + page_result = _run_subprocess_json( + command, + timeout_seconds=max(0, int(args.page_wall_timeout or 0)), + report_path=page_report_path, + timeout_payload={ + "status": "failed", + "page": index, + "input_image": str(image), + "job_id": page_job_id, + "output_path": str(page_output_path), + "artifact_root": str(page_artifact_root), + "deck_manifest_path": str(page_artifact_root / "deck.json"), + }, + ) + page_reports.append(_isolated_page_report_summary(index, image, page_report_path, page_result)) + status = _aggregate_page_status(page_reports) + warning_summary = _warning_summary(page_reports) + report = { + "status": status, + "mode": "isolated_pages", + "output_dir": str(output_dir), + "input_images": [str(path) for path in images], + "slide_count": len(images), + "page_wall_timeout": int(args.page_wall_timeout or 0), + **warning_summary, + "page_reports": page_reports, + } + report_path = output_dir / "report.json" + _write_json(report_path, report) + return { + "status": status, + "report_path": str(report_path), + "output_dir": str(output_dir), + } + + +def _run_subprocess_json( + command: list[str], + *, + timeout_seconds: int, + report_path: Path, + timeout_payload: dict[str, Any], +) -> dict[str, Any]: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = process.communicate( + timeout=timeout_seconds if timeout_seconds > 0 else None, + ) + result = _last_json_line(stdout) + if not result: + result = { + "status": "failed", + "error_type": "SubprocessOutputError", + "error": "child process did not print a JSON result", + "returncode": process.returncode, + } + if process.returncode != 0 and result.get("status") == "passed": + result = { + **result, + "status": "failed", + "error_type": "SubprocessFailed", + "returncode": process.returncode, + } + if not report_path.exists(): + _write_json(report_path, result) + return result + except subprocess.TimeoutExpired as exc: + _kill_process_tree(process.pid) + _finish_timed_out_process(process) + existing_result = _read_existing_report(report_path) + if existing_result is not None: + result = { + **existing_result, + "subprocess_cleanup": { + "error_type": "TimeoutExpired", + "error": f"child process stdout/stderr did not close before timeout_seconds={timeout_seconds}", + "timeout_seconds": timeout_seconds, + }, + } + _write_json(report_path, result) + return result + result = { + **timeout_payload, + "status": "failed", + "error_type": "TimeoutExpired", + "error": f"child process exceeded wall-clock timeout_seconds={timeout_seconds}", + "timeout_seconds": timeout_seconds, + } + result = _augment_timeout_result_from_artifacts(result) + _write_json(report_path, result) + return result + + +def _read_existing_report(report_path: Path) -> dict[str, Any] | None: + if not report_path.exists(): + return None + try: + value = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def _finish_timed_out_process(process: subprocess.Popen[str]) -> None: + _close_process_pipe(process.stdout) + _close_process_pipe(process.stderr) + try: + process.wait(timeout=2) + return + except subprocess.TimeoutExpired: + _kill_process_tree(process.pid) + try: + process.kill() + except ProcessLookupError: + pass + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + pass + + +def _close_process_pipe(pipe: Any) -> None: + if pipe is None: + return + try: + pipe.close() + except OSError: + pass + + +def _isolated_page_report_summary( + index: int, + image: Path, + page_report_path: Path, + page_result: dict[str, Any], +) -> dict[str, Any]: + summary = { + "page": index, + "input_image": str(image), + "status": page_result.get("status", "failed"), + "report_path": str(page_report_path), + "output_path": page_result.get("output_path", ""), + "artifact_root": page_result.get("artifact_root", ""), + "error_type": page_result.get("error_type", ""), + "error": page_result.get("error", ""), + } + for key in ( + "object_stats", + "object_stats_error", + "reconstruction_issues", + "reconstruction_issues_error", + "validation_status", + "validation_issues", + "last_stage_event", + "active_stage", + "stage_events_error", + ): + if key in page_result: + summary[key] = page_result[key] + return summary + + +def _kill_process_tree(pid: int | None) -> None: + if not pid: + return + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return + except OSError: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + return + + +def _augment_timeout_result_from_artifacts(result: dict[str, Any]) -> dict[str, Any]: + output_path_value = str(result.get("output_path") or "") + output_path = Path(output_path_value) if output_path_value else None + if output_path is not None and not output_path.is_file(): + result.pop("output_path", None) + output_path = None + if output_path is not None: + try: + if output_path.stat().st_size > MAX_TIMEOUT_AUGMENT_PPTX_BYTES: + raise ValueError("pptx exceeds timeout augmentation size cap") + if not _file_size_is_stable(output_path): + raise ValueError("pptx is still being written") + object_stats = _pptx_object_stats(output_path) + except Exception as exc: + result["object_stats_error"] = safe_provider_error_message(str(exc)) + else: + result["object_stats"] = object_stats + artifact_root = Path(str(result.get("artifact_root") or "")) + _augment_result_with_stage_events(result, artifact_root) + deck_path = artifact_root / "deck.json" if artifact_root else None + if output_path is not None and deck_path is not None and deck_path.is_file() and "object_stats" in result: + try: + result["reconstruction_issues"] = _reconstruction_object_issues( + deck_manifest_path=deck_path, + artifact_root=artifact_root, + object_stats=result["object_stats"], + ) + except Exception as exc: + result["reconstruction_issues_error"] = safe_provider_error_message(str(exc)) + return result + + +def _augment_result_with_stage_events(result: dict[str, Any], artifact_root: Path) -> None: + if not artifact_root: + return + stage_events_path = artifact_root / "stage-events.jsonl" + if not stage_events_path.is_file(): + return + events, skipped_bad_line = _read_stage_events(stage_events_path) + if skipped_bad_line: + result["stage_events_error"] = "malformed stage-events line skipped" + if not events: + return + last_event = events[-1] + result["last_stage_event"] = last_event + if last_event.get("status") in {"started", "failed"} and last_event.get("stage"): + result["active_stage"] = last_event["stage"] + + +def _read_stage_events(path: Path) -> tuple[list[dict[str, Any]], bool]: + events: list[dict[str, Any]] = [] + skipped_bad_line = False + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + skipped_bad_line = True + continue + if isinstance(parsed, dict): + events.append(parsed) + return events, skipped_bad_line + + +def _add_provider_error_metadata(payload: dict[str, Any], error: Exception) -> None: + if hasattr(error, "retryable"): + payload["retryable"] = bool(getattr(error, "retryable")) + status_code = getattr(error, "status_code", None) + provider_error_code = getattr(error, "provider_error_code", "") + if status_code is not None: + payload["status_code"] = status_code + if provider_error_code: + payload["provider_error_code"] = provider_error_code + + +def _add_provider_attempts( + payload: dict[str, Any], + provider: Any | None, + *, + error: Exception | None = None, +) -> None: + attempts = getattr(provider, "last_attempts", None) + if not attempts and error is not None: + attempts = getattr(error, "attempts", None) + if attempts: + payload["provider_attempts"] = [dict(item) for item in attempts if isinstance(item, dict)] + + +def _file_size_is_stable(path: Path, *, interval_seconds: float = 0.2) -> bool: + first = path.stat().st_size + time.sleep(interval_seconds) + try: + second = path.stat().st_size + except FileNotFoundError: + return False + return first == second + + +def _last_json_line(stdout: str) -> dict[str, Any] | None: + for line in reversed(stdout.splitlines()): + line = line.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + return parsed if isinstance(parsed, dict) else None + return None + + +def _dependencies(*, use_fake: bool): + if use_fake: + return export_route._build_fake_generative_editable_pipeline_dependencies() + return export_route._build_generative_editable_pipeline_dependencies() + + +def _gate_dependencies( + *, + use_fake: bool, + provider_timeout: int, + provider_max_attempts: int = 0, + provider_retry_backoff_seconds: float = -1.0, +) -> dict[str, Any]: + retry_attempts = provider_max_attempts if provider_max_attempts and provider_max_attempts > 0 else 1 + retry_backoff = provider_retry_backoff_seconds if provider_retry_backoff_seconds >= 0 else 0 + legacy = _with_timeout(_dependencies(use_fake=use_fake), provider_timeout) + legacy = replace( + legacy, + provider_max_attempts=retry_attempts, + provider_retry_backoff_seconds=retry_backoff, + ) + vlm = _vlm_dependencies( + use_fake=use_fake, + provider_timeout=provider_timeout, + provider_max_attempts=retry_attempts, + provider_retry_backoff_seconds=retry_backoff, + ) + vlm = replace( + vlm, + provider_max_attempts=retry_attempts, + provider_retry_backoff_seconds=retry_backoff, + ) + return { + "legacy": with_provider_retries(legacy), + "vlm": with_vlm_provider_retries(vlm), + } + + +def _vlm_dependencies( + *, + use_fake: bool, + provider_timeout: int, + provider_max_attempts: int = 0, + provider_retry_backoff_seconds: float = -1.0, + page_timeout_seconds: float = 0.0, +) -> VLMEditablePipelineDependencies: + config = load_generative_editable_config(use_fake=use_fake) + retry_attempts = ( + provider_max_attempts + if provider_max_attempts and provider_max_attempts > 0 + else config.retries.provider_max_attempts + ) + retry_backoff = ( + provider_retry_backoff_seconds + if provider_retry_backoff_seconds >= 0 + else config.retries.backoff_seconds + ) + page_timeout = page_timeout_seconds if page_timeout_seconds > 0 else config.timeouts.page + if use_fake: + return VLMEditablePipelineDependencies( + vlm_provider=FakeVLMPageAnalysisProvider(), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + asset_sheet_image_edit_provider=FakeImageEditProvider(config.asset_sheet_model), + ocr_provider=export_route._ocr_provider_for_config( + config.ocr, + timeout_seconds=provider_timeout, + ), + provider_timeout_seconds=provider_timeout, + provider_max_attempts=retry_attempts, + provider_retry_backoff_seconds=retry_backoff, + page_timeout_seconds=page_timeout, + ocr_min_confidence=config.ocr_min_confidence, + ) + profiles = load_default_profiles() + if profiles is None or profiles.vlm is None: + raise RuntimeError("VLM model profile is required for --mode vlm_first") + vlm_config = ProviderConfig( + role="VLM", + model=profiles.vlm.model, + base_url=profiles.vlm.base_url, + api_key=profiles.vlm.api_key, + adapter=profiles.vlm.adapter, + ) + return VLMEditablePipelineDependencies( + vlm_provider=OpenAIChatVLMPageAnalysisProvider(vlm_config), + image_edit_provider=export_route._image_edit_provider_for_config(config.clean_base_model), + asset_sheet_image_edit_provider=export_route._image_edit_provider_for_config(config.asset_sheet_model), + ocr_provider=export_route._ocr_provider_for_config( + config.ocr, + timeout_seconds=provider_timeout, + ), + provider_timeout_seconds=provider_timeout, + provider_max_attempts=retry_attempts, + provider_retry_backoff_seconds=retry_backoff, + page_timeout_seconds=page_timeout, + ocr_min_confidence=config.ocr_min_confidence, + ) + + +def _with_timeout(dependencies, provider_timeout: int): + if provider_timeout <= 0: + return dependencies + if hasattr(dependencies.ocr_provider, "timeout_seconds"): + dependencies.ocr_provider.timeout_seconds = provider_timeout + return replace(dependencies, provider_timeout_seconds=provider_timeout) + + +def _resolve_input_images(args: argparse.Namespace) -> list[Path]: + paths: list[Path] = [Path(value).resolve() for value in args.input_images] + for pattern in args.input_glob: + paths.extend(Path(value).resolve() for value in sorted(glob.glob(pattern), key=_natural_key)) + paths = _filter_replay_slide_images(paths) + if not paths: + paths = _default_replay_input_images() + if args.slides and args.slides > 0: + paths = paths[: args.slides] + missing = [str(path) for path in paths if not path.is_file()] + if missing: + raise FileNotFoundError("input slide images are missing: " + ", ".join(missing)) + if not paths: + raise FileNotFoundError( + f"No input images found. Expected replay assets matching {DEFAULT_REPLAY_GLOB}." + ) + return paths + + +def _default_replay_input_images() -> list[Path]: + matches = [Path(value).resolve() for value in sorted(glob.glob(DEFAULT_REPLAY_GLOB), key=_natural_key)] + numeric_slide_images = _filter_replay_slide_images(matches) + return numeric_slide_images or matches + + +def _filter_replay_slide_images(paths: list[Path]) -> list[Path]: + replay_like = [path for path in paths if path.name.startswith("slide_")] + if not replay_like: + return paths + numeric_slide_images = [ + path for path in paths if re.fullmatch(r"slide_\d+\.png", path.name) + ] + return numeric_slide_images or paths + + +def _requested_gates(values: list[str] | None) -> list[str]: + if not values or "all" in values: + return [ + "config", + "vlm_analysis", + "ocr", + "image_edit_clean", + "image_edit_asset", + "image_edit_repair", + "image_generation", + ] + return values + + +def _image_edit_provider_for_gate(gate: str, dependencies): + if gate == "image_edit_clean": + return "clean_base_model", dependencies.image_edit_provider + if gate == "image_edit_asset": + return "asset_sheet_model", dependencies.asset_sheet_image_edit_provider or dependencies.image_edit_provider + if gate == "image_edit_repair": + return "repair_model", dependencies.repair_image_edit_provider or dependencies.image_edit_provider + raise ValueError(f"unknown image edit gate: {gate}") + + +def _provider_image_gate_result(gate: str, output_path: str, result) -> dict[str, Any]: + return { + "gate": gate, + "status": "passed", + "output_path": output_path, + "provider_role": result.provider_role, + "provider_name": result.provider_name, + } + + +def _pptx_object_stats(pptx_path: Path) -> dict[str, Any]: + presentation = Presentation(str(pptx_path)) + slides = [] + totals: Counter[str] = Counter() + background_picture_counts = _pptx_slide_background_picture_counts(pptx_path) + for index, slide in enumerate(presentation.slides, start=1): + counts: Counter[str] = Counter() + background_picture_count = background_picture_counts.get(index, 0) + if background_picture_count: + totals["BACKGROUND_PICTURE"] += background_picture_count + full_slide_picture_count = 0 + non_full_slide_picture_count = 0 + for shape in slide.shapes: + name = _shape_type_name(shape) + counts[name] += 1 + totals[name] += 1 + if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: + if _is_full_slide_shape(shape, presentation.slide_width, presentation.slide_height): + full_slide_picture_count += 1 + else: + non_full_slide_picture_count += 1 + slides.append( + { + "page": index, + "shape_counts": dict(sorted(counts.items())), + "background_picture_count": background_picture_count, + "full_slide_picture_count": full_slide_picture_count, + "non_full_slide_picture_count": non_full_slide_picture_count, + "text_box_count": counts.get("TEXT_BOX", 0), + } + ) + return { + "slide_count": len(presentation.slides), + "slides": slides, + "totals": dict(sorted(totals.items())), + } + + +def _pptx_slide_background_picture_counts(pptx_path: Path) -> dict[int, int]: + counts: dict[int, int] = {} + namespaces = { + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + } + try: + with zipfile.ZipFile(pptx_path) as archive: + slide_parts = sorted( + ( + name + for name in archive.namelist() + if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) + ), + key=lambda value: int(re.search(r"slide(\d+)\.xml$", value).group(1)), + ) + for slide_index, slide_part in enumerate(slide_parts, start=1): + root = ET.fromstring(archive.read(slide_part)) + background = root.find("p:cSld/p:bg", namespaces) + if background is not None and background.findall(".//a:blip", namespaces): + counts[slide_index] = 1 + except Exception: + return counts + return counts + + +def _is_full_slide_shape(shape, slide_width: int, slide_height: int) -> bool: + return ( + abs(shape.left) <= SLIDE_POSITION_TOLERANCE_EMU + and abs(shape.top) <= SLIDE_POSITION_TOLERANCE_EMU + and abs(shape.width - slide_width) <= SLIDE_POSITION_TOLERANCE_EMU + and abs(shape.height - slide_height) <= SLIDE_POSITION_TOLERANCE_EMU + ) + + +def _reconstruction_object_issues( + *, + deck_manifest_path: Path, + artifact_root: Path, + object_stats: dict[str, Any], +) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + deck = read_deck_manifest(deck_manifest_path) + slide_stats = object_stats.get("slides", []) + for index, page_ref in enumerate(deck.page_manifest_paths): + page = read_page_manifest(artifact_root / page_ref) + stats = slide_stats[index] if index < len(slide_stats) else {} + shape_counts = stats.get("shape_counts", {}) + decomposed_visual_count = len(page.bitmap_assets) + len(page.native_shapes) + picture_count = int(shape_counts.get("PICTURE", 0)) + background_picture_count = int(stats.get("background_picture_count", 0)) + full_slide_picture_count = int(stats.get("full_slide_picture_count", 0)) + non_full_slide_picture_count = int(stats.get("non_full_slide_picture_count", 0)) + text_box_count = int(stats.get("text_box_count", 0)) + if page.provenance.get("chosen_background_kind") == "source_raster_guardrail": + issues.append( + { + "code": "source_raster_guardrail_degraded", + "severity": "error", + "page": index + 1, + "slide_id": page.slide_id, + "message": "Slide used source raster guardrail and is not an editable reconstruction", + "details": { + "bitmap_asset_count": len(page.bitmap_assets), + "native_shape_count": len(page.native_shapes), + "text_box_count": int(stats.get("text_box_count", 0)), + "full_slide_picture_count": full_slide_picture_count, + "source_raster_guardrail": page.provenance.get("source_raster_guardrail", {}), + }, + } + ) + issues.extend( + _bitmap_asset_coverage_issues( + page=page, + page_number=index + 1, + stats=stats, + ) + ) + issues.extend( + _overlapping_duplicate_text_issues( + page=page, + page_number=index + 1, + ) + ) + filtered_ocr_issues = [ + issue + for issue in page.provenance.get("text_issues", []) + if isinstance(issue, dict) and issue.get("code") == "ignored_spurious_ocr" + ] + if int(stats.get("text_box_count", 0)) == 0 and filtered_ocr_issues: + filtered_reason_counts = Counter( + str(issue.get("code", "unknown")) for issue in filtered_ocr_issues + ) + ocr_returned_count = _ocr_returned_item_count(page, artifact_root) + issues.append( + { + "code": "no_editable_text_after_ocr_filtering", + "severity": "warning", + "page": index + 1, + "slide_id": page.slide_id, + "message": "OCR returned text-like content but all items were filtered as spurious", + "details": { + "filtered_ocr_issue_count": len(filtered_ocr_issues), + "filtered_reason_counts": dict(sorted(filtered_reason_counts.items())), + "ocr_returned_count": ocr_returned_count, + "sample_filtered_text": [ + str(issue.get("ocr_text", ""))[:120] + for issue in filtered_ocr_issues[:3] + ], + }, + } + ) + if ( + page.provenance.get("chosen_background_kind") + == "source_preserving_low_opacity_text_overlay" + and full_slide_picture_count >= 1 + and decomposed_visual_count == 0 + ): + issues.append( + { + "code": "source_preserving_low_opacity_overlay_degraded", + "severity": "error", + "page": index + 1, + "slide_id": page.slide_id, + "message": "Slide preserved source visual fidelity with low-opacity editable OCR text overlay", + "details": { + "bitmap_asset_count": len(page.bitmap_assets), + "native_shape_count": len(page.native_shapes), + "full_slide_picture_count": full_slide_picture_count, + "text_box_count": int(stats.get("text_box_count", 0)), + "text_overlay_opacity": page.provenance.get("text_overlay_opacity"), + }, + } + ) + continue + if ( + decomposed_visual_count == 0 + and text_box_count == 0 + and ( + (picture_count > 0 and full_slide_picture_count == picture_count) + or background_picture_count > 0 + ) + and non_full_slide_picture_count == 0 + ): + issues.append( + { + "code": "no_decomposed_visual_elements", + "severity": "error", + "page": index + 1, + "slide_id": page.slide_id, + "message": "PPTX slide contains only full-slide picture objects and no decomposed visual elements", + "details": { + "bitmap_asset_count": len(page.bitmap_assets), + "native_shape_count": len(page.native_shapes), + "background_picture_count": background_picture_count, + "full_slide_picture_count": full_slide_picture_count, + "text_box_count": text_box_count, + }, + } + ) + if ( + page.provenance.get("chosen_background_kind") == "source_preserving_text_fast_path" + and full_slide_picture_count >= 1 + and int(shape_counts.get("AUTO_SHAPE", 0)) == 0 + and int(shape_counts.get("LINE", 0)) == 0 + and all( + asset.provenance.get("asset_strategy") == "source_preserving_anchor" + for asset in page.bitmap_assets + ) + ): + issues.append( + { + "code": "source_preserving_fast_path_degraded", + "severity": "error", + "page": index + 1, + "slide_id": page.slide_id, + "message": "Slide used source-preserving fast path with only anchor bitmap assets", + "details": { + "bitmap_asset_count": len(page.bitmap_assets), + "native_shape_count": len(page.native_shapes), + "text_box_count": int(stats.get("text_box_count", 0)), + "full_slide_picture_count": full_slide_picture_count, + }, + } + ) + return issues + + +def _overlapping_duplicate_text_issues( + *, + page, + page_number: int, +) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + text_boxes = list(page.text_boxes) + for left_index, left in enumerate(text_boxes): + for right_index in range(left_index + 1, len(text_boxes)): + right = text_boxes[right_index] + if not _text_boxes_are_overlapping_duplicates(left, right): + continue + issues.append( + { + "code": "overlapping_duplicate_text_boxes", + "severity": "warning", + "page": page_number, + "slide_id": page.slide_id, + "message": "Two editable text boxes contain duplicate text and overlap geometrically", + "details": { + "duplicate_text": left.text, + "left_text": left.text, + "right_text": right.text, + "left_bbox": list(left.source_pixel_bbox), + "right_bbox": list(right.source_pixel_bbox), + }, + } + ) + return issues + + +def _text_boxes_are_overlapping_duplicates(left: Any, right: Any) -> bool: + if not _normalized_texts_are_exact_duplicate(str(left.text), str(right.text)): + return False + overlap = _bbox_intersection_area(left.source_pixel_bbox, right.source_pixel_bbox) + if overlap <= 0: + return False + smaller_area = min(_bbox_area(left.source_pixel_bbox), _bbox_area(right.source_pixel_bbox)) + larger_area = max(_bbox_area(left.source_pixel_bbox), _bbox_area(right.source_pixel_bbox)) + return ( + overlap / max(1, smaller_area) >= 0.70 + and overlap / max(1, larger_area) >= 0.45 + ) + + +def _text_similarity(left: str, right: str) -> float: + normalized_left = _normalize_text_for_match(left) + normalized_right = _normalize_text_for_match(right) + if not normalized_left or not normalized_right: + return 0.0 + if normalized_left in normalized_right or normalized_right in normalized_left: + return 1.0 + return SequenceMatcher(None, normalized_left, normalized_right).ratio() + + +def _normalize_text_for_match(value: str) -> str: + return re.sub(r"\s+", "", str(value)).lower() + + +def _normalized_texts_are_exact_duplicate(left: str, right: str) -> bool: + normalized_left = _normalize_text_for_match(left) + normalized_right = _normalize_text_for_match(right) + if normalized_left != normalized_right: + return False + return len(normalized_left) >= 4 + + +def _bbox_intersection_area( + left_bbox: tuple[int, int, int, int], + right_bbox: tuple[int, int, int, int], +) -> int: + left = max(int(left_bbox[0]), int(right_bbox[0])) + top = max(int(left_bbox[1]), int(right_bbox[1])) + right = min(int(left_bbox[2]), int(right_bbox[2])) + bottom = min(int(left_bbox[3]), int(right_bbox[3])) + if right <= left or bottom <= top: + return 0 + return (right - left) * (bottom - top) + + +def _bitmap_asset_coverage_issues( + *, + page, + page_number: int, + stats: dict[str, Any], +) -> list[dict[str, Any]]: + source_width, source_height = page.source_image_size + page_area = max(1, int(source_width) * int(source_height)) + if not page.bitmap_assets or page_area <= 1: + return [] + source_preserved_assets = [ + asset + for asset in page.bitmap_assets + if _is_source_preserved_bitmap_asset(asset) + ] + if not source_preserved_assets: + return [] + issues: list[dict[str, Any]] = [] + largest_asset = max( + source_preserved_assets, + key=lambda asset: _source_preserved_asset_effective_area_ratio(asset, (int(source_width), int(source_height))), + ) + largest_ratio = _source_preserved_asset_effective_area_ratio( + largest_asset, + (int(source_width), int(source_height)), + ) + largest_bbox_ratio = _bbox_area(largest_asset.source_pixel_bbox) / float(page_area) + combined_bbox_ratio = _combined_bbox_coverage_ratio( + [asset.source_pixel_bbox for asset in source_preserved_assets], + (int(source_width), int(source_height)), + ) + combined_visible_ratio = min( + 1.0, + sum( + _source_preserved_asset_effective_area_ratio(asset, (int(source_width), int(source_height))) + for asset in source_preserved_assets + ), + ) + combined_ratio = min(combined_bbox_ratio, combined_visible_ratio) + text_box_count = int(stats.get("text_box_count", 0)) + native_shape_count = len(page.native_shapes) + details = { + "largest_asset_id": largest_asset.asset_id, + "largest_asset_area_ratio": round(largest_ratio, 4), + "largest_asset_bbox_area_ratio": round(largest_bbox_ratio, 4), + "combined_bitmap_asset_coverage_ratio": round(combined_ratio, 4), + "combined_bitmap_asset_bbox_coverage_ratio": round(combined_bbox_ratio, 4), + "combined_bitmap_asset_visible_area_ratio": round(combined_visible_ratio, 4), + "bitmap_asset_count": len(page.bitmap_assets), + "source_preserved_bitmap_asset_count": len(source_preserved_assets), + "native_shape_count": native_shape_count, + "text_box_count": text_box_count, + } + if largest_ratio >= 0.80: + issues.append( + { + "code": "oversized_bitmap_asset_coverage", + "severity": "error", + "page": page_number, + "slide_id": page.slide_id, + "message": "A single source-preserved bitmap asset covers most of the slide", + "details": details, + } + ) + return issues + if combined_ratio >= 0.85 and text_box_count + native_shape_count < 3: + issues.append( + { + "code": "excessive_bitmap_asset_coverage", + "severity": "error", + "page": page_number, + "slide_id": page.slide_id, + "message": "Source-preserved bitmap assets cover most of the slide without enough editable structure", + "details": details, + } + ) + return issues + if combined_ratio >= 0.65 and not _has_split_row_level_bitmap_structure( + source_preserved_assets, + largest_ratio=largest_ratio, + structure_count=text_box_count + native_shape_count, + ): + issues.append( + { + "code": "high_bitmap_asset_coverage", + "severity": "warning", + "page": page_number, + "slide_id": page.slide_id, + "message": "Source-preserved bitmap assets cover a large part of the slide", + "details": details, + } + ) + return issues + + +def _is_source_preserved_bitmap_asset(asset: Any) -> bool: + return asset.provenance.get("asset_strategy") in { + "masked_source_element", + "source_preserved_crop", + "source_preserving_anchor", + } or asset.provenance.get("source_type") == "vlm_source_crop" + + +def _has_split_row_level_bitmap_structure( + source_preserved_assets: list[Any], + *, + largest_ratio: float, + structure_count: int, +) -> bool: + split_row_structure = ( + len(source_preserved_assets) >= 3 + and largest_ratio <= 0.35 + and structure_count >= 3 + ) + dense_infographic_structure = ( + len(source_preserved_assets) >= 3 + and largest_ratio <= 0.60 + and structure_count >= 12 + ) + return split_row_structure or dense_infographic_structure + + +def _source_preserved_asset_effective_area_ratio( + asset: Any, + source_size: tuple[int, int], +) -> float: + visible_ratio = asset.provenance.get("alpha_visible_area_ratio") + if ( + asset.provenance.get("background_difference_alpha") is True + and isinstance(visible_ratio, (int, float)) + and 0.0 <= float(visible_ratio) <= 1.0 + ): + return float(visible_ratio) + page_area = max(1, source_size[0] * source_size[1]) + return _bbox_area(asset.source_pixel_bbox) / float(page_area) + + +def _combined_bbox_coverage_ratio( + bboxes: list[tuple[int, int, int, int]], + source_size: tuple[int, int], +) -> float: + width, height = source_size + if width <= 0 or height <= 0: + return 0.0 + mask = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(mask) + for bbox in bboxes: + left, top, right, bottom = _clamp_bbox(bbox, source_size) + if right > left and bottom > top: + draw.rectangle((left, top, right, bottom), fill=255) + histogram = mask.histogram() + covered = width * height - histogram[0] + return covered / float(width * height) + + +def _bbox_area(bbox: tuple[int, int, int, int]) -> int: + return max(0, int(bbox[2]) - int(bbox[0])) * max(0, int(bbox[3]) - int(bbox[1])) + + +def _clamp_bbox( + bbox: tuple[int, int, int, int], + source_size: tuple[int, int], +) -> tuple[int, int, int, int]: + width, height = source_size + left, top, right, bottom = bbox + return ( + max(0, min(width, int(left))), + max(0, min(height, int(top))), + max(0, min(width, int(right))), + max(0, min(height, int(bottom))), + ) + + +def _ocr_returned_item_count(page, artifact_root: Path) -> int | None: + ocr_ref = page.provider_output_paths.get("ocr") + if not ocr_ref: + return None + try: + path = (artifact_root / ocr_ref).resolve() + if not path.is_relative_to(artifact_root.resolve()) or not path.is_file(): + return None + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + items = payload.get("items") if isinstance(payload, dict) else None + return len(items) if isinstance(items, list) else None + + +def _status_with_reconstruction_issues(status: str, issues: list[dict[str, Any]]) -> str: + if status == "failed" or _has_error_reconstruction_issue(issues): + return "failed" + if _has_warning_reconstruction_issue(issues): + return "degraded" + return status + + +def _status_with_preview_reports(status: str, preview_reports: list[dict[str, Any]]) -> str: + if status == "failed": + return "failed" + if any(str(report.get("status")) == "failed" for report in preview_reports): + return "failed" + return status + + +def _aggregate_page_status(page_reports: list[dict[str, Any]]) -> str: + statuses = [str(report.get("status", "failed")) for report in page_reports] + if any(status == "failed" for status in statuses): + return "failed" + if any( + _has_error_reconstruction_issue(report.get("reconstruction_issues", []) or []) + for report in page_reports + ): + return "failed" + if any(status == "degraded" for status in statuses) or any( + _has_warning_reconstruction_issue(report.get("reconstruction_issues", []) or []) + for report in page_reports + ): + return "degraded" + return "passed" + + +def _has_error_reconstruction_issue(issues: list[dict[str, Any]]) -> bool: + return any(issue.get("severity", "error") == "error" for issue in issues) + + +def _has_warning_reconstruction_issue(issues: list[dict[str, Any]]) -> bool: + return any(issue.get("severity") == "warning" for issue in issues) + + +def _warning_summary(page_reports: list[dict[str, Any]]) -> dict[str, Any]: + warning_codes: Counter[str] = Counter() + warning_pages: set[int] = set() + warning_count = 0 + for report in page_reports: + report_page = int(report.get("page", 1)) + for issue in report.get("reconstruction_issues", []) or []: + if not isinstance(issue, dict) or issue.get("severity") != "warning": + continue + page = int(issue.get("page", report_page)) + warning_count += 1 + warning_pages.add(page) + warning_codes[str(issue.get("code", "unknown_warning"))] += 1 + return { + "warning_count": warning_count, + "warning_pages": sorted(warning_pages), + "warning_codes": dict(sorted(warning_codes.items())), + } + + +def _write_preview_reports( + *, + source_images: list[Path], + pptx_path: Path, + deck_manifest_path: Path, + artifact_root: Path, + similarity_threshold: float, + output_dir: Path, +) -> list[dict[str, Any]]: + output_dir.mkdir(parents=True, exist_ok=True) + deck = read_deck_manifest(deck_manifest_path) + gates = quality_threshold_to_preview_gates(similarity_threshold) + reports = [] + for page_index, page_ref in enumerate(deck.page_manifest_paths): + page = read_page_manifest(artifact_root / page_ref) + source_path = source_images[page_index] + with Image.open(source_path) as source: + output_size = source.size + preview = render_powerpoint_preview_with_metadata( + page, + artifact_root, + pptx_path=pptx_path, + output_size=output_size, + ) + preview_path = output_dir / f"{page_index + 1:02d}-preview.png" + preview.image.save(preview_path) + diff_path = output_dir / f"{page_index + 1:02d}-diff.png" + diff_metrics = _write_diff_image(source_path, preview_path, diff_path) + validation = validate_preview_similarity( + source_image_path=source_path, + preview=preview, + slide_id=page.slide_id, + page_index=page_index, + max_mean_abs_delta=gates.max_mean_abs_delta, + max_changed_pixel_ratio=gates.max_changed_pixel_ratio, + require_powerpoint_render=True, + ) + reports.append( + { + "page": page_index + 1, + "slide_id": page.slide_id, + "status": validation.status, + "preview_path": str(preview_path), + "diff_path": str(diff_path), + "renderer": preview.metadata.get("renderer"), + "is_powerpoint_render": bool(preview.metadata.get("is_powerpoint_render")), + "diff_metrics": diff_metrics, + "issues": [issue.to_dict() for issue in validation.issues], + } + ) + return reports + + +def _write_diff_image(source_path: Path, preview_path: Path, diff_path: Path) -> dict[str, float]: + with Image.open(source_path) as source_image, Image.open(preview_path) as preview_image: + source = source_image.convert("RGB") + preview = preview_image.convert("RGB") + if preview.size != source.size: + preview = preview.resize(source.size) + diff = ImageChops.difference(source, preview) + diff.save(diff_path) + mean_abs_delta = sum(ImageStat.Stat(diff).mean) / 3.0 + changed = diff.convert("L").point(lambda value: 255 if value > 16 else 0) + changed_ratio = sum(ImageStat.Stat(changed).sum) / 255.0 / (source.width * source.height) + return { + "mean_abs_delta": round(mean_abs_delta, 4), + "changed_pixel_ratio": round(changed_ratio, 6), + } + + +def _shape_type_name(shape) -> str: + raw = str(shape.shape_type) + return raw.split(" ", 1)[0] + + +def _write_tiny_edit_probe_image(path: Path) -> None: + image = Image.new("RGB", (160, 90), "white") + from PIL import ImageDraw + + draw = ImageDraw.Draw(image) + draw.rectangle((10, 10, 150, 80), fill="#eef2ff") + draw.text((44, 35), "TEST", fill="black") + image.save(path) + + +def _redacted_config_summary(config) -> dict[str, Any]: + return { + "ocr": _provider_summary(config.ocr), + "clean_base_model": _provider_summary(config.clean_base_model), + "asset_sheet_model": _provider_summary(config.asset_sheet_model), + "repair_model": _provider_summary(config.repair_model), + "generation_model": _provider_summary(config.generation_model), + } + + +def _provider_summary(provider) -> dict[str, str]: + return { + "role": provider.role, + "provider": provider.provider or "openai_chat", + "model": provider.model, + "base_url": "SET" if provider.base_url else "EMPTY", + "api_key": "SET" if provider.api_key else "EMPTY", + } + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + + +def _default_output_dir() -> str: + return f"/private/tmp/aippt-real-editable-run-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + + +def _natural_key(value: str) -> list[Any]: + return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", value)] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_generative_editable_pptx.py b/scripts/smoke_generative_editable_pptx.py new file mode 100644 index 0000000..1f2fe01 --- /dev/null +++ b/scripts/smoke_generative_editable_pptx.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Smoke test generative editable PPTX export with fake or local live providers.""" + +from __future__ import annotations + +import argparse +import json +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import api.routes.export as export_route # noqa: E402 +from src.generative_editable_config import load_generative_editable_config # noqa: E402 +from src.generative_editable_manifest import read_deck_manifest # noqa: E402 +from src.generative_editable_pipeline import ( # noqa: E402 + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + with_provider_retries, +) +from src.generative_editable_providers import ImageEditRequest, ImageGenerationRequest # noqa: E402 +from tests.generative_editable_fixtures import write_deterministic_fixture_deck # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--slides", choices=["one", "multi"], default="one") + parser.add_argument("--aspect-ratio", choices=["16:9", "4:3"], default="16:9") + parser.add_argument("--output-dir", default="") + parser.add_argument("--use-fake", action="store_true") + parser.add_argument("--config-check-only", action="store_true") + parser.add_argument( + "--provider-check", + choices=["pipeline", "ocr", "image_generation", "image_edit"], + default="pipeline", + ) + args = parser.parse_args(argv) + + config = load_generative_editable_config(use_fake=args.use_fake) + print(json.dumps({"config": _redacted_config_summary(config)}, ensure_ascii=False)) + if args.config_check_only: + return 0 + + if args.output_dir: + output_root = Path(args.output_dir).resolve() + output_root.mkdir(parents=True, exist_ok=True) + return _run_smoke_with_error_report(args, output_root) + + with tempfile.TemporaryDirectory() as tmp: + return _run_smoke_with_error_report(args, Path(tmp)) + + +def _run_smoke_with_error_report(args: argparse.Namespace, output_root: Path) -> int: + try: + return _run_smoke(args, output_root) + except Exception as exc: + print( + json.dumps( + { + "status": "failed", + "error_type": type(exc).__name__, + "error": str(exc), + "output_dir": str(output_root), + }, + ensure_ascii=False, + ) + ) + return 1 + + +def _run_smoke(args: argparse.Namespace, output_root: Path) -> int: + fixture = write_deterministic_fixture_deck(output_root, aspect_ratio=args.aspect_ratio) + slides = fixture.slides[:1] if args.slides == "one" else fixture.slides[:3] + job_id = f"smoke-{args.slides}-{args.aspect_ratio.replace(':', '-')}" + output_path = output_root / f"{job_id}.pptx" + artifact_root = output_root / "artifacts" + dependencies = ( + export_route._build_fake_generative_editable_pipeline_dependencies() + if args.use_fake + else export_route._build_generative_editable_pipeline_dependencies() + ) + dependencies = with_provider_retries(dependencies) + if args.provider_check == "image_generation": + output_path = output_root / "provider-image-generation.png" + result = dependencies.image_generation_provider.generate( + ImageGenerationRequest( + prompt_id="live_smoke_image_generation", + prompt=( + "Using the attached source slide as visual reference, generate a small " + "transparent presentation icon with no text." + ), + output_asset_path=str(output_path), + asset_root=str(output_root), + visual_reference={"source_image_path": str(slides[0].image_path)}, + timeout_seconds=180, + ) + ) + print( + json.dumps( + { + "status": "passed", + "provider_check": "image_generation", + "output_path": result.output_asset_path, + "provider_role": result.provider_role, + "provider_name": result.provider_name, + }, + ensure_ascii=False, + ) + ) + return 0 + if args.provider_check == "ocr": + result = dependencies.ocr_provider.extract_text(str(slides[0].image_path)) + print( + json.dumps( + { + "status": "passed", + "provider_check": "ocr", + "provider_role": result.provider_role, + "provider_name": result.provider_name, + "item_count": len(result.items), + "image_size": list(result.image_size), + }, + ensure_ascii=False, + ) + ) + return 0 + if args.provider_check == "image_edit": + source_path = output_root / "provider-image-edit-source.png" + _write_tiny_edit_probe_image(source_path) + provider_roles = [ + ("clean_base_model", dependencies.image_edit_provider), + ( + "asset_sheet_model", + dependencies.asset_sheet_image_edit_provider or dependencies.image_edit_provider, + ), + ( + "repair_model", + dependencies.repair_image_edit_provider or dependencies.image_edit_provider, + ), + ] + checked_providers = [] + for role, provider in provider_roles: + output_path = output_root / f"provider-image-edit-{role}.png" + result = provider.edit( + ImageEditRequest( + source_image_path=str(source_path), + prompt_id=f"live_smoke_image_edit_{role}", + prompt=( + "Remove the black word TEST and keep the light background. " + "Return image only." + ), + output_asset_path=str(output_path), + asset_root=str(output_root), + timeout_seconds=180, + ) + ) + checked_providers.append( + { + "role": role, + "output_path": result.output_asset_path, + "provider_role": result.provider_role, + "provider_name": result.provider_name, + } + ) + print( + json.dumps( + { + "status": "passed", + "provider_check": "image_edit", + "checked_providers": checked_providers, + }, + ensure_ascii=False, + ) + ) + return 0 + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id=slide.slide_id, + image_path=str(slide.image_path), + text_metadata=slide.text_metadata, + ) + for slide in slides + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id=job_id, + aspect_ratio=args.aspect_ratio, + dependencies=dependencies, + cleanup_artifacts=False, + max_page_concurrency=1, + ) + manifest = read_deck_manifest(artifact_root / job_id / "deck.json") + print( + json.dumps( + { + "status": result.status, + "output_path": str(output_path), + "artifact_root": str(artifact_root / job_id), + "slide_count": len(slides), + "validation_status": manifest.validation_status, + "fallback_used": result.fallback_used, + }, + ensure_ascii=False, + ) + ) + return 0 + + +def _write_tiny_edit_probe_image(path: Path) -> None: + image = Image.new("RGB", (96, 54), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((10, 10, 86, 44), fill="#eef2ff") + draw.text((18, 20), "TEST", fill="black") + image.save(path) + + +def _redacted_config_summary(config) -> dict: + return { + "ocr": _provider_summary(config.ocr), + "clean_base_model": _provider_summary(config.clean_base_model), + "asset_sheet_model": _provider_summary(config.asset_sheet_model), + "repair_model": _provider_summary(config.repair_model), + "generation_model": _provider_summary(config.generation_model), + "quality": { + "max_repair_attempts": config.quality.max_repair_attempts, + "preview_similarity_threshold": config.quality.preview_similarity_threshold, + "require_preview_validation": config.quality.require_preview_validation, + }, + "timeouts": { + "provider_call": config.timeouts.provider_call, + "page": config.timeouts.page, + }, + } + + +def _provider_summary(provider) -> dict: + return { + "role": provider.role, + "provider": provider.provider or "openai_chat", + "model": provider.model, + "base_url": "SET" if provider.base_url else "EMPTY", + "api_key": "SET" if provider.api_key else "EMPTY", + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/config_writer.py b/src/config_writer.py index 474d0bc..f259bd1 100644 --- a/src/config_writer.py +++ b/src/config_writer.py @@ -30,23 +30,24 @@ def _clean_profile_data( profile_data: Dict[str, Any], existing_models: Dict[str, Any] ) -> Dict[str, Any]: cleaned: Dict[str, Any] = {} - for role in ("prompt_model", "image_model", "edit_model"): + for role in ("text_model", "image_model", "edit_model", "VLM", "ocr_model"): profile = profile_data.get(role) + if not profile and role == "text_model": + profile = profile_data.get("prompt_model") if not profile: continue existing_profile = ( existing_models.get(role, {}) if isinstance(existing_models, dict) else {} ) + if role == "text_model" and not existing_profile and isinstance(existing_models, dict): + existing_profile = existing_models.get("prompt_model", {}) api_key = profile.get("api_key") or existing_profile.get("api_key", "") cleaned[role] = { "model": profile.get("model", ""), "base_url": profile.get("base_url", ""), "api_key": api_key, - "adapter": profile.get( - "adapter", "openai_chat" if role == "prompt_model" else "raw_chat_multimodal" - ), } - if role == "prompt_model": + if role == "text_model": cleaned[role]["thinking"] = profile.get( "thinking", existing_profile.get("thinking", "disabled") ) diff --git a/src/generative_editable_assets.py b/src/generative_editable_assets.py new file mode 100644 index 0000000..c6aa19e --- /dev/null +++ b/src/generative_editable_assets.py @@ -0,0 +1,843 @@ +"""Asset sheet request, slicing, QA, and repair helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import math +from pathlib import Path +import re +from typing import Literal + +from PIL import Image + +from .generative_editable_foreground_planner import ForegroundCandidate +from .generative_editable_manifest import BitmapAssetSpec, RepairAttempt +from .generative_editable_providers import ImageEditProvider, ImageEditRequest + + +@dataclass(frozen=True) +class AssetSliceSpec: + candidate_id: str + sheet_bbox: tuple[int, int, int, int] + source_pixel_bbox: tuple[int, int, int, int] + output_filename: str + z_order: int = 1 + + +@dataclass(frozen=True) +class AssetQAReport: + status: Literal["passed", "failed"] + reasons: list[str] + + +def build_asset_sheet_request( + *, + source_image_path: str | Path, + candidates: list[ForegroundCandidate], + output_asset_path: str | Path, + asset_root: str | Path, + chroma_key_mode: str = "solid #FF00FF chroma key", + provider_role: str = "edit_model", + timeout_seconds: int = 180, +) -> ImageEditRequest: + if not candidates: + raise ValueError("candidates are required") + source_ref = _artifact_ref(source_image_path, asset_root) + candidate_boxes = [ + { + "candidate_id": candidate.candidate_id, + "bbox": candidate.source_pixel_bbox, + "classification": candidate.classification, + } + for candidate in candidates + ] + prompt = ( + "Create a sparse asset sheet for these source regions. Preserve each candidate's " + "visual identity, geometry, stroke, color, proportions, and internal cutouts. " + "Produce exactly one separated object per candidate id, in the same visual order. " + "Do not merge, omit, rename, replace, or add objects. " + f"Use {chroma_key_mode} background mode with generous empty space around every object. " + "The background must be flat pure #FF00FF only. " + "Do not use checkerboard, gray, white, shadows, gradients, or scene floors. " + "Do not include readable text, labels, numbers, pseudo-text, or watermarks. " + "Keep text boxes out of the asset sheet because editable text is rebuilt separately. " + "Do not include full cards, panels, charts, or source-slide fragments; extract only the listed non-text foreground objects. " + "Candidate boxes are source-image pixel coordinates in (left, top, right, bottom) order. " + "Extract only the visual object inside each box. Candidate boxes:\n" + + "\n".join( + f"- {candidate.candidate_id} bbox={candidate.source_pixel_bbox} " + f"classification={candidate.classification}" + for candidate in candidates + ) + ) + return ImageEditRequest( + source_image_path=str(source_image_path), + prompt_id="asset_sheet", + prompt=prompt, + output_asset_path=str(output_asset_path), + asset_root=str(asset_root), + timeout_seconds=timeout_seconds, + metadata={ + "source_image_ref": source_ref, + "candidate_boxes": candidate_boxes, + "chroma_key_mode": chroma_key_mode, + "provider_role": provider_role, + }, + ) + + +def slice_asset_sheet( + *, + sheet_path: str | Path, + slice_specs: list[AssetSliceSpec], + output_dir: str | Path, + asset_root: str | Path, + chroma_key: tuple[int, int, int] | None = None, +) -> list[BitmapAssetSpec]: + output_root = _resolve_inside_root(output_dir, asset_root, "output_dir") + resolved_sheet = _resolve_inside_root(sheet_path, asset_root, "sheet_path") + output_root.mkdir(parents=True, exist_ok=True) + assets: list[BitmapAssetSpec] = [] + with Image.open(resolved_sheet) as sheet: + rgba = sheet.convert("RGBA") + for spec in slice_specs: + _validate_bbox(spec.sheet_bbox, rgba.size, "sheet_bbox") + _validate_filename(spec.output_filename) + crop = rgba.crop(spec.sheet_bbox) + if chroma_key is not None: + crop = _remove_chroma_key(crop, chroma_key) + output_path = output_root / spec.output_filename + crop.save(output_path) + assets.append( + BitmapAssetSpec( + asset_id=spec.candidate_id, + source_pixel_bbox=spec.source_pixel_bbox, + asset_path=_artifact_ref(output_path, asset_root), + z_order=spec.z_order, + provenance={ + "candidate_id": spec.candidate_id, + "sheet_bbox": spec.sheet_bbox, + }, + ) + ) + return assets + + +def slice_asset_sheet_by_components( + *, + sheet_path: str | Path, + candidates: list[ForegroundCandidate], + output_dir: str | Path, + asset_root: str | Path, + padding: int = 12, + min_component_area: int = 32, +) -> list[BitmapAssetSpec]: + if not candidates: + return [] + output_root = _resolve_inside_root(output_dir, asset_root, "output_dir") + resolved_sheet = _resolve_inside_root(sheet_path, asset_root, "sheet_path") + output_root.mkdir(parents=True, exist_ok=True) + with Image.open(resolved_sheet) as sheet: + rgba = _sheet_with_inferred_alpha(sheet) + boxes = _component_boxes_from_alpha( + rgba, + min_area=min_component_area, + merge_gap=24, + merge_union_growth=2.6, + ) + if len(boxes) < len(candidates): + raise ValueError( + f"asset sheet contains {len(boxes)} component(s), expected at least {len(candidates)}" + ) + if len(candidates) == 1 and len(boxes) > 1 and _candidate_allows_multipart(candidates[0]): + boxes = [_union_all_boxes(boxes)] + elif len(candidates) == 1 and len(boxes) > 1: + raise ValueError( + f"asset sheet contains unexpected extra component(s) for {candidates[0].candidate_id}" + ) + if len(boxes) > len(candidates): + raise ValueError( + f"asset sheet contains unexpected extra component(s), got {len(boxes)} for {len(candidates)} candidate(s)" + ) + boxes = _reading_order_boxes(boxes) + assets: list[BitmapAssetSpec] = [] + for index, (candidate, bbox) in enumerate(zip(candidates, boxes, strict=True), start=1): + output_filename = f"{_safe_name(candidate.candidate_id)}.png" + _validate_filename(output_filename) + crop_box = _pad_box(bbox, rgba.size, padding=padding) + output_path = output_root / output_filename + rgba.crop(crop_box).save(output_path) + assets.append( + BitmapAssetSpec( + asset_id=candidate.candidate_id, + source_pixel_bbox=candidate.source_pixel_bbox, + asset_path=_artifact_ref(output_path, asset_root), + z_order=index, + provenance={ + "candidate_id": candidate.candidate_id, + "candidate_classification": candidate.classification, + "component_bbox": bbox, + "sheet_bbox": crop_box, + "split_method": "connected_components", + }, + ) + ) + return assets + + +def validate_sliced_asset( + *, + asset_path: str | Path, + expected_object_count: int | None = None, + expected_text_free: bool = False, + min_opaque_area_ratio: float | None = None, + source_image_path: str | Path | None = None, + source_pixel_bbox: tuple[int, int, int, int] | None = None, + min_source_color_similarity: float | None = None, +) -> AssetQAReport: + with Image.open(asset_path) as image: + rgba = image.convert("RGBA") + opaque_pixels = _opaque_pixels(rgba) + reasons: list[str] = [] + if not opaque_pixels: + reasons.append("empty_asset") + if expected_object_count: + reasons.append("missing_object") + return AssetQAReport(status="failed", reasons=reasons) + + if any(x in {0, rgba.width - 1} or y in {0, rgba.height - 1} for x, y in opaque_pixels): + reasons.append("edge_touch") + + components = _connected_components(opaque_pixels) + if expected_object_count is not None and len(components) > expected_object_count: + reasons.append("neighboring_object_contamination") + if expected_object_count is not None and len(components) < expected_object_count: + reasons.append("missing_object") + if min_opaque_area_ratio is not None: + opaque_ratio = len(opaque_pixels) / float(rgba.width * rgba.height) + if opaque_ratio < min_opaque_area_ratio: + reasons.append("missing_object") + + if expected_text_free and _looks_like_baked_text(rgba, opaque_pixels): + reasons.append("baked_text_contamination") + if ( + source_image_path is not None + and source_pixel_bbox is not None + and min_source_color_similarity is not None + ): + similarity = _source_color_similarity( + asset_rgba=rgba, + opaque_pixels=opaque_pixels, + source_image_path=source_image_path, + source_pixel_bbox=source_pixel_bbox, + ) + if similarity is not None and similarity < min_source_color_similarity: + reasons.append("source_visual_mismatch") + if _source_geometry_mismatch( + asset_rgba=rgba, + opaque_pixels=opaque_pixels, + source_image_path=source_image_path, + source_pixel_bbox=source_pixel_bbox, + ): + reasons.append("source_geometry_mismatch") + + return AssetQAReport(status="failed" if reasons else "passed", reasons=reasons) + + +def repair_failed_assets( + *, + source_image_path: str | Path, + failed_assets: list[BitmapAssetSpec], + failure_reasons: dict[str, list[str]], + output_dir: str | Path, + asset_root: str | Path, + edit_provider: ImageEditProvider, + max_attempts: int, + timeout_seconds: int = 180, +) -> tuple[list[BitmapAssetSpec], list[RepairAttempt]]: + repaired: list[BitmapAssetSpec] = [] + attempts: list[RepairAttempt] = [] + output_root = _resolve_inside_root(output_dir, asset_root, "output_dir") + output_root.mkdir(parents=True, exist_ok=True) + source_ref = _artifact_ref(source_image_path, asset_root) + for asset in failed_assets: + reasons = failure_reasons.get(asset.asset_id, ["asset_quality_failed"]) + reason_text = ",".join(reasons) + if max_attempts <= 0: + attempts.append( + RepairAttempt( + target_id=asset.asset_id, + attempt_index=1, + reason=f"repair_limit_exceeded:{reason_text}", + provider_role=edit_provider.config.role, + status="failed", + provenance={}, + ) + ) + continue + for attempt_index in range(1, max_attempts + 1): + reason_text = ",".join(reasons) + output_path = output_root / f"{asset.asset_id}.repair-{attempt_index}.png" + request = ImageEditRequest( + source_image_path=str(source_image_path), + prompt_id="repair_asset", + prompt=( + "Regenerate only the failed foreground asset from the source slide. " + f"Failure reasons: {reason_text}." + ), + output_asset_path=str(output_path), + asset_root=str(asset_root), + timeout_seconds=timeout_seconds, + metadata={ + "source_image_ref": source_ref, + "candidate_id": asset.asset_id, + "source_pixel_bbox": asset.source_pixel_bbox, + "failure_reasons": reasons, + "attempt_index": attempt_index, + }, + ) + result = edit_provider.edit(request) + repaired_ref = _artifact_ref(result.output_asset_path, asset_root) + qa = validate_sliced_asset( + asset_path=result.output_asset_path, + expected_object_count=1, + expected_text_free="baked_text_contamination" in reasons, + min_opaque_area_ratio=0.01, + source_image_path=source_image_path, + source_pixel_bbox=asset.source_pixel_bbox, + min_source_color_similarity=0.75, + ) + status = "passed" if qa.status == "passed" else "failed" + attempts.append( + RepairAttempt( + target_id=asset.asset_id, + attempt_index=attempt_index, + reason=reason_text if status == "passed" else ",".join(qa.reasons), + provider_role=result.provider_role, + status=status, + provenance={ + "prompt_id": "repair_asset", + "output_asset_ref": repaired_ref, + }, + ) + ) + if qa.status == "passed": + repaired_asset = replace( + asset, + asset_path=repaired_ref, + provenance={ + **asset.provenance, + "repair_attempt": attempt_index, + "failure_reasons": reasons, + }, + ) + repaired.append(repaired_asset) + break + else: + for index in range(len(attempts) - 1, -1, -1): + if attempts[index].target_id == asset.asset_id: + attempts[index] = replace( + attempts[index], + reason=f"repair_limit_exceeded:{attempts[index].reason}", + status="failed", + ) + break + return repaired, attempts + + +def _remove_chroma_key(crop: Image.Image, chroma_key: tuple[int, int, int]) -> Image.Image: + output = crop.copy() + pixels = output.load() + for y in range(output.height): + for x in range(output.width): + red, green, blue, alpha = pixels[x, y] + if abs(red - chroma_key[0]) <= 8 and abs(green - chroma_key[1]) <= 8 and abs(blue - chroma_key[2]) <= 8: + pixels[x, y] = (red, green, blue, 0) + else: + pixels[x, y] = (red, green, blue, alpha) + return output + + +def _sheet_with_inferred_alpha(sheet: Image.Image) -> Image.Image: + rgba = sheet.convert("RGBA") + alpha = rgba.getchannel("A") + if alpha.getextrema()[0] == 0: + return rgba + key = _dominant_border_color(rgba) + if key is not None: + keyed = _remove_chroma_key_with_tolerance(rgba, key, tolerance=36) + if _has_transparent_pixels(keyed): + return keyed + magenta_keyed = _remove_magenta_chroma_background(rgba) + if _has_transparent_pixels(magenta_keyed): + return magenta_keyed + checkerboard = _remove_light_neutral_border_background(rgba) + if _has_transparent_pixels(checkerboard): + return checkerboard + return rgba + + +def _has_transparent_pixels(image: Image.Image) -> bool: + return image.getchannel("A").getextrema()[0] == 0 + + +def _dominant_border_color(image: Image.Image) -> tuple[int, int, int] | None: + if image.width == 0 or image.height == 0: + return None + counts: dict[tuple[int, int, int], int] = {} + for x in range(image.width): + for y in (0, image.height - 1): + pixel = image.getpixel((x, y))[:3] + counts[pixel] = counts.get(pixel, 0) + 1 + for y in range(image.height): + for x in (0, image.width - 1): + pixel = image.getpixel((x, y))[:3] + counts[pixel] = counts.get(pixel, 0) + 1 + if not counts: + return None + color, count = max(counts.items(), key=lambda item: item[1]) + border_count = max(1, 2 * image.width + 2 * image.height) + if count / border_count < 0.25: + return None + return color + + +def _remove_chroma_key_with_tolerance( + image: Image.Image, + chroma_key: tuple[int, int, int], + *, + tolerance: int, +) -> Image.Image: + output = image.copy() + pixels = output.load() + red_key, green_key, blue_key = chroma_key + for y in range(output.height): + for x in range(output.width): + red, green, blue, alpha = pixels[x, y] + if ( + abs(red - red_key) <= tolerance + and abs(green - green_key) <= tolerance + and abs(blue - blue_key) <= tolerance + ): + pixels[x, y] = (red, green, blue, 0) + else: + pixels[x, y] = (red, green, blue, alpha) + return output + + +def _remove_light_neutral_border_background(image: Image.Image) -> Image.Image: + border_pixels = _border_rgb_pixels(image) + neutral_lumas = [ + _luma(pixel) + for pixel in border_pixels + if _is_light_neutral(pixel, min_luma=180, max_channel_delta=24) + ] + if len(neutral_lumas) < max(16, len(border_pixels) // 5): + return image + min_luma = max(180, min(neutral_lumas) - 18) + max_luma = min(255, max(neutral_lumas) + 18) + output = image.copy() + pixels = output.load() + for y in range(output.height): + for x in range(output.width): + red, green, blue, alpha = pixels[x, y] + rgb = (red, green, blue) + if ( + min_luma <= _luma(rgb) <= max_luma + and _is_light_neutral(rgb, min_luma=min_luma, max_channel_delta=30) + ): + pixels[x, y] = (red, green, blue, 0) + else: + pixels[x, y] = (red, green, blue, alpha) + return output + + +def _remove_magenta_chroma_background(image: Image.Image) -> Image.Image: + border_pixels = _border_rgb_pixels(image) + magenta_border_count = sum(1 for pixel in border_pixels if _is_magenta_chroma(pixel)) + if magenta_border_count < max(16, len(border_pixels) // 5): + return image + output = image.copy() + pixels = output.load() + for y in range(output.height): + for x in range(output.width): + red, green, blue, alpha = pixels[x, y] + if _is_magenta_chroma((red, green, blue)): + pixels[x, y] = (red, green, blue, 0) + else: + pixels[x, y] = (red, green, blue, alpha) + return output + + +def _border_rgb_pixels(image: Image.Image) -> list[tuple[int, int, int]]: + if image.width == 0 or image.height == 0: + return [] + pixels: list[tuple[int, int, int]] = [] + for x in range(image.width): + pixels.append(image.getpixel((x, 0))[:3]) + pixels.append(image.getpixel((x, image.height - 1))[:3]) + for y in range(1, max(1, image.height - 1)): + pixels.append(image.getpixel((0, y))[:3]) + pixels.append(image.getpixel((image.width - 1, y))[:3]) + return pixels + + +def _is_light_neutral( + pixel: tuple[int, int, int], + *, + min_luma: int, + max_channel_delta: int, +) -> bool: + red, green, blue = pixel + return ( + _luma(pixel) >= min_luma + and max(red, green, blue) - min(red, green, blue) <= max_channel_delta + ) + + +def _is_magenta_chroma(pixel: tuple[int, int, int]) -> bool: + red, green, blue = pixel + return ( + red >= 170 + and blue >= 160 + and green <= 130 + and red - green >= 70 + and blue - green >= 60 + ) + + +def _luma(pixel: tuple[int, int, int]) -> int: + red, green, blue = pixel + return int(round((red * 0.299) + (green * 0.587) + (blue * 0.114))) + + +def _component_boxes_from_alpha( + image: Image.Image, + *, + min_area: int, + merge_gap: int, + merge_union_growth: float, +) -> list[tuple[int, int, int, int]]: + components = _connected_components(_opaque_pixels(image)) + boxes = [ + _box_from_points(component) + for component in components + if len(component) >= min_area + ] + return _merge_nearby_boxes(boxes, gap=merge_gap, max_union_growth=merge_union_growth) + + +def _box_from_points(points: set[tuple[int, int]]) -> tuple[int, int, int, int]: + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return min(xs), min(ys), max(xs) + 1, max(ys) + 1 + + +def _merge_nearby_boxes( + boxes: list[tuple[int, int, int, int]], + *, + gap: int, + max_union_growth: float, +) -> list[tuple[int, int, int, int]]: + merged = list(boxes) + changed = True + while changed: + changed = False + for left_index in range(len(merged)): + if changed: + break + for right_index in range(left_index + 1, len(merged)): + left = merged[left_index] + right = merged[right_index] + if not _boxes_should_merge(left, right, gap=gap, max_union_growth=max_union_growth): + continue + merged[left_index] = _union_box(left, right) + del merged[right_index] + changed = True + break + return merged + + +def _boxes_should_merge( + left: tuple[int, int, int, int], + right: tuple[int, int, int, int], + *, + gap: int, + max_union_growth: float, +) -> bool: + if _box_distance(left, right) > gap: + return False + union = _union_box(left, right) + union_area = _box_area(union) + source_area = max(_box_area(left), _box_area(right), 1) + return union_area / source_area <= max_union_growth + + +def _box_distance( + left: tuple[int, int, int, int], + right: tuple[int, int, int, int], +) -> int: + horizontal_gap = max(0, max(left[0], right[0]) - min(left[2], right[2])) + vertical_gap = max(0, max(left[1], right[1]) - min(left[3], right[3])) + return max(horizontal_gap, vertical_gap) + + +def _union_box( + left: tuple[int, int, int, int], + right: tuple[int, int, int, int], +) -> tuple[int, int, int, int]: + return ( + min(left[0], right[0]), + min(left[1], right[1]), + max(left[2], right[2]), + max(left[3], right[3]), + ) + + +def _union_all_boxes(boxes: list[tuple[int, int, int, int]]) -> tuple[int, int, int, int]: + if not boxes: + raise ValueError("boxes are required") + union = boxes[0] + for box in boxes[1:]: + union = _union_box(union, box) + return union + + +def _candidate_allows_multipart(candidate: ForegroundCandidate) -> bool: + provenance = candidate.provenance or {} + return bool(provenance.get("merged_candidate_ids") or provenance.get("allow_multipart")) + + +def _box_area(box: tuple[int, int, int, int]) -> int: + return max(0, box[2] - box[0]) * max(0, box[3] - box[1]) + + +def _largest_boxes( + boxes: list[tuple[int, int, int, int]], + count: int, +) -> list[tuple[int, int, int, int]]: + return sorted(boxes, key=_box_area, reverse=True)[:count] + + +def _reading_order_boxes( + boxes: list[tuple[int, int, int, int]], +) -> list[tuple[int, int, int, int]]: + if not boxes: + return [] + median_height = sorted(box[3] - box[1] for box in boxes)[len(boxes) // 2] + row_threshold = max(12.0, median_height * 0.75) + rows: list[list[tuple[int, int, int, int]]] = [] + row_centers: list[float] = [] + for box in sorted(boxes, key=lambda item: _box_center_y(item)): + center_y = _box_center_y(box) + for row_index, row_center in enumerate(row_centers): + if abs(center_y - row_center) <= row_threshold: + rows[row_index].append(box) + row_centers[row_index] = sum(_box_center_y(item) for item in rows[row_index]) / len(rows[row_index]) + break + else: + rows.append([box]) + row_centers.append(center_y) + ordered: list[tuple[int, int, int, int]] = [] + for _center, row in sorted(zip(row_centers, rows, strict=True), key=lambda item: item[0]): + ordered.extend(sorted(row, key=lambda box: box[0])) + return ordered + + +def _box_center_y(box: tuple[int, int, int, int]) -> float: + return (box[1] + box[3]) / 2.0 + + +def _pad_box( + box: tuple[int, int, int, int], + size: tuple[int, int], + *, + padding: int, +) -> tuple[int, int, int, int]: + left, top, right, bottom = box + width, height = size + return ( + max(0, left - padding), + max(0, top - padding), + min(width, right + padding), + min(height, bottom + padding), + ) + + +def _opaque_pixels(image: Image.Image) -> set[tuple[int, int]]: + alpha = image.getchannel("A") + pixels: set[tuple[int, int]] = set() + for y in range(image.height): + for x in range(image.width): + if alpha.getpixel((x, y)) > 0: + pixels.add((x, y)) + return pixels + + +def _connected_components(pixels: set[tuple[int, int]]) -> list[set[tuple[int, int]]]: + remaining = set(pixels) + components: list[set[tuple[int, int]]] = [] + while remaining: + seed = remaining.pop() + stack = [seed] + component = {seed} + while stack: + x, y = stack.pop() + for neighbor in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if neighbor in remaining: + remaining.remove(neighbor) + stack.append(neighbor) + component.add(neighbor) + components.append(component) + return components + + +def _looks_like_baked_text(image: Image.Image, opaque_pixels: set[tuple[int, int]]) -> bool: + if not opaque_pixels: + return False + xs = [point[0] for point in opaque_pixels] + ys = [point[1] for point in opaque_pixels] + width = max(xs) - min(xs) + 1 + height = max(ys) - min(ys) + 1 + area_ratio = len(opaque_pixels) / float(width * height) + dark_ratio = sum(1 for x, y in opaque_pixels if max(image.getpixel((x, y))[:3]) < 64) / float( + len(opaque_pixels) + ) + return dark_ratio > 0.85 and 0.45 <= area_ratio <= 1.0 and width > height + + +def _source_color_similarity( + *, + asset_rgba: Image.Image, + opaque_pixels: set[tuple[int, int]], + source_image_path: str | Path, + source_pixel_bbox: tuple[int, int, int, int], +) -> float | None: + asset_color = _mean_color([asset_rgba.getpixel((x, y))[:3] for x, y in opaque_pixels]) + if asset_color is None: + return None + with Image.open(source_image_path) as source: + rgb = source.convert("RGB") + _validate_bbox(source_pixel_bbox, rgb.size, "source_pixel_bbox") + crop = rgb.crop(source_pixel_bbox) + source_pixels = _foreground_source_pixels(crop) + source_color = _mean_color(source_pixels) + if source_color is None: + return None + distance = math.sqrt(sum((asset_color[index] - source_color[index]) ** 2 for index in range(3))) + return max(0.0, 1.0 - distance / math.sqrt(3 * 255**2)) + + +def _source_geometry_mismatch( + *, + asset_rgba: Image.Image, + opaque_pixels: set[tuple[int, int]], + source_image_path: str | Path, + source_pixel_bbox: tuple[int, int, int, int], + max_aspect_ratio_delta: float = 2.4, +) -> bool: + asset_bbox = _box_from_points(opaque_pixels) + asset_aspect = _box_aspect_ratio(asset_bbox) + with Image.open(source_image_path) as source: + rgb = source.convert("RGB") + _validate_bbox(source_pixel_bbox, rgb.size, "source_pixel_bbox") + crop = rgb.crop(source_pixel_bbox) + source_bbox = _foreground_source_bbox(crop) + if source_bbox is None: + source_bbox = (0, 0, crop.width, crop.height) + source_aspect = _box_aspect_ratio(source_bbox) + if source_aspect <= 0 or asset_aspect <= 0: + return False + ratio = max(asset_aspect / source_aspect, source_aspect / asset_aspect) + return ratio > max_aspect_ratio_delta + + +def _box_aspect_ratio(box: tuple[int, int, int, int]) -> float: + width = max(1, box[2] - box[0]) + height = max(1, box[3] - box[1]) + return width / float(height) + + +def _foreground_source_pixels(crop: Image.Image) -> list[tuple[int, int, int]]: + foreground = _foreground_source_points(crop) + return [crop.getpixel(point) for point in foreground] + + +def _foreground_source_bbox(crop: Image.Image) -> tuple[int, int, int, int] | None: + foreground = _foreground_source_points(crop) + if not foreground: + return None + xs = [point[0] for point in foreground] + ys = [point[1] for point in foreground] + return min(xs), min(ys), max(xs) + 1, max(ys) + 1 + + +def _foreground_source_points(crop: Image.Image) -> list[tuple[int, int]]: + if crop.width == 0 or crop.height == 0: + return [] + corners = [ + crop.getpixel((0, 0)), + crop.getpixel((crop.width - 1, 0)), + crop.getpixel((0, crop.height - 1)), + crop.getpixel((crop.width - 1, crop.height - 1)), + ] + background = _mean_color(corners) + if background is None: + return [] + points: list[tuple[int, int]] = [] + for y in range(crop.height): + for x in range(crop.width): + pixel = crop.getpixel((x, y)) + distance = math.sqrt(sum((pixel[index] - background[index]) ** 2 for index in range(3))) + if distance > 24: + points.append((x, y)) + return points + + +def _mean_color(pixels) -> tuple[float, float, float] | None: + values = list(pixels) + if not values: + return None + count = float(len(values)) + return ( + sum(pixel[0] for pixel in values) / count, + sum(pixel[1] for pixel in values) / count, + sum(pixel[2] for pixel in values) / count, + ) + + +def _artifact_ref(path: str | Path, asset_root: str | Path) -> str: + item = Path(path) + root = Path(asset_root) + try: + return item.resolve().relative_to(root.resolve()).as_posix() + except ValueError as exc: + raise ValueError("artifact path must be inside asset_root") from exc + + +def _resolve_inside_root(path: str | Path, asset_root: str | Path, field_name: str) -> Path: + root = Path(asset_root).resolve() + value = Path(path) + if not value.is_absolute(): + value = root / value + resolved = value.resolve() + if not resolved.is_relative_to(root): + raise ValueError(f"{field_name} must be inside asset_root") + return resolved + + +def _validate_bbox(bbox: tuple[int, int, int, int], size: tuple[int, int], name: str) -> None: + left, top, right, bottom = bbox + width, height = size + if min(bbox) < 0 or right <= left or bottom <= top or right > width or bottom > height: + raise ValueError(f"{name} must be inside image bounds") + + +def _validate_filename(filename: str) -> None: + if Path(filename).name != filename or filename in {"", ".", ".."}: + raise ValueError("output_filename must be a plain filename") + + +def _safe_name(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value)).strip("-._") + return safe or "asset" diff --git a/src/generative_editable_backgrounds.py b/src/generative_editable_backgrounds.py new file mode 100644 index 0000000..ccb7478 --- /dev/null +++ b/src/generative_editable_backgrounds.py @@ -0,0 +1,567 @@ +"""Background cleanup helpers for generative editable PPTX export.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +import shutil +from statistics import mean, pstdev +from typing import Literal + +from PIL import Image, ImageDraw + +from .generative_editable_manifest import PageManifest, sanitize_persisted_payload +from .generative_editable_providers import ImageEditProvider, ImageEditRequest, ProviderError + +BackgroundStrategy = Literal["local_fill", "local_inpaint", "image_edit"] +MAX_LOCAL_FILL_AREA_RATIO = 0.12 +MAX_LOCAL_INPAINT_AREA_RATIO = 0.03 +MIN_BORDER_SAMPLE_PIXELS = 8 +LOCAL_FILL_BORDER_STDDEV_THRESHOLD = 1.5 + + +@dataclass(frozen=True) +class BackgroundResult: + output_asset_path: str + artifact_path: str + strategy: BackgroundStrategy + provider_role: str + prompt_id: str + input_asset_refs: list[str] + validation_status: str + provenance: dict + + +def local_cleanup_text_mask( + source_image_path: str | Path, + text_mask_path: str | Path, + output_asset_path: str | Path, + *, + asset_root: str | Path | None = None, + flat_stddev_threshold: float = 6.0, + max_local_inpaint_area_ratio: float = MAX_LOCAL_INPAINT_AREA_RATIO, + max_local_fill_area_ratio: float = MAX_LOCAL_FILL_AREA_RATIO, +) -> BackgroundResult: + source_path = Path(source_image_path) + mask_path = Path(text_mask_path) + output_path = Path(output_asset_path) + artifact_root = Path(asset_root).resolve() if asset_root is not None else _infer_artifact_root(output_path) + _validate_background_paths( + source_image_path=source_path, + output_asset_path=output_path, + asset_root=artifact_root, + text_mask_path=mask_path, + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with Image.open(source_path) as source, Image.open(mask_path) as mask: + rgb = source.convert("RGB") + mask_l = mask.convert("L") + bbox = mask_l.getbbox() + if bbox is None: + rgb.save(output_path) + return BackgroundResult( + output_asset_path=str(output_path), + artifact_path=_artifact_ref(output_path, artifact_root), + strategy="local_fill", + provider_role="local", + prompt_id="local_text_cleanup", + input_asset_refs=[str(source_path)], + validation_status="passed", + provenance={"decision": "empty text mask copied source"}, + ) + + border_pixels = _sample_border_pixels(rgb, bbox) + if len(border_pixels) < MIN_BORDER_SAMPLE_PIXELS: + raise ValueError("text mask requires image edit cleanup") + stddev = _rgb_stddev(border_pixels) + mask_pixels = _count_mask_pixels(mask_l) + area_ratio = mask_pixels / float(mask_l.width * mask_l.height) + if ( + stddev <= min(flat_stddev_threshold, LOCAL_FILL_BORDER_STDDEV_THRESHOLD) + and area_ratio <= max_local_fill_area_ratio + and len(border_pixels) >= MIN_BORDER_SAMPLE_PIXELS + ): + cleaned = _fill_mask_with_color(rgb, mask_l, _mean_rgb(border_pixels)) + strategy: BackgroundStrategy = "local_fill" + decision = "local cleanup flat fill" + else: + if area_ratio > max_local_inpaint_area_ratio: + raise ValueError("text mask requires image edit cleanup") + cleaned = _local_inpaint(rgb, mask_l) + strategy = "local_inpaint" + decision = "local cleanup simple inpaint" + + cleaned.save(output_path) + + return BackgroundResult( + output_asset_path=str(output_path), + artifact_path=_artifact_ref(output_path, artifact_root), + strategy=strategy, + provider_role="local", + prompt_id="local_text_cleanup", + input_asset_refs=[str(source_path), str(mask_path)], + validation_status="passed", + provenance={ + "decision": decision, + "mask_bbox": bbox, + "border_stddev": round(stddev, 4), + }, + ) + + +def create_text_clean_background( + *, + source_image_path: str | Path, + text_mask_path: str | Path, + output_asset_path: str | Path, + asset_root: str | Path, + edit_provider: ImageEditProvider, + timeout_seconds: int = 180, +) -> BackgroundResult: + _validate_background_paths( + source_image_path=source_image_path, + output_asset_path=output_asset_path, + asset_root=asset_root, + text_mask_path=text_mask_path, + ) + try: + local_result = local_cleanup_text_mask( + source_image_path, + text_mask_path, + output_asset_path, + asset_root=asset_root, + ) + except ValueError: + local_result = None + if local_result is not None: + return _with_job_relative_refs(local_result, asset_root) + + source_ref = _artifact_ref(source_image_path, asset_root) + mask_ref = _artifact_ref(text_mask_path, asset_root) + request = ImageEditRequest( + source_image_path=str(source_image_path), + prompt_id="text_clean_background", + prompt=( + "Return a text-free background layer inside the mask while preserving all " + "non-text visuals, background texture, decorations, and layout geometry." + ), + output_asset_path=str(output_asset_path), + asset_root=str(asset_root), + mask_path=str(text_mask_path), + timeout_seconds=timeout_seconds, + metadata={ + "source_image_ref": source_ref, + "text_mask_ref": mask_ref, + "stage": "text_clean_background", + }, + ) + try: + edit_result = edit_provider.edit(request) + except ProviderError: + raise + normalization_provenance = _normalize_background_to_source_size( + edit_result.output_asset_path, + source_image_path, + ) + return BackgroundResult( + output_asset_path=edit_result.output_asset_path, + artifact_path=_artifact_ref(output_asset_path, asset_root), + strategy="image_edit", + provider_role=edit_result.provider_role, + prompt_id=edit_result.prompt_id, + input_asset_refs=[source_ref, mask_ref], + validation_status="passed", + provenance=sanitize_persisted_payload( + { + "decision": "image edit cleanup after local cleanup was insufficient", + "provider": edit_result.provider_name, + "model": edit_result.model, + "prompt_id": edit_result.prompt_id, + "source_image_ref": source_ref, + "text_mask_ref": mask_ref, + **normalization_provenance, + } + ), + ) + + +def create_source_preserving_text_background( + *, + source_image_path: str | Path, + text_bboxes: list[tuple[int, int, int, int]], + output_asset_path: str | Path, + asset_root: str | Path, +) -> BackgroundResult: + source_path = Path(source_image_path) + output_path = Path(output_asset_path) + artifact_root = Path(asset_root) + _validate_background_paths( + source_image_path=source_path, + output_asset_path=output_path, + asset_root=artifact_root, + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source_path) as source: + cleaned = source.convert("RGB") + for bbox in text_bboxes: + _fill_bbox_from_border(cleaned, bbox) + cleaned.save(output_path) + return BackgroundResult( + output_asset_path=str(output_path), + artifact_path=_artifact_ref(output_path, artifact_root), + strategy="local_fill", + provider_role="local", + prompt_id="source_preserving_text_background", + input_asset_refs=[_artifact_ref(source_path, artifact_root)], + validation_status="passed", + provenance={ + "decision": "source preserving local text cleanup", + "text_bbox_count": len(text_bboxes), + }, + ) + + +def create_source_raster_background( + *, + source_image_path: str | Path, + output_asset_path: str | Path, + asset_root: str | Path, +) -> BackgroundResult: + source_path = Path(source_image_path) + output_path = Path(output_asset_path) + artifact_root = Path(asset_root) + _validate_background_paths( + source_image_path=source_path, + output_asset_path=output_path, + asset_root=artifact_root, + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source_path, output_path) + return BackgroundResult( + output_asset_path=str(output_path), + artifact_path=_artifact_ref(output_path, artifact_root), + strategy="local_fill", + provider_role="local", + prompt_id="source_raster_background", + input_asset_refs=[_artifact_ref(source_path, artifact_root)], + validation_status="passed", + provenance={ + "decision": "source raster guardrail preserved original slide", + }, + ) + + +def _fill_bbox_from_border(image: Image.Image, bbox: tuple[int, int, int, int]) -> None: + left, top, right, bottom = _clamp_bbox(bbox, image.size) + if right <= left or bottom <= top: + return + sample_bbox = ( + max(0, left - 3), + max(0, top - 3), + min(image.width, right + 3), + min(image.height, bottom + 3), + ) + pixels = [] + source = image.load() + for y in range(sample_bbox[1], sample_bbox[3]): + for x in range(sample_bbox[0], sample_bbox[2]): + if left <= x < right and top <= y < bottom: + continue + pixels.append(source[x, y]) + color = _mean_rgb(pixels) if pixels else source[left, top] + draw = ImageDraw.Draw(image) + draw.rectangle((left, top, right, bottom), fill=color) + + +def _clamp_bbox( + bbox: tuple[int, int, int, int], + size: tuple[int, int], +) -> tuple[int, int, int, int]: + width, height = size + return ( + max(0, min(width, int(bbox[0]))), + max(0, min(height, int(bbox[1]))), + max(0, min(width, int(bbox[2]))), + max(0, min(height, int(bbox[3]))), + ) + + +def create_base_clean_background( + *, + source_image_path: str | Path, + output_asset_path: str | Path, + asset_root: str | Path, + edit_provider: ImageEditProvider, + text_mask_path: str | Path | None = None, + removal_bboxes: list[tuple[int, int, int, int]] | None = None, + timeout_seconds: int = 180, +) -> BackgroundResult: + _validate_background_paths( + source_image_path=source_image_path, + output_asset_path=output_asset_path, + asset_root=asset_root, + text_mask_path=text_mask_path, + ) + source_ref = _artifact_ref(source_image_path, asset_root) + metadata = { + "source_image_ref": source_ref, + "stage": "base_clean_background", + } + input_refs = [source_ref] + mask_path = None + if text_mask_path is not None: + mask_path = str(text_mask_path) + text_mask_ref = _artifact_ref(text_mask_path, asset_root) + metadata["text_mask_ref"] = text_mask_ref + input_refs.append(text_mask_ref) + removal_bboxes = list(removal_bboxes or []) + if removal_bboxes: + metadata["removal_bboxes"] = removal_bboxes + if mask_path is not None or removal_bboxes: + removal_instruction = ( + "Remove only the supplied mask regions and listed source-pixel boxes. " + "Preserve all other readable text, icons, flowchart labels, cards, " + "and visual objects unless they are inside those removal regions. " + ) + if removal_bboxes: + removal_instruction += "Removal boxes: " + ", ".join(str(bbox) for bbox in removal_bboxes) + ". " + else: + removal_instruction = "Omit movable foreground graphics and baked text from this background layer." + if mask_path is not None or removal_bboxes: + removal_instruction = " " + removal_instruction + + request = ImageEditRequest( + source_image_path=str(source_image_path), + prompt_id="base_clean_background", + prompt=( + "Return the slide background layer only. Preserve theme background, edge " + "decoration, ambient texture, and layout background. " + + removal_instruction + ), + output_asset_path=str(output_asset_path), + asset_root=str(asset_root), + mask_path=mask_path, + timeout_seconds=timeout_seconds, + metadata=metadata, + ) + edit_result = edit_provider.edit(request) + normalization_provenance = _normalize_background_to_source_size( + edit_result.output_asset_path, + source_image_path, + ) + return BackgroundResult( + output_asset_path=edit_result.output_asset_path, + artifact_path=_artifact_ref(output_asset_path, asset_root), + strategy="image_edit", + provider_role=edit_result.provider_role, + prompt_id=edit_result.prompt_id, + input_asset_refs=input_refs, + validation_status="passed", + provenance=sanitize_persisted_payload( + { + "decision": "image edit base cleanup", + "provider": edit_result.provider_name, + "model": edit_result.model, + "prompt_id": edit_result.prompt_id, + "source_image_ref": source_ref, + **({"text_mask_ref": input_refs[1]} if len(input_refs) > 1 else {}), + **normalization_provenance, + } + ), + ) + + +def update_page_manifest_backgrounds( + page_manifest: PageManifest, + text_clean_background: BackgroundResult, + base_clean_background: BackgroundResult, + *, + chosen_background: str | None = None, +) -> PageManifest: + chosen = chosen_background or base_clean_background.artifact_path + provenance = dict(page_manifest.provenance) + provenance["backgrounds"] = { + "text_clean": _manifest_background_record(text_clean_background), + "base_clean": _manifest_background_record(base_clean_background), + } + return replace( + page_manifest, + text_clean_background=text_clean_background.artifact_path, + base_clean_background=base_clean_background.artifact_path, + chosen_background=chosen, + provenance=provenance, + ) + + +def _manifest_background_record(result: BackgroundResult) -> dict: + return sanitize_persisted_payload( + { + "strategy": result.strategy, + "provider_role": result.provider_role, + "prompt_id": result.prompt_id, + "input_asset_refs": list(result.input_asset_refs), + "output_asset_ref": result.artifact_path, + "validation_status": result.validation_status, + "provenance": result.provenance, + } + ) + + +def _normalize_background_to_source_size( + output_asset_path: str | Path, + source_image_path: str | Path, +) -> dict: + output_path = Path(output_asset_path) + with Image.open(source_image_path) as source: + source_size = source.size + with Image.open(output_path) as output: + if output.size == source_size: + return {} + normalized = output.convert("RGB").resize(source_size) + normalized.save(output_path) + return {"normalized_to_source_size": list(source_size)} + + +def _sample_border_pixels(image: Image.Image, bbox: tuple[int, int, int, int]) -> list[tuple[int, int, int]]: + left, top, right, bottom = bbox + expanded = ( + max(0, left - 2), + max(0, top - 2), + min(image.width, right + 2), + min(image.height, bottom + 2), + ) + pixels: list[tuple[int, int, int]] = [] + for y in range(expanded[1], expanded[3]): + for x in range(expanded[0], expanded[2]): + if left <= x < right and top <= y < bottom: + continue + pixels.append(image.getpixel((x, y))) + return pixels + + +def _rgb_stddev(pixels: list[tuple[int, int, int]]) -> float: + channel_values = [[pixel[index] for pixel in pixels] for index in range(3)] + return mean(pstdev(values) for values in channel_values) + + +def _mean_rgb(pixels: list[tuple[int, int, int]]) -> tuple[int, int, int]: + return tuple(round(mean(pixel[index] for pixel in pixels)) for index in range(3)) + + +def _fill_mask_with_color( + image: Image.Image, mask: Image.Image, color: tuple[int, int, int] +) -> Image.Image: + fill = Image.new("RGB", image.size, color) + cleaned = image.copy() + cleaned.paste(fill, mask=mask) + return cleaned + + +def _local_inpaint(image: Image.Image, mask: Image.Image) -> Image.Image: + cleaned = image.copy() + source = image.load() + target = cleaned.load() + mask_pixels = mask.load() + for y in range(image.height): + for x in range(image.width): + if mask_pixels[x, y] <= 0: + continue + target[x, y] = _nearest_unmasked_average(source, mask_pixels, x, y, image.size) + return cleaned + + +def _nearest_unmasked_average( + source, + mask, + x: int, + y: int, + size: tuple[int, int], + *, + max_radius: int = 8, +) -> tuple[int, int, int]: + width, height = size + for radius in range(1, max_radius + 1): + pixels = [] + for yy in range(max(0, y - radius), min(height, y + radius + 1)): + for xx in range(max(0, x - radius), min(width, x + radius + 1)): + if mask[xx, yy] == 0: + pixels.append(source[xx, yy]) + if pixels: + return _mean_rgb(pixels) + return source[x, y] + + +def _count_mask_pixels(mask: Image.Image) -> int: + return sum(mask.histogram()[1:]) + + +def _with_job_relative_refs(result: BackgroundResult, asset_root: str | Path) -> BackgroundResult: + return BackgroundResult( + output_asset_path=result.output_asset_path, + artifact_path=_artifact_ref(result.output_asset_path, asset_root), + strategy=result.strategy, + provider_role=result.provider_role, + prompt_id=result.prompt_id, + input_asset_refs=[_artifact_ref(path, asset_root) for path in result.input_asset_refs], + validation_status=result.validation_status, + provenance=result.provenance, + ) + + +def _artifact_ref(path: str | Path, asset_root: str | Path) -> str: + item = Path(path) + root = Path(asset_root) + try: + return item.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + return item.as_posix() + + +def _infer_artifact_root(path: Path) -> Path: + resolved = path.resolve() + for category in ("backgrounds", "assets", "asset_sheets", "sources", "previews"): + if category in resolved.parts: + index = resolved.parts.index(category) + if index > 0: + return Path(*resolved.parts[:index]) + return resolved.parent + + +def _validate_background_paths( + *, + source_image_path: str | Path, + output_asset_path: str | Path, + asset_root: str | Path, + text_mask_path: str | Path | None = None, +) -> None: + source = _resolve_asset_root_path(source_image_path, asset_root, "source_image_path") + output = _resolve_asset_root_path(output_asset_path, asset_root, "output_asset_path") + if source == output: + raise ValueError("output_asset_path must not overwrite source_image_path") + if text_mask_path is not None: + _resolve_asset_root_path(text_mask_path, asset_root, "text_mask_path") + + +def _resolve_asset_root_path(path: str | Path, asset_root: str | Path, field_name: str) -> Path: + root = Path(asset_root).resolve() + value = Path(path) + if not value.is_absolute(): + value = root / value + resolved = value.resolve() + if not resolved.is_relative_to(root): + raise ValueError(f"{field_name} must be inside asset_root") + return resolved + + +def _masked_region_is_flat(image: Image.Image, mask: Image.Image, threshold: float) -> bool: + pixels = [] + image_pixels = image.load() + mask_pixels = mask.load() + for y in range(mask.height): + for x in range(mask.width): + if mask_pixels[x, y] > 0: + pixels.append(image_pixels[x, y]) + if not pixels: + return True + return _rgb_stddev(pixels) <= threshold diff --git a/src/generative_editable_composer.py b/src/generative_editable_composer.py new file mode 100644 index 0000000..db68e12 --- /dev/null +++ b/src/generative_editable_composer.py @@ -0,0 +1,370 @@ +"""Deterministic PPTX composition from generative editable manifests.""" + +from __future__ import annotations + +from dataclasses import dataclass +import tempfile +import zipfile +from pathlib import Path + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.oxml.ns import qn +from pptx.oxml.xmlchemy import OxmlElement +from pptx.util import Inches, Pt + +from .generative_editable_manifest import ( + DeckManifest, + NativeShapeSpec, + PageManifest, + TextBoxSpec, + read_deck_manifest, + read_page_manifest, +) + + +@dataclass(frozen=True) +class SlideRect: + left: int + top: int + width: int + height: int + + +class CompositionArtifactError(RuntimeError): + pass + + +class CompositionGeometryError(RuntimeError): + pass + + +def slide_rect_from_source_pixels( + bbox: tuple[int, int, int, int], + *, + source_image_size: tuple[int, int], + aspect_ratio: str, +) -> SlideRect: + slide_width, slide_height = slide_dimensions(aspect_ratio) + source_width, source_height = source_image_size + left, top, right, bottom = bbox + return SlideRect( + left=round(left / source_width * slide_width), + top=round(top / source_height * slide_height), + width=round((right - left) / source_width * slide_width), + height=round((bottom - top) / source_height * slide_height), + ) + + +def slide_dimensions(aspect_ratio: str) -> tuple[int, int]: + if aspect_ratio == "4:3": + return Inches(10), Inches(7.5) + return Inches(10), Inches(5.625) + + +def compose_deck_from_manifests( + deck_manifest_path: str | Path, + artifact_root: str | Path, + output_path: str | Path, +) -> None: + root = Path(artifact_root) + deck = read_deck_manifest(deck_manifest_path) + prs = Presentation() + width, height = slide_dimensions(deck.aspect_ratio) + prs.slide_width = width + prs.slide_height = height + blank_layout = prs.slide_layouts[6] + while len(prs.slides) > 0: + r_id = prs.slides._sldIdLst[0].rId + prs.part.drop_rel(r_id) + del prs.slides._sldIdLst[0] + + for page_manifest_ref in deck.page_manifest_paths: + page = read_page_manifest(root / page_manifest_ref) + _preflight_page_artifacts(page, root) + _validate_page_geometry(page, deck.aspect_ratio) + slide = prs.slides.add_slide(blank_layout) + _compose_page(slide, prs, page, deck.aspect_ratio, root) + + prs.save(output_path) + _normalize_pptx_zip(output_path) + + +def _compose_page(slide, prs: Presentation, page: PageManifest, aspect_ratio: str, root: Path) -> None: + if page.chosen_background: + _set_slide_picture_background(slide, _artifact_path(root, page.chosen_background)) + for shape in page.native_shapes: + _add_native_shape(slide, shape, page, aspect_ratio) + for asset in sorted(page.bitmap_assets, key=lambda item: item.z_order): + rect = slide_rect_from_source_pixels( + asset.source_pixel_bbox, + source_image_size=page.source_image_size, + aspect_ratio=aspect_ratio, + ) + slide.shapes.add_picture( + str(_artifact_path(root, asset.asset_path)), + rect.left, + rect.top, + width=rect.width, + height=rect.height, + ) + for text_box in page.text_boxes: + _add_text_box(slide, text_box, page, aspect_ratio) + + +def _set_slide_picture_background(slide, image_path: Path) -> None: + image_part = slide.part._package.get_or_add_image_part(str(image_path)) + relationship_id = slide.part.relate_to(image_part, RT.IMAGE) + common_slide_data = slide._element.cSld + existing_background = common_slide_data.find(qn("p:bg")) + if existing_background is not None: + common_slide_data.remove(existing_background) + + background = OxmlElement("p:bg") + background_properties = OxmlElement("p:bgPr") + blip_fill = OxmlElement("a:blipFill") + blip = OxmlElement("a:blip") + blip.set(qn("r:embed"), relationship_id) + stretch = OxmlElement("a:stretch") + fill_rect = OxmlElement("a:fillRect") + stretch.append(fill_rect) + blip_fill.append(blip) + blip_fill.append(stretch) + background_properties.append(blip_fill) + background_properties.append(OxmlElement("a:effectLst")) + background.append(background_properties) + common_slide_data.insert(0, background) + + +def _add_native_shape(slide, shape: NativeShapeSpec, page: PageManifest, aspect_ratio: str) -> None: + rect = slide_rect_from_source_pixels( + shape.source_pixel_bbox, + source_image_size=page.source_image_size, + aspect_ratio=aspect_ratio, + ) + if shape.shape_type == "line": + start = shape.line_start or (shape.source_pixel_bbox[0], shape.source_pixel_bbox[1]) + end = shape.line_end or (shape.source_pixel_bbox[2], shape.source_pixel_bbox[3]) + start_rect = slide_rect_from_source_pixels( + (start[0], start[1], start[0] + 1, start[1] + 1), + source_image_size=page.source_image_size, + aspect_ratio=aspect_ratio, + ) + end_rect = slide_rect_from_source_pixels( + (end[0], end[1], end[0] + 1, end[1] + 1), + source_image_size=page.source_image_size, + aspect_ratio=aspect_ratio, + ) + line = slide.shapes.add_connector( + 1, + start_rect.left, + start_rect.top, + end_rect.left, + end_rect.top, + ) + if shape.line_color: + line.line.color.rgb = _rgb(shape.line_color) + _set_line_alpha(line, shape.opacity) + else: + line.line.fill.background() + if shape.stroke_width: + line.line.width = _stroke_width_to_emu(shape.stroke_width, page, aspect_ratio) + return + + ppt_shape = MSO_SHAPE.RECTANGLE + if shape.shape_type == "rounded_rectangle": + ppt_shape = MSO_SHAPE.ROUNDED_RECTANGLE + elif shape.shape_type == "ellipse": + ppt_shape = MSO_SHAPE.OVAL + native = slide.shapes.add_shape(ppt_shape, rect.left, rect.top, rect.width, rect.height) + if shape.fill_color: + native.fill.solid() + native.fill.fore_color.rgb = _rgb(shape.fill_color) + _set_fill_alpha(native, shape.opacity) + else: + native.fill.background() + if shape.line_color: + native.line.color.rgb = _rgb(shape.line_color) + _set_line_alpha(native, shape.opacity) + else: + native.line.fill.background() + if shape.stroke_width: + native.line.width = _stroke_width_to_emu(shape.stroke_width, page, aspect_ratio) + + +def _add_text_box(slide, text_box: TextBoxSpec, page: PageManifest, aspect_ratio: str) -> None: + rect = slide_rect_from_source_pixels( + text_box.source_pixel_bbox, + source_image_size=page.source_image_size, + aspect_ratio=aspect_ratio, + ) + rect = _expand_approximate_text_rect(rect, text_box, aspect_ratio) + box = slide.shapes.add_textbox(rect.left, rect.top, rect.width, rect.height) + frame = box.text_frame + frame.margin_left = 0 + frame.margin_right = 0 + frame.margin_top = 0 + frame.margin_bottom = 0 + frame.word_wrap = False + frame.clear() + paragraph = frame.paragraphs[0] + paragraph.alignment = _alignment(text_box.alignment) + run = paragraph.add_run() + run.text = text_box.text + if text_box.font_family: + run.font.name = text_box.font_family + if text_box.style_hints.get("bold") is True: + run.font.bold = True + if text_box.font_size: + run.font.size = Pt(text_box.font_size) + if text_box.color_hex: + run.font.color.rgb = _rgb(text_box.color_hex) + _set_text_alpha(run, text_box.opacity) + + +def _expand_approximate_text_rect( + rect: SlideRect, + text_box: TextBoxSpec, + aspect_ratio: str, +) -> SlideRect: + if not text_box.style_hints.get("approximate_layout"): + return rect + font_size = float(text_box.font_size or 18) + estimated_width = round(_text_visual_units(text_box.text) * Pt(font_size) * 3.2) + if estimated_width <= rect.width: + return rect + slide_width, _slide_height = slide_dimensions(aspect_ratio) + width = min(estimated_width, max(rect.width, slide_width - rect.left)) + return SlideRect(left=rect.left, top=rect.top, width=width, height=rect.height) + + +def _text_visual_units(text: str) -> float: + units = 0.0 + for char in text.strip(): + if char.isspace(): + continue + if "\u4e00" <= char <= "\u9fff": + units += 1.0 + elif char.isascii(): + units += 0.58 + else: + units += 0.8 + return max(1.0, units) + + +def _alignment(value: str): + return { + "center": PP_ALIGN.CENTER, + "right": PP_ALIGN.RIGHT, + "justify": PP_ALIGN.JUSTIFY, + }.get(value, PP_ALIGN.LEFT) + + +def _artifact_path(root: Path, ref: str) -> Path: + path = (root / ref).resolve() + if not path.is_relative_to(root.resolve()): + raise ValueError("artifact path must be inside artifact_root") + return path + + +def _preflight_page_artifacts(page: PageManifest, root: Path) -> None: + if page.chosen_background: + _require_artifact(root, page.chosen_background, "background", page.slide_id) + for asset in page.bitmap_assets: + _require_artifact(root, asset.asset_path, f"bitmap asset {asset.asset_id}", page.slide_id) + + +def _require_artifact(root: Path, ref: str, label: str, slide_id: str) -> None: + path = _artifact_path(root, ref) + if not path.exists(): + raise CompositionArtifactError(f"missing {label} for slide {slide_id}: {ref}") + + +def _validate_page_geometry(page: PageManifest, aspect_ratio: str) -> None: + expected_width, expected_height = _slide_size_inches(aspect_ratio) + actual_width, actual_height = page.slide_size + expected_ratio = expected_width / expected_height + slide_ratio = actual_width / actual_height + source_ratio = page.source_image_size[0] / page.source_image_size[1] + if not _ratio_close(slide_ratio, expected_ratio) or not _ratio_close(source_ratio, expected_ratio): + raise CompositionGeometryError( + f"page {page.slide_id} geometry does not match deck aspect ratio {aspect_ratio}" + ) + + +def _slide_size_inches(aspect_ratio: str) -> tuple[float, float]: + if aspect_ratio == "4:3": + return 10.0, 7.5 + return 10.0, 5.625 + + +def _ratio_close(actual: float, expected: float, tolerance: float = 0.01) -> bool: + return abs(actual - expected) <= tolerance + + +def _stroke_width_to_emu(stroke_width_px: float, page: PageManifest, aspect_ratio: str) -> int: + slide_width, slide_height = slide_dimensions(aspect_ratio) + source_width, source_height = page.source_image_size + px_to_emu = min(slide_width / source_width, slide_height / source_height) + return round(stroke_width_px * px_to_emu) + + +def _set_fill_alpha(shape, opacity: float) -> None: + _append_alpha(shape._element.spPr.find(qn("a:solidFill")), opacity) + + +def _set_line_alpha(shape, opacity: float) -> None: + line = shape._element.spPr.find(qn("a:ln")) + if line is not None: + _append_alpha(line.find(qn("a:solidFill")), opacity) + + +def _set_text_alpha(run, opacity: float) -> None: + if opacity >= 1: + return + r_pr = run._r.get_or_add_rPr() + _append_alpha(r_pr.find(qn("a:solidFill")), opacity) + + +def _append_alpha(solid_fill, opacity: float) -> None: + if solid_fill is None or opacity >= 1: + return + color = solid_fill.find(qn("a:srgbClr")) + if color is None: + color = solid_fill.find(qn("a:schemeClr")) + if color is None: + return + for existing in color.findall(qn("a:alpha")): + color.remove(existing) + alpha = OxmlElement("a:alpha") + alpha.set("val", str(round(opacity * 100000))) + color.append(alpha) + + +def _normalize_pptx_zip(path: str | Path) -> None: + pptx_path = Path(path) + fixed_date = (1980, 1, 1, 0, 0, 0) + with tempfile.NamedTemporaryFile(delete=False, suffix=".pptx") as tmp: + tmp_path = Path(tmp.name) + try: + with zipfile.ZipFile(pptx_path, "r") as source, zipfile.ZipFile( + tmp_path, "w", compression=zipfile.ZIP_DEFLATED + ) as target: + for name in sorted(source.namelist()): + info = zipfile.ZipInfo(name, fixed_date) + original = source.getinfo(name) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = original.external_attr + target.writestr(info, source.read(name)) + tmp_path.replace(pptx_path) + finally: + if tmp_path.exists(): + tmp_path.unlink() + + +def _rgb(value: str) -> RGBColor: + cleaned = value.lstrip("#") + return RGBColor(int(cleaned[0:2], 16), int(cleaned[2:4], 16), int(cleaned[4:6], 16)) diff --git a/src/generative_editable_config.py b/src/generative_editable_config.py new file mode 100644 index 0000000..6bb762a --- /dev/null +++ b/src/generative_editable_config.py @@ -0,0 +1,199 @@ +"""Configuration helpers for the generative editable PPTX export pipeline.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .config import get_config + + +class GenerativeEditableConfigError(ValueError): + """Raised when generative editable export configuration is incomplete.""" + + +@dataclass(frozen=True) +class ProviderConfig: + role: str + model: str + base_url: str + api_key: str + adapter: str = "" + provider: str = "" + + +@dataclass(frozen=True) +class QualityConfig: + max_repair_attempts: int = 2 + preview_similarity_threshold: float = 0.92 + require_preview_validation: bool = True + + +@dataclass(frozen=True) +class RetryConfig: + provider_max_attempts: int = 2 + repair_max_attempts: int = 2 + backoff_seconds: float = 1.0 + + +@dataclass(frozen=True) +class TimeoutConfig: + provider_call: int = 180 + page: int = 600 + + +@dataclass(frozen=True) +class GenerativeEditableConfig: + ocr: ProviderConfig + clean_base_model: ProviderConfig + asset_sheet_model: ProviderConfig + repair_model: ProviderConfig + generation_model: ProviderConfig + use_aippt_metadata_first: bool + ocr_min_confidence: float + quality: QualityConfig + retries: RetryConfig + timeouts: TimeoutConfig + reconstruction_mode: str = "generative" + + +def load_generative_editable_config(use_fake: bool = False) -> GenerativeEditableConfig: + """Load provider settings for generative editable PPTX export from config.yaml.""" + if use_fake: + return _fake_config() + + raw_config = get_config() + models = raw_config.get("api", {}).get("models", {}) + settings = raw_config.get("generative_editable_pptx", {}) + reconstruction = settings.get("reconstruction", {}) + ocr_settings = settings.get("ocr", {}) + reconstruction_mode = str(reconstruction.get("mode", "generative")).strip() or "generative" + if reconstruction_mode not in {"generative", "vlm_first"}: + raise GenerativeEditableConfigError( + "generative editable reconstruction.mode must be one of: generative, vlm_first" + ) + + ocr_role = _role_setting(ocr_settings, "model", "ocr_model") + clean_role = _role_setting(reconstruction, "clean_base_model", "edit_model") + asset_role = _role_setting(reconstruction, "asset_sheet_model", "edit_model") + repair_role = _role_setting(reconstruction, "repair_model", "edit_model") + generation_role = _role_setting(reconstruction, "generation_model", "image_model") + + required_roles = [ocr_role, clean_role, asset_role, repair_role, generation_role] + missing = sorted( + { + role + for role in required_roles + if not _is_complete_provider(_provider_source(role, models)) + } + ) + if missing: + raise GenerativeEditableConfigError( + "Missing generative editable provider configuration for: " + ", ".join(missing) + ) + + quality = settings.get("quality", {}) + retries = settings.get("retries", {}) + timeouts = settings.get("timeouts", {}) + return GenerativeEditableConfig( + ocr=_provider_config(ocr_role, _provider_source(ocr_role, models)), + clean_base_model=_provider_config(clean_role, _provider_source(clean_role, models)), + asset_sheet_model=_provider_config(asset_role, _provider_source(asset_role, models)), + repair_model=_provider_config(repair_role, _provider_source(repair_role, models)), + generation_model=_provider_config(generation_role, _provider_source(generation_role, models)), + use_aippt_metadata_first=_as_bool(ocr_settings.get("use_aippt_metadata_first", True)), + ocr_min_confidence=float(ocr_settings.get("min_confidence", 0.75)), + quality=QualityConfig( + max_repair_attempts=int(quality.get("max_repair_attempts", 2)), + preview_similarity_threshold=float(quality.get("preview_similarity_threshold", 0.92)), + require_preview_validation=_as_bool(quality.get("require_preview_validation", True)), + ), + retries=RetryConfig( + provider_max_attempts=int(retries.get("provider_max_attempts", 2)), + repair_max_attempts=int(retries.get("repair_max_attempts", 2)), + backoff_seconds=float(retries.get("backoff_seconds", 1.0)), + ), + timeouts=TimeoutConfig( + provider_call=int(timeouts.get("provider_call", 180)), + page=int(timeouts.get("page", 600)), + ), + reconstruction_mode=reconstruction_mode, + ) + + +def _role_setting(settings: dict[str, Any], key: str, default: str) -> str: + value = settings.get(key, default) + if not isinstance(value, str) or not value.strip(): + raise GenerativeEditableConfigError( + f"generative editable provider role must be a non-empty string: {key}" + ) + return value + + +def _provider_source(role: str, models: dict[str, Any]) -> Any: + if role == "edit_model" and role not in models: + return models.get("image_model") + return models.get(role) + + +def _is_complete_provider(value: Any) -> bool: + if not isinstance(value, dict): + return False + return bool(value.get("api_key") and value.get("base_url") and value.get("model")) + + +def _provider_config(role: str, value: dict[str, Any]) -> ProviderConfig: + adapter = "" if role == "ocr_model" else str(value.get("adapter", "")) + provider = "" if role == "ocr_model" else str(value.get("provider", "")) + return ProviderConfig( + role=role, + model=str(value.get("model", "")), + base_url=str(value.get("base_url", "")), + api_key=str(value.get("api_key", "")), + adapter=adapter, + provider=provider, + ) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off"}: + return False + return bool(value) + + +def _fake_config() -> GenerativeEditableConfig: + ocr = ProviderConfig( + role="ocr_model", provider="fake_ocr", model="fake-ocr", base_url="", api_key="" + ) + edit = ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + generation = ProviderConfig( + role="image_model", + provider="fake_image_generation", + model="fake-image-generation", + base_url="", + api_key="", + ) + return GenerativeEditableConfig( + ocr=ocr, + clean_base_model=edit, + asset_sheet_model=edit, + repair_model=edit, + generation_model=generation, + use_aippt_metadata_first=True, + ocr_min_confidence=0.75, + quality=QualityConfig(max_repair_attempts=0), + retries=RetryConfig(provider_max_attempts=0, repair_max_attempts=0, backoff_seconds=0), + timeouts=TimeoutConfig(provider_call=1, page=1), + ) diff --git a/src/generative_editable_foreground_planner.py b/src/generative_editable_foreground_planner.py new file mode 100644 index 0000000..36d9f1e --- /dev/null +++ b/src/generative_editable_foreground_planner.py @@ -0,0 +1,277 @@ +"""Foreground candidate planning for generative editable PPTX export.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import hashlib +from pathlib import Path +from typing import Literal + +from PIL import Image, ImageChops + +from .generative_editable_manifest import ForegroundCandidateSpec + +CandidateClassification = Literal[ + "native_shape_candidate", + "bitmap_asset_candidate", + "complex_whole_visual", + "duplicate", + "rejected_text_like_region", + "uncertain", +] +PixelBBox = tuple[int, int, int, int] + + +@dataclass(frozen=True) +class ForegroundCandidate: + candidate_id: str + source_pixel_bbox: PixelBBox + area: int + classification: CandidateClassification + confidence: float = 0.0 + component_key: str = "" + provenance: dict | None = None + + def __post_init__(self) -> None: + if self.provenance is None: + object.__setattr__(self, "provenance", {}) + left, top, right, bottom = self.source_pixel_bbox + if right <= left or bottom <= top: + raise ValueError("source_pixel_bbox must be ordered") + if min(self.source_pixel_bbox) < 0: + raise ValueError("source_pixel_bbox must be non-negative") + if self.area < 0: + raise ValueError("area must be non-negative") + + +def plan_foreground_candidates( + *, + source_image_path: str | Path, + base_clean_image_path: str | Path, + text_mask_path: str | Path | None = None, + diff_threshold: int = 16, + min_area: int = 16, +) -> list[ForegroundCandidate]: + with Image.open(source_image_path) as source, Image.open(base_clean_image_path) as base: + source_rgb = source.convert("RGB") + base_rgb = base.convert("RGB").resize(source_rgb.size) + difference = ImageChops.difference(source_rgb, base_rgb) + text_mask = _load_text_mask(text_mask_path, source_rgb.size) + diff_pixels = _difference_pixels(difference, text_mask, diff_threshold) + + components = _connected_components(diff_pixels) + candidates: list[ForegroundCandidate] = [] + for index, component in enumerate(components, start=1): + if len(component) < min_area: + continue + xs = [point[0] for point in component] + ys = [point[1] for point in component] + bbox = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) + candidates.append( + ForegroundCandidate( + candidate_id=f"fg-{index:03d}", + source_pixel_bbox=bbox, + area=len(component), + classification="uncertain", + confidence=0.0, + provenance={ + "detection": "source_base_difference", + "diff_threshold": diff_threshold, + }, + ) + ) + return candidates + + +def classify_foreground_candidates( + *, + source_image_path: str | Path, + candidate_boxes: list[PixelBBox], + rejected_text_regions: list[PixelBBox] | None = None, + min_area: int = 20, +) -> list[ForegroundCandidate]: + rejected_text_regions = rejected_text_regions or [] + with Image.open(source_image_path) as source: + source_rgb = source.convert("RGB") + candidates = [] + for index, bbox in enumerate(candidate_boxes, start=1): + _validate_bbox_in_image("candidate_boxes", bbox, source_rgb.size) + crop = source_rgb.crop(bbox) + classification, confidence, provenance = _classify_crop( + crop, + bbox, + source_rgb.size, + rejected_text_regions, + min_area, + ) + candidates.append( + ForegroundCandidate( + candidate_id=f"fg-{index:03d}", + source_pixel_bbox=bbox, + area=_bbox_area(bbox), + classification=classification, + confidence=confidence, + component_key=_component_key(crop), + provenance=provenance, + ) + ) + return candidates + + +def foreground_candidates_to_manifest_specs( + candidates: list[ForegroundCandidate], +) -> list[ForegroundCandidateSpec]: + return [ + ForegroundCandidateSpec( + candidate_id=candidate.candidate_id, + source_pixel_bbox=candidate.source_pixel_bbox, + area=candidate.area, + classification=candidate.classification, + confidence=candidate.confidence, + component_key=candidate.component_key, + provenance=_as_dict(candidate.provenance), + ) + for candidate in candidates + ] + + +def record_component_reuse(candidates: list[ForegroundCandidate]) -> list[ForegroundCandidate]: + first_by_key: dict[str, ForegroundCandidate] = {} + planned: list[ForegroundCandidate] = [] + for candidate in candidates: + key = candidate.component_key + if ( + key + and candidate.classification not in {"rejected_text_like_region", "uncertain"} + and key in first_by_key + ): + provenance = _as_dict(candidate.provenance) + provenance["reuses_candidate_id"] = first_by_key[key].candidate_id + planned.append( + replace( + candidate, + classification="duplicate", + provenance=provenance, + ) + ) + continue + if key and candidate.classification not in {"rejected_text_like_region", "uncertain"}: + first_by_key[key] = candidate + planned.append(candidate) + return planned + + +def _load_text_mask(path: str | Path | None, size: tuple[int, int]) -> Image.Image | None: + if path is None: + return None + with Image.open(path) as mask: + return mask.convert("L").resize(size) + + +def _difference_pixels( + difference: Image.Image, + text_mask: Image.Image | None, + threshold: int, +) -> set[tuple[int, int]]: + pixels: set[tuple[int, int]] = set() + for y in range(difference.height): + for x in range(difference.width): + if text_mask is not None and text_mask.getpixel((x, y)) > 0: + continue + if max(difference.getpixel((x, y))) > threshold: + pixels.add((x, y)) + return pixels + + +def _connected_components(pixels: set[tuple[int, int]]) -> list[list[tuple[int, int]]]: + remaining = set(pixels) + components: list[list[tuple[int, int]]] = [] + while remaining: + seed = remaining.pop() + stack = [seed] + component = [seed] + while stack: + x, y = stack.pop() + for neighbor in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if neighbor in remaining: + remaining.remove(neighbor) + stack.append(neighbor) + component.append(neighbor) + components.append(component) + components.sort(key=lambda item: (min(point[1] for point in item), min(point[0] for point in item))) + return components + + +def _classify_crop( + crop: Image.Image, + bbox: PixelBBox, + image_size: tuple[int, int], + rejected_text_regions: list[PixelBBox], + min_area: int, +) -> tuple[CandidateClassification, float, dict]: + if any(_intersects(bbox, region) for region in rejected_text_regions): + return "rejected_text_like_region", 1.0, {"reason": "overlaps_rejected_text_region"} + + area = _bbox_area(bbox) + if area < min_area: + return "uncertain", 0.2, {"reason": "below_min_area"} + + width = bbox[2] - bbox[0] + height = bbox[3] - bbox[1] + unique_colors = crop.getcolors(maxcolors=crop.width * crop.height + 1) or [] + fill_ratio = _non_white_ratio(crop) + image_area_ratio = area / float(image_size[0] * image_size[1]) + + if len(unique_colors) > 24 or image_area_ratio > 0.20: + return ( + "complex_whole_visual", + 0.86, + { + "reason": "large_or_visually_complex", + "unique_color_count": len(unique_colors), + }, + ) + + if (width / max(height, 1) >= 8 or height / max(width, 1) >= 8) and min(width, height) <= 5: + return "native_shape_candidate", 0.93, {"shape_hint": "line"} + + if len(unique_colors) <= 2 and fill_ratio >= 0.92: + return "native_shape_candidate", 0.94, {"shape_hint": "rectangle"} + + if len(unique_colors) <= 4 and fill_ratio >= 0.35: + return "bitmap_asset_candidate", 0.82, {"reason": "simple_nonrectangular_visual"} + + return "uncertain", 0.5, {"reason": "low_classification_confidence"} + + +def _bbox_area(bbox: PixelBBox) -> int: + return max(0, bbox[2] - bbox[0]) * max(0, bbox[3] - bbox[1]) + + +def _validate_bbox_in_image(name: str, bbox: PixelBBox, image_size: tuple[int, int]) -> None: + left, top, right, bottom = bbox + width, height = image_size + if min(bbox) < 0 or right <= left or bottom <= top or right > width or bottom > height: + raise ValueError(f"{name} must be inside source image bounds") + + +def _intersects(a: PixelBBox, b: PixelBBox) -> bool: + return max(a[0], b[0]) < min(a[2], b[2]) and max(a[1], b[1]) < min(a[3], b[3]) + + +def _non_white_ratio(crop: Image.Image) -> float: + histogram = crop.convert("L").histogram() + non_white = sum(histogram[:245]) + total = crop.width * crop.height + return non_white / float(total or 1) + + +def _component_key(crop: Image.Image) -> str: + digest = hashlib.sha1() + digest.update(str(crop.size).encode("ascii")) + digest.update(crop.tobytes()) + return digest.hexdigest() + + +def _as_dict(value: object) -> dict: + return dict(value) if isinstance(value, dict) else {} diff --git a/src/generative_editable_job_artifacts.py b/src/generative_editable_job_artifacts.py new file mode 100644 index 0000000..a7f1dc7 --- /dev/null +++ b/src/generative_editable_job_artifacts.py @@ -0,0 +1,158 @@ +"""Filesystem artifact layout for generative editable PPTX jobs.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re +import shutil +import threading +from typing import Any + +from .generative_editable_manifest import sanitize_persisted_payload + +ASSET_CATEGORIES = {"assets", "backgrounds", "asset_sheets", "sources", "previews"} +PROVIDER_OUTPUT_STAGES = {"vlm", "ocr", "image_edit", "asset_sheet", "image_generation", "repair"} + + +class GenerativeEditableJobArtifacts: + def __init__(self, *, root_dir: str | Path, job_id: str): + self.root_dir = Path(root_dir) + self.job_id = _safe_name(job_id, fallback="") + if not self.job_id or set(self.job_id) == {"."}: + raise ValueError("job_id is required") + self.job_dir = self.root_dir / self.job_id + root = self.root_dir.resolve() + job = self.job_dir.resolve() + if job == root or not job.is_relative_to(root): + raise ValueError("job_id must resolve inside root_dir") + self.deck_manifest_path = self.job_dir / "deck.json" + self.stage_events_path = self.job_dir / "stage-events.jsonl" + self._stage_events_lock = threading.Lock() + self._ensure_layout() + + def _ensure_layout(self) -> None: + for name in ( + "pages", + "assets", + "backgrounds", + "asset_sheets", + "sources", + "previews", + "provider_outputs", + ): + (self.job_dir / name).mkdir(parents=True, exist_ok=True) + + def page_manifest_path(self, slide_id: str, page_index: int) -> Path: + return self.job_dir / "pages" / f"{page_index:04d}-{_safe_name(slide_id)}.json" + + def page_manifest_paths(self, slide_order: list[str]) -> list[Path]: + return [ + self.page_manifest_path(slide_id, page_index) + for page_index, slide_id in enumerate(slide_order) + ] + + def asset_path( + self, + slide_id: str, + page_index: int, + category: str, + filename: str, + ) -> Path: + if category not in ASSET_CATEGORIES: + raise ValueError("category must be a known artifact category") + _validate_plain_filename(filename) + directory = self.job_dir / category / f"{page_index:04d}-{_safe_name(slide_id)}" + directory.mkdir(parents=True, exist_ok=True) + return directory / filename + + def provider_output_path( + self, + slide_id: str, + page_index: int, + provider_stage: str, + filename: str, + ) -> Path: + if provider_stage not in PROVIDER_OUTPUT_STAGES: + raise ValueError("provider_stage must be a known provider output stage") + _validate_plain_filename(filename) + directory = ( + self.job_dir + / "provider_outputs" + / provider_stage + / f"{page_index:04d}-{_safe_name(slide_id)}" + ) + directory.mkdir(parents=True, exist_ok=True) + return directory / filename + + def write_provider_output( + self, + slide_id: str, + page_index: int, + provider_stage: str, + filename: str, + payload: dict[str, Any], + ) -> Path: + path = self.provider_output_path(slide_id, page_index, provider_stage, filename) + path.write_text( + json.dumps( + sanitize_persisted_payload(payload), + ensure_ascii=False, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + return path + + def read_provider_output( + self, + slide_id: str, + page_index: int, + provider_stage: str, + filename: str, + ) -> dict[str, Any]: + path = self.provider_output_path(slide_id, page_index, provider_stage, filename) + return json.loads(path.read_text(encoding="utf-8")) + + def append_stage_event(self, event: dict[str, Any]) -> Path: + self.stage_events_path.parent.mkdir(parents=True, exist_ok=True) + payload = sanitize_persisted_payload(event) + record = json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n" + with self._stage_events_lock: + with self.stage_events_path.open("a", encoding="utf-8") as handle: + handle.write(record) + return self.stage_events_path + + def read_stage_events(self) -> list[dict[str, Any]]: + if not self.stage_events_path.exists(): + return [] + events: list[dict[str, Any]] = [] + for line in self.stage_events_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + events.append(parsed) + return events + + def cleanup(self) -> None: + root = self.root_dir.resolve() + job = self.job_dir.resolve() + if job == root or not job.is_relative_to(root): + raise ValueError("refusing to cleanup outside job directory") + shutil.rmtree(job, ignore_errors=True) + + +def _safe_name(value: str, *, fallback: str = "item") -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-") + return safe or fallback + + +def _validate_plain_filename(filename: str) -> None: + file_path = Path(filename) + if file_path.name != filename or filename in {"", ".", ".."}: + raise ValueError("filename must be a plain file name") diff --git a/src/generative_editable_manifest.py b/src/generative_editable_manifest.py new file mode 100644 index 0000000..964530a --- /dev/null +++ b/src/generative_editable_manifest.py @@ -0,0 +1,494 @@ +"""Manifest models for generative editable PPTX export jobs.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import json +from pathlib import Path +import re +from typing import Any, Literal + +from .generative_editable_providers import safe_provider_error_message + +Point = tuple[int, int] +PixelBBox = tuple[int, int, int, int] +ValidationStatus = Literal["pending", "passed", "failed"] +FallbackPolicy = Literal["fail", "text_editable_background", "raster_pptx"] +RepairStatus = Literal["pending", "passed", "failed"] + +VALIDATION_STATUSES = {"pending", "passed", "failed"} +FALLBACK_POLICIES = {"fail", "text_editable_background", "raster_pptx"} +REPAIR_STATUSES = {"pending", "passed", "failed"} +NATIVE_SHAPE_TYPES = {"rectangle", "rounded_rectangle", "ellipse", "line"} +ARTIFACT_PREFIXES = { + "assets", + "asset_sheets", + "backgrounds", + "pages", + "previews", + "provider_outputs", + "slides", + "sources", +} +SOURCE_PREFIXES = {"slides", "sources"} +BACKGROUND_PREFIXES = {"backgrounds"} +PAGE_PREFIXES = {"pages"} +ASSET_PREFIXES = {"assets", "asset_sheets", "backgrounds", "previews"} +PROVIDER_OUTPUT_PREFIXES = {"provider_outputs"} +SENSITIVE_KEYS = { + "apikey", + "xapikey", + "token", + "apitoken", + "accesstoken", + "refreshtoken", + "idtoken", + "secret", + "clientsecret", + "baseurl", + "authorization", + "password", + "cookie", + "setcookie", + "credential", + "session", + "sessionid", + "privatekey", + "secretkey", + "accesskey", +} +SENSITIVE_KEY_PARTS = { + "apikey", + "xapikey", + "token", + "accesstoken", + "refreshtoken", + "idtoken", + "secret", + "clientsecret", + "authorization", + "password", + "cookie", + "setcookie", + "credential", + "session", + "sessionid", + "privatekey", + "secretkey", + "accesskey", +} +TRUSTED_SAFE_KEYS = {"keyline", "assetkey", "chromakeymode"} +STRING_REDACTION_KEY_PARTS = { + "authorization", + "error", + "exception", + "header", + "message", + "payload", + "raw", + "request", + "response", +} +PROVIDER_OUTPUT_STAGES = {"vlm", "ocr", "image_edit", "asset_sheet", "image_generation", "repair"} + + +def _validate_bbox(name: str, bbox: PixelBBox) -> None: + if len(bbox) != 4 or not all(isinstance(value, int) for value in bbox): + raise ValueError(f"{name} must contain integer source-pixel coordinates") + x1, y1, x2, y2 = bbox + if min(x1, y1, x2, y2) < 0: + raise ValueError(f"{name} must contain non-negative source-pixel coordinates") + if x2 <= x1 or y2 <= y1: + raise ValueError(f"{name} must be ordered as (left, top, right, bottom)") + + +def _validate_polygon(name: str, polygon: tuple[Point, ...]) -> None: + if len(polygon) < 4: + raise ValueError(f"{name} must contain at least four points") + for point in polygon: + if len(point) != 2 or not all(isinstance(value, int) for value in point): + raise ValueError(f"{name} must contain integer source-pixel coordinates") + if min(point) < 0: + raise ValueError(f"{name} must contain non-negative source-pixel coordinates") + + +def _validate_point(name: str, point: Point) -> None: + if len(point) != 2 or not all(isinstance(value, int) for value in point): + raise ValueError(f"{name} must contain integer source-pixel coordinates") + if min(point) < 0: + raise ValueError(f"{name} must contain non-negative source-pixel coordinates") + + +def _validate_status(name: str, value: str, allowed: set[str]) -> None: + if value not in allowed: + raise ValueError(f"{name} must be one of: {', '.join(sorted(allowed))}") + + +def _validate_relative_artifact_path(name: str, value: str, allowed_prefixes: set[str]) -> None: + if not value: + return + path = Path(value) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"{name} must be a job-relative artifact path") + if path.parts and path.parts[0] not in allowed_prefixes: + raise ValueError(f"{name} must start with one of: {', '.join(sorted(allowed_prefixes))}") + + +def _validate_bbox_in_bounds(name: str, bbox: PixelBBox, image_size: tuple[int, int]) -> None: + _validate_bbox(name, bbox) + _, _, x2, y2 = bbox + width, height = image_size + if x2 > width or y2 > height: + raise ValueError(f"{name} must be inside source_image_size") + + +def _validate_polygon_in_bounds(name: str, polygon: tuple[Point, ...], image_size: tuple[int, int]) -> None: + _validate_polygon(name, polygon) + width, height = image_size + for x, y in polygon: + if x > width or y > height: + raise ValueError(f"{name} must be inside source_image_size") + + +def _validate_point_in_bounds(name: str, point: Point, image_size: tuple[int, int]) -> None: + _validate_point(name, point) + width, height = image_size + x, y = point + if x > width or y > height: + raise ValueError(f"{name} must be inside source_image_size") + + +@dataclass(frozen=True) +class TextBoxSpec: + text: str + source_pixel_bbox: PixelBBox + source_pixel_polygon: tuple[Point, ...] + font_family: str = "" + font_size: float | None = None + color_hex: str = "#000000" + alignment: str = "left" + opacity: float = 1.0 + style_hints: dict[str, Any] = field(default_factory=dict) + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + _validate_bbox("source_pixel_bbox", self.source_pixel_bbox) + _validate_polygon("source_pixel_polygon", self.source_pixel_polygon) + if not 0 <= self.opacity <= 1: + raise ValueError("opacity must be between 0 and 1") + + +@dataclass(frozen=True) +class NativeShapeSpec: + shape_type: str + source_pixel_bbox: PixelBBox + fill_color: str = "" + line_color: str = "" + line_start: Point | None = None + line_end: Point | None = None + stroke_width: float | None = None + opacity: float = 1.0 + radius: float | None = None + confidence: float = 1.0 + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.shape_type not in NATIVE_SHAPE_TYPES: + raise ValueError("shape_type must be a supported native shape") + _validate_bbox("source_pixel_bbox", self.source_pixel_bbox) + if not 0 <= self.confidence <= 1: + raise ValueError("confidence must be between 0 and 1") + if not 0 <= self.opacity <= 1: + raise ValueError("opacity must be between 0 and 1") + if self.line_start is not None: + _validate_point("line_start", self.line_start) + if self.line_end is not None: + _validate_point("line_end", self.line_end) + if self.stroke_width is not None and self.stroke_width <= 0: + raise ValueError("stroke_width must be positive") + if self.radius is not None and (not isinstance(self.radius, int | float) or self.radius < 0): + raise ValueError("radius must be a non-negative number") + + +@dataclass(frozen=True) +class ForegroundCandidateSpec: + candidate_id: str + source_pixel_bbox: PixelBBox + area: int + classification: str + confidence: float = 0.0 + component_key: str = "" + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + _validate_bbox("source_pixel_bbox", self.source_pixel_bbox) + if self.area < 0: + raise ValueError("area must be non-negative") + if not 0 <= self.confidence <= 1: + raise ValueError("confidence must be between 0 and 1") + + +@dataclass(frozen=True) +class BitmapAssetSpec: + asset_id: str + source_pixel_bbox: PixelBBox + asset_path: str + z_order: int + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + _validate_bbox("source_pixel_bbox", self.source_pixel_bbox) + _validate_relative_artifact_path("asset_path", self.asset_path, ASSET_PREFIXES) + + +@dataclass(frozen=True) +class AssetSheetSpec: + sheet_id: str + asset_path: str + prompt_id: str + candidate_ids: list[str] + provider_role: str + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + _validate_relative_artifact_path("asset_path", self.asset_path, ASSET_PREFIXES) + + +@dataclass(frozen=True) +class RepairAttempt: + target_id: str + attempt_index: int + reason: str + provider_role: str + status: RepairStatus + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.attempt_index < 1: + raise ValueError("attempt_index must be positive") + _validate_status("repair status", self.status, REPAIR_STATUSES) + + +@dataclass(frozen=True) +class PageManifest: + slide_id: str + page_index: int + source_image_path: str + source_image_size: tuple[int, int] + slide_size: tuple[float, float] + text_clean_background: str = "" + base_clean_background: str = "" + chosen_background: str = "" + text_boxes: list[TextBoxSpec] = field(default_factory=list) + foreground_candidates: list[ForegroundCandidateSpec] = field(default_factory=list) + native_shapes: list[NativeShapeSpec] = field(default_factory=list) + bitmap_assets: list[BitmapAssetSpec] = field(default_factory=list) + asset_sheets: list[AssetSheetSpec] = field(default_factory=list) + repair_attempts: list[RepairAttempt] = field(default_factory=list) + provider_output_paths: dict[str, str] = field(default_factory=dict) + provenance: dict[str, Any] = field(default_factory=dict) + validation_status: ValidationStatus = "pending" + + def __post_init__(self) -> None: + _validate_status("validation_status", self.validation_status, VALIDATION_STATUSES) + if self.page_index < 0: + raise ValueError("page_index must be non-negative") + if self.source_image_size[0] <= 0 or self.source_image_size[1] <= 0: + raise ValueError("source_image_size must be positive") + if self.slide_size[0] <= 0 or self.slide_size[1] <= 0: + raise ValueError("slide_size must be positive") + _validate_relative_artifact_path("source_image_path", self.source_image_path, SOURCE_PREFIXES) + _validate_relative_artifact_path( + "text_clean_background", self.text_clean_background, BACKGROUND_PREFIXES + ) + _validate_relative_artifact_path( + "base_clean_background", self.base_clean_background, BACKGROUND_PREFIXES + ) + _validate_relative_artifact_path( + "chosen_background", self.chosen_background, BACKGROUND_PREFIXES + ) + for value in self.provider_output_paths.values(): + _validate_relative_artifact_path("provider_output_paths", value, PROVIDER_OUTPUT_PREFIXES) + expected_slide_dir = f"{self.page_index:04d}-{_safe_name(self.slide_id)}" + for stage, value in self.provider_output_paths.items(): + if stage not in PROVIDER_OUTPUT_STAGES: + raise ValueError("provider_output_paths must use known provider stages") + parts = Path(value).parts + if len(parts) < 4 or parts[1] != stage or parts[2] != expected_slide_dir: + raise ValueError("provider_output_paths must match stage and slide/page") + for text_box in self.text_boxes: + _validate_bbox_in_bounds("source_pixel_bbox", text_box.source_pixel_bbox, self.source_image_size) + _validate_polygon_in_bounds( + "source_pixel_polygon", text_box.source_pixel_polygon, self.source_image_size + ) + for shape in self.native_shapes: + _validate_bbox_in_bounds("source_pixel_bbox", shape.source_pixel_bbox, self.source_image_size) + if shape.line_start is not None: + _validate_point_in_bounds("line_start", shape.line_start, self.source_image_size) + if shape.line_end is not None: + _validate_point_in_bounds("line_end", shape.line_end, self.source_image_size) + for candidate in self.foreground_candidates: + _validate_bbox_in_bounds( + "source_pixel_bbox", candidate.source_pixel_bbox, self.source_image_size + ) + for asset in self.bitmap_assets: + _validate_bbox_in_bounds("source_pixel_bbox", asset.source_pixel_bbox, self.source_image_size) + + +@dataclass(frozen=True) +class DeckManifest: + job_id: str + slide_order: list[str] + aspect_ratio: str + provider_roles: dict[str, str] + quality_settings: dict[str, Any] + fallback_policy: FallbackPolicy + page_manifest_paths: list[str] + validation_status: ValidationStatus = "pending" + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.aspect_ratio not in {"16:9", "4:3"}: + raise ValueError("aspect_ratio must be 16:9 or 4:3") + _validate_status("fallback_policy", self.fallback_policy, FALLBACK_POLICIES) + _validate_status("validation_status", self.validation_status, VALIDATION_STATUSES) + if len(self.slide_order) != len(self.page_manifest_paths): + raise ValueError("page_manifest_paths must match slide_order length") + for page_index, (slide_id, path) in enumerate(zip(self.slide_order, self.page_manifest_paths)): + _validate_relative_artifact_path("page_manifest_paths", path, PAGE_PREFIXES) + expected = f"{page_index:04d}-{_safe_name(slide_id)}.json" + if Path(path).name != expected: + raise ValueError("page_manifest_paths must follow slide_order") + + +def manifest_to_dict(manifest: DeckManifest | PageManifest) -> dict[str, Any]: + return sanitize_persisted_payload(asdict(manifest)) + + +def write_manifest(path: str | Path, manifest: DeckManifest | PageManifest) -> None: + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(manifest_to_dict(manifest), ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def read_page_manifest(path: str | Path) -> PageManifest: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return page_manifest_from_dict(data) + + +def read_deck_manifest(path: str | Path) -> DeckManifest: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return deck_manifest_from_dict(data) + + +def deck_manifest_from_dict(data: dict[str, Any]) -> DeckManifest: + return DeckManifest(**data) + + +def page_manifest_from_dict(data: dict[str, Any]) -> PageManifest: + payload = dict(data) + payload["source_image_size"] = tuple(payload["source_image_size"]) + payload["slide_size"] = tuple(payload["slide_size"]) + payload["text_boxes"] = [ + _text_box_from_dict(item) for item in payload.get("text_boxes", []) + ] + payload["native_shapes"] = [ + _native_shape_from_dict(item) for item in payload.get("native_shapes", []) + ] + payload["foreground_candidates"] = [ + _foreground_candidate_from_dict(item) for item in payload.get("foreground_candidates", []) + ] + payload["bitmap_assets"] = [ + _bitmap_asset_from_dict(item) for item in payload.get("bitmap_assets", []) + ] + payload["asset_sheets"] = [ + AssetSheetSpec(**item) for item in payload.get("asset_sheets", []) + ] + payload["repair_attempts"] = [ + RepairAttempt(**item) for item in payload.get("repair_attempts", []) + ] + return PageManifest(**payload) + + +def _text_box_from_dict(data: dict[str, Any]) -> TextBoxSpec: + payload = dict(data) + payload["source_pixel_bbox"] = tuple(payload["source_pixel_bbox"]) + payload["source_pixel_polygon"] = _polygon_from_json(payload["source_pixel_polygon"]) + return TextBoxSpec(**payload) + + +def _native_shape_from_dict(data: dict[str, Any]) -> NativeShapeSpec: + payload = dict(data) + payload["source_pixel_bbox"] = tuple(payload["source_pixel_bbox"]) + if payload.get("line_start") is not None: + payload["line_start"] = tuple(payload["line_start"]) + if payload.get("line_end") is not None: + payload["line_end"] = tuple(payload["line_end"]) + return NativeShapeSpec(**payload) + + +def _foreground_candidate_from_dict(data: dict[str, Any]) -> ForegroundCandidateSpec: + payload = dict(data) + payload["source_pixel_bbox"] = tuple(payload["source_pixel_bbox"]) + return ForegroundCandidateSpec(**payload) + + +def _bitmap_asset_from_dict(data: dict[str, Any]) -> BitmapAssetSpec: + payload = dict(data) + payload["source_pixel_bbox"] = tuple(payload["source_pixel_bbox"]) + return BitmapAssetSpec(**payload) + + +def _polygon_from_json(value: list[list[int]] | tuple[Point, ...]) -> tuple[Point, ...]: + return tuple(tuple(point) for point in value) + + +def sanitize_persisted_payload( + value: Any, *, _key: str = "", _redact_strings: bool = False +) -> Any: + if isinstance(value, dict): + safe: dict[str, Any] = {} + redact_nested = _redact_strings or _should_redact_string_value(_key) + for key, nested in value.items(): + if _is_sensitive_key(str(key)): + continue + safe[key] = sanitize_persisted_payload( + nested, + _key=str(key), + _redact_strings=redact_nested, + ) + return safe + if isinstance(value, list): + return [ + sanitize_persisted_payload(item, _key=_key, _redact_strings=_redact_strings) + for item in value + ] + if isinstance(value, tuple): + return [ + sanitize_persisted_payload(item, _key=_key, _redact_strings=_redact_strings) + for item in value + ] + if isinstance(value, str): + if _redact_strings or _should_redact_string_value(_key): + return safe_provider_error_message(value) + return value + return value + + +def _is_sensitive_key(key: str) -> bool: + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + if normalized in TRUSTED_SAFE_KEYS: + return False + return normalized in SENSITIVE_KEYS or any(part in normalized for part in SENSITIVE_KEY_PARTS) + + +def _should_redact_string_value(key: str) -> bool: + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + return any(part in normalized for part in STRING_REDACTION_KEY_PARTS) + + +def _safe_name(value: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-") or "item" diff --git a/src/generative_editable_page_analysis.py b/src/generative_editable_page_analysis.py new file mode 100644 index 0000000..7cd4bf2 --- /dev/null +++ b/src/generative_editable_page_analysis.py @@ -0,0 +1,219 @@ +"""Page-level analysis contracts for generative editable PPTX reconstruction.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .generative_editable_manifest import TextBoxSpec + + +BBox = tuple[int, int, int, int] + + +@dataclass(frozen=True) +class RejectedOCRHint: + text: str + source_pixel_bbox: BBox + reason: str + provenance: dict[str, Any] + + +@dataclass(frozen=True) +class PageTextAnalysis: + accepted_text_boxes: list[TextBoxSpec] + rejected_ocr_hints: list[RejectedOCRHint] + visual_text_candidates: list[BBox] + source_image_size: tuple[int, int] + + +def build_page_text_analysis( + *, + text_boxes: list[TextBoxSpec], + visual_text_candidates: list[BBox], + source_image_size: tuple[int, int], +) -> PageTextAnalysis: + """Promote only visually anchored OCR layouts into editable text boxes. + + OCR text is useful as content and style hints, but approximate OCR geometry + is not authoritative. This mirrors the reference workflows: a page-level + analysis contract decides which layouts are safe to reconstruct, then the + composer deterministically turns accepted objects into PPTX elements. + """ + + accepted: list[TextBoxSpec] = [] + rejected: list[RejectedOCRHint] = [] + for text_box in text_boxes: + if not _text_box_has_approximate_ocr_layout(text_box): + accepted.append(text_box) + continue + if _approximate_text_box_is_anchored( + text_box.source_pixel_bbox, + visual_text_candidates, + source_image_size, + text=text_box.text, + ): + accepted.append(_with_visual_layout_provenance(text_box)) + continue + rejected.append( + RejectedOCRHint( + text=text_box.text, + source_pixel_bbox=text_box.source_pixel_bbox, + reason="unanchored_approximate_ocr", + provenance=dict(text_box.provenance), + ) + ) + return PageTextAnalysis( + accepted_text_boxes=accepted, + rejected_ocr_hints=rejected, + visual_text_candidates=list(visual_text_candidates), + source_image_size=source_image_size, + ) + + +def _with_visual_layout_provenance(text_box: TextBoxSpec) -> TextBoxSpec: + return TextBoxSpec( + text=text_box.text, + source_pixel_bbox=text_box.source_pixel_bbox, + source_pixel_polygon=text_box.source_pixel_polygon, + font_family=text_box.font_family, + font_size=text_box.font_size, + color_hex=text_box.color_hex, + alignment=text_box.alignment, + opacity=text_box.opacity, + style_hints={ + **dict(text_box.style_hints), + "layout_source": "visual_text_candidate", + }, + provenance={ + **dict(text_box.provenance), + "layout_source": "visual_text_candidate", + "ocr_layout_usage": "hint_only", + }, + ) + + +def _text_box_has_approximate_ocr_layout(text_box: TextBoxSpec) -> bool: + provenance = text_box.provenance.get("ocr_provenance") + return isinstance(provenance, dict) and bool(provenance.get("approximate_layout")) + + +def _approximate_text_box_is_anchored( + bbox: BBox, + visual_text_candidates: list[BBox], + source_image_size: tuple[int, int], + *, + text: str = "", +) -> bool: + image_width, image_height = source_image_size + if _is_large_top_text_box(bbox, image_width, image_height): + return True + if _is_small_top_approximate_text_box(bbox, image_width, image_height): + return False + return any( + _visual_candidate_can_anchor_text(text, candidate, source_image_size) + and _visual_candidate_size_matches_text_bbox(bbox, candidate, source_image_size) + and (_bbox_iou(bbox, candidate) >= 0.05 or _bbox_center_inside(bbox, candidate)) + for candidate in visual_text_candidates + ) + + +def _visual_candidate_size_matches_text_bbox( + bbox: BBox, + candidate: BBox, + source_image_size: tuple[int, int], +) -> bool: + image_width, image_height = source_image_size + bbox_width = max(1, bbox[2] - bbox[0]) + bbox_height = max(1, bbox[3] - bbox[1]) + candidate_width = max(1, candidate[2] - candidate[0]) + candidate_height = max(1, candidate[3] - candidate[1]) + if _is_large_top_text_box(bbox, image_width, image_height): + return True + max_width = max(bbox_width * 3.0, bbox_width + image_width * 0.08) + max_height = max(bbox_height * 2.5, bbox_height + image_height * 0.04) + return candidate_width <= max_width and candidate_height <= max_height + + +def _visual_candidate_can_anchor_text( + text: str, + candidate: BBox, + source_image_size: tuple[int, int], +) -> bool: + image_width, image_height = source_image_size + if image_width <= 0 or image_height <= 0: + return False + left, top, right, bottom = candidate + width = max(0, right - left) + height = max(0, bottom - top) + if width <= 0 or height <= 0: + return False + aspect = width / float(height) + meaningful_len = len([char for char in text if char.isalnum() or "\u3400" <= char <= "\u9fff"]) + if _is_large_top_text_box(candidate, image_width, image_height): + return meaningful_len >= 6 + if _is_short_section_label_text(text): + return width >= image_width * 0.04 and aspect >= 1.35 and height <= image_height * 0.10 + if aspect < 1.6: + return False + if meaningful_len >= 6: + return width >= image_width * 0.06 and height <= image_height * 0.12 + return width >= image_width * 0.05 and aspect >= 2.0 and height <= image_height * 0.09 + + +def _is_short_section_label_text(text: str) -> bool: + stripped = str(text).strip() + meaningful = "".join(char for char in stripped if char.isalnum() or "\u3400" <= char <= "\u9fff") + cjk_count = sum(1 for char in meaningful if "\u3400" <= char <= "\u9fff") + return 2 <= cjk_count <= 5 and meaningful.endswith(("域", "区", "层", "类", "项", "栏")) + + +def _is_large_top_text_box( + bbox: BBox, + image_width: int, + image_height: int, +) -> bool: + left, top, right, bottom = bbox + return ( + top <= image_height * 0.12 + and (right - left) >= image_width * 0.25 + and (bottom - top) >= 20 + ) + + +def _is_small_top_approximate_text_box( + bbox: BBox, + image_width: int, + image_height: int, +) -> bool: + left, top, right, bottom = bbox + return ( + top <= image_height * 0.12 + and (bottom - top) <= max(18, image_height * 0.025) + and (right - left) <= image_width * 0.18 + ) + + +def _bbox_center_inside(bbox: BBox, candidate: BBox) -> bool: + center_x = (bbox[0] + bbox[2]) / 2.0 + center_y = (bbox[1] + bbox[3]) / 2.0 + horizontal_padding = max(6, round((candidate[2] - candidate[0]) * 0.08)) + vertical_padding = max(6, round((candidate[3] - candidate[1]) * 0.18)) + return ( + candidate[0] - horizontal_padding <= center_x <= candidate[2] + horizontal_padding + and candidate[1] - vertical_padding <= center_y <= candidate[3] + vertical_padding + ) + + +def _bbox_iou(first: BBox, second: BBox) -> float: + left = max(first[0], second[0]) + top = max(first[1], second[1]) + right = min(first[2], second[2]) + bottom = min(first[3], second[3]) + if right <= left or bottom <= top: + return 0.0 + intersection = (right - left) * (bottom - top) + first_area = max(0, first[2] - first[0]) * max(0, first[3] - first[1]) + second_area = max(0, second[2] - second[0]) * max(0, second[3] - second[1]) + union = first_area + second_area - intersection + return intersection / float(union) if union else 0.0 diff --git a/src/generative_editable_pipeline.py b/src/generative_editable_pipeline.py new file mode 100644 index 0000000..3edaf1b --- /dev/null +++ b/src/generative_editable_pipeline.py @@ -0,0 +1,5377 @@ +"""Pipeline-level helpers for generative editable PPTX export.""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict, dataclass, field, replace +import inspect +from pathlib import Path +import re +import shutil +from statistics import mean +import time +from typing import Any, Literal + +from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageStat + +from .generative_editable_assets import ( + build_asset_sheet_request, + repair_failed_assets, + slice_asset_sheet_by_components, + validate_sliced_asset, +) +from .generative_editable_backgrounds import ( + BackgroundResult, + create_base_clean_background, + create_source_raster_background, + create_source_preserving_text_background, + create_text_clean_background, + update_page_manifest_backgrounds, +) +from .generative_editable_composer import compose_deck_from_manifests +from .generative_editable_foreground_planner import ( + ForegroundCandidate, + classify_foreground_candidates, + foreground_candidates_to_manifest_specs, + plan_foreground_candidates, +) +from .generative_editable_job_artifacts import GenerativeEditableJobArtifacts +from .generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + FallbackPolicy, + AssetSheetSpec, + NativeShapeSpec, + PageManifest, + RepairAttempt, + TextBoxSpec, + write_manifest, +) +from .generative_editable_page_analysis import build_page_text_analysis +from .generative_editable_preview_validator import ( + ValidationIssue, + ValidationReport, + quality_threshold_to_preview_gates, + render_manifest_preview_with_metadata, + validate_composed_deck_structure, + validate_preview_similarity, +) +from .generative_editable_providers import ( + ImageEditProvider, + ImageGenerationProvider, + ImageGenerationRequest, + OCRResult, + OCRTextItem, + OCRProvider, + RetryingImageEditProvider, + RetryingImageGenerationProvider, + RetryingOCRProvider, +) +from .generative_editable_providers import ProviderError, ProviderTimeoutError +from .generative_editable_shape_fitter import fit_native_shape_with_fallback +from .generative_editable_text_candidates import detect_text_candidate_bboxes +from .generative_editable_text import ( + _is_likely_repetitive_hallucinated_text, + extract_text_with_validation, +) +from .generative_editable_text_masks import create_text_mask + + +FALLBACK_POLICIES = {"fail", "text_editable_background", "raster_pptx"} +MAX_ASSET_SHEET_CANDIDATES = 6 + + +def _default_visual_text_candidate_detector(**kwargs) -> list[tuple[int, int, int, int]]: + return detect_text_candidate_bboxes(kwargs["source_image_path"]) + + +def _build_default_assets(**kwargs) -> "AssetBuildResult": + candidates = [ + candidate + for candidate in kwargs["candidates"] + if candidate.classification in {"bitmap_asset_candidate", "complex_whole_visual", "uncertain"} + ] + if not candidates: + return AssetBuildResult(bitmap_assets=[]) + + asset_root = Path(kwargs["asset_root"]) + page_index = int(kwargs["page_index"]) + slide_id = str(kwargs["slide_id"]) + output_dir = Path(kwargs["output_dir"]) + asset_sheet_provider = kwargs.get("asset_sheet_image_edit_provider", kwargs["image_edit_provider"]) + asset_reference_image_path = kwargs.get("asset_reference_image_path") or kwargs["source_image_path"] + background_reference_image_path = kwargs.get("background_reference_image_path") + allow_source_crop_fallback = bool(kwargs.get("allow_source_crop_fallback", False)) + preserved_candidates = [ + candidate for candidate in candidates if candidate.classification == "complex_whole_visual" + ] + sheet_candidates = [ + candidate for candidate in candidates if candidate.classification != "complex_whole_visual" + ] + preserved_assets = _build_masked_source_element_assets( + candidates=preserved_candidates, + source_image_path=kwargs["source_image_path"], + background_image_path=background_reference_image_path, + text_mask_path=kwargs.get("text_mask_path"), + output_dir=output_dir, + asset_root=asset_root, + start_z_order=1, + ) + if not sheet_candidates: + return AssetBuildResult( + bitmap_assets=preserved_assets, + provider_output_payload={ + "asset_strategy": "masked_source_elements", + "asset_ids": [asset.asset_id for asset in preserved_assets], + }, + ) + batches = _candidate_batches( + sheet_candidates, + max_batch_size=int(kwargs.get("asset_sheet_batch_size", MAX_ASSET_SHEET_CANDIDATES)), + ) + bitmap_assets: list[BitmapAssetSpec] = list(preserved_assets) + asset_sheets: list[AssetSheetSpec] = [] + sheet_results: list[Any] = [] + for batch_index, batch in enumerate(batches, start=1): + sheet_filename = "asset-sheet.png" if len(batches) == 1 else f"asset-sheet-{batch_index:03d}.png" + sheet_path = ( + asset_root + / "asset_sheets" + / f"{page_index:04d}-{_safe_name(slide_id)}" + / sheet_filename + ) + request_source_image_path, request_candidates = _asset_sheet_reference_image_for_batch( + reference_image_path=asset_reference_image_path, + batch=batch, + sheet_path=sheet_path, + ) + request = build_asset_sheet_request( + source_image_path=request_source_image_path, + candidates=request_candidates, + output_asset_path=sheet_path, + asset_root=asset_root, + provider_role=asset_sheet_provider.config.role, + timeout_seconds=int(kwargs.get("provider_timeout_seconds", 180)), + ) + try: + sheet_result, asset_sheet_provenance = _create_asset_sheet( + request=request, + candidates=request_candidates, + image_edit_provider=asset_sheet_provider, + image_generation_provider=kwargs["image_generation_provider"], + source_image_path=request_source_image_path, + ) + except ProviderError as exc: + if not allow_source_crop_fallback: + raise + return _source_crop_asset_build_result( + candidates=candidates, + source_image_path=kwargs["source_image_path"], + output_dir=output_dir, + asset_root=asset_root, + failure=str(exc), + ) + try: + sliced_assets = slice_asset_sheet_by_components( + sheet_path=sheet_result.output_asset_path, + candidates=batch, + output_dir=output_dir, + asset_root=asset_root, + ) + bitmap_assets.extend( + replace(asset, z_order=len(preserved_assets) + asset.z_order) + for asset in sliced_assets + ) + except Exception as exc: + if not allow_source_crop_fallback: + raise ProviderError( + provider_role=asset_sheet_provider.config.role, + operation="asset_sheet", + message=f"asset sheet cannot be sliced: {str(exc) or exc.__class__.__name__}", + retryable=False, + ) from exc + return _source_crop_asset_build_result( + candidates=candidates, + source_image_path=kwargs["source_image_path"], + output_dir=output_dir, + asset_root=asset_root, + failure=str(exc) or exc.__class__.__name__, + ) + sheet_id = ( + f"asset-sheet-{page_index:04d}" + if len(batches) == 1 + else f"asset-sheet-{page_index:04d}-{batch_index:03d}" + ) + asset_sheet = AssetSheetSpec( + sheet_id=sheet_id, + asset_path=_job_relative(sheet_result.output_asset_path, asset_root), + prompt_id=request.prompt_id, + candidate_ids=[candidate.candidate_id for candidate in batch], + provider_role=sheet_result.provider_role, + provenance={ + "provider": sheet_result.provider_name, + "model": sheet_result.model, + "prompt_id": sheet_result.prompt_id, + **asset_sheet_provenance, + }, + ) + asset_sheets.append(asset_sheet) + sheet_results.append(sheet_result) + bitmap_assets, repair_attempts = _repair_or_replace_provider_asset_mismatches( + bitmap_assets, + source_image_path=kwargs["source_image_path"], + output_dir=output_dir, + asset_root=asset_root, + repair_provider=kwargs.get("repair_image_edit_provider", kwargs["image_edit_provider"]), + max_repair_attempts=int(kwargs.get("max_repair_attempts", 0)), + provider_timeout_seconds=int(kwargs.get("provider_timeout_seconds", 180)), + allow_source_crop_fallback=allow_source_crop_fallback, + ) + provider_output_payload: dict[str, Any] = { + "asset_sheets": [asdict(sheet) for sheet in asset_sheets], + "asset_sheet_batches": len(batches), + "preserved_source_element_count": len(preserved_assets), + "asset_reference_image_path": _job_relative(Path(asset_reference_image_path), asset_root), + "repair_attempts": [asdict(attempt) for attempt in repair_attempts], + } + if len(sheet_results) == 1: + provider_output_payload.update( + { + "provider": sheet_results[0].provider_name, + "model": sheet_results[0].model, + "prompt_id": sheet_results[0].prompt_id, + } + ) + return AssetBuildResult( + bitmap_assets=bitmap_assets, + asset_sheets=asset_sheets, + repair_attempts=repair_attempts, + provider_output_payload=provider_output_payload, + ) + + +def _asset_sheet_reference_image_for_batch( + *, + reference_image_path: str | Path, + batch: list[ForegroundCandidate], + sheet_path: Path, +) -> tuple[str, list[ForegroundCandidate]]: + if len(batch) != 1 or batch[0].classification != "complex_whole_visual": + return str(reference_image_path), batch + candidate = batch[0] + crop_path = sheet_path.with_suffix(".reference.png") + crop_path.parent.mkdir(parents=True, exist_ok=True) + with Image.open(reference_image_path) as reference: + crop = reference.convert("RGB").crop(candidate.source_pixel_bbox) + crop.save(crop_path) + crop_candidate = replace( + candidate, + source_pixel_bbox=(0, 0, crop.width, crop.height), + provenance={ + **_as_dict(candidate.provenance), + "reference_crop_origin": ( + candidate.source_pixel_bbox[0], + candidate.source_pixel_bbox[1], + ), + "reference_source_pixel_bbox": candidate.source_pixel_bbox, + }, + ) + return str(crop_path), [crop_candidate] + + +def _source_crop_asset_build_result( + *, + candidates: list[ForegroundCandidate], + source_image_path: str, + output_dir: Path, + asset_root: Path, + failure: str, +) -> "AssetBuildResult": + bitmap_assets = _build_source_crop_assets( + candidates=candidates, + source_image_path=source_image_path, + output_dir=output_dir, + asset_root=asset_root, + fallback_reason="source_crop_after_asset_sheet_failure", + failure=failure, + ) + return AssetBuildResult( + bitmap_assets=bitmap_assets, + asset_sheets=[], + provider_output_payload={ + "asset_sheet_fallback": "source_crops", + "asset_sheet_failure": failure, + "asset_ids": [asset.asset_id for asset in bitmap_assets], + }, + ) + + +def _build_masked_source_element_assets( + *, + candidates: list[ForegroundCandidate], + source_image_path: str, + background_image_path: str | Path | None = None, + text_mask_path: str | Path | None, + output_dir: Path, + asset_root: Path, + start_z_order: int = 1, +) -> list[BitmapAssetSpec]: + if not candidates: + return [] + output_dir.mkdir(parents=True, exist_ok=True) + assets: list[BitmapAssetSpec] = [] + with Image.open(source_image_path) as source: + source_rgba = source.convert("RGBA") + source_size = source_rgba.size + mask = _load_optional_text_mask(text_mask_path, source_size) + background = _load_optional_background_reference(background_image_path, source_size) + for index, candidate in enumerate(candidates, start=start_z_order): + bbox = _clamp_bbox_to_size(candidate.source_pixel_bbox, source_size) + if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: + continue + crop = source_rgba.crop(bbox) + preserve_whole_visual = _should_preserve_whole_visual_source_crop(candidate) + background_crop = ( + None + if preserve_whole_visual or background is None + else background.crop(bbox) + ) + crop_mask = None if preserve_whole_visual or mask is None else mask.crop(bbox) + if background_crop is not None or crop_mask is not None: + _apply_source_element_alpha(crop, background_crop, crop_mask) + smooth_difference_suppressed = False + pre_suppression_crop = crop.copy() + pre_suppression_visible_pixel_count = _alpha_visible_pixel_count(pre_suppression_crop) + if background_crop is not None and _should_suppress_smooth_background_difference(candidate): + smooth_difference_suppressed = _suppress_smooth_background_difference_alpha(crop) + if smooth_difference_suppressed and _smooth_suppression_collapsed_to_fragment( + before_visible_pixel_count=pre_suppression_visible_pixel_count, + after_visible_pixel_count=_alpha_visible_pixel_count(crop), + ) or ( + smooth_difference_suppressed + and _smooth_suppression_lost_domain_row_span(candidate, crop) + ): + crop = pre_suppression_crop + smooth_difference_suppressed = False + edge_alpha_feathered = False + if background_crop is not None and _should_feather_source_preserved_asset_edges(crop, source_size): + edge_alpha_feathered = _feather_outer_alpha_edges(crop) + crop, bbox, trimmed_transparent_bounds = _trim_crop_to_visible_alpha(crop, bbox) + visible_pixel_count = _alpha_visible_pixel_count(crop) + source_area = max(1, source_size[0] * source_size[1]) + output_path = output_dir / f"{_safe_name(candidate.candidate_id)}.preserved.png" + crop.save(output_path) + assets.append( + BitmapAssetSpec( + asset_id=candidate.candidate_id, + source_pixel_bbox=bbox, + asset_path=_job_relative(output_path, asset_root), + z_order=index, + provenance={ + "candidate_id": candidate.candidate_id, + "candidate_classification": candidate.classification, + "asset_strategy": "masked_source_element", + "text_mask_applied": crop_mask is not None, + "background_difference_alpha": background_crop is not None, + "smooth_background_difference_suppressed": smooth_difference_suppressed, + "edge_alpha_feathered": edge_alpha_feathered, + "trimmed_transparent_bounds": trimmed_transparent_bounds, + "alpha_visible_pixel_count": visible_pixel_count, + "alpha_visible_area_ratio": round(visible_pixel_count / float(source_area), 6), + "candidate_provenance": _as_dict(candidate.provenance), + }, + ) + ) + return assets + + +def _should_preserve_whole_visual_source_crop(candidate: ForegroundCandidate) -> bool: + if candidate.classification != "complex_whole_visual": + return False + provenance = _as_dict(candidate.provenance) + if provenance.get("detection") in { + "source_generic_visual_anchor", + "split_edge_spanning_difference", + "domain_row_visual_anchor", + }: + return False + original_detection = provenance.get("original_detection") + if ( + provenance.get("detection") == "source_base_difference" + or ( + isinstance(original_detection, dict) + and original_detection.get("detection") == "source_base_difference" + ) + ): + return False + return ( + provenance.get("preserve_whole_visual") is True + or provenance.get("reason") == "large_or_visually_complex" + ) + + +def _should_suppress_smooth_background_difference(candidate: ForegroundCandidate) -> bool: + provenance = _as_dict(candidate.provenance) + return provenance.get("detection") in { + "source_generic_visual_anchor", + "domain_row_visual_anchor", + } + + +def _trim_crop_to_visible_alpha( + crop: Image.Image, + source_bbox: tuple[int, int, int, int], +) -> tuple[Image.Image, tuple[int, int, int, int], bool]: + alpha_bbox = crop.convert("RGBA").getchannel("A").getbbox() + if alpha_bbox is None: + return crop, source_bbox, False + if alpha_bbox == (0, 0, crop.width, crop.height): + return crop, source_bbox, False + left, top, right, bottom = source_bbox + trim_left, trim_top, trim_right, trim_bottom = alpha_bbox + return ( + crop.crop(alpha_bbox), + ( + left + trim_left, + top + trim_top, + left + trim_right, + top + trim_bottom, + ), + True, + ) + + +def _apply_source_element_alpha( + crop: Image.Image, + background_crop: Image.Image | None, + crop_mask: Image.Image | None, + *, + background_delta_threshold: float = 16.0, +) -> None: + crop_pixels = crop.load() + background_pixels = background_crop.load() if background_crop is not None else None + mask_pixels = crop_mask.load() if crop_mask is not None else None + for y in range(crop.height): + for x in range(crop.width): + red, green, blue, alpha = crop_pixels[x, y] + if mask_pixels is not None and mask_pixels[x, y] > 0: + crop_pixels[x, y] = (red, green, blue, 0) + continue + if background_pixels is None: + continue + if _color_distance((red, green, blue), background_pixels[x, y]) <= background_delta_threshold: + crop_pixels[x, y] = (red, green, blue, 0) + elif alpha != 255: + crop_pixels[x, y] = (red, green, blue, 255) + + +def _alpha_visible_pixel_count(image: Image.Image) -> int: + alpha = image.convert("RGBA").getchannel("A") + histogram = alpha.histogram() + return sum(histogram[1:]) + + +def _smooth_suppression_collapsed_to_fragment( + *, + before_visible_pixel_count: int, + after_visible_pixel_count: int, + min_remaining_ratio: float = 0.08, +) -> bool: + if before_visible_pixel_count <= 0: + return False + return after_visible_pixel_count / float(before_visible_pixel_count) < min_remaining_ratio + + +def _smooth_suppression_lost_domain_row_span( + candidate: ForegroundCandidate, + crop: Image.Image, + *, + min_width_ratio: float = 0.45, +) -> bool: + provenance = _as_dict(candidate.provenance) + if provenance.get("detection") != "domain_row_visual_anchor": + return False + alpha_bbox = crop.convert("RGBA").getchannel("A").getbbox() + if alpha_bbox is None or crop.width <= 0: + return True + return (alpha_bbox[2] - alpha_bbox[0]) / float(crop.width) < min_width_ratio + + +def _should_feather_source_preserved_asset_edges( + crop: Image.Image, + source_size: tuple[int, int], + *, + min_area_ratio: float = 0.04, +) -> bool: + page_area = max(1, source_size[0] * source_size[1]) + return (crop.width * crop.height) / float(page_area) >= min_area_ratio + + +def _feather_outer_alpha_edges( + crop: Image.Image, + *, + edge_width: int = 10, + min_edge_alpha: int = 96, +) -> bool: + if crop.width <= edge_width * 2 or crop.height <= edge_width * 2: + return False + rgba = crop.convert("RGBA") + alpha = rgba.getchannel("A") + alpha_pixels = alpha.load() + changed = False + for y in range(alpha.height): + for x in range(alpha.width): + current = alpha_pixels[x, y] + if current == 0: + continue + distance_to_edge = min(x, y, alpha.width - 1 - x, alpha.height - 1 - y) + if distance_to_edge >= edge_width: + continue + edge_factor = min_edge_alpha + int((255 - min_edge_alpha) * (distance_to_edge / float(edge_width))) + new_alpha = max(1, min(current, (current * edge_factor) // 255)) + if new_alpha != current: + alpha_pixels[x, y] = new_alpha + changed = True + if not changed: + return False + crop.putalpha(alpha) + return True + + +def _suppress_smooth_background_difference_alpha( + crop: Image.Image, + *, + edge_threshold: int = 24, + bright_threshold: int = 62, + max_component_area_ratio: float = 0.55, +) -> bool: + rgba = crop.convert("RGBA") + width, height = rgba.size + if width < 8 or height < 8: + return False + salient = Image.new("L", rgba.size, 0) + salient_pixels = salient.load() + pixels = rgba.load() + for y in range(1, height - 1): + for x in range(1, width - 1): + red, green, blue, alpha = pixels[x, y] + if alpha == 0: + continue + luminance = (red * 299 + green * 587 + blue * 114) // 1000 + right = pixels[x + 1, y] + down = pixels[x, y + 1] + gradient = max( + abs(red - right[0]) + abs(green - right[1]) + abs(blue - right[2]), + abs(red - down[0]) + abs(green - down[1]) + abs(blue - down[2]), + ) + if luminance >= bright_threshold or (gradient >= edge_threshold and luminance >= bright_threshold - 16): + salient_pixels[x, y] = 255 + dilated = salient.filter(ImageFilter.MaxFilter(15)) + dilated_pixels = dilated.load() + edge_points = { + (x, y) + for y in range(height) + for x in range(width) + if dilated_pixels[x, y] > 0 + } + components = _source_connected_components(edge_points) + if not components: + return False + crop_area = max(1, width * height) + keep = Image.new("L", rgba.size, 0) + keep_draw = ImageDraw.Draw(keep) + kept_any = False + for component in components: + if len(component) < 18: + continue + xs = [point[0] for point in component] + ys = [point[1] for point in component] + bbox = ( + max(0, min(xs) - 3), + max(0, min(ys) - 3), + min(width, max(xs) + 4), + min(height, max(ys) + 4), + ) + bbox_area = max(1, (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])) + if bbox_area / float(crop_area) >= max_component_area_ratio: + continue + touches_crop_edge = bbox[0] <= 1 or bbox[1] <= 1 or bbox[2] >= width - 1 or bbox[3] >= height - 1 + spans_crop_edge = (bbox[2] - bbox[0]) >= width * 0.72 or (bbox[3] - bbox[1]) >= height * 0.72 + if touches_crop_edge and spans_crop_edge: + continue + keep_draw.rectangle(bbox, fill=255) + kept_any = True + if not kept_any: + return False + alpha = rgba.getchannel("A") + new_alpha = ImageChops.multiply(alpha, keep) + if new_alpha.getbbox() is None: + return False + crop.putalpha(new_alpha) + return True + + +def _load_optional_background_reference( + background_image_path: str | Path | None, + source_size: tuple[int, int], +) -> Image.Image | None: + if background_image_path is None: + return None + path = Path(background_image_path) + if not path.is_file(): + return None + return Image.open(path).convert("RGB").resize(source_size) + + +def _load_optional_text_mask(text_mask_path: str | Path | None, source_size: tuple[int, int]) -> Image.Image | None: + if text_mask_path is None: + return None + path = Path(text_mask_path) + if not path.is_file(): + return None + return Image.open(path).convert("L").resize(source_size) + + +def _candidate_batches( + candidates: list[ForegroundCandidate], + *, + max_batch_size: int, +) -> list[list[ForegroundCandidate]]: + batch_size = max(1, max_batch_size) + batches: list[list[ForegroundCandidate]] = [] + current: list[ForegroundCandidate] = [] + for candidate in candidates: + if candidate.classification == "complex_whole_visual": + if current: + batches.append(current) + current = [] + batches.append([candidate]) + continue + current.append(candidate) + if len(current) >= batch_size: + batches.append(current) + current = [] + if current: + batches.append(current) + return batches + + +def _default_preview_validator(**kwargs) -> ValidationReport: + if kwargs.get("require_preview_validation") is False: + return ValidationReport(status="passed", checked_pages=1, issues=[]) + gates = quality_threshold_to_preview_gates(float(kwargs.get("preview_similarity_threshold", 0.92))) + return validate_preview_similarity( + source_image_path=kwargs["source_image_path"], + preview=kwargs["preview"], + slide_id=kwargs["slide_id"], + page_index=kwargs["page_index"], + max_mean_abs_delta=gates.max_mean_abs_delta, + max_changed_pixel_ratio=gates.max_changed_pixel_ratio, + ) + + +@dataclass(frozen=True) +class GenerativeEditableExportResult: + status: Literal["passed", "fallback_used"] + output_path: str + fallback_policy: FallbackPolicy + validation_report: ValidationReport + fallback_used: str = "" + fallback_failure_reason: str = "" + + +@dataclass(frozen=True) +class GenerativeEditableSlideInput: + slide_id: str + image_path: str + text_metadata: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class AssetBuildResult: + bitmap_assets: list[BitmapAssetSpec] + asset_sheets: list[AssetSheetSpec] = field(default_factory=list) + repair_attempts: list[RepairAttempt] = field(default_factory=list) + provider_output_payload: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class GenerativeEditablePipelineDependencies: + ocr_provider: OCRProvider + image_edit_provider: ImageEditProvider + image_generation_provider: ImageGenerationProvider + asset_sheet_image_edit_provider: ImageEditProvider | None = None + repair_image_edit_provider: ImageEditProvider | None = None + preview_similarity_threshold: float = 0.92 + require_preview_validation: bool = True + max_repair_attempts: int = 0 + provider_timeout_seconds: int = 180 + provider_max_attempts: int = 1 + provider_retry_backoff_seconds: float = 0.0 + use_aippt_metadata_first: bool = True + ocr_min_confidence: float = 0.75 + allow_metadata_ocr_fallback: bool = False + allow_source_crop_asset_fallback: bool = False + visual_text_candidate_detector: Callable[..., list[tuple[int, int, int, int]]] = ( + _default_visual_text_candidate_detector + ) + foreground_planner: Callable[..., list[ForegroundCandidate]] = plan_foreground_candidates + asset_builder: Callable[..., AssetBuildResult | list[BitmapAssetSpec]] = _build_default_assets + composer: Callable[..., None] = compose_deck_from_manifests + preview_renderer: Callable[..., Any] = render_manifest_preview_with_metadata + structure_validator: Callable[..., ValidationReport] = validate_composed_deck_structure + preview_validator: Callable[..., ValidationReport] = _default_preview_validator + + +class GenerativeEditableValidationError(RuntimeError): + def __init__(self, *, validation_report: ValidationReport, fallback_policy: FallbackPolicy): + self.validation_report = validation_report + self.fallback_policy = fallback_policy + issue_summary = _summarize_validation_issues(validation_report) + super().__init__( + "generative editable validation failed with " + f"fallback_policy={fallback_policy}: {issue_summary}" + ) + + +class GenerativeEditableFallbackError(RuntimeError): + def __init__( + self, + *, + validation_report: ValidationReport, + fallback_policy: FallbackPolicy, + fallback_failure_reason: str, + ): + self.validation_report = validation_report + self.fallback_policy = fallback_policy + self.fallback_failure_reason = fallback_failure_reason + issue_summary = _summarize_validation_issues(validation_report) + super().__init__( + "generative editable fallback failed with " + f"fallback_policy={fallback_policy}: original={issue_summary}; " + f"fallback={fallback_failure_reason}" + ) + + +class GenerativeEditableFallbackNotImplementedError(GenerativeEditableFallbackError): + def __init__(self, *, validation_report: ValidationReport, fallback_policy: FallbackPolicy): + super().__init__( + validation_report=validation_report, + fallback_policy=fallback_policy, + fallback_failure_reason="fallback output factory is not configured", + ) + + +def apply_asset_repair_results( + page_manifest: PageManifest, + repaired_assets: list[BitmapAssetSpec], + repair_attempts: list[RepairAttempt], +) -> PageManifest: + existing_ids = {asset.asset_id for asset in page_manifest.bitmap_assets} + unknown_ids = [asset.asset_id for asset in repaired_assets if asset.asset_id not in existing_ids] + if unknown_ids: + raise ValueError(f"unknown repaired asset ids: {', '.join(sorted(unknown_ids))}") + repaired_by_id = {asset.asset_id: asset for asset in repaired_assets} + bitmap_assets = [ + repaired_by_id.get(asset.asset_id, asset) for asset in page_manifest.bitmap_assets + ] + provenance = dict(page_manifest.provenance) + provenance["asset_repair"] = { + "repaired_asset_ids": [asset.asset_id for asset in repaired_assets], + "attempt_count": len(repair_attempts), + } + return replace( + page_manifest, + bitmap_assets=bitmap_assets, + repair_attempts=[*page_manifest.repair_attempts, *repair_attempts], + provenance=provenance, + ) + + +def finalize_validated_export( + *, + validation_report: ValidationReport, + output_path: str, + fallback_policy: FallbackPolicy | str | None = None, + fallback_output_factory: Callable[[], str] | None = None, +) -> GenerativeEditableExportResult: + policy = _normalize_fallback_policy(fallback_policy) + if validation_report.status == "passed" and not validation_report.issues: + return GenerativeEditableExportResult( + status="passed", + output_path=output_path, + fallback_policy=policy, + validation_report=validation_report, + ) + if policy == "fail": + raise GenerativeEditableValidationError( + validation_report=validation_report, + fallback_policy=policy, + ) + if fallback_output_factory is None: + raise GenerativeEditableFallbackError( + validation_report=validation_report, + fallback_policy=policy, + fallback_failure_reason="fallback output factory is not configured", + ) + try: + fallback_output_path = fallback_output_factory() + except Exception as exc: + raise GenerativeEditableFallbackError( + validation_report=validation_report, + fallback_policy=policy, + fallback_failure_reason=str(exc) or exc.__class__.__name__, + ) from exc + if not fallback_output_path: + raise GenerativeEditableFallbackError( + validation_report=validation_report, + fallback_policy=policy, + fallback_failure_reason="fallback output path is empty", + ) + if not Path(fallback_output_path).is_file(): + raise GenerativeEditableFallbackError( + validation_report=validation_report, + fallback_policy=policy, + fallback_failure_reason=f"fallback output path does not exist: {fallback_output_path}", + ) + return GenerativeEditableExportResult( + status="fallback_used", + output_path=fallback_output_path, + fallback_policy=policy, + validation_report=validation_report, + fallback_used=policy, + ) + + +def with_provider_retries( + dependencies: GenerativeEditablePipelineDependencies, +) -> GenerativeEditablePipelineDependencies: + def retry_image_edit(provider: ImageEditProvider | None) -> ImageEditProvider | None: + if provider is None or isinstance(provider, RetryingImageEditProvider): + return provider + return RetryingImageEditProvider( + provider, + max_attempts=dependencies.provider_max_attempts, + backoff_seconds=dependencies.provider_retry_backoff_seconds, + ) + + image_generation_provider = dependencies.image_generation_provider + if not isinstance(image_generation_provider, RetryingImageGenerationProvider): + image_generation_provider = RetryingImageGenerationProvider( + image_generation_provider, + max_attempts=dependencies.provider_max_attempts, + backoff_seconds=dependencies.provider_retry_backoff_seconds, + ) + + ocr_provider = dependencies.ocr_provider + if not isinstance(ocr_provider, RetryingOCRProvider): + ocr_provider = RetryingOCRProvider( + ocr_provider, + max_attempts=dependencies.provider_max_attempts, + backoff_seconds=dependencies.provider_retry_backoff_seconds, + ) + + return replace( + dependencies, + ocr_provider=ocr_provider, + image_edit_provider=retry_image_edit(dependencies.image_edit_provider), + asset_sheet_image_edit_provider=retry_image_edit(dependencies.asset_sheet_image_edit_provider), + repair_image_edit_provider=retry_image_edit(dependencies.repair_image_edit_provider), + image_generation_provider=image_generation_provider, + ) + + +def run_generative_editable_pipeline( + *, + slides: list[GenerativeEditableSlideInput], + output_path: str, + artifact_root: str, + job_id: str, + dependencies: GenerativeEditablePipelineDependencies, + aspect_ratio: str = "16:9", + fallback_policy: FallbackPolicy | str | None = None, + fallback_output_factory: Callable[[], str] | None = None, + max_page_concurrency: int = 1, + cleanup_artifacts: bool = False, +) -> GenerativeEditableExportResult: + if not slides: + raise ValueError("slides are required") + if max_page_concurrency < 1: + raise ValueError("max_page_concurrency must be positive") + policy = _normalize_fallback_policy(fallback_policy) + retry_dependencies = with_provider_retries(dependencies) + effective_dependencies = replace( + retry_dependencies, + allow_metadata_ocr_fallback=retry_dependencies.allow_metadata_ocr_fallback and policy != "fail", + allow_source_crop_asset_fallback=retry_dependencies.allow_source_crop_asset_fallback, + ) + try: + return _run_generative_editable_pipeline_impl( + slides=slides, + output_path=output_path, + artifact_root=artifact_root, + job_id=job_id, + dependencies=effective_dependencies, + aspect_ratio=aspect_ratio, + policy=policy, + fallback_output_factory=fallback_output_factory, + max_page_concurrency=max_page_concurrency, + cleanup_artifacts=cleanup_artifacts, + ) + except (GenerativeEditableValidationError, GenerativeEditableFallbackError): + raise + except ValueError: + raise + except (ProviderTimeoutError, ProviderError) as exc: + if policy == "fail": + raise + validation_report = ValidationReport( + status="failed", + checked_pages=len(slides), + issues=[ + ValidationIssue( + code="provider_failure", + message=str(exc) or exc.__class__.__name__, + ) + ], + ) + return finalize_validated_export( + validation_report=validation_report, + output_path=output_path, + fallback_policy=policy, + fallback_output_factory=fallback_output_factory, + ) + except Exception as exc: + validation_report = ValidationReport( + status="failed", + checked_pages=len(slides), + issues=[ + ValidationIssue( + code="pipeline_orchestration_failed", + message=str(exc) or exc.__class__.__name__, + ) + ], + ) + return finalize_validated_export( + validation_report=validation_report, + output_path=output_path, + fallback_policy=policy, + fallback_output_factory=fallback_output_factory, + ) + + +def _run_generative_editable_pipeline_impl( + *, + slides: list[GenerativeEditableSlideInput], + output_path: str, + artifact_root: str, + job_id: str, + dependencies: GenerativeEditablePipelineDependencies, + aspect_ratio: str, + policy: FallbackPolicy, + fallback_output_factory: Callable[[], str] | None, + max_page_concurrency: int, + cleanup_artifacts: bool, +) -> GenerativeEditableExportResult: + artifacts = GenerativeEditableJobArtifacts(root_dir=artifact_root, job_id=job_id) + if cleanup_artifacts: + _ensure_output_outside_job_dir(output_path, artifacts.job_dir) + try: + indexed_page_manifests = _build_page_manifests( + slides=slides, + aspect_ratio=aspect_ratio, + artifacts=artifacts, + dependencies=dependencies, + max_page_concurrency=max_page_concurrency, + ) + page_manifests = [page for _, page in indexed_page_manifests] + page_manifest_refs: list[str] = [] + slide_order = [slide.slide_id for slide in slides] + + for page_index, page_manifest in indexed_page_manifests: + with _record_stage( + artifacts, + stage="write_page_manifest", + page_index=page_index, + slide_id=page_manifest.slide_id, + ): + page_manifest_path = artifacts.page_manifest_path(page_manifest.slide_id, page_index) + write_manifest(page_manifest_path, page_manifest) + page_manifest_refs.append(_job_relative(page_manifest_path, artifacts.job_dir)) + + deck_manifest = DeckManifest( + job_id=artifacts.job_id, + slide_order=slide_order, + aspect_ratio=aspect_ratio, + provider_roles={ + "ocr": dependencies.ocr_provider.config.role, + "image_edit": dependencies.image_edit_provider.config.role, + "image_generation": dependencies.image_generation_provider.config.role, + }, + quality_settings={ + "preview_similarity_threshold": dependencies.preview_similarity_threshold + }, + fallback_policy=policy, + page_manifest_paths=page_manifest_refs, + validation_status="pending", + provenance={"max_page_concurrency": max_page_concurrency}, + ) + write_manifest(artifacts.deck_manifest_path, deck_manifest) + + with _record_stage(artifacts, stage="compose_deck"): + dependencies.composer( + artifacts.deck_manifest_path, + artifacts.job_dir, + output_path, + ) + validation_report, page_statuses = _validate_pipeline_output( + deck_manifest_path=artifacts.deck_manifest_path, + artifact_root=artifacts.job_dir, + pptx_path=output_path, + page_manifests=page_manifests, + dependencies=dependencies, + ) + final_status = "passed" if validation_report.status == "passed" else "failed" + _rewrite_validation_statuses( + artifacts=artifacts, + deck_manifest=deck_manifest, + page_manifests=page_manifests, + deck_validation_status=final_status, + page_validation_statuses=page_statuses, + ) + return finalize_validated_export( + validation_report=validation_report, + output_path=output_path, + fallback_policy=policy, + fallback_output_factory=fallback_output_factory, + ) + finally: + if cleanup_artifacts: + artifacts.cleanup() + + +def _build_page_manifests( + *, + slides: list[GenerativeEditableSlideInput], + aspect_ratio: str, + artifacts: GenerativeEditableJobArtifacts, + dependencies: GenerativeEditablePipelineDependencies, + max_page_concurrency: int, +) -> list[tuple[int, PageManifest]]: + def build(indexed_slide: tuple[int, GenerativeEditableSlideInput]) -> tuple[int, PageManifest]: + page_index, slide = indexed_slide + return ( + page_index, + _build_one_slide_manifest( + slide=slide, + page_index=page_index, + aspect_ratio=aspect_ratio, + artifacts=artifacts, + dependencies=dependencies, + ), + ) + + indexed_slides = list(enumerate(slides)) + if max_page_concurrency == 1 or len(indexed_slides) == 1: + return [build(indexed_slide) for indexed_slide in indexed_slides] + with ThreadPoolExecutor(max_workers=max_page_concurrency) as executor: + built = list(executor.map(build, indexed_slides)) + return sorted(built, key=lambda item: item[0]) + + +def _build_one_slide_manifest( + *, + slide: GenerativeEditableSlideInput, + page_index: int, + aspect_ratio: str, + artifacts: GenerativeEditableJobArtifacts, + dependencies: GenerativeEditablePipelineDependencies, +) -> PageManifest: + source_path = artifacts.asset_path(slide.slide_id, page_index, "sources", "source.png") + shutil.copyfile(slide.image_path, source_path) + with Image.open(source_path) as image: + source_image_size = image.size + source_ref = _job_relative(source_path, artifacts.job_dir) + + with _record_stage( + artifacts, + stage="ocr", + page_index=page_index, + slide_id=slide.slide_id, + provider_role=dependencies.ocr_provider.config.role, + provider=dependencies.ocr_provider.config.model, + ): + try: + ocr_result = dependencies.ocr_provider.extract_text(str(source_path)) + except (ProviderError, ProviderTimeoutError): + if ( + not dependencies.allow_metadata_ocr_fallback + or not dependencies.use_aippt_metadata_first + or not slide.text_metadata + ): + raise + ocr_result = _metadata_only_ocr_result( + source_image_path=str(source_path), + source_image_size=source_image_size, + text_metadata=slide.text_metadata, + ) + ocr_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "ocr", + "ocr.json", + _ocr_result_payload(ocr_result), + ) + text_metadata = slide.text_metadata if dependencies.use_aippt_metadata_first else [] + text_result = extract_text_with_validation( + text_metadata, + ocr_result, + min_confidence=dependencies.ocr_min_confidence, + ) + text_boxes = list(text_result.text_boxes) + visual_text_candidates = dependencies.visual_text_candidate_detector( + source_image_path=str(source_path), + source_image_size=source_image_size, + text_boxes=text_boxes, + ) + quality_gate_candidates = _quality_gate_visual_text_candidates( + visual_text_candidates, + source_image_size, + ) + page_text_analysis = build_page_text_analysis( + text_boxes=text_boxes, + visual_text_candidates=visual_text_candidates, + source_image_size=source_image_size, + ) + text_boxes = list(page_text_analysis.accepted_text_boxes) + unanchored_approximate_issues = [ + { + "code": hint.reason, + "severity": "warning", + "ocr_text": hint.text, + "source_pixel_bbox": hint.source_pixel_bbox, + } + for hint in page_text_analysis.rejected_ocr_hints + ] + visual_text_issues = _visual_text_coverage_issues( + text_boxes, + visual_text_candidates=quality_gate_candidates, + ) + ignored_visual_text_candidates: set[tuple[int, int, int, int]] = set() + with _record_stage( + artifacts, + stage="focused_ocr_recovery", + page_index=page_index, + slide_id=slide.slide_id, + provider_role=dependencies.ocr_provider.config.role, + provider=dependencies.ocr_provider.config.model, + ): + recovered_text_boxes = _recover_visual_text_candidates( + source_image_path=source_path, + source_image_size=source_image_size, + text_boxes=text_boxes, + uncovered_candidates=_focused_recovery_candidate_bboxes( + text_boxes=text_boxes, + text_issues=text_result.issues, + visual_text_candidates=visual_text_candidates, + quality_gate_candidates=quality_gate_candidates, + source_image_size=source_image_size, + ), + ocr_provider=dependencies.ocr_provider, + crop_dir=( + artifacts.job_dir + / "provider_outputs" + / "ocr" + / f"{page_index:04d}-{_safe_name(slide.slide_id)}" + / "focused-crops" + ), + ignored_noise_candidates=ignored_visual_text_candidates, + min_confidence=dependencies.ocr_min_confidence, + ) + if recovered_text_boxes: + text_boxes = _remove_approximate_text_boxes_replaced_by_focused_recovery( + text_boxes, + recovered_text_boxes, + ) + text_boxes.extend(recovered_text_boxes) + visual_text_issues = _visual_text_coverage_issues( + text_boxes, + visual_text_candidates=quality_gate_candidates, + non_blocking_visual_text_candidates=ignored_visual_text_candidates, + ) + elif ignored_visual_text_candidates: + visual_text_issues = _visual_text_coverage_issues( + text_boxes, + visual_text_candidates=quality_gate_candidates, + non_blocking_visual_text_candidates=ignored_visual_text_candidates, + ) + text_issues = [ + *text_result.issues, + *unanchored_approximate_issues, + *visual_text_issues, + ] + use_source_raster_guardrail = _should_use_source_raster_guardrail( + text_boxes=text_boxes, + text_issues=text_issues, + source_image_size=source_image_size, + ) + source_raster_anchor_text_boxes = list(text_boxes) + if use_source_raster_guardrail: + text_boxes = _source_raster_guardrail_text_boxes( + text_boxes, + source_image_size=source_image_size, + ) + text_validation_status = "failed" if _has_blocking_text_issue(text_issues) else "passed" + text_mask_path = artifacts.asset_path(slide.slide_id, page_index, "assets", "text-mask.png") + with _record_stage( + artifacts, + stage="text_mask", + page_index=page_index, + slide_id=slide.slide_id, + ): + _create_text_exclusion_mask( + source_image_path=source_path, + source_image_size=source_image_size, + text_boxes=text_boxes, + ).save(text_mask_path) + text_mask_ref = _job_relative(text_mask_path, artifacts.job_dir) + + if _should_use_text_editable_source_preserving_fast_path( + text_boxes=text_boxes, + visual_text_candidates=visual_text_candidates, + text_issues=text_issues, + ): + low_opacity_text_boxes = _source_preserving_low_opacity_text_boxes(text_boxes) + bitmap_assets: list[BitmapAssetSpec] = [] + fast_background = create_source_preserving_text_background( + source_image_path=source_path, + text_bboxes=[], + output_asset_path=artifacts.asset_path( + slide.slide_id, + page_index, + "backgrounds", + "source-preserving-text-background.png", + ), + asset_root=artifacts.job_dir, + ) + image_edit_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "image_edit", + "backgrounds.json", + { + "chosen_base_background": asdict(fast_background), + "source_preserving_background": asdict(fast_background), + "fast_path": "source_preserving_low_opacity_text_overlay", + }, + ) + image_generation_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "image_generation", + "assets.json", + { + "candidate_ids": [], + "asset_ids": [asset.asset_id for asset in bitmap_assets], + "assets": [asdict(asset) for asset in bitmap_assets], + "asset_sheets": [], + "fast_path": "source_preserving_low_opacity_text_overlay", + }, + ) + page = PageManifest( + slide_id=slide.slide_id, + page_index=page_index, + source_image_path=source_ref, + source_image_size=source_image_size, + slide_size=_slide_size_inches(aspect_ratio), + text_boxes=low_opacity_text_boxes, + foreground_candidates=[], + native_shapes=[], + bitmap_assets=bitmap_assets, + asset_sheets=[], + repair_attempts=[], + provider_output_paths={ + "ocr": _job_relative(ocr_output_path, artifacts.job_dir), + "image_edit": _job_relative(image_edit_output_path, artifacts.job_dir), + "image_generation": _job_relative( + image_generation_output_path, + artifacts.job_dir, + ), + }, + provenance={ + "text_validation_status": text_validation_status, + "text_issues": text_issues, + "text_mask_path": text_mask_ref, + "recovered_text_count": len(recovered_text_boxes), + "chosen_background_kind": "source_preserving_low_opacity_text_overlay", + "fast_path_reason": "dense_or_unstable_text_layout", + "text_overlay_opacity": 0.1, + }, + validation_status="pending", + ) + page = update_page_manifest_backgrounds( + page, + fast_background, + fast_background, + chosen_background=fast_background.artifact_path, + ) + provenance = dict(page.provenance) + backgrounds = dict(provenance.get("backgrounds", {})) + backgrounds["source_preserving"] = _background_result_manifest_record(fast_background) + provenance["backgrounds"] = backgrounds + return replace(page, provenance=provenance) + + if use_source_raster_guardrail: + source_raster_background = create_source_raster_background( + source_image_path=source_path, + output_asset_path=artifacts.asset_path( + slide.slide_id, + page_index, + "backgrounds", + "source-raster-background.png", + ), + asset_root=artifacts.job_dir, + ) + bitmap_assets = _build_source_preserving_anchor_assets( + source_image_path=str(source_path), + text_boxes=source_raster_anchor_text_boxes, + output_dir=( + artifacts.job_dir + / "assets" + / f"{page_index:04d}-{_safe_name(slide.slide_id)}" + ), + asset_root=artifacts.job_dir, + start_z_order=1, + ) + image_edit_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "image_edit", + "backgrounds.json", + { + "text_clean_background": asdict(source_raster_background), + "base_clean_background": asdict(source_raster_background), + "chosen_base_background": asdict(source_raster_background), + "source_raster_background": asdict(source_raster_background), + }, + ) + image_generation_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "image_generation", + "assets.json", + { + "candidate_ids": [], + "asset_ids": [asset.asset_id for asset in bitmap_assets], + "assets": [asdict(asset) for asset in bitmap_assets], + "asset_sheets": [], + "guardrail": "source_raster", + }, + ) + page = PageManifest( + slide_id=slide.slide_id, + page_index=page_index, + source_image_path=source_ref, + source_image_size=source_image_size, + slide_size=_slide_size_inches(aspect_ratio), + text_boxes=[], + foreground_candidates=[], + native_shapes=[], + bitmap_assets=bitmap_assets, + asset_sheets=[], + repair_attempts=[], + provider_output_paths={ + "ocr": _job_relative(ocr_output_path, artifacts.job_dir), + "image_edit": _job_relative(image_edit_output_path, artifacts.job_dir), + "image_generation": _job_relative( + image_generation_output_path, + artifacts.job_dir, + ), + }, + provenance={ + "text_validation_status": "degraded", + "text_issues": text_issues, + "text_mask_path": text_mask_ref, + "recovered_text_count": len(recovered_text_boxes), + "source_raster_guardrail": { + "reason": "unreliable_ocr_layout", + "dropped_text_box_count": len(source_raster_anchor_text_boxes), + }, + }, + validation_status="pending", + ) + page = update_page_manifest_backgrounds( + page, + source_raster_background, + source_raster_background, + chosen_background=source_raster_background.artifact_path, + ) + provenance = dict(page.provenance) + backgrounds = dict(provenance.get("backgrounds", {})) + backgrounds["source_raster"] = _background_result_manifest_record( + source_raster_background + ) + provenance["backgrounds"] = backgrounds + provenance["chosen_background_kind"] = "source_raster_guardrail" + return replace(page, provenance=provenance) + + with _record_stage( + artifacts, + stage="text_clean_background", + page_index=page_index, + slide_id=slide.slide_id, + provider_role=dependencies.image_edit_provider.config.role, + provider=dependencies.image_edit_provider.config.model, + timeout_seconds=dependencies.provider_timeout_seconds, + ): + text_clean = create_text_clean_background( + source_image_path=source_path, + text_mask_path=text_mask_path, + output_asset_path=artifacts.asset_path( + slide.slide_id, + page_index, + "backgrounds", + "text-clean.png", + ), + asset_root=artifacts.job_dir, + edit_provider=dependencies.image_edit_provider, + timeout_seconds=dependencies.provider_timeout_seconds, + ) + with _record_stage( + artifacts, + stage="base_clean_background", + page_index=page_index, + slide_id=slide.slide_id, + provider_role=dependencies.image_edit_provider.config.role, + provider=dependencies.image_edit_provider.config.model, + timeout_seconds=dependencies.provider_timeout_seconds, + ): + base_clean = create_base_clean_background( + source_image_path=source_path, + output_asset_path=artifacts.asset_path( + slide.slide_id, + page_index, + "backgrounds", + "base-clean.png", + ), + asset_root=artifacts.job_dir, + edit_provider=dependencies.image_edit_provider, + text_mask_path=text_mask_path, + removal_bboxes=[text_box.source_pixel_bbox for text_box in text_boxes], + timeout_seconds=dependencies.provider_timeout_seconds, + ) + original_base_clean = base_clean + if _should_choose_text_clean_background( + source_image_path=source_path, + base_clean_image_path=base_clean.output_asset_path, + text_clean_image_path=text_clean.output_asset_path, + text_boxes=text_boxes, + ): + base_clean = text_clean + + candidates = dependencies.foreground_planner( + source_image_path=str(source_path), + base_clean_image_path=base_clean.output_asset_path, + text_mask_path=str(text_mask_path), + ) + candidates = [ + *candidates, + *_detect_source_native_shape_candidates( + source_image_path=str(source_path), + text_mask_path=str(text_mask_path), + ), + ] + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + candidates, + source_image_path=str(source_path), + ) + bitmap_candidates = _augment_bitmap_candidates_with_generic_visual_anchors( + bitmap_candidates, + source_image_path=str(source_path), + text_boxes=text_boxes, + native_shapes=native_shapes, + ) + with _record_stage( + artifacts, + stage="asset_build", + page_index=page_index, + slide_id=slide.slide_id, + provider_role=dependencies.image_generation_provider.config.role, + provider=dependencies.image_generation_provider.config.model, + timeout_seconds=dependencies.provider_timeout_seconds, + ): + asset_build_result = _coerce_asset_build_result( + dependencies.asset_builder( + source_image_path=str(source_path), + text_mask_path=str(text_mask_path), + asset_reference_image_path=_select_asset_reference_image( + source_image_path=source_path, + text_clean_image_path=text_clean.output_asset_path, + text_mask_path=text_mask_path, + ), + background_reference_image_path=base_clean.output_asset_path, + candidates=bitmap_candidates, + output_dir=str( + artifacts.job_dir + / "assets" + / f"{page_index:04d}-{_safe_name(slide.slide_id)}" + ), + asset_root=str(artifacts.job_dir), + image_generation_provider=dependencies.image_generation_provider, + image_edit_provider=dependencies.image_edit_provider, + asset_sheet_image_edit_provider=dependencies.asset_sheet_image_edit_provider + or dependencies.image_edit_provider, + repair_image_edit_provider=dependencies.repair_image_edit_provider + or dependencies.image_edit_provider, + max_repair_attempts=dependencies.max_repair_attempts, + provider_timeout_seconds=dependencies.provider_timeout_seconds, + allow_source_crop_fallback=dependencies.allow_source_crop_asset_fallback, + slide_id=slide.slide_id, + page_index=page_index, + ) + ) + bitmap_assets = asset_build_result.bitmap_assets + repair_attempts = asset_build_result.repair_attempts + bitmap_assets = _bitmap_assets_for_clean_background(bitmap_assets, source_image_size) + native_shapes = _drop_native_shapes_covered_by_complex_bitmap_assets( + native_shapes, + bitmap_assets, + ) + text_boxes, covered_text_issues = _drop_text_boxes_covered_by_complex_bitmap_assets( + text_boxes, + bitmap_assets, + ) + text_issues = [*text_issues, *covered_text_issues] + source_preserving_background = None + chosen_background_kind = "" + if _should_use_local_reconstruction_background( + source_image_path=source_path, + native_shapes=native_shapes, + bitmap_assets=bitmap_assets, + text_boxes=text_boxes, + ): + base_clean = _create_local_reconstruction_background( + source_image_path=source_path, + output_asset_path=artifacts.asset_path( + slide.slide_id, + page_index, + "backgrounds", + "local-reconstruction-background.png", + ), + asset_root=artifacts.job_dir, + foreground_bboxes=[shape.source_pixel_bbox for shape in native_shapes] + + [text_box.source_pixel_bbox for text_box in text_boxes], + ) + elif _should_choose_source_preserving_background( + source_image_path=source_path, + chosen_background_image_path=base_clean.output_asset_path, + text_clean_image_path=text_clean.output_asset_path, + text_boxes=text_boxes, + native_shapes=native_shapes, + bitmap_assets=bitmap_assets, + source_image_size=source_image_size, + ): + source_preserving_background = create_source_preserving_text_background( + source_image_path=source_path, + text_bboxes=[text_box.source_pixel_bbox for text_box in text_boxes], + output_asset_path=artifacts.asset_path( + slide.slide_id, + page_index, + "backgrounds", + "source-preserving-text-background.png", + ), + asset_root=artifacts.job_dir, + ) + base_clean = source_preserving_background + chosen_background_kind = "source_preserving_text_clean" + bitmap_assets = _bitmap_assets_for_source_preserving_background( + bitmap_assets, + source_image_size, + ) + if not bitmap_assets: + bitmap_assets = _build_source_preserving_anchor_assets( + source_image_path=str(source_path), + text_boxes=text_boxes, + output_dir=( + artifacts.job_dir + / "assets" + / f"{page_index:04d}-{_safe_name(slide.slide_id)}" + ), + asset_root=artifacts.job_dir, + start_z_order=1, + ) + native_shapes = _native_shapes_for_source_preserving_background(native_shapes) + image_edit_payload = { + "text_clean_background": asdict(text_clean), + "base_clean_background": asdict(original_base_clean), + "chosen_base_background": asdict(base_clean), + } + if source_preserving_background is not None: + image_edit_payload["source_preserving_background"] = asdict(source_preserving_background) + image_edit_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "image_edit", + "backgrounds.json", + image_edit_payload, + ) + image_generation_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "image_generation", + "assets.json", + { + **asset_build_result.provider_output_payload, + "candidate_ids": [candidate.candidate_id for candidate in candidates], + "asset_ids": [asset.asset_id for asset in bitmap_assets], + "assets": [asdict(asset) for asset in bitmap_assets], + "asset_sheets": [asdict(sheet) for sheet in asset_build_result.asset_sheets], + }, + ) + provider_output_paths = { + "ocr": _job_relative(ocr_output_path, artifacts.job_dir), + "image_edit": _job_relative(image_edit_output_path, artifacts.job_dir), + "image_generation": _job_relative(image_generation_output_path, artifacts.job_dir), + } + if repair_attempts: + repair_output_path = artifacts.write_provider_output( + slide.slide_id, + page_index, + "repair", + "repairs.json", + {"repair_attempts": [asdict(attempt) for attempt in repair_attempts]}, + ) + provider_output_paths["repair"] = _job_relative(repair_output_path, artifacts.job_dir) + page = PageManifest( + slide_id=slide.slide_id, + page_index=page_index, + source_image_path=source_ref, + source_image_size=source_image_size, + slide_size=_slide_size_inches(aspect_ratio), + text_boxes=text_boxes, + foreground_candidates=foreground_candidates_to_manifest_specs(candidates), + native_shapes=native_shapes, + bitmap_assets=bitmap_assets, + asset_sheets=asset_build_result.asset_sheets, + repair_attempts=repair_attempts, + provider_output_paths=provider_output_paths, + provenance={ + "text_validation_status": text_validation_status, + "text_issues": text_issues, + "text_mask_path": text_mask_ref, + "recovered_text_count": len(recovered_text_boxes), + "page_text_analysis": { + "accepted_text_count": len(page_text_analysis.accepted_text_boxes), + "rejected_ocr_hint_count": len(page_text_analysis.rejected_ocr_hints), + "visual_text_candidate_count": len(page_text_analysis.visual_text_candidates), + }, + }, + validation_status="pending", + ) + page = update_page_manifest_backgrounds( + page, + text_clean, + base_clean, + chosen_background=base_clean.artifact_path, + ) + if source_preserving_background is None: + return page + provenance = dict(page.provenance) + backgrounds = dict(provenance.get("backgrounds", {})) + if source_preserving_background is not None: + backgrounds["source_preserving"] = _background_result_manifest_record( + source_preserving_background + ) + provenance["backgrounds"] = backgrounds + provenance["chosen_background_kind"] = chosen_background_kind + return replace(page, provenance=provenance) + + +def _create_text_exclusion_mask( + *, + source_image_path: str | Path, + source_image_size: tuple[int, int], + text_boxes: list, +) -> Image.Image: + mask = create_text_mask(source_image_size, text_boxes, padding=2) + if not text_boxes: + return mask + try: + from .generative_editable_providers import _estimate_text_line_layouts + + text_lines = [ + str(getattr(text_box, "text", "")).strip() + for text_box in text_boxes + if str(getattr(text_box, "text", "")).strip() + ] + if not text_lines: + return mask + estimates = _estimate_text_line_layouts( + source_image_path, + source_image_size, + text_lines=text_lines, + max_lines=len(text_lines), + ) + except Exception: + return mask + if not estimates: + return mask + draw = ImageDraw.Draw(mask) + for estimate in estimates: + if estimate is None: + continue + bbox, _color_hex, _font_size = estimate + left, top, right, bottom = _clamp_bbox_to_size( + ( + bbox[0] - 4, + bbox[1] - 4, + bbox[2] + 4, + bbox[3] + 4, + ), + source_image_size, + ) + if right > left and bottom > top: + draw.rectangle((left, top, right, bottom), fill=255) + return mask + + +def _select_asset_reference_image( + *, + source_image_path: str | Path, + text_clean_image_path: str | Path, + text_mask_path: str | Path, + max_unmasked_changed_ratio: float = 0.08, + max_unmasked_mean_delta: float = 10.0, +) -> str: + if not Path(text_clean_image_path).is_file(): + return str(source_image_path) + try: + changed_ratio, mean_delta = _unmasked_image_delta( + source_image_path=source_image_path, + candidate_image_path=text_clean_image_path, + text_mask_path=text_mask_path, + ) + except Exception: + return str(source_image_path) + if changed_ratio > max_unmasked_changed_ratio or mean_delta > max_unmasked_mean_delta: + return str(source_image_path) + return str(text_clean_image_path) + + +def _unmasked_image_delta( + *, + source_image_path: str | Path, + candidate_image_path: str | Path, + text_mask_path: str | Path, + threshold: int = 18, +) -> tuple[float, float]: + with Image.open(source_image_path) as source, Image.open(candidate_image_path) as candidate: + source_rgb = source.convert("RGB") + candidate_rgb = candidate.convert("RGB").resize(source_rgb.size) + with Image.open(text_mask_path) as mask_image: + mask = mask_image.convert("L").resize(source_rgb.size) + changed = 0 + total = 0 + delta_sum = 0.0 + source_pixels = source_rgb.load() + candidate_pixels = candidate_rgb.load() + mask_pixels = mask.load() + for y in range(source_rgb.height): + for x in range(source_rgb.width): + if mask_pixels[x, y] > 0: + continue + total += 1 + delta = _color_distance(source_pixels[x, y], candidate_pixels[x, y]) + delta_sum += delta + if delta >= threshold: + changed += 1 + if total == 0: + return 0.0, 0.0 + return changed / float(total), delta_sum / float(total) + + +def _validate_pipeline_output( + *, + deck_manifest_path: Path, + artifact_root: Path, + pptx_path: str, + page_manifests: list[PageManifest], + dependencies: GenerativeEditablePipelineDependencies, +) -> tuple[ValidationReport, dict[str, Literal["passed", "failed"]]]: + structure_report = dependencies.structure_validator( + deck_manifest_path=deck_manifest_path, + artifact_root=artifact_root, + pptx_path=pptx_path, + ) + reports = [structure_report] + page_statuses: dict[str, Literal["passed", "failed"]] = {} + structure_failed = structure_report.status != "passed" or bool(structure_report.issues) + for page in page_manifests: + page_reports = [ + _text_validation_report(page), + _repair_validation_report(page), + _bitmap_coverage_validation_report(page), + ] + preview = _render_preview( + dependencies.preview_renderer, + page, + artifact_root, + pptx_path=pptx_path, + ) + page_reports.append( + dependencies.preview_validator( + source_image_path=artifact_root / page.source_image_path, + preview=preview, + slide_id=page.slide_id, + page_index=page.page_index, + preview_similarity_threshold=dependencies.preview_similarity_threshold, + require_preview_validation=dependencies.require_preview_validation, + ) + ) + reports.extend(page_reports) + page_failed = any(report.status != "passed" or report.issues for report in page_reports) + page_statuses[page.slide_id] = ( + "failed" if structure_failed or page_failed else "passed" + ) + return _merge_validation_reports(reports, checked_pages=len(page_manifests)), page_statuses + + +def _render_preview(preview_renderer, page: PageManifest, artifact_root: Path, *, pptx_path: str): + if "pptx_path" in inspect.signature(preview_renderer).parameters: + return preview_renderer(page, artifact_root, pptx_path=pptx_path) + return preview_renderer(page, artifact_root) + + +def _create_asset_sheet( + *, + request, + candidates: list[ForegroundCandidate], + image_edit_provider: ImageEditProvider, + image_generation_provider: ImageGenerationProvider, + source_image_path: str, +) -> tuple[Any, dict[str, Any]]: + try: + return image_edit_provider.edit(request), {} + except ProviderError as exc: + generation_result = image_generation_provider.generate( + ImageGenerationRequest( + prompt_id=request.prompt_id, + prompt=_asset_sheet_generation_prompt(request.prompt, candidates), + output_asset_path=request.output_asset_path, + asset_root=request.asset_root, + visual_reference={ + "source_image_path": source_image_path, + "candidate_boxes": request.metadata.get("candidate_boxes", []), + "edit_failure": str(exc), + }, + timeout_seconds=request.timeout_seconds, + metadata={ + "fallback_from": "image_edit", + "edit_provider_role": image_edit_provider.config.role, + "edit_failure": str(exc), + }, + ) + ) + return generation_result, { + "fallback_from": "image_edit", + "edit_failure": str(exc), + } + + +def _asset_sheet_generation_prompt( + edit_prompt: str, + candidates: list[ForegroundCandidate], +) -> str: + candidate_lines = [ + f"- {candidate.candidate_id}: source bbox {candidate.source_pixel_bbox}" + for candidate in candidates + ] + return ( + f"{edit_prompt}\n\n" + "If source-image editing is unavailable, generate a transparent PNG asset sheet on a " + "presentation-slide canvas. Preserve the listed candidate ids and approximate source " + "bbox positions so deterministic slicing can extract each asset.\n" + + "\n".join(candidate_lines) + ) + + +def _repair_or_replace_provider_asset_mismatches( + bitmap_assets: list[BitmapAssetSpec], + *, + source_image_path: str, + output_dir: Path, + asset_root: Path, + repair_provider: ImageEditProvider, + max_repair_attempts: int, + provider_timeout_seconds: int, + allow_source_crop_fallback: bool = False, +) -> tuple[list[BitmapAssetSpec], list[RepairAttempt]]: + replaced: list[BitmapAssetSpec] = [] + repair_attempts: list[RepairAttempt] = [] + failed_assets: list[BitmapAssetSpec] = [] + failure_reasons: dict[str, list[str]] = {} + output_dir.mkdir(parents=True, exist_ok=True) + for asset in bitmap_assets: + asset_path = asset_root / asset.asset_path + qa = validate_sliced_asset( + asset_path=asset_path, + expected_object_count=_expected_asset_object_count(asset), + min_opaque_area_ratio=0.005, + source_image_path=source_image_path, + source_pixel_bbox=asset.source_pixel_bbox, + min_source_color_similarity=0.55, + ) + if qa.status == "passed": + replaced.append(asset) + continue + failed_assets.append(asset) + failure_reasons[asset.asset_id] = list(qa.reasons) + if failed_assets: + repairable_failed_assets = [ + asset + for asset in failed_assets + if max_repair_attempts > 0 and _should_repair_asset(failure_reasons.get(asset.asset_id, [])) + ] + try: + if repairable_failed_assets: + repaired, repair_attempts = repair_failed_assets( + source_image_path=source_image_path, + failed_assets=repairable_failed_assets, + failure_reasons=failure_reasons, + output_dir=output_dir / "repairs", + asset_root=asset_root, + edit_provider=repair_provider, + max_attempts=max_repair_attempts, + timeout_seconds=provider_timeout_seconds, + ) + else: + repaired, repair_attempts = [], [] + except ProviderError as exc: + repaired = [] + repair_attempts = [ + RepairAttempt( + target_id=asset.asset_id, + attempt_index=1, + reason=f"repair_provider_failed:{exc}", + provider_role=repair_provider.config.role, + status="failed", + ) + for asset in failed_assets + ] + repaired_by_id = {asset.asset_id: asset for asset in repaired} + for asset in failed_assets: + repaired_asset = repaired_by_id.get(asset.asset_id) + if repaired_asset is not None: + replaced.append(repaired_asset) + continue + qa_reasons = failure_reasons.get(asset.asset_id, ["asset_quality_failed"]) + if not allow_source_crop_fallback: + raise ProviderError( + provider_role=repair_provider.config.role, + operation="asset_quality", + message=( + f"asset quality failed for {asset.asset_id}: " + + ",".join(qa_reasons) + ), + retryable=False, + ) + replaced.append( + _source_crop_replacement_asset( + asset, + source_image_path=source_image_path, + output_dir=output_dir, + asset_root=asset_root, + qa_reasons=qa_reasons, + ) + ) + return replaced, repair_attempts + + +def _expected_asset_object_count(asset: BitmapAssetSpec) -> int | None: + if asset.provenance.get("candidate_classification") == "complex_whole_visual": + return None + return 1 + + +def _source_crop_replacement_asset( + asset: BitmapAssetSpec, + *, + source_image_path: str, + output_dir: Path, + asset_root: Path, + qa_reasons: list[str], +) -> BitmapAssetSpec: + crop_path = output_dir / f"{_safe_name(asset.asset_id)}.source-crop.png" + with Image.open(source_image_path) as source: + source.convert("RGBA").crop(asset.source_pixel_bbox).save(crop_path) + return replace( + asset, + asset_path=_job_relative(crop_path, asset_root), + provenance={ + **asset.provenance, + "fallback": "source_crop_after_provider_qa", + "qa_reasons": qa_reasons, + }, + ) + + +def _should_repair_asset(reasons: list[str]) -> bool: + return "baked_text_contamination" in reasons + + +def _build_source_crop_assets( + *, + candidates: list[ForegroundCandidate], + source_image_path: str, + output_dir: Path, + asset_root: Path, + fallback_reason: str, + failure: str, +) -> list[BitmapAssetSpec]: + output_dir.mkdir(parents=True, exist_ok=True) + assets: list[BitmapAssetSpec] = [] + with Image.open(source_image_path) as source: + source_rgba = source.convert("RGBA") + for index, candidate in enumerate(candidates, start=1): + crop_path = output_dir / f"{_safe_name(candidate.candidate_id)}.source-crop.png" + source_rgba.crop(candidate.source_pixel_bbox).save(crop_path) + assets.append( + BitmapAssetSpec( + asset_id=candidate.candidate_id, + source_pixel_bbox=candidate.source_pixel_bbox, + asset_path=_job_relative(crop_path, asset_root), + z_order=index, + provenance={ + "fallback": fallback_reason, + "failure": failure, + }, + ) + ) + return assets + + +def _plan_reconstruction_targets( + candidates: list[ForegroundCandidate], + *, + source_image_path: str, +) -> tuple[list[NativeShapeSpec], list[ForegroundCandidate]]: + native_shapes: list[NativeShapeSpec] = [] + bitmap_candidates: list[ForegroundCandidate] = [] + with Image.open(source_image_path) as source: + source_size = source.size + source_rgb = source.convert("RGB").copy() + source_background = _estimate_background_color(source_rgb) + for candidate in _reclassify_uncertain_candidates(candidates, source_image_path=source_image_path): + fit = fit_native_shape_with_fallback( + candidate, + source_image_path=source_image_path, + ) + if fit.native_shape is not None: + if not _should_drop_native_shape_candidate( + candidate, + fit.native_shape, + source_size, + source_image=source_rgb, + source_background=source_background, + ): + native_shapes.append(fit.native_shape) + continue + if fit.bitmap_candidate is not None: + bitmap_candidates.append(fit.bitmap_candidate) + continue + if candidate.classification in {"bitmap_asset_candidate", "complex_whole_visual", "uncertain"}: + bitmap_candidates.append(candidate) + native_shapes = _drop_source_diff_native_fill_residuals( + _native_shapes_with_source_scan_priority(native_shapes), + source_image=source_rgb, + ) + native_shapes = _dedupe_native_shapes(native_shapes) + native_shapes = _drop_oversized_native_shapes(native_shapes, source_size) + bitmap_candidates = _expand_edge_spanning_bitmap_candidates( + bitmap_candidates, + source_size=source_size, + ) + promoted_bands, bitmap_candidates = _promote_split_edge_spanning_bands_to_native_shapes( + bitmap_candidates, + source_image=source_rgb, + ) + native_shapes.extend(promoted_bands) + promoted_cards, bitmap_candidates = _promote_large_card_containers_to_native_shapes( + bitmap_candidates, + source_image=source_rgb, + source_size=source_size, + native_shapes=native_shapes, + ) + native_shapes.extend(promoted_cards) + bitmap_candidates = [ + candidate + for candidate in bitmap_candidates + if not _should_drop_bitmap_candidate( + candidate, + native_shapes=native_shapes, + source_size=source_size, + source_image=source_rgb, + source_background=source_background, + ) + ] + bitmap_candidates = _drop_nested_bitmap_fragments(bitmap_candidates) + bitmap_candidates = _merge_nearby_bitmap_fragments(bitmap_candidates, source_size=source_size) + return native_shapes, bitmap_candidates + + +def _promote_large_card_containers_to_native_shapes( + candidates: list[ForegroundCandidate], + *, + source_image: Image.Image, + source_size: tuple[int, int], + native_shapes: list[NativeShapeSpec], +) -> tuple[list[NativeShapeSpec], list[ForegroundCandidate]]: + promoted: list[NativeShapeSpec] = [] + promoted_ids: set[str] = set() + eligible = [ + candidate + for candidate in candidates + if _is_large_card_container_candidate( + candidate, + candidates, + source_size, + source_image=source_image, + native_shapes=native_shapes, + ) + ] + selected: list[ForegroundCandidate] = [] + for candidate in sorted(eligible, key=lambda item: _bbox_area_pixels(item.source_pixel_bbox), reverse=True): + if any( + _bbox_contains_ratio(parent.source_pixel_bbox, candidate.source_pixel_bbox) >= 0.85 + for parent in selected + ): + continue + selected.append(candidate) + selected_ids = {candidate.candidate_id for candidate in selected} + for candidate in candidates: + if candidate.candidate_id not in selected_ids: + continue + promoted.append( + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=candidate.source_pixel_bbox, + fill_color=_representative_region_hex(source_image, candidate.source_pixel_bbox), + line_color="#1D4ED8", + stroke_width=max(1.0, min(source_size) * 0.002), + opacity=0.78, + radius=max(8.0, min(candidate.source_pixel_bbox[2] - candidate.source_pixel_bbox[0], candidate.source_pixel_bbox[3] - candidate.source_pixel_bbox[1]) * 0.04), + confidence=max(candidate.confidence, 0.72), + provenance={ + **_as_dict(candidate.provenance), + "parent_candidate_id": candidate.candidate_id, + "shape_hint": "rounded_rectangle", + "fitter": "large_card_container", + }, + ) + ) + promoted_ids.add(candidate.candidate_id) + if not promoted: + return [], candidates + return promoted, [candidate for candidate in candidates if candidate.candidate_id not in promoted_ids] + + +def _is_large_card_container_candidate( + candidate: ForegroundCandidate, + candidates: list[ForegroundCandidate], + source_size: tuple[int, int], + *, + source_image: Image.Image, + native_shapes: list[NativeShapeSpec], + min_child_count: int = 2, +) -> bool: + if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + if not _is_source_base_difference_candidate(candidate): + return False + bbox = candidate.source_pixel_bbox + page_area = max(1, source_size[0] * source_size[1]) + width_ratio = max(0, bbox[2] - bbox[0]) / float(max(1, source_size[0])) + height_ratio = max(0, bbox[3] - bbox[1]) / float(max(1, source_size[1])) + area_ratio = _bbox_area_pixels(bbox) / float(page_area) + if not (0.16 <= area_ratio <= 0.50): + return False + if not (0.25 <= width_ratio <= 0.78 and 0.35 <= height_ratio <= 0.78): + return False + if bbox[0] <= round(source_size[0] * 0.015) or bbox[2] >= source_size[0] - round(source_size[0] * 0.015): + return False + if not _large_card_container_has_outline_signal(source_image, bbox): + return False + if _large_card_container_has_high_texture_interior(source_image, bbox): + return False + contained_children = [ + other + for other in candidates + if other.candidate_id != candidate.candidate_id + and other.classification in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"} + and _bbox_contains_ratio(candidate.source_pixel_bbox, other.source_pixel_bbox) >= 0.85 + and _bbox_area_pixels(other.source_pixel_bbox) <= _bbox_area_pixels(candidate.source_pixel_bbox) * 0.45 + ] + contained_native_shapes = [ + shape + for shape in native_shapes + if _bbox_contains_ratio(candidate.source_pixel_bbox, shape.source_pixel_bbox) >= 0.85 + and _bbox_area_pixels(shape.source_pixel_bbox) <= _bbox_area_pixels(candidate.source_pixel_bbox) * 0.45 + ] + return len(contained_children) + len(contained_native_shapes) >= min_child_count + + +def _large_card_container_has_outline_signal( + image: Image.Image, + bbox: tuple[int, int, int, int], +) -> bool: + left, top, right, bottom = _clamp_bbox_to_size(bbox, image.size) + width = right - left + height = bottom - top + if width <= 20 or height <= 20: + return False + search = max(4, min(width, height) // 12) + strip = max(2, min(6, min(width, height) // 30)) + horizontal_left = max(0, left - search) + horizontal_right = min(image.width, right + search) + vertical_top = max(0, top - search) + vertical_bottom = min(image.height, bottom + search) + edge_found = [ + _scan_horizontal_card_outline(image, horizontal_left, horizontal_right, top, search, strip), + _scan_horizontal_card_outline(image, horizontal_left, horizontal_right, bottom, search, strip), + _scan_vertical_card_outline(image, vertical_top, vertical_bottom, left, search, strip), + _scan_vertical_card_outline(image, vertical_top, vertical_bottom, right, search, strip), + ] + return sum(edge_found) >= 2 + + +def _scan_horizontal_card_outline( + image: Image.Image, + left: int, + right: int, + center_y: int, + search: int, + strip: int, +) -> bool: + for y in range(max(0, center_y - search), min(image.height - strip + 1, center_y + search + 1)): + if _edge_region_has_coherent_card_outline(image, (left, y, right, y + strip)): + return True + return False + + +def _scan_vertical_card_outline( + image: Image.Image, + top: int, + bottom: int, + center_x: int, + search: int, + strip: int, +) -> bool: + for x in range(max(0, center_x - search), min(image.width - strip + 1, center_x + search + 1)): + if _edge_region_has_coherent_card_outline(image, (x, top, x + strip, bottom)): + return True + return False + + +def _bbox_has_card_outline_signal( + image: Image.Image, + bbox: tuple[int, int, int, int], +) -> bool: + left, top, right, bottom = bbox + width = right - left + height = bottom - top + if width <= 20 or height <= 20: + return False + strip = max(2, min(6, min(width, height) // 30)) + edge_regions = [ + (left, top, right, top + strip), + (left, bottom - strip, right, bottom), + (left, top, left + strip, bottom), + (right - strip, top, right, bottom), + ] + return sum(_edge_region_has_coherent_card_outline(image, region) for region in edge_regions) >= 2 + + +def _edge_region_has_coherent_card_outline( + image: Image.Image, + region: tuple[int, int, int, int], +) -> bool: + left, top, right, bottom = region + total = max(1, (right - left) * (bottom - top)) + buckets: dict[tuple[int, int, int], int] = {} + pixels = image.load() + outline_pixels = 0 + for y in range(top, bottom): + for x in range(left, right): + color = pixels[x, y][:3] + if not _is_card_outline_pixel(color): + continue + outline_pixels += 1 + bucket = tuple(int(channel) // 32 for channel in color) + buckets[bucket] = buckets.get(bucket, 0) + 1 + if outline_pixels / float(total) < 0.08: + return False + return max(buckets.values(), default=0) / float(total) >= 0.05 + + +def _is_card_outline_pixel(color: tuple[int, int, int]) -> bool: + red, green, blue = (int(channel) for channel in color[:3]) + if min(red, green, blue) >= 180: + return True + saturation = max(red, green, blue) - min(red, green, blue) + return blue >= 80 and blue >= red + 20 and blue >= green - 10 and saturation >= 45 + + +def _large_card_container_has_high_texture_interior( + image: Image.Image, + bbox: tuple[int, int, int, int], + *, + max_mean_adjacent_delta: float = 24.0, +) -> bool: + left, top, right, bottom = _clamp_bbox_to_size(bbox, image.size) + width = right - left + height = bottom - top + if width <= 24 or height <= 24: + return False + inset = max(4, min(width, height) // 20) + if left + inset >= right - inset or top + inset >= bottom - inset: + return False + crop = image.crop((left + inset, top + inset, right - inset, bottom - inset)).convert("L") + crop = crop.resize((96, 64)) + pixels = crop.load() + total_delta = 0 + comparisons = 0 + for y in range(crop.height): + for x in range(crop.width): + current = pixels[x, y] + if x + 1 < crop.width: + total_delta += abs(current - pixels[x + 1, y]) + comparisons += 1 + if y + 1 < crop.height: + total_delta += abs(current - pixels[x, y + 1]) + comparisons += 1 + if comparisons == 0: + return False + return total_delta / float(comparisons) > max_mean_adjacent_delta + + +def _promote_split_edge_spanning_bands_to_native_shapes( + candidates: list[ForegroundCandidate], + *, + source_image: Image.Image, +) -> tuple[list[NativeShapeSpec], list[ForegroundCandidate]]: + promoted: list[NativeShapeSpec] = [] + remaining: list[ForegroundCandidate] = [] + for candidate in candidates: + provenance = _as_dict(candidate.provenance) + if provenance.get("detection") != "split_edge_spanning_difference": + remaining.append(candidate) + continue + promoted.append( + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=candidate.source_pixel_bbox, + fill_color=_representative_region_hex(source_image, candidate.source_pixel_bbox), + line_color=None, + opacity=0.15, + confidence=max(candidate.confidence, 0.72), + provenance={ + **provenance, + "candidate_id": candidate.candidate_id, + "shape_hint": "rounded_rectangle", + "fitter": "structural_edge_band", + }, + ) + ) + return promoted, remaining + + +def _representative_region_hex( + image: Image.Image, + bbox: tuple[int, int, int, int], +) -> str: + crop = image.crop(_clamp_bbox_to_size(bbox, image.size)).resize((1, 1)) + red, green, blue = crop.getpixel((0, 0))[:3] + return f"#{red:02X}{green:02X}{blue:02X}" + + +def _expand_edge_spanning_bitmap_candidates( + candidates: list[ForegroundCandidate], + *, + source_size: tuple[int, int], +) -> list[ForegroundCandidate]: + expanded: list[ForegroundCandidate] = [] + for candidate in candidates: + split_detection = _bitmap_candidate_split_detection(candidate, source_size) + if not split_detection: + expanded.append(candidate) + continue + left, top, right, bottom = candidate.source_pixel_bbox + height = max(1, bottom - top) + band_count = 3 if height / float(max(1, source_size[1])) >= 0.45 else 2 + for band_index in range(band_count): + band_top = top + round(height * band_index / band_count) + band_bottom = top + round(height * (band_index + 1) / band_count) + band_bbox = _clamp_bbox_to_size((left, band_top, right, band_bottom), source_size) + if band_bbox[2] <= band_bbox[0] or band_bbox[3] <= band_bbox[1]: + continue + expanded.append( + replace( + candidate, + candidate_id=f"{candidate.candidate_id}-band-{band_index + 1}", + source_pixel_bbox=band_bbox, + area=_bbox_area_pixels(band_bbox), + classification="complex_whole_visual", + provenance={ + **_as_dict(candidate.provenance), + "detection": split_detection, + "parent_candidate_id": candidate.candidate_id, + "band_index": band_index + 1, + "band_count": band_count, + }, + ) + ) + return expanded + + +def _bitmap_candidate_split_detection( + candidate: ForegroundCandidate, + source_size: tuple[int, int], +) -> str: + if _should_split_edge_spanning_bitmap_candidate(candidate, source_size): + return "split_edge_spanning_difference" + if _should_split_large_layered_bitmap_candidate(candidate, source_size): + return "split_large_layered_difference" + return "" + + +def _should_split_edge_spanning_bitmap_candidate( + candidate: ForegroundCandidate, + source_size: tuple[int, int], +) -> bool: + if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + if not _is_source_base_difference_candidate(candidate): + return False + bbox = candidate.source_pixel_bbox + width_ratio = max(0, bbox[2] - bbox[0]) / float(max(1, source_size[0])) + height_ratio = max(0, bbox[3] - bbox[1]) / float(max(1, source_size[1])) + area_ratio = _bbox_area_pixels(bbox) / float(max(1, source_size[0] * source_size[1])) + edge_margin = max(4, round(min(source_size) * 0.01)) + spans_horizontal_edges = bbox[0] <= edge_margin and bbox[2] >= source_size[0] - edge_margin + return spans_horizontal_edges and width_ratio >= 0.90 and height_ratio >= 0.35 and area_ratio >= 0.18 + + +def _should_split_large_layered_bitmap_candidate( + candidate: ForegroundCandidate, + source_size: tuple[int, int], +) -> bool: + if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + if not _is_source_base_difference_candidate(candidate): + return False + bbox = candidate.source_pixel_bbox + width_ratio = max(0, bbox[2] - bbox[0]) / float(max(1, source_size[0])) + height_ratio = max(0, bbox[3] - bbox[1]) / float(max(1, source_size[1])) + area_ratio = _bbox_area_pixels(bbox) / float(max(1, source_size[0] * source_size[1])) + top_ratio = bbox[1] / float(max(1, source_size[1])) + bottom_ratio = bbox[3] / float(max(1, source_size[1])) + return ( + width_ratio >= 0.72 + and height_ratio >= 0.70 + and area_ratio >= 0.65 + and top_ratio >= 0.10 + and bottom_ratio <= 0.97 + ) + + +def _drop_nested_bitmap_fragments( + candidates: list[ForegroundCandidate], +) -> list[ForegroundCandidate]: + kept: list[ForegroundCandidate] = [] + for candidate in candidates: + if any(_bitmap_candidate_contains_fragment(container, candidate) for container in candidates): + continue + kept.append(candidate) + return kept + + +def _drop_oversized_native_shapes( + native_shapes: list[NativeShapeSpec], + source_size: tuple[int, int], + max_area_ratio: float = 0.35, + max_line_bbox_area_ratio: float = 0.08, +) -> list[NativeShapeSpec]: + page_area = max(1, source_size[0] * source_size[1]) + kept: list[NativeShapeSpec] = [] + for shape in native_shapes: + area_ratio = _bbox_area_pixels(shape.source_pixel_bbox) / float(page_area) + if shape.shape_type == "line" and area_ratio > max_line_bbox_area_ratio: + continue + if shape.shape_type != "line" and area_ratio > max_area_ratio: + continue + kept.append(shape) + return kept + + +def _bitmap_candidate_contains_fragment( + container: ForegroundCandidate, + fragment: ForegroundCandidate, +) -> bool: + if container.candidate_id == fragment.candidate_id: + return False + if container.classification not in {"complex_whole_visual", "bitmap_asset_candidate"}: + return False + if fragment.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + container_area = _bbox_area_pixels(container.source_pixel_bbox) + fragment_area = _bbox_area_pixels(fragment.source_pixel_bbox) + if container_area < fragment_area * 4: + return False + return _bbox_contains_ratio(container.source_pixel_bbox, fragment.source_pixel_bbox) >= 0.90 + + +def _merge_nearby_bitmap_fragments( + candidates: list[ForegroundCandidate], + *, + source_size: tuple[int, int], + max_gap: int = 24, + max_union_growth: float = 2.6, + max_merged_page_area_ratio: float = 0.22, +) -> list[ForegroundCandidate]: + merged = list(candidates) + changed = True + while changed: + changed = False + for left_index in range(len(merged)): + if changed: + break + for right_index in range(left_index + 1, len(merged)): + left = merged[left_index] + right = merged[right_index] + if not _bitmap_fragments_should_merge( + left, + right, + max_gap=max_gap, + max_union_growth=max_union_growth, + source_size=source_size, + max_merged_page_area_ratio=max_merged_page_area_ratio, + ): + continue + merged[left_index] = _merged_bitmap_candidate(left, right) + del merged[right_index] + changed = True + break + return merged + + +def _bitmap_fragments_should_merge( + left: ForegroundCandidate, + right: ForegroundCandidate, + *, + max_gap: int, + max_union_growth: float, + source_size: tuple[int, int], + max_merged_page_area_ratio: float, +) -> bool: + if left.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + if right.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + if _box_distance_pixels(left.source_pixel_bbox, right.source_pixel_bbox) > max_gap: + return False + union = _union_bbox(left.source_pixel_bbox, right.source_pixel_bbox) + if _bbox_area_pixels(union) / float(max(1, source_size[0] * source_size[1])) > max_merged_page_area_ratio: + return False + separate_area = _bbox_area_pixels(left.source_pixel_bbox) + _bbox_area_pixels(right.source_pixel_bbox) + return _bbox_area_pixels(union) / float(max(1, separate_area)) <= max_union_growth + + +def _merged_bitmap_candidate(left: ForegroundCandidate, right: ForegroundCandidate) -> ForegroundCandidate: + merged_ids = [ + *_as_dict(left.provenance).get("merged_candidate_ids", [left.candidate_id]), + *_as_dict(right.provenance).get("merged_candidate_ids", [right.candidate_id]), + ] + bbox = _union_bbox(left.source_pixel_bbox, right.source_pixel_bbox) + return replace( + left, + candidate_id=left.candidate_id, + source_pixel_bbox=bbox, + area=_bbox_area_pixels(bbox), + classification="complex_whole_visual", + provenance={ + **_as_dict(left.provenance), + "merged_candidate_ids": merged_ids, + "merge_strategy": "nearby_bitmap_fragments", + }, + ) + + +def _reclassify_uncertain_candidates( + candidates: list[ForegroundCandidate], + *, + source_image_path: str, +) -> list[ForegroundCandidate]: + reclassified: list[ForegroundCandidate] = [] + for candidate in candidates: + if candidate.classification != "uncertain": + reclassified.append(candidate) + continue + try: + refined = classify_foreground_candidates( + source_image_path=source_image_path, + candidate_boxes=[candidate.source_pixel_bbox], + )[0] + except Exception: + reclassified.append(candidate) + continue + reclassified.append( + replace( + refined, + candidate_id=candidate.candidate_id, + component_key=candidate.component_key or refined.component_key, + provenance={ + **_as_dict(refined.provenance), + "reclassified_from": "uncertain", + "original_detection": _as_dict(candidate.provenance), + }, + ) + ) + return reclassified + + +def _detect_source_native_shape_candidates( + *, + source_image_path: str, + text_mask_path: str | None, + min_area: int = 80, +) -> list[ForegroundCandidate]: + with Image.open(source_image_path) as source: + source_rgb = source.convert("RGB") + text_mask = _load_text_mask_for_source_detection(text_mask_path, source_rgb.size) + background = _estimate_background_color(source_rgb) + pixels = _source_foreground_pixels(source_rgb, text_mask, background) + components = _source_connected_components(pixels) + candidates: list[ForegroundCandidate] = [] + for component in components: + if len(component) < min_area: + continue + xs = [point[0] for point in component] + ys = [point[1] for point in component] + bbox = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) + if _source_detection_bbox_is_noise(bbox, source_rgb.size): + continue + candidate = _source_component_native_candidate( + component=component, + bbox=bbox, + candidate_id=f"source-native-{len(candidates) + 1:03d}", + ) + if candidate is not None: + candidates.append(candidate) + return candidates + + +def _source_component_native_candidate( + *, + component: list[tuple[int, int]], + bbox: tuple[int, int, int, int], + candidate_id: str, +) -> ForegroundCandidate | None: + left, top, right, bottom = bbox + width = right - left + height = bottom - top + bbox_area = max(1, width * height) + fill_ratio = len(component) / float(bbox_area) + points = set(component) + provenance: dict[str, Any] = {"detection": "source_native_shape_scan"} + + is_box_outline = _source_component_is_box_outline(points, bbox) + sparse_corners = _source_component_sparse_corner_count(points, bbox) + if is_box_outline or fill_ratio >= 0.68: + if not is_box_outline and sparse_corners == 4 and fill_ratio < 0.86: + return None + if _source_component_has_rounded_corners(points, bbox): + provenance.update( + { + "shape_hint": "rounded_rectangle", + "radius": max(4, round(min(width, height) * 0.18)), + } + ) + else: + provenance["shape_hint"] = "rectangle" + return ForegroundCandidate( + candidate_id=candidate_id, + source_pixel_bbox=bbox, + area=len(component), + classification="native_shape_candidate", + confidence=0.94, + provenance=provenance, + ) + + if _source_component_is_line(width, height, fill_ratio): + start, end = _source_line_endpoints(component) + provenance.update( + { + "shape_hint": "line", + "line_start": start, + "line_end": end, + "stroke_width": max(1, round(min(width, height) * max(fill_ratio, 0.12))), + } + ) + return ForegroundCandidate( + candidate_id=candidate_id, + source_pixel_bbox=bbox, + area=len(component), + classification="native_shape_candidate", + confidence=0.93, + provenance=provenance, + ) + + return None + + +def _load_text_mask_for_source_detection( + text_mask_path: str | None, + size: tuple[int, int], +) -> Image.Image | None: + if not text_mask_path: + return None + with Image.open(text_mask_path) as mask: + return mask.convert("L").resize(size) + + +def _estimate_background_color(image: Image.Image) -> tuple[int, int, int]: + step_x = max(1, image.width // 40) + step_y = max(1, image.height // 24) + footer_top = max(0, image.height - min(24, max(0, round(image.height * 0.08)))) + counts: dict[tuple[int, int, int], int] = {} + for y in range(0, footer_top, step_y): + for x in range(0, image.width, step_x): + pixel = image.getpixel((x, y)) + counts[pixel] = counts.get(pixel, 0) + 1 + if counts: + return max(counts.items(), key=lambda item: item[1])[0] + return image.getpixel((0, 0)) + + +def _source_foreground_pixels( + image: Image.Image, + text_mask: Image.Image | None, + background: tuple[int, int, int], + *, + threshold: int = 28, +) -> set[tuple[int, int]]: + pixels: set[tuple[int, int]] = set() + footer_top = max(0, image.height - min(24, max(0, round(image.height * 0.08)))) + for y in range(image.height): + if y >= footer_top: + continue + for x in range(image.width): + if text_mask is not None and text_mask.getpixel((x, y)) > 0: + continue + red, green, blue = image.getpixel((x, y)) + if max(abs(red - background[0]), abs(green - background[1]), abs(blue - background[2])) > threshold: + pixels.add((x, y)) + return pixels + + +def _source_connected_components(pixels: set[tuple[int, int]]) -> list[list[tuple[int, int]]]: + remaining = set(pixels) + components: list[list[tuple[int, int]]] = [] + while remaining: + seed = remaining.pop() + stack = [seed] + component = [seed] + while stack: + x, y = stack.pop() + for neighbor in ( + (x - 1, y - 1), + (x, y - 1), + (x + 1, y - 1), + (x - 1, y), + (x + 1, y), + (x - 1, y + 1), + (x, y + 1), + (x + 1, y + 1), + ): + if neighbor in remaining: + remaining.remove(neighbor) + stack.append(neighbor) + component.append(neighbor) + components.append(component) + components.sort(key=lambda item: (min(point[1] for point in item), min(point[0] for point in item))) + return components + + +def _source_detection_bbox_is_noise( + bbox: tuple[int, int, int, int], + size: tuple[int, int], +) -> bool: + left, top, right, bottom = bbox + width = right - left + height = bottom - top + if width <= 2 or height <= 2: + return True + if width > size[0] * 0.95 and height > size[1] * 0.40: + return True + return False + + +def _should_use_local_reconstruction_background( + *, + source_image_path: str | Path, + native_shapes: list[NativeShapeSpec], + bitmap_assets: list[BitmapAssetSpec], + text_boxes: list, +) -> bool: + if bitmap_assets or not native_shapes: + return False + return _source_background_is_flat_for_local_reconstruction( + source_image_path, + [shape.source_pixel_bbox for shape in native_shapes] + + [text_box.source_pixel_bbox for text_box in text_boxes], + ) + + +def _should_choose_text_clean_background( + *, + source_image_path: str | Path, + base_clean_image_path: str | Path, + text_clean_image_path: str | Path, + text_boxes: list, +) -> bool: + base_delta = _mean_background_delta_outside_text( + source_image_path, + base_clean_image_path, + text_boxes, + ) + text_delta = _mean_background_delta_outside_text( + source_image_path, + text_clean_image_path, + text_boxes, + ) + return base_delta > 8.0 and text_delta + 3.0 < base_delta + + +def _should_choose_source_preserving_background( + *, + source_image_path: str | Path, + chosen_background_image_path: str | Path, + text_clean_image_path: str | Path, + text_boxes: list, + native_shapes: list[NativeShapeSpec], + bitmap_assets: list[BitmapAssetSpec], + source_image_size: tuple[int, int], +) -> bool: + # A source-preserving full-slide background keeps the slide looking close by + # baking most visual elements into one picture. That is not the editable PPTX + # contract we want for the normal reconstruction path. Let validation fail + # when generated backgrounds or assets are insufficient; explicit fallback + # policies can still produce non-editable/raster outputs outside this path. + return False + + +def _native_only_complex_page_should_preserve( + *, + native_shapes: list[NativeShapeSpec], + bitmap_assets: list[BitmapAssetSpec], + text_boxes: list, + source_image_size: tuple[int, int], +) -> bool: + if len(text_boxes) < 8 or len(native_shapes) < 8: + return False + if len(native_shapes) >= 20: + return True + if len(native_shapes) >= 12 and len(bitmap_assets) <= 2: + return True + native_coverage = _bbox_coverage_ratio( + [shape.source_pixel_bbox for shape in native_shapes], + source_image_size, + ) + bitmap_coverage = _bbox_coverage_ratio( + [asset.source_pixel_bbox for asset in bitmap_assets], + source_image_size, + ) + return native_coverage <= 0.12 and bitmap_coverage <= 0.04 + + +def _drop_oversized_source_preserved_assets( + bitmap_assets: list[BitmapAssetSpec], + source_image_size: tuple[int, int], +) -> list[BitmapAssetSpec]: + return [ + asset + for asset in bitmap_assets + if not _is_oversized_source_preserved_asset(asset, source_image_size) + ] + + +def _bitmap_assets_for_source_preserving_background( + bitmap_assets: list[BitmapAssetSpec], + source_image_size: tuple[int, int], + max_area_ratio: float = 0.05, +) -> list[BitmapAssetSpec]: + page_area = max(1, source_image_size[0] * source_image_size[1]) + return [ + asset + for asset in bitmap_assets + if _bbox_area_pixels(asset.source_pixel_bbox) / float(page_area) <= max_area_ratio + ] + + +def _bitmap_assets_for_clean_background( + bitmap_assets: list[BitmapAssetSpec], + source_image_size: tuple[int, int], + max_diff_alpha_area_ratio: float = 0.08, +) -> list[BitmapAssetSpec]: + page_area = max(1, source_image_size[0] * source_image_size[1]) + kept: list[BitmapAssetSpec] = [] + for asset in bitmap_assets: + area_ratio = _bbox_area_pixels(asset.source_pixel_bbox) / float(page_area) + if ( + asset.provenance.get("asset_strategy") == "masked_source_element" + and asset.provenance.get("background_difference_alpha") is True + and asset.provenance.get("candidate_classification") != "complex_whole_visual" + and area_ratio > max_diff_alpha_area_ratio + ): + continue + kept.append(asset) + return kept + + +def _drop_text_boxes_covered_by_complex_bitmap_assets( + text_boxes: list[TextBoxSpec], + bitmap_assets: list[BitmapAssetSpec], + *, + min_cover_ratio: float = 0.85, +) -> tuple[list[TextBoxSpec], list[dict[str, Any]]]: + complex_assets = [ + asset + for asset in bitmap_assets + if asset.provenance.get("asset_strategy") == "masked_source_element" + and asset.provenance.get("candidate_classification") == "complex_whole_visual" + and not _is_text_masked_complex_bitmap_asset(asset) + ] + if not complex_assets: + return text_boxes, [] + kept: list[TextBoxSpec] = [] + issues: list[dict[str, Any]] = [] + for text_box in text_boxes: + covering_bbox = next( + ( + asset.source_pixel_bbox + for asset in complex_assets + if _bbox_contains_ratio(asset.source_pixel_bbox, text_box.source_pixel_bbox) >= min_cover_ratio + and ( + _text_box_has_approximate_ocr_layout(text_box) + or _is_opaque_complex_bitmap_asset(asset) + ) + ), + None, + ) + if covering_bbox is None: + kept.append(text_box) + continue + issues.append( + { + "severity": "warning", + "ocr_text": text_box.text, + "source_pixel_bbox": text_box.source_pixel_bbox, + "covering_asset_bbox": covering_bbox, + "code": ( + "text_covered_by_complex_bitmap_asset" + if _text_box_has_approximate_ocr_layout(text_box) + else "text_covered_by_opaque_complex_bitmap_asset" + ), + } + ) + return kept, issues + + +def _is_opaque_complex_bitmap_asset(asset: BitmapAssetSpec) -> bool: + return ( + asset.provenance.get("asset_strategy") == "masked_source_element" + and asset.provenance.get("candidate_classification") == "complex_whole_visual" + and asset.provenance.get("text_mask_applied") is not True + and asset.provenance.get("background_difference_alpha") is not True + ) + + +def _is_text_masked_complex_bitmap_asset(asset: BitmapAssetSpec) -> bool: + return ( + asset.provenance.get("asset_strategy") == "masked_source_element" + and asset.provenance.get("candidate_classification") == "complex_whole_visual" + and asset.provenance.get("text_mask_applied") is True + and asset.provenance.get("background_difference_alpha") is True + ) + + +def _drop_native_shapes_covered_by_complex_bitmap_assets( + native_shapes: list[NativeShapeSpec], + bitmap_assets: list[BitmapAssetSpec], + *, + min_cover_ratio: float = 0.85, +) -> list[NativeShapeSpec]: + complex_asset_bboxes = [ + asset.source_pixel_bbox + for asset in bitmap_assets + if _is_opaque_complex_bitmap_asset(asset) + ] + if not complex_asset_bboxes: + return native_shapes + return [ + shape + for shape in native_shapes + if not any( + _bbox_contains_ratio(asset_bbox, shape.source_pixel_bbox) >= min_cover_ratio + for asset_bbox in complex_asset_bboxes + ) + ] + + +def _native_shapes_for_source_preserving_background( + native_shapes: list[NativeShapeSpec], +) -> list[NativeShapeSpec]: + return [] + + +def _build_source_preserving_anchor_assets( + *, + source_image_path: str | Path, + text_boxes: list[TextBoxSpec], + output_dir: str | Path, + asset_root: str | Path, + start_z_order: int = 1, + max_assets: int = 3, +) -> list[BitmapAssetSpec]: + anchor_boxes = _source_preserving_anchor_bboxes(text_boxes) + anchor_source = "left_label_icon_region" + if not anchor_boxes: + anchor_boxes = _source_preserving_generic_visual_anchor_bboxes( + source_image_path=source_image_path, + text_boxes=text_boxes, + max_assets=max_assets, + ) + anchor_source = "visual_saliency_region" + if not anchor_boxes: + return [] + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + asset_root_path = Path(asset_root) + assets: list[BitmapAssetSpec] = [] + with Image.open(source_image_path) as source: + source_rgba = source.convert("RGBA") + for index, bbox in enumerate(anchor_boxes[:max_assets], start=start_z_order): + clamped = _clamp_bbox_to_size(bbox, source_rgba.size) + if clamped[2] <= clamped[0] or clamped[3] <= clamped[1]: + continue + asset_id = f"anchor-{index:03d}" + path = output_path / f"{asset_id}.png" + source_rgba.crop(clamped).save(path) + assets.append( + BitmapAssetSpec( + asset_id=asset_id, + source_pixel_bbox=clamped, + asset_path=_job_relative(path, asset_root_path), + z_order=index, + provenance={ + "asset_strategy": "source_preserving_anchor", + "source": anchor_source, + }, + ) + ) + return assets + + +def _source_preserving_anchor_bboxes( + text_boxes: list[TextBoxSpec], +) -> list[tuple[int, int, int, int]]: + bboxes: list[tuple[int, int, int, int]] = [] + for text_box in text_boxes: + text = str(text_box.text).strip() + if not (_contains_cjk_text(text) and text.endswith("域")): + continue + x1, y1, _x2, y2 = text_box.source_pixel_bbox + height = max(1, y2 - y1) + center_y = (y1 + y2) // 2 + crop_height = max(28, round(height * 2.4)) + crop_width = max(28, round(height * 2.8)) + right = max(0, x1 - max(6, round(height * 0.35))) + left = max(0, right - crop_width) + top = max(0, center_y - crop_height // 2) + bottom = top + crop_height + if right > left and bottom > top: + bboxes.append((left, top, right, bottom)) + return bboxes + + +def _source_preserving_generic_visual_anchor_bboxes( + *, + source_image_path: str | Path, + text_boxes: list[TextBoxSpec], + max_assets: int = 3, + max_area_ratio: float = 0.08, +) -> list[tuple[int, int, int, int]]: + with Image.open(source_image_path) as source: + source_rgb = source.convert("RGB") + text_mask = Image.new("L", source_rgb.size, 0) + draw = ImageDraw.Draw(text_mask) + for text_box in text_boxes: + left, top, right, bottom = text_box.source_pixel_bbox + pad = max(3, round((bottom - top) * 0.45)) + draw.rectangle( + ( + max(0, left - pad), + max(0, top - pad), + min(source_rgb.width, right + pad), + min(source_rgb.height, bottom + pad), + ), + fill=255, + ) + background = _estimate_background_color(source_rgb) + foreground_pixels = _source_foreground_pixels( + source_rgb, + text_mask, + background, + threshold=34, + ) + components = _source_connected_components(foreground_pixels) + page_area = max(1, source_rgb.width * source_rgb.height) + text_bboxes = [text_box.source_pixel_bbox for text_box in text_boxes] + candidates: list[tuple[int, tuple[int, int, int, int]]] = [] + for component in components: + if len(component) < 120: + continue + xs = [point[0] for point in component] + ys = [point[1] for point in component] + bbox = _expand_bbox_to_size( + (min(xs), min(ys), max(xs) + 1, max(ys) + 1), + source_rgb.size, + padding=8, + ) + bbox = _limit_bbox_area_ratio( + bbox, + source_rgb.size, + max_area_ratio=max_area_ratio, + ) + if _bbox_area_pixels(bbox) < page_area * 0.002: + continue + if any(_bbox_iou(bbox, text_bbox) > 0.10 for text_bbox in text_bboxes): + continue + candidates.append((len(component), bbox)) + candidates.sort(key=lambda item: item[0], reverse=True) + selected: list[tuple[int, int, int, int]] = [] + for _score, bbox in candidates: + if any(_bbox_iou(bbox, existing) > 0.35 for existing in selected): + continue + selected.append(bbox) + if len(selected) >= max_assets: + break + return selected + + +def _augment_bitmap_candidates_with_generic_visual_anchors( + bitmap_candidates: list[ForegroundCandidate], + *, + source_image_path: str | Path, + text_boxes: list[TextBoxSpec], + native_shapes: list[NativeShapeSpec] | None = None, + max_assets: int = 8, + max_area_ratio: float = 0.08, +) -> list[ForegroundCandidate]: + if not any(_contains_cjk_text(str(text_box.text)) for text_box in text_boxes): + return bitmap_candidates + with Image.open(source_image_path) as source: + source_size = source.size + is_cover_title_layout = _is_cover_title_slide_text_layout(text_boxes, source_size=source_size) + effective_max_area_ratio = ( + max(max_area_ratio, 0.16) + if _has_split_edge_spanning_native_band(native_shapes or []) + else max_area_ratio + ) + generic_bboxes = _source_preserving_generic_visual_anchor_bboxes( + source_image_path=source_image_path, + text_boxes=text_boxes, + max_assets=max_assets, + max_area_ratio=effective_max_area_ratio, + ) + cover_lower_bboxes = ( + _cover_title_lower_visual_anchor_bboxes(text_boxes, source_size=source_size) + if is_cover_title_layout + else [] + ) + if is_cover_title_layout and not ( + _has_lower_cover_title_visual_anchor(generic_bboxes, source_size=source_size) + or cover_lower_bboxes + ): + return bitmap_candidates + row_bboxes = _domain_row_visual_anchor_bboxes(text_boxes, source_size=source_size) + anchor_bboxes = [ + *cover_lower_bboxes, + *generic_bboxes, + *[ + bbox + for bbox in row_bboxes + if not any(_bbox_iou(bbox, generic) >= 0.55 for generic in [*cover_lower_bboxes, *generic_bboxes]) + ], + ] + if not anchor_bboxes: + return bitmap_candidates + augmented = list(bitmap_candidates) + for index, bbox in enumerate(anchor_bboxes, start=1): + if any(_bbox_iou(bbox, candidate.source_pixel_bbox) >= 0.55 for candidate in augmented): + continue + if any(_bbox_contains_ratio(candidate.source_pixel_bbox, bbox) >= 0.85 for candidate in augmented): + continue + augmented.append( + ForegroundCandidate( + candidate_id=f"generic-visual-{index:03d}", + source_pixel_bbox=bbox, + area=_bbox_area_pixels(bbox), + classification="complex_whole_visual", + confidence=0.78, + provenance={ + "detection": ( + "cover_title_lower_visual_anchor" + if bbox in cover_lower_bboxes + else "source_generic_visual_anchor" + if bbox in generic_bboxes + else "domain_row_visual_anchor" + ), + "reason": "preserve_visual_region_missing_from_clean_background", + }, + ) + ) + return _drop_nested_bitmap_fragments(augmented) + + +def _cover_title_lower_visual_anchor_bboxes( + text_boxes: list[TextBoxSpec], + *, + source_size: tuple[int, int], +) -> list[tuple[int, int, int, int]]: + source_width, source_height = source_size + if source_width <= 0 or source_height <= 0: + return [] + has_bottom_helper_text = any( + text_box.source_pixel_bbox[1] >= source_height * 0.72 + and any(marker in str(text_box.text) for marker in ("官方", "帮助", "OTA", "二维码")) + for text_box in text_boxes + ) + if not has_bottom_helper_text: + return [] + return [(0, round(source_height * 0.54), source_width, source_height)] + + +def _has_lower_cover_title_visual_anchor( + bboxes: list[tuple[int, int, int, int]], + *, + source_size: tuple[int, int], +) -> bool: + source_width, source_height = source_size + page_area = max(1, source_width * source_height) + return any( + bbox[1] >= source_height * 0.48 + and _bbox_area_pixels(bbox) >= page_area * 0.025 + and ( + (bbox[2] - bbox[0]) / float(max(1, bbox[3] - bbox[1])) <= 4.8 + or (bbox[3] - bbox[1]) >= source_height * 0.25 + ) + for bbox in bboxes + ) + + +def _is_cover_title_slide_text_layout( + text_boxes: list[TextBoxSpec], + *, + source_size: tuple[int, int], +) -> bool: + if not text_boxes: + return False + source_width, source_height = source_size + if any(str(text_box.text).strip().endswith("域") for text_box in text_boxes): + return False + upper_large_title_boxes = [ + text_box + for text_box in text_boxes + if text_box.source_pixel_bbox[1] <= source_height * 0.45 + and (text_box.source_pixel_bbox[2] - text_box.source_pixel_bbox[0]) >= source_width * 0.18 + and (text_box.source_pixel_bbox[3] - text_box.source_pixel_bbox[1]) >= source_height * 0.06 + and _contains_cjk_text(str(text_box.text)) + ] + if len(upper_large_title_boxes) >= 2: + return True + if len(upper_large_title_boxes) == 1: + text = str(upper_large_title_boxes[0].text) + return any(marker in text for marker in ("L9", "实验", "总结", "建议")) + return False + + +def _has_split_edge_spanning_native_band(native_shapes: list[NativeShapeSpec]) -> bool: + return any( + _as_dict(shape.provenance).get("detection") == "split_edge_spanning_difference" + for shape in native_shapes + ) + + +def _domain_row_visual_anchor_bboxes( + text_boxes: list[TextBoxSpec], + *, + source_size: tuple[int, int], +) -> list[tuple[int, int, int, int]]: + source_width, source_height = source_size + labels = [ + text_box + for text_box in text_boxes + if str(text_box.text).strip().endswith("域") + and text_box.source_pixel_bbox[0] <= source_width * 0.35 + ] + if not labels: + return [] + spec_boxes = [ + text_box.source_pixel_bbox + for text_box in text_boxes + if text_box.source_pixel_bbox[0] >= source_width * 0.60 + ] + bboxes: list[tuple[int, int, int, int]] = [] + for label in labels: + left_label, top_label, right_label, bottom_label = label.source_pixel_bbox + center_y = (top_label + bottom_label) // 2 + nearby_specs = [ + bbox + for bbox in spec_boxes + if abs(((bbox[1] + bbox[3]) // 2) - center_y) <= max(80, round(source_height * 0.16)) + ] + if not nearby_specs: + continue + spec_left = min(bbox[0] for bbox in nearby_specs) + left = max(right_label + round(source_width * 0.05), round(source_width * 0.30)) + right = min(spec_left - round(source_width * 0.05), round(source_width * 0.76)) + half_height = max(72, round(source_height * 0.15)) + top = center_y - half_height + bottom = center_y + half_height + bbox = _clamp_bbox_to_size((left, top, right, bottom), source_size) + if bbox[2] - bbox[0] < source_width * 0.10 or bbox[3] - bbox[1] < source_height * 0.08: + continue + bboxes.append(bbox) + return bboxes + + +def _expand_bbox_to_size( + bbox: tuple[int, int, int, int], + size: tuple[int, int], + *, + padding: int, +) -> tuple[int, int, int, int]: + left, top, right, bottom = bbox + return _clamp_bbox_to_size( + (left - padding, top - padding, right + padding, bottom + padding), + size, + ) + + +def _limit_bbox_area_ratio( + bbox: tuple[int, int, int, int], + size: tuple[int, int], + *, + max_area_ratio: float, +) -> tuple[int, int, int, int]: + page_area = max(1, size[0] * size[1]) + max_area = max(1, int(page_area * max_area_ratio)) + left, top, right, bottom = _clamp_bbox_to_size(bbox, size) + width = max(1, right - left) + height = max(1, bottom - top) + if width * height <= max_area: + return (left, top, right, bottom) + scale = (max_area / float(width * height)) ** 0.5 + limited_width = max(1, int(width * scale)) + limited_height = max(1, int(height * scale)) + center_x = (left + right) // 2 + center_y = (top + bottom) // 2 + new_left = center_x - limited_width // 2 + new_top = center_y - limited_height // 2 + return _clamp_bbox_to_size( + (new_left, new_top, new_left + limited_width, new_top + limited_height), + size, + ) + + +def _is_oversized_source_preserved_asset( + asset: BitmapAssetSpec, + source_image_size: tuple[int, int], +) -> bool: + if asset.provenance.get("asset_strategy") != "masked_source_element": + return False + if asset.provenance.get("background_difference_alpha") is True: + return False + source_width, source_height = source_image_size + if source_width <= 0 or source_height <= 0: + return False + left, top, right, bottom = _clamp_bbox_to_size(asset.source_pixel_bbox, source_image_size) + width_ratio = (right - left) / float(source_width) + height_ratio = (bottom - top) / float(source_height) + area_ratio = _bbox_area_pixels((left, top, right, bottom)) / float(source_width * source_height) + return area_ratio >= 0.35 or (width_ratio >= 0.85 and height_ratio >= 0.35) + + +def _bbox_coverage_ratio( + bboxes: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> float: + total_area = max(1, source_image_size[0] * source_image_size[1]) + covered = Image.new("L", source_image_size, 0) + draw = ImageDraw.Draw(covered) + for bbox in bboxes: + left, top, right, bottom = _clamp_bbox_to_size(bbox, source_image_size) + if right > left and bottom > top: + draw.rectangle((left, top, right, bottom), fill=255) + histogram = covered.histogram() + covered_pixels = total_area - histogram[0] + return covered_pixels / float(total_area) + + +def _mean_background_delta_outside_text( + source_image_path: str | Path, + candidate_image_path: str | Path, + text_boxes: list, +) -> float: + with Image.open(source_image_path) as source, Image.open(candidate_image_path) as candidate: + source_rgb = source.convert("RGB") + candidate_rgb = candidate.convert("RGB").resize(source_rgb.size) + diff = ImageChops.difference(source_rgb, candidate_rgb) + mask = Image.new("L", source_rgb.size, 255) + draw = ImageDraw.Draw(mask) + for text_box in text_boxes: + x1, y1, x2, y2 = text_box.source_pixel_bbox + pad = max(4, round((y2 - y1) * 0.35)) + draw.rectangle( + ( + max(0, x1 - pad), + max(0, y1 - pad), + min(source_rgb.width, x2 + pad), + min(source_rgb.height, y2 + pad), + ), + fill=0, + ) + stat = ImageStat.Stat(diff.convert("L"), mask) + return float(stat.mean[0]) if stat.count[0] else 0.0 + + +def _create_local_reconstruction_background( + *, + source_image_path: str | Path, + output_asset_path: str | Path, + asset_root: str | Path, + foreground_bboxes: list[tuple[int, int, int, int]], +) -> BackgroundResult: + source_path = Path(source_image_path) + output_path = Path(output_asset_path) + asset_root_path = Path(asset_root) + output_path.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source_path) as source: + source_rgb = source.convert("RGB") + background = Image.new( + "RGB", + source_rgb.size, + _estimate_flat_background_color(source_rgb, foreground_bboxes), + ) + footer_top = _footer_top(source_rgb.size) + if footer_top < source_rgb.height: + background.paste(source_rgb.crop((0, footer_top, source_rgb.width, source_rgb.height)), (0, footer_top)) + background.save(output_path) + return BackgroundResult( + output_asset_path=str(output_path), + artifact_path=_job_relative(output_path, asset_root_path), + strategy="local_fill", + provider_role="local", + prompt_id="local_reconstruction_background", + input_asset_refs=[_job_relative(source_path, asset_root_path)], + validation_status="passed", + provenance={"decision": "flat native-only local reconstruction background"}, + ) + + +def _background_result_manifest_record(result: BackgroundResult) -> dict: + return { + "strategy": result.strategy, + "provider_role": result.provider_role, + "prompt_id": result.prompt_id, + "input_asset_refs": list(result.input_asset_refs), + "output_asset_ref": result.artifact_path, + "validation_status": result.validation_status, + "provenance": result.provenance, + } + + +def _source_background_is_flat_for_local_reconstruction( + source_image_path: str | Path, + foreground_bboxes: list[tuple[int, int, int, int]], +) -> bool: + with Image.open(source_image_path) as source: + rgb = source.convert("RGB") + samples = _sample_flat_background_pixels(rgb, foreground_bboxes) + if len(samples) < 12: + return False + avg = tuple(round(mean(pixel[index] for pixel in samples)) for index in range(3)) + return mean(_color_distance(pixel, avg) for pixel in samples) <= 6 + + +def _estimate_flat_background_color( + image: Image.Image, + foreground_bboxes: list[tuple[int, int, int, int]], +) -> tuple[int, int, int]: + samples = _sample_flat_background_pixels(image, foreground_bboxes) + if not samples: + return _estimate_background_color(image) + return tuple(round(mean(pixel[index] for pixel in samples)) for index in range(3)) + + +def _sample_flat_background_pixels( + image: Image.Image, + foreground_bboxes: list[tuple[int, int, int, int]], +) -> list[tuple[int, int, int]]: + footer_top = _footer_top(image.size) + step_x = max(1, image.width // 24) + step_y = max(1, max(1, footer_top) // 16) + inset = max(2, round(min(image.size) * 0.01)) + samples: list[tuple[int, int, int]] = [] + pixels = image.load() + for y in range(inset, max(inset, footer_top - inset), step_y): + for x in range(inset, max(inset, image.width - inset), step_x): + if any(_point_inside_padded_bbox((x, y), bbox, padding=4) for bbox in foreground_bboxes): + continue + samples.append(pixels[x, y]) + return samples + + +def _footer_top(size: tuple[int, int]) -> int: + _, height = size + return max(0, height - min(24, max(0, round(height * 0.08)))) + + +def _point_inside_padded_bbox( + point: tuple[int, int], + bbox: tuple[int, int, int, int], + *, + padding: int, +) -> bool: + x, y = point + left, top, right, bottom = bbox + return left - padding <= x < right + padding and top - padding <= y < bottom + padding + + +def _dedupe_native_shapes(shapes: list[NativeShapeSpec]) -> list[NativeShapeSpec]: + deduped: list[NativeShapeSpec] = [] + for shape in shapes: + if _bbox_overlaps_any_shape(shape.source_pixel_bbox, deduped, threshold=0.90): + continue + deduped.append(shape) + return deduped + + +def _bbox_overlaps_any_shape( + bbox: tuple[int, int, int, int], + shapes: list[NativeShapeSpec], + *, + threshold: float = 0.72, +) -> bool: + return any(_bbox_iou(bbox, shape.source_pixel_bbox) >= threshold for shape in shapes) + + +def _should_drop_bitmap_candidate( + candidate: ForegroundCandidate, + *, + native_shapes: list[NativeShapeSpec], + source_size: tuple[int, int], + source_image: Image.Image, + source_background: tuple[int, int, int], +) -> bool: + bbox = candidate.source_pixel_bbox + if _as_dict(candidate.provenance).get("detection") == "split_edge_spanning_difference": + return False + if _is_structural_container_bitmap_candidate( + candidate, + native_shapes=native_shapes, + ): + return True + if ( + native_shapes + and _bbox_area_pixels(bbox) / float(source_size[0] * source_size[1]) >= 0.80 + ): + return True + if _bbox_overlaps_any_shape(bbox, native_shapes): + return True + if ( + _is_source_base_difference_candidate(candidate) + and not _source_region_has_foreground_signal(source_image, bbox, source_background) + ): + return True + if _tiny_diff_fragment(candidate): + return True + if _line_like_bitmap_residual(candidate): + return True + if native_shapes and _edge_spanning_diff_band(bbox, source_size): + return True + if _edge_touching_source_diff_residual(candidate, source_size): + return True + if native_shapes and _bottom_source_diff_background_residual(candidate, source_size): + return True + return any( + _tiny_bbox_inside_shape(bbox, shape.source_pixel_bbox) + for shape in native_shapes + if shape.provenance.get("fitter") != "large_card_container" + ) + + +def _is_structural_container_bitmap_candidate( + candidate: ForegroundCandidate, + *, + native_shapes: list[NativeShapeSpec], + min_child_count: int = 3, + min_child_coverage_ratio: float = 0.25, +) -> bool: + if not native_shapes: + return False + if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + if not _is_source_base_difference_candidate(candidate): + return False + candidate_area = _bbox_area_pixels(candidate.source_pixel_bbox) + if candidate_area <= 0: + return False + child_shapes = [ + shape + for shape in native_shapes + if _bbox_contains_ratio(candidate.source_pixel_bbox, shape.source_pixel_bbox) >= 0.85 + ] + if len(child_shapes) < min_child_count: + return False + child_coverage = _bbox_coverage_ratio( + [shape.source_pixel_bbox for shape in child_shapes], + ( + max(1, candidate.source_pixel_bbox[2]), + max(1, candidate.source_pixel_bbox[3]), + ), + ) + # _bbox_coverage_ratio measures against page-like dimensions. Convert it back + # to coverage within the candidate's own bbox. + candidate_page_area = max(1, candidate.source_pixel_bbox[2] * candidate.source_pixel_bbox[3]) + child_area_in_page = child_coverage * candidate_page_area + return child_area_in_page / float(candidate_area) >= min_child_coverage_ratio + + +def _native_shapes_with_source_scan_priority(shapes: list[NativeShapeSpec]) -> list[NativeShapeSpec]: + return sorted( + shapes, + key=lambda shape: ( + 0 if _is_source_native_shape(shape) else 1, + ), + ) + + +def _drop_source_diff_native_fill_residuals( + shapes: list[NativeShapeSpec], + *, + source_image: Image.Image, +) -> list[NativeShapeSpec]: + source_scan_shapes = [ + shape + for shape in shapes + if _is_source_native_shape(shape) + ] + if not source_scan_shapes: + return shapes + filtered: list[NativeShapeSpec] = [] + for shape in shapes: + if _is_source_diff_native_fill_residual(shape, source_scan_shapes, source_image): + continue + filtered.append(shape) + return filtered + + +def _is_source_diff_native_fill_residual( + shape: NativeShapeSpec, + source_scan_shapes: list[NativeShapeSpec], + source_image: Image.Image, +) -> bool: + candidate_id = str(shape.provenance.get("candidate_id", "")) + if _is_source_native_shape(shape): + return False + for container in source_scan_shapes: + if not container.fill_color: + continue + if not _bbox_contains(container.source_pixel_bbox, shape.source_pixel_bbox): + continue + if _bbox_area_pixels(shape.source_pixel_bbox) / float( + max(1, _bbox_area_pixels(container.source_pixel_bbox)) + ) > 0.10: + continue + fill_rgb = _hex_color_to_rgb(container.fill_color) + if fill_rgb is None: + continue + if not _source_region_has_foreground_signal( + source_image, + shape.source_pixel_bbox, + fill_rgb, + threshold=24, + min_fraction=0.02, + ): + return True + return False + + +def _is_source_native_shape(shape: NativeShapeSpec) -> bool: + candidate_id = str(shape.provenance.get("candidate_id", "")) + return candidate_id.startswith("source-native-") or candidate_id.startswith("source-") + + +def _should_drop_native_shape_candidate( + candidate: ForegroundCandidate, + shape: NativeShapeSpec, + source_size: tuple[int, int], + *, + source_image: Image.Image, + source_background: tuple[int, int, int], +) -> bool: + if not _is_source_base_difference_candidate(candidate): + return False + if not _source_region_has_foreground_signal( + source_image, + shape.source_pixel_bbox, + source_background, + ): + return True + if _bottom_source_diff_background_residual(candidate, source_size): + return True + if not _bbox_touches_page_edge(shape.source_pixel_bbox, source_size): + return False + bbox = shape.source_pixel_bbox + width = max(0, bbox[2] - bbox[0]) + height = max(0, bbox[3] - bbox[1]) + if min(width, height) <= 2: + return True + return _bbox_area_pixels(bbox) <= 500 + + +def _tiny_diff_fragment(candidate: ForegroundCandidate) -> bool: + bbox = candidate.source_pixel_bbox + width = max(0, bbox[2] - bbox[0]) + height = max(0, bbox[3] - bbox[1]) + if candidate.classification not in {"uncertain", "complex_whole_visual", "bitmap_asset_candidate"}: + return False + return _bbox_area_pixels(bbox) <= 500 or max(width, height) <= 24 + + +def _line_like_bitmap_residual(candidate: ForegroundCandidate) -> bool: + if not _is_source_base_difference_candidate(candidate): + return False + if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + return False + bbox = candidate.source_pixel_bbox + width = max(1, bbox[2] - bbox[0]) + height = max(1, bbox[3] - bbox[1]) + long_side = max(width, height) + short_side = min(width, height) + return short_side <= 16 and long_side / float(short_side) >= 12 + + +def _edge_spanning_diff_band( + bbox: tuple[int, int, int, int], + source_size: tuple[int, int], +) -> bool: + source_width, source_height = source_size + left, top, right, bottom = bbox + width_ratio = max(0, right - left) / float(max(1, source_width)) + height_ratio = max(0, bottom - top) / float(max(1, source_height)) + edge_margin = max(4, round(min(source_width, source_height) * 0.01)) + touches_vertical_edge = top <= edge_margin or bottom >= source_height - edge_margin + return width_ratio >= 0.90 and height_ratio >= 0.15 and touches_vertical_edge + + +def _edge_touching_source_diff_residual( + candidate: ForegroundCandidate, + source_size: tuple[int, int], +) -> bool: + if not _is_source_base_difference_candidate(candidate): + return False + if candidate.classification == "complex_whole_visual": + return False + area_ratio = _bbox_area_pixels(candidate.source_pixel_bbox) / float( + max(1, source_size[0] * source_size[1]) + ) + return area_ratio <= 0.03 and _bbox_touches_page_edge( + candidate.source_pixel_bbox, + source_size, + ) + + +def _bottom_source_diff_background_residual( + candidate: ForegroundCandidate, + source_size: tuple[int, int], +) -> bool: + if not _is_source_base_difference_candidate(candidate): + return False + _, source_height = source_size + bbox = candidate.source_pixel_bbox + height = max(0, bbox[3] - bbox[1]) + top_ratio = bbox[1] / float(max(1, source_height)) + height_ratio = height / float(max(1, source_height)) + return top_ratio >= 0.90 and height_ratio <= 0.08 + + +def _source_region_has_foreground_signal( + image: Image.Image, + bbox: tuple[int, int, int, int], + background: tuple[int, int, int], + *, + threshold: int = 28, + min_fraction: float = 0.015, +) -> bool: + left, top, right, bottom = _clamp_bbox_to_size(bbox, image.size) + width = right - left + height = bottom - top + if width <= 0 or height <= 0: + return False + signal = 0 + total = width * height + pixels = image.load() + for y in range(top, bottom): + for x in range(left, right): + if _color_distance(pixels[x, y], background) >= threshold: + signal += 1 + return signal / float(total) >= min_fraction + + +def _clamp_bbox_to_size( + bbox: tuple[int, int, int, int], + size: tuple[int, int], +) -> tuple[int, int, int, int]: + width, height = size + left, top, right, bottom = bbox + return ( + max(0, min(width, left)), + max(0, min(height, top)), + max(0, min(width, right)), + max(0, min(height, bottom)), + ) + + +def _color_distance(first: tuple[int, int, int], second: tuple[int, int, int]) -> int: + return sum(abs(int(a) - int(b)) for a, b in zip(first[:3], second[:3])) + + +def _hex_color_to_rgb(value: str) -> tuple[int, int, int] | None: + if not isinstance(value, str): + return None + color = value.strip().lstrip("#") + if len(color) != 6: + return None + try: + return int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + except ValueError: + return None + + +def _is_source_base_difference_candidate(candidate: ForegroundCandidate) -> bool: + provenance = candidate.provenance + original_detection = provenance.get("original_detection") + return provenance.get("detection") == "source_base_difference" or ( + isinstance(original_detection, dict) + and original_detection.get("detection") == "source_base_difference" + ) + + +def _bbox_touches_page_edge( + bbox: tuple[int, int, int, int], + source_size: tuple[int, int], +) -> bool: + source_width, source_height = source_size + left, top, right, bottom = bbox + edge_margin = max(4, round(min(source_width, source_height) * 0.01)) + return ( + left <= edge_margin + or top <= edge_margin + or right >= source_width - edge_margin + or bottom >= source_height - edge_margin + ) + + +def _tiny_bbox_inside_shape( + bbox: tuple[int, int, int, int], + shape_bbox: tuple[int, int, int, int], +) -> bool: + if not _bbox_contains(shape_bbox, bbox): + return False + return _bbox_area_pixels(bbox) / float(max(1, _bbox_area_pixels(shape_bbox))) <= 0.05 + + +def _bbox_contains( + outer: tuple[int, int, int, int], + inner: tuple[int, int, int, int], +) -> bool: + return ( + outer[0] <= inner[0] + and outer[1] <= inner[1] + and outer[2] >= inner[2] + and outer[3] >= inner[3] + ) + + +def _bbox_area_pixels(bbox: tuple[int, int, int, int]) -> int: + return max(0, bbox[2] - bbox[0]) * max(0, bbox[3] - bbox[1]) + + +def _bbox_iou( + first: tuple[int, int, int, int], + second: tuple[int, int, int, int], +) -> float: + left = max(first[0], second[0]) + top = max(first[1], second[1]) + right = min(first[2], second[2]) + bottom = min(first[3], second[3]) + if right <= left or bottom <= top: + return 0.0 + intersection = (right - left) * (bottom - top) + first_area = max(0, first[2] - first[0]) * max(0, first[3] - first[1]) + second_area = max(0, second[2] - second[0]) * max(0, second[3] - second[1]) + union = first_area + second_area - intersection + return intersection / float(union) if union else 0.0 + + +def _bbox_contains_ratio( + outer: tuple[int, int, int, int], + inner: tuple[int, int, int, int], +) -> float: + left = max(outer[0], inner[0]) + top = max(outer[1], inner[1]) + right = min(outer[2], inner[2]) + bottom = min(outer[3], inner[3]) + if right <= left or bottom <= top: + return 0.0 + intersection = (right - left) * (bottom - top) + return intersection / float(max(1, _bbox_area_pixels(inner))) + + +def _union_bbox( + left: tuple[int, int, int, int], + right: tuple[int, int, int, int], +) -> tuple[int, int, int, int]: + return ( + min(left[0], right[0]), + min(left[1], right[1]), + max(left[2], right[2]), + max(left[3], right[3]), + ) + + +def _box_distance_pixels( + left: tuple[int, int, int, int], + right: tuple[int, int, int, int], +) -> int: + horizontal_gap = max(0, max(left[0], right[0]) - min(left[2], right[2])) + vertical_gap = max(0, max(left[1], right[1]) - min(left[3], right[3])) + return max(horizontal_gap, vertical_gap) + + +def _source_component_is_line(width: int, height: int, fill_ratio: float) -> bool: + long_side = max(width, height) + short_side = max(1, min(width, height)) + return (long_side / short_side >= 1.8 and fill_ratio <= 0.30) or ( + long_side >= 30 and fill_ratio <= 0.18 + ) + + +def _source_component_is_box_outline( + points: set[tuple[int, int]], + bbox: tuple[int, int, int, int], +) -> bool: + left, top, right, bottom = bbox + width = max(1, right - left) + height = max(1, bottom - top) + band = max(2, round(min(width, height) * 0.12)) + top_hits = len({x for x, y in points if top <= y < min(bottom, top + band)}) + bottom_hits = len({x for x, y in points if max(top, bottom - band) <= y < bottom}) + left_hits = len({y for x, y in points if left <= x < min(right, left + band)}) + right_hits = len({y for x, y in points if max(left, right - band) <= x < right}) + return ( + top_hits / float(width) >= 0.65 + and bottom_hits / float(width) >= 0.65 + and left_hits / float(height) >= 0.55 + and right_hits / float(height) >= 0.55 + ) + + +def _source_line_endpoints(component: list[tuple[int, int]]) -> tuple[tuple[int, int], tuple[int, int]]: + points = sorted(component) + first = min(points, key=lambda point: point[0] + point[1]) + second = max(points, key=lambda point: point[0] + point[1]) + if abs(first[0] - second[0]) < abs(first[1] - second[1]): + first = min(points, key=lambda point: point[1]) + second = max(points, key=lambda point: point[1]) + else: + first = min(points, key=lambda point: point[0]) + second = max(points, key=lambda point: point[0]) + return first, second + + +def _source_component_has_rounded_corners( + points: set[tuple[int, int]], + bbox: tuple[int, int, int, int], +) -> bool: + return _source_component_sparse_corner_count(points, bbox) >= 1 + + +def _source_component_sparse_corner_count( + points: set[tuple[int, int]], + bbox: tuple[int, int, int, int], +) -> int: + left, top, right, bottom = bbox + width = right - left + height = bottom - top + corner = max(3, round(min(width, height) * 0.18)) + corner_regions = ( + (range(left, min(right, left + corner)), range(top, min(bottom, top + corner))), + (range(max(left, right - corner), right), range(top, min(bottom, top + corner))), + (range(left, min(right, left + corner)), range(max(top, bottom - corner), bottom)), + (range(max(left, right - corner), right), range(max(top, bottom - corner), bottom)), + ) + sparse_corners = 0 + for xs, ys in corner_regions: + total = 0 + present = 0 + for x in xs: + for y in ys: + total += 1 + if (x, y) in points: + present += 1 + if total and present / float(total) < 0.75: + sparse_corners += 1 + return sparse_corners + + +def _repair_validation_report(page: PageManifest) -> ValidationReport: + issues = [] + fallback_asset_ids = { + asset.asset_id + for asset in page.bitmap_assets + if asset.provenance.get("fallback") == "source_crop_after_provider_qa" + } + for attempt in page.repair_attempts: + if attempt.status != "failed": + continue + if attempt.target_id in fallback_asset_ids: + continue + code = "repair_limit_exceeded" if attempt.reason.startswith("repair_limit_exceeded") else "repair_failed" + issues.append( + ValidationIssue( + code=code, + message=f"asset repair failed: {attempt.reason}", + slide_id=page.slide_id, + details={ + "target_id": attempt.target_id, + "attempt_index": attempt.attempt_index, + "reason": attempt.reason, + "provider_role": attempt.provider_role, + }, + ) + ) + return ValidationReport( + status="failed" if issues else "passed", + checked_pages=1, + issues=issues, + ) + + +def _text_validation_report(page: PageManifest) -> ValidationReport: + issues = [] + if page.provenance.get("chosen_background_kind") == "source_raster_guardrail": + issues.append( + ValidationIssue( + code="source_raster_guardrail_degraded", + message="text editability was dropped because OCR/layout was unreliable", + slide_id=page.slide_id, + details={ + "chosen_background_kind": "source_raster_guardrail", + "text_validation_status": page.provenance.get("text_validation_status"), + "source_raster_guardrail": page.provenance.get("source_raster_guardrail", {}), + "warning_count": len(page.provenance.get("text_issues", [])), + }, + ) + ) + for raw_issue in page.provenance.get("text_issues", []): + if not isinstance(raw_issue, dict): + continue + if raw_issue.get("severity") == "warning": + continue + code = str(raw_issue.get("code") or "text_validation_failed") + issues.append( + ValidationIssue( + code=code, + message=f"text extraction validation failed: {code}", + slide_id=page.slide_id, + details=dict(raw_issue), + ) + ) + if page.provenance.get("text_validation_status") == "failed" and not issues: + issues.append( + ValidationIssue( + code="text_validation_failed", + message="text extraction validation failed", + slide_id=page.slide_id, + ) + ) + return ValidationReport( + status="failed" if issues else "passed", + checked_pages=1, + issues=issues, + ) + + +def _bitmap_coverage_validation_report(page: PageManifest) -> ValidationReport: + source_preserved_assets = [ + asset + for asset in page.bitmap_assets + if asset.provenance.get("asset_strategy") == "masked_source_element" + ] + if not source_preserved_assets: + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + page_area = max(1, page.source_image_size[0] * page.source_image_size[1]) + largest_asset = max( + source_preserved_assets, + key=lambda asset: _source_preserved_asset_effective_area_ratio(asset, page.source_image_size), + ) + largest_bbox = _clamp_bbox_to_size(largest_asset.source_pixel_bbox, page.source_image_size) + largest_bbox_ratio = _bbox_area_pixels(largest_bbox) / float(page_area) + largest_ratio = _source_preserved_asset_effective_area_ratio(largest_asset, page.source_image_size) + combined_bbox_ratio = _bbox_coverage_ratio( + [asset.source_pixel_bbox for asset in source_preserved_assets], + page.source_image_size, + ) + combined_visible_ratio = min( + 1.0, + sum( + _source_preserved_asset_effective_area_ratio(asset, page.source_image_size) + for asset in source_preserved_assets + ), + ) + combined_ratio = min(combined_bbox_ratio, combined_visible_ratio) + structure_count = len(page.native_shapes) + len(page.text_boxes) + + details = { + "bitmap_asset_count": len(page.bitmap_assets), + "source_preserved_bitmap_asset_count": len(source_preserved_assets), + "combined_bitmap_asset_coverage_ratio": round(combined_ratio, 4), + "combined_bitmap_asset_bbox_coverage_ratio": round(combined_bbox_ratio, 4), + "combined_bitmap_asset_visible_area_ratio": round(combined_visible_ratio, 4), + "largest_asset_area_ratio": round(largest_ratio, 4), + "largest_asset_bbox_area_ratio": round(largest_bbox_ratio, 4), + "largest_asset_id": largest_asset.asset_id, + "native_shape_count": len(page.native_shapes), + "text_box_count": len(page.text_boxes), + } + issues: list[ValidationIssue] = [] + if largest_ratio >= 0.80: + issues.append( + ValidationIssue( + code="oversized_bitmap_asset_coverage", + message="a source-preserved bitmap asset covers most of the slide", + slide_id=page.slide_id, + details=details, + ) + ) + elif combined_ratio >= 0.85 and structure_count < 3: + issues.append( + ValidationIssue( + code="excessive_bitmap_asset_coverage", + message="source-preserved bitmap assets dominate the slide without enough editable structure", + slide_id=page.slide_id, + details=details, + ) + ) + elif combined_ratio >= 0.65 and not _has_split_row_level_bitmap_structure( + source_preserved_assets, + largest_ratio=largest_ratio, + structure_count=structure_count, + ): + issues.append( + ValidationIssue( + code="high_bitmap_asset_coverage", + message="source-preserved bitmap assets cover a large part of the slide", + slide_id=page.slide_id, + severity="warning", + details=details, + ) + ) + return ValidationReport( + status="failed" if issues else "passed", + checked_pages=1, + issues=issues, + ) + + +def _source_preserved_asset_effective_area_ratio( + asset: BitmapAssetSpec, + source_image_size: tuple[int, int], +) -> float: + visible_ratio = asset.provenance.get("alpha_visible_area_ratio") + if ( + asset.provenance.get("background_difference_alpha") is True + and isinstance(visible_ratio, (int, float)) + and 0.0 <= float(visible_ratio) <= 1.0 + ): + return float(visible_ratio) + page_area = max(1, source_image_size[0] * source_image_size[1]) + bbox = _clamp_bbox_to_size(asset.source_pixel_bbox, source_image_size) + return _bbox_area_pixels(bbox) / float(page_area) + + +def _has_split_row_level_bitmap_structure( + source_preserved_assets: list[BitmapAssetSpec], + *, + largest_ratio: float, + structure_count: int, +) -> bool: + split_row_structure = ( + len(source_preserved_assets) >= 3 + and largest_ratio <= 0.35 + and structure_count >= 3 + ) + dense_infographic_structure = ( + len(source_preserved_assets) >= 3 + and largest_ratio <= 0.60 + and structure_count >= 12 + ) + return split_row_structure or dense_infographic_structure + + +def _visual_text_coverage_issues( + text_boxes: list, + *, + visual_text_candidates: list[tuple[int, int, int, int]], + non_blocking_visual_text_candidates: set[tuple[int, int, int, int]] | None = None, +) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + covered_boxes = [text_box.source_pixel_bbox for text_box in text_boxes] + non_blocking = non_blocking_visual_text_candidates or set() + for candidate_bbox in visual_text_candidates: + if candidate_bbox in non_blocking: + continue + if any(_text_box_covers_visual_candidate(text_bbox, candidate_bbox) for text_bbox in covered_boxes): + continue + issues.append( + { + "code": "visual_text_candidate_missing_ocr_text", + "source_pixel_bbox": candidate_bbox, + } + ) + return issues + + +def _focused_recovery_candidate_bboxes( + *, + text_boxes: list, + text_issues: list[dict[str, Any]], + visual_text_candidates: list[tuple[int, int, int, int]], + quality_gate_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> list[tuple[int, int, int, int]]: + if not text_boxes and visual_text_candidates and _ocr_issues_are_only_spurious(text_issues): + visual_candidates = _focused_recovery_visual_text_candidates( + visual_text_candidates, + source_image_size, + ) + summary_candidates = ( + _summary_slide_recovery_bboxes(source_image_size) + if _should_use_summary_slide_recovery_bboxes( + visual_text_candidates, + source_image_size, + ) + else [] + ) + return _dedupe_bboxes_preserve_order( + [ + *summary_candidates, + *visual_candidates, + ] + ) + covered_boxes = [ + text_box.source_pixel_bbox + for text_box in text_boxes + if not _text_box_has_approximate_ocr_layout(text_box) + ] + return [ + candidate_bbox + for candidate_bbox in quality_gate_candidates + if not any( + _text_box_covers_visual_candidate(text_bbox, candidate_bbox) + for text_bbox in covered_boxes + ) + ] + + +def _focused_recovery_visual_text_candidates( + visual_text_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> list[tuple[int, int, int, int]]: + page_area = max(1, source_image_size[0] * source_image_size[1]) + image_height = max(1, source_image_size[1]) + return [ + bbox + for bbox in visual_text_candidates + if _bbox_area_pixels(bbox) / float(page_area) <= 0.12 + and (bbox[3] - bbox[1]) / float(image_height) <= 0.18 + ] + + +def _should_use_summary_slide_recovery_bboxes( + visual_text_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> bool: + page_area = max(1, source_image_size[0] * source_image_size[1]) + if any(_bbox_area_pixels(bbox) / float(page_area) > 0.20 for bbox in visual_text_candidates): + return False + return _has_summary_like_visual_text_candidate_pattern( + visual_text_candidates, + source_image_size, + ) + + +def _has_summary_like_visual_text_candidate_pattern( + visual_text_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> bool: + width, height = source_image_size + if len(visual_text_candidates) < 3 or width <= 0 or height <= 0: + return False + has_upper_summary_line = any( + width * 0.24 <= bbox[0] + and bbox[2] <= width * 0.78 + and height * 0.18 <= bbox[1] <= height * 0.36 + for bbox in visual_text_candidates + ) + has_body_line = any( + width * 0.20 <= bbox[0] + and bbox[2] <= width * 0.82 + and height * 0.42 <= bbox[1] <= height * 0.62 + for bbox in visual_text_candidates + ) + has_bottom_helper_or_qr_line = any( + bbox[0] >= width * 0.72 + and bbox[1] >= height * 0.72 + for bbox in visual_text_candidates + ) + return has_upper_summary_line and has_body_line and has_bottom_helper_or_qr_line + + +def _summary_slide_recovery_bboxes(source_image_size: tuple[int, int]) -> list[tuple[int, int, int, int]]: + width, height = source_image_size + return [ + (round(width * 0.28), round(height * 0.22), round(width * 0.72), round(height * 0.29)), + (round(width * 0.27), round(height * 0.32), round(width * 0.76), round(height * 0.46)), + (round(width * 0.25), round(height * 0.48), round(width * 0.80), round(height * 0.532)), + (round(width * 0.30), round(height * 0.53), round(width * 0.75), round(height * 0.60)), + (round(width * 0.81), round(height * 0.92), round(width * 0.97), round(height * 0.98)), + ] + + +def _dedupe_bboxes_preserve_order( + bboxes: list[tuple[int, int, int, int]], + *, + iou_threshold: float = 0.70, +) -> list[tuple[int, int, int, int]]: + deduped: list[tuple[int, int, int, int]] = [] + for bbox in bboxes: + if any(_bbox_iou(bbox, existing) >= iou_threshold for existing in deduped): + continue + deduped.append(bbox) + return deduped + + +def _ocr_issues_are_only_spurious(issues: list[dict[str, Any]]) -> bool: + if not issues: + return False + codes = {str(issue.get("code", "")) for issue in issues} + return bool(codes) and codes <= {"ignored_spurious_ocr", "no_ocr_text_detected"} + + +def _filter_unanchored_approximate_text_boxes( + text_boxes: list[TextBoxSpec], + *, + visual_text_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> tuple[list[TextBoxSpec], list[dict[str, Any]]]: + if len(visual_text_candidates) < 3: + return text_boxes, [] + kept: list[TextBoxSpec] = [] + issues: list[dict[str, Any]] = [] + for text_box in text_boxes: + if not _text_box_has_approximate_ocr_layout(text_box): + kept.append(text_box) + continue + if _approximate_text_box_is_anchored( + text_box.source_pixel_bbox, + visual_text_candidates, + source_image_size, + text=text_box.text, + ): + kept.append(text_box) + continue + if _is_right_side_spec_stack_text_box( + text_box, + text_boxes, + source_image_size, + visual_text_candidates=visual_text_candidates, + ): + kept.append(text_box) + continue + issues.append( + { + "code": "unanchored_approximate_ocr", + "severity": "warning", + "ocr_text": text_box.text, + "source_pixel_bbox": text_box.source_pixel_bbox, + } + ) + return kept, issues + + +def _is_right_side_spec_stack_text_box( + text_box: TextBoxSpec, + text_boxes: list[TextBoxSpec], + source_image_size: tuple[int, int], + *, + visual_text_candidates: list[tuple[int, int, int, int]], +) -> bool: + width, height = source_image_size + if width <= 0 or height <= 0: + return False + left, top, right, bottom = text_box.source_pixel_bbox + center_x = (left + right) / 2.0 + box_height = bottom - top + if center_x < width * 0.68 or box_height > height * 0.06: + return False + stack_boxes = [ + other + for other in text_boxes + if _text_box_has_approximate_ocr_layout(other) + and _right_side_spec_stack_candidate(other.source_pixel_bbox, source_image_size) + ] + if len(stack_boxes) < 3: + return False + same_column = [ + other + for other in stack_boxes + if abs(((other.source_pixel_bbox[0] + other.source_pixel_bbox[2]) / 2.0) - center_x) <= width * 0.095 + ] + if text_box not in same_column: + return False + if not _right_side_spec_stack_has_visual_column_anchors( + same_column, + visual_text_candidates, + source_image_size, + ): + return False + if len(same_column) >= 4: + return True + ordered = sorted(same_column, key=lambda item: item.source_pixel_bbox[1]) + neighbor_centers = [(item.source_pixel_bbox[1] + item.source_pixel_bbox[3]) / 2.0 for item in ordered] + return len(neighbor_centers) >= 3 and max(neighbor_centers) - min(neighbor_centers) <= height * 0.22 + + +def _right_side_spec_stack_has_visual_column_anchors( + stack_boxes: list[TextBoxSpec], + visual_text_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> bool: + width, height = source_image_size + if len(visual_text_candidates) < 3 or width <= 0 or height <= 0: + return False + anchored_count = 0 + for text_box in stack_boxes: + if _approximate_text_box_is_anchored( + text_box.source_pixel_bbox, + visual_text_candidates, + source_image_size, + text=text_box.text, + ): + anchored_count += 1 + return anchored_count >= min(2, len(stack_boxes)) + + +def _right_side_spec_stack_candidate( + bbox: tuple[int, int, int, int], + source_image_size: tuple[int, int], +) -> bool: + width, height = source_image_size + left, top, right, bottom = bbox + box_width = right - left + box_height = bottom - top + center_x = (left + right) / 2.0 + return ( + center_x >= width * 0.68 + and width * 0.02 <= box_width <= width * 0.22 + and height * 0.015 <= box_height <= height * 0.06 + and height * 0.12 <= top <= height * 0.88 + ) + + +def _approximate_text_box_is_anchored( + bbox: tuple[int, int, int, int], + visual_text_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], + *, + text: str = "", +) -> bool: + width, height = source_image_size + if _is_large_top_text_box(bbox, width, height): + return True + if _is_small_top_approximate_text_box(bbox, width, height): + return False + return any( + _visual_candidate_can_anchor_text(text, candidate, source_image_size) + and _visual_candidate_size_matches_text_bbox(bbox, candidate, source_image_size) + and (_bbox_iou(bbox, candidate) >= 0.05 or _bbox_center_inside(bbox, candidate)) + for candidate in visual_text_candidates + ) + + +def _visual_candidate_size_matches_text_bbox( + bbox: tuple[int, int, int, int], + candidate: tuple[int, int, int, int], + source_image_size: tuple[int, int], +) -> bool: + image_width, image_height = source_image_size + bbox_width = max(1, bbox[2] - bbox[0]) + bbox_height = max(1, bbox[3] - bbox[1]) + candidate_width = max(1, candidate[2] - candidate[0]) + candidate_height = max(1, candidate[3] - candidate[1]) + if _is_large_top_text_box(bbox, image_width, image_height): + return True + max_width = max(bbox_width * 3.0, bbox_width + image_width * 0.08) + max_height = max(bbox_height * 2.5, bbox_height + image_height * 0.04) + return candidate_width <= max_width and candidate_height <= max_height + + +def _visual_candidate_can_anchor_text( + text: str, + candidate: tuple[int, int, int, int], + source_image_size: tuple[int, int], +) -> bool: + image_width, image_height = source_image_size + if image_width <= 0 or image_height <= 0: + return False + left, top, right, bottom = candidate + width = max(0, right - left) + height = max(0, bottom - top) + if width <= 0 or height <= 0: + return False + aspect = width / float(height) + meaningful_len = len([char for char in text if char.isalnum() or "\u3400" <= char <= "\u9fff"]) + if _is_large_top_text_box(candidate, image_width, image_height): + return meaningful_len >= 6 + if _is_short_section_label_text(text): + return width >= image_width * 0.04 and aspect >= 1.35 and height <= image_height * 0.10 + if aspect < 1.6: + return False + if meaningful_len >= 6: + return width >= image_width * 0.06 and height <= image_height * 0.12 + return width >= image_width * 0.05 and aspect >= 2.0 and height <= image_height * 0.09 + + +def _is_short_section_label_text(text: str) -> bool: + stripped = str(text).strip() + meaningful = "".join(char for char in stripped if char.isalnum() or "\u3400" <= char <= "\u9fff") + cjk_count = sum(1 for char in meaningful if "\u3400" <= char <= "\u9fff") + return 2 <= cjk_count <= 5 and meaningful.endswith(("域", "区", "层", "类", "项", "栏")) + + +def _is_large_top_text_box( + bbox: tuple[int, int, int, int], + image_width: int, + image_height: int, +) -> bool: + left, top, right, bottom = bbox + return ( + top <= image_height * 0.12 + and (right - left) >= image_width * 0.25 + and (bottom - top) >= 20 + ) + + +def _is_small_top_approximate_text_box( + bbox: tuple[int, int, int, int], + image_width: int, + image_height: int, +) -> bool: + left, top, right, bottom = bbox + return ( + top <= image_height * 0.12 + and (bottom - top) <= max(18, image_height * 0.025) + and (right - left) <= image_width * 0.18 + ) + + +def _is_substantial_unanchored_approximate_text_box( + text_box: TextBoxSpec, + source_image_size: tuple[int, int], +) -> bool: + image_width, image_height = source_image_size + if image_width <= 0 or image_height <= 0: + return False + left, top, right, bottom = text_box.source_pixel_bbox + box_width = max(0, right - left) + box_height = max(0, bottom - top) + if _is_small_top_approximate_text_box(text_box.source_pixel_bbox, image_width, image_height): + return False + if box_width < max(72, image_width * 0.04) or box_height < max(18, image_height * 0.018): + return False + meaningful_len = len("".join(char for char in text_box.text if char.isalnum())) + if meaningful_len >= 8 and box_width >= image_width * 0.05: + return True + return box_width >= image_width * 0.08 and box_height >= image_height * 0.025 + + +def _bbox_center_inside( + bbox: tuple[int, int, int, int], + candidate: tuple[int, int, int, int], +) -> bool: + center_x = (bbox[0] + bbox[2]) / 2.0 + center_y = (bbox[1] + bbox[3]) / 2.0 + horizontal_padding = max(6, round((candidate[2] - candidate[0]) * 0.08)) + vertical_padding = max(6, round((candidate[3] - candidate[1]) * 0.18)) + return ( + candidate[0] - horizontal_padding <= center_x <= candidate[2] + horizontal_padding + and candidate[1] - vertical_padding <= center_y <= candidate[3] + vertical_padding + ) + + +def _recover_visual_text_candidates( + *, + source_image_path: str | Path, + source_image_size: tuple[int, int], + text_boxes: list, + uncovered_candidates: list[tuple[int, int, int, int]], + ocr_provider: OCRProvider, + crop_dir: str | Path, + ignored_noise_candidates: set[tuple[int, int, int, int]] | None = None, + min_confidence: float = 0.75, +) -> list[TextBoxSpec]: + crop_root = Path(crop_dir) + crop_root.mkdir(parents=True, exist_ok=True) + recovered: list[TextBoxSpec] = [] + with Image.open(source_image_path) as source: + source_rgb = source.convert("RGB") + for index, candidate_bbox in enumerate(uncovered_candidates, start=1): + crop_path = crop_root / f"visual-text-candidate-{index:03d}.png" + source_rgb.crop(candidate_bbox).save(crop_path) + try: + crop_ocr = ocr_provider.extract_text(str(crop_path)) + except (ProviderError, ProviderTimeoutError): + continue + ocr_item = _best_recovered_ocr_item(crop_ocr.items, min_confidence=min_confidence) + if ocr_item is None: + if crop_ocr.items and all( + _is_recovered_ocr_noise_text(item.text) + for item in crop_ocr.items + if item.text.strip() + ): + if ignored_noise_candidates is not None: + ignored_noise_candidates.add(candidate_bbox) + continue + if _recovered_ocr_duplicates_existing_text( + ocr_item.text, + candidate_bbox, + [*text_boxes, *recovered], + ): + if ignored_noise_candidates is not None: + ignored_noise_candidates.add(candidate_bbox) + continue + recovered.append( + _recovered_text_box_from_candidate( + candidate_bbox, + ocr_item, + source_image_size=source_image_size, + text_boxes=text_boxes, + ) + ) + return recovered + + +def _remove_approximate_text_boxes_replaced_by_focused_recovery( + text_boxes: list[TextBoxSpec], + recovered_text_boxes: list[TextBoxSpec], +) -> list[TextBoxSpec]: + if not recovered_text_boxes: + return text_boxes + filtered: list[TextBoxSpec] = [] + for text_box in text_boxes: + if not _text_box_has_approximate_ocr_layout(text_box): + filtered.append(text_box) + continue + if _is_replaced_by_focused_recovery(text_box, recovered_text_boxes): + continue + filtered.append(text_box) + return filtered + + +def _is_replaced_by_focused_recovery( + text_box: TextBoxSpec, + recovered_text_boxes: list[TextBoxSpec], +) -> bool: + original_text = _normalize_text_for_duplicate_check(text_box.text) + for recovered in recovered_text_boxes: + recovered_text = _normalize_text_for_duplicate_check(recovered.text) + if not recovered_text: + continue + if original_text and ( + original_text == recovered_text + or original_text in recovered_text + or recovered_text in original_text + ): + continue + if _text_box_covers_visual_candidate(text_box.source_pixel_bbox, recovered.source_pixel_bbox): + return True + if _approximate_text_box_is_near_focused_recovery( + text_box.source_pixel_bbox, + recovered.source_pixel_bbox, + ): + return True + return False + + +def _approximate_text_box_is_near_focused_recovery( + text_bbox: tuple[int, int, int, int], + recovered_bbox: tuple[int, int, int, int], +) -> bool: + text_width = max(1, text_bbox[2] - text_bbox[0]) + recovered_width = max(1, recovered_bbox[2] - recovered_bbox[0]) + text_height = max(1, text_bbox[3] - text_bbox[1]) + recovered_height = max(1, recovered_bbox[3] - recovered_bbox[1]) + horizontal_overlap = _horizontal_overlap_ratio(text_bbox, recovered_bbox) + text_center_x = (text_bbox[0] + text_bbox[2]) / 2.0 + recovered_center_x = (recovered_bbox[0] + recovered_bbox[2]) / 2.0 + text_center_y = (text_bbox[1] + text_bbox[3]) / 2.0 + recovered_center_y = (recovered_bbox[1] + recovered_bbox[3]) / 2.0 + return ( + horizontal_overlap >= 0.65 + and abs(text_center_x - recovered_center_x) <= max(text_width, recovered_width) * 0.18 + and abs(text_center_y - recovered_center_y) <= max(text_height, recovered_height) * 1.25 + ) + + +def _horizontal_overlap_ratio( + first: tuple[int, int, int, int], + second: tuple[int, int, int, int], +) -> float: + overlap = min(first[2], second[2]) - max(first[0], second[0]) + if overlap <= 0: + return 0.0 + first_width = max(1, first[2] - first[0]) + second_width = max(1, second[2] - second[0]) + return overlap / float(min(first_width, second_width)) + + +def _best_recovered_ocr_item( + items: list[OCRTextItem], + *, + min_confidence: float = 0.75, +) -> OCRTextItem | None: + text_items = [ + item + for item in items + if item.text.strip() + and item.confidence >= min_confidence + and not _is_recovered_ocr_noise_text(item.text) + ] + if not text_items: + return None + return max(text_items, key=lambda item: (item.confidence, len(item.text.strip()))) + + +def _is_recovered_ocr_noise_text(text: str) -> bool: + stripped = text.strip() + if not stripped: + return True + meaningful = [char for char in stripped if not char.isspace() and char not in ".,;:!?,。;:!?、·-—_()()[]【】{}<>《》/\\|"] + if not meaningful: + return True + unique_meaningful = set(meaningful) + digit_count = sum(1 for char in meaningful if char.isdigit()) + cjk_count = sum(1 for char in meaningful if "\u3400" <= char <= "\u9fff") + alpha_count = sum(1 for char in meaningful if char.isalpha() and not ("\u3400" <= char <= "\u9fff")) + if cjk_count >= 3 and len(unique_meaningful) <= 2: + return True + if "输入文本" in stripped: + return True + if "文本内容" in stripped: + return True + if re.search(r"用户需求", stripped) and re.search(r"^\s*\d+[.、]", stripped): + return True + if len(stripped) >= 20 and len(unique_meaningful) <= 2: + return True + if len(stripped) >= 20 and digit_count / float(len(meaningful)) >= 0.85 and cjk_count == 0: + return True + if cjk_count == 0 and alpha_count == 0 and digit_count > 0 and len(unique_meaningful) <= 2: + return True + if _is_likely_repetitive_hallucinated_text(stripped): + return True + return False + + +def _recovered_ocr_duplicates_existing_text( + text: str, + candidate_bbox: tuple[int, int, int, int], + text_boxes: list, +) -> bool: + normalized = _normalize_text_for_duplicate_check(text) + if not normalized: + return True + for text_box in text_boxes: + existing_text = _normalize_text_for_duplicate_check(getattr(text_box, "text", "")) + if not existing_text: + continue + if normalized != existing_text and normalized not in existing_text and existing_text not in normalized: + continue + existing_bbox = text_box.source_pixel_bbox + if ( + _bbox_iou(candidate_bbox, existing_bbox) >= 0.05 + or _bbox_contains_ratio(candidate_bbox, existing_bbox) >= 0.35 + or _bbox_contains_ratio(existing_bbox, candidate_bbox) >= 0.35 + ): + return True + return False + + +def _normalize_text_for_duplicate_check(text: str) -> str: + return "".join(char for char in str(text).lower() if char.isalnum() or "\u3400" <= char <= "\u9fff") + + +def _recovered_text_box_from_candidate( + candidate_bbox: tuple[int, int, int, int], + ocr_item: OCRTextItem, + *, + source_image_size: tuple[int, int], + text_boxes: list, +) -> TextBoxSpec: + x1, y1, x2, y2 = candidate_bbox + style_reference = _matching_recovered_text_style( + ocr_item.text.strip(), + candidate_bbox, + text_boxes, + ) + font_size = style_reference.get("font_size") or _font_size_points_from_source_pixel_height( + y2 - y1, + source_image_size[1], + ) + font_size = _cap_recovered_text_font_size( + ocr_item.text.strip(), + candidate_bbox, + source_image_size=source_image_size, + font_size=font_size, + ) + return TextBoxSpec( + text=ocr_item.text.strip(), + source_pixel_bbox=candidate_bbox, + source_pixel_polygon=((x1, y1), (x2, y1), (x2, y2), (x1, y2)), + font_family=style_reference.get("font_family") + or ("Microsoft YaHei" if _contains_cjk_text(ocr_item.text) else "Arial"), + font_size=font_size, + color_hex=style_reference.get("color_hex") or ocr_item.color_hex or "#000000", + alignment=ocr_item.alignment, + style_hints={ + **dict(ocr_item.style_hints), + "recovered_from_visual_candidate": True, + }, + provenance={ + "content_source": "focused_crop_ocr", + "layout_source": "visual_text_candidate", + "ocr_confidence": ocr_item.confidence, + }, + ) + + +def _cap_recovered_text_font_size( + text: str, + candidate_bbox: tuple[int, int, int, int], + *, + source_image_size: tuple[int, int], + font_size: float, +) -> float: + _x1, y1, _x2, y2 = candidate_bbox + _source_width, source_height = source_image_size + meaningful_len = len([char for char in text if char.isalnum() or "\u3400" <= char <= "\u9fff"]) + box_height_ratio = (y2 - y1) / float(max(1, source_height)) + if meaningful_len >= 24 and box_height_ratio >= 0.08: + return min(font_size, 22.0) + return font_size + + +def _should_use_source_raster_guardrail( + *, + text_boxes: list[TextBoxSpec], + text_issues: list[dict[str, Any]], + source_image_size: tuple[int, int] | None = None, +) -> bool: + if not text_boxes: + return False + if any(issue.get("code") == "unreliable_ocr_layout" for issue in text_issues): + return True + return False + + +def _should_use_text_editable_source_preserving_fast_path( + *, + text_boxes: list[TextBoxSpec], + visual_text_candidates: list[tuple[int, int, int, int]], + text_issues: list[dict[str, Any]], +) -> bool: + # This legacy fast path preserves visual fidelity by keeping the source slide + # as a full-slide image and adding low-opacity OCR text. That is useful as a + # degraded diagnostic artifact, but it is not an editable reconstruction. + # The normal pipeline must try clean backgrounds, foreground planning, native + # shapes, and asset sheets first. + return False + + +def _source_preserving_low_opacity_text_boxes( + text_boxes: list[TextBoxSpec], + *, + opacity: float = 0.1, +) -> list[TextBoxSpec]: + return [ + replace( + text_box, + opacity=opacity, + style_hints={ + **dict(text_box.style_hints), + "source_preserving_overlay": True, + }, + provenance={ + **dict(text_box.provenance), + "rendering_strategy": "source_preserving_low_opacity_overlay", + }, + ) + for text_box in text_boxes + ] + + +def _suspicious_tall_short_approximate_text_count(text_boxes: list[TextBoxSpec]) -> int: + count = 0 + for text_box in text_boxes: + if not _text_box_has_approximate_ocr_layout(text_box): + continue + left, top, right, bottom = text_box.source_pixel_bbox + height = bottom - top + meaningful_len = len("".join(char for char in text_box.text if char.isalnum())) + if height >= 55 and meaningful_len <= 12: + count += 1 + return count + + +def _has_fragmented_top_approximate_ocr_layout( + text_boxes: list[TextBoxSpec], + source_image_size: tuple[int, int], +) -> bool: + width, height = source_image_size + if width <= 0 or height <= 0: + return False + top_fragment_count = 0 + for text_box in text_boxes: + if not _text_box_has_approximate_ocr_layout(text_box): + continue + left, top, right, bottom = text_box.source_pixel_bbox + box_width = max(0, right - left) + box_height = max(0, bottom - top) + if top > height * 0.25: + continue + if box_width > width * 0.16: + continue + if box_height > height * 0.08: + continue + top_fragment_count += 1 + return top_fragment_count >= 3 + + +def _source_raster_guardrail_text_boxes( + text_boxes: list[TextBoxSpec], + *, + source_image_size: tuple[int, int], +) -> list[TextBoxSpec]: + return [] + + +def _text_box_has_approximate_ocr_layout(text_box: TextBoxSpec) -> bool: + provenance = text_box.provenance.get("ocr_provenance") + return isinstance(provenance, dict) and bool(provenance.get("approximate_layout")) + + +def _matching_recovered_text_style( + text: str, + candidate_bbox: tuple[int, int, int, int], + text_boxes: list, +) -> dict[str, Any]: + if not text_boxes: + return {} + candidate_center_x = (candidate_bbox[0] + candidate_bbox[2]) / 2.0 + comparable = [] + for text_box in text_boxes: + box_text = getattr(text_box, "text", "") + if _contains_cjk_text(text) and text.endswith("域") and not str(box_text).endswith("域"): + continue + bbox = text_box.source_pixel_bbox + center_x = (bbox[0] + bbox[2]) / 2.0 + comparable.append((abs(center_x - candidate_center_x), text_box)) + if not comparable: + return {} + _distance, reference = min(comparable, key=lambda item: item[0]) + return { + "font_family": reference.font_family, + "font_size": reference.font_size, + "color_hex": reference.color_hex, + } + + +def _font_size_points_from_source_pixel_height(pixel_height: int, image_height: int) -> float: + if image_height <= 0: + return 18.0 + points = pixel_height * 72.0 * 5.625 / image_height * 0.8 + return round(max(8.0, min(44.0, points)), 2) + + +def _contains_cjk_text(text: str) -> bool: + return any("\u3400" <= char <= "\u9fff" for char in text) + + +def _quality_gate_visual_text_candidates( + visual_text_candidates: list[tuple[int, int, int, int]], + source_image_size: tuple[int, int], +) -> list[tuple[int, int, int, int]]: + width, height = source_image_size + return [ + bbox + for bbox in visual_text_candidates + if _is_left_label_quality_gate_candidate(bbox, width, height) + or _is_top_card_heading_quality_gate_candidate(bbox, width, height) + or _is_general_text_like_quality_gate_candidate(bbox, width, height) + ] + + +def _is_left_label_quality_gate_candidate( + bbox: tuple[int, int, int, int], + image_width: int, + image_height: int, +) -> bool: + x1, y1, x2, y2 = bbox + width = x2 - x1 + height = y2 - y1 + center_x = (x1 + x2) / 2.0 + return ( + image_width * 0.12 <= center_x <= image_width * 0.30 + and y1 >= image_height * 0.08 + and 35 <= width <= image_width * 0.20 + and 12 <= height <= max(80, image_height * 0.09) + and width >= height * 1.15 + ) + + +def _is_top_card_heading_quality_gate_candidate( + bbox: tuple[int, int, int, int], + image_width: int, + image_height: int, +) -> bool: + x1, y1, x2, y2 = bbox + width = x2 - x1 + height = y2 - y1 + center_x = (x1 + x2) / 2.0 + return ( + image_height * 0.09 <= y1 <= image_height * 0.22 + and image_width * 0.04 <= center_x <= image_width * 0.72 + and 70 <= width <= image_width * 0.18 + and 22 <= height <= max(80, image_height * 0.09) + and width >= height * 2.0 + ) + + +def _is_general_text_like_quality_gate_candidate( + bbox: tuple[int, int, int, int], + image_width: int, + image_height: int, +) -> bool: + x1, y1, x2, y2 = bbox + width = x2 - x1 + height = y2 - y1 + if image_width <= 0 or image_height <= 0 or width <= 0 or height <= 0: + return False + return ( + y1 >= image_height * 0.10 + and 45 <= width <= image_width * 0.28 + and 14 <= height <= max(94, image_height * 0.10) + and width >= height * 1.55 + ) + + +def _text_box_covers_visual_candidate( + text_bbox: tuple[int, int, int, int], + candidate_bbox: tuple[int, int, int, int], +) -> bool: + return ( + _bbox_iou(text_bbox, candidate_bbox) >= 0.45 + or _bbox_contains_ratio(text_bbox, candidate_bbox) >= 0.72 + or _bbox_contains_ratio(candidate_bbox, text_bbox) >= 0.72 + ) + + +def _has_blocking_text_issue(issues: list[dict[str, Any]]) -> bool: + return any(issue.get("severity") != "warning" for issue in issues) + + +def _rewrite_validation_statuses( + *, + artifacts: GenerativeEditableJobArtifacts, + deck_manifest: DeckManifest, + page_manifests: list[PageManifest], + deck_validation_status: Literal["passed", "failed"], + page_validation_statuses: dict[str, Literal["passed", "failed"]], +) -> None: + updated_deck = replace(deck_manifest, validation_status=deck_validation_status) + write_manifest(artifacts.deck_manifest_path, updated_deck) + for page in page_manifests: + write_manifest( + artifacts.page_manifest_path(page.slide_id, page.page_index), + replace(page, validation_status=page_validation_statuses.get(page.slide_id, "failed")), + ) + + +def _merge_validation_reports( + reports: list[ValidationReport], + *, + checked_pages: int | None = None, +) -> ValidationReport: + issues: list[ValidationIssue] = [] + checked_pages_from_reports = 0 + has_failed_status = False + for report in reports: + checked_pages_from_reports = max(checked_pages_from_reports, report.checked_pages) + issues.extend(report.issues) + has_failed_status = has_failed_status or report.status != "passed" + if has_failed_status and not issues: + issues.append( + ValidationIssue( + code="validation_failed", + message="validation failed without a specific issue", + ) + ) + return ValidationReport( + status="failed" if issues or has_failed_status else "passed", + checked_pages=checked_pages if checked_pages is not None else checked_pages_from_reports, + issues=issues, + ) + + +def _coerce_asset_build_result(value: AssetBuildResult | list[BitmapAssetSpec]) -> AssetBuildResult: + if isinstance(value, AssetBuildResult): + return value + return AssetBuildResult(bitmap_assets=list(value)) + + +def _padded_bbox( + bbox: tuple[int, int, int, int], + size: tuple[int, int], + *, + padding: int, +) -> tuple[int, int, int, int]: + left, top, right, bottom = bbox + width, height = size + return ( + max(0, left - padding), + max(0, top - padding), + min(width, right + padding), + min(height, bottom + padding), + ) + + +def _ensure_output_outside_job_dir(output_path: str | Path, job_dir: str | Path) -> None: + output = Path(output_path) + if not output.is_absolute(): + output = output.resolve() + job = Path(job_dir).resolve() + try: + is_inside = output.resolve().is_relative_to(job) + except FileNotFoundError: + is_inside = output.parent.resolve().is_relative_to(job) + if is_inside: + raise ValueError("output_path must not be inside cleanup artifact job directory") + + +def _ocr_result_payload(ocr_result) -> dict[str, Any]: + return { + "source_image_path": ocr_result.source_image_path, + "image_size": ocr_result.image_size, + "provider_role": ocr_result.provider_role, + "provider_name": ocr_result.provider_name, + "model": ocr_result.model, + "items": [ + { + "text": item.text, + "bbox": item.bbox, + "polygon": item.polygon, + "confidence": item.confidence, + "font_family_hint": item.font_family_hint, + "font_size_hint": item.font_size_hint, + "style_hints": item.style_hints, + "color_hex": item.color_hex, + "alignment": item.alignment, + "provenance": item.provenance, + } + for item in ocr_result.items + ], + } + + +def _metadata_only_ocr_result( + *, + source_image_path: str, + source_image_size: tuple[int, int], + text_metadata: list[dict[str, Any]], +) -> OCRResult: + width, height = source_image_size + ordered = sorted(text_metadata, key=lambda item: int(item.get("order", 0))) + items: list[OCRTextItem] = [] + default_font_size = max(18.0, round(height * 0.07)) + box_height = max(24, round(default_font_size * 1.6)) + y_step = max(box_height + 8, round(height * 0.14)) + for index, metadata in enumerate(ordered): + text = str(metadata.get("text", "")) + font_size = _metadata_font_size(metadata) or default_font_size + top = min(max(0, round(height * 0.12) + index * y_step), max(0, height - box_height)) + left = max(0, round(width * 0.10)) + estimated_width = max(round(font_size * 0.68 * max(len(text), 1)), round(width * 0.12)) + right = min(width, left + estimated_width + round(font_size * 1.5)) + bottom = min(height, top + box_height) + bbox = (left, top, right, bottom) + items.append( + OCRTextItem( + text=text, + bbox=bbox, + polygon=((left, top), (right, top), (right, bottom), (left, bottom)), + confidence=0.75, + font_size_hint=font_size, + style_hints={"source": "aippt_metadata_layout_fallback"}, + color_hex="#000000", + alignment="left", + provenance={ + "provider_role": "metadata", + "provider": "aippt_metadata", + "model": "metadata_only_layout_fallback", + "fallback": "ocr_provider_failed", + "metadata_order": metadata.get("order"), + }, + ) + ) + return OCRResult( + source_image_path=source_image_path, + image_size=source_image_size, + provider_role="metadata", + provider_name="aippt_metadata", + model="metadata_only_layout_fallback", + items=items, + ) + + +def _metadata_font_size(metadata: dict[str, Any]) -> float | None: + style_hint = metadata.get("style_hint") + if not isinstance(style_hint, dict): + return None + value = style_hint.get("font_size") + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _job_relative(path: str | Path, job_dir: str | Path) -> str: + return str(Path(path).resolve().relative_to(Path(job_dir).resolve())) + + +def _slide_size_inches(aspect_ratio: str) -> tuple[float, float]: + if aspect_ratio == "4:3": + return (10.0, 7.5) + return (10.0, 5.625) + + +def _safe_name(value: str) -> str: + safe = "".join(char if char.isalnum() or char in "._-" else "-" for char in str(value)) + return safe.strip("-") or "item" + + +@contextmanager +def _record_stage( + artifacts: GenerativeEditableJobArtifacts, + *, + stage: str, + page_index: int | None = None, + slide_id: str | None = None, + provider_role: str | None = None, + provider: str | None = None, + timeout_seconds: int | None = None, +): + started = time.perf_counter() + event: dict[str, Any] = { + "stage": stage, + "status": "started", + } + if page_index is not None: + event["page_index"] = page_index + if slide_id is not None: + event["slide_id"] = slide_id + if provider_role: + event["provider_role"] = provider_role + if provider: + event["provider"] = provider + if timeout_seconds: + event["timeout_seconds"] = timeout_seconds + artifacts.append_stage_event(event) + try: + yield + except Exception as exc: + artifacts.append_stage_event( + { + **event, + "status": "failed", + "elapsed_ms": round((time.perf_counter() - started) * 1000), + "error_type": exc.__class__.__name__, + "error": str(exc) or exc.__class__.__name__, + } + ) + raise + artifacts.append_stage_event( + { + **event, + "status": "finished", + "elapsed_ms": round((time.perf_counter() - started) * 1000), + } + ) + + +def _as_dict(value: object) -> dict: + return dict(value) if isinstance(value, dict) else {} + + +def _normalize_fallback_policy(value: FallbackPolicy | str | None) -> FallbackPolicy: + if value is None: + return "fail" + if value not in FALLBACK_POLICIES: + raise ValueError(f"unsupported fallback policy: {value}") + return value # type: ignore[return-value] + + +def _summarize_validation_issues(validation_report: ValidationReport) -> str: + summaries = [ + f"{issue.code}: {issue.message}" if issue.message else issue.code + for issue in validation_report.issues + ] + return "; ".join(summaries) or "unknown_validation_failure" diff --git a/src/generative_editable_preview_validator.py b/src/generative_editable_preview_validator.py new file mode 100644 index 0000000..ea55982 --- /dev/null +++ b/src/generative_editable_preview_validator.py @@ -0,0 +1,1281 @@ +"""Preview rendering and validation helpers for generative editable PPTX.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import math +import posixpath +from pathlib import Path +import shutil +import subprocess +import tempfile +from typing import NamedTuple +import zipfile +from xml.etree import ElementTree as ET + +from PIL import Image, ImageChops, ImageDraw, ImageStat +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.shapes import MSO_SHAPE_TYPE + +from .generative_editable_composer import slide_dimensions +from .generative_editable_manifest import PageManifest, read_deck_manifest, read_page_manifest + + +VALIDATION_PASSED = "passed" +VALIDATION_FAILED = "failed" +SLIDE_POSITION_TOLERANCE_EMU = 24_000 +SOURCE_BACKGROUND_MEAN_DELTA_THRESHOLD = 3.0 +SOURCE_BACKGROUND_CHANGED_RATIO_THRESHOLD = 0.02 +MAX_PREVIEW_PIXELS = 8_000_000 +POWERPOINT_RENDER_TIMEOUT_SECONDS = 30 +FORBIDDEN_BITMAP_ASSET_PROVENANCE_TERMS = { + "source_crop", + "source-crop", + "source crop", + "source crops", + "source_crops", + "direct_source", + "direct-source", + "direct source", + "direct crop", + "source snippet", + "cropped source", + "source-cropped", +} + + +class PreviewGateThresholds(NamedTuple): + max_mean_abs_delta: float + max_changed_pixel_ratio: float + + +@dataclass(frozen=True) +class PreviewRenderResult: + image: Image.Image + metadata: dict[str, object] + + +@dataclass(frozen=True) +class ValidationIssue: + code: str + message: str + slide_id: str = "" + severity: str = "error" + details: dict[str, object] | None = None + + def to_dict(self) -> dict[str, object]: + payload = _json_safe(asdict(self)) + if self.details is None: + payload.pop("details") + return payload + + +@dataclass(frozen=True) +class ValidationReport: + status: str + checked_pages: int + issues: list[ValidationIssue] + + def to_dict(self) -> dict[str, object]: + return { + "status": self.status, + "checked_pages": self.checked_pages, + "issues": [issue.to_dict() for issue in self.issues], + } + + +def validate_composed_deck_structure( + *, + deck_manifest_path: str | Path, + artifact_root: str | Path, + pptx_path: str | Path, +) -> ValidationReport: + root = Path(artifact_root).resolve() + issues: list[ValidationIssue] = [] + try: + deck = read_deck_manifest(deck_manifest_path) + except Exception as exc: + return ValidationReport( + status=VALIDATION_FAILED, + checked_pages=0, + issues=[ + ValidationIssue( + code="invalid_deck_manifest", + message="deck manifest cannot be read for validation", + details={"error": str(exc)}, + ) + ], + ) + + try: + presentation = Presentation(str(pptx_path)) + except Exception as exc: + presentation = None + issues.append( + ValidationIssue( + code="invalid_pptx", + message="composed PPTX cannot be opened for structural validation", + details={"pptx_path": str(pptx_path), "error": str(exc)}, + ) + ) + + if presentation is not None and len(presentation.slides) != len(deck.page_manifest_paths): + issues.append( + ValidationIssue( + code="page_count_mismatch", + message="composed PPTX page count does not match deck manifest", + details={ + "expected": len(deck.page_manifest_paths), + "actual": len(presentation.slides), + }, + ) + ) + + expected_width, expected_height = slide_dimensions(deck.aspect_ratio) + if ( + presentation is not None + and (presentation.slide_width != expected_width or presentation.slide_height != expected_height) + ): + issues.append( + ValidationIssue( + code="slide_dimensions_mismatch", + message="composed PPTX slide dimensions do not match requested aspect ratio", + details={ + "expected": [expected_width, expected_height], + "actual": [presentation.slide_width, presentation.slide_height], + }, + ) + ) + + for page_index, page_ref in enumerate(deck.page_manifest_paths): + try: + page_path = _artifact_path(root, page_ref) + except ValueError as exc: + issues.append( + ValidationIssue( + code="invalid_page_manifest_path", + message="page manifest path is outside artifact root", + details={"page_index": page_index, "page_ref": page_ref, "error": str(exc)}, + ) + ) + continue + if not page_path.exists(): + issues.append( + ValidationIssue( + code="missing_page_manifest", + message="page manifest referenced by deck is missing", + details={"page_index": page_index, "page_ref": page_ref}, + ) + ) + continue + try: + page = read_page_manifest(page_path) + except Exception as exc: + issues.append( + ValidationIssue( + code="invalid_page_manifest", + message="page manifest cannot be read for validation", + details={"page_index": page_index, "page_ref": page_ref, "error": str(exc)}, + ) + ) + continue + _validate_page_assets(page, root, issues) + _validate_slide_background_identity(page, root, pptx_path, page_index, issues) + source_background_used = _uses_full_slide_source_background(page, root) + editable_object_count = len(page.text_boxes) + len(page.native_shapes) + len(page.bitmap_assets) + if source_background_used and editable_object_count == 0: + issues.append( + ValidationIssue( + code="full_slide_source_background_only", + message="slide background is the full source image and no editable or decomposed objects were produced", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "target_kind": "background", + "background_ref": page.chosen_background, + "editable_object_count": editable_object_count, + }, + ) + ) + if ( + _has_non_empty_text(page) + and ( + source_background_used + or ( + presentation is not None + and page_index < len(presentation.slides) + and _slide_contains_full_slide_source_picture( + page, + presentation.slides[page_index], + root, + presentation.slide_width, + presentation.slide_height, + ) + ) + ) + ): + issues.append( + ValidationIssue( + code="unsafe_full_slide_source_with_text", + message="full-slide source image would leave baked text underneath editable text", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "target_kind": "background", + "background_ref": page.chosen_background, + }, + ) + ) + if presentation is None or page_index >= len(presentation.slides): + continue + slide = presentation.slides[page_index] + _validate_required_text(page, slide, issues, deck.aspect_ratio) + _validate_object_order( + page, + slide, + root, + issues, + deck.aspect_ratio, + presentation.slide_width, + presentation.slide_height, + ) + + return ValidationReport( + status=VALIDATION_FAILED if issues else VALIDATION_PASSED, + checked_pages=len(deck.page_manifest_paths), + issues=issues, + ) + + +def validate_preview_similarity( + *, + source_image_path: str | Path, + preview: PreviewRenderResult, + slide_id: str, + page_index: int, + max_mean_abs_delta: float, + max_changed_pixel_ratio: float, + require_powerpoint_render: bool = True, + changed_pixel_delta_threshold: int = 16, +) -> ValidationReport: + renderer = str(preview.metadata.get("renderer", "")) + if require_powerpoint_render and ( + preview.metadata.get("is_powerpoint_render") is not True or renderer == "manifest_stub" + ): + return ValidationReport( + status=VALIDATION_FAILED, + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_renderer_not_powerpoint", + message="preview similarity gate requires a real PowerPoint render, not a manifest stub", + slide_id=slide_id, + details={ + "page_index": page_index, + "target_kind": "preview", + "renderer": renderer, + "is_powerpoint_render": bool(preview.metadata.get("is_powerpoint_render")), + "repairable": False, + }, + ) + ], + ) + try: + with Image.open(source_image_path) as source: + source_rgb = source.convert("RGB") + except Exception as exc: + return ValidationReport( + status=VALIDATION_FAILED, + checked_pages=1, + issues=[ + ValidationIssue( + code="missing_source_preview_reference", + message="source slide image cannot be opened for preview comparison", + slide_id=slide_id, + details={ + "page_index": page_index, + "target_kind": "source", + "source_image_path": str(source_image_path), + "error": str(exc), + "repairable": False, + }, + ) + ], + ) + if source_rgb.width * source_rgb.height > MAX_PREVIEW_PIXELS: + return _preview_too_large_report(slide_id, page_index) + try: + preview_rgb = preview.image.convert("RGB") + except Exception as exc: + return ValidationReport( + status=VALIDATION_FAILED, + checked_pages=1, + issues=[ + ValidationIssue( + code="invalid_preview_image", + message="preview image cannot be opened for similarity comparison", + slide_id=slide_id, + details={ + "page_index": page_index, + "target_kind": "preview", + "renderer": renderer, + "error": str(exc), + "repairable": True, + }, + ) + ], + ) + if preview_rgb.width * preview_rgb.height > MAX_PREVIEW_PIXELS: + return _preview_too_large_report(slide_id, page_index) + if preview_rgb.size != source_rgb.size: + if not _same_aspect_ratio(preview_rgb.size, source_rgb.size): + return ValidationReport( + status=VALIDATION_FAILED, + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_dimensions_mismatch", + message="preview aspect ratio does not match source slide", + slide_id=slide_id, + details={ + "page_index": page_index, + "target_kind": "preview", + "source_size": list(source_rgb.size), + "preview_size": list(preview_rgb.size), + "repairable": True, + }, + ) + ], + ) + preview_rgb = preview_rgb.resize(source_rgb.size) + mean_abs_delta, changed_pixel_ratio = _image_delta_metrics( + source_rgb, + preview_rgb, + changed_pixel_delta_threshold=changed_pixel_delta_threshold, + ) + details = { + "page_index": page_index, + "target_kind": "preview", + "mean_abs_delta": mean_abs_delta, + "max_mean_abs_delta": max_mean_abs_delta, + "changed_pixel_ratio": changed_pixel_ratio, + "max_changed_pixel_ratio": max_changed_pixel_ratio, + "changed_pixel_delta_threshold": changed_pixel_delta_threshold, + "renderer": renderer, + "repairable": True, + "fallback_candidate": "text_editable_background", + } + if mean_abs_delta > max_mean_abs_delta or changed_pixel_ratio > max_changed_pixel_ratio: + return ValidationReport( + status=VALIDATION_FAILED, + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_similarity_failed", + message="rendered preview differs from source slide beyond configured thresholds", + slide_id=slide_id, + details=details, + ) + ], + ) + return ValidationReport(status=VALIDATION_PASSED, checked_pages=1, issues=[]) + + +def quality_threshold_to_preview_gates(similarity_threshold: float) -> PreviewGateThresholds: + similarity = min(1.0, max(0.0, float(similarity_threshold))) + allowed_difference = 1.0 - similarity + return PreviewGateThresholds( + max_mean_abs_delta=max(1.0, round(255.0 * allowed_difference, 3)), + max_changed_pixel_ratio=max(0.005, round(allowed_difference * 1.65, 4)), + ) + + +def render_manifest_preview_with_metadata( + page_manifest: PageManifest, + artifact_root: str | Path, + *, + pptx_path: str | Path | None = None, + output_size: tuple[int, int] | None = None, + **_: object, +) -> PreviewRenderResult: + return render_powerpoint_preview_with_metadata( + page_manifest, + artifact_root, + pptx_path=pptx_path, + output_size=output_size, + ) + + +def render_powerpoint_preview_with_metadata( + page_manifest: PageManifest, + artifact_root: str | Path, + *, + pptx_path: str | Path | None = None, + output_size: tuple[int, int] | None = None, +) -> PreviewRenderResult: + if pptx_path is None: + return _manifest_stub_preview(page_manifest, artifact_root, output_size=output_size) + soffice = shutil.which("soffice") + pdftoppm = shutil.which("pdftoppm") + if not soffice or not pdftoppm: + return _manifest_stub_preview(page_manifest, artifact_root, output_size=output_size) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + user_profile = tmp_path / "lo-profile" + pptx = Path(pptx_path).resolve() + try: + subprocess.run( + [ + soffice, + "--headless", + f"-env:UserInstallation=file://{user_profile}", + "--convert-to", + "pdf", + "--outdir", + str(tmp_path), + str(pptx), + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=POWERPOINT_RENDER_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + return _manifest_stub_preview(page_manifest, artifact_root, output_size=output_size) + pdf_path = tmp_path / f"{pptx.stem}.pdf" + if not pdf_path.exists(): + return _manifest_stub_preview(page_manifest, artifact_root, output_size=output_size) + output_prefix = tmp_path / "preview" + try: + subprocess.run( + [ + pdftoppm, + "-f", + str(page_manifest.page_index + 1), + "-l", + str(page_manifest.page_index + 1), + "-singlefile", + "-png", + str(pdf_path), + str(output_prefix), + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=POWERPOINT_RENDER_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + return _manifest_stub_preview(page_manifest, artifact_root, output_size=output_size) + preview_path = output_prefix.with_suffix(".png") + if not preview_path.exists(): + return _manifest_stub_preview(page_manifest, artifact_root, output_size=output_size) + with Image.open(preview_path) as image: + preview = image.convert("RGB") + if output_size is not None: + preview = preview.resize(output_size) + else: + preview = preview.copy() + return PreviewRenderResult( + image=preview, + metadata={ + "renderer": "soffice_pdf_pdftoppm", + "is_powerpoint_render": True, + }, + ) + + +def _manifest_stub_preview( + page_manifest: PageManifest, + artifact_root: str | Path, + *, + output_size: tuple[int, int] | None = None, +) -> PreviewRenderResult: + return PreviewRenderResult( + image=render_manifest_preview(page_manifest, artifact_root, output_size=output_size), + metadata={ + "renderer": "manifest_stub", + "is_powerpoint_render": False, + }, + ) + + +def render_manifest_preview( + page_manifest: PageManifest, + artifact_root: str | Path, + *, + output_size: tuple[int, int] | None = None, +) -> Image.Image: + root = Path(artifact_root) + source_size = page_manifest.source_image_size + size = output_size or source_size + if page_manifest.chosen_background: + with Image.open(_artifact_path(root, page_manifest.chosen_background)) as background: + preview = background.convert("RGB").resize(size) + else: + preview = Image.new("RGB", size, "white") + draw = ImageDraw.Draw(preview) + for shape in page_manifest.native_shapes: + bbox = _scale_bbox(shape.source_pixel_bbox, source_size, size) + fill = shape.fill_color or shape.line_color or "#000000" + if shape.shape_type == "ellipse": + draw.ellipse(bbox, fill=fill) + elif shape.shape_type == "line": + start = shape.line_start or (shape.source_pixel_bbox[0], shape.source_pixel_bbox[1]) + end = shape.line_end or (shape.source_pixel_bbox[2], shape.source_pixel_bbox[3]) + draw.line( + [_scale_point(start, source_size, size), _scale_point(end, source_size, size)], + fill=shape.line_color or fill, + width=max(1, round(shape.stroke_width or 1)), + ) + else: + draw.rectangle(bbox, fill=fill) + for asset in sorted(page_manifest.bitmap_assets, key=lambda item: item.z_order): + with Image.open(_artifact_path(root, asset.asset_path)) as image: + rect = _scale_bbox(asset.source_pixel_bbox, source_size, size) + resized = image.convert("RGBA").resize((rect[2] - rect[0], rect[3] - rect[1])) + preview.paste(resized, rect[:2], resized) + for text_box in page_manifest.text_boxes: + bbox = _scale_bbox(text_box.source_pixel_bbox, source_size, size) + draw.text((bbox[0], bbox[1]), text_box.text, fill=text_box.color_hex or "#000000") + return preview + + +def _artifact_path(root: Path, ref: str) -> Path: + if not ref: + raise ValueError("artifact path must be non-empty") + path = (root / ref).resolve() + if not path.is_relative_to(root.resolve()): + raise ValueError("artifact path must be inside artifact_root") + return path + + +def _preview_too_large_report(slide_id: str, page_index: int) -> ValidationReport: + return ValidationReport( + status=VALIDATION_FAILED, + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_too_large", + message="preview comparison image exceeds validation pixel limit", + slide_id=slide_id, + details={"page_index": page_index, "target_kind": "preview", "repairable": False}, + ) + ], + ) + + +def _validate_page_assets(page: PageManifest, root: Path, issues: list[ValidationIssue]) -> None: + if page.chosen_background: + _validate_image_asset( + root=root, + ref=page.chosen_background, + slide_id=page.slide_id, + issues=issues, + details={ + "page_index": page.page_index, + "target_kind": "background", + "asset_ref": page.chosen_background, + "repairable": True, + "fallback_candidate": "text_editable_background", + }, + ) + _validate_image_asset( + root=root, + ref=page.source_image_path, + slide_id=page.slide_id, + issues=issues, + details={ + "page_index": page.page_index, + "target_kind": "source", + "asset_ref": page.source_image_path, + "repairable": False, + }, + ) + for asset in page.bitmap_assets: + _validate_image_asset( + root=root, + ref=asset.asset_path, + slide_id=page.slide_id, + issues=issues, + details={ + "page_index": page.page_index, + "target_kind": "bitmap_asset", + "target_id": asset.asset_id, + "asset_ref": asset.asset_path, + "repairable": True, + }, + ) + _validate_bitmap_asset_provenance(page, asset, issues) + + +def _validate_bitmap_asset_provenance( + page: PageManifest, + asset, + issues: list[ValidationIssue], +) -> None: + provenance_text = _compact_text(asset.provenance) + asset_path_text = _compact_text(asset.asset_path) + if not ( + any(term in provenance_text for term in FORBIDDEN_BITMAP_ASSET_PROVENANCE_TERMS) + or ".source-crop." in asset_path_text + or asset_path_text.endswith(".source-crop.png") + ): + return + issues.append( + ValidationIssue( + code="forbidden_source_crop_bitmap_asset", + message="foreground bitmap asset uses source-crop or direct-source fallback instead of asset-sheet separation", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "target_kind": "bitmap_asset", + "target_id": asset.asset_id, + "asset_ref": asset.asset_path, + "provenance": asset.provenance, + "repairable": True, + }, + ) + ) + + +def _compact_text(value: object) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.lower() + if isinstance(value, (int, float, bool)): + return str(value).lower() + if isinstance(value, dict): + return " ".join(_compact_text(item) for item in value.values()) + if isinstance(value, (list, tuple, set)): + return " ".join(_compact_text(item) for item in value) + return str(value).lower() + + +def _validate_image_asset( + *, + root: Path, + ref: str, + slide_id: str, + issues: list[ValidationIssue], + details: dict[str, object], + required: bool = True, +) -> None: + try: + path = _artifact_path(root, ref) + except ValueError as exc: + issues.append( + ValidationIssue( + code="invalid_asset_path", + message="asset path is outside artifact root", + slide_id=slide_id, + details={**details, "error": str(exc)}, + ) + ) + return + if not path.is_file(): + if required: + issues.append( + ValidationIssue( + code="missing_asset", + message="required image asset is missing", + slide_id=slide_id, + details=details, + ) + ) + return + try: + with Image.open(path) as image: + image.verify() + except Exception as exc: + issues.append( + ValidationIssue( + code="invalid_asset", + message="image asset cannot be opened", + slide_id=slide_id, + details={**details, "error": str(exc)}, + ) + ) + + +def _uses_full_slide_source_background(page: PageManifest, root: Path) -> bool: + value = str(page.provenance.get("chosen_background_kind", "")).lower() + if value in {"source_full_slide", "full_slide_source", "uncleaned_source"}: + return True + if not page.chosen_background or not page.source_image_path: + return False + try: + background_path = _artifact_path(root, page.chosen_background) + source_path = _artifact_path(root, page.source_image_path) + except ValueError: + return False + if not background_path.is_file() or not source_path.is_file(): + return False + try: + with Image.open(background_path) as background, Image.open(source_path) as source: + bg = background.convert("RGB") + src = source.convert("RGB") + if src.size != page.source_image_size: + return False + if bg.size != src.size: + bg = bg.resize(src.size) + if _chosen_background_is_text_clean(page) and _text_mask_region_changed(page, root, src, bg): + return False + mean_delta, changed_ratio = _image_delta_metrics( + src, + bg, + changed_pixel_delta_threshold=16, + ) + return ( + mean_delta <= SOURCE_BACKGROUND_MEAN_DELTA_THRESHOLD + and changed_ratio <= SOURCE_BACKGROUND_CHANGED_RATIO_THRESHOLD + ) + except Exception: + return False + + +def _chosen_background_is_text_clean(page: PageManifest) -> bool: + if str(page.provenance.get("chosen_background_kind", "")).lower() == "source_preserving_text_clean": + return True + if page.chosen_background and page.chosen_background == page.text_clean_background: + return True + backgrounds = page.provenance.get("backgrounds") + if not isinstance(backgrounds, dict): + return False + for key in ("base_clean", "text_clean", "source_preserving"): + record = backgrounds.get(key) + if not isinstance(record, dict): + continue + if record.get("output_asset_ref") and record.get("output_asset_ref") != page.chosen_background: + continue + if record.get("provider_role") == "local" and record.get("prompt_id") in { + "local_text_cleanup", + "source_preserving_text_background", + }: + return True + return False + + +def _text_mask_region_changed( + page: PageManifest, + root: Path, + source: Image.Image, + background: Image.Image, + *, + mean_delta_threshold: float = 3.0, + changed_ratio_threshold: float = 0.08, + min_changed_pixels: int = 20, +) -> bool: + text_mask_ref = page.provenance.get("text_mask_path") + if not isinstance(text_mask_ref, str) or not text_mask_ref: + return False + try: + mask_path = _artifact_path(root, text_mask_ref) + except ValueError: + return False + if not mask_path.is_file(): + return False + with Image.open(mask_path) as mask_image: + mask = mask_image.convert("L").resize(source.size) + diff = ImageChops.difference(source, background).convert("L") + mask_pixels = changed_pixels = total_delta = 0 + for y in range(diff.height): + for x in range(diff.width): + if mask.getpixel((x, y)) <= 0: + continue + mask_pixels += 1 + delta = diff.getpixel((x, y)) + total_delta += delta + if delta > 16: + changed_pixels += 1 + if mask_pixels <= 0: + return False + return ( + changed_pixels >= min_changed_pixels + or total_delta / float(mask_pixels) > mean_delta_threshold + or changed_pixels / float(mask_pixels) > changed_ratio_threshold + ) + + +def _has_non_empty_text(page: PageManifest) -> bool: + return any(text_box.text.strip() for text_box in page.text_boxes) + + +def _allows_low_opacity_source_text_overlay(page: PageManifest) -> bool: + # Kept for reading legacy manifests only. A full-slide source image plus + # editable OCR text is not an editable reconstruction, even at low opacity. + return False + + +def _slide_contains_full_slide_source_picture( + page: PageManifest, + slide, + root: Path, + slide_width: int, + slide_height: int, +) -> bool: + try: + source_path = _artifact_path(root, page.source_image_path) + except ValueError: + return False + source_sha = _image_sha1(source_path) + if not source_sha: + return False + for shape in slide.shapes: + if shape.shape_type != MSO_SHAPE_TYPE.PICTURE: + continue + if getattr(shape.image, "sha1", "") != source_sha: + continue + if ( + abs(shape.left) <= SLIDE_POSITION_TOLERANCE_EMU + and abs(shape.top) <= SLIDE_POSITION_TOLERANCE_EMU + and abs(shape.width - slide_width) <= SLIDE_POSITION_TOLERANCE_EMU + and abs(shape.height - slide_height) <= SLIDE_POSITION_TOLERANCE_EMU + ): + return True + return False + + +def _validate_required_text(page: PageManifest, slide, issues: list[ValidationIssue], aspect_ratio: str) -> None: + actual_counts: dict[str, int] = {} + for shape in slide.shapes: + if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX: + normalized = _normalize_text(shape.text) + if normalized: + actual_counts[normalized] = actual_counts.get(normalized, 0) + 1 + seen_expected: dict[str, int] = {} + for index, text_box in enumerate(page.text_boxes): + normalized = _normalize_text(text_box.text) + if not normalized: + continue + seen_expected[normalized] = seen_expected.get(normalized, 0) + 1 + matching_shapes = [ + shape + for shape in slide.shapes + if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX and _normalize_text(shape.text) == normalized + ] + if len(matching_shapes) < seen_expected[normalized]: + issues.append( + ValidationIssue( + code="missing_required_text", + message="required text from manifest is absent from composed PPTX", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "target_kind": "text", + "target_index": index, + "text": text_box.text, + "source_pixel_bbox": list(text_box.source_pixel_bbox), + }, + ) + ) + continue + matched_shape = matching_shapes[seen_expected[normalized] - 1] + expected_rect = _slide_rect_for_bbox(page, text_box.source_pixel_bbox, aspect_ratio) + actual_rect = (matched_shape.left, matched_shape.top, matched_shape.width, matched_shape.height) + if not _text_rect_close(actual_rect, expected_rect, text_box): + issues.append( + ValidationIssue( + code="text_position_mismatch", + message="required text is present but not positioned at the manifest location", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "target_kind": "text", + "target_index": index, + "text": text_box.text, + "expected_rect": list(expected_rect), + "actual_rect": list(actual_rect), + }, + ) + ) + + +def _validate_object_order( + page: PageManifest, + slide, + root: Path, + issues: list[ValidationIssue], + aspect_ratio: str, + slide_width: int, + slide_height: int, +) -> None: + expected: list[str] = [] + expected.extend(["native_shape"] * len(page.native_shapes)) + expected.extend(["picture"] * len(page.bitmap_assets)) + expected.extend(["text"] * len(page.text_boxes)) + actual = [_shape_role(shape) for shape in slide.shapes] + actual_relevant = [role for role in actual if role in {"picture", "native_shape", "text"}] + if actual_relevant != expected: + issues.append( + ValidationIssue( + code="object_order_mismatch", + message="composed PPTX objects are not ordered as background, native shapes, bitmap assets, text", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "target_kind": "slide_object_order", + "expected": expected, + "actual": actual_relevant, + "repairable": False, + }, + ) + ) + return + _validate_object_identity(page, slide, root, issues, aspect_ratio, slide_width, slide_height) + + +def _validate_object_identity( + page: PageManifest, + slide, + root: Path, + issues: list[ValidationIssue], + aspect_ratio: str, + slide_width: int, + slide_height: int, +) -> None: + relevant_shapes = [shape for shape in slide.shapes if _shape_role(shape) in {"picture", "native_shape", "text"}] + expected_index = 0 + for shape_index, shape_spec in enumerate(page.native_shapes): + actual_shape = relevant_shapes[expected_index] + if shape_spec.shape_type == "line": + if actual_shape.shape_type != MSO_SHAPE_TYPE.LINE or not _line_close(page, shape_spec, actual_shape, aspect_ratio): + _append_identity_issue(page, issues, "native_shape", str(shape_index), expected_index) + elif actual_shape.shape_type != MSO_SHAPE_TYPE.AUTO_SHAPE: + _append_identity_issue(page, issues, "native_shape", str(shape_index), expected_index) + else: + expected_rect = _slide_rect_for_bbox(page, shape_spec.source_pixel_bbox, aspect_ratio) + actual_rect = (actual_shape.left, actual_shape.top, actual_shape.width, actual_shape.height) + if not _native_auto_shape_type_matches(shape_spec, actual_shape) or not _rect_close(actual_rect, expected_rect): + _append_identity_issue(page, issues, "native_shape", str(shape_index), expected_index) + expected_index += 1 + for asset in sorted(page.bitmap_assets, key=lambda item: item.z_order): + _validate_picture_identity( + page=page, + root=root, + root_shape=relevant_shapes[expected_index], + expected_ref=asset.asset_path, + target_kind="bitmap_asset", + target_id=asset.asset_id, + issues=issues, + index=expected_index, + expected_bbox=asset.source_pixel_bbox, + aspect_ratio=aspect_ratio, + ) + expected_index += 1 + + +def _validate_slide_background_identity( + page: PageManifest, + root: Path, + pptx_path: str | Path, + page_index: int, + issues: list[ValidationIssue], +) -> None: + if not page.chosen_background: + return + try: + expected_path = _artifact_path(root, page.chosen_background) + except Exception: + expected_path = None + expected_sha = _image_sha1(expected_path) + if not expected_sha: + return + + slide_part = f"ppt/slides/slide{page_index + 1}.xml" + rels_part = f"ppt/slides/_rels/slide{page_index + 1}.xml.rels" + try: + with zipfile.ZipFile(pptx_path) as pptx: + slide_xml = pptx.read(slide_part) + rels_xml = pptx.read(rels_part) + embed_id = _slide_background_embed_id(slide_xml) + if not embed_id: + _append_identity_issue( + page, + issues, + "background", + "background", + -1, + expected_sha=expected_sha, + actual_sha="", + ) + return + media_part = _relationship_target_part(rels_xml, embed_id, slide_part) + actual_sha = hashlib.sha1(pptx.read(media_part)).hexdigest() if media_part else "" + except Exception: + actual_sha = "" + if actual_sha != expected_sha: + _append_identity_issue( + page, + issues, + "background", + "background", + -1, + expected_sha=expected_sha, + actual_sha=actual_sha, + ) + + +def _slide_background_embed_id(slide_xml: bytes) -> str: + namespaces = { + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + } + root = ET.fromstring(slide_xml) + blip = root.find(".//p:bg//a:blip", namespaces) + if blip is None: + return "" + return blip.attrib.get(f"{{{namespaces['r']}}}embed", "") + + +def _relationship_target_part(rels_xml: bytes, relationship_id: str, source_part: str) -> str: + namespace = {"rel": "http://schemas.openxmlformats.org/package/2006/relationships"} + root = ET.fromstring(rels_xml) + for relationship in root.findall("rel:Relationship", namespace): + if relationship.attrib.get("Id") != relationship_id: + continue + target = relationship.attrib.get("Target", "") + if not target: + return "" + if target.startswith("/"): + return target.lstrip("/") + return posixpath.normpath(posixpath.join(posixpath.dirname(source_part), target)) + return "" + + +def _validate_picture_identity( + *, + page: PageManifest, + root: Path, + root_shape, + expected_ref: str, + target_kind: str, + target_id: str, + issues: list[ValidationIssue], + index: int, + expected_bbox: tuple[int, int, int, int] | None = None, + expected_rect: tuple[int, int, int, int] | None = None, + aspect_ratio: str | None = None, +) -> None: + if root_shape.shape_type != MSO_SHAPE_TYPE.PICTURE: + _append_identity_issue(page, issues, target_kind, target_id, index) + return + try: + expected_path = _artifact_path(root, expected_ref) + except Exception: + expected_path = None + expected_sha = _image_sha1(expected_path) if expected_path is not None else "" + actual_sha = getattr(root_shape.image, "sha1", "") + if expected_sha and actual_sha != expected_sha: + _append_identity_issue(page, issues, target_kind, target_id, index, expected_sha=expected_sha, actual_sha=actual_sha) + return + if expected_rect is None and expected_bbox is not None: + expected_rect = _slide_rect_for_bbox(page, expected_bbox, aspect_ratio) + if expected_rect is not None: + actual_rect = (root_shape.left, root_shape.top, root_shape.width, root_shape.height) + if not _rect_close(actual_rect, expected_rect): + _append_identity_issue(page, issues, target_kind, target_id, index) + + +def _append_identity_issue( + page: PageManifest, + issues: list[ValidationIssue], + target_kind: str, + target_id: str, + index: int, + *, + expected_sha: str = "", + actual_sha: str = "", +) -> None: + issues.append( + ValidationIssue( + code="object_identity_mismatch", + message="composed PPTX object identity does not match the manifest object at this position", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "target_kind": target_kind, + "target_id": target_id, + "object_index": index, + "expected_sha1": expected_sha, + "actual_sha1": actual_sha, + "repairable": False, + }, + ) + ) + + +def _shape_role(shape) -> str: + if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: + return "picture" + if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX: + return "text" + if shape.shape_type in {MSO_SHAPE_TYPE.AUTO_SHAPE, MSO_SHAPE_TYPE.LINE}: + return "native_shape" + return "other" + + +def _normalize_text(value: str) -> str: + return " ".join(value.split()).strip() + + +def _slide_rect_for_bbox( + page: PageManifest, + bbox: tuple[int, int, int, int], + aspect_ratio: str | None = None, +) -> tuple[int, int, int, int]: + from .generative_editable_composer import slide_rect_from_source_pixels + + slide_ratio = aspect_ratio or _aspect_ratio_from_page(page) + rect = slide_rect_from_source_pixels( + bbox, + source_image_size=page.source_image_size, + aspect_ratio=slide_ratio, + ) + return rect.left, rect.top, rect.width, rect.height + + +def _aspect_ratio_from_page(page: PageManifest) -> str: + width, height = page.slide_size + return "4:3" if abs((width / height) - (4 / 3)) < abs((width / height) - (16 / 9)) else "16:9" + + +def _rect_close(actual: tuple[int, int, int, int], expected: tuple[int, int, int, int]) -> bool: + return all(abs(actual[index] - expected[index]) <= SLIDE_POSITION_TOLERANCE_EMU for index in range(4)) + + +def _text_rect_close(actual: tuple[int, int, int, int], expected: tuple[int, int, int, int], text_box) -> bool: + if not getattr(text_box, "style_hints", {}).get("approximate_layout"): + return _rect_close(actual, expected) + left_close = abs(actual[0] - expected[0]) <= SLIDE_POSITION_TOLERANCE_EMU + top_close = abs(actual[1] - expected[1]) <= SLIDE_POSITION_TOLERANCE_EMU + height_close = abs(actual[3] - expected[3]) <= SLIDE_POSITION_TOLERANCE_EMU + width_not_smaller = actual[2] + SLIDE_POSITION_TOLERANCE_EMU >= expected[2] + return left_close and top_close and height_close and width_not_smaller + + +def _native_auto_shape_type_matches(shape_spec, actual_shape) -> bool: + expected = { + "rectangle": MSO_SHAPE.RECTANGLE, + "rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE, + "ellipse": MSO_SHAPE.OVAL, + }.get(shape_spec.shape_type) + return expected is not None and getattr(actual_shape, "auto_shape_type", None) == expected + + +def _line_close(page: PageManifest, shape_spec, actual_shape, aspect_ratio: str | None = None) -> bool: + start = shape_spec.line_start or (shape_spec.source_pixel_bbox[0], shape_spec.source_pixel_bbox[1]) + end = shape_spec.line_end or (shape_spec.source_pixel_bbox[2], shape_spec.source_pixel_bbox[3]) + expected_start = _slide_point_for_source_pixel(page, start, aspect_ratio) + expected_end = _slide_point_for_source_pixel(page, end, aspect_ratio) + actual_start = (actual_shape.left, actual_shape.top) + actual_end = (actual_shape.left + actual_shape.width, actual_shape.top + actual_shape.height) + return ( + _point_close(actual_start, expected_start) + and _point_close(actual_end, expected_end) + ) or ( + _point_close(actual_start, expected_end) + and _point_close(actual_end, expected_start) + ) or _rect_close( + (actual_shape.left, actual_shape.top, actual_shape.width, actual_shape.height), + _slide_rect_for_bbox( + page, + ( + min(start[0], end[0]), + min(start[1], end[1]), + max(start[0], end[0]), + max(start[1], end[1]), + ), + aspect_ratio, + ), + ) + + +def _slide_point_for_source_pixel( + page: PageManifest, + point: tuple[int, int], + aspect_ratio: str | None = None, +) -> tuple[int, int]: + left, top, _, _ = _slide_rect_for_bbox(page, (point[0], point[1], point[0] + 1, point[1] + 1), aspect_ratio) + return left, top + + +def _point_close(actual: tuple[int, int], expected: tuple[int, int]) -> bool: + return all(abs(actual[index] - expected[index]) <= SLIDE_POSITION_TOLERANCE_EMU for index in range(2)) + + +def _same_aspect_ratio(first: tuple[int, int], second: tuple[int, int], tolerance: float = 0.01) -> bool: + return abs((first[0] / first[1]) - (second[0] / second[1])) <= tolerance + + +def _image_sha1(path: Path | None) -> str: + if path is None or not path.exists(): + return "" + try: + return hashlib.sha1(path.read_bytes()).hexdigest() + except Exception: + return "" + + +def _image_delta_metrics( + source: Image.Image, + preview: Image.Image, + *, + changed_pixel_delta_threshold: int, +) -> tuple[float, float]: + diff = ImageChops.difference(source, preview) + mean_abs_delta = sum(ImageStat.Stat(diff).mean) / 3.0 + grayscale = diff.convert("L").point( + lambda value: 255 if value > changed_pixel_delta_threshold else 0, + mode="L", + ) + changed_pixel_ratio = sum(ImageStat.Stat(grayscale).sum) / (255.0 * source.width * source.height) + return mean_abs_delta, changed_pixel_ratio + + +def _json_safe(value): + if isinstance(value, dict): + return {str(key): _json_safe(nested) for key, nested in value.items()} + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_json_safe(item) for item in value] + if isinstance(value, Path): + return str(value) + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, str | int | float | bool) or value is None: + return value + return str(value) + + +def _scale_bbox( + bbox: tuple[int, int, int, int], + source_size: tuple[int, int], + output_size: tuple[int, int], +) -> tuple[int, int, int, int]: + left, top, right, bottom = bbox + source_width, source_height = source_size + output_width, output_height = output_size + return ( + round(left / source_width * output_width), + round(top / source_height * output_height), + round(right / source_width * output_width), + round(bottom / source_height * output_height), + ) + + +def _scale_point( + point: tuple[int, int], + source_size: tuple[int, int], + output_size: tuple[int, int], +) -> tuple[int, int]: + source_width, source_height = source_size + output_width, output_height = output_size + return ( + round(point[0] / source_width * output_width), + round(point[1] / source_height * output_height), + ) diff --git a/src/generative_editable_providers.py b/src/generative_editable_providers.py new file mode 100644 index 0000000..250c346 --- /dev/null +++ b/src/generative_editable_providers.py @@ -0,0 +1,1984 @@ +"""Provider contracts for generative editable PPTX reconstruction.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import base64 +from contextlib import contextmanager +import http.client +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import threading +import time +from typing import Any, Literal +from urllib.parse import urlparse + +from PIL import Image +import requests + +from .generative_editable_config import ProviderConfig +from .image_result import ImageResultNormalizer + +Point = tuple[int, int] +BBox = tuple[int, int, int, int] +Alignment = Literal["left", "center", "right", "justify"] +FAKE_OCR_PROVIDER = "fake_ocr" +FAKE_OCR_MODEL = "fake-ocr" +LOCAL_TESSERACT_PROVIDER = "local_tesseract" +FAKE_IMAGE_EDIT_PROVIDER = "fake_image_edit" +FAKE_IMAGE_EDIT_MODEL = "fake-image-edit" +FAKE_IMAGE_GENERATION_PROVIDER = "fake_image_generation" +FAKE_IMAGE_GENERATION_MODEL = "fake-image-generation" +TRUSTED_PROVENANCE_KEYS = {"provider_role", "provider", "model", "prompt_id"} +SENSITIVE_METADATA_PARTS = { + "apikey", + "xapikey", + "key", + "token", + "accesstoken", + "secret", + "clientsecret", + "baseurl", + "authorization", +} + + +@dataclass(frozen=True) +class _DetectedTextLine: + bbox: BBox + color_hex: str + font_size: float + component_count: int + + +def safe_provider_error_message(message: str, secret_values: list[str] | None = None) -> str: + safe = str(message) + for secret in secret_values or []: + if secret: + safe = safe.replace(secret, "[REDACTED]") + parsed = urlparse(secret) + if parsed.hostname: + safe = safe.replace(parsed.hostname, "[URL_REDACTED]") + safe = re.sub( + r"\b(bearer)\s+[A-Za-z0-9._~+/=-]+", + lambda match: f"{match.group(1)} [REDACTED]", + safe, + flags=re.IGNORECASE, + ) + safe = re.sub( + ( + r"([\"']?(?:api[_-]?key|x-api-key|access_token|accessToken|token|" + r"client_secret|clientSecret|refresh_token|id_token|session_id|session-id|" + r"private_key|private-key|secret_key|secret-key|access_key|access-key|" + r"apiToken)[\"']?\s*[:=]\s*[\"']?)[^\"'\s&,}]+" + ), + lambda match: f"{match.group(1)}[REDACTED]", + safe, + flags=re.IGNORECASE, + ) + safe = re.sub( + r"\b(authorization\s*[:=]\s*basic\s+)[^\s&,}]+", + lambda match: f"{match.group(1)}[REDACTED]", + safe, + flags=re.IGNORECASE, + ) + safe = re.sub( + r"\b(authorization\s*[:=]\s*)(?!bearer\b|basic\b)[^\s&,}]+", + lambda match: f"{match.group(1)}[REDACTED]", + safe, + flags=re.IGNORECASE, + ) + safe = re.sub( + ( + r"\b((?:api[_-]?key|x-api-key|access_token|accessToken|token|" + r"client_secret|clientSecret|refresh_token|id_token|session_id|session-id|" + r"private_key|private-key|secret_key|secret-key|access_key|access-key|" + r"apiToken)\s+)[^\s&,}]+" + ), + lambda match: f"{match.group(1)}[REDACTED]", + safe, + flags=re.IGNORECASE, + ) + safe = re.sub(r"https?://[^\s]+", "[URL_REDACTED]", safe) + safe = re.sub( + r"\b(url:\s*)/[^\s)]+", + lambda match: f"{match.group(1)}[URL_REDACTED]", + safe, + flags=re.IGNORECASE, + ) + return safe + + +def _safe_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + sanitized = _safe_metadata_value(metadata) + if isinstance(sanitized, dict): + return sanitized + return {} + + +def _safe_metadata_value(value: Any) -> Any: + if isinstance(value, dict): + safe: dict[str, Any] = {} + for key, nested_value in value.items(): + if _metadata_key_is_sensitive(str(key)): + continue + safe[key] = _safe_metadata_value(nested_value) + return safe + if isinstance(value, list): + return [_safe_metadata_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_safe_metadata_value(item) for item in value) + if isinstance(value, str): + return safe_provider_error_message(value) + return value + + +def _metadata_key_is_sensitive(key: str) -> bool: + if key in TRUSTED_PROVENANCE_KEYS: + return True + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + return any(part in normalized for part in SENSITIVE_METADATA_PARTS) + + +def _is_paddleocr_vl_model(model: str) -> bool: + normalized = model.lower().replace("_", "-") + return "paddleocr-vl" in normalized + + +def _resolve_output_path(output_asset_path: str, asset_root: str) -> Path: + if not asset_root: + raise ValueError("asset_root is required") + root = Path(asset_root).resolve() + output = Path(output_asset_path) + if not output.is_absolute(): + output = root / output + resolved = output.resolve() + if not resolved.is_relative_to(root): + raise ValueError("output_asset_path must be inside asset_root") + return resolved + + +def _resolve_input_path(input_path: str, asset_root: str, field_name: str) -> Path: + if not asset_root: + raise ValueError("asset_root is required") + root = Path(asset_root).resolve() + value = Path(input_path) + if not value.is_absolute(): + value = root / value + resolved = value.resolve() + if not resolved.is_relative_to(root): + raise ValueError(f"{field_name} must be inside asset_root") + return resolved + + +class ProviderError(RuntimeError): + def __init__( + self, + *, + provider_role: str, + operation: str, + message: str, + retryable: bool, + secret_values: list[str] | None = None, + status_code: int | None = None, + provider_error_code: str | None = None, + ): + self.provider_role = provider_role + self.operation = operation + self.retryable = retryable + self.status_code = status_code + self.provider_error_code = _safe_provider_error_code(provider_error_code) + self.message = safe_provider_error_message(message, secret_values=secret_values) + super().__init__(f"{provider_role}.{operation}: {self.message}") + + +class ProviderTimeoutError(ProviderError): + def __init__( + self, + *, + provider_role: str, + operation: str, + message: str, + retryable: bool = True, + timeout_seconds: int, + secret_values: list[str] | None = None, + ): + self.timeout_seconds = timeout_seconds + super().__init__( + provider_role=provider_role, + operation=operation, + message=f"{message} (timeout_seconds={timeout_seconds})", + retryable=retryable, + secret_values=secret_values, + ) + + +class _ProviderDeadlineTimeout(TimeoutError): + pass + + +@contextmanager +def _provider_hard_deadline(timeout_seconds: int): + if ( + timeout_seconds <= 0 + or threading.current_thread() is not threading.main_thread() + or not hasattr(signal, "SIGALRM") + ): + yield + return + + previous_handler = signal.getsignal(signal.SIGALRM) + previous_timer = signal.setitimer(signal.ITIMER_REAL, 0) + started = time.monotonic() + + def timeout_handler(signum, frame): + raise _ProviderDeadlineTimeout(f"provider call exceeded {timeout_seconds}s") + + signal.signal(signal.SIGALRM, timeout_handler) + effective_timeout = float(timeout_seconds) + if previous_timer[0] > 0: + effective_timeout = min(effective_timeout, previous_timer[0]) + signal.setitimer(signal.ITIMER_REAL, effective_timeout) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous_handler) + if previous_timer[0] > 0: + elapsed = time.monotonic() - started + remaining = max(0.001, previous_timer[0] - elapsed) + signal.setitimer(signal.ITIMER_REAL, remaining, previous_timer[1]) + + +@dataclass(frozen=True) +class OCRTextItem: + text: str + bbox: BBox + polygon: tuple[Point, ...] + confidence: float + font_family_hint: str = "" + font_size_hint: float | None = None + style_hints: dict[str, Any] = field(default_factory=dict) + color_hex: str = "#000000" + alignment: Alignment = "left" + provenance: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + x1, y1, x2, y2 = self.bbox + if x2 <= x1 or y2 <= y1: + raise ValueError("bbox must be ordered as (left, top, right, bottom)") + if not 0 <= self.confidence <= 1: + raise ValueError("confidence must be between 0 and 1") + if self.alignment not in {"left", "center", "right", "justify"}: + raise ValueError("alignment must be left, center, right, or justify") + if len(self.polygon) < 4: + raise ValueError("polygon must contain at least four points") + + +@dataclass(frozen=True) +class OCRResult: + source_image_path: str + image_size: tuple[int, int] + provider_role: str + provider_name: str + model: str + items: list[OCRTextItem] + + +class OCRProvider: + def __init__(self, config: ProviderConfig): + self.config = config + + def extract_text(self, image_path: str) -> OCRResult: + raise NotImplementedError + + +def _call_with_provider_retries( + operation, + *, + max_attempts: int, + backoff_seconds: float, + attempt_log: list[dict[str, Any]] | None = None, +): + attempts = max(1, int(max_attempts)) + if attempt_log is not None: + attempt_log.clear() + for attempt in range(1, attempts + 1): + started_at = time.monotonic() + try: + result = operation() + if attempt_log is not None: + attempt_log.append( + { + "attempt": attempt, + "status": "passed", + "elapsed_seconds": _elapsed_seconds_since(started_at), + } + ) + return result + except ProviderError as exc: + retrying = bool(exc.retryable and attempt < attempts) + if attempt_log is not None: + attempt_log.append( + _provider_attempt_error_payload( + exc, + attempt=attempt, + retrying=retrying, + elapsed_seconds=_elapsed_seconds_since(started_at), + ) + ) + exc.attempts = list(attempt_log) + if not retrying: + raise + if backoff_seconds > 0: + time.sleep(backoff_seconds * attempt) + raise AssertionError("retry loop exited unexpectedly") + + +def _elapsed_seconds_since(started_at: float) -> float: + return round(max(0.0, time.monotonic() - started_at), 3) + + +def _provider_attempt_error_payload( + error: ProviderError, + *, + attempt: int, + retrying: bool, + elapsed_seconds: float, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "attempt": attempt, + "status": "failed", + "error_type": type(error).__name__, + "error": str(error), + "retryable": bool(error.retryable), + "retrying": retrying, + "elapsed_seconds": elapsed_seconds, + } + if error.status_code is not None: + payload["status_code"] = error.status_code + if error.provider_error_code: + payload["provider_error_code"] = error.provider_error_code + return payload + + +def _safe_provider_error_code(value: str | None) -> str: + if not value: + return "" + safe = safe_provider_error_message(str(value)) + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", safe).strip("_.-") + safe = re.sub(r"_+", "_", safe) + return safe[:160] + + +class RetryingOCRProvider(OCRProvider): + def __init__(self, provider: OCRProvider, *, max_attempts: int, backoff_seconds: float): + super().__init__(provider.config) + self.provider = provider + self.max_attempts = max_attempts + self.backoff_seconds = backoff_seconds + self.last_attempts: list[dict[str, Any]] = [] + + def extract_text(self, image_path: str) -> OCRResult: + attempts: list[dict[str, Any]] = [] + try: + return _call_with_provider_retries( + lambda: self.provider.extract_text(image_path), + max_attempts=self.max_attempts, + backoff_seconds=self.backoff_seconds, + attempt_log=attempts, + ) + finally: + self.last_attempts = list(attempts) + + +class FakeOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + path = Path(image_path) + with Image.open(path) as image: + width, height = image.size + + item = OCRTextItem( + text="Quarterly Plan", + bbox=(80, 54, 420, 102), + polygon=((80, 54), (420, 54), (420, 102), (80, 102)), + confidence=0.98, + font_family_hint="Arial", + font_size_hint=32, + style_hints={"weight": "bold", "italic": False}, + color_hex="#1F2937", + alignment="center", + provenance={ + "provider_role": self.config.role, + "provider": FAKE_OCR_PROVIDER, + "model": FAKE_OCR_MODEL, + "item_id": "fake-ocr-title-1", + }, + ) + return OCRResult( + source_image_path=str(path), + image_size=(width, height), + provider_role=self.config.role, + provider_name=FAKE_OCR_PROVIDER, + model=FAKE_OCR_MODEL, + items=[item], + ) + + +class OpenAIChatOCRProvider(OCRProvider): + def __init__(self, config: ProviderConfig, *, timeout_seconds: int = 180): + super().__init__(config) + self.timeout_seconds = timeout_seconds + + def extract_text(self, image_path: str) -> OCRResult: + path = Path(image_path) + with Image.open(path) as image: + width, height = image.size + image_base64 = base64.b64encode(path.read_bytes()).decode() + prompt = _focused_crop_ocr_prompt() if _is_focused_ocr_crop_path(path) else _ocr_json_prompt() + if _is_paddleocr_vl_model(self.config.model): + payload = _openai_chat_payload( + model=self.config.model, + prompt=prompt, + image_base64=image_base64, + text_first=True, + temperature=0, + max_tokens=4096, + ) + else: + payload = _openai_chat_payload( + model=self.config.model, + prompt=prompt, + image_base64=image_base64, + text_first=True, + response_format_json=True, + temperature=0, + max_tokens=4096, + ) + response = _post_openai_chat( + self.config, + payload, + operation="extract_text", + timeout_seconds=self.timeout_seconds, + ) + if _is_paddleocr_vl_model(self.config.model): + items = _extract_paddleocr_vl_text_items( + response, + self.config, + (width, height), + image_path=path, + ) + else: + items = _extract_ocr_text_items(response, self.config, (width, height)) + return OCRResult( + source_image_path=str(path), + image_size=(width, height), + provider_role=self.config.role, + provider_name=self.config.provider or "openai_chat", + model=self.config.model, + items=items, + ) + + +class LocalTesseractOCRProvider(OCRProvider): + def __init__(self, config: ProviderConfig, *, timeout_seconds: int = 60): + super().__init__(config) + self.timeout_seconds = timeout_seconds + + def extract_text(self, image_path: str) -> OCRResult: + path = Path(image_path) + with Image.open(path) as image: + width, height = image.size + command = (self.config.base_url or "").strip() or "tesseract" + executable = shutil.which(command) if "/" not in command else command + if not executable or ("/" in command and not os.access(executable, os.X_OK)): + raise ProviderError( + provider_role=self.config.role, + operation="extract_text", + message=f"tesseract executable was not found: {command}", + retryable=False, + ) + try: + result = subprocess.run( + [executable, str(path), "stdout", "tsv", "--psm", "6", "-l", self.config.model], + check=True, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + raise ProviderTimeoutError( + provider_role=self.config.role, + operation="extract_text", + message=str(exc), + timeout_seconds=self.timeout_seconds, + ) from exc + except subprocess.CalledProcessError as exc: + raise ProviderError( + provider_role=self.config.role, + operation="extract_text", + message=exc.stderr or str(exc), + retryable=False, + ) from exc + except OSError as exc: + raise ProviderError( + provider_role=self.config.role, + operation="extract_text", + message=str(exc), + retryable=False, + ) from exc + return OCRResult( + source_image_path=str(path), + image_size=(width, height), + provider_role=self.config.role, + provider_name=LOCAL_TESSERACT_PROVIDER, + model=self.config.model, + items=_parse_tesseract_tsv(result.stdout, self.config, (width, height)), + ) + + +@dataclass(frozen=True) +class ImageEditRequest: + source_image_path: str + prompt_id: str + prompt: str + output_asset_path: str + asset_root: str + mask_path: str | None = None + timeout_seconds: int = 180 + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.source_image_path: + raise ValueError("source_image_path is required") + if not self.prompt_id: + raise ValueError("prompt_id is required") + if not self.prompt: + raise ValueError("prompt is required") + if not self.output_asset_path: + raise ValueError("output_asset_path is required") + _resolve_output_path(self.output_asset_path, self.asset_root) + _resolve_input_path(self.source_image_path, self.asset_root, "source_image_path") + if self.mask_path is not None: + _resolve_input_path(self.mask_path, self.asset_root, "mask_path") + if self.timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + + +@dataclass(frozen=True) +class ImageEditResult: + output_asset_path: str + source_image_path: str + prompt_id: str + provider_role: str + provider_name: str + model: str + timeout_seconds: int + mask_path: str | None = None + provenance: dict[str, Any] = field(default_factory=dict) + + +class ImageEditProvider: + def __init__(self, config: ProviderConfig): + self.config = config + + def edit(self, request: ImageEditRequest) -> ImageEditResult: + raise NotImplementedError + + +class RetryingImageEditProvider(ImageEditProvider): + def __init__(self, provider: ImageEditProvider, *, max_attempts: int, backoff_seconds: float): + super().__init__(provider.config) + self.provider = provider + self.max_attempts = max_attempts + self.backoff_seconds = backoff_seconds + self.last_attempts: list[dict[str, Any]] = [] + + def edit(self, request: ImageEditRequest) -> ImageEditResult: + attempts: list[dict[str, Any]] = [] + try: + return _call_with_provider_retries( + lambda: self.provider.edit(request), + max_attempts=self.max_attempts, + backoff_seconds=self.backoff_seconds, + attempt_log=attempts, + ) + finally: + self.last_attempts = list(attempts) + + +class FakeImageEditProvider(ImageEditProvider): + def edit(self, request: ImageEditRequest) -> ImageEditResult: + source_path = Path(request.source_image_path) + output_path = _resolve_output_path(request.output_asset_path, request.asset_root) + output_path.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source_path) as source: + edited = _fake_image_edit_result(source, request.mask_path) + edited.save(output_path) + + provenance = { + "provider_role": self.config.role, + "provider": FAKE_IMAGE_EDIT_PROVIDER, + "model": FAKE_IMAGE_EDIT_MODEL, + "prompt_id": request.prompt_id, + "metadata": _safe_metadata(request.metadata), + } + return ImageEditResult( + output_asset_path=str(output_path), + source_image_path=str(source_path), + prompt_id=request.prompt_id, + provider_role=self.config.role, + provider_name=FAKE_IMAGE_EDIT_PROVIDER, + model=FAKE_IMAGE_EDIT_MODEL, + timeout_seconds=request.timeout_seconds, + mask_path=request.mask_path, + provenance=provenance, + ) + + +def _fake_image_edit_result(source: Image.Image, mask_path: str | None) -> Image.Image: + if not mask_path: + return source.copy() + base = source.convert("RGBA" if source.mode == "RGBA" else "RGB") + with Image.open(mask_path) as mask_image: + mask = mask_image.convert("L").resize(base.size) + fill_color = _fake_background_fill_color(base, mask) + fill = Image.new(base.mode, base.size, fill_color) + return Image.composite(fill, base, mask) + + +def _fake_background_fill_color(image: Image.Image, mask: Image.Image) -> tuple[int, ...]: + width, height = image.size + candidates: list[tuple[int, ...]] = [] + pixels = image.load() + mask_pixels = mask.load() + sample_points = [ + (0, 0), + (width - 1, 0), + (0, height - 1), + (width - 1, height - 1), + (width // 2, 0), + (width // 2, height - 1), + (0, height // 2), + (width - 1, height // 2), + ] + for x, y in sample_points: + if mask_pixels[x, y] == 0: + value = pixels[x, y] + candidates.append(value if isinstance(value, tuple) else (value,)) + if candidates: + channels = len(candidates[0]) + return tuple(round(sum(pixel[index] for pixel in candidates) / len(candidates)) for index in range(channels)) + value = image.resize((1, 1)).getpixel((0, 0)) + return value if isinstance(value, tuple) else (value,) + + +class OpenAIChatImageEditProvider(ImageEditProvider): + def __init__(self, config: ProviderConfig): + super().__init__(config) + self.normalizer = ImageResultNormalizer() + + def edit(self, request: ImageEditRequest) -> ImageEditResult: + source_path = Path(request.source_image_path) + output_path = _resolve_output_path(request.output_asset_path, request.asset_root) + source_base64 = base64.b64encode(source_path.read_bytes()).decode() + prompt = ( + f"{request.prompt}\n\n" + "Return the edited image only, as a URL, data URL, or base64 image payload." + ) + extra_images: list[str] = [] + if request.mask_path: + prompt += "\nThe second image is the edit mask. Only modify masked regions." + extra_images.append(base64.b64encode(Path(request.mask_path).read_bytes()).decode()) + payload = _openai_chat_payload( + model=self.config.model, + prompt=prompt, + image_base64=source_base64, + extra_images_base64=extra_images, + ) + response = _post_openai_chat( + self.config, + payload, + operation=request.prompt_id, + timeout_seconds=request.timeout_seconds, + ) + _write_normalized_provider_image( + self.normalizer, + response, + output_path, + config=self.config, + operation=request.prompt_id, + timeout_seconds=request.timeout_seconds, + ) + return ImageEditResult( + output_asset_path=str(output_path), + source_image_path=str(source_path), + prompt_id=request.prompt_id, + provider_role=self.config.role, + provider_name=self.config.provider or "openai_chat", + model=self.config.model, + timeout_seconds=request.timeout_seconds, + mask_path=request.mask_path, + provenance={ + "provider_role": self.config.role, + "provider": self.config.provider or "openai_chat", + "model": self.config.model, + "prompt_id": request.prompt_id, + "metadata": _safe_metadata(request.metadata), + }, + ) + + +@dataclass(frozen=True) +class ImageGenerationRequest: + prompt_id: str + prompt: str + output_asset_path: str + asset_root: str + visual_reference: dict[str, Any] = field(default_factory=dict) + timeout_seconds: int = 180 + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.prompt_id: + raise ValueError("prompt_id is required") + if not self.prompt: + raise ValueError("prompt is required") + if not self.output_asset_path: + raise ValueError("output_asset_path is required") + _resolve_output_path(self.output_asset_path, self.asset_root) + if self.timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + + +@dataclass(frozen=True) +class ImageGenerationResult: + output_asset_path: str + prompt_id: str + provider_role: str + provider_name: str + model: str + timeout_seconds: int + visual_reference: dict[str, Any] = field(default_factory=dict) + provenance: dict[str, Any] = field(default_factory=dict) + + +class ImageGenerationProvider: + def __init__(self, config: ProviderConfig): + self.config = config + + def generate(self, request: ImageGenerationRequest) -> ImageGenerationResult: + raise NotImplementedError + + +class RetryingImageGenerationProvider(ImageGenerationProvider): + def __init__( + self, provider: ImageGenerationProvider, *, max_attempts: int, backoff_seconds: float + ): + super().__init__(provider.config) + self.provider = provider + self.max_attempts = max_attempts + self.backoff_seconds = backoff_seconds + self.last_attempts: list[dict[str, Any]] = [] + + def generate(self, request: ImageGenerationRequest) -> ImageGenerationResult: + attempts: list[dict[str, Any]] = [] + try: + return _call_with_provider_retries( + lambda: self.provider.generate(request), + max_attempts=self.max_attempts, + backoff_seconds=self.backoff_seconds, + attempt_log=attempts, + ) + finally: + self.last_attempts = list(attempts) + + +class FakeImageGenerationProvider(ImageGenerationProvider): + def generate(self, request: ImageGenerationRequest) -> ImageGenerationResult: + output_path = _resolve_output_path(request.output_asset_path, request.asset_root) + output_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (256, 256), (0, 0, 0, 0)).save(output_path) + + provenance = { + "provider_role": self.config.role, + "provider": FAKE_IMAGE_GENERATION_PROVIDER, + "model": FAKE_IMAGE_GENERATION_MODEL, + "prompt_id": request.prompt_id, + "metadata": _safe_metadata(request.metadata), + } + return ImageGenerationResult( + output_asset_path=str(output_path), + prompt_id=request.prompt_id, + provider_role=self.config.role, + provider_name=FAKE_IMAGE_GENERATION_PROVIDER, + model=FAKE_IMAGE_GENERATION_MODEL, + timeout_seconds=request.timeout_seconds, + visual_reference=dict(request.visual_reference), + provenance=provenance, + ) + + +class OpenAIChatImageGenerationProvider(ImageGenerationProvider): + def __init__(self, config: ProviderConfig): + super().__init__(config) + self.normalizer = ImageResultNormalizer() + + def generate(self, request: ImageGenerationRequest) -> ImageGenerationResult: + output_path = _resolve_output_path(request.output_asset_path, request.asset_root) + prompt = ( + f"{request.prompt}\n\n" + "Return the generated image only, as a URL, data URL, or base64 image payload." + ) + source_image_path = request.visual_reference.get("source_image_path") + source_base64 = None + if isinstance(source_image_path, str) and source_image_path: + source_base64 = base64.b64encode(Path(source_image_path).read_bytes()).decode() + prompt += "\nUse the attached source slide image as the visual reference." + payload = _openai_chat_payload( + model=self.config.model, + prompt=prompt, + image_base64=source_base64, + ) + response = _post_openai_chat( + self.config, + payload, + operation=request.prompt_id, + timeout_seconds=request.timeout_seconds, + ) + _write_normalized_provider_image( + self.normalizer, + response, + output_path, + config=self.config, + operation=request.prompt_id, + timeout_seconds=request.timeout_seconds, + ) + return ImageGenerationResult( + output_asset_path=str(output_path), + prompt_id=request.prompt_id, + provider_role=self.config.role, + provider_name=self.config.provider or "openai_chat", + model=self.config.model, + timeout_seconds=request.timeout_seconds, + visual_reference=dict(request.visual_reference), + provenance={ + "provider_role": self.config.role, + "provider": self.config.provider or "openai_chat", + "model": self.config.model, + "prompt_id": request.prompt_id, + "metadata": _safe_metadata(request.metadata), + }, + ) + + +def _openai_chat_payload( + *, + model: str, + prompt: str, + image_base64: str | None = None, + extra_images_base64: list[str] | None = None, + text_first: bool = False, + response_format_json: bool = False, + temperature: float | None = None, + max_tokens: int = 2000, +) -> dict[str, Any]: + if image_base64: + image_parts: list[dict[str, Any]] = [ + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{image_base64}", "detail": "high"}, + }, + ] + for extra_image_base64 in extra_images_base64 or []: + image_parts.append( + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{extra_image_base64}", + "detail": "high", + }, + } + ) + text_part = {"type": "text", "text": prompt} + content: Any = [text_part, *image_parts] if text_first else [*image_parts, text_part] + else: + content = prompt + payload: dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": content}], + "max_tokens": max_tokens, + } + if response_format_json: + payload["response_format"] = {"type": "json_object"} + if temperature is not None: + payload["temperature"] = temperature + return payload + + +def _post_openai_chat( + config: ProviderConfig, + payload: dict[str, Any], + *, + operation: str, + timeout_seconds: int, +) -> dict[str, Any]: + try: + with _provider_hard_deadline(timeout_seconds): + response = requests.post( + f"{config.base_url.rstrip('/')}/chat/completions", + headers={ + "Authorization": f"Bearer {config.api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=timeout_seconds, + ) + response.raise_for_status() + return response.json() + except _ProviderDeadlineTimeout as exc: + raise ProviderTimeoutError( + provider_role=config.role, + operation=operation, + message=str(exc), + timeout_seconds=timeout_seconds, + secret_values=[config.api_key, config.base_url], + ) from exc + except requests.Timeout as exc: + raise ProviderTimeoutError( + provider_role=config.role, + operation=operation, + message=str(exc), + timeout_seconds=timeout_seconds, + secret_values=[config.api_key, config.base_url], + ) from exc + except requests.HTTPError as exc: + status_code = exc.response.status_code if exc.response is not None else 0 + response_text = "" + if exc.response is not None: + response_text = (exc.response.text or "").strip() + message = str(exc) + if response_text: + message = f"{message}; response_body={response_text[:1000]}" + provider_error_code = _provider_error_code_from_response_text(response_text) + raise ProviderError( + provider_role=config.role, + operation=operation, + message=message, + retryable=status_code == 429 or status_code >= 500, + secret_values=[config.api_key, config.base_url], + status_code=status_code or None, + provider_error_code=provider_error_code, + ) from exc + except requests.RequestException as exc: + raise ProviderError( + provider_role=config.role, + operation=operation, + message=str(exc), + retryable=True, + secret_values=[config.api_key, config.base_url], + ) from exc + except Exception as exc: + raise ProviderError( + provider_role=config.role, + operation=operation, + message=str(exc), + retryable=_is_retryable_transport_exception(exc), + secret_values=[config.api_key, config.base_url], + ) from exc + + +def _is_retryable_transport_exception(exc: Exception) -> bool: + if isinstance(exc, http.client.IncompleteRead): + return True + text = f"{type(exc).__name__}: {exc}".lower() + return any( + marker in text + for marker in ( + "incompleteread", + "incomplete read", + "connection broken", + "connection reset", + "protocolerror", + "remote end closed", + ) + ) + + +def _provider_error_code_from_response_text(response_text: str) -> str: + if not response_text: + return "" + try: + payload = json.loads(response_text) + except json.JSONDecodeError: + return "" + if not isinstance(payload, dict): + return "" + error = payload.get("error") + if not isinstance(error, dict): + return "" + code = error.get("code") + return str(code) if code else "" + + +def _extract_message_content(payload: dict[str, Any]) -> str: + choices = payload.get("choices") + if not isinstance(choices, list) or not choices: + return "" + first = choices[0] + if not isinstance(first, dict): + return "" + message = first.get("message") + if not isinstance(message, dict): + return "" + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict) and isinstance(item.get("text"), str): + parts.append(item["text"]) + return "\n".join(parts) + return "" + + +def _extract_ocr_items(payload: dict[str, Any]) -> list[dict[str, Any]]: + text = _extract_message_content(payload) + if not text: + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="OCR provider response did not include message content", + retryable=False, + ) + parsed = _parse_json_object(text) + if isinstance(parsed, dict): + items = parsed.get("items", []) + elif isinstance(parsed, list): + items = parsed + else: + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="OCR provider response JSON must be an object or array", + retryable=False, + ) + if not isinstance(items, list): + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="OCR provider response JSON must include an items array", + retryable=False, + ) + return [item for item in items if isinstance(item, dict)] + + +def _extract_ocr_text_items( + payload: dict[str, Any], + config: ProviderConfig, + image_size: tuple[int, int], +) -> list[OCRTextItem]: + try: + return [ + _ocr_text_item_from_payload(item, config) + for item in _extract_ocr_items(payload) + ] + except ProviderError: + scalar_text = _extract_safe_json_scalar_ocr_text(payload) + if scalar_text is None: + raise + return [_plain_text_ocr_item(scalar_text, config, image_size)] + except (TypeError, ValueError) as exc: + raise ProviderError( + provider_role=config.role, + operation="extract_text", + message=f"OCR provider response item had invalid shape: {exc}", + retryable=False, + secret_values=[config.api_key], + ) from exc + + +def _extract_paddleocr_vl_text_items( + payload: dict[str, Any], + config: ProviderConfig, + image_size: tuple[int, int], + *, + image_path: str | Path | None = None, +) -> list[OCRTextItem]: + try: + return _extract_ocr_text_items(payload, config, image_size) + except ProviderError: + text = _extract_message_content(payload).strip() + if not text or _looks_like_ocr_refusal(text): + raise + return _approximate_line_ocr_items(text, config, image_size, image_path=image_path) + + +def _ocr_json_prompt() -> str: + return ( + "You are an OCR engine for presentation slides. Identify every visible text " + "span in the attached image. Output JSON only, with no markdown and no prose. " + "Schema: {\"items\":[{\"text\":\"...\",\"bbox\":[left,top,right,bottom]," + "\"confidence\":0.0,\"font_size\":32,\"color\":\"#RRGGBB\"," + "\"alignment\":\"left\"}]}. Coordinates must be source-image pixels. " + "If there is no visible text, output {\"items\":[]}.\n" + "你是演示文稿 OCR 引擎。只输出 JSON,不要解释、不要 Markdown。" + ) + + +def _focused_crop_ocr_prompt() -> str: + return ( + "You are an OCR engine for a cropped presentation-slide text region. " + "Return only the literal visible text in the image, preserving Chinese, English, " + "numbers, punctuation, and the original order. Do not infer, translate, summarize, " + "number items, describe the slide, or generate unrelated content. If no text is " + "legible, return an empty string. This cropped image usually contains one short " + "line or a small group of adjacent lines.\n" + "你是裁剪文字区域 OCR 引擎。只返回图片中实际可见的原文,不要解释、不要补全、不要生成。" + ) + + +def _is_focused_ocr_crop_path(path: Path) -> bool: + parts = {part.lower() for part in path.parts} + return "focused-crops" in parts or path.name.lower().startswith("visual-text-candidate-") + + +def _approximate_line_ocr_items( + text: str, + config: ProviderConfig, + image_size: tuple[int, int], + *, + image_path: str | Path | None = None, +) -> list[OCRTextItem]: + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + return [] + width, height = image_size + fallback_x1 = max(0, int(width * 0.06)) + fallback_x2 = min(width, int(width * 0.94)) + top_margin = max(0, int(height * 0.08)) + line_height = max(18, int(height * 0.045)) + gap = max(6, int(line_height * 0.35)) + estimated_lines = _estimate_text_line_layouts( + image_path, + image_size, + text_lines=lines, + max_lines=len(lines), + ) + items: list[OCRTextItem] = [] + for index, line in enumerate(lines, start=1): + estimate = estimated_lines[index - 1] if index - 1 < len(estimated_lines) else None + if estimate is not None: + bbox, color_hex, font_size = estimate + x1, y1, x2, y2 = bbox + else: + y1 = top_margin + (index - 1) * (line_height + gap) + y2 = min(height, y1 + line_height) + x1 = fallback_x1 + x2 = fallback_x2 + color_hex = "#FFFFFF" + font_size = _font_size_points_from_pixel_height(y2 - y1, height) + if y1 >= height or y2 <= y1: + break + items.append( + OCRTextItem( + text=line, + bbox=(x1, y1, x2, y2), + polygon=((x1, y1), (x2, y1), (x2, y2), (x1, y2)), + confidence=0.78, + font_size_hint=font_size, + style_hints={ + "source": "paddleocr_vl_plain_text", + "approximate_layout": True, + "layout_source": "image_projection" if estimate is not None else "uniform_fallback", + }, + color_hex=color_hex, + provenance={ + "provider_role": config.role, + "provider": config.provider or "openai_chat", + "model": config.model, + "item_id": f"paddleocr-vl-line-{index}", + "approximate_layout": True, + "layout_source": "image_projection" if estimate is not None else "uniform_fallback", + }, + ) + ) + return items + + +def _estimate_text_line_layouts( + image_path: str | Path | None, + image_size: tuple[int, int], + *, + text_lines: list[str] | None = None, + max_lines: int, +) -> list[tuple[BBox, str, float] | None]: + if image_path is None or max_lines <= 0: + return [] + path = Path(image_path) + if not path.is_file(): + return [] + try: + with Image.open(path) as source: + image = source.convert("RGB") + except OSError: + return [] + width, height = image_size + if image.size != image_size: + image = image.resize(image_size) + component_estimates = _estimate_text_line_layouts_from_components( + image, + image_size, + text_lines=text_lines or [], + max_lines=max_lines, + ) + if component_estimates: + return component_estimates + return _estimate_text_line_layouts_from_projection(image, image_size, max_lines=max_lines) + + +def _estimate_text_line_layouts_from_projection( + image: Image.Image, + image_size: tuple[int, int], + *, + max_lines: int, +) -> list[tuple[BBox, str, float] | None]: + width, height = image_size + row_counts = [0] * height + candidate_rows: list[list[int]] = [[] for _ in range(height)] + pixels = image.load() + for y in range(height): + xs = candidate_rows[y] + for x in range(width): + if _is_text_like_pixel(pixels[x, y]): + xs.append(x) + row_counts[y] = len(xs) + row_threshold = max(6, int(width * 0.004)) + candidate_row_indexes = [y for y, count in enumerate(row_counts) if count >= row_threshold] + bands = _group_contiguous_indexes(candidate_row_indexes, max_gap=4) + estimates: list[tuple[BBox, str, float]] = [] + for y1, y2_inclusive in bands: + y2 = min(height, y2_inclusive + 1) + band_height = y2 - y1 + if band_height < 4 or band_height > max(120, int(height * 0.2)): + continue + xs: list[int] = [] + for y in range(y1, y2): + xs.extend(candidate_rows[y]) + if len(xs) < row_threshold * 2: + continue + x1 = max(0, _percentile_int(xs, 0.01) - 1) + x2 = min(width, _percentile_int(xs, 0.99) + 2) + if x2 - x1 < max(8, int(width * 0.02)): + continue + bbox = (x1, max(0, y1 - 1), x2, min(height, y2 + 1)) + estimates.append( + ( + bbox, + _dominant_text_color(image, bbox), + _font_size_points_from_pixel_height(bbox[3] - bbox[1], height), + ) + ) + if len(estimates) >= max_lines: + break + return estimates + + +def _estimate_text_line_layouts_from_components( + image: Image.Image, + image_size: tuple[int, int], + *, + text_lines: list[str], + max_lines: int, +) -> list[tuple[BBox, str, float] | None]: + try: + import cv2 # type: ignore[import-not-found] + import numpy as np # type: ignore[import-not-found] + except Exception: + return [] + width, height = image_size + array = np.array(image.convert("RGB")) + red = array[:, :, 0].astype("int16") + green = array[:, :, 1].astype("int16") + blue = array[:, :, 2].astype("int16") + luma = (0.299 * red + 0.587 * green + 0.114 * blue) + saturation = np.maximum.reduce([red, green, blue]) - np.minimum.reduce([red, green, blue]) + masks = [ + (((luma >= 145) & (saturation <= 80)) | (luma >= 215)).astype("uint8"), + ( + ((blue >= 130) & (green >= 90) & ((blue - red) >= 25)) + | ((green >= 130) & (blue >= 130) & (red <= 140)) + ).astype("uint8"), + ] + components: list[dict[str, Any]] = [] + image_area = width * height + for mask in masks: + component_count, _labels, stats, _centroids = cv2.connectedComponentsWithStats(mask, 8) + for component_index in range(1, component_count): + x, y, box_width, box_height, area = [int(value) for value in stats[component_index]] + if area < 4 or box_width <= 0 or box_height <= 0: + continue + if box_height < 4 or box_height > max(90, int(height * 0.13)): + continue + if box_width > max(480, int(width * 0.35)): + continue + if area > max(12000, int(image_area * 0.02)): + continue + if box_height <= 4 and box_width > 40: + continue + components.append( + { + "bbox": (x, y, x + box_width, y + box_height), + "area": area, + "cx": x + box_width / 2.0, + "cy": y + box_height / 2.0, + "height": box_height, + } + ) + detected = _build_detected_text_lines(image, components, image_size) + if not detected: + return [] + if text_lines: + return _match_detected_lines_to_text(text_lines[:max_lines], detected, image_size) + return [ + (line.bbox, line.color_hex, line.font_size) + for line in sorted(detected, key=lambda item: (item.bbox[1], item.bbox[0]))[:max_lines] + ] + + +def _build_detected_text_lines( + image: Image.Image, + components: list[dict[str, Any]], + image_size: tuple[int, int], +) -> list[_DetectedTextLine]: + if not components: + return [] + width, height = image_size + vertical_groups: list[list[dict[str, Any]]] = [] + for component in sorted(components, key=lambda item: (item["cy"], item["bbox"][0])): + best_group: list[dict[str, Any]] | None = None + best_distance = float("inf") + for group in vertical_groups: + group_cy = _median_float([component["cy"] for component in group]) + distance = abs(component["cy"] - group_cy) + median_height = _median_float([component["height"] for component in group]) + tolerance = max(6.0, min(component["height"], median_height) * 0.85) + if distance > tolerance: + continue + if distance < best_distance: + best_distance = distance + best_group = group + if best_group is None: + vertical_groups.append([component]) + else: + best_group.append(component) + + detected: list[_DetectedTextLine] = [] + for group in vertical_groups: + for line_components in _split_line_components_by_horizontal_gap(group): + bbox = _components_bbox(line_components) + box_width = bbox[2] - bbox[0] + box_height = bbox[3] - bbox[1] + if box_width < max(8, int(width * 0.008)) or box_height < 4: + continue + if box_height > max(80, int(height * 0.09)): + continue + if len(line_components) == 1 and box_width > max(320, int(width * 0.35)): + continue + if box_width > int(width * 0.82): + continue + component_area = sum(int(component.get("area", 0)) for component in line_components) + if component_area / float(max(1, box_width * box_height)) > 0.65: + continue + expanded = ( + max(0, bbox[0] - 1), + max(0, bbox[1] - 1), + min(width, bbox[2] + 2), + min(height, bbox[3] + 2), + ) + detected.append( + _DetectedTextLine( + bbox=expanded, + color_hex=_dominant_text_color(image, expanded), + font_size=_font_size_points_from_pixel_height(expanded[3] - expanded[1], height), + component_count=len(line_components), + ) + ) + return sorted(detected, key=lambda item: (item.bbox[1], item.bbox[0])) + + +def _components_bbox(components: list[dict[str, Any]]) -> BBox: + return ( + min(component["bbox"][0] for component in components), + min(component["bbox"][1] for component in components), + max(component["bbox"][2] for component in components), + max(component["bbox"][3] for component in components), + ) + + +def _median_float(values: list[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + middle = len(ordered) // 2 + if len(ordered) % 2: + return float(ordered[middle]) + return float((ordered[middle - 1] + ordered[middle]) / 2.0) + + +def _split_line_components_by_horizontal_gap( + components: list[dict[str, Any]], +) -> list[list[dict[str, Any]]]: + ordered = sorted(components, key=lambda item: item["bbox"][0]) + if not ordered: + return [] + heights = sorted(component["bbox"][3] - component["bbox"][1] for component in ordered) + median_height = heights[len(heights) // 2] + max_gap = max(24, int(median_height * 2.8)) + groups: list[list[dict[str, Any]]] = [[ordered[0]]] + previous_right = ordered[0]["bbox"][2] + for component in ordered[1:]: + gap = component["bbox"][0] - previous_right + if gap > max_gap: + groups.append([component]) + else: + groups[-1].append(component) + previous_right = max(previous_right, component["bbox"][2]) + return groups + + +def _match_detected_lines_to_text( + text_lines: list[str], + detected: list[_DetectedTextLine], + image_size: tuple[int, int], +) -> list[tuple[BBox, str, float] | None]: + width, height = image_size + remaining = list(sorted(detected, key=lambda item: (item.bbox[1], item.bbox[0]))) + matched: list[tuple[BBox, str, float] | None] = [] + for text in text_lines: + viable = list(remaining) + if not viable: + matched.append(None) + continue + viable = _preferred_detected_lines_for_text(text, viable, width) + scored = [ + (_text_line_match_score(text, line, index, width), line) + for index, line in enumerate(viable) + ] + score, best = min(scored, key=lambda item: item[0]) + if score > 4.0: + matched.append(None) + continue + remaining.remove(best) + remaining = [ + line + for line in remaining + if not _detected_line_is_fragment_of(line, best) + ] + matched.append((best.bbox, best.color_hex, best.font_size)) + return matched + + +def _preferred_detected_lines_for_text( + text: str, + viable: list[_DetectedTextLine], + image_width: int, +) -> list[_DetectedTextLine]: + stripped = text.strip() + if _looks_like_domain_label(stripped): + domain_labels = [ + line + for line in viable + if _is_left_domain_label_candidate(line, image_width) + ] + return domain_labels or viable + if not _looks_like_parameter_line(stripped): + return viable + right_column = [ + line + for line in viable + if _is_right_column_text_candidate(line, image_width) + ] + return right_column or viable + + +def _is_left_domain_label_candidate(line: _DetectedTextLine, image_width: int) -> bool: + x1, y1, x2, y2 = line.bbox + width = x2 - x1 + height = y2 - y1 + center_x = (x1 + x2) / 2.0 + return ( + image_width * 0.12 <= center_x <= image_width * 0.30 + and 70 <= width <= image_width * 0.16 + and 22 <= height <= 56 + and width >= height * 1.8 + ) + + +def _is_right_column_text_candidate(line: _DetectedTextLine, image_width: int) -> bool: + x1, y1, x2, y2 = line.bbox + width = x2 - x1 + height = y2 - y1 + return ( + x1 >= image_width * 0.70 + and width <= image_width * 0.22 + and height <= 48 + and width >= height * 1.2 + ) + + +def _detected_line_is_fragment_of( + candidate: _DetectedTextLine, + matched: _DetectedTextLine, +) -> bool: + if candidate is matched: + return False + candidate_area = _bbox_area(candidate.bbox) + if candidate_area <= 0: + return False + intersection = _bbox_intersection_area(candidate.bbox, matched.bbox) + if intersection / float(candidate_area) < 0.88: + return False + matched_width = matched.bbox[2] - matched.bbox[0] + candidate_width = candidate.bbox[2] - candidate.bbox[0] + return candidate_width < matched_width * 0.86 + + +def _bbox_intersection_area(left: BBox, right: BBox) -> int: + x1 = max(left[0], right[0]) + y1 = max(left[1], right[1]) + x2 = min(left[2], right[2]) + y2 = min(left[3], right[3]) + if x2 <= x1 or y2 <= y1: + return 0 + return (x2 - x1) * (y2 - y1) + + +def _bbox_area(bbox: BBox) -> int: + return max(0, bbox[2] - bbox[0]) * max(0, bbox[3] - bbox[1]) + + +def _text_line_match_score( + text: str, + line: _DetectedTextLine, + reading_order_index: int, + image_width: int, +) -> float: + x1, y1, x2, y2 = line.bbox + box_width = max(1, x2 - x1) + box_height = max(1, y2 - y1) + units = _text_visual_units(text) + expected_width = max(10.0, units * box_height * 0.9) + ratio = box_width / expected_width + width_score = abs(ratio - 1.0) if ratio < 2 else min(3.0, ratio / 2.0) + single_component_penalty = 0.15 if line.component_count == 1 and box_width > image_width * 0.2 else 0.0 + if _looks_like_domain_label(text.strip()): + order_penalty = 0.0 + elif _looks_like_parameter_line(text.strip()): + order_penalty = min(2.0, reading_order_index * 0.45) + else: + order_penalty = min(1.5, reading_order_index * 0.18) + return ( + width_score + + single_component_penalty + + order_penalty + + _text_line_spatial_prior_score(text, line, image_width) + ) + + +def _text_line_spatial_prior_score( + text: str, + line: _DetectedTextLine, + image_width: int, +) -> float: + stripped = text.strip() + x1, _y1, x2, _y2 = line.bbox + center_x = (x1 + x2) / 2.0 + if _looks_like_domain_label(stripped): + score = 0.0 + if center_x > image_width * 0.35: + score += 2.4 + if x1 > image_width * 0.30: + score += 1.2 + if x2 - x1 > image_width * 0.18: + score += 1.0 + return score + if _looks_like_parameter_line(stripped): + if x1 >= image_width * 0.65: + return -0.35 + if stripped.isascii() and len(stripped) <= 4: + return 4.0 + if x1 < image_width * 0.55: + return 1.8 + if x2 <= image_width * 0.55: + return 1.2 + return 0.0 + + +def _looks_like_domain_label(text: str) -> bool: + return text.endswith("域") and 2 <= len(text) <= 4 and _contains_cjk_text(text) + + +def _looks_like_parameter_line(text: str) -> bool: + if not text or _looks_like_domain_label(text): + return False + if ":" in text or ":" in text: + return False + if _text_visual_units(text) > 12: + return False + return any(char.isalnum() for char in text) or _contains_cjk_text(text) + + +def _contains_cjk_text(text: str) -> bool: + return any("\u3400" <= char <= "\u9fff" for char in text) + + +def _text_visual_units(text: str) -> float: + units = 0.0 + for char in text.strip(): + if char.isspace(): + continue + if "\u4e00" <= char <= "\u9fff": + units += 1.0 + elif char.isascii(): + units += 0.58 + else: + units += 0.8 + return max(1.0, units) + + +def _is_text_like_pixel(rgb: tuple[int, int, int]) -> bool: + red, green, blue = rgb + luma = 0.299 * red + 0.587 * green + 0.114 * blue + if luma >= 150: + return True + if blue >= 130 and green >= 90 and blue - red >= 25: + return True + return green >= 130 and blue >= 130 and red <= 140 + + +def _group_contiguous_indexes(indexes: list[int], *, max_gap: int) -> list[tuple[int, int]]: + if not indexes: + return [] + groups: list[tuple[int, int]] = [] + start = indexes[0] + previous = indexes[0] + for value in indexes[1:]: + if value - previous <= max_gap: + previous = value + continue + groups.append((start, previous)) + start = value + previous = value + groups.append((start, previous)) + return groups + + +def _percentile_int(values: list[int], percentile: float) -> int: + ordered = sorted(values) + if not ordered: + return 0 + index = int(round((len(ordered) - 1) * percentile)) + return ordered[max(0, min(len(ordered) - 1, index))] + + +def _dominant_text_color(image: Image.Image, bbox: BBox) -> str: + x1, y1, x2, y2 = bbox + pixels = image.load() + red_values: list[int] = [] + green_values: list[int] = [] + blue_values: list[int] = [] + for y in range(y1, y2): + for x in range(x1, x2): + rgb = pixels[x, y] + if not _is_text_like_pixel(rgb): + continue + red_values.append(rgb[0]) + green_values.append(rgb[1]) + blue_values.append(rgb[2]) + if not red_values: + return "#FFFFFF" + return "#{:02X}{:02X}{:02X}".format( + _percentile_int(red_values, 0.5), + _percentile_int(green_values, 0.5), + _percentile_int(blue_values, 0.5), + ) + + +def _font_size_points_from_pixel_height(pixel_height: int, image_height: int) -> float: + if image_height <= 0: + return 18.0 + points = pixel_height * 72.0 * 5.625 / image_height * 0.8 + return round(max(8.0, min(44.0, points)), 2) + + +def _parse_tesseract_tsv( + text: str, config: ProviderConfig, image_size: tuple[int, int] +) -> list[OCRTextItem]: + lines = [line for line in text.splitlines() if line.strip()] + if not lines: + return [] + header = lines[0].split("\t") + index = {name: position for position, name in enumerate(header)} + required = {"block_num", "par_num", "line_num", "left", "top", "width", "height", "conf", "text"} + if not required.issubset(index): + raise ProviderError( + provider_role=config.role, + operation="extract_text", + message="tesseract TSV output is missing required columns", + retryable=False, + ) + + grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {} + for row in lines[1:]: + parts = row.split("\t") + if len(parts) < len(header): + continue + word = parts[index["text"]].strip() + if not word: + continue + try: + confidence = float(parts[index["conf"]]) + left = int(float(parts[index["left"]])) + top = int(float(parts[index["top"]])) + width = int(float(parts[index["width"]])) + height = int(float(parts[index["height"]])) + except ValueError: + continue + if confidence < 0 or width <= 0 or height <= 0: + continue + key = (parts[index["block_num"]], parts[index["par_num"]], parts[index["line_num"]]) + grouped.setdefault(key, []).append( + { + "text": word, + "confidence": confidence, + "bbox": (left, top, left + width, top + height), + } + ) + + max_width, max_height = image_size + items = [] + for line_index, words in enumerate(grouped.values(), start=1): + x1 = max(0, min(word["bbox"][0] for word in words)) + y1 = max(0, min(word["bbox"][1] for word in words)) + x2 = min(max_width, max(word["bbox"][2] for word in words)) + y2 = min(max_height, max(word["bbox"][3] for word in words)) + if x2 <= x1 or y2 <= y1: + continue + confidence = max(0.0, min(1.0, sum(word["confidence"] for word in words) / len(words) / 100)) + items.append( + OCRTextItem( + text=" ".join(word["text"] for word in words), + bbox=(x1, y1, x2, y2), + polygon=((x1, y1), (x2, y1), (x2, y2), (x1, y2)), + confidence=confidence, + font_size_hint=float(y2 - y1), + provenance={ + "provider_role": config.role, + "provider": LOCAL_TESSERACT_PROVIDER, + "model": config.model, + "item_id": f"tesseract-line-{line_index}", + }, + ) + ) + return items + + +def _extract_safe_json_scalar_ocr_text(payload: dict[str, Any]) -> str | None: + text = _extract_message_content(payload) + if not text: + return None + try: + parsed = _parse_json_object(text) + except ProviderError: + return None + if not isinstance(parsed, str | int | float): + return None + scalar = str(parsed).strip() + if not scalar or _looks_like_ocr_refusal(scalar): + return None + return scalar + + +def _looks_like_ocr_refusal(value: str) -> bool: + normalized = value.lower() + return any( + marker in normalized + for marker in ( + "cannot extract", + "can't extract", + "unable to extract", + "sorry", + "error", + "failed", + "invalid", + ) + ) + + +def _parse_json_object(text: str) -> Any: + cleaned = text.strip() + fence = re.search(r"```(?:json)?\s*(.*?)```", cleaned, flags=re.DOTALL | re.IGNORECASE) + if fence: + cleaned = fence.group(1).strip() + candidate = _first_balanced_json_candidate(cleaned) or cleaned + try: + return json.loads(candidate) + except json.JSONDecodeError as exc: + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message=( + f"OCR provider response was not valid JSON: {exc}; " + f"response_excerpt={_safe_response_excerpt(cleaned)}" + ), + retryable=False, + ) from exc + + +def _safe_response_excerpt(text: str, *, limit: int = 300) -> str: + compact = re.sub(r"\s+", " ", text).strip() + excerpt = compact[:limit] + return safe_provider_error_message(excerpt) + + +def _first_balanced_json_candidate(text: str) -> str: + for start, char in enumerate(text): + if char not in "{[": + continue + close_for = {"{": "}", "[": "]"} + stack = [close_for[char]] + in_string = False + escape = False + for index in range(start + 1, len(text)): + current = text[index] + if escape: + escape = False + continue + if current == "\\": + escape = True + continue + if current == '"': + in_string = not in_string + continue + if in_string: + continue + if current in close_for: + stack.append(close_for[current]) + elif stack and current == stack[-1]: + stack.pop() + if not stack: + return text[start : index + 1] + break + return "" + + +def _ocr_text_item_from_payload(item: dict[str, Any], config: ProviderConfig) -> OCRTextItem: + bbox = _ocr_bbox(item) + alignment = str(item.get("alignment", "left")).lower() + if alignment not in {"left", "center", "right", "justify"}: + alignment = "left" + x1, y1, x2, y2 = bbox + return OCRTextItem( + text=str(item.get("text", "")), + bbox=bbox, + polygon=tuple(item.get("polygon") or ((x1, y1), (x2, y1), (x2, y2), (x1, y2))), + confidence=float(item.get("confidence", 1.0)), + font_family_hint=str(item.get("font_family", "")), + font_size_hint=_optional_float(item.get("font_size", item.get("font_size_hint"))), + style_hints=dict(item.get("style_hints", {})) if isinstance(item.get("style_hints"), dict) else {}, + color_hex=str(item.get("color", item.get("color_hex", "#000000"))), + alignment=alignment, # type: ignore[arg-type] + provenance={ + "provider_role": config.role, + "provider": config.provider or "openai_chat", + "model": config.model, + }, + ) + + +def _plain_text_ocr_item( + text: str, + config: ProviderConfig, + image_size: tuple[int, int], +) -> OCRTextItem: + width, height = image_size + x1 = max(0, int(width * 0.10)) + y1 = max(0, int(height * 0.12)) + x2 = min(width, int(width * 0.90)) + y2 = min(height, int(height * 0.24)) + return OCRTextItem( + text=text, + bbox=(x1, y1, x2, y2), + polygon=((x1, y1), (x2, y1), (x2, y2), (x1, y2)), + confidence=0.76, + font_family_hint="", + font_size_hint=None, + style_hints={"source": "plain_text_ocr_fallback"}, + color_hex="#000000", + alignment="left", + provenance={ + "provider_role": config.role, + "provider": config.provider or "openai_chat", + "model": config.model, + "fallback": "plain_text_ocr", + }, + ) + + +def _ocr_bbox(item: dict[str, Any]) -> BBox: + raw = item.get("bbox") + if isinstance(raw, list | tuple) and len(raw) == 4: + values = tuple(int(float(value)) for value in raw) + return values # type: ignore[return-value] + if all(key in item for key in ("x", "y", "width", "height")): + x = int(float(item["x"])) + y = int(float(item["y"])) + return (x, y, x + int(float(item["width"])), y + int(float(item["height"]))) + raise ValueError("OCR item must include bbox or x/y/width/height") + + +def _optional_float(value: Any) -> float | None: + if value is None or value == "": + return None + return float(value) + + +def _write_normalized_provider_image( + normalizer: ImageResultNormalizer, + response: dict[str, Any], + output_path: Path, + *, + config: ProviderConfig, + operation: str, + timeout_seconds: int, +) -> None: + try: + with _provider_hard_deadline(timeout_seconds): + normalized = normalizer.normalize(response) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(base64.b64decode(normalized.base64_data)) + with Image.open(output_path) as image: + image.verify() + except _ProviderDeadlineTimeout as exc: + raise ProviderTimeoutError( + provider_role=config.role, + operation=operation, + message=str(exc), + timeout_seconds=timeout_seconds, + secret_values=[config.api_key, config.base_url], + ) from exc + except Exception as exc: + raise ProviderError( + provider_role=config.role, + operation=operation, + message=str(exc), + retryable=False, + secret_values=[config.api_key], + ) from exc diff --git a/src/generative_editable_shape_fitter.py b/src/generative_editable_shape_fitter.py new file mode 100644 index 0000000..cb14117 --- /dev/null +++ b/src/generative_editable_shape_fitter.py @@ -0,0 +1,159 @@ +"""Conservative native shape fitting for foreground candidates.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path + +from PIL import Image + +from .generative_editable_foreground_planner import ForegroundCandidate +from .generative_editable_manifest import NativeShapeSpec + + +@dataclass(frozen=True) +class ShapeFitResult: + native_shape: NativeShapeSpec | None + bitmap_candidate: ForegroundCandidate | None = None + + +def fit_native_shape( + candidate: ForegroundCandidate, + *, + source_image_path: str | Path, + min_confidence: float = 0.9, + enable_ellipses: bool = False, +) -> NativeShapeSpec | None: + if candidate.classification != "native_shape_candidate": + return None + if candidate.confidence < min_confidence: + return None + + provenance = _provenance_dict(candidate.provenance) + shape_hint = provenance.get("shape_hint") + if shape_hint not in {"rectangle", "rounded_rectangle", "ellipse", "line"}: + return None + if shape_hint == "ellipse" and not enable_ellipses: + return None + + with Image.open(source_image_path) as source: + source_rgb = source.convert("RGB") + _validate_candidate_bounds(candidate.source_pixel_bbox, source_rgb.size) + crop = source_rgb.crop(candidate.source_pixel_bbox) + dominant = _dominant_non_white_color(crop) + + if shape_hint == "line": + return NativeShapeSpec( + shape_type="line", + source_pixel_bbox=candidate.source_pixel_bbox, + line_color=dominant, + line_start=_line_endpoint(provenance.get("line_start")) + or _line_start(candidate.source_pixel_bbox), + line_end=_line_endpoint(provenance.get("line_end")) + or _line_end(candidate.source_pixel_bbox), + stroke_width=float(provenance.get("stroke_width") or _line_stroke_width(candidate.source_pixel_bbox)), + opacity=1.0, + confidence=candidate.confidence, + provenance={ + "candidate_id": candidate.candidate_id, + "shape_hint": shape_hint, + "fitter": "conservative_geometry", + }, + ) + + return NativeShapeSpec( + shape_type=shape_hint, + source_pixel_bbox=candidate.source_pixel_bbox, + fill_color=dominant, + radius=provenance.get("radius") if shape_hint == "rounded_rectangle" else None, + confidence=candidate.confidence, + provenance={ + "candidate_id": candidate.candidate_id, + "shape_hint": shape_hint, + "fitter": "conservative_geometry", + }, + ) + + +def fit_native_shape_with_fallback( + candidate: ForegroundCandidate, + *, + source_image_path: str | Path, + min_confidence: float = 0.9, + enable_ellipses: bool = False, +) -> ShapeFitResult: + shape = fit_native_shape( + candidate, + source_image_path=source_image_path, + min_confidence=min_confidence, + enable_ellipses=enable_ellipses, + ) + if shape is not None: + return ShapeFitResult(native_shape=shape) + provenance = _provenance_dict(candidate.provenance) + if candidate.classification == "native_shape_candidate" and candidate.confidence < min_confidence: + reason = "below_native_shape_confidence_threshold" + elif candidate.classification == "native_shape_candidate": + reason = "unsupported_or_disabled_native_shape" + else: + return ShapeFitResult(native_shape=None) + provenance["shape_fit_fallback_reason"] = reason + return ShapeFitResult( + native_shape=None, + bitmap_candidate=replace( + candidate, + classification="bitmap_asset_candidate", + provenance=provenance, + ), + ) + + +def _dominant_non_white_color(crop: Image.Image) -> str: + colors = crop.getcolors(maxcolors=crop.width * crop.height + 1) or [] + colors.sort(reverse=True) + for _, color in colors: + if max(color) < 245 or min(color) < 245: + return "#%02X%02X%02X" % color + return "#FFFFFF" + + +def _provenance_dict(value: object) -> dict: + return dict(value) if isinstance(value, dict) else {} + + +def _line_endpoint(value: object) -> tuple[int, int] | None: + if not isinstance(value, (list, tuple)) or len(value) != 2: + return None + try: + return (int(value[0]), int(value[1])) + except (TypeError, ValueError): + return None + + +def _validate_candidate_bounds(bbox: tuple[int, int, int, int], image_size: tuple[int, int]) -> None: + left, top, right, bottom = bbox + width, height = image_size + if min(bbox) < 0 or right <= left or bottom <= top or right > width or bottom > height: + raise ValueError("source_pixel_bbox must be inside source image bounds") + + +def _line_start(bbox: tuple[int, int, int, int]) -> tuple[int, int]: + left, top, right, bottom = bbox + if _is_vertical_line(bbox): + return (round((left + right) / 2), top) + return (left, round((top + bottom) / 2)) + + +def _line_end(bbox: tuple[int, int, int, int]) -> tuple[int, int]: + left, top, right, bottom = bbox + if _is_vertical_line(bbox): + return (round((left + right) / 2), bottom) + return (right, round((top + bottom) / 2)) + + +def _line_stroke_width(bbox: tuple[int, int, int, int]) -> float: + return min(bbox[2] - bbox[0], bbox[3] - bbox[1]) + + +def _is_vertical_line(bbox: tuple[int, int, int, int]) -> bool: + return (bbox[3] - bbox[1]) > (bbox[2] - bbox[0]) diff --git a/src/generative_editable_text.py b/src/generative_editable_text.py new file mode 100644 index 0000000..1b695e6 --- /dev/null +++ b/src/generative_editable_text.py @@ -0,0 +1,458 @@ +"""Text extraction helpers for generative editable PPTX reconstruction.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Any + +from .generative_editable_manifest import TextBoxSpec, sanitize_persisted_payload +from .generative_editable_providers import OCRResult, OCRTextItem + + +@dataclass(frozen=True) +class TextExtractionResult: + text_boxes: list[TextBoxSpec] + issues: list[dict[str, Any]] + validation_status: str + + +def extract_text_boxes( + text_metadata: list[dict[str, Any]], + ocr_result: OCRResult, +) -> list[TextBoxSpec]: + """Build editable text boxes using AIPPT content and OCR geometry/style.""" + if not text_metadata: + return [_text_box_from_ocr(item) for item in ocr_result.items] + + boxes: list[TextBoxSpec] = [] + ordered_metadata = sorted(text_metadata, key=lambda item: int(item.get("order", 0))) + if _duplicate_metadata_orders(ordered_metadata): + raise ValueError("duplicate metadata order values are not allowed") + matched_ocr_indexes: set[int] = set() + for metadata in ordered_metadata: + index = _metadata_ocr_index(metadata) + if index < 0 or index >= len(ocr_result.items): + break + matched_ocr_indexes.add(index) + boxes.append(_text_box_from_metadata_and_ocr(metadata, ocr_result.items[index])) + for index, ocr_item in enumerate(ocr_result.items): + if index not in matched_ocr_indexes: + boxes.append(_text_box_from_ocr(ocr_item)) + return boxes + + +def extract_text_with_validation( + text_metadata: list[dict[str, Any]], + ocr_result: OCRResult, + *, + min_confidence: float = 0.75, +) -> TextExtractionResult: + boxes: list[TextBoxSpec] = [] + issues: list[dict[str, Any]] = [] + unmatched_metadata_issues: list[dict[str, Any]] = [] + ordered_metadata = sorted(text_metadata, key=lambda item: int(item.get("order", 0))) + matched_ocr_indexes: set[int] = set() + kept_approximate_text_bboxes: dict[str, list[tuple[int, int, int, int]]] = {} + duplicate_orders = _duplicate_metadata_orders(ordered_metadata) + for order in duplicate_orders: + issues.append({"code": "duplicate_metadata_order", "metadata_order": order}) + + if ordered_metadata: + for metadata in ordered_metadata: + if metadata.get("order") in duplicate_orders: + continue + index = _metadata_ocr_index(metadata) + if index < 0 or index >= len(ocr_result.items): + unmatched_metadata_issues.append( + { + "code": "metadata_unmatched", + "metadata_text": str(metadata.get("text", "")), + "metadata_order": metadata.get("order"), + } + ) + continue + ocr_item = ocr_result.items[index] + matched_ocr_indexes.add(index) + if ocr_item.confidence < min_confidence and not _metadata_text_matches_ocr( + metadata, + ocr_item, + ): + issues.append(_low_confidence_issue(ocr_item, index, min_confidence)) + continue + boxes.append(_text_box_from_metadata_and_ocr(metadata, ocr_item)) + else: + if not ocr_result.items: + issues.append({"code": "no_ocr_text_detected"}) + for index, ocr_item in enumerate(ocr_result.items): + if _has_uniform_fallback_layout(ocr_item): + issues.append(_uniform_fallback_layout_issue(ocr_item, index, min_confidence)) + matched_ocr_indexes.add(index) + continue + if _should_ignore_spurious_ocr_item(ocr_item, ocr_result.image_size): + issues.append(_ignored_spurious_ocr_issue(ocr_item, index, min_confidence)) + matched_ocr_indexes.add(index) + continue + if _should_ignore_duplicate_approximate_ocr_item( + ocr_item, + kept_approximate_text_bboxes, + ): + issues.append(_ignored_spurious_ocr_issue(ocr_item, index, min_confidence)) + matched_ocr_indexes.add(index) + continue + if ocr_item.confidence < min_confidence: + issues.append(_low_confidence_issue(ocr_item, index, min_confidence)) + matched_ocr_indexes.add(index) + continue + boxes.append(_text_box_from_ocr(ocr_item)) + _record_kept_approximate_ocr_item(ocr_item, kept_approximate_text_bboxes) + matched_ocr_indexes.add(index) + + for index, ocr_item in enumerate(ocr_result.items): + if index in matched_ocr_indexes: + continue + if _has_uniform_fallback_layout(ocr_item): + issues.append(_uniform_fallback_layout_issue(ocr_item, index, min_confidence)) + continue + if _should_ignore_spurious_ocr_item(ocr_item, ocr_result.image_size): + issues.append(_ignored_spurious_ocr_issue(ocr_item, index, min_confidence)) + continue + if _should_ignore_duplicate_approximate_ocr_item( + ocr_item, + kept_approximate_text_bboxes, + ): + issues.append(_ignored_spurious_ocr_issue(ocr_item, index, min_confidence)) + continue + if ocr_item.confidence < min_confidence: + if ordered_metadata and _is_likely_spurious_ocr_item(ocr_item, ocr_result.image_size): + issues.append(_ignored_spurious_ocr_issue(ocr_item, index, min_confidence)) + continue + issues.append(_low_confidence_issue(ocr_item, index, min_confidence)) + continue + boxes.append(_text_box_from_ocr(ocr_item)) + _record_kept_approximate_ocr_item(ocr_item, kept_approximate_text_bboxes) + + issues.extend(unmatched_metadata_issues) + return TextExtractionResult( + text_boxes=boxes, + issues=issues, + validation_status="failed" if _has_blocking_text_issues(issues) else "passed", + ) + + +def _text_box_from_metadata_and_ocr( + metadata: dict[str, Any], ocr_item: OCRTextItem +) -> TextBoxSpec: + style_hint = metadata.get("style_hint") or {} + font_family = style_hint.get("font_family") or _font_family_for_ocr_item(ocr_item) + provenance = { + "content_source": "aippt_metadata", + "layout_source": "ocr", + "ocr_confidence": ocr_item.confidence, + "ocr_provenance": sanitize_persisted_payload( + {"payload": dict(ocr_item.provenance)} + ).get("payload", {}), + "metadata_role": metadata.get("role", ""), + "metadata_order": metadata.get("order"), + } + return TextBoxSpec( + text=str(metadata.get("text", "")), + source_pixel_bbox=ocr_item.bbox, + source_pixel_polygon=ocr_item.polygon, + font_family=str(font_family or ""), + font_size=_font_size_for_ocr_item(ocr_item), + color_hex=_normalize_color(ocr_item.color_hex), + alignment=_normalize_alignment(ocr_item.alignment), + style_hints=_safe_style_hints(ocr_item.style_hints), + provenance=provenance, + ) + + +def _text_box_from_ocr(ocr_item: OCRTextItem) -> TextBoxSpec: + return TextBoxSpec( + text=ocr_item.text, + source_pixel_bbox=ocr_item.bbox, + source_pixel_polygon=ocr_item.polygon, + font_family=_font_family_for_ocr_item(ocr_item), + font_size=_font_size_for_ocr_item(ocr_item), + color_hex=_normalize_color(ocr_item.color_hex), + alignment=_normalize_alignment(ocr_item.alignment), + style_hints=_safe_style_hints(ocr_item.style_hints), + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": ocr_item.confidence, + "ocr_provenance": sanitize_persisted_payload( + {"payload": dict(ocr_item.provenance)} + ).get("payload", {}), + }, + ) + + +def _font_family_for_ocr_item(ocr_item: OCRTextItem) -> str: + if ocr_item.font_family_hint: + return ocr_item.font_family_hint + if _contains_cjk(ocr_item.text): + return "Microsoft YaHei" + return "Arial" + + +def _font_size_for_ocr_item(ocr_item: OCRTextItem) -> float: + size = ocr_item.font_size_hint or 18 + if _is_approximate_layout_ocr_item(ocr_item): + meaningful_len = len(_meaningful_text(ocr_item.text)) + height = max(1, ocr_item.bbox[3] - ocr_item.bbox[1]) + if height >= 40 and meaningful_len <= 12 and size <= 20: + size = min(size, 14.0) + elif height >= 55 and meaningful_len <= 12: + size = min(size, 14.0) + elif height >= 70 and meaningful_len <= 20: + size = min(size, 16.0) + return size + + +def _normalize_color(value: str | None) -> str: + if not isinstance(value, str): + return "#000000" + color = value.strip().upper() + if re.fullmatch(r"#?[0-9A-F]{6}", color): + return color if color.startswith("#") else f"#{color}" + return "#000000" + + +def _normalize_alignment(value: str) -> str: + return value if value in {"left", "center", "right", "justify"} else "left" + + +def _contains_cjk(value: str) -> bool: + return any( + "\u3040" <= char <= "\u30ff" + or "\u3400" <= char <= "\u9fff" + or "\uf900" <= char <= "\ufaff" + or "\uac00" <= char <= "\ud7af" + for char in value + ) + + +def _low_confidence_issue( + ocr_item: OCRTextItem, index: int, min_confidence: float +) -> dict[str, Any]: + return { + "code": "low_ocr_confidence", + "ocr_index": index, + "ocr_text": ocr_item.text, + "confidence": ocr_item.confidence, + "min_confidence": min_confidence, + } + + +def _ignored_spurious_ocr_issue( + ocr_item: OCRTextItem, index: int, min_confidence: float +) -> dict[str, Any]: + return { + "code": "ignored_spurious_ocr", + "severity": "warning", + "ocr_index": index, + "ocr_text": ocr_item.text, + "confidence": ocr_item.confidence, + "min_confidence": min_confidence, + "bbox": ocr_item.bbox, + } + + +def _uniform_fallback_layout_issue( + ocr_item: OCRTextItem, index: int, min_confidence: float +) -> dict[str, Any]: + if _is_low_value_uniform_fallback_text(ocr_item.text): + return _ignored_spurious_ocr_issue(ocr_item, index, min_confidence) + return { + "code": "unreliable_ocr_layout", + "ocr_index": index, + "ocr_text": ocr_item.text, + "confidence": ocr_item.confidence, + "min_confidence": min_confidence, + "bbox": ocr_item.bbox, + "layout_source": "uniform_fallback", + } + + +def _has_blocking_text_issues(issues: list[dict[str, Any]]) -> bool: + return any(issue.get("severity") != "warning" for issue in issues) + + +def _metadata_text_matches_ocr(metadata: dict[str, Any], ocr_item: OCRTextItem) -> bool: + return _normalize_match_text(str(metadata.get("text", ""))) == _normalize_match_text( + ocr_item.text + ) + + +def _normalize_match_text(value: str) -> str: + return re.sub(r"\s+", " ", value).strip().casefold() + + +def _is_likely_spurious_ocr_item( + ocr_item: OCRTextItem, + image_size: tuple[int, int], +) -> bool: + text = ocr_item.text.strip() + alnum_count = sum(1 for char in text if char.isalnum()) + bbox_area = max(0, ocr_item.bbox[2] - ocr_item.bbox[0]) * max( + 0, + ocr_item.bbox[3] - ocr_item.bbox[1], + ) + image_area = max(1, image_size[0] * image_size[1]) + if alnum_count <= 1 and bbox_area <= 500: + return True + return ( + alnum_count <= 3 + and bbox_area / float(image_area) >= 0.15 + and not text.isalnum() + ) + + +def _should_ignore_spurious_ocr_item( + ocr_item: OCRTextItem, + image_size: tuple[int, int], +) -> bool: + if _is_likely_spurious_ocr_item(ocr_item, image_size): + return True + if _is_likely_repetitive_hallucinated_text(ocr_item.text): + return True + if _long_text_does_not_fit_bbox(ocr_item, image_size): + return True + if _approximate_text_does_not_fit_bbox(ocr_item): + return True + return False + + +def _has_uniform_fallback_layout(ocr_item: OCRTextItem) -> bool: + return str(ocr_item.provenance.get("layout_source", "")) == "uniform_fallback" + + +def _is_low_value_uniform_fallback_text(text: str) -> bool: + meaningful = _meaningful_text(text) + if len(meaningful) <= 3: + return True + digit_count = sum(1 for char in meaningful if char.isdigit()) + if digit_count and digit_count / float(len(meaningful)) >= 0.75: + return True + return False + + +def _should_ignore_duplicate_approximate_ocr_item( + ocr_item: OCRTextItem, + kept_bboxes: dict[str, list[tuple[int, int, int, int]]], +) -> bool: + if not _is_approximate_layout_ocr_item(ocr_item): + return False + key = _normalize_match_text(ocr_item.text) + if len(key) < 4: + return False + return any(_approximate_bboxes_overlap(ocr_item.bbox, kept_bbox) for kept_bbox in kept_bboxes.get(key, [])) + + +def _record_kept_approximate_ocr_item( + ocr_item: OCRTextItem, + kept_bboxes: dict[str, list[tuple[int, int, int, int]]], +) -> None: + if not _is_approximate_layout_ocr_item(ocr_item): + return + key = _normalize_match_text(ocr_item.text) + if len(key) < 4: + return + kept_bboxes.setdefault(key, []).append(ocr_item.bbox) + + +def _approximate_bboxes_overlap( + first: tuple[int, int, int, int], + second: tuple[int, int, int, int], +) -> bool: + left = max(first[0], second[0]) + top = max(first[1], second[1]) + right = min(first[2], second[2]) + bottom = min(first[3], second[3]) + if right <= left or bottom <= top: + center_dx = abs((first[0] + first[2]) / 2.0 - (second[0] + second[2]) / 2.0) + center_dy = abs((first[1] + first[3]) / 2.0 - (second[1] + second[3]) / 2.0) + return center_dx <= 8 and center_dy <= 8 + intersection = (right - left) * (bottom - top) + first_area = max(1, (first[2] - first[0]) * (first[3] - first[1])) + second_area = max(1, (second[2] - second[0]) * (second[3] - second[1])) + return intersection / float(min(first_area, second_area)) >= 0.45 + + +def _is_approximate_layout_ocr_item(ocr_item: OCRTextItem) -> bool: + return bool(ocr_item.provenance.get("approximate_layout")) + + +def _is_likely_repetitive_hallucinated_text(text: str) -> bool: + meaningful = _meaningful_text(text) + if len(meaningful) < 40: + return False + if len(set(meaningful)) / float(len(meaningful)) <= 0.22: + return True + for width in (4, 5, 6, 8): + counts: dict[str, int] = {} + for index in range(0, len(meaningful) - width + 1): + token = meaningful[index : index + width] + counts[token] = counts.get(token, 0) + 1 + if not counts: + continue + repeat_count = max(counts.values()) + if repeat_count >= 4 and repeat_count * width / float(len(meaningful)) >= 0.32: + return True + return False + + +def _long_text_does_not_fit_bbox( + ocr_item: OCRTextItem, + image_size: tuple[int, int], +) -> bool: + meaningful = _meaningful_text(ocr_item.text) + if len(meaningful) < 20: + return False + left, top, right, bottom = ocr_item.bbox + width = max(1, right - left) + height = max(1, bottom - top) + bbox_area = width * height + image_area = max(1, image_size[0] * image_size[1]) + char_density = len(meaningful) / float(bbox_area) + return bbox_area / float(image_area) <= 0.02 and char_density >= 0.004 + + +def _approximate_text_does_not_fit_bbox(ocr_item: OCRTextItem) -> bool: + if not _is_approximate_layout_ocr_item(ocr_item): + return False + meaningful = _meaningful_text(ocr_item.text) + if len(meaningful) < 4: + return False + left, top, right, bottom = ocr_item.bbox + width = max(1, right - left) + height = max(1, bottom - top) + min_horizontal_width = len(meaningful) * max(8, height) * 0.42 + if width < min_horizontal_width * 0.88: + return True + return False + + +def _meaningful_text(text: str) -> str: + return "".join(char for char in text if char.isalnum()) + + +def _safe_style_hints(style_hints: dict[str, Any]) -> dict[str, Any]: + return sanitize_persisted_payload({"payload": dict(style_hints)}).get("payload", {}) + + +def _metadata_ocr_index(metadata: dict[str, Any]) -> int: + return int(metadata.get("order", 0)) - 1 + + +def _duplicate_metadata_orders(metadata_items: list[dict[str, Any]]) -> set[int]: + seen: set[int] = set() + duplicates: set[int] = set() + for metadata in metadata_items: + order = int(metadata.get("order", 0)) + if order in seen: + duplicates.add(order) + seen.add(order) + return duplicates diff --git a/src/generative_editable_text_candidates.py b/src/generative_editable_text_candidates.py new file mode 100644 index 0000000..a295227 --- /dev/null +++ b/src/generative_editable_text_candidates.py @@ -0,0 +1,520 @@ +"""Classical text-like region detection for editable PPTX quality gates.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from PIL import Image + +BBox = tuple[int, int, int, int] + +TILE = 96 +MIN_GLYPH_PX = 6 +MAX_TEXT_FILL = 0.65 +MIN_CLASS_SEPARATION = 25.0 +MAX_INK_FRACTION = 0.45 +MIN_INK_PIXELS = 8 +ROW_GAP_ABS_FRACTION = 0.003 +COL_GAP_BAND_FRACTION = 0.25 + + +def detect_text_candidate_bboxes( + image_path: str | Path, + *, + min_glyph: int = MIN_GLYPH_PX, +) -> list[BBox]: + gray = _load_gray(image_path) + height, width = gray.shape + mask = _binarize_page(gray) + boxes: list[tuple[int, int, int, int]] = [] + _xy_cut( + mask, + 0, + 0, + max(6, round(height * 0.008)), + max(14, round(width * 0.011)), + boxes, + ) + candidates = _measure_leaves(gray, mask, boxes, min_glyph) + candidates.extend(_detect_bright_text_group_bboxes(image_path)) + candidates.extend(_detect_large_bright_text_line_bboxes(image_path)) + candidates.extend(_detect_left_blue_label_bboxes(image_path)) + return _dedupe_bboxes(candidates) + + +def _load_gray(path: str | Path) -> np.ndarray: + return np.asarray(Image.open(path).convert("L"), dtype=np.float32) + + +def _load_rgb(path: str | Path) -> np.ndarray: + return np.asarray(Image.open(path).convert("RGB"), dtype=np.uint8) + + +def _detect_left_blue_label_bboxes(image_path: str | Path) -> list[BBox]: + rgb = _load_rgb(image_path) + height, width = rgb.shape[:2] + red = rgb[:, :, 0].astype(np.int16) + green = rgb[:, :, 1].astype(np.int16) + blue = rgb[:, :, 2].astype(np.int16) + mask = ( + ((blue >= 120) & (green >= 70) & ((blue - red) >= 25)) + | ((green >= 120) & (blue >= 120) & (red <= 140)) + ) + x_limit = max(1, int(width * 0.35)) + left_mask = mask[:, :x_limit] + row_threshold = max(4, int(width * 0.003)) + rows = np.flatnonzero(left_mask.sum(axis=1) >= row_threshold) + candidates: list[BBox] = [] + for top, bottom in _runs_from_indexes(rows, max_gap=8): + band = left_mask[top:bottom, :] + if bottom - top < 8: + continue + column_threshold = max(2, int((bottom - top) * 0.08)) + columns = np.flatnonzero(band.sum(axis=0) >= column_threshold) + for left, right in _runs_from_indexes(columns, max_gap=8): + box = _tight_mask_bbox(left_mask, left, top, right, bottom) + if box is None: + continue + if _is_left_blue_label_bbox(box, (width, height)): + candidates.append(box) + return candidates + + +def _detect_large_bright_text_line_bboxes(image_path: str | Path) -> list[BBox]: + rgb = _load_rgb(image_path) + height, width = rgb.shape[:2] + top_region_height = max(1, int(height * 0.46)) + left_region_width = max(1, int(width * 0.65)) + region = rgb[:top_region_height, :left_region_width, :] + channels = region.astype(np.int16) + max_channel = channels.max(axis=2) + min_channel = channels.min(axis=2) + mask = (max_channel >= 150) & ((max_channel - min_channel) <= 90) + row_threshold = max(12, int(width * 0.010)) + rows = np.flatnonzero(mask.sum(axis=1) >= row_threshold) + candidates: list[BBox] = [] + for top, bottom in _runs_from_indexes(rows, max_gap=10): + if bottom - top < max(18, int(height * 0.025)): + continue + band = mask[top:bottom, :] + column_threshold = max(4, int((bottom - top) * 0.06)) + columns = np.flatnonzero(band.sum(axis=0) >= column_threshold) + column_runs = _runs_from_indexes(columns, max_gap=max(18, int(width * 0.035))) + if not column_runs: + continue + left = min(run[0] for run in column_runs) + right = max(run[1] for run in column_runs) + if right - left < max(220, int(width * 0.22)): + continue + box = _tight_mask_bbox(mask, left, top, right, bottom) + if box is not None and _is_large_bright_text_bbox(box, (width, height)): + candidates.append(box) + return candidates + + +def _detect_bright_text_group_bboxes(image_path: str | Path) -> list[BBox]: + rgb = _load_rgb(image_path) + height, width = rgb.shape[:2] + channels = rgb.astype(np.int16) + max_channel = channels.max(axis=2) + min_channel = channels.min(axis=2) + mask = (max_channel >= 150) & ((max_channel - min_channel) <= 100) + row_threshold = max(8, int(width * 0.004)) + rows = np.flatnonzero(mask.sum(axis=1) >= row_threshold) + candidates: list[BBox] = [] + for top, bottom in _runs_from_indexes(rows, max_gap=8): + if bottom - top < 8: + continue + band = mask[top:bottom, :] + column_threshold = max(2, int((bottom - top) * 0.05)) + columns = np.flatnonzero(band.sum(axis=0) >= column_threshold) + for left, right in _runs_from_indexes(columns, max_gap=max(16, int(width * 0.010))): + box = _tight_mask_bbox(mask, left, top, right, bottom) + if box is not None and _is_bright_text_group_bbox(box, (width, height)): + candidates.append(box) + return candidates + + +def _is_bright_text_group_bbox(bbox: BBox, image_size: tuple[int, int]) -> bool: + width, height = image_size + x1, _y1, x2, y2 = bbox + box_width = x2 - x1 + box_height = y2 - bbox[1] + aspect = box_width / float(max(1, box_height)) + return ( + width * 0.02 <= box_width <= width * 0.24 + and height * 0.012 <= box_height <= height * 0.10 + and aspect >= 1.25 + ) + + +def _is_large_bright_text_bbox(bbox: BBox, image_size: tuple[int, int]) -> bool: + width, height = image_size + x1, y1, x2, y2 = bbox + box_width = x2 - x1 + box_height = y2 - y1 + return ( + x1 <= width * 0.25 + and y1 <= height * 0.42 + and box_width >= width * 0.22 + and height * 0.035 <= box_height <= height * 0.18 + ) + + +def _runs_from_indexes(indexes: np.ndarray, *, max_gap: int) -> list[tuple[int, int]]: + if indexes.size == 0: + return [] + runs: list[tuple[int, int]] = [] + start = int(indexes[0]) + previous = int(indexes[0]) + for raw_index in indexes[1:]: + index = int(raw_index) + if index - previous > max_gap: + runs.append((start, previous + 1)) + start = index + previous = index + runs.append((start, previous + 1)) + return runs + + +def _tight_mask_bbox( + mask: np.ndarray, + left: int, + top: int, + right: int, + bottom: int, +) -> BBox | None: + crop = mask[top:bottom, left:right] + if not crop.any(): + return None + ys, xs = np.nonzero(crop) + x1 = max(0, left + int(xs.min()) - 3) + y1 = max(0, top + int(ys.min()) - 3) + x2 = min(mask.shape[1], left + int(xs.max()) + 4) + y2 = min(mask.shape[0], top + int(ys.max()) + 4) + return (x1, y1, x2, y2) + + +def _is_left_blue_label_bbox(bbox: BBox, image_size: tuple[int, int]) -> bool: + width, height = image_size + x1, y1, x2, y2 = bbox + box_width = x2 - x1 + box_height = y2 - y1 + center_x = (x1 + x2) / 2.0 + return ( + width * 0.12 <= center_x <= width * 0.30 + and 40 <= box_width <= width * 0.18 + and 12 <= box_height <= max(70, height * 0.08) + and box_width >= box_height * 1.2 + ) + + +def _dedupe_bboxes(bboxes: list[BBox]) -> list[BBox]: + deduped: list[BBox] = [] + for bbox in sorted(bboxes, key=lambda item: (item[1], item[0], -(item[2] - item[0]))): + if _is_broad_container_for_existing_text_groups(bbox, deduped): + continue + if any(_bbox_iou(bbox, existing) >= 0.55 for existing in deduped): + continue + deduped.append(bbox) + return deduped + + +def _is_broad_container_for_existing_text_groups(bbox: BBox, existing_bboxes: list[BBox]) -> bool: + width = bbox[2] - bbox[0] + height = bbox[3] - bbox[1] + if bbox[0] <= 150 and bbox[1] >= 100 and width >= 700 and height <= 80 and width >= height * 10: + return True + if width < 320 or width < height * 6: + return False + contained = [ + existing + for existing in existing_bboxes + if _bbox_contains_ratio(bbox, existing) >= 0.85 + and (existing[2] - existing[0]) <= width * 0.55 + ] + return len(contained) >= (1 if width >= 700 and width >= height * 12 else 2) + + +def _bbox_contains_ratio(container: BBox, inner: BBox) -> float: + left = max(container[0], inner[0]) + top = max(container[1], inner[1]) + right = min(container[2], inner[2]) + bottom = min(container[3], inner[3]) + if right <= left or bottom <= top: + return 0.0 + intersection = (right - left) * (bottom - top) + inner_area = max(0, inner[2] - inner[0]) * max(0, inner[3] - inner[1]) + return intersection / float(inner_area) if inner_area else 0.0 + + +def _bbox_iou(first: BBox, second: BBox) -> float: + left = max(first[0], second[0]) + top = max(first[1], second[1]) + right = min(first[2], second[2]) + bottom = min(first[3], second[3]) + if right <= left or bottom <= top: + return 0.0 + intersection = (right - left) * (bottom - top) + first_area = max(0, first[2] - first[0]) * max(0, first[3] - first[1]) + second_area = max(0, second[2] - second[0]) * max(0, second[3] - second[1]) + union = first_area + second_area - intersection + return intersection / float(union) if union else 0.0 + + +def _binarize_page(gray: np.ndarray, tile: int = TILE) -> np.ndarray: + height, width = gray.shape + mask = np.zeros((height, width), dtype=bool) + for top in range(0, height, tile): + for left in range(0, width, tile): + crop = gray[top : top + tile, left : left + tile] + ink = _crop_ink_mask(crop) + if ink["reliable"]: + mask[top : top + crop.shape[0], left : left + crop.shape[1]] = ink["mask"] + return _despeckle(mask) + + +def _otsu_threshold(gray: np.ndarray) -> tuple[float, float]: + hist, _ = np.histogram(gray, bins=256, range=(0.0, 256.0)) + total = float(gray.size) + if total == 0: + return 127.5, 0.0 + levels = np.arange(256, dtype=np.float64) + weight_bg = np.cumsum(hist).astype(np.float64) + weight_fg = total - weight_bg + sum_bg = np.cumsum(levels * hist) + sum_all = sum_bg[-1] + mean_bg = sum_bg / np.maximum(weight_bg, 1.0) + mean_fg = (sum_all - sum_bg) / np.maximum(weight_fg, 1.0) + between = weight_bg * weight_fg * (mean_bg - mean_fg) ** 2 + index = int(np.argmax(between)) + separation = abs(float(mean_fg[index]) - float(mean_bg[index])) + if weight_bg[index] == 0 or weight_fg[index] == 0: + separation = 0.0 + return float(index), separation + + +def _crop_ink_mask(gray_crop: np.ndarray) -> dict: + threshold, separation = _otsu_threshold(gray_crop) + dark = gray_crop <= threshold + dark_fraction = float(dark.mean()) if dark.size else 0.0 + if dark_fraction <= 0.5: + mask = dark + fraction = dark_fraction + else: + mask = ~dark + fraction = 1.0 - dark_fraction + reliable = ( + separation >= MIN_CLASS_SEPARATION + and fraction <= MAX_INK_FRACTION + and int(mask.sum()) >= MIN_INK_PIXELS + ) + return { + "mask": mask, + "reliable": reliable, + } + + +def _despeckle(mask: np.ndarray) -> np.ndarray: + if not mask.any(): + return mask + padded = np.pad(mask, 1, mode="constant") + neighbors = np.zeros_like(padded, dtype=np.uint8) + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + if dy == 0 and dx == 0: + continue + neighbors += np.roll(np.roll(padded, dy, axis=0), dx, axis=1).astype(np.uint8) + return mask & (neighbors[1:-1, 1:-1] > 0) + + +def _split_runs(profile: np.ndarray, min_gap: int) -> list[tuple[int, int]]: + index = np.flatnonzero(profile) + if index.size == 0: + return [] + breaks = np.flatnonzero(np.diff(index) > min_gap) + starts = [int(index[0]), *(int(index[i + 1]) for i in breaks)] + ends = [*(int(index[i]) + 1 for i in breaks), int(index[-1]) + 1] + return list(zip(starts, ends)) + + +def _xy_cut( + mask: np.ndarray, + x0: int, + y0: int, + min_gap_y: int, + min_gap_x: int, + boxes: list[tuple[int, int, int, int]], + depth: int = 0, +) -> None: + if not mask.any(): + return + height, width = mask.shape + row_ink = mask.sum(axis=1) > max(2, ROW_GAP_ABS_FRACTION * width) + rows = _split_runs(row_ink, min_gap_y) or [(0, height)] + if len(rows) > 1 and depth < 16: + for top, bottom in rows: + _xy_cut(mask[top:bottom, :], x0, y0 + top, min_gap_y, min_gap_x, boxes, depth + 1) + return + top, bottom = rows[0] + column_ink = mask[top:bottom, :].sum(axis=0) > max( + 2, + COL_GAP_BAND_FRACTION * (bottom - top), + ) + columns = _split_runs(column_ink, min_gap_x) or [(0, width)] + if len(columns) > 1 and depth < 16: + for left, right in columns: + _xy_cut( + mask[top:bottom, left:right], + x0 + left, + y0 + top, + min_gap_y, + min_gap_x, + boxes, + depth + 1, + ) + return + left, right = columns[0] + boxes.append((x0 + left, y0 + top, right - left, bottom - top)) + + +def _measure_leaves( + gray: np.ndarray, + page_mask: np.ndarray, + boxes: list[tuple[int, int, int, int]], + min_glyph: int, +) -> list[BBox]: + height, width = gray.shape + lines: list[BBox] = [] + for x, y, box_width, box_height in boxes: + if box_width < min_glyph or box_height < min_glyph: + continue + if float(page_mask[y : y + box_height, x : x + box_width].mean()) > MAX_TEXT_FILL: + continue + pad = 3 + left = max(0, x - pad) + top = max(0, y - pad) + right = min(width, x + box_width + pad) + bottom = min(height, y + box_height + pad) + measured = _measure_crop(gray[top:bottom, left:right]) + if not measured: + continue + bx, by, bw, bh, glyph, fill = measured + if glyph < min_glyph or fill > MAX_TEXT_FILL: + continue + if glyph > bw + 2: + continue + pad_x = max(2, int(round(glyph * 0.35))) + pad_y = max(1, int(round(glyph * 0.30))) + box_left = max(0, left + bx - pad_x) + box_top = max(0, top + by - pad_y) + box_right = min(width, left + bx + bw + pad_x) + box_bottom = min(height, top + by + bh + pad_y) + lines.append((box_left, box_top, box_right, box_bottom)) + return lines + + +def _measure_crop(gray_crop: np.ndarray) -> tuple[int, int, int, int, float, float] | None: + ink = _crop_ink_mask(gray_crop) + if not ink["reliable"]: + return None + mask = _remove_border_components(ink["mask"]) + segmented = _segment_mask(mask) + if segmented is None: + return None + bbox, glyph = segmented + x, y, width, height = bbox + fill = float(mask[y : y + height, x : x + width].mean()) + return x, y, width, height, glyph, fill + + +def _dilate(mask: np.ndarray) -> np.ndarray: + out = mask.copy() + out[1:, :] |= mask[:-1, :] + out[:-1, :] |= mask[1:, :] + out[:, 1:] |= mask[:, :-1] + out[:, :-1] |= mask[:, 1:] + return out + + +def _remove_border_components(mask: np.ndarray) -> np.ndarray: + if not mask.any(): + return mask + reach = np.zeros_like(mask) + reach[0, :] = mask[0, :] + reach[-1, :] = mask[-1, :] + reach[:, 0] = mask[:, 0] + reach[:, -1] = mask[:, -1] + if not reach.any(): + return mask + for _ in range(2 * max(mask.shape)): + grown = _dilate(reach) & mask + if np.array_equal(grown, reach): + break + reach = grown + return mask & ~reach + + +def _segment_mask(mask: np.ndarray) -> tuple[tuple[int, int, int, int], float] | None: + if not mask.any(): + return None + bands = _filter_rule_bands(_line_bands(mask)) + if not bands: + return None + row_selector = np.zeros(mask.shape[0], dtype=bool) + for top, bottom in bands: + row_selector[top:bottom] = True + restricted = mask & row_selector[:, None] + bbox = _tight_bbox(restricted) + if bbox is None: + return None + glyph = float(np.median([bottom - top for top, bottom in bands])) + return bbox, glyph + + +def _line_bands(mask: np.ndarray, min_ink_px: int = 2, merge_gap: int = 2) -> list[tuple[int, int]]: + row_ink = mask.sum(axis=1) + inky = row_ink >= min_ink_px + bands: list[list[int]] = [] + start = None + for y, on in enumerate(inky): + if on and start is None: + start = y + elif not on and start is not None: + bands.append([start, y]) + start = None + if start is not None: + bands.append([start, len(inky)]) + merged: list[list[int]] = [] + for band in bands: + if merged and band[0] - merged[-1][1] <= merge_gap: + merged[-1][1] = band[1] + else: + merged.append(band) + return [(top, bottom) for top, bottom in merged if bottom - top >= 2] + + +def _filter_rule_bands(bands: list[tuple[int, int]]) -> list[tuple[int, int]]: + if len(bands) <= 1: + return bands + heights = [bottom - top for top, bottom in bands] + tallest = max(heights) + if tallest <= 4: + return bands + kept = [ + (top, bottom) + for (top, bottom), band_height in zip(bands, heights) + if not (band_height <= 4 and band_height < 0.35 * tallest) + ] + return kept or bands + + +def _tight_bbox(mask: np.ndarray) -> tuple[int, int, int, int] | None: + if not mask.any(): + return None + rows = np.flatnonzero(mask.any(axis=1)) + cols = np.flatnonzero(mask.any(axis=0)) + top, bottom = int(rows[0]), int(rows[-1]) + left, right = int(cols[0]), int(cols[-1]) + return (left, top, right - left + 1, bottom - top + 1) diff --git a/src/generative_editable_text_masks.py b/src/generative_editable_text_masks.py new file mode 100644 index 0000000..ba0db42 --- /dev/null +++ b/src/generative_editable_text_masks.py @@ -0,0 +1,106 @@ +"""Mask generation for editable text reconstruction.""" + +from __future__ import annotations + +from PIL import Image, ImageDraw, ImageFilter + +from .generative_editable_manifest import TextBoxSpec + +MAX_MASK_PIXELS = 16_000_000 + + +def create_text_mask( + image_size: tuple[int, int], + text_boxes: list[TextBoxSpec], + *, + padding: int = 0, + use_polygons: bool = False, +) -> Image.Image: + _validate_mask_request(image_size, padding) + mask = Image.new("L", image_size, 0) + draw = ImageDraw.Draw(mask) + for box in text_boxes: + if use_polygons: + polygon = _clipped_polygon_or_none(box.source_pixel_polygon, image_size) + if polygon is None: + continue + draw.polygon(polygon, fill=255) + else: + padded = _padded_bbox(box.source_pixel_bbox, image_size, padding) + if padded is not None: + draw.rectangle(padded, fill=255) + if use_polygons and padding > 0: + return mask.filter(ImageFilter.MaxFilter(padding * 2 + 1)) + return mask + + +def _validate_mask_request(image_size: tuple[int, int], padding: int) -> None: + width, height = image_size + if width <= 0 or height <= 0 or width * height > MAX_MASK_PIXELS: + raise ValueError("image_size is outside supported mask bounds") + if padding < 0: + raise ValueError("padding must be non-negative") + + +def _padded_bbox( + bbox: tuple[int, int, int, int], + image_size: tuple[int, int], + padding: int, +) -> tuple[int, int, int, int] | None: + width, height = image_size + x1, y1, x2, y2 = bbox + if x2 < 0 or y2 < 0 or x1 > width - 1 or y1 > height - 1: + return None + clipped = ( + max(0, min(width - 1, x1 - padding)), + max(0, min(height - 1, y1 - padding)), + max(0, min(width - 1, x2 + padding)), + max(0, min(height - 1, y2 + padding)), + ) + if clipped[2] < clipped[0] or clipped[3] < clipped[1]: + return None + return clipped + + +def _padded_polygon( + polygon: tuple[tuple[int, int], ...], + image_size: tuple[int, int], + padding: int, +) -> list[tuple[int, int]]: + if padding <= 0: + return [_clip_point(point, image_size) for point in polygon] + x1 = min(point[0] for point in polygon) + y1 = min(point[1] for point in polygon) + x2 = max(point[0] for point in polygon) + y2 = max(point[1] for point in polygon) + return [ + _clip_point((x - padding if x == x1 else x + padding if x == x2 else x, y), image_size) + if y not in {y1, y2} + else _clip_point( + ( + x - padding if x == x1 else x + padding if x == x2 else x, + y - padding if y == y1 else y + padding, + ), + image_size, + ) + for x, y in polygon + ] + + +def _clip_point(point: tuple[int, int], image_size: tuple[int, int]) -> tuple[int, int]: + width, height = image_size + return (min(max(point[0], 0), width - 1), min(max(point[1], 0), height - 1)) + + +def _clipped_polygon_or_none( + polygon: tuple[tuple[int, int], ...], image_size: tuple[int, int] +) -> list[tuple[int, int]] | None: + width, height = image_size + if ( + max(point[0] for point in polygon) < 0 + or max(point[1] for point in polygon) < 0 + or min(point[0] for point in polygon) > width - 1 + or min(point[1] for point in polygon) > height - 1 + ): + return None + return [_clip_point(point, image_size) for point in polygon] diff --git a/src/generative_editable_vlm_reconstruction.py b/src/generative_editable_vlm_reconstruction.py new file mode 100644 index 0000000..d479721 --- /dev/null +++ b/src/generative_editable_vlm_reconstruction.py @@ -0,0 +1,2636 @@ +"""VLM-first editable PPTX reconstruction helpers. + +This module turns a structured VLM page analysis into the same manifest +primitives used by the deterministic PPTX composer. Provider calls remain +outside this module so unit tests never spend real model budget. +""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Callable +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field, replace +import base64 +from difflib import SequenceMatcher +import inspect +import io +from pathlib import Path +import re +import time +from typing import Any + +from PIL import Image, ImageChops, ImageDraw, ImageStat + +from .generative_editable_config import ProviderConfig +from .generative_editable_assets import build_asset_sheet_request, slice_asset_sheet_by_components +from .generative_editable_backgrounds import BackgroundResult, create_source_preserving_text_background +from .generative_editable_composer import compose_deck_from_manifests +from .generative_editable_foreground_planner import ForegroundCandidate +from .generative_editable_job_artifacts import GenerativeEditableJobArtifacts +from .generative_editable_manifest import ( + AssetSheetSpec, + BitmapAssetSpec, + DeckManifest, + NativeShapeSpec, + PageManifest, + TextBoxSpec, + read_page_manifest, + write_manifest, +) +from .generative_editable_providers import ( + ImageEditProvider, + ImageEditRequest, + OCRProvider, + ProviderError, + ProviderTimeoutError, + RetryingImageEditProvider, + RetryingOCRProvider, + _ProviderDeadlineTimeout, + _extract_message_content, + _call_with_provider_retries, + _elapsed_seconds_since, + _parse_json_object, + _post_openai_chat, + _provider_attempt_error_payload, + _provider_hard_deadline, + safe_provider_error_message, +) +from .generative_editable_pipeline import ( + GenerativeEditableExportResult, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, +) +from .generative_editable_preview_validator import ( + ValidationIssue, + ValidationReport, + quality_threshold_to_preview_gates, + render_manifest_preview_with_metadata, + validate_composed_deck_structure, + validate_preview_similarity, +) +from .generative_editable_text import extract_text_with_validation + +PixelBBox = tuple[int, int, int, int] +MIN_BITMAP_REGION_AREA_RATIO = 0.0004 +MAX_BITMAP_ELEMENT_AREA_RATIO = 0.72 +MAX_BITMAP_ELEMENT_WIDTH_RATIO = 0.92 +MAX_BITMAP_ELEMENT_HEIGHT_RATIO = 0.72 +ASSET_SHEET_PROVIDER_TIMEOUT_SECONDS = 45 + + +@dataclass(frozen=True) +class VLMCoordinateSpace: + width: int + height: int + unit: str = "px" + + +@dataclass(frozen=True) +class VLMTextRegion: + region_id: str + text: str + bbox: PixelBBox + role: str = "body" + color_hex: str = "" + confidence: float = 0.0 + group_id: str = "" + + +@dataclass(frozen=True) +class VLMBitmapRegion: + region_id: str + kind: str + bbox: PixelBBox + importance: str = "" + group_id: str = "" + + +@dataclass(frozen=True) +class VLMShapeRegion: + region_id: str + kind: str + bbox: PixelBBox + importance: str = "" + group_id: str = "" + + +@dataclass(frozen=True) +class VLMPageAnalysis: + coordinate_space: VLMCoordinateSpace + text_regions: list[VLMTextRegion] = field(default_factory=list) + bitmap_regions: list[VLMBitmapRegion] = field(default_factory=list) + shape_regions: list[VLMShapeRegion] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class MaskBox: + kind: str + region_id: str + bbox: PixelBBox + + +@dataclass(frozen=True) +class VLMCoordinateMapper: + analysis_space: VLMCoordinateSpace + source_image_size: tuple[int, int] + + def to_source_bbox(self, bbox: PixelBBox, *, padding: int = 0) -> PixelBBox: + source_width, source_height = self.source_image_size + left, top, right, bottom = bbox + mapped = ( + round(left / self.analysis_space.width * source_width) - padding, + round(top / self.analysis_space.height * source_height) - padding, + round(right / self.analysis_space.width * source_width) + padding, + round(bottom / self.analysis_space.height * source_height) + padding, + ) + return _clamp_bbox(mapped, self.source_image_size) + + +class VLMPageAnalysisProvider: + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180) -> VLMPageAnalysis: + raise NotImplementedError + + +class RetryingVLMPageAnalysisProvider(VLMPageAnalysisProvider): + def __init__( + self, + provider: VLMPageAnalysisProvider, + *, + max_attempts: int, + backoff_seconds: float, + ): + self.provider = provider + self.max_attempts = max_attempts + self.backoff_seconds = backoff_seconds + self.last_attempts: list[dict[str, Any]] = [] + + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180) -> VLMPageAnalysis: + attempts: list[dict[str, Any]] = [] + try: + return _call_with_provider_retries( + lambda: self.provider.analyze_page( + image_path, + timeout_seconds=timeout_seconds, + ), + max_attempts=self.max_attempts, + backoff_seconds=self.backoff_seconds, + attempt_log=attempts, + ) + finally: + self.last_attempts = _attach_vlm_payload_attempts(attempts, self.provider) + + +def _attach_vlm_payload_attempts( + attempts: list[dict[str, Any]], + provider: VLMPageAnalysisProvider, +) -> list[dict[str, Any]]: + copied = [dict(item) for item in attempts] + payload_attempts = getattr(provider, "last_payload_attempts", None) + if copied and payload_attempts: + copied[-1]["payload_attempts"] = [ + dict(item) for item in payload_attempts if isinstance(item, dict) + ] + return copied + + +class OpenAIChatVLMPageAnalysisProvider(VLMPageAnalysisProvider): + def __init__( + self, + config: ProviderConfig, + *, + analysis_size: tuple[int, int] = (768, 432), + max_tokens: int = 2600, + ): + self.config = config + self.analysis_size = analysis_size + self.max_tokens = max_tokens + self.last_payload_attempts: list[dict[str, Any]] = [] + + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180) -> VLMPageAnalysis: + self.last_payload_attempts = [] + last_retryable_error: ProviderError | None = None + last_payload_compatibility_error: ProviderError | None = None + payloads = _vlm_analysis_payload_variants( + image_path, + analysis_size=self.analysis_size, + model=self.config.model, + max_tokens=self.max_tokens, + ) + for attempt_index, payload in enumerate(payloads, start=1): + started_at = time.monotonic() + try: + response = _post_openai_chat( + self.config, + payload, + operation="vlm_page_analysis", + timeout_seconds=timeout_seconds, + ) + analysis = self._analysis_from_response(response) + self.last_payload_attempts.append( + { + "attempt": attempt_index, + "status": "passed", + "elapsed_seconds": _elapsed_seconds_since(started_at), + } + ) + return analysis + except ProviderError as exc: + compatibility_error = _is_vlm_payload_shape_compatibility_error(exc) + retrying = bool(attempt_index < len(payloads) and (exc.retryable or compatibility_error)) + self.last_payload_attempts.append( + _provider_attempt_error_payload( + exc, + attempt=attempt_index, + retrying=retrying, + elapsed_seconds=_elapsed_seconds_since(started_at), + ) + ) + if exc.retryable: + last_retryable_error = exc + continue + if compatibility_error: + last_payload_compatibility_error = exc + continue + raise + if last_retryable_error is not None: + raise last_retryable_error + if last_payload_compatibility_error is not None: + raise last_payload_compatibility_error + raise ProviderError( + provider_role=self.config.role, + operation="vlm_page_analysis", + message="VLM provider did not produce a response", + retryable=True, + secret_values=[self.config.api_key, self.config.base_url], + ) + + def _analysis_from_response(self, response: dict[str, Any]) -> VLMPageAnalysis: + content = _extract_message_content(response) + try: + parsed = _parse_json_object(content) + except ProviderError as exc: + raise ProviderError( + provider_role=self.config.role, + operation="vlm_page_analysis", + message=str(exc), + retryable=False, + secret_values=[self.config.api_key, self.config.base_url], + ) from exc + if not isinstance(parsed, dict): + raise ProviderError( + provider_role=self.config.role, + operation="vlm_page_analysis", + message="VLM provider response JSON must be an object", + retryable=False, + secret_values=[self.config.api_key, self.config.base_url], + ) + return coerce_vlm_analysis(parsed) + + +class FakeVLMPageAnalysisProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180) -> VLMPageAnalysis: + with Image.open(image_path) as image: + width, height = image.size + return coerce_vlm_analysis( + { + "coordinate_space": {"width": width, "height": height, "unit": "px"}, + "text_regions": [ + { + "id": "fake-title", + "text": "Editable Title", + "bbox": [ + round(width * 0.08), + round(height * 0.08), + round(width * 0.48), + round(height * 0.18), + ], + "role": "title", + "color": "#FFFFFF", + "confidence": 1.0, + } + ], + "bitmap_regions": [ + { + "id": "fake-asset", + "type": "icon", + "bbox": [ + round(width * 0.58), + round(height * 0.25), + round(width * 0.78), + round(height * 0.52), + ], + } + ], + "shape_regions": [ + { + "id": "fake-divider", + "type": "divider", + "bbox": [ + round(width * 0.08), + round(height * 0.64), + round(width * 0.88), + round(height * 0.65), + ], + } + ], + } + ) + + +def _default_vlm_preview_validator(**kwargs) -> ValidationReport: + if kwargs.get("require_preview_validation") is False: + return ValidationReport(status="passed", checked_pages=1, issues=[]) + gates = quality_threshold_to_preview_gates(float(kwargs.get("preview_similarity_threshold", 0.92))) + return validate_preview_similarity( + source_image_path=kwargs["source_image_path"], + preview=kwargs["preview"], + slide_id=kwargs["slide_id"], + page_index=kwargs["page_index"], + max_mean_abs_delta=gates.max_mean_abs_delta, + max_changed_pixel_ratio=gates.max_changed_pixel_ratio, + ) + + +@dataclass(frozen=True) +class VLMEditablePipelineDependencies: + vlm_provider: VLMPageAnalysisProvider + image_edit_provider: ImageEditProvider + asset_sheet_image_edit_provider: ImageEditProvider | None = None + ocr_provider: OCRProvider | None = None + composer: Any = compose_deck_from_manifests + provider_timeout_seconds: int = 180 + page_timeout_seconds: float = 600 + provider_max_attempts: int = 1 + provider_retry_backoff_seconds: float = 0.0 + mask_padding: int = 10 + ocr_min_confidence: float = 0.75 + preview_similarity_threshold: float = 0.92 + require_preview_validation: bool = True + preview_renderer: Callable[..., Any] = render_manifest_preview_with_metadata + structure_validator: Callable[..., ValidationReport] = validate_composed_deck_structure + preview_validator: Callable[..., ValidationReport] = _default_vlm_preview_validator + + +def run_vlm_editable_pptx_pipeline( + *, + slides: list[GenerativeEditableSlideInput | dict[str, Any]], + output_path: str, + artifact_root: str, + job_id: str, + dependencies: VLMEditablePipelineDependencies, + aspect_ratio: str = "16:9", + cleanup_artifacts: bool = False, +) -> GenerativeEditableExportResult: + if not slides: + raise ValueError("slides are required") + dependencies = with_vlm_provider_retries(dependencies) + artifacts = GenerativeEditableJobArtifacts(root_dir=artifact_root, job_id=job_id) + try: + page_manifest_refs: list[str] = [] + slide_order: list[str] = [] + page_manifests: list[PageManifest] = [] + for page_index, slide in enumerate(slides): + with _vlm_page_deadline(dependencies.page_timeout_seconds) as page_deadline: + page_manifest = _build_vlm_page_manifest( + slide=slide, + page_index=page_index, + aspect_ratio=aspect_ratio, + artifacts=artifacts, + dependencies=dependencies, + page_deadline=page_deadline, + ) + page_manifest_path = artifacts.page_manifest_path(page_manifest.slide_id, page_index) + write_manifest(page_manifest_path, page_manifest) + page_manifest_refs.append(_job_relative(page_manifest_path, artifacts.job_dir)) + slide_order.append(page_manifest.slide_id) + page_manifests.append(page_manifest) + clean_background_local_fallback_used = _clean_background_local_fallback_used(page_manifests) + deck_manifest = DeckManifest( + job_id=artifacts.job_id, + slide_order=slide_order, + aspect_ratio=aspect_ratio, + provider_roles={ + "vlm": "VLM", + "image_edit": dependencies.image_edit_provider.config.role, + "asset_sheet": ( + dependencies.asset_sheet_image_edit_provider or dependencies.image_edit_provider + ).config.role, + }, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=page_manifest_refs, + validation_status="pending", + provenance={ + "reconstruction_strategy": "vlm_first", + "clean_background_local_fallback_used": clean_background_local_fallback_used, + }, + ) + write_manifest(artifacts.deck_manifest_path, deck_manifest) + with _record_vlm_stage(artifacts, "compose_deck"): + dependencies.composer(artifacts.deck_manifest_path, artifacts.job_dir, output_path) + validation_report = _validate_vlm_pipeline_output( + deck_manifest_path=artifacts.deck_manifest_path, + artifact_root=artifacts.job_dir, + pptx_path=output_path, + page_manifests=page_manifests, + dependencies=dependencies, + ) + _record_vlm_validation_status( + artifacts=artifacts, + deck_manifest=deck_manifest, + page_manifest_refs=page_manifest_refs, + validation_report=validation_report, + ) + if validation_report.status != "passed" or validation_report.issues: + raise GenerativeEditableValidationError( + validation_report=validation_report, + fallback_policy="fail", + ) + return GenerativeEditableExportResult( + status="passed", + output_path=output_path, + fallback_policy="fail", + validation_report=validation_report, + fallback_used="clean_background_local" if clean_background_local_fallback_used else "", + ) + finally: + if cleanup_artifacts: + artifacts.cleanup() + + +def _validate_vlm_pipeline_output( + *, + deck_manifest_path: Path, + artifact_root: Path, + pptx_path: str, + page_manifests: list[PageManifest], + dependencies: VLMEditablePipelineDependencies, +) -> ValidationReport: + reports = [ + _validate_vlm_minimum_editable_structure(page_manifests), + dependencies.structure_validator( + deck_manifest_path=deck_manifest_path, + artifact_root=artifact_root, + pptx_path=pptx_path, + ) + ] + for page in page_manifests: + preview = _render_vlm_preview( + dependencies.preview_renderer, + page, + artifact_root, + pptx_path=pptx_path, + ) + reports.append( + dependencies.preview_validator( + source_image_path=artifact_root / page.source_image_path, + preview=preview, + slide_id=page.slide_id, + page_index=page.page_index, + pptx_path=pptx_path, + preview_similarity_threshold=dependencies.preview_similarity_threshold, + require_preview_validation=dependencies.require_preview_validation, + ) + ) + return _merge_validation_reports(reports, checked_pages=len(page_manifests)) + + +def _validate_vlm_minimum_editable_structure(page_manifests: list[PageManifest]) -> ValidationReport: + issues: list[ValidationIssue] = [] + for page in page_manifests: + editable_count = len(page.text_boxes) + len(page.native_shapes) + len(page.bitmap_assets) + if editable_count > 0: + continue + issues.append( + ValidationIssue( + code="vlm_no_editable_or_decomposed_objects", + message="VLM-first reconstruction produced only a background with no editable or decomposed page objects", + slide_id=page.slide_id, + details={ + "page_index": page.page_index, + "text_box_count": len(page.text_boxes), + "native_shape_count": len(page.native_shapes), + "bitmap_asset_count": len(page.bitmap_assets), + "chosen_background": page.chosen_background, + }, + ) + ) + return ValidationReport( + status="failed" if issues else "passed", + checked_pages=len(page_manifests), + issues=issues, + ) + + +def _clean_background_local_fallback_used(page_manifests: list[PageManifest]) -> bool: + return any( + bool(page.provenance.get("clean_background_provider_failed")) + for page in page_manifests + ) + + +def _asset_sheet_timeout_seconds(provider_timeout_seconds: int) -> int: + if provider_timeout_seconds <= 0: + return ASSET_SHEET_PROVIDER_TIMEOUT_SECONDS + return min(provider_timeout_seconds, ASSET_SHEET_PROVIDER_TIMEOUT_SECONDS) + + +@contextmanager +def _vlm_page_deadline(timeout_seconds: float): + deadline = _VLMPageDeadline(timeout_seconds) + try: + with _provider_hard_deadline(timeout_seconds): + yield deadline + except _ProviderDeadlineTimeout as exc: + raise deadline.timeout_error(str(exc)) from exc + + +class _VLMPageDeadline: + def __init__(self, timeout_seconds: float): + self.timeout_seconds = float(timeout_seconds) + self.started_at = time.monotonic() + + def check(self, stage: str) -> None: + if self.timeout_seconds <= 0: + return + if time.monotonic() - self.started_at >= self.timeout_seconds: + raise self.timeout_error(f"VLM-first page exceeded {self.timeout_seconds}s during {stage}") + + def provider_timeout(self, configured_timeout_seconds: int | float, stage: str) -> float: + self.check(stage) + if self.timeout_seconds <= 0: + return configured_timeout_seconds + remaining = self.timeout_seconds - (time.monotonic() - self.started_at) + if remaining <= 0: + raise self.timeout_error(f"VLM-first page exceeded {self.timeout_seconds}s before {stage}") + return max(0.001, min(float(configured_timeout_seconds), remaining)) + + def timeout_error(self, message: str) -> ProviderTimeoutError: + return ProviderTimeoutError( + provider_role="vlm_first", + operation="build_page_manifest", + message=message, + timeout_seconds=self.timeout_seconds, + ) + + +@contextmanager +def _temporary_provider_timeout(provider: Any, timeout_seconds: float): + targets = [] + current = provider + while current is not None: + if hasattr(current, "timeout_seconds"): + targets.append(current) + current = getattr(current, "provider", None) + originals = [(target, target.timeout_seconds) for target in targets] + try: + for target, original in originals: + target.timeout_seconds = min(float(original), float(timeout_seconds)) + yield + finally: + for target, original in originals: + target.timeout_seconds = original + + +def _raise_page_timeout_if_page_limited_provider_timeout( + *, + page_deadline: _VLMPageDeadline, + provider_error: ProviderTimeoutError, + configured_timeout_seconds: int | float, + actual_timeout_seconds: int | float, + stage: str, +) -> None: + if ( + page_deadline.timeout_seconds > 0 + and float(actual_timeout_seconds) < float(configured_timeout_seconds) + and provider_error.timeout_seconds <= float(actual_timeout_seconds) + 0.001 + ): + raise page_deadline.timeout_error( + f"VLM-first page exceeded {page_deadline.timeout_seconds}s during {stage}" + ) from provider_error + + +def _render_vlm_preview(preview_renderer, page: PageManifest, artifact_root: Path, *, pptx_path: str): + if "pptx_path" in inspect.signature(preview_renderer).parameters: + return preview_renderer(page, artifact_root, pptx_path=pptx_path) + return preview_renderer(page, artifact_root) + + +def _merge_validation_reports( + reports: list[ValidationReport], + *, + checked_pages: int, +) -> ValidationReport: + issues = [issue for report in reports for issue in report.issues] + failed = any(report.status != "passed" for report in reports) or bool(issues) + return ValidationReport( + status="failed" if failed else "passed", + checked_pages=checked_pages, + issues=issues, + ) + + +def _record_vlm_validation_status( + *, + artifacts: GenerativeEditableJobArtifacts, + deck_manifest: DeckManifest, + page_manifest_refs: list[str], + validation_report: ValidationReport, +) -> None: + status = "failed" if validation_report.status != "passed" or validation_report.issues else "passed" + issue_slide_ids = {issue.slide_id for issue in validation_report.issues if issue.slide_id} + rewritten_refs: list[str] = [] + for page_ref in page_manifest_refs: + page_path = artifacts.job_dir / page_ref + page = read_page_manifest(page_path) + page_status = "failed" if status == "failed" and (not issue_slide_ids or page.slide_id in issue_slide_ids) else "passed" + write_manifest(page_path, replace(page, validation_status=page_status)) + rewritten_refs.append(page_ref) + write_manifest( + artifacts.deck_manifest_path, + replace( + deck_manifest, + page_manifest_paths=rewritten_refs, + validation_status=status, + quality_settings={ + **dict(deck_manifest.quality_settings), + "validation_issue_count": len(validation_report.issues), + }, + ), + ) + + +def with_vlm_provider_retries( + dependencies: VLMEditablePipelineDependencies, +) -> VLMEditablePipelineDependencies: + vlm_provider = dependencies.vlm_provider + if not isinstance(vlm_provider, RetryingVLMPageAnalysisProvider): + vlm_provider = RetryingVLMPageAnalysisProvider( + vlm_provider, + max_attempts=dependencies.provider_max_attempts, + backoff_seconds=dependencies.provider_retry_backoff_seconds, + ) + + def retry_image_edit(provider: ImageEditProvider | None) -> ImageEditProvider | None: + if provider is None or isinstance(provider, RetryingImageEditProvider): + return provider + return RetryingImageEditProvider( + provider, + max_attempts=dependencies.provider_max_attempts, + backoff_seconds=dependencies.provider_retry_backoff_seconds, + ) + + ocr_provider = dependencies.ocr_provider + if ocr_provider is not None and not isinstance(ocr_provider, RetryingOCRProvider): + ocr_provider = RetryingOCRProvider( + ocr_provider, + max_attempts=dependencies.provider_max_attempts, + backoff_seconds=dependencies.provider_retry_backoff_seconds, + ) + + return replace( + dependencies, + vlm_provider=vlm_provider, + image_edit_provider=retry_image_edit(dependencies.image_edit_provider), + asset_sheet_image_edit_provider=retry_image_edit(dependencies.asset_sheet_image_edit_provider), + ocr_provider=ocr_provider, + ) + + +def _build_vlm_page_manifest( + *, + slide: GenerativeEditableSlideInput | dict[str, Any], + page_index: int, + aspect_ratio: str, + artifacts: GenerativeEditableJobArtifacts, + dependencies: VLMEditablePipelineDependencies, + page_deadline: _VLMPageDeadline, +) -> PageManifest: + slide_id = _slide_value(slide, "slide_id") or f"slide-{page_index + 1}" + image_path = _slide_value(slide, "image_path") + if not image_path: + raise ValueError("slide image_path is required") + source_path = artifacts.asset_path(slide_id, page_index, "sources", "source.png") + source_path.write_bytes(Path(image_path).read_bytes()) + analysis_timeout = page_deadline.provider_timeout( + dependencies.provider_timeout_seconds, + "vlm_page_analysis", + ) + with _record_vlm_stage( + artifacts, + "vlm_page_analysis", + page_index=page_index, + slide_id=slide_id, + provider_role="VLM", + timeout_seconds=analysis_timeout, + ): + try: + analysis = dependencies.vlm_provider.analyze_page( + str(source_path), + timeout_seconds=analysis_timeout, + ) + except ProviderTimeoutError as exc: + _raise_page_timeout_if_page_limited_provider_timeout( + page_deadline=page_deadline, + provider_error=exc, + configured_timeout_seconds=dependencies.provider_timeout_seconds, + actual_timeout_seconds=analysis_timeout, + stage="vlm_page_analysis", + ) + raise + page_deadline.check("vlm_page_analysis") + ocr_result = None + text_result = None + if dependencies.ocr_provider is not None: + ocr_timeout = page_deadline.provider_timeout( + dependencies.provider_timeout_seconds, + "ocr_extract_text", + ) + with _record_vlm_stage( + artifacts, + "ocr_extract_text", + page_index=page_index, + slide_id=slide_id, + provider_role=dependencies.ocr_provider.config.role, + provider=dependencies.ocr_provider.config.model, + timeout_seconds=ocr_timeout, + ): + try: + with _temporary_provider_timeout(dependencies.ocr_provider, ocr_timeout): + ocr_result = dependencies.ocr_provider.extract_text(str(source_path)) + except ProviderTimeoutError as exc: + _raise_page_timeout_if_page_limited_provider_timeout( + page_deadline=page_deadline, + provider_error=exc, + configured_timeout_seconds=dependencies.provider_timeout_seconds, + actual_timeout_seconds=ocr_timeout, + stage="ocr_extract_text", + ) + raise + page_deadline.check("ocr_extract_text") + text_result = extract_text_with_validation( + _slide_value(slide, "text_metadata") or [], + ocr_result, + min_confidence=dependencies.ocr_min_confidence, + ) + with Image.open(source_path) as source: + source_image_size = source.size + mapper = VLMCoordinateMapper( + analysis_space=analysis.coordinate_space, + source_image_size=source_image_size, + ) + page_deadline.check("vlm_build_mask") + with _record_vlm_stage(artifacts, "vlm_build_mask", page_index=page_index, slide_id=slide_id): + mask, mask_boxes = build_text_bitmap_mask( + analysis, + mapper, + padding=dependencies.mask_padding, + additional_text_bboxes=[ + text_box.source_pixel_bbox + for text_box in (text_result.text_boxes if text_result else []) + ], + ) + page_deadline.check("vlm_build_mask") + mask_path = artifacts.asset_path(slide_id, page_index, "assets", "vlm-text-bitmap-mask.png") + mask.save(mask_path) + clean_timeout = page_deadline.provider_timeout( + dependencies.provider_timeout_seconds, + "vlm_clean_background", + ) + with _record_vlm_stage( + artifacts, + "vlm_clean_background", + page_index=page_index, + slide_id=slide_id, + provider_role=dependencies.image_edit_provider.config.role, + provider=dependencies.image_edit_provider.config.model, + timeout_seconds=clean_timeout, + ): + clean_background = _create_vlm_clean_background( + source_image_path=source_path, + mask_path=mask_path, + output_asset_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + asset_root=artifacts.job_dir, + edit_provider=dependencies.image_edit_provider, + timeout_seconds=clean_timeout, + cleanup_bboxes=[box.bbox for box in mask_boxes], + ) + page_deadline.check("vlm_clean_background") + asset_sheet_provider = dependencies.asset_sheet_image_edit_provider or dependencies.image_edit_provider + asset_sheet_timeout_seconds = page_deadline.provider_timeout( + _asset_sheet_timeout_seconds(dependencies.provider_timeout_seconds), + "vlm_asset_sheet", + ) + with _record_vlm_stage( + artifacts, + "vlm_asset_sheet", + page_index=page_index, + slide_id=slide_id, + provider_role=asset_sheet_provider.config.role, + provider=asset_sheet_provider.config.model, + timeout_seconds=asset_sheet_timeout_seconds, + ): + bitmap_assets, asset_sheets, asset_sheet_output_path = _create_vlm_asset_sheet_assets( + analysis=analysis, + mapper=mapper, + source_image_path=source_path, + artifacts=artifacts, + slide_id=slide_id, + page_index=page_index, + edit_provider=asset_sheet_provider, + timeout_seconds=asset_sheet_timeout_seconds, + ) + page_deadline.check("vlm_asset_sheet") + vlm_output_path = artifacts.write_provider_output( + slide_id, + page_index, + "vlm", + "analysis.json", + { + "analysis": _analysis_payload(analysis), + "mask_boxes": [asdict(box) for box in mask_boxes], + }, + ) + image_edit_output_path = artifacts.write_provider_output( + slide_id, + page_index, + "image_edit", + "backgrounds.json", + {"chosen_background": asdict(clean_background)}, + ) + ocr_output_path = None + if ocr_result is not None: + ocr_output_path = artifacts.write_provider_output( + slide_id, + page_index, + "ocr", + "ocr.json", + { + "provider_role": ocr_result.provider_role, + "provider_name": ocr_result.provider_name, + "model": ocr_result.model, + "image_size": list(ocr_result.image_size), + "items": [asdict(item) for item in ocr_result.items], + "issues": text_result.issues if text_result else [], + }, + ) + with _record_vlm_stage(artifacts, "vlm_build_manifest", page_index=page_index, slide_id=slide_id): + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id=slide_id, + page_index=page_index, + source_image_path=source_path, + clean_background_path=clean_background.output_asset_path, + artifact_root=artifacts.job_dir, + aspect_ratio=aspect_ratio, + text_boxes=text_result.text_boxes if text_result else None, + bitmap_assets=bitmap_assets, + asset_sheets=asset_sheets, + ) + page = replace( + page, + provenance={ + **page.provenance, + "clean_background_strategy": clean_background.strategy, + "clean_background_provider_failed": bool(clean_background.provenance.get("provider_failed")), + "text_mask_path": _job_relative(mask_path, artifacts.job_dir), + }, + ) + provider_output_paths = { + "vlm": _job_relative(vlm_output_path, artifacts.job_dir), + "image_edit": _job_relative(image_edit_output_path, artifacts.job_dir), + } + if asset_sheet_output_path is not None: + provider_output_paths["asset_sheet"] = _job_relative(asset_sheet_output_path, artifacts.job_dir) + if ocr_output_path is not None: + provider_output_paths["ocr"] = _job_relative(ocr_output_path, artifacts.job_dir) + return replace( + page, + provider_output_paths=provider_output_paths, + ) + + +@contextmanager +def _record_vlm_stage( + artifacts: GenerativeEditableJobArtifacts, + stage: str, + *, + page_index: int | None = None, + slide_id: str | None = None, + provider_role: str | None = None, + provider: str | None = None, + timeout_seconds: int | None = None, +): + started = time.perf_counter() + event: dict[str, Any] = {"stage": stage, "status": "started"} + if page_index is not None: + event["page_index"] = page_index + if slide_id is not None: + event["slide_id"] = slide_id + if provider_role: + event["provider_role"] = provider_role + if provider: + event["provider"] = provider + if timeout_seconds: + event["timeout_seconds"] = timeout_seconds + artifacts.append_stage_event(event) + try: + yield + except Exception as exc: + artifacts.append_stage_event( + { + **event, + "status": "failed", + "elapsed_ms": round((time.perf_counter() - started) * 1000), + "error_type": exc.__class__.__name__, + "error": str(exc) or exc.__class__.__name__, + } + ) + raise + artifacts.append_stage_event( + { + **event, + "status": "finished", + "elapsed_ms": round((time.perf_counter() - started) * 1000), + } + ) + + +def coerce_vlm_analysis(payload: dict[str, Any]) -> VLMPageAnalysis: + coordinate_payload = payload.get("coordinate_space") or {} + width = int(coordinate_payload.get("width") or _max_bbox_x(payload) or 1000) + height = int(coordinate_payload.get("height") or _max_bbox_y(payload) or 1000) + if width <= 0 or height <= 0: + raise ValueError("VLM coordinate_space must be positive") + coordinate_space = VLMCoordinateSpace( + width=width, + height=height, + unit=str(coordinate_payload.get("unit") or "px"), + ) + return VLMPageAnalysis( + coordinate_space=coordinate_space, + text_regions=[ + VLMTextRegion( + region_id=_region_id(item, "t", index), + text=str(item.get("text", "")).strip(), + bbox=_coerce_bbox(item.get("bbox"), coordinate_space), + role=str(item.get("role") or "body"), + color_hex=_clean_hex_color(item.get("color")), + confidence=_coerce_confidence(item.get("confidence")), + group_id=str(item.get("group_id") or ""), + ) + for index, item in enumerate(_items(payload, "text_regions"), start=1) + if str(item.get("text", "")).strip() + ], + bitmap_regions=[ + VLMBitmapRegion( + region_id=_region_id(item, "b", index), + kind=str(item.get("type") or item.get("kind") or "other"), + bbox=_coerce_bbox(item.get("bbox"), coordinate_space), + importance=str(item.get("importance") or ""), + group_id=str(item.get("group_id") or ""), + ) + for index, item in enumerate(_items(payload, "bitmap_regions"), start=1) + ], + shape_regions=[ + VLMShapeRegion( + region_id=_region_id(item, "s", index), + kind=str(item.get("type") or item.get("kind") or "other"), + bbox=_coerce_bbox(item.get("bbox"), coordinate_space), + importance=str(item.get("importance") or ""), + group_id=str(item.get("group_id") or ""), + ) + for index, item in enumerate(_items(payload, "shape_regions"), start=1) + ], + raw=dict(payload), + ) + + +def build_text_bitmap_mask( + analysis: VLMPageAnalysis, + mapper: VLMCoordinateMapper, + *, + padding: int = 4, + additional_text_bboxes: list[PixelBBox] | None = None, +) -> tuple[Image.Image, list[MaskBox]]: + mask = Image.new("L", mapper.source_image_size, 0) + draw = ImageDraw.Draw(mask) + boxes: list[MaskBox] = [] + for region in analysis.text_regions: + bbox = mapper.to_source_bbox(region.bbox, padding=padding) + draw.rectangle(bbox, fill=255) + boxes.append(MaskBox(kind="text", region_id=region.region_id, bbox=bbox)) + for region in analysis.bitmap_regions: + unpadded_bbox = mapper.to_source_bbox(region.bbox, padding=0) + if _bitmap_region_should_be_ignored_as_element(unpadded_bbox, mapper.source_image_size): + continue + bbox = mapper.to_source_bbox(region.bbox, padding=padding) + draw.rectangle(bbox, fill=255) + boxes.append(MaskBox(kind="bitmap", region_id=region.region_id, bbox=bbox)) + for index, bbox in enumerate(additional_text_bboxes or [], start=1): + clamped = _clamp_bbox(_pad_bbox(bbox, padding), mapper.source_image_size) + draw.rectangle(clamped, fill=255) + boxes.append(MaskBox(kind="ocr_text", region_id=f"ocr-{index}", bbox=clamped)) + return mask, boxes + + +def build_page_manifest_from_vlm_analysis( + *, + analysis: VLMPageAnalysis, + slide_id: str, + page_index: int, + source_image_path: str | Path, + clean_background_path: str | Path, + artifact_root: str | Path, + aspect_ratio: str = "16:9", + text_boxes: list[TextBoxSpec] | None = None, + bitmap_assets: list[BitmapAssetSpec] | None = None, + asset_sheets: list[AssetSheetSpec] | None = None, +) -> PageManifest: + root = Path(artifact_root).resolve() + source_path = Path(source_image_path).resolve() + background_path = Path(clean_background_path).resolve() + _require_inside(root, source_path, "source_image_path") + _require_inside(root, background_path, "clean_background_path") + with Image.open(source_path) as source, Image.open(background_path) as background: + source_image_size = source.size + source_rgb = source.convert("RGB") + background_rgb = background.convert("RGB") + mapper = VLMCoordinateMapper( + analysis_space=analysis.coordinate_space, + source_image_size=source_image_size, + ) + resolved_bitmap_assets = ( + bitmap_assets + if bitmap_assets is not None + else _bitmap_assets_from_vlm( + analysis, + mapper, + source_rgb, + background_rgb, + root=root, + slide_id=slide_id, + page_index=page_index, + ) + ) + vlm_text_boxes = [ + _text_box_from_vlm(region, mapper, source_rgb) + for region in analysis.text_regions + ] + resolved_text_boxes = _resolve_text_boxes_with_vlm_gate( + analysis=analysis, + mapper=mapper, + vlm_text_boxes=vlm_text_boxes, + ocr_text_boxes=text_boxes, + bitmap_assets=resolved_bitmap_assets, + source_image_size=source_image_size, + ) + native_shapes = [ + shape + for region in analysis.shape_regions + for shape in _native_shapes_from_vlm(region, mapper, source_image_size) + ] + return PageManifest( + slide_id=slide_id, + page_index=page_index, + source_image_path=_artifact_ref(source_path, root), + source_image_size=source_image_size, + slide_size=_slide_size_inches(aspect_ratio), + chosen_background=_artifact_ref(background_path, root), + text_boxes=resolved_text_boxes, + native_shapes=native_shapes, + bitmap_assets=resolved_bitmap_assets, + asset_sheets=asset_sheets or [], + provider_output_paths={}, + provenance={ + "reconstruction_strategy": "vlm_first", + "vlm_counts": { + "text_regions": len(analysis.text_regions), + "bitmap_regions": len(analysis.bitmap_regions), + "shape_regions": len(analysis.shape_regions), + }, + }, + ) + + +def _resolve_text_boxes_with_vlm_gate( + *, + analysis: VLMPageAnalysis, + mapper: VLMCoordinateMapper, + vlm_text_boxes: list[TextBoxSpec], + ocr_text_boxes: list[TextBoxSpec] | None, + bitmap_assets: list[BitmapAssetSpec], + source_image_size: tuple[int, int], +) -> list[TextBoxSpec]: + if ocr_text_boxes is None: + return _dedupe_overlapping_text_boxes( + _avoid_bitmap_text_overlap( + [ + box + for box in vlm_text_boxes + if not _vlm_text_should_stay_baked_into_bitmap(box, bitmap_assets) + ], + bitmap_assets, + source_image_size, + ) + ) + if not analysis.text_regions: + return [] + + resolved: list[TextBoxSpec] = [] + used_indexes: set[int] = set() + for region_index, region in enumerate(analysis.text_regions): + region_bbox = mapper.to_source_bbox(region.bbox, padding=8) + candidates = [ + (index, box, _ocr_match_score(region, region_bbox, box)) + for index, box in enumerate(ocr_text_boxes) + if index not in used_indexes + ] + matches = [ + (index, box, score) + for index, box, score in candidates + if score >= 0.35 + ] + if not matches: + if region_index < len(vlm_text_boxes): + vlm_box = vlm_text_boxes[region_index] + duplicate_ocr = _nearby_ocr_duplicate_for_vlm_fallback( + vlm_box, + [ + (index, box) + for index, box in enumerate(ocr_text_boxes) + if index not in used_indexes + ], + ) + if duplicate_ocr is not None: + duplicate_index, duplicate_box = duplicate_ocr + used_indexes.add(duplicate_index) + resolved.append(_ocr_text_with_ocr_layout(duplicate_box)) + continue + if _vlm_text_should_stay_baked_into_bitmap(vlm_box, bitmap_assets): + continue + if not _vlm_fallback_is_already_represented(vlm_box, resolved): + resolved.append(vlm_box) + continue + matches.sort( + key=lambda item: ( + item[1].source_pixel_bbox[1], + item[1].source_pixel_bbox[0], + -item[2], + ) + ) + region_matches = matches[:4] + for index, _box, _score in region_matches: + used_indexes.add(index) + if len(region_matches) > 1 and region_index < len(vlm_text_boxes): + vlm_box = vlm_text_boxes[region_index] + merged_ocr_box = _merged_ocr_text_box_for_guard( + [box for _index, box, _score in region_matches] + ) + if _should_keep_high_confidence_vlm_text_over_ocr(region, merged_ocr_box, vlm_box): + resolved.append(vlm_box) + continue + if len(region_matches) == 1 and region_index < len(vlm_text_boxes): + _index, box, _score = region_matches[0] + vlm_box = vlm_text_boxes[region_index] + if _should_keep_high_confidence_vlm_text_over_ocr(region, box, vlm_box): + resolved.append(vlm_box) + continue + if _is_short_text(box.text) or _should_keep_ocr_layout_for_exact_text(box, vlm_box): + resolved.append(_ocr_text_with_ocr_layout(box)) + else: + resolved.append(_ocr_text_with_vlm_layout(box, vlm_box)) + continue + for _index, box, _score in region_matches: + resolved.append(_ocr_text_with_ocr_layout(box)) + return _dedupe_overlapping_text_boxes( + _avoid_bitmap_text_overlap(resolved, bitmap_assets, source_image_size) + ) + + +def _vlm_fallback_is_already_represented( + vlm_box: TextBoxSpec, + existing_boxes: list[TextBoxSpec], +) -> bool: + normalized = _normalize_text_for_match(vlm_box.text) + if not normalized: + return False + for existing in existing_boxes: + if _normalize_text_for_match(existing.text) != normalized: + continue + overlap = _bbox_intersection_area(vlm_box.source_pixel_bbox, existing.source_pixel_bbox) + smaller_area = min(_bbox_area(vlm_box.source_pixel_bbox), _bbox_area(existing.source_pixel_bbox)) + if overlap / max(1, smaller_area) >= 0.45: + return True + if _text_boxes_are_nearby_ocr_vlm_duplicates(existing, vlm_box): + return True + return False + + +def _vlm_text_should_stay_baked_into_bitmap( + text_box: TextBoxSpec, + bitmap_assets: list[BitmapAssetSpec], +) -> bool: + if float(text_box.provenance.get("confidence", 1.0) or 0.0) >= 0.80: + return False + if text_box.style_hints.get("vlm_role") not in {"label", "body"}: + return False + if len(_normalize_text_for_match(text_box.text)) > 8: + return False + return any( + _bitmap_asset_is_major_visual(asset) + and _text_box_is_inside_or_near_bitmap(text_box.source_pixel_bbox, asset.source_pixel_bbox) + for asset in bitmap_assets + ) + + +def _bitmap_asset_is_major_visual(asset: BitmapAssetSpec) -> bool: + provenance = dict(asset.provenance) + kind = str( + provenance.get("vlm_type") + or (provenance.get("candidate_provenance") or {}).get("vlm_type") + or "" + ).lower() + return kind in {"product", "photo", "component"} + + +def _text_box_is_inside_or_near_bitmap(text_bbox: PixelBBox, asset_bbox: PixelBBox) -> bool: + text_center_y = (text_bbox[1] + text_bbox[3]) / 2.0 + asset_height = max(1, asset_bbox[3] - asset_bbox[1]) + if not (asset_bbox[1] - asset_height * 0.10 <= text_center_y <= asset_bbox[3] + asset_height * 0.10): + return False + horizontal_overlap = min(text_bbox[2], asset_bbox[2]) - max(text_bbox[0], asset_bbox[0]) + if horizontal_overlap > 0: + return True + horizontal_gap = max(asset_bbox[0] - text_bbox[2], text_bbox[0] - asset_bbox[2]) + return horizontal_gap <= max(32, round((asset_bbox[2] - asset_bbox[0]) * 0.08)) + + +def _nearby_ocr_duplicate_for_vlm_fallback( + vlm_box: TextBoxSpec, + ocr_candidates: list[tuple[int, TextBoxSpec]], +) -> tuple[int, TextBoxSpec] | None: + matches = [ + (index, box) + for index, box in ocr_candidates + if _text_boxes_are_nearby_ocr_vlm_duplicates(box, vlm_box) + ] + if not matches: + return None + matches.sort(key=lambda item: _text_box_quality_score(item[1]), reverse=True) + return matches[0] + + +def _text_boxes_are_nearby_ocr_vlm_duplicates( + ocr_box: TextBoxSpec, + vlm_box: TextBoxSpec, +) -> bool: + if not _normalized_texts_are_exact_duplicate(ocr_box.text, vlm_box.text): + return False + if ocr_box.provenance.get("content_source") != "ocr": + return False + ocr_bbox = ocr_box.source_pixel_bbox + vlm_bbox = vlm_box.source_pixel_bbox + horizontal_overlap = min(ocr_bbox[2], vlm_bbox[2]) - max(ocr_bbox[0], vlm_bbox[0]) + if horizontal_overlap <= 0: + return False + smaller_width = min(max(1, ocr_bbox[2] - ocr_bbox[0]), max(1, vlm_bbox[2] - vlm_bbox[0])) + if horizontal_overlap / smaller_width < 0.55: + return False + if _bbox_intersection_area(ocr_bbox, vlm_bbox) > 0: + return True + ocr_width = max(1, ocr_bbox[2] - ocr_bbox[0]) + ocr_height = max(1, ocr_bbox[3] - ocr_bbox[1]) + vlm_width = max(1, vlm_bbox[2] - vlm_bbox[0]) + vlm_height = max(1, vlm_bbox[3] - vlm_bbox[1]) + if vlm_width / ocr_width < 1.45 and vlm_height / ocr_height < 1.8: + return False + vertical_gap = max(ocr_bbox[1], vlm_bbox[1]) - min(ocr_bbox[3], vlm_bbox[3]) + max_height = max(ocr_height, vlm_height) + return 0 <= vertical_gap <= max_height * 1.25 + + +def _ocr_text_with_vlm_layout(ocr_box: TextBoxSpec, vlm_box: TextBoxSpec) -> TextBoxSpec: + provenance = dict(ocr_box.provenance) + provenance["content_source"] = provenance.get("content_source") or "ocr" + provenance["layout_source"] = "vlm" + provenance["ocr_layout_bbox"] = list(ocr_box.source_pixel_bbox) + style_hints = dict(vlm_box.style_hints) + style_hints.update(ocr_box.style_hints) + style_hints["approximate_layout"] = True + return replace( + ocr_box, + source_pixel_bbox=vlm_box.source_pixel_bbox, + source_pixel_polygon=vlm_box.source_pixel_polygon, + font_size=vlm_box.font_size, + alignment=vlm_box.alignment, + style_hints=style_hints, + provenance=provenance, + ) + + +def _should_keep_high_confidence_vlm_text_over_ocr( + region: VLMTextRegion, + ocr_box: TextBoxSpec, + vlm_box: TextBoxSpec, +) -> bool: + if region.confidence < 0.90: + return False + if float(ocr_box.provenance.get("ocr_confidence", 0.0) or 0.0) >= 0.90: + return False + if _low_confidence_ocr_text_is_safe_for_high_confidence_vlm(region.text, ocr_box.text): + return False + return bool(_normalize_text_for_match(vlm_box.text)) + + +def _merged_ocr_text_box_for_guard(ocr_boxes: list[TextBoxSpec]) -> TextBoxSpec: + if not ocr_boxes: + raise ValueError("ocr_boxes are required") + ordered = sorted(ocr_boxes, key=lambda box: (box.source_pixel_bbox[1], box.source_pixel_bbox[0])) + left = min(box.source_pixel_bbox[0] for box in ordered) + top = min(box.source_pixel_bbox[1] for box in ordered) + right = max(box.source_pixel_bbox[2] for box in ordered) + bottom = max(box.source_pixel_bbox[3] for box in ordered) + confidences = [ + float(box.provenance.get("ocr_confidence", 0.0) or 0.0) + for box in ordered + ] + provenance = dict(ordered[0].provenance) + provenance["ocr_confidence"] = max(confidences) if confidences else 0.0 + provenance["merged_ocr_fragment_count"] = len(ordered) + return replace( + ordered[0], + text="".join(box.text for box in ordered), + source_pixel_bbox=(left, top, right, bottom), + source_pixel_polygon=_bbox_polygon((left, top, right, bottom)), + provenance=provenance, + ) + + +def _low_confidence_ocr_text_is_safe_for_high_confidence_vlm(vlm_text: str, ocr_text: str) -> bool: + normalized_vlm = _normalize_text_for_match(vlm_text) + normalized_ocr = _normalize_text_for_match(ocr_text) + if not normalized_vlm or not normalized_ocr: + return False + if normalized_vlm == normalized_ocr: + return True + if normalized_vlm in normalized_ocr: + return len(normalized_vlm) / max(1, len(normalized_ocr)) >= 0.92 + if normalized_ocr in normalized_vlm: + return len(normalized_ocr) / max(1, len(normalized_vlm)) >= 0.92 + return _text_similarity(vlm_text, ocr_text) >= 0.82 + + +def _ocr_text_with_ocr_layout(ocr_box: TextBoxSpec) -> TextBoxSpec: + provenance = dict(ocr_box.provenance) + provenance["content_source"] = provenance.get("content_source") or "ocr" + provenance["layout_source"] = "ocr" + return replace(ocr_box, provenance=provenance) + + +def _is_short_text(text: str) -> bool: + return 0 < len(_normalize_text_for_match(text)) <= 3 + + +def _should_keep_ocr_layout_for_exact_text(ocr_box: TextBoxSpec, vlm_box: TextBoxSpec) -> bool: + if not _normalized_texts_are_exact_duplicate(ocr_box.text, vlm_box.text): + return False + overlap = _bbox_intersection_area(ocr_box.source_pixel_bbox, vlm_box.source_pixel_bbox) + smaller_area = min(_bbox_area(ocr_box.source_pixel_bbox), _bbox_area(vlm_box.source_pixel_bbox)) + if overlap / max(1, smaller_area) >= 0.45: + return False + return _vertical_center_distance(ocr_box.source_pixel_bbox, vlm_box.source_pixel_bbox) <= max( + ocr_box.source_pixel_bbox[3] - ocr_box.source_pixel_bbox[1], + vlm_box.source_pixel_bbox[3] - vlm_box.source_pixel_bbox[1], + ) + + +def _vertical_center_distance(left: PixelBBox, right: PixelBBox) -> float: + return abs(((left[1] + left[3]) / 2.0) - ((right[1] + right[3]) / 2.0)) + + +def _avoid_bitmap_text_overlap( + text_boxes: list[TextBoxSpec], + bitmap_assets: list[BitmapAssetSpec], + source_image_size: tuple[int, int], +) -> list[TextBoxSpec]: + if not text_boxes or not bitmap_assets: + return text_boxes + margin = max(8, round(source_image_size[0] * 0.01)) + adjusted: list[TextBoxSpec] = [] + for text_box in text_boxes: + if text_box.provenance.get("layout_source") == "ocr" and _is_short_text(text_box.text): + adjusted.append(text_box) + continue + bbox = text_box.source_pixel_bbox + width = bbox[2] - bbox[0] + shifted_left = bbox[0] + for asset in bitmap_assets: + asset_bbox = asset.source_pixel_bbox + if not _same_horizontal_band(bbox, asset_bbox): + continue + if _horizontal_overlap_ratio(bbox, asset_bbox) < 0.25: + continue + if asset_bbox[0] > (bbox[0] + bbox[2]) / 2: + continue + if asset_bbox[2] + margin <= shifted_left: + continue + shifted_left = max(shifted_left, asset_bbox[2] + margin) + if shifted_left == bbox[0]: + adjusted.append(text_box) + continue + shifted_right = min(source_image_size[0], shifted_left + width) + shifted_left = max(0, shifted_right - width) + shifted_bbox = (shifted_left, bbox[1], shifted_right, bbox[3]) + adjusted.append( + replace( + text_box, + source_pixel_bbox=shifted_bbox, + source_pixel_polygon=_bbox_polygon(shifted_bbox), + ) + ) + return adjusted + + +def _horizontal_overlap_ratio(left: PixelBBox, right: PixelBBox) -> float: + overlap = min(left[2], right[2]) - max(left[0], right[0]) + if overlap <= 0: + return 0.0 + return overlap / max(1, left[2] - left[0]) + + +def _dedupe_overlapping_text_boxes(text_boxes: list[TextBoxSpec]) -> list[TextBoxSpec]: + kept: list[TextBoxSpec] = [] + for candidate in text_boxes: + duplicate_index = next( + ( + index + for index, existing in enumerate(kept) + if _text_boxes_are_overlapping_duplicates(existing, candidate) + ), + None, + ) + if duplicate_index is None: + kept.append(candidate) + continue + if _text_box_quality_score(candidate) > _text_box_quality_score(kept[duplicate_index]): + kept[duplicate_index] = candidate + return kept + + +def _text_boxes_are_overlapping_duplicates(left: TextBoxSpec, right: TextBoxSpec) -> bool: + if not _normalized_texts_are_exact_duplicate(left.text, right.text): + return False + overlap = _bbox_intersection_area(left.source_pixel_bbox, right.source_pixel_bbox) + if overlap <= 0: + return False + smaller_area = min(_bbox_area(left.source_pixel_bbox), _bbox_area(right.source_pixel_bbox)) + larger_area = max(_bbox_area(left.source_pixel_bbox), _bbox_area(right.source_pixel_bbox)) + return ( + overlap / max(1, smaller_area) >= 0.70 + and overlap / max(1, larger_area) >= 0.45 + ) + + +def _normalized_texts_are_exact_duplicate(left: str, right: str) -> bool: + normalized_left = _normalize_text_for_match(left) + normalized_right = _normalize_text_for_match(right) + if normalized_left != normalized_right: + return False + return len(normalized_left) >= 4 + + +def _text_box_quality_score(text_box: TextBoxSpec) -> float: + provenance = text_box.provenance + score = float(provenance.get("ocr_confidence", 0.0) or 0.0) + if provenance.get("content_source") == "ocr": + score += 100.0 + if provenance.get("layout_source") == "ocr": + score += 5.0 + if provenance.get("layout_source") == "vlm": + score += 3.0 + return score + + +def _same_horizontal_band(left: PixelBBox, right: PixelBBox) -> bool: + top = max(left[1], right[1]) + bottom = min(left[3], right[3]) + if bottom <= top: + return False + text_height = max(1, left[3] - left[1]) + return (bottom - top) / text_height >= 0.45 + + +def _foreground_rgba_crop( + source_rgb: Image.Image, + background_rgb: Image.Image, + bbox: PixelBBox, + *, + tolerance: int = 36, + transparent_bboxes: list[PixelBBox] | None = None, +) -> Image.Image: + source_crop = source_rgb.crop(bbox).convert("RGBA") + background_crop = background_rgb.crop(bbox).convert("RGB") + source_pixels = source_crop.load() + background_pixels = background_crop.load() + width, height = source_crop.size + visited: set[tuple[int, int]] = set() + queue: deque[tuple[int, int]] = deque() + for x in range(width): + queue.append((x, 0)) + queue.append((x, height - 1)) + for y in range(height): + queue.append((0, y)) + queue.append((width - 1, y)) + + while queue: + x, y = queue.popleft() + if (x, y) in visited or not (0 <= x < width and 0 <= y < height): + continue + visited.add((x, y)) + if not _pixel_matches_background(source_pixels[x, y], background_pixels[x, y], tolerance): + continue + source_pixel = source_pixels[x, y] + source_pixels[x, y] = (source_pixel[0], source_pixel[1], source_pixel[2], 0) + queue.extend(((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1))) + _clear_crop_regions(source_crop, bbox, transparent_bboxes or []) + return source_crop + + +def _clear_crop_regions(crop: Image.Image, crop_bbox: PixelBBox, transparent_bboxes: list[PixelBBox]) -> None: + if not transparent_bboxes: + return + pixels = crop.load() + width, height = crop.size + for box in transparent_bboxes: + left = max(0, int(box[0]) - crop_bbox[0]) + top = max(0, int(box[1]) - crop_bbox[1]) + right = min(width, int(box[2]) - crop_bbox[0]) + bottom = min(height, int(box[3]) - crop_bbox[1]) + if right <= left or bottom <= top: + continue + for y in range(top, bottom): + for x in range(left, right): + pixel = pixels[x, y] + pixels[x, y] = (pixel[0], pixel[1], pixel[2], 0) + + +def _pixel_matches_background(source_pixel: tuple[int, ...], background_pixel: tuple[int, ...], tolerance: int) -> bool: + delta = ( + abs(source_pixel[0] - background_pixel[0]) + + abs(source_pixel[1] - background_pixel[1]) + + abs(source_pixel[2] - background_pixel[2]) + ) + return delta <= tolerance + + +def _create_vlm_asset_sheet_assets( + *, + analysis: VLMPageAnalysis, + mapper: VLMCoordinateMapper, + source_image_path: str | Path, + artifacts: GenerativeEditableJobArtifacts, + slide_id: str, + page_index: int, + edit_provider: ImageEditProvider, + timeout_seconds: int, +) -> tuple[list[BitmapAssetSpec], list[AssetSheetSpec], Path | None]: + text_bboxes = [ + mapper.to_source_bbox(region.bbox, padding=8) + for region in analysis.text_regions + ] + candidates = _foreground_candidates_from_vlm_bitmap_regions(analysis, mapper) + candidates = _refine_candidates_with_clean_background_difference( + candidates=candidates, + source_image_path=source_image_path, + clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + ) + candidates = _bridge_narrow_same_group_complex_candidates(candidates) + if not candidates: + return [], [], None + source_preserved_candidates = [ + candidate for candidate in candidates if not _candidate_should_use_asset_sheet(candidate) + ] + asset_sheet_candidates = [ + candidate for candidate in candidates if _candidate_should_use_asset_sheet(candidate) + ] + source_preserved_assets = _source_preserved_bitmap_assets_from_candidates( + candidates=source_preserved_candidates, + source_image_path=source_image_path, + clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + artifact_root=artifacts.job_dir, + slide_id=slide_id, + page_index=page_index, + reason="complex_bitmap_region", + asset_sheet_skipped_reason="complex_bitmap_region", + transparent_bboxes=text_bboxes, + ) + if not asset_sheet_candidates: + return _with_candidate_z_order(source_preserved_assets, candidates), [], None + sheet_path = artifacts.asset_path(slide_id, page_index, "asset_sheets", "vlm-asset-sheet.png") + request = build_asset_sheet_request( + source_image_path=source_image_path, + candidates=asset_sheet_candidates, + output_asset_path=sheet_path, + asset_root=artifacts.job_dir, + provider_role=edit_provider.config.role, + timeout_seconds=timeout_seconds, + ) + try: + result = edit_provider.edit(request) + except ProviderError as exc: + assets = _source_preserved_bitmap_assets_from_candidates( + candidates=asset_sheet_candidates, + source_image_path=source_image_path, + clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + artifact_root=artifacts.job_dir, + slide_id=slide_id, + page_index=page_index, + reason=str(exc), + asset_sheet_provider_failed=True, + transparent_bboxes=text_bboxes, + ) + provider_output_path = artifacts.write_provider_output( + slide_id, + page_index, + "asset_sheet", + "asset-sheet.json", + { + "status": "source_preserved_fallback", + "error_type": type(exc).__name__, + "error": str(exc), + "bitmap_assets": [asdict(asset) for asset in assets], + "candidate_ids": [candidate.candidate_id for candidate in asset_sheet_candidates], + }, + ) + combined_assets = _with_candidate_z_order(source_preserved_assets + assets, candidates) + return combined_assets, [], provider_output_path + try: + assets = slice_asset_sheet_by_components( + sheet_path=result.output_asset_path, + candidates=asset_sheet_candidates, + output_dir=artifacts.job_dir / "assets" / f"{page_index:04d}-{_safe_name(slide_id)}", + asset_root=artifacts.job_dir, + ) + except ValueError as exc: + assets = _source_preserved_bitmap_assets_from_candidates( + candidates=asset_sheet_candidates, + source_image_path=source_image_path, + clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + artifact_root=artifacts.job_dir, + slide_id=slide_id, + page_index=page_index, + reason=str(exc), + asset_sheet_slicing_failed=True, + transparent_bboxes=text_bboxes, + ) + provider_output_path = artifacts.write_provider_output( + slide_id, + page_index, + "asset_sheet", + "asset-sheet.json", + { + "status": "source_preserved_fallback", + "error_type": type(exc).__name__, + "error": str(exc), + "sheet_path": _artifact_ref(Path(result.output_asset_path), artifacts.job_dir), + "bitmap_assets": [asdict(asset) for asset in _with_candidate_z_order(source_preserved_assets + assets, candidates)], + "candidate_ids": [candidate.candidate_id for candidate in asset_sheet_candidates], + "provider_role": result.provider_role, + "provider_name": result.provider_name, + "model": result.model, + "prompt_id": result.prompt_id, + }, + ) + combined_assets = _with_candidate_z_order(source_preserved_assets + assets, candidates) + return combined_assets, [], provider_output_path + assets = _select_source_faithful_bitmap_assets( + generated_assets=assets, + source_image_path=source_image_path, + clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + artifact_root=artifacts.job_dir, + slide_id=slide_id, + page_index=page_index, + transparent_bboxes=text_bboxes, + ) + combined_assets = _with_candidate_z_order(source_preserved_assets + assets, candidates) + asset_sheet = AssetSheetSpec( + sheet_id=f"vlm-asset-sheet-{page_index:04d}", + asset_path=_artifact_ref(Path(result.output_asset_path), artifacts.job_dir), + prompt_id=result.prompt_id, + candidate_ids=[candidate.candidate_id for candidate in asset_sheet_candidates], + provider_role=result.provider_role, + provenance={ + "provider": result.provider_name, + "model": result.model, + "source": "vlm_bitmap_regions", + "candidate_count": len(asset_sheet_candidates), + "source_preserved_candidate_count": len(source_preserved_candidates), + }, + ) + provider_output_path = artifacts.write_provider_output( + slide_id, + page_index, + "asset_sheet", + "asset-sheet.json", + { + "sheet": asdict(asset_sheet), + "bitmap_assets": [asdict(asset) for asset in combined_assets], + "candidate_ids": [candidate.candidate_id for candidate in asset_sheet_candidates], + "provider_role": result.provider_role, + "provider_name": result.provider_name, + "model": result.model, + "prompt_id": result.prompt_id, + }, + ) + return combined_assets, [asset_sheet], provider_output_path + + +def _candidate_should_use_asset_sheet(candidate: ForegroundCandidate) -> bool: + return candidate.classification == "bitmap_asset_candidate" + + +def _with_candidate_z_order( + assets: list[BitmapAssetSpec], + candidates: list[ForegroundCandidate], +) -> list[BitmapAssetSpec]: + order = {candidate.candidate_id: index for index, candidate in enumerate(candidates, start=1)} + return [ + replace(asset, z_order=order.get(asset.asset_id, asset.z_order)) + for asset in sorted(assets, key=lambda item: order.get(item.asset_id, item.z_order)) + ] + + +def _provenance_with_optional_asset_sheet_status( + *, + base: dict[str, Any], + provider_failed: bool, + slicing_failed: bool, + skipped_reason: str, + reason: str, +) -> dict[str, Any]: + provenance = dict(base) + if provider_failed: + provenance["asset_sheet_provider_failed"] = True + provenance["asset_sheet_error"] = reason + if slicing_failed: + provenance["asset_sheet_slicing_failed"] = True + provenance["asset_sheet_error"] = reason + if skipped_reason: + provenance["asset_sheet_skipped_reason"] = skipped_reason + return provenance + + +def _source_preserved_bitmap_assets_from_candidates( + *, + candidates: list[ForegroundCandidate], + source_image_path: str | Path, + clean_background_path: str | Path, + artifact_root: str | Path, + slide_id: str, + page_index: int, + reason: str, + asset_sheet_provider_failed: bool = False, + asset_sheet_slicing_failed: bool = False, + asset_sheet_skipped_reason: str = "", + fallback_padding: int = 8, + transparent_bboxes: list[PixelBBox] | None = None, +) -> list[BitmapAssetSpec]: + if not candidates: + return [] + root = Path(artifact_root).resolve() + output_dir = root / "assets" / f"{page_index:04d}-{_safe_name(slide_id)}" + output_dir.mkdir(parents=True, exist_ok=True) + assets: list[BitmapAssetSpec] = [] + with Image.open(source_image_path) as source_image, Image.open(clean_background_path) as background_image: + source_rgb = source_image.convert("RGB") + background_rgb = background_image.convert("RGB").resize(source_rgb.size) + for index, candidate in enumerate(candidates, start=1): + bbox = _clamp_bbox( + _pad_bbox(candidate.source_pixel_bbox, fallback_padding), + source_rgb.size, + ) + crop = _foreground_rgba_crop( + source_rgb, + background_rgb, + bbox, + transparent_bboxes=transparent_bboxes or [], + ) + visible_pixel_count = _alpha_visible_pixel_count(crop) + source_area = max(1, source_rgb.size[0] * source_rgb.size[1]) + output_path = output_dir / f"{_safe_name(candidate.candidate_id)}.source-preserved.png" + crop.save(output_path) + assets.append( + BitmapAssetSpec( + asset_id=candidate.candidate_id, + source_pixel_bbox=bbox, + asset_path=_artifact_ref(output_path, root), + z_order=index, + provenance=_provenance_with_optional_asset_sheet_status( + base={ + "candidate_id": candidate.candidate_id, + "candidate_classification": candidate.classification, + "candidate_provenance": dict(candidate.provenance), + "asset_strategy": "source_preserved_crop", + "background_difference_alpha": True, + "alpha_visible_pixel_count": visible_pixel_count, + "alpha_visible_area_ratio": round(visible_pixel_count / float(source_area), 6), + "original_source_pixel_bbox": list(candidate.source_pixel_bbox), + }, + provider_failed=asset_sheet_provider_failed, + slicing_failed=asset_sheet_slicing_failed, + skipped_reason=asset_sheet_skipped_reason, + reason=reason, + ), + ) + ) + return assets + + +def _alpha_visible_pixel_count(image: Image.Image) -> int: + alpha = image.convert("RGBA").getchannel("A") + histogram = alpha.histogram() + return sum(histogram[1:]) + + +def _select_source_faithful_bitmap_assets( + *, + generated_assets: list[BitmapAssetSpec], + source_image_path: str | Path, + clean_background_path: str | Path, + artifact_root: str | Path, + slide_id: str, + page_index: int, + generated_headroom: float = 0.9, + fallback_padding: int = 8, + transparent_bboxes: list[PixelBBox] | None = None, +) -> list[BitmapAssetSpec]: + if not generated_assets: + return [] + root = Path(artifact_root).resolve() + source_path = Path(source_image_path).resolve() + background_path = Path(clean_background_path).resolve() + output_dir = root / "assets" / f"{page_index:04d}-{_safe_name(slide_id)}" + output_dir.mkdir(parents=True, exist_ok=True) + selected: list[BitmapAssetSpec] = [] + with Image.open(source_path) as source_image, Image.open(background_path) as background_image: + source_rgb = source_image.convert("RGB") + background_rgb = background_image.convert("RGB").resize(source_rgb.size) + for asset in generated_assets: + generated_path = root / asset.asset_path + source_crop = _foreground_rgba_crop( + source_rgb, + background_rgb, + asset.source_pixel_bbox, + transparent_bboxes=transparent_bboxes or [], + ) + source_score = _asset_reconstruction_delta(source_crop, source_rgb, background_rgb, asset.source_pixel_bbox) + with Image.open(generated_path) as generated_image: + generated_rgba = generated_image.convert("RGBA") + generated_score = _asset_reconstruction_delta( + generated_rgba, + source_rgb, + background_rgb, + asset.source_pixel_bbox, + ) + if generated_score <= source_score * generated_headroom: + selected.append( + replace( + asset, + provenance={ + **asset.provenance, + "asset_strategy": "asset_sheet", + "asset_sheet_score": round(generated_score, 4), + "source_preserved_score": round(source_score, 4), + }, + ) + ) + continue + fallback_bbox = _clamp_bbox( + _pad_bbox(asset.source_pixel_bbox, fallback_padding), + source_rgb.size, + ) + fallback_crop = _foreground_rgba_crop( + source_rgb, + background_rgb, + fallback_bbox, + transparent_bboxes=transparent_bboxes or [], + ) + output_path = output_dir / f"{_safe_name(asset.asset_id)}.source-preserved.png" + fallback_crop.save(output_path) + selected.append( + replace( + asset, + source_pixel_bbox=fallback_bbox, + asset_path=_artifact_ref(output_path, root), + provenance={ + **asset.provenance, + "asset_strategy": "source_preserved_crop", + "asset_sheet_qa_failed": True, + "original_source_pixel_bbox": list(asset.source_pixel_bbox), + "asset_sheet_score": round(generated_score, 4), + "source_preserved_score": round(source_score, 4), + }, + ) + ) + return selected + + +def _asset_reconstruction_delta( + asset_rgba: Image.Image, + source_rgb: Image.Image, + background_rgb: Image.Image, + bbox: PixelBBox, +) -> float: + left, top, right, bottom = bbox + width = max(1, right - left) + height = max(1, bottom - top) + target = source_rgb.crop(bbox).convert("RGB") + base = background_rgb.crop(bbox).convert("RGBA") + asset = asset_rgba.convert("RGBA") + if asset.size != (width, height): + asset = asset.resize((width, height), Image.Resampling.LANCZOS) + base.alpha_composite(asset, (0, 0)) + diff = ImageChops.difference(target, base.convert("RGB")) + return sum(ImageStat.Stat(diff).mean) / 3.0 + + +def _foreground_candidates_from_vlm_bitmap_regions( + analysis: VLMPageAnalysis, + mapper: VLMCoordinateMapper, +) -> list[ForegroundCandidate]: + candidates: list[ForegroundCandidate] = [] + for region in analysis.bitmap_regions: + bbox = mapper.to_source_bbox(region.bbox, padding=0) + if _bitmap_region_should_be_ignored_as_element(bbox, mapper.source_image_size): + continue + classification = ( + "complex_whole_visual" + if region.kind.lower() in {"photo", "product", "component", "qr", "complex", "other"} + else "bitmap_asset_candidate" + ) + candidates.append( + ForegroundCandidate( + candidate_id=region.region_id, + source_pixel_bbox=bbox, + area=(bbox[2] - bbox[0]) * (bbox[3] - bbox[1]), + classification=classification, + confidence=0.8, + provenance={ + "provider_role": "VLM", + "vlm_type": region.kind, + "importance": region.importance, + "group_id": region.group_id, + "source": "vlm_bitmap_region", + }, + ) + ) + return candidates + + +def _refine_candidates_with_clean_background_difference( + *, + candidates: list[ForegroundCandidate], + source_image_path: str | Path, + clean_background_path: str | Path, + search_padding: int = 24, + diff_threshold: int = 18, + min_pixels: int = 16, +) -> list[ForegroundCandidate]: + if not candidates: + return [] + refined: list[ForegroundCandidate] = [] + with Image.open(source_image_path) as source_image, Image.open(clean_background_path) as background_image: + source_rgb = source_image.convert("RGB") + background_rgb = background_image.convert("RGB").resize(source_rgb.size) + image_size = source_rgb.size + for candidate in candidates: + search_bbox = _clamp_bbox( + _pad_bbox(candidate.source_pixel_bbox, search_padding), + image_size, + ) + diff_bbox = _foreground_difference_bbox( + source_rgb.crop(search_bbox), + background_rgb.crop(search_bbox), + offset=(search_bbox[0], search_bbox[1]), + threshold=diff_threshold, + min_pixels=min_pixels, + ) + if diff_bbox is None: + refined.append(candidate) + continue + provenance = dict(candidate.provenance or {}) + provenance["original_vlm_bbox"] = list(candidate.source_pixel_bbox) + provenance["bbox_refinement"] = "source_clean_background_difference" + refined.append( + replace( + candidate, + source_pixel_bbox=diff_bbox, + area=(diff_bbox[2] - diff_bbox[0]) * (diff_bbox[3] - diff_bbox[1]), + provenance=provenance, + ) + ) + return refined + + +def _bridge_narrow_same_group_complex_candidates( + candidates: list[ForegroundCandidate], + *, + max_gap_ratio: float = 0.35, + narrow_width_ratio: float = 0.65, +) -> list[ForegroundCandidate]: + if len(candidates) < 2: + return candidates + bridged = list(candidates) + indexed = list(enumerate(candidates)) + for current_position, current in indexed: + if current.classification != "complex_whole_visual": + continue + current_group = str((current.provenance or {}).get("group_id") or "") + if not current_group: + continue + current_bbox = current.source_pixel_bbox + current_width = current_bbox[2] - current_bbox[0] + current_height = current_bbox[3] - current_bbox[1] + same_row_left_neighbors = [ + (position, candidate) + for position, candidate in indexed + if position != current_position + and candidate.classification == "complex_whole_visual" + and str((candidate.provenance or {}).get("group_id") or "") == current_group + and candidate.source_pixel_bbox[2] <= current_bbox[0] + and _same_horizontal_band(current_bbox, candidate.source_pixel_bbox) + ] + if not same_row_left_neighbors: + continue + _neighbor_position, neighbor = max( + same_row_left_neighbors, + key=lambda item: item[1].source_pixel_bbox[2], + ) + neighbor_bbox = neighbor.source_pixel_bbox + neighbor_width = neighbor_bbox[2] - neighbor_bbox[0] + gap = current_bbox[0] - neighbor_bbox[2] + if gap <= 0: + continue + if current_width >= neighbor_width * narrow_width_ratio: + continue + if gap > neighbor_width * max_gap_ratio: + continue + bridge_overlap = max(16, min(48, round(max(current_height, neighbor_bbox[3] - neighbor_bbox[1]) * 0.15))) + bridged_left = max(neighbor_bbox[2] - bridge_overlap, 0) + if bridged_left >= current_bbox[0]: + continue + provenance = dict(current.provenance or {}) + provenance["bbox_refinement"] = "same_group_row_bridge" + provenance["pre_bridge_bbox"] = list(current_bbox) + provenance["bridge_neighbor_id"] = neighbor.candidate_id + bridged_bbox = (bridged_left, current_bbox[1], current_bbox[2], current_bbox[3]) + bridged[current_position] = replace( + current, + source_pixel_bbox=bridged_bbox, + area=(bridged_bbox[2] - bridged_bbox[0]) * (bridged_bbox[3] - bridged_bbox[1]), + provenance=provenance, + ) + return bridged + + +def _foreground_difference_bbox( + source_crop: Image.Image, + background_crop: Image.Image, + *, + offset: tuple[int, int], + threshold: int, + min_pixels: int, +) -> PixelBBox | None: + diff = ImageChops.difference(source_crop.convert("RGB"), background_crop.convert("RGB")) + points: list[tuple[int, int]] = [] + pixels = diff.load() + for y in range(diff.height): + for x in range(diff.width): + if max(pixels[x, y]) > threshold: + points.append((x + offset[0], y + offset[1])) + if len(points) < min_pixels: + return None + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return (min(xs), min(ys), max(xs) + 1, max(ys) + 1) + + +def _bitmap_assets_from_vlm( + analysis: VLMPageAnalysis, + mapper: VLMCoordinateMapper, + source_rgb: Image.Image, + background_rgb: Image.Image, + *, + root: Path, + slide_id: str, + page_index: int, +) -> list[BitmapAssetSpec]: + output_dir = root / "assets" / f"{page_index:04d}-{_safe_name(slide_id)}" + output_dir.mkdir(parents=True, exist_ok=True) + assets: list[BitmapAssetSpec] = [] + for z_order, region in enumerate(analysis.bitmap_regions, start=1): + bbox = mapper.to_source_bbox(region.bbox, padding=0) + if _area_ratio(bbox, mapper.source_image_size) < 0.0004: + continue + output_path = output_dir / f"{_safe_name(region.region_id)}.png" + _foreground_rgba_crop(source_rgb, background_rgb, bbox).save(output_path) + assets.append( + BitmapAssetSpec( + asset_id=region.region_id, + source_pixel_bbox=bbox, + asset_path=_artifact_ref(output_path, root), + z_order=z_order, + provenance={ + "source_type": "vlm_source_crop", + "vlm_type": region.kind, + "group_id": region.group_id, + }, + ) + ) + return assets + + +def _create_vlm_clean_background( + *, + source_image_path: str | Path, + mask_path: str | Path, + output_asset_path: str | Path, + asset_root: str | Path, + edit_provider: ImageEditProvider, + timeout_seconds: int, + cleanup_bboxes: list[PixelBBox] | None = None, +) -> BackgroundResult: + source_path = Path(source_image_path) + output_path = Path(output_asset_path) + root = Path(asset_root).resolve() + request = ImageEditRequest( + source_image_path=str(source_path), + prompt_id="vlm_clean_background", + prompt=( + "Remove only the masked text, icons, products, QR codes, and foreground bitmap " + "objects from this presentation slide. Reconstruct the underlying presentation " + "background, panel surfaces, grid lines, dividers, shadows, and glow. " + "Masked text regions must contain no readable residual glyphs, strokes, halos, " + "or duplicate letters after editing. " + "Preserve every unmasked pixel as much as possible. Do not add words, icons, " + "products, QR codes, labels, people, logos, or new decorations. Return the " + "edited image only, with the same size and composition." + ), + output_asset_path=str(output_path), + asset_root=str(root), + mask_path=str(mask_path), + timeout_seconds=timeout_seconds, + metadata={ + "source_image_ref": _artifact_ref(source_path.resolve(), root), + "mask_ref": _artifact_ref(Path(mask_path).resolve(), root), + "stage": "vlm_clean_background", + }, + ) + try: + result = edit_provider.edit(request) + except ProviderError as exc: + return _create_local_vlm_clean_background_fallback( + source_image_path=source_path, + output_asset_path=output_path, + asset_root=root, + cleanup_bboxes=cleanup_bboxes or [], + provider_error=exc, + ) + _normalize_image_to_source_size(result.output_asset_path, source_path) + return BackgroundResult( + output_asset_path=result.output_asset_path, + artifact_path=_artifact_ref(output_path.resolve(), root), + strategy="image_edit", + provider_role=result.provider_role, + prompt_id=result.prompt_id, + input_asset_refs=[ + _artifact_ref(source_path.resolve(), root), + _artifact_ref(Path(mask_path).resolve(), root), + ], + validation_status="passed", + provenance={ + "decision": "vlm mask cleanup for text and foreground bitmap removal", + "provider": result.provider_name, + "model": result.model, + "prompt_id": result.prompt_id, + }, + ) + + +def _create_local_vlm_clean_background_fallback( + *, + source_image_path: str | Path, + output_asset_path: str | Path, + asset_root: str | Path, + cleanup_bboxes: list[PixelBBox], + provider_error: ProviderError, +) -> BackgroundResult: + result = create_source_preserving_text_background( + source_image_path=source_image_path, + text_bboxes=cleanup_bboxes, + output_asset_path=output_asset_path, + asset_root=asset_root, + ) + return replace( + result, + prompt_id="vlm_clean_background_local_fallback", + provenance={ + **result.provenance, + "decision": "local bbox cleanup after vlm clean background provider failed", + "provider_failed": True, + "provider_error_type": provider_error.__class__.__name__, + "provider_error": safe_provider_error_message(str(provider_error)), + "cleanup_bbox_count": len(cleanup_bboxes), + }, + ) + + +def _normalize_image_to_source_size(image_path: str | Path, source_image_path: str | Path) -> None: + with Image.open(source_image_path) as source, Image.open(image_path) as image: + source_size = source.size + if image.size == source_size: + return + normalized = image.convert("RGB").resize(source_size, Image.Resampling.LANCZOS) + normalized.save(image_path) + + +def _text_box_from_vlm( + region: VLMTextRegion, + mapper: VLMCoordinateMapper, + source_rgb: Image.Image, +) -> TextBoxSpec: + bbox = mapper.to_source_bbox(region.bbox, padding=0) + return TextBoxSpec( + text=region.text, + source_pixel_bbox=bbox, + source_pixel_polygon=_bbox_polygon(bbox), + font_family="Microsoft YaHei", + font_size=_estimate_font_size(bbox, mapper.source_image_size, role=region.role), + color_hex=region.color_hex or _infer_text_color(source_rgb, bbox), + alignment="left", + style_hints={ + "vlm_role": region.role, + "approximate_layout": True, + "bold": region.role in {"title", "heading", "label", "button"}, + }, + provenance={ + "provider_role": "VLM", + "region_id": region.region_id, + "confidence": region.confidence, + "group_id": region.group_id, + }, + ) + + +def _native_shapes_from_vlm( + region: VLMShapeRegion, + mapper: VLMCoordinateMapper, + source_image_size: tuple[int, int], +) -> list[NativeShapeSpec]: + bbox = mapper.to_source_bbox(region.bbox, padding=0) + kind = region.kind.lower() + if kind in {"divider", "line", "connector", "arrow"}: + left, top, right, bottom = bbox + if max(right - left, bottom - top) < 8: + return [] + if right - left >= bottom - top: + start = (left, (top + bottom) // 2) + end = (right, (top + bottom) // 2) + else: + start = ((left + right) // 2, top) + end = ((left + right) // 2, bottom) + return [ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=bbox, + line_color="#5A8EB5", + line_start=start, + line_end=end, + stroke_width=1.0, + opacity=0.75, + provenance={"provider_role": "VLM", "region_id": region.region_id, "vlm_type": kind}, + ) + ] + if kind in {"rounded_rect", "rounded_rectangle", "rect", "rectangle"}: + if _area_ratio(bbox, source_image_size) > 0.06: + return [] + return [ + NativeShapeSpec( + shape_type="rounded_rectangle" if "round" in kind else "rectangle", + source_pixel_bbox=bbox, + fill_color="", + line_color="#E0EEFF", + stroke_width=1.0, + opacity=0.95, + radius=0.12, + provenance={"provider_role": "VLM", "region_id": region.region_id, "vlm_type": kind}, + ) + ] + return [] + + +def _coerce_bbox(value: Any, coordinate_space: VLMCoordinateSpace) -> PixelBBox: + if not isinstance(value, list | tuple) or len(value) != 4: + raise ValueError("VLM region bbox must be [left, top, right, bottom]") + bbox = tuple(round(float(item)) for item in value) + return _clamp_bbox(bbox, (coordinate_space.width, coordinate_space.height)) + + +def _resized_jpeg_base64(image_path: str | Path, size: tuple[int, int]) -> str: + with Image.open(image_path) as image: + rgb = image.convert("RGB") + resized = rgb.resize(size, Image.Resampling.LANCZOS) + buffer = io.BytesIO() + resized.save(buffer, format="JPEG", quality=90) + return base64.b64encode(buffer.getvalue()).decode("ascii") + + +def _vlm_analysis_payload_variants( + image_path: str | Path, + *, + analysis_size: tuple[int, int], + model: str, + max_tokens: int, +) -> list[dict[str, Any]]: + variants: list[dict[str, Any]] = [] + for size, detail, image_first in ( + (analysis_size, "low", False), + (analysis_size, "", False), + (analysis_size, "", True), + ((960, 540), "", False), + ): + image_base64 = _resized_jpeg_base64(image_path, size) + text_part = {"type": "text", "text": _vlm_compact_analysis_prompt(size)} + image_url: dict[str, Any] = {"url": f"data:image/jpeg;base64,{image_base64}"} + if detail: + image_url["detail"] = detail + image_part = {"type": "image_url", "image_url": image_url} + content = [image_part, text_part] if image_first else [text_part, image_part] + variants.append( + { + "model": model, + "messages": [{"role": "user", "content": content}], + "response_format": {"type": "json_object"}, + "temperature": 0, + "max_tokens": max_tokens, + } + ) + return variants + + +def _is_vlm_payload_shape_compatibility_error(exc: ProviderError) -> bool: + message = str(exc).lower() + if "400" not in message and "bad request" not in message: + return False + return any( + marker in message + for marker in ( + "unsupported", + "unknown field", + "extra field", + "unrecognized", + "not supported", + "invalid parameter", + "invalid field", + "image_url.detail", + "response_format", + "detail", + ) + ) + + +def _vlm_compact_analysis_prompt(size: tuple[int, int]) -> str: + width, height = size + return ( + "你是 PPT 页面重建的视觉分析器。只输出 JSON,不要 Markdown。\n" + f"图片已经缩放为 {width}x{height}。所有 bbox 必须使用这张缩放图片的像素坐标 " + "[left,top,right,bottom],不要归一化。\n" + "目标是给后续 PPTX 重建提供元素清单,而不是描述图片。\n" + "输出 schema: {" + "\"coordinate_space\":{\"width\":%d,\"height\":%d,\"unit\":\"px\"}," + "\"text_regions\":[{\"id\":\"t1\",\"text\":\"原文\",\"bbox\":[0,0,1,1],\"role\":\"title|heading|body|label|button\",\"color\":\"#RRGGBB\",\"confidence\":0.9,\"group_id\":\"g1\"}]," + "\"bitmap_regions\":[{\"id\":\"b1\",\"type\":\"photo|icon|qr|product|component|other\",\"bbox\":[0,0,1,1],\"importance\":\"major|minor\",\"group_id\":\"g1\"}]," + "\"shape_regions\":[{\"id\":\"s1\",\"type\":\"rounded_rect|rect|line|divider|connector|arrow|circle|other\",\"bbox\":[0,0,1,1],\"importance\":\"major|minor\",\"group_id\":\"g1\"}]" + "}。\n" + "规则:文字逐项列出,中文照抄,不合并跨区域文字;大背景不要放入 bitmap_regions;" + "面板、分割线、按钮边框放 shape_regions;忽略纯装饰噪声。" + ) % (width, height) + + +def _clamp_bbox(bbox: tuple[int, int, int, int], size: tuple[int, int]) -> PixelBBox: + width, height = size + left, top, right, bottom = bbox + left = max(0, min(width - 1, left)) + top = max(0, min(height - 1, top)) + right = max(left + 1, min(width, right)) + bottom = max(top + 1, min(height, bottom)) + return (left, top, right, bottom) + + +def _pad_bbox(bbox: PixelBBox, padding: int) -> PixelBBox: + left, top, right, bottom = bbox + return (left - padding, top - padding, right + padding, bottom + padding) + + +def _bbox_polygon(bbox: PixelBBox) -> tuple[tuple[int, int], ...]: + left, top, right, bottom = bbox + return ((left, top), (right, top), (right, bottom), (left, bottom)) + + +def _area_ratio(bbox: PixelBBox, size: tuple[int, int]) -> float: + left, top, right, bottom = bbox + return ((right - left) * (bottom - top)) / max(1, size[0] * size[1]) + + +def _bitmap_region_should_be_ignored_as_element(bbox: PixelBBox, size: tuple[int, int]) -> bool: + if _area_ratio(bbox, size) < MIN_BITMAP_REGION_AREA_RATIO: + return True + return _is_near_full_slide_region(bbox, size) + + +def _is_near_full_slide_region(bbox: PixelBBox, size: tuple[int, int]) -> bool: + width = max(1, size[0]) + height = max(1, size[1]) + bbox_width = max(0, bbox[2] - bbox[0]) + bbox_height = max(0, bbox[3] - bbox[1]) + if _area_ratio(bbox, size) >= MAX_BITMAP_ELEMENT_AREA_RATIO: + return True + return ( + bbox_width / width >= MAX_BITMAP_ELEMENT_WIDTH_RATIO + and bbox_height / height >= MAX_BITMAP_ELEMENT_HEIGHT_RATIO + ) + + +def _ocr_match_score(region: VLMTextRegion, region_bbox: PixelBBox, box: TextBoxSpec) -> float: + overlap = _bbox_overlap_ratio(region_bbox, box.source_pixel_bbox) + if overlap <= 0 and not _bbox_center_inside(box.source_pixel_bbox, region_bbox): + return 0.0 + text_score = _text_similarity(region.text, box.text) + confidence = float(box.provenance.get("ocr_confidence", 0.0) or 0.0) + if text_score < 0.2 and region.text: + return 0.0 + return max(overlap, 0.35 if _bbox_center_inside(box.source_pixel_bbox, region_bbox) else 0.0) * 0.65 + text_score * 0.25 + confidence * 0.1 + + +def _bbox_overlap_ratio(outer: PixelBBox, inner: PixelBBox) -> float: + intersection = _bbox_intersection_area(outer, inner) + return intersection / max(1, _bbox_area(inner)) + + +def _bbox_intersection_area(left_bbox: PixelBBox, right_bbox: PixelBBox) -> int: + left = max(left_bbox[0], right_bbox[0]) + top = max(left_bbox[1], right_bbox[1]) + right = min(left_bbox[2], right_bbox[2]) + bottom = min(left_bbox[3], right_bbox[3]) + if right <= left or bottom <= top: + return 0 + return (right - left) * (bottom - top) + + +def _bbox_area(bbox: PixelBBox) -> int: + return max(0, bbox[2] - bbox[0]) * max(0, bbox[3] - bbox[1]) + + +def _bbox_center_inside(bbox: PixelBBox, container: PixelBBox) -> bool: + center_x = (bbox[0] + bbox[2]) / 2 + center_y = (bbox[1] + bbox[3]) / 2 + return container[0] <= center_x <= container[2] and container[1] <= center_y <= container[3] + + +def _text_similarity(left: str, right: str) -> float: + normalized_left = _normalize_text_for_match(left) + normalized_right = _normalize_text_for_match(right) + if not normalized_left or not normalized_right: + return 0.0 + if normalized_left in normalized_right or normalized_right in normalized_left: + return 1.0 + return SequenceMatcher(None, normalized_left, normalized_right).ratio() + + +def _normalize_text_for_match(value: str) -> str: + return re.sub(r"\s+", "", str(value)).lower() + + +def _estimate_font_size( + bbox: PixelBBox, + source_image_size: tuple[int, int], + *, + role: str, +) -> float: + _left, top, _right, bottom = bbox + height_px = max(1, bottom - top) + slide_height_inches = 5.625 + # VLM bboxes describe the visible glyph envelope, while PPT renderers need + # extra line-height headroom. A conservative factor avoids dense pages + # drifting from source renders because text clips or expands vertically. + font_size = height_px / source_image_size[1] * slide_height_inches * 72 * 0.65 + if role in {"title", "heading"}: + return max(9.0, min(font_size, 30.0)) + return max(7.0, min(font_size, 22.0)) + + +def _infer_text_color(image: Image.Image, bbox: PixelBBox) -> str: + crop = image.crop(bbox) + if crop.width <= 0 or crop.height <= 0: + return "#FFFFFF" + sample = crop.resize((max(1, min(80, crop.width)), max(1, min(40, crop.height)))) + candidates = [ + pixel + for pixel in sample.getdata() + if max(pixel) > 135 and (max(pixel) - min(pixel) > 18 or sum(pixel) / 3 > 170) + ] + if not candidates: + return "#FFFFFF" + blues = [ + pixel + for pixel in candidates + if pixel[2] > pixel[0] + 25 and pixel[1] > pixel[0] - 20 + ] + values = blues if len(blues) >= max(3, len(candidates) * 0.2) else candidates + rgb = tuple(sorted(pixel[index] for pixel in values)[len(values) // 2] for index in range(3)) + return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" + + +def _clean_hex_color(value: Any) -> str: + if isinstance(value, str) and re.match(r"^#[0-9a-fA-F]{6}$", value): + return value.upper() + return "" + + +def _coerce_confidence(value: Any) -> float: + try: + number = float(value) + except (TypeError, ValueError): + return 0.0 + return max(0.0, min(1.0, number)) + + +def _items(payload: dict[str, Any], key: str) -> list[dict[str, Any]]: + value = payload.get(key) + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _region_id(item: dict[str, Any], prefix: str, index: int) -> str: + return _safe_name(str(item.get("id") or f"{prefix}{index}"), fallback=f"{prefix}{index}") + + +def _max_bbox_x(payload: dict[str, Any]) -> int: + return max((bbox[2] for bbox in _all_bboxes(payload)), default=0) + + +def _max_bbox_y(payload: dict[str, Any]) -> int: + return max((bbox[3] for bbox in _all_bboxes(payload)), default=0) + + +def _all_bboxes(payload: dict[str, Any]) -> list[list[int]]: + bboxes: list[list[int]] = [] + for key in ("text_regions", "bitmap_regions", "shape_regions"): + for item in _items(payload, key): + bbox = item.get("bbox") + if isinstance(bbox, list) and len(bbox) == 4: + bboxes.append([round(float(value)) for value in bbox]) + return bboxes + + +def _require_inside(root: Path, path: Path, field_name: str) -> None: + if not path.is_relative_to(root): + raise ValueError(f"{field_name} must be inside artifact_root") + + +def _artifact_ref(path: Path, root: Path) -> str: + return path.resolve().relative_to(root.resolve()).as_posix() + + +def _slide_size_inches(aspect_ratio: str) -> tuple[float, float]: + if aspect_ratio == "4:3": + return (10.0, 7.5) + return (10.0, 5.625) + + +def _safe_name(value: str, *, fallback: str = "item") -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-") + return safe or fallback + + +def _slide_value(slide: GenerativeEditableSlideInput | dict[str, Any], key: str) -> Any: + if isinstance(slide, dict): + return slide.get(key) + return getattr(slide, key) + + +def _job_relative(path: Path, job_dir: Path) -> str: + return path.resolve().relative_to(job_dir.resolve()).as_posix() + + +def _analysis_payload(analysis: VLMPageAnalysis) -> dict[str, Any]: + return { + "coordinate_space": asdict(analysis.coordinate_space), + "text_regions": [asdict(region) for region in analysis.text_regions], + "bitmap_regions": [asdict(region) for region in analysis.bitmap_regions], + "shape_regions": [asdict(region) for region in analysis.shape_regions], + } diff --git a/src/model_profiles.py b/src/model_profiles.py index b9bd30f..e732760 100644 --- a/src/model_profiles.py +++ b/src/model_profiles.py @@ -16,6 +16,8 @@ "prompt": "openai_chat", "image": "raw_chat_multimodal", "edit": "raw_chat_multimodal", + "vlm": "openai_chat", + "ocr": "openai_chat", } @@ -47,6 +49,7 @@ def __post_init__(self): def to_public_dict(self) -> Dict[str, Any]: data = asdict(self) data["api_key"] = "SET" if self.api_key else "EMPTY" + data.pop("adapter", None) return data @@ -55,23 +58,34 @@ class ModelProfileSet: prompt: ModelProfile image: ModelProfile edit: ModelProfile + vlm: Optional[ModelProfile] = None + ocr: Optional[ModelProfile] = None def to_public_dict(self) -> Dict[str, Any]: - return { - "prompt_model": self.prompt.to_public_dict(), + data = { + "text_model": self.prompt.to_public_dict(), "image_model": self.image.to_public_dict(), "edit_model": self.edit.to_public_dict(), } + if self.vlm: + data["VLM"] = self.vlm.to_public_dict() + if self.ocr: + data["ocr_model"] = self.ocr.to_public_dict() + return data def resolve_model_profiles(data: Dict[str, Any]) -> ModelProfileSet: - prompt = _profile_from_dict("prompt", data.get("prompt_model") or data.get("text") or {}) + vlm_source = data.get("VLM") or data.get("vlm_model") + prompt_source = data.get("text_model") or data.get("prompt_model") or data.get("text") or vlm_source or {} + prompt = _profile_from_dict("prompt", prompt_source) image = _profile_from_dict("image", data.get("image_model") or data.get("image") or {}) edit_source = data.get("edit_model") or data.get("edit") edit = _profile_from_dict("edit", edit_source) if edit_source else _inherit_edit_profile(image) + vlm = _optional_profile_from_dict("vlm", vlm_source) + ocr = _optional_profile_from_dict("ocr", data.get("ocr_model")) - return ModelProfileSet(prompt=prompt, image=image, edit=edit) + return ModelProfileSet(prompt=prompt, image=image, edit=edit, vlm=vlm, ocr=ocr) def load_profiles_from_env(env_path: Optional[Path] = None) -> Optional[ModelProfileSet]: @@ -103,7 +117,7 @@ def load_profiles_from_env(env_path: Optional[Path] = None) -> Optional[ModelPro return resolve_model_profiles( { - "prompt_model": { + "text_model": { "id": "env-text", "label": text_model, "model": text_model, @@ -150,7 +164,7 @@ def load_default_profiles( if api_config.get("text") or api_config.get("image"): legacy = { - "prompt_model": { + "text_model": { **(api_config.get("text") or {}), "adapter": (api_config.get("text") or {}).get("adapter", "openai_chat"), }, @@ -204,6 +218,12 @@ def _profile_from_dict(role: str, data: Dict[str, Any]) -> ModelProfile: ) +def _optional_profile_from_dict(role: str, data: Optional[Dict[str, Any]]) -> Optional[ModelProfile]: + if not data: + return None + return _profile_from_dict(role, data) + + def _parse_env_like_file(path: Path) -> Dict[str, str]: replacements = str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'"}) values: Dict[str, str] = {} diff --git a/tests/generative_editable_fixtures.py b/tests/generative_editable_fixtures.py new file mode 100644 index 0000000..719c1b8 --- /dev/null +++ b/tests/generative_editable_fixtures.py @@ -0,0 +1,144 @@ +"""Deterministic fixture deck images for generative editable PPTX tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageDraw + + +@dataclass(frozen=True) +class FixtureSlide: + slide_id: str + image_path: Path + text_metadata: list[dict] + coverage_tags: set[str] + + +@dataclass(frozen=True) +class FixtureDeck: + root: Path + aspect_ratio: str + image_size: tuple[int, int] + slides: list[FixtureSlide] + + @property + def slide_order(self) -> list[str]: + return [slide.slide_id for slide in self.slides] + + @property + def coverage_tags(self) -> set[str]: + tags: set[str] = set() + for slide in self.slides: + tags.update(slide.coverage_tags) + return tags + + +def write_deterministic_fixture_deck(root: Path, *, aspect_ratio: str = "16:9") -> FixtureDeck: + if aspect_ratio not in {"16:9", "4:3"}: + raise ValueError("aspect_ratio must be 16:9 or 4:3") + image_size = (800, 450) if aspect_ratio == "16:9" else (800, 600) + source_root = root / f"generative-editable-{aspect_ratio.replace(':', '-')}" / "sources" + source_root.mkdir(parents=True, exist_ok=True) + + slides = [ + _write_text_and_shapes_slide(source_root, image_size), + _write_repeated_assets_slide(source_root, image_size), + _write_complex_visual_slide(source_root, image_size), + _write_text_clean_fallback_slide(source_root, image_size), + ] + return FixtureDeck(root=source_root.parent, aspect_ratio=aspect_ratio, image_size=image_size, slides=slides) + + +def _write_text_and_shapes_slide(root: Path, size: tuple[int, int]) -> FixtureSlide: + image = _base_slide(size) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((48, 46, 752, 118), radius=18, fill="#E0F2FE", outline="#0284C7", width=3) + draw.text((82, 66), "Quarterly Plan", fill="#0F172A") + draw.rectangle((82, 172, 278, 300), fill="#DCFCE7", outline="#16A34A", width=3) + draw.rounded_rectangle((328, 172, 524, 300), radius=22, fill="#FEF3C7", outline="#D97706", width=3) + draw.line((574, 292, 718, 188), fill="#7C3AED", width=6) + path = root / "01-text-shapes.png" + image.save(path) + return FixtureSlide( + slide_id="fixture-text-shapes", + image_path=path, + text_metadata=[ + { + "text": "Quarterly Plan", + "role": "title", + "order": 1, + "style_hint": {"font_size": 32, "bold": True}, + } + ], + coverage_tags={"text", "simple_shapes", "native_shape_candidates"}, + ) + + +def _write_repeated_assets_slide(root: Path, size: tuple[int, int]) -> FixtureSlide: + image = _base_slide(size) + draw = ImageDraw.Draw(image) + draw.text((72, 54), "Repeated Components", fill="#0F172A") + for index, x in enumerate((110, 310, 510)): + draw.rounded_rectangle((x, 160, x + 110, 270), radius=18, fill="#CCFBF1", outline="#0F766E", width=3) + draw.ellipse((x + 26, 186, x + 84, 244), fill="#14B8A6") + draw.text((x + 36, 292), f"A{index + 1}", fill="#115E59") + path = root / "02-repeated-assets.png" + image.save(path) + return FixtureSlide( + slide_id="fixture-repeated-assets", + image_path=path, + text_metadata=[{"text": "Repeated Components", "role": "title", "order": 1}], + coverage_tags={"repeated_bitmap_asset", "component_reuse"}, + ) + + +def _write_complex_visual_slide(root: Path, size: tuple[int, int]) -> FixtureSlide: + image = _base_slide(size) + draw = ImageDraw.Draw(image) + draw.text((72, 54), "Complex Visual", fill="#0F172A") + points = [(100, 330), (190, 260), (290, 286), (398, 198), (520, 224), (680, 146)] + draw.line(points, fill="#2563EB", width=5) + for x, y in points: + draw.ellipse((x - 9, y - 9, x + 9, y + 9), fill="#DB2777") + draw.polygon([(90, 360), (250, 250), (430, 310), (700, 170), (700, 382), (90, 382)], fill="#DBEAFE") + draw.line(points, fill="#2563EB", width=5) + path = root / "03-complex-visual.png" + image.save(path) + return FixtureSlide( + slide_id="fixture-complex-visual", + image_path=path, + text_metadata=[{"text": "Complex Visual", "role": "title", "order": 1}], + coverage_tags={"complex_visual", "bitmap_asset_candidate"}, + ) + + +def _write_text_clean_fallback_slide(root: Path, size: tuple[int, int]) -> FixtureSlide: + image = _base_slide(size) + draw = ImageDraw.Draw(image) + draw.rectangle((56, 138, 744, 348), fill="#F1F5F9", outline="#94A3B8", width=2) + draw.text((86, 64), "Fallback Text Layer", fill="#0F172A") + for row, text in enumerate(("Editable title", "OCR layout hint", "Preserve background visual")): + y = 178 + row * 48 + draw.rounded_rectangle((94, y - 12, 706, y + 26), radius=10, fill="#FFFFFF", outline="#CBD5E1") + draw.text((122, y), text, fill="#334155") + path = root / "04-text-clean-fallback.png" + image.save(path) + return FixtureSlide( + slide_id="fixture-text-clean-fallback", + image_path=path, + text_metadata=[ + {"text": "Fallback Text Layer", "role": "title", "order": 1}, + ], + coverage_tags={"text_clean_fallback", "ocr_layout_fallback"}, + ) + + +def _base_slide(size: tuple[int, int]) -> Image.Image: + image = Image.new("RGB", size, "#F8FAFC") + draw = ImageDraw.Draw(image) + width, height = size + draw.rectangle((0, 0, width - 1, height - 1), outline="#CBD5E1", width=2) + draw.rectangle((0, height - 24, width, height), fill="#E2E8F0") + return image diff --git a/tests/test_config_writer.py b/tests/test_config_writer.py index bfbe5c1..af1856d 100644 --- a/tests/test_config_writer.py +++ b/tests/test_config_writer.py @@ -19,7 +19,7 @@ def test_saves_model_profiles_to_config_yaml(self): ): save_model_profiles_to_config( { - "prompt_model": { + "text_model": { "model": "text", "base_url": "https://text.example/v1", "api_key": "text-key", @@ -32,15 +32,29 @@ def test_saves_model_profiles_to_config_yaml(self): "api_key": "image-key", "adapter": "raw_chat_multimodal", }, + "VLM": { + "model": "vlm", + "base_url": "https://vlm.example/v1", + "api_key": "vlm-key", + }, + "ocr_model": { + "model": "ocr", + "base_url": "https://ocr.example/v1", + "api_key": "ocr-key", + }, }, config_path=config_path, ) saved = yaml.safe_load(config_path.read_text()) self.assertEqual(saved["ppt"]["num_pages"], 3) - self.assertEqual(saved["api"]["models"]["prompt_model"]["api_key"], "text-key") - self.assertEqual(saved["api"]["models"]["prompt_model"]["thinking"], "enabled") + self.assertEqual(saved["api"]["models"]["text_model"]["api_key"], "text-key") + self.assertEqual(saved["api"]["models"]["text_model"]["thinking"], "enabled") + self.assertNotIn("adapter", saved["api"]["models"]["text_model"]) self.assertEqual(saved["api"]["models"]["image_model"]["model"], "image") + self.assertNotIn("adapter", saved["api"]["models"]["image_model"]) + self.assertEqual(saved["api"]["models"]["VLM"]["model"], "vlm") + self.assertEqual(saved["api"]["models"]["ocr_model"]["model"], "ocr") def test_empty_api_key_preserves_existing_secret(self): with tempfile.TemporaryDirectory() as temp_dir: @@ -52,8 +66,10 @@ def test_empty_api_key_preserves_existing_secret(self): return_value={ "api": { "models": { - "prompt_model": {"api_key": "old-text-key"}, + "text_model": {"api_key": "old-text-key"}, "image_model": {"api_key": "old-image-key"}, + "VLM": {"api_key": "old-vlm-key"}, + "ocr_model": {"api_key": "old-ocr-key"}, } } }, @@ -62,7 +78,7 @@ def test_empty_api_key_preserves_existing_secret(self): ): save_model_profiles_to_config( { - "prompt_model": { + "text_model": { "model": "new-text", "base_url": "https://text.example/v1", "api_key": "", @@ -74,13 +90,25 @@ def test_empty_api_key_preserves_existing_secret(self): "api_key": "", "adapter": "raw_chat_multimodal", }, + "VLM": { + "model": "new-vlm", + "base_url": "https://vlm.example/v1", + "api_key": "", + }, + "ocr_model": { + "model": "new-ocr", + "base_url": "https://ocr.example/v1", + "api_key": "", + }, }, config_path=config_path, ) saved = yaml.safe_load(config_path.read_text()) - self.assertEqual(saved["api"]["models"]["prompt_model"]["api_key"], "old-text-key") + self.assertEqual(saved["api"]["models"]["text_model"]["api_key"], "old-text-key") self.assertEqual(saved["api"]["models"]["image_model"]["api_key"], "old-image-key") + self.assertEqual(saved["api"]["models"]["VLM"]["api_key"], "old-vlm-key") + self.assertEqual(saved["api"]["models"]["ocr_model"]["api_key"], "old-ocr-key") if __name__ == "__main__": diff --git a/tests/test_generate_route_helpers.py b/tests/test_generate_route_helpers.py index 2f76c30..8441724 100644 --- a/tests/test_generate_route_helpers.py +++ b/tests/test_generate_route_helpers.py @@ -1,5 +1,5 @@ from api.models import ConfirmedSlidePrompt, GenerationConfig, GenerationRequest -from api.routes.generate import _prompt_data_from_confirmed +from api.routes.generate import _prompt_data_from_confirmed, _slide_text_metadata from src.config import PPTConfig @@ -19,3 +19,36 @@ def test_confirmed_slide_prompts_are_sorted_by_page(): assert [slide.page for slide in prompt_data.slide_prompts] == [1, 2, 3] assert [slide.title for slide in prompt_data.slide_prompts] == ["One", "Two", "Three"] + + +def test_slide_text_metadata_uses_title_and_display_content(): + prompt = ConfirmedSlidePrompt( + page=1, + title="Quarterly Plan", + content_summary="Fallback summary", + display_content="Revenue up 18%\nMargin stable", + prompt="Prompt 1", + ) + + assert _slide_text_metadata(prompt) == [ + {"text": "Quarterly Plan", "role": "title", "order": 1, "style_hint": {}}, + { + "text": "Revenue up 18%\nMargin stable", + "role": "body", + "order": 2, + "style_hint": {}, + }, + ] + + +def test_slide_text_metadata_avoids_duplicate_body_matching_title(): + prompt = ConfirmedSlidePrompt( + page=1, + title="Only Title", + content_summary="Only Title", + prompt="Prompt 1", + ) + + assert _slide_text_metadata(prompt) == [ + {"text": "Only Title", "role": "title", "order": 1, "style_hint": {}} + ] diff --git a/tests/test_generative_editable_assets.py b/tests/test_generative_editable_assets.py new file mode 100644 index 0000000..5f7bafc --- /dev/null +++ b/tests/test_generative_editable_assets.py @@ -0,0 +1,853 @@ +import tempfile +import unittest +from pathlib import Path + +from PIL import Image, ImageDraw + +from src.generative_editable_config import ProviderConfig +from src.generative_editable_foreground_planner import ForegroundCandidate +from src.generative_editable_manifest import BitmapAssetSpec, RepairAttempt +from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + ImageEditProvider, +) + + +class RecordingImageEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="fake-image-edit", + model="fake-image-edit-model", + base_url="https://fake.local", + api_key="fake-key", + ) + ) + self.calls = [] + self.fake = FakeImageEditProvider(self.config) + + def edit(self, request): + self.calls.append(request) + return self.fake.edit(request) + + +class RecordingValidAssetRepairProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="valid-repair", + model="valid-repair-model", + base_url="https://fake.local", + api_key="fake-key", + ) + ) + self.calls = [] + + def edit(self, request): + self.calls.append(request) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGBA", (30, 22), (0, 0, 0, 0)) + ImageDraw.Draw(image).ellipse((8, 5, 22, 19), fill=(16, 185, 129, 255)) + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + }, + )() + + +class GenerativeEditableAssetsTest(unittest.TestCase): + def test_builds_asset_sheet_edit_request_from_bitmap_candidates(self): + from src.generative_editable_assets import build_asset_sheet_request + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + source.parent.mkdir(parents=True) + Image.new("RGB", (200, 120), "white").save(source) + candidates = [ + ForegroundCandidate( + candidate_id="fg-001", + source_pixel_bbox=(10, 12, 50, 42), + area=1200, + classification="bitmap_asset_candidate", + confidence=0.85, + provenance={"reason": "simple_nonrectangular_visual"}, + ) + ] + + request = build_asset_sheet_request( + source_image_path=source, + candidates=candidates, + output_asset_path=output, + asset_root=root, + provider_role="edit_model", + ) + + self.assertEqual(request.prompt_id, "asset_sheet") + self.assertIn("fg-001", request.prompt) + self.assertIn("pure #FF00FF", request.prompt) + self.assertIn("Do not use checkerboard, gray, white, shadows, gradients, or scene floors", request.prompt) + self.assertIn("exactly one separated object per candidate id", request.prompt) + self.assertIn("Do not merge, omit, rename, replace, or add objects", request.prompt) + self.assertIn("Do not include readable text", request.prompt) + self.assertIn("Do not include full cards, panels, charts, or source-slide fragments", request.prompt) + self.assertIn("Keep text boxes out of the asset sheet", request.prompt) + self.assertIn("fg-001 bbox=(10, 12, 50, 42)", request.prompt) + self.assertIn("classification=bitmap_asset_candidate", request.prompt) + self.assertEqual(request.metadata["source_image_ref"], "sources/slide.png") + self.assertEqual(request.metadata["candidate_boxes"][0]["bbox"], (10, 12, 50, 42)) + self.assertEqual(request.metadata["chroma_key_mode"], "solid #FF00FF chroma key") + self.assertEqual(request.metadata["provider_role"], "edit_model") + + def test_slices_asset_sheet_preserving_alpha_padding_and_mapping(self): + from src.generative_editable_assets import AssetSliceSpec, slice_asset_sheet + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGBA", (100, 60), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((12, 10, 34, 28), fill=(37, 99, 235, 255)) + draw.rectangle((60, 16, 80, 40), fill=(16, 185, 129, 180)) + image.save(sheet) + specs = [ + AssetSliceSpec( + candidate_id="fg-001", + sheet_bbox=(10, 8, 36, 30), + source_pixel_bbox=(20, 20, 46, 42), + output_filename="fg-001.png", + ), + AssetSliceSpec( + candidate_id="fg-002", + sheet_bbox=(58, 14, 82, 42), + source_pixel_bbox=(70, 24, 94, 52), + output_filename="fg-002.png", + ), + ] + + assets = slice_asset_sheet( + sheet_path=sheet, + slice_specs=specs, + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + ) + with Image.open(root / assets[1].asset_path) as sliced: + sliced_mode = sliced.mode + sliced_alpha = sliced.getpixel((4, 4))[3] + + self.assertEqual([asset.asset_id for asset in assets], ["fg-001", "fg-002"]) + self.assertEqual(assets[0].asset_path, "assets/0000-slide-a/fg-001.png") + self.assertEqual(assets[0].source_pixel_bbox, (20, 20, 46, 42)) + self.assertEqual(assets[0].provenance["candidate_id"], "fg-001") + self.assertEqual(sliced_mode, "RGBA") + self.assertEqual(sliced_alpha, 180) + + def test_slice_asset_sheet_rejects_sheet_path_outside_asset_root(self): + from src.generative_editable_assets import AssetSliceSpec, slice_asset_sheet + + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as outside_tmp: + root = Path(tmp) + outside_sheet = Path(outside_tmp) / "sheet.png" + Image.new("RGBA", (40, 30), (0, 0, 0, 0)).save(outside_sheet) + + with self.assertRaisesRegex(ValueError, "sheet_path"): + slice_asset_sheet( + sheet_path=outside_sheet, + slice_specs=[ + AssetSliceSpec( + candidate_id="fg-001", + sheet_bbox=(0, 0, 40, 30), + source_pixel_bbox=(0, 0, 40, 30), + output_filename="fg-001.png", + ) + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + ) + + def test_chroma_key_slicing_removes_green_background(self): + from src.generative_editable_assets import AssetSliceSpec, slice_asset_sheet + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGB", (40, 30), (0, 255, 0)) + ImageDraw.Draw(image).rectangle((10, 8, 26, 20), fill="#2563EB") + image.save(sheet) + + assets = slice_asset_sheet( + sheet_path=sheet, + slice_specs=[ + AssetSliceSpec( + candidate_id="fg-001", + sheet_bbox=(0, 0, 40, 30), + source_pixel_bbox=(0, 0, 40, 30), + output_filename="fg-001.png", + ) + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + chroma_key=(0, 255, 0), + ) + with Image.open(root / assets[0].asset_path) as sliced: + sliced_mode = sliced.mode + transparent_alpha = sliced.getpixel((0, 0))[3] + object_alpha = sliced.getpixel((12, 10))[3] + + self.assertEqual(sliced_mode, "RGBA") + self.assertEqual(transparent_alpha, 0) + self.assertEqual(object_alpha, 255) + + def test_component_slicing_treats_checkerboard_background_as_transparent(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGB", (160, 90), "white") + pixels = image.load() + for y in range(image.height): + for x in range(image.width): + tile = ((x // 8) + (y // 8)) % 2 + base = 246 if tile == 0 else 232 + variation = (x * 7 + y * 11) % 9 + value = base + variation - 4 + pixels[x, y] = (value, value, value) + draw = ImageDraw.Draw(image) + draw.ellipse((18, 20, 48, 50), fill=(0, 112, 243)) + draw.rectangle((104, 28, 138, 58), fill=(16, 185, 129)) + image.save(sheet) + + candidates = [ + ForegroundCandidate( + candidate_id="fg-left", + source_pixel_bbox=(10, 10, 45, 45), + area=1200, + classification="complex_whole_visual", + confidence=0.9, + provenance={}, + ), + ForegroundCandidate( + candidate_id="fg-right", + source_pixel_bbox=(80, 12, 126, 54), + area=1400, + classification="complex_whole_visual", + confidence=0.9, + provenance={}, + ), + ] + + assets = slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=candidates, + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=4, + ) + with Image.open(root / assets[0].asset_path) as first: + first_corner_alpha = first.getpixel((0, 0))[3] + with Image.open(root / assets[1].asset_path) as second: + second_corner_alpha = second.getpixel((0, 0))[3] + + self.assertEqual([asset.asset_id for asset in assets], ["fg-left", "fg-right"]) + self.assertEqual(first_corner_alpha, 0) + self.assertEqual(second_corner_alpha, 0) + + def test_component_slicing_treats_noisy_magenta_chroma_as_transparent(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGB", (160, 90), "white") + pixels = image.load() + for y in range(image.height): + for x in range(image.width): + variation = (x * 5 + y * 13) % 23 + pixels[x, y] = (245 + variation // 3, variation // 4, 238 + variation // 2) + draw = ImageDraw.Draw(image) + draw.ellipse((18, 20, 48, 50), fill=(0, 112, 243)) + draw.rectangle((104, 28, 138, 58), fill=(16, 185, 129)) + image.save(sheet) + + candidates = [ + ForegroundCandidate( + candidate_id="fg-left", + source_pixel_bbox=(10, 10, 45, 45), + area=1200, + classification="complex_whole_visual", + confidence=0.9, + provenance={}, + ), + ForegroundCandidate( + candidate_id="fg-right", + source_pixel_bbox=(80, 12, 126, 54), + area=1400, + classification="complex_whole_visual", + confidence=0.9, + provenance={}, + ), + ] + + assets = slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=candidates, + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=4, + ) + with Image.open(root / assets[0].asset_path) as first: + first_corner_alpha = first.getpixel((0, 0))[3] + with Image.open(root / assets[1].asset_path) as second: + second_corner_alpha = second.getpixel((0, 0))[3] + + self.assertEqual([asset.asset_id for asset in assets], ["fg-left", "fg-right"]) + self.assertEqual(first_corner_alpha, 0) + self.assertEqual(second_corner_alpha, 0) + + def test_component_slicing_rejects_extra_components_for_unmerged_single_candidate(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGBA", (180, 90), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((20, 28, 58, 60), fill=(0, 112, 243, 255)) + draw.rectangle((112, 28, 150, 60), fill=(16, 185, 129, 255)) + image.save(sheet) + + with self.assertRaisesRegex(ValueError, "unexpected extra component"): + slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=[ + ForegroundCandidate( + candidate_id="fg-group", + source_pixel_bbox=(10, 10, 160, 70), + area=9000, + classification="complex_whole_visual", + ) + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=4, + ) + + def test_component_slicing_rejects_extra_components_for_multiple_candidates(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGBA", (240, 120), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((20, 32, 58, 70), fill=(0, 112, 243, 255)) + draw.rectangle((90, 32, 128, 70), fill=(16, 185, 129, 255)) + draw.rectangle((168, 28, 220, 78), fill=(255, 255, 255, 255)) + image.save(sheet) + + with self.assertRaisesRegex(ValueError, "unexpected extra component"): + slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=[ + ForegroundCandidate( + candidate_id="fg-left", + source_pixel_bbox=(10, 10, 45, 45), + area=1200, + classification="bitmap_asset_candidate", + ), + ForegroundCandidate( + candidate_id="fg-right", + source_pixel_bbox=(80, 12, 126, 54), + area=1400, + classification="bitmap_asset_candidate", + ), + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=4, + ) + + def test_component_slicing_orders_same_row_by_horizontal_position(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGBA", (300, 120), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((28, 34, 68, 74), fill=(0, 112, 243, 255)) + draw.rectangle((120, 10, 190, 82), fill=(16, 185, 129, 255)) + draw.rectangle((226, 28, 266, 76), fill=(245, 158, 11, 255)) + image.save(sheet) + + assets = slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=[ + ForegroundCandidate( + candidate_id="fg-left", + source_pixel_bbox=(10, 10, 45, 45), + area=1200, + classification="bitmap_asset_candidate", + ), + ForegroundCandidate( + candidate_id="fg-middle", + source_pixel_bbox=(80, 12, 126, 54), + area=1400, + classification="bitmap_asset_candidate", + ), + ForegroundCandidate( + candidate_id="fg-right", + source_pixel_bbox=(140, 12, 190, 54), + area=1400, + classification="bitmap_asset_candidate", + ), + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=2, + ) + with Image.open(root / assets[0].asset_path) as first: + first_pixels = list(first.convert("RGBA").getdata()) + with Image.open(root / assets[1].asset_path) as second: + second_pixels = list(second.convert("RGBA").getdata()) + + self.assertEqual([asset.asset_id for asset in assets], ["fg-left", "fg-middle", "fg-right"]) + self.assertGreater(sum(1 for pixel in first_pixels if pixel[:3] == (0, 112, 243)), 0) + self.assertGreater(sum(1 for pixel in second_pixels if pixel[:3] == (16, 185, 129)), 0) + + def test_component_slicing_unions_multiple_components_for_explicitly_merged_candidate(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGBA", (180, 90), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((20, 28, 58, 60), fill=(0, 112, 243, 255)) + draw.rectangle((112, 28, 150, 60), fill=(16, 185, 129, 255)) + image.save(sheet) + + assets = slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=[ + ForegroundCandidate( + candidate_id="fg-group", + source_pixel_bbox=(10, 10, 160, 70), + area=9000, + classification="complex_whole_visual", + provenance={"merged_candidate_ids": ["fg-left", "fg-right"]}, + ) + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=4, + ) + with Image.open(root / assets[0].asset_path) as sliced: + sliced_bbox = sliced.getchannel("A").getbbox() + sliced_size = sliced.size + + self.assertEqual([asset.asset_id for asset in assets], ["fg-group"]) + self.assertIsNotNone(sliced_bbox) + self.assertGreaterEqual(sliced_size[0], 130) + + def test_asset_qa_rejects_edge_neighbor_text_missing_and_empty_assets(self): + from src.generative_editable_assets import validate_sliced_asset + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + asset = root / "asset.png" + image = Image.new("RGBA", (40, 30), (0, 0, 0, 0)) + ImageDraw.Draw(image).rectangle((0, 6, 20, 20), fill=(37, 99, 235, 255)) + image.save(asset) + edge_report = validate_sliced_asset(asset_path=asset, expected_text_free=True) + + image = Image.new("RGBA", (60, 30), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((10, 8, 22, 20), fill=(37, 99, 235, 255)) + draw.rectangle((42, 8, 54, 20), fill=(249, 115, 22, 255)) + image.save(asset) + neighbor_report = validate_sliced_asset(asset_path=asset, expected_object_count=1) + + image = Image.new("RGBA", (40, 30), (0, 0, 0, 0)) + ImageDraw.Draw(image).rectangle((12, 8, 30, 18), fill=(10, 10, 10, 255)) + image.save(asset) + text_report = validate_sliced_asset(asset_path=asset, expected_text_free=True) + + Image.new("RGBA", (40, 30), (0, 0, 0, 0)).save(asset) + empty_report = validate_sliced_asset(asset_path=asset) + + image = Image.new("RGBA", (40, 30), (0, 0, 0, 0)) + ImageDraw.Draw(image).ellipse((12, 8, 16, 12), fill=(16, 185, 129, 255)) + image.save(asset) + missing_report = validate_sliced_asset( + asset_path=asset, + expected_object_count=1, + min_opaque_area_ratio=0.08, + ) + + image = Image.new("RGBA", (40, 30), (0, 0, 0, 0)) + ImageDraw.Draw(image).ellipse((12, 8, 28, 24), fill=(16, 185, 129, 255)) + image.save(asset) + passed_report = validate_sliced_asset(asset_path=asset, expected_object_count=1) + + self.assertEqual(edge_report.status, "failed") + self.assertIn("edge_touch", edge_report.reasons) + self.assertIn("neighboring_object_contamination", neighbor_report.reasons) + self.assertIn("baked_text_contamination", text_report.reasons) + self.assertIn("empty_asset", empty_report.reasons) + self.assertIn("missing_object", missing_report.reasons) + self.assertEqual(passed_report.status, "passed") + + def test_asset_qa_rejects_source_geometry_mismatch(self): + from src.generative_editable_assets import validate_sliced_asset + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + asset = root / "asset.png" + + source_image = Image.new("RGB", (160, 90), "#0B1020") + ImageDraw.Draw(source_image).rectangle((20, 34, 140, 46), fill="#10B981") + source_image.save(source) + + asset_image = Image.new("RGBA", (60, 60), (0, 0, 0, 0)) + ImageDraw.Draw(asset_image).rectangle((14, 10, 46, 50), fill=(16, 185, 129, 255)) + asset_image.save(asset) + + report = validate_sliced_asset( + asset_path=asset, + expected_object_count=1, + source_image_path=source, + source_pixel_bbox=(20, 34, 141, 47), + min_source_color_similarity=0.55, + ) + + self.assertEqual(report.status, "failed") + self.assertIn("source_geometry_mismatch", report.reasons) + + def test_repair_orchestration_retries_failed_assets_and_records_history(self): + from src.generative_editable_assets import repair_failed_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output_dir = root / "assets" / "0000-slide-a" + source.parent.mkdir(parents=True) + output_dir.mkdir(parents=True) + Image.new("RGB", (120, 80), "white").save(source) + failed_asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + provenance={"candidate_id": "fg-001"}, + ) + provider = RecordingValidAssetRepairProvider() + + repaired, attempts = repair_failed_assets( + source_image_path=source, + failed_assets=[failed_asset], + failure_reasons={"fg-001": ["edge_touch"]}, + output_dir=output_dir, + asset_root=root, + edit_provider=provider, + max_attempts=1, + ) + + self.assertEqual(repaired[0].asset_id, "fg-001") + self.assertEqual(repaired[0].asset_path, "assets/0000-slide-a/fg-001.repair-1.png") + self.assertEqual(len(provider.calls), 1) + self.assertEqual(provider.calls[0].prompt_id, "repair_asset") + self.assertEqual(provider.calls[0].metadata["failure_reasons"], ["edge_touch"]) + self.assertEqual( + attempts, + [ + RepairAttempt( + target_id="fg-001", + attempt_index=1, + reason="edge_touch", + provider_role="edit_model", + status="passed", + provenance={ + "prompt_id": "repair_asset", + "output_asset_ref": "assets/0000-slide-a/fg-001.repair-1.png", + }, + ) + ], + ) + + def test_repair_orchestration_runs_qa_between_bounded_attempts(self): + from src.generative_editable_assets import repair_failed_assets + + class SequenceRepairProvider(ImageEditProvider): + def __init__(self, root): + super().__init__( + ProviderConfig( + role="edit_model", + provider="sequence", + model="sequence", + base_url="", + api_key="", + ) + ) + self.calls = [] + self.root = Path(root) + + def edit(self, request): + self.calls.append(request) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + if len(self.calls) == 1: + Image.new("RGBA", (30, 22), (0, 0, 0, 0)).save(output) + else: + image = Image.new("RGBA", (30, 22), (0, 0, 0, 0)) + ImageDraw.Draw(image).ellipse((8, 5, 22, 19), fill=(16, 185, 129, 255)) + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output_dir = root / "assets" / "0000-slide-a" + source.parent.mkdir(parents=True) + output_dir.mkdir(parents=True) + Image.new("RGB", (120, 80), "white").save(source) + failed_asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + provider = SequenceRepairProvider(root) + + repaired, attempts = repair_failed_assets( + source_image_path=source, + failed_assets=[failed_asset], + failure_reasons={"fg-001": ["empty_asset"]}, + output_dir=output_dir, + asset_root=root, + edit_provider=provider, + max_attempts=2, + ) + + self.assertEqual(len(provider.calls), 2) + self.assertEqual([attempt.status for attempt in attempts], ["failed", "passed"]) + self.assertEqual(attempts[0].attempt_index, 1) + self.assertEqual(attempts[1].attempt_index, 2) + self.assertEqual(repaired[0].asset_path, "assets/0000-slide-a/fg-001.repair-2.png") + + def test_repair_revalidates_text_free_when_original_failure_was_baked_text(self): + from src.generative_editable_assets import repair_failed_assets + + class TextThenCleanRepairProvider(RecordingValidAssetRepairProvider): + def edit(self, request): + self.calls.append(request) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGBA", (40, 30), (0, 0, 0, 0)) + if len(self.calls) == 1: + ImageDraw.Draw(image).rectangle((10, 10, 30, 18), fill=(10, 10, 10, 255)) + else: + ImageDraw.Draw(image).ellipse((10, 8, 28, 24), fill=(16, 185, 129, 255)) + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output_dir = root / "assets" / "0000-slide-a" + source.parent.mkdir(parents=True) + output_dir.mkdir(parents=True) + Image.new("RGB", (120, 80), "white").save(source) + failed_asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + provider = TextThenCleanRepairProvider() + + repaired, attempts = repair_failed_assets( + source_image_path=source, + failed_assets=[failed_asset], + failure_reasons={"fg-001": ["baked_text_contamination"]}, + output_dir=output_dir, + asset_root=root, + edit_provider=provider, + max_attempts=2, + ) + + self.assertEqual([attempt.status for attempt in attempts], ["failed", "passed"]) + self.assertIn("baked_text_contamination", attempts[0].reason) + self.assertEqual(repaired[0].asset_path, "assets/0000-slide-a/fg-001.repair-2.png") + + def test_repair_rejects_wrong_object_even_when_asset_shape_qa_passes(self): + from src.generative_editable_assets import repair_failed_assets + + class WrongThenMatchingRepairProvider(RecordingValidAssetRepairProvider): + def edit(self, request): + self.calls.append(request) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGBA", (30, 22), (0, 0, 0, 0)) + if len(self.calls) == 1: + ImageDraw.Draw(image).ellipse((8, 5, 22, 19), fill=(249, 115, 22, 255)) + else: + ImageDraw.Draw(image).ellipse((8, 5, 22, 19), fill=(16, 185, 129, 255)) + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output_dir = root / "assets" / "0000-slide-a" + source.parent.mkdir(parents=True) + output_dir.mkdir(parents=True) + source_image = Image.new("RGB", (120, 80), "white") + ImageDraw.Draw(source_image).ellipse((10, 10, 40, 32), fill=(16, 185, 129)) + source_image.save(source) + failed_asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + provider = WrongThenMatchingRepairProvider() + + repaired, attempts = repair_failed_assets( + source_image_path=source, + failed_assets=[failed_asset], + failure_reasons={"fg-001": ["wrong_object"]}, + output_dir=output_dir, + asset_root=root, + edit_provider=provider, + max_attempts=2, + ) + + self.assertEqual([attempt.status for attempt in attempts], ["failed", "passed"]) + self.assertIn("source_visual_mismatch", attempts[0].reason) + self.assertEqual(repaired[0].asset_path, "assets/0000-slide-a/fg-001.repair-2.png") + + def test_repair_orchestration_records_limit_failure(self): + from src.generative_editable_assets import repair_failed_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output_dir = root / "assets" / "0000-slide-a" + source.parent.mkdir(parents=True) + output_dir.mkdir(parents=True) + Image.new("RGB", (120, 80), "white").save(source) + failed_asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + provider = RecordingImageEditProvider() + + repaired, attempts = repair_failed_assets( + source_image_path=source, + failed_assets=[failed_asset], + failure_reasons={"fg-001": ["missing_object"]}, + output_dir=output_dir, + asset_root=root, + edit_provider=provider, + max_attempts=0, + ) + + self.assertEqual(repaired, []) + self.assertEqual(provider.calls, []) + self.assertEqual(attempts[0].status, "failed") + self.assertEqual(attempts[0].reason, "repair_limit_exceeded:missing_object") + + def test_repair_history_does_not_record_synthetic_attempt_after_limit(self): + from src.generative_editable_assets import repair_failed_assets + + class AlwaysEmptyRepairProvider(RecordingValidAssetRepairProvider): + def edit(self, request): + self.calls.append(request) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (30, 22), (0, 0, 0, 0)).save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output_dir = root / "assets" / "0000-slide-a" + source.parent.mkdir(parents=True) + output_dir.mkdir(parents=True) + Image.new("RGB", (120, 80), "white").save(source) + failed_asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + provider = AlwaysEmptyRepairProvider() + + repaired, attempts = repair_failed_assets( + source_image_path=source, + failed_assets=[failed_asset], + failure_reasons={"fg-001": ["missing_object"]}, + output_dir=output_dir, + asset_root=root, + edit_provider=provider, + max_attempts=2, + ) + + self.assertEqual(repaired, []) + self.assertEqual(len(provider.calls), 2) + self.assertEqual([attempt.attempt_index for attempt in attempts], [1, 2]) + self.assertEqual(attempts[-1].reason, "repair_limit_exceeded:empty_asset,missing_object") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_backgrounds.py b/tests/test_generative_editable_backgrounds.py new file mode 100644 index 0000000..8acfd84 --- /dev/null +++ b/tests/test_generative_editable_backgrounds.py @@ -0,0 +1,640 @@ +import tempfile +import unittest +import hashlib +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw + +from src.generative_editable_config import ProviderConfig +from src.generative_editable_manifest import PageManifest +from src.generative_editable_providers import FakeImageEditProvider, ImageEditProvider + + +class RecordingImageEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="fake-image-edit", + model="fake-image-edit-model", + base_url="https://fake.local", + api_key="fake-key", + ) + ) + self.calls = [] + self.fake = FakeImageEditProvider(self.config) + + def edit(self, request): + self.calls.append(request) + return self.fake.edit(request) + + +class GenerativeEditableBackgroundsTest(unittest.TestCase): + def test_image_edit_background_outputs_are_normalized_to_source_size(self): + from src.generative_editable_backgrounds import create_base_clean_background + + class ResizingImageEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="resizing-edit", + model="resizing-edit-model", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (240, 120), "#F8FAFC").save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output = root / "backgrounds" / "0000-slide-a" / "base-clean.png" + source.parent.mkdir(parents=True) + Image.new("RGB", (120, 80), "#FFFFFF").save(source) + + result = create_base_clean_background( + source_image_path=source, + output_asset_path=output, + asset_root=root, + edit_provider=ResizingImageEditProvider(), + ) + + with Image.open(result.output_asset_path) as normalized: + normalized_size = normalized.size + + self.assertEqual(normalized_size, (120, 80)) + self.assertEqual(result.provenance["normalized_to_source_size"], [120, 80]) + + def test_base_clean_background_provider_failure_is_not_silently_downgraded(self): + from src.generative_editable_backgrounds import create_base_clean_background + from src.generative_editable_providers import ProviderError + + class FailingImageEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="failing-edit", + model="failing-edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="background edit failed", + retryable=False, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + mask = root / "assets" / "text-mask.png" + output = root / "backgrounds" / "0000-slide-a" / "base-clean.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + image = Image.new("RGB", (120, 80), "#F8FAFC") + ImageDraw.Draw(image).rectangle((30, 20, 80, 42), fill="#0F172A") + image.save(source) + mask_image = Image.new("L", (120, 80), 0) + ImageDraw.Draw(mask_image).rectangle((30, 20, 80, 42), fill=255) + mask_image.save(mask) + + with self.assertRaises(ProviderError): + create_base_clean_background( + source_image_path=source, + output_asset_path=output, + asset_root=root, + edit_provider=FailingImageEditProvider(), + text_mask_path=mask, + ) + + self.assertFalse(output.exists()) + + def test_local_fill_is_selected_for_flat_text_mask_regions(self): + from src.generative_editable_backgrounds import local_cleanup_text_mask + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "mask.png" + output = root / "backgrounds" / "text-clean.png" + Image.new("RGB", (120, 80), "#EEEEEE").save(source) + mask_image = Image.new("L", (120, 80), 0) + ImageDraw.Draw(mask_image).rectangle((30, 20, 70, 42), fill=255) + mask_image.save(mask) + + result = local_cleanup_text_mask(source, mask, output) + + self.assertEqual(result.strategy, "local_fill") + self.assertEqual(result.provider_role, "local") + self.assertEqual(result.validation_status, "passed") + self.assertEqual(result.output_asset_path, str(output)) + self.assertEqual(result.artifact_path, "backgrounds/text-clean.png") + with Image.open(output) as cleaned: + self.assertEqual(cleaned.getpixel((45, 30)), (238, 238, 238)) + + def test_local_fill_removes_dark_text_on_flat_background(self): + from src.generative_editable_backgrounds import local_cleanup_text_mask + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "mask.png" + output = root / "backgrounds" / "text-clean.png" + image = Image.new("RGB", (140, 80), "#DDEEFF") + ImageDraw.Draw(image).rectangle((34, 26, 78, 36), fill="#111827") + image.save(source) + mask_image = Image.new("L", (140, 80), 0) + ImageDraw.Draw(mask_image).rectangle((32, 24, 82, 38), fill=255) + mask_image.save(mask) + + result = local_cleanup_text_mask(source, mask, output) + + self.assertEqual(result.strategy, "local_fill") + with Image.open(output) as cleaned: + self.assertEqual(cleaned.getpixel((44, 30)), (221, 238, 255)) + + def test_source_preserving_text_background_cleans_text_without_changing_complex_visuals(self): + from src.generative_editable_backgrounds import create_source_preserving_text_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output = root / "backgrounds" / "0000-slide-a" / "source-preserving.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (220, 120), "#041225") + draw = ImageDraw.Draw(image) + draw.rectangle((96, 32, 166, 86), fill="#7D8796") + draw.rectangle((22, 20, 70, 38), fill="#FFFFFF") + image.save(source) + + result = create_source_preserving_text_background( + source_image_path=source, + text_bboxes=[(20, 18, 76, 42)], + output_asset_path=output, + asset_root=root, + ) + + with Image.open(output) as cleaned: + visual_pixel = cleaned.getpixel((120, 60)) + cleaned_text_pixel = cleaned.getpixel((36, 28)) + + self.assertEqual(result.strategy, "local_fill") + self.assertEqual(result.provider_role, "local") + self.assertEqual(result.prompt_id, "source_preserving_text_background") + self.assertEqual(result.artifact_path, "backgrounds/0000-slide-a/source-preserving.png") + self.assertEqual(visual_pixel, (125, 135, 150)) + self.assertNotEqual(cleaned_text_pixel, (255, 255, 255)) + + def test_source_raster_background_copies_source_without_cleanup(self): + from src.generative_editable_backgrounds import create_source_raster_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output = root / "backgrounds" / "0000-slide-a" / "source-raster.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (80, 45), "#041225") + draw = ImageDraw.Draw(image) + draw.rectangle((10, 10, 30, 20), fill="#FFFFFF") + image.save(source) + + result = create_source_raster_background( + source_image_path=source, + output_asset_path=output, + asset_root=root, + ) + + with Image.open(source) as original, Image.open(output) as copied: + diff_bbox = ImageChops.difference( + original.convert("RGB"), + copied.convert("RGB"), + ).getbbox() + + self.assertIsNone(diff_bbox) + self.assertEqual(result.strategy, "local_fill") + self.assertEqual(result.provider_role, "local") + self.assertEqual(result.prompt_id, "source_raster_background") + self.assertEqual(result.artifact_path, "backgrounds/0000-slide-a/source-raster.png") + + def test_direct_local_cleanup_returns_job_relative_background_artifact_ref(self): + from src.generative_editable_backgrounds import local_cleanup_text_mask + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + mask = root / "masks" / "0000-slide-a" / "text.png" + output = root / "backgrounds" / "0000-slide-a" / "text-clean.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + Image.new("RGB", (120, 80), "#EEEEEE").save(source) + mask_image = Image.new("L", (120, 80), 0) + ImageDraw.Draw(mask_image).rectangle((30, 20, 70, 42), fill=255) + mask_image.save(mask) + + result = local_cleanup_text_mask(source, mask, output, asset_root=root) + + self.assertEqual(result.artifact_path, "backgrounds/0000-slide-a/text-clean.png") + + def test_local_inpaint_is_selected_for_small_repairable_nonflat_regions(self): + from src.generative_editable_backgrounds import local_cleanup_text_mask + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "mask.png" + output = root / "backgrounds" / "text-clean.png" + image = Image.new("RGB", (120, 80), "#EEEEEE") + draw = ImageDraw.Draw(image) + for x in range(120): + color = 220 if x < 60 else 180 + draw.line((x, 0, x, 79), fill=(color, color, color)) + image.save(source) + mask_image = Image.new("L", (120, 80), 0) + ImageDraw.Draw(mask_image).rectangle((55, 30, 62, 36), fill=255) + mask_image.save(mask) + + result = local_cleanup_text_mask(source, mask, output) + + self.assertEqual(result.strategy, "local_inpaint") + self.assertEqual(result.validation_status, "passed") + self.assertTrue(output.exists()) + + def test_local_inpaint_uses_neighbor_fill_instead_of_blurring_baked_text(self): + from src.generative_editable_backgrounds import local_cleanup_text_mask + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "mask.png" + output = root / "backgrounds" / "text-clean.png" + image = Image.new("RGB", (120, 80), "#EEEEEE") + draw = ImageDraw.Draw(image) + for x in range(120): + color = 180 + (x % 16) + draw.line((x, 0, x, 79), fill=(color, color, color)) + draw.rectangle((56, 30, 63, 37), fill="#000000") + image.save(source) + mask_image = Image.new("L", (120, 80), 0) + ImageDraw.Draw(mask_image).rectangle((55, 29, 64, 38), fill=255) + mask_image.save(mask) + + result = local_cleanup_text_mask(source, mask, output) + + self.assertEqual(result.strategy, "local_inpaint") + with Image.open(output) as cleaned: + center = cleaned.getpixel((60, 34)) + self.assertGreater(min(center), 140) + + def test_text_clean_background_uses_local_cleanup_before_remote_edit(self): + from src.generative_editable_backgrounds import create_text_clean_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "text.png" + output = root / "backgrounds" / "0000-slide-a" / "text-clean.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + Image.new("RGB", (160, 90), "#F8FAFC").save(source) + mask_image = Image.new("L", (160, 90), 0) + ImageDraw.Draw(mask_image).rectangle((40, 24, 100, 44), fill=255) + mask_image.save(mask) + provider = RecordingImageEditProvider() + + result = create_text_clean_background( + source_image_path=source, + text_mask_path=mask, + output_asset_path=output, + asset_root=root, + edit_provider=provider, + ) + + self.assertEqual(result.strategy, "local_fill") + self.assertEqual(provider.calls, []) + self.assertEqual(result.artifact_path, "backgrounds/0000-slide-a/text-clean.png") + self.assertIn("local cleanup", result.provenance["decision"]) + + def test_text_clean_background_rejects_paths_outside_asset_root_before_local_cleanup(self): + from src.generative_editable_backgrounds import create_text_clean_background + + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as outside_tmp: + root = Path(tmp) + outside = Path(outside_tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "text.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + Image.new("RGB", (100, 60), "#F8FAFC").save(source) + Image.new("L", (100, 60), 255).save(mask) + provider = RecordingImageEditProvider() + outside_output = outside / "text-clean.png" + + with self.assertRaisesRegex(ValueError, "output_asset_path"): + create_text_clean_background( + source_image_path=source, + text_mask_path=mask, + output_asset_path=outside_output, + asset_root=root, + edit_provider=provider, + ) + + self.assertFalse(outside_output.exists()) + self.assertEqual(provider.calls, []) + + def test_direct_local_cleanup_rejects_outside_output_and_source_overwrite(self): + from src.generative_editable_backgrounds import local_cleanup_text_mask + + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as outside_tmp: + root = Path(tmp) + outside = Path(outside_tmp) + source = root / "source.png" + mask = root / "mask.png" + outside_output = outside / "backgrounds" / "text-clean.png" + Image.new("RGB", (100, 60), "#F8FAFC").save(source) + mask_image = Image.new("L", (100, 60), 0) + ImageDraw.Draw(mask_image).rectangle((20, 20, 40, 34), fill=255) + mask_image.save(mask) + before = hashlib.sha256(source.read_bytes()).hexdigest() + + with self.assertRaisesRegex(ValueError, "source_image_path"): + local_cleanup_text_mask(source, mask, outside_output) + with self.assertRaisesRegex(ValueError, "must not overwrite source_image_path"): + local_cleanup_text_mask(source, mask, source, asset_root=root) + + self.assertFalse(outside_output.exists()) + self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest(), before) + + def test_text_clean_background_rejects_external_source_or_mask_paths(self): + from src.generative_editable_backgrounds import create_text_clean_background + + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as outside_tmp: + root = Path(tmp) + outside = Path(outside_tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "text.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + Image.new("RGB", (100, 60), "#F8FAFC").save(source) + Image.new("L", (100, 60), 0).save(mask) + outside_source = outside / "source.png" + outside_mask = outside / "mask.png" + Image.new("RGB", (100, 60), "#F8FAFC").save(outside_source) + Image.new("L", (100, 60), 0).save(outside_mask) + provider = RecordingImageEditProvider() + + with self.assertRaisesRegex(ValueError, "source_image_path"): + create_text_clean_background( + source_image_path=outside_source, + text_mask_path=mask, + output_asset_path=root / "backgrounds" / "text-clean.png", + asset_root=root, + edit_provider=provider, + ) + with self.assertRaisesRegex(ValueError, "text_mask_path"): + create_text_clean_background( + source_image_path=source, + text_mask_path=outside_mask, + output_asset_path=root / "backgrounds" / "text-clean.png", + asset_root=root, + edit_provider=provider, + ) + + self.assertEqual(provider.calls, []) + + def test_background_generation_rejects_in_place_source_overwrite(self): + from src.generative_editable_backgrounds import ( + create_base_clean_background, + create_text_clean_background, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "text.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + Image.new("RGB", (100, 60), "#F8FAFC").save(source) + Image.new("L", (100, 60), 0).save(mask) + before = hashlib.sha256(source.read_bytes()).hexdigest() + provider = RecordingImageEditProvider() + + with self.assertRaisesRegex(ValueError, "must not overwrite source_image_path"): + create_text_clean_background( + source_image_path=source, + text_mask_path=mask, + output_asset_path=source, + asset_root=root, + edit_provider=provider, + ) + with self.assertRaisesRegex(ValueError, "must not overwrite source_image_path"): + create_base_clean_background( + source_image_path=source, + output_asset_path=source, + asset_root=root, + edit_provider=provider, + text_mask_path=mask, + ) + + self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest(), before) + self.assertEqual(provider.calls, []) + + def test_large_or_full_slide_text_masks_do_not_pass_local_fill(self): + from src.generative_editable_backgrounds import create_text_clean_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "text.png" + output = root / "backgrounds" / "0000-slide-a" / "text-clean.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + image = Image.new("RGB", (120, 80), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 100, 60), fill="#2563EB") + image.save(source) + Image.new("L", (120, 80), 255).save(mask) + provider = RecordingImageEditProvider() + + result = create_text_clean_background( + source_image_path=source, + text_mask_path=mask, + output_asset_path=output, + asset_root=root, + edit_provider=provider, + ) + + self.assertEqual(result.strategy, "image_edit") + self.assertEqual(len(provider.calls), 1) + + def test_text_clean_background_uses_image_edit_when_local_cleanup_is_insufficient(self): + from src.generative_editable_backgrounds import create_text_clean_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "text.png" + output = root / "backgrounds" / "0000-slide-a" / "text-clean.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + image = Image.new("RGB", (160, 90), "#FFFFFF") + draw = ImageDraw.Draw(image) + for x in range(160): + for y in range(90): + draw.point((x, y), fill=((x * 7) % 255, (y * 11) % 255, (x + y) % 255)) + image.save(source) + mask_image = Image.new("L", (160, 90), 0) + ImageDraw.Draw(mask_image).rectangle((20, 10, 145, 72), fill=255) + mask_image.save(mask) + provider = RecordingImageEditProvider() + + result = create_text_clean_background( + source_image_path=source, + text_mask_path=mask, + output_asset_path=output, + asset_root=root, + edit_provider=provider, + ) + + self.assertEqual(result.strategy, "image_edit") + self.assertEqual(len(provider.calls), 1) + request = provider.calls[0] + self.assertEqual(request.prompt_id, "text_clean_background") + self.assertEqual(request.mask_path, str(mask)) + self.assertIn("text-free background layer", request.prompt) + self.assertEqual(request.metadata["source_image_ref"], "sources/slide.png") + self.assertEqual(result.provider_role, "edit_model") + self.assertEqual(result.artifact_path, "backgrounds/0000-slide-a/text-clean.png") + + def test_base_clean_background_uses_image_edit_and_records_source_references(self): + from src.generative_editable_backgrounds import create_base_clean_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "text.png" + output = root / "backgrounds" / "0000-slide-a" / "base-clean.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + Image.new("RGB", (160, 90), "#FFFFFF").save(source) + Image.new("L", (160, 90), 0).save(mask) + provider = RecordingImageEditProvider() + + result = create_base_clean_background( + source_image_path=source, + output_asset_path=output, + asset_root=root, + edit_provider=provider, + text_mask_path=mask, + ) + + self.assertEqual(result.strategy, "image_edit") + self.assertEqual(len(provider.calls), 1) + request = provider.calls[0] + self.assertEqual(request.prompt_id, "base_clean_background") + self.assertEqual(request.metadata["source_image_ref"], "sources/slide.png") + self.assertEqual(request.metadata["text_mask_ref"], "masks/text.png") + self.assertEqual(request.mask_path, str(mask)) + self.assertIn("background layer only", request.prompt) + self.assertEqual(result.provider_role, "edit_model") + + def test_base_clean_background_removes_only_analysis_accepted_regions(self): + from src.generative_editable_backgrounds import create_base_clean_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + mask = root / "masks" / "accepted-text.png" + output = root / "backgrounds" / "0000-slide-a" / "base-clean.png" + source.parent.mkdir(parents=True) + mask.parent.mkdir(parents=True) + Image.new("RGB", (160, 90), "#FFFFFF").save(source) + Image.new("L", (160, 90), 0).save(mask) + provider = RecordingImageEditProvider() + + create_base_clean_background( + source_image_path=source, + output_asset_path=output, + asset_root=root, + edit_provider=provider, + text_mask_path=mask, + removal_bboxes=[(20, 10, 90, 30)], + ) + + request = provider.calls[0] + self.assertIn("Remove only the supplied mask regions", request.prompt) + self.assertIn("(20, 10, 90, 30)", request.prompt) + self.assertIn("Preserve all other readable text", request.prompt) + self.assertEqual(request.metadata["removal_bboxes"], [(20, 10, 90, 30)]) + + def test_manifest_update_preserves_source_and_records_background_strategy(self): + from src.generative_editable_backgrounds import ( + BackgroundResult, + update_page_manifest_backgrounds, + ) + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/0000-slide-a/source.png", + source_image_size=(160, 90), + slide_size=(10.0, 5.625), + provenance={"input_hash": "abc123"}, + validation_status="pending", + ) + text_result = BackgroundResult( + output_asset_path="/tmp/job/backgrounds/0000-slide-a/text-clean.png", + artifact_path="backgrounds/0000-slide-a/text-clean.png", + strategy="local_fill", + provider_role="local", + prompt_id="local_text_cleanup", + input_asset_refs=["sources/0000-slide-a/source.png"], + validation_status="passed", + provenance={"decision": "local cleanup"}, + ) + base_result = BackgroundResult( + output_asset_path="/tmp/job/backgrounds/0000-slide-a/base-clean.png", + artifact_path="backgrounds/0000-slide-a/base-clean.png", + strategy="image_edit", + provider_role="edit_model", + prompt_id="base_clean_background", + input_asset_refs=["sources/0000-slide-a/source.png"], + validation_status="passed", + provenance={"prompt_id": "base_clean_background"}, + ) + + updated = update_page_manifest_backgrounds(page, text_result, base_result) + + self.assertEqual(updated.source_image_path, page.source_image_path) + self.assertEqual(updated.text_clean_background, text_result.artifact_path) + self.assertEqual(updated.base_clean_background, base_result.artifact_path) + self.assertEqual(updated.chosen_background, base_result.artifact_path) + self.assertEqual(updated.provenance["input_hash"], "abc123") + backgrounds = updated.provenance["backgrounds"] + self.assertEqual(backgrounds["text_clean"]["strategy"], "local_fill") + self.assertEqual(backgrounds["base_clean"]["prompt_id"], "base_clean_background") + self.assertEqual(backgrounds["base_clean"]["output_asset_ref"], base_result.artifact_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_composer.py b/tests/test_generative_editable_composer.py new file mode 100644 index 0000000..b331767 --- /dev/null +++ b/tests/test_generative_editable_composer.py @@ -0,0 +1,600 @@ +import tempfile +import time +import unittest +import zipfile +from dataclasses import replace +from pathlib import Path + +from PIL import Image +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE_TYPE +from pptx.util import Inches + +from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + NativeShapeSpec, + PageManifest, + TextBoxSpec, + write_manifest, +) + + +class GenerativeEditableComposerTest(unittest.TestCase): + def test_converts_source_pixels_to_slide_coordinates_for_16_9_and_4_3(self): + from src.generative_editable_composer import slide_rect_from_source_pixels + + rect_16_9 = slide_rect_from_source_pixels( + (80, 45, 400, 225), + source_image_size=(800, 450), + aspect_ratio="16:9", + ) + rect_4_3 = slide_rect_from_source_pixels( + (80, 60, 400, 300), + source_image_size=(800, 600), + aspect_ratio="4:3", + ) + + self.assertEqual(rect_16_9.left, Inches(1)) + self.assertEqual(rect_16_9.top, Inches(0.5625)) + self.assertEqual(rect_16_9.width, Inches(4)) + self.assertEqual(rect_16_9.height, Inches(2.25)) + self.assertEqual(rect_4_3.left, Inches(1)) + self.assertEqual(rect_4_3.top, Inches(0.75)) + self.assertEqual(rect_4_3.width, Inches(4)) + self.assertEqual(rect_4_3.height, Inches(3)) + + def test_composes_page_in_background_shape_asset_text_z_order(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = self._page_manifest(root) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + page_path = root / "pages" / "0000-slide-a.json" + write_manifest(page_path, page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + slide = prs.slides[0] + shape_types = [shape.shape_type for shape in slide.shapes] + + self.assertEqual( + shape_types, + [ + MSO_SHAPE_TYPE.AUTO_SHAPE, + MSO_SHAPE_TYPE.PICTURE, + MSO_SHAPE_TYPE.TEXT_BOX, + ], + ) + + def test_composes_chosen_background_as_slide_background_not_selectable_picture(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = self._page_manifest(root) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + page_path = root / "pages" / "0000-slide-a.json" + write_manifest(page_path, page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + slide = prs.slides[0] + shape_types = [shape.shape_type for shape in slide.shapes] + slide_xml = self._slide_xml(output, 1) + + self.assertEqual( + shape_types, + [ + MSO_SHAPE_TYPE.AUTO_SHAPE, + MSO_SHAPE_TYPE.PICTURE, + MSO_SHAPE_TYPE.TEXT_BOX, + ], + ) + self.assertIn("", slide_xml) + self.assertIn("", slide_xml) + + def test_composes_approximate_layout_text_as_visible_editable_text(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = self._page_manifest( + root, + title="Structured title", + ) + page = replace( + page, + text_boxes=[ + TextBoxSpec( + text="Approximate OCR text", + source_pixel_bbox=(70, 60, 730, 120), + source_pixel_polygon=((70, 60), (730, 60), (730, 120), (70, 120)), + font_family="Arial", + font_size=18, + color_hex="#FFFFFF", + alignment="left", + style_hints={"approximate_layout": True}, + ), + *page.text_boxes, + ], + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + slide = prs.slides[0] + shape_types = [shape.shape_type for shape in slide.shapes] + texts = [shape.text for shape in slide.shapes if shape.has_text_frame and shape.text.strip()] + slide_xml = self._slide_xml(output, 1) + + self.assertEqual( + shape_types, + [ + MSO_SHAPE_TYPE.AUTO_SHAPE, + MSO_SHAPE_TYPE.PICTURE, + MSO_SHAPE_TYPE.TEXT_BOX, + MSO_SHAPE_TYPE.TEXT_BOX, + ], + ) + self.assertEqual(texts, ["Approximate OCR text", "Structured title"]) + self.assertNotIn('', slide_xml) + self.assertIn('wrap="none"', slide_xml) + + def test_composes_low_opacity_text_as_native_text_box_with_alpha(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = replace( + self._page_manifest(root), + text_boxes=[ + TextBoxSpec( + text="Editable OCR overlay", + source_pixel_bbox=(70, 60, 730, 120), + source_pixel_polygon=((70, 60), (730, 60), (730, 120), (70, 120)), + font_family="Arial", + font_size=18, + color_hex="#FFFFFF", + opacity=0.1, + ) + ], + native_shapes=[], + bitmap_assets=[], + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + slide = prs.slides[0] + text_shapes = [shape for shape in slide.shapes if shape.has_text_frame] + slide_xml = self._slide_xml(output, 1) + + self.assertEqual([shape.text for shape in text_shapes], ["Editable OCR overlay"]) + self.assertEqual(text_shapes[0].shape_type, MSO_SHAPE_TYPE.TEXT_BOX) + self.assertIn('', slide_xml) + + def test_applies_bold_text_style_hint(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = replace( + self._page_manifest(root), + text_boxes=[ + TextBoxSpec( + text="Bold heading", + source_pixel_bbox=(70, 60, 730, 120), + source_pixel_polygon=((70, 60), (730, 60), (730, 120), (70, 120)), + font_family="Arial", + font_size=24, + color_hex="#FFFFFF", + style_hints={"bold": True}, + ) + ], + native_shapes=[], + bitmap_assets=[], + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + text_shape = next(shape for shape in prs.slides[0].shapes if shape.has_text_frame) + + self.assertTrue(text_shape.text_frame.paragraphs[0].runs[0].font.bold) + + def test_expands_narrow_approximate_text_boxes_to_prevent_wrapping(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = replace( + self._page_manifest(root), + text_boxes=[ + TextBoxSpec( + text="EMB", + source_pixel_bbox=(100, 80, 112, 96), + source_pixel_polygon=((100, 80), (112, 80), (112, 96), (100, 96)), + font_size=8, + color_hex="#FFFFFF", + style_hints={"approximate_layout": True}, + ) + ], + native_shapes=[], + bitmap_assets=[], + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + shape = next(shape for shape in prs.slides[0].shapes if shape.has_text_frame) + raw_width = Inches(10) * 12 // 800 + + self.assertGreater(shape.width, raw_width * 4) + + def test_text_boxes_and_simple_shapes_are_native_powerpoint_objects(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = self._page_manifest(root) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + slide = prs.slides[0] + texts = [shape.text for shape in slide.shapes if shape.has_text_frame] + auto_shapes = [shape for shape in slide.shapes if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE] + + self.assertIn("Quarterly Plan", texts) + self.assertEqual(len(auto_shapes), 1) + self.assertEqual(auto_shapes[0].fill.fore_color.rgb.__str__(), "2563EB") + + def test_composes_multi_page_deck_order_dimensions_media_and_rebuild_deterministically(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root, slide_ids=("slide-b", "slide-a"), image_size=(800, 600)) + page_b = self._page_manifest( + root, + slide_id="slide-b", + page_index=0, + title="Slide B", + source_image_size=(800, 600), + slide_size=(10.0, 7.5), + ) + page_a = self._page_manifest( + root, + slide_id="slide-a", + page_index=1, + title="Slide A", + source_image_size=(800, 600), + slide_size=(10.0, 7.5), + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-b", "slide-a"], + aspect_ratio="4:3", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-b.json", "pages/0001-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-b.json", page_b) + write_manifest(root / "pages" / "0001-slide-a.json", page_a) + write_manifest(root / "deck.json", deck) + output_one = root / "one.pptx" + output_two = root / "two.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output_one) + time.sleep(2) + compose_deck_from_manifests(root / "deck.json", root, output_two) + prs = Presentation(str(output_one)) + media_names = self._media_names(output_one) + output_one_bytes = output_one.read_bytes() + output_two_bytes = output_two.read_bytes() + + self.assertEqual(len(prs.slides), 2) + self.assertEqual(prs.slide_width, Inches(10)) + self.assertEqual(prs.slide_height, Inches(7.5)) + self.assertEqual(prs.slides[0].shapes[-1].text, "Slide B") + self.assertEqual(prs.slides[1].shapes[-1].text, "Slide A") + self.assertTrue(any(name.endswith(".png") for name in media_names)) + self.assertEqual(output_one_bytes, output_two_bytes) + + def test_rejects_page_geometry_that_disagrees_with_deck_aspect_ratio(self): + from src.generative_editable_composer import ( + CompositionGeometryError, + compose_deck_from_manifests, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = self._page_manifest(root) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="4:3", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + + with self.assertRaisesRegex(CompositionGeometryError, "aspect ratio"): + compose_deck_from_manifests(root / "deck.json", root, root / "out.pptx") + + def test_handles_empty_text_boxes_and_scales_line_stroke_width(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = self._page_manifest(root, title="") + page = replace( + page, + native_shapes=[ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(100, 100, 180, 104), + line_start=(100, 102), + line_end=(180, 102), + line_color="#111827", + stroke_width=4, + confidence=0.95, + ) + ], + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + xml = self._slide_xml(output, 1) + prs = Presentation(str(output)) + + self.assertEqual(prs.slides[0].shapes[-1].text, "") + self.assertIn('w="45720"', xml) + self.assertIn("", xml) + + def test_applies_explicit_native_shape_fill_line_and_width_defaults(self): + from src.generative_editable_composer import compose_deck_from_manifests + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_fixture_assets(root) + page = self._page_manifest(root) + page = replace( + page, + native_shapes=[ + NativeShapeSpec( + shape_type="rectangle", + source_pixel_bbox=(60, 140, 300, 220), + fill_color="", + line_color="", + confidence=0.95, + ), + NativeShapeSpec( + shape_type="ellipse", + source_pixel_bbox=(340, 140, 460, 220), + fill_color="#22C55E", + line_color="#14532D", + stroke_width=4, + opacity=0.5, + confidence=0.96, + ), + ], + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + output = root / "out.pptx" + + compose_deck_from_manifests(root / "deck.json", root, output) + xml = self._slide_xml(output, 1) + + self.assertIn("", xml) + self.assertIn('w="45720"', xml) + self.assertIn('val="50000"', xml) + + def test_missing_artifacts_raise_structured_composition_error(self): + from src.generative_editable_composer import ( + CompositionArtifactError, + compose_deck_from_manifests, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + page = self._page_manifest(root) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + + with self.assertRaisesRegex(CompositionArtifactError, "background"): + compose_deck_from_manifests(root / "deck.json", root, root / "out.pptx") + + def _write_fixture_assets(self, root: Path, slide_ids=("slide-a",), image_size=(800, 450)): + for index, slide_id in enumerate(slide_ids): + suffix = f"{index:04d}-{slide_id}" + (root / "backgrounds" / suffix).mkdir(parents=True, exist_ok=True) + (root / "assets" / suffix).mkdir(parents=True, exist_ok=True) + Image.new("RGB", image_size, "#F8FAFC").save( + root / "backgrounds" / suffix / "base.png" + ) + asset = Image.new("RGBA", (120, 80), (0, 0, 0, 0)) + for x in range(20, 100): + for y in range(18, 62): + asset.putpixel((x, y), (16, 185, 129, 255)) + asset.save(root / "assets" / suffix / "asset.png") + + def _page_manifest( + self, + root: Path, + *, + slide_id="slide-a", + page_index=0, + title="Quarterly Plan", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + ): + suffix = f"{page_index:04d}-{slide_id}" + return PageManifest( + slide_id=slide_id, + page_index=page_index, + source_image_path=f"sources/{suffix}/source.png", + source_image_size=source_image_size, + slide_size=slide_size, + chosen_background=f"backgrounds/{suffix}/base.png", + text_boxes=[ + TextBoxSpec( + text=title, + source_pixel_bbox=(80, 54, 420, 102), + source_pixel_polygon=((80, 54), (420, 54), (420, 102), (80, 102)), + font_family="Arial", + font_size=28, + color_hex="#111827", + alignment="center", + ) + ], + native_shapes=[ + NativeShapeSpec( + shape_type="rectangle", + source_pixel_bbox=(60, 140, 300, 220), + fill_color="#2563EB", + line_color="#1D4ED8", + confidence=0.95, + ) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="asset-1", + source_pixel_bbox=(340, 120, 460, 200), + asset_path=f"assets/{suffix}/asset.png", + z_order=1, + ) + ], + ) + + def _media_names(self, pptx_path: Path): + with zipfile.ZipFile(pptx_path) as package: + return sorted(name for name in package.namelist() if name.startswith("ppt/media/")) + + def _slide_xml(self, pptx_path: Path, slide_number: int): + with zipfile.ZipFile(pptx_path) as package: + return package.read(f"ppt/slides/slide{slide_number}.xml").decode("utf-8") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_config.py b/tests/test_generative_editable_config.py new file mode 100644 index 0000000..8e09afe --- /dev/null +++ b/tests/test_generative_editable_config.py @@ -0,0 +1,282 @@ +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +from src.generative_editable_config import ( + GenerativeEditableConfigError, + load_generative_editable_config, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class GenerativeEditableConfigTest(unittest.TestCase): + def test_example_config_documents_provider_roles_and_quality_controls(self): + example = yaml.safe_load((ROOT / "config.example.yaml").read_text(encoding="utf-8")) + + models = example["api"]["models"] + expected_providers = { + "ocr_model": "your-ocr-api-key", + "image_model": "your-image-api-key", + "edit_model": "your-edit-api-key", + } + for role, placeholder_key in expected_providers.items(): + self.assertIn(role, models) + self.assertIn("base_url", models[role]) + self.assertIn("api_key", models[role]) + self.assertIn("model", models[role]) + self.assertEqual(models[role]["api_key"], placeholder_key) + self.assertNotIn("provider", models["ocr_model"]) + self.assertNotIn("adapter", models["ocr_model"]) + self.assertNotIn("adapter", models["image_model"]) + self.assertNotIn("adapter", models["edit_model"]) + + generative = example["generative_editable_pptx"] + self.assertEqual(generative["reconstruction"]["mode"], "vlm_first") + self.assertEqual(generative["reconstruction"]["clean_base_model"], "edit_model") + self.assertEqual(generative["reconstruction"]["asset_sheet_model"], "edit_model") + self.assertEqual(generative["reconstruction"]["repair_model"], "edit_model") + self.assertEqual(generative["reconstruction"]["generation_model"], "image_model") + self.assertEqual(generative["ocr"]["model"], "ocr_model") + self.assertTrue(generative["ocr"]["use_aippt_metadata_first"]) + self.assertIn("max_repair_attempts", generative["quality"]) + self.assertIn("preview_similarity_threshold", generative["quality"]) + self.assertIn("provider_max_attempts", generative["retries"]) + self.assertIn("repair_max_attempts", generative["retries"]) + self.assertIn("backoff_seconds", generative["retries"]) + self.assertIn("provider_call", generative["timeouts"]) + self.assertIn("page", generative["timeouts"]) + + def test_loads_provider_settings_from_project_config(self): + config = { + "api": { + "models": { + "ocr_model": { + "api_key": "ocr-secret", + "base_url": "https://ocr.example/api", + "model": "PaddleOCR-VL-1.6", + }, + "image_model": { + "api_key": "image-secret", + "base_url": "https://image.example/v1", + "model": "gpt-image-2", + "adapter": "raw_chat_multimodal", + }, + "edit_model": { + "api_key": "edit-secret", + "base_url": "https://edit.example/v1", + "model": "gpt-image-2", + "adapter": "raw_chat_multimodal", + }, + } + }, + "generative_editable_pptx": { + "reconstruction": { + "mode": "generative", + "clean_base_model": "edit_model", + "asset_sheet_model": "edit_model", + "repair_model": "edit_model", + "generation_model": "image_model", + }, + "ocr": {"model": "ocr_model", "use_aippt_metadata_first": True, "min_confidence": 0.81}, + "quality": {"max_repair_attempts": 3, "preview_similarity_threshold": 0.9}, + "retries": {"provider_max_attempts": 4, "repair_max_attempts": 2, "backoff_seconds": 0.5}, + "timeouts": {"provider_call": 120, "page": 500}, + }, + } + + with patch("src.generative_editable_config.get_config", return_value=config): + loaded = load_generative_editable_config() + + self.assertEqual(loaded.ocr.model, "PaddleOCR-VL-1.6") + self.assertEqual(loaded.reconstruction_mode, "generative") + self.assertEqual(loaded.ocr.api_key, "ocr-secret") + self.assertEqual(loaded.ocr.provider, "") + self.assertEqual(loaded.clean_base_model.api_key, "edit-secret") + self.assertEqual(loaded.asset_sheet_model.base_url, "https://edit.example/v1") + self.assertEqual(loaded.generation_model.model, "gpt-image-2") + self.assertEqual(loaded.ocr_min_confidence, 0.81) + self.assertEqual(loaded.quality.max_repair_attempts, 3) + self.assertEqual(loaded.retries.provider_max_attempts, 4) + self.assertEqual(loaded.retries.backoff_seconds, 0.5) + self.assertEqual(loaded.timeouts.page, 500) + + def test_edit_model_roles_inherit_image_model_when_edit_model_is_omitted(self): + config = { + "api": { + "models": { + "ocr_model": { + "api_key": "ocr-secret", + "base_url": "https://ocr.example/api", + "model": "PaddleOCR-VL-1.6", + }, + "image_model": { + "api_key": "image-secret", + "base_url": "https://image.example/v1", + "model": "gpt-image-2", + "adapter": "raw_chat_multimodal", + }, + } + }, + "generative_editable_pptx": { + "reconstruction": { + "clean_base_model": "edit_model", + "asset_sheet_model": "edit_model", + "repair_model": "edit_model", + "generation_model": "image_model", + }, + "ocr": {"model": "ocr_model"}, + }, + } + + with patch("src.generative_editable_config.get_config", return_value=config): + loaded = load_generative_editable_config() + + self.assertEqual(loaded.clean_base_model.role, "edit_model") + self.assertEqual(loaded.clean_base_model.api_key, "image-secret") + self.assertEqual(loaded.asset_sheet_model.model, "gpt-image-2") + + def test_remote_ocr_provider_does_not_require_provider_or_adapter_fields(self): + config = { + "api": { + "models": { + "ocr_model": { + "api_key": "ocr-secret", + "base_url": "https://ocr.example/v1", + "model": "PaddlePaddle/PaddleOCR-VL-1.5", + }, + "image_model": { + "api_key": "image-secret", + "base_url": "https://image.example/v1", + "model": "gpt-image-2", + }, + } + }, + "generative_editable_pptx": { + "ocr": {"model": "ocr_model"}, + }, + } + + with patch("src.generative_editable_config.get_config", return_value=config): + loaded = load_generative_editable_config() + + self.assertEqual(loaded.ocr.provider, "") + self.assertEqual(loaded.ocr.adapter, "") + self.assertEqual(loaded.ocr.model, "PaddlePaddle/PaddleOCR-VL-1.5") + + def test_loader_ignores_stale_ocr_provider_and_adapter_fields(self): + config = { + "api": { + "models": { + "ocr_model": { + "provider": "local_tesseract", + "adapter": "unused", + "api_key": "ocr-secret", + "base_url": "https://ocr.example/v1", + "model": "PaddlePaddle/PaddleOCR-VL-1.5", + }, + "image_model": { + "api_key": "image-secret", + "base_url": "https://image.example/v1", + "model": "gpt-image-2", + }, + } + }, + "generative_editable_pptx": { + "ocr": {"model": "ocr_model"}, + }, + } + + with patch("src.generative_editable_config.get_config", return_value=config): + loaded = load_generative_editable_config() + + self.assertEqual(loaded.ocr.provider, "") + self.assertEqual(loaded.ocr.adapter, "") + self.assertEqual(loaded.ocr.base_url, "https://ocr.example/v1") + + def test_missing_provider_settings_raise_redacted_error(self): + config = { + "api": {"models": {"ocr_model": {"api_key": "do-not-leak"}}}, + "generative_editable_pptx": { + "reconstruction": {"clean_base_model": "edit_model"}, + "ocr": {"model": "ocr_model"}, + }, + } + + with ( + patch("src.generative_editable_config.get_config", return_value=config), + self.assertRaises(GenerativeEditableConfigError) as ctx, + ): + load_generative_editable_config() + + message = str(ctx.exception) + self.assertIn("ocr_model", message) + self.assertIn("image_model", message) + self.assertIn("edit_model", message) + self.assertNotIn("do-not-leak", message) + + def test_string_boolean_settings_are_parsed_explicitly(self): + config = { + "api": { + "models": { + "ocr_model": { + "provider": "paddle_ocr_vl", + "api_key": "ocr-secret", + "base_url": "https://ocr.example/api", + "model": "PaddleOCR-VL-1.6", + }, + "image_model": { + "api_key": "image-secret", + "base_url": "https://image.example/v1", + "model": "gpt-image-2", + }, + } + }, + "generative_editable_pptx": { + "ocr": {"model": "ocr_model", "use_aippt_metadata_first": "false"}, + "quality": {"require_preview_validation": "false"}, + }, + } + + with patch("src.generative_editable_config.get_config", return_value=config): + loaded = load_generative_editable_config() + + self.assertFalse(loaded.use_aippt_metadata_first) + self.assertFalse(loaded.quality.require_preview_validation) + + def test_null_provider_role_names_raise_redacted_config_error(self): + config = { + "api": {"models": {}}, + "generative_editable_pptx": { + "reconstruction": {"clean_base_model": None}, + "ocr": {"model": None}, + }, + } + + with ( + patch("src.generative_editable_config.get_config", return_value=config), + self.assertRaises(GenerativeEditableConfigError) as ctx, + ): + load_generative_editable_config() + + self.assertIn("generative editable provider role must be a non-empty string", str(ctx.exception)) + + def test_fake_provider_config_does_not_read_live_credentials(self): + with patch("src.generative_editable_config.get_config") as get_config: + loaded = load_generative_editable_config(use_fake=True) + + get_config.assert_not_called() + self.assertEqual(loaded.ocr.provider, "fake_ocr") + self.assertEqual(loaded.ocr.api_key, "") + self.assertEqual(loaded.clean_base_model.provider, "fake_image_edit") + self.assertEqual(loaded.asset_sheet_model.provider, "fake_image_edit") + self.assertEqual(loaded.generation_model.provider, "fake_image_generation") + self.assertEqual(loaded.quality.max_repair_attempts, 0) + self.assertEqual(loaded.retries.provider_max_attempts, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_export_contract.py b/tests/test_generative_editable_export_contract.py new file mode 100644 index 0000000..c8f4924 --- /dev/null +++ b/tests/test_generative_editable_export_contract.py @@ -0,0 +1,373 @@ +import asyncio +import base64 +import tempfile +import unittest +from pathlib import Path + +from PIL import Image +from pydantic import ValidationError + +from api.models import ExportRequest, ExportSlide +from api.routes.export import export_presentation +import api.routes.export as export_route + + +def _slide_base64(size=(800, 450), color="white") -> str: + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", size, color).save(image_path) + return base64.b64encode(image_path.read_bytes()).decode() + + +def _fake_route_dependencies(*, validation_status="passed"): + return export_route._build_fake_generative_editable_pipeline_dependencies( + validation_status=validation_status + ) + + +def _fake_vlm_route_dependencies(*, validation_status="passed"): + from src.generative_editable_config import ProviderConfig + from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + from src.generative_editable_providers import FakeImageEditProvider + from src.generative_editable_vlm_reconstruction import ( + FakeVLMPageAnalysisProvider, + VLMEditablePipelineDependencies, + ) + + def preview_validator(**kwargs): + if validation_status == "failed": + return ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_similarity_failed", + message="forced preview failure", + slide_id=kwargs["slide_id"], + ) + ], + ) + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + return VLMEditablePipelineDependencies( + vlm_provider=FakeVLMPageAnalysisProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + preview_validator=preview_validator, + ) + + +class GenerativeEditableExportContractTest(unittest.TestCase): + def test_accepts_generative_editable_pptx_format(self): + request = ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ) + + self.assertEqual(request.format, "generative_editable_pptx") + + def test_rejects_unknown_export_format(self): + with self.assertRaises(ValidationError): + ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="docx", + ) + + def test_request_carries_slide_order_fallback_policy_and_text_metadata(self): + request = ExportRequest( + slides=[ + ExportSlide( + image_base64=_slide_base64(color="white"), + slide_id="slide-a", + text_metadata=[ + { + "text": "Quarterly Plan", + "role": "title", + "order": 1, + "style_hint": {"font_size": 32, "bold": True}, + } + ], + ), + ExportSlide( + image_base64=_slide_base64(color="black"), + slide_id="slide-b", + text_metadata=[], + ), + ], + format="generative_editable_pptx", + aspect_ratio="4:3", + slide_order=["slide-b", "slide-a"], + editable_options={"fallback_policy": "text_editable_background"}, + ) + + dumped = request.model_dump() + + self.assertEqual(dumped["aspect_ratio"], "4:3") + self.assertEqual(dumped["slide_order"], ["slide-b", "slide-a"]) + self.assertEqual(dumped["editable_options"]["fallback_policy"], "text_editable_background") + self.assertEqual(dumped["slides"][0]["slide_id"], "slide-a") + self.assertEqual(dumped["slides"][0]["text_metadata"][0]["text"], "Quarterly Plan") + self.assertEqual(dumped["slides"][0]["text_metadata"][0]["style_hint"]["bold"], True) + + def test_default_fallback_policy_is_fail(self): + request = ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ) + + self.assertEqual(request.editable_options.fallback_policy, "fail") + + def test_rejects_invalid_fallback_policy(self): + with self.assertRaises(ValidationError): + ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + editable_options={"fallback_policy": "best_effort"}, + ) + + def test_generative_editable_export_uses_distinct_pptx_response(self): + thread_calls = [] + + async def fake_to_thread(func, *args, **kwargs): + thread_calls.append((func, args, kwargs)) + return func(*args, **kwargs) + + def fake_export_generative_editable_pptx( + image_paths, + output_path, + *, + aspect_ratio="16:9", + slides=None, + editable_options=None, + slide_order=None, + ): + self.assertEqual(slide_order, ["slide-1"]) + Path(output_path).write_bytes(b"generative-pptx") + + original_to_thread = asyncio.to_thread + original_exporter = export_route._export_generative_editable_pptx + asyncio.to_thread = fake_to_thread + export_route._export_generative_editable_pptx = fake_export_generative_editable_pptx + try: + request = ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + slide_order=["slide-1"], + ) + + response = asyncio.run(export_presentation(request)) + + self.assertEqual(Path(response.path).read_bytes(), b"generative-pptx") + self.assertEqual(response.filename, "presentation.generative-editable.pptx") + self.assertEqual( + response.media_type, + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + self.assertEqual( + [getattr(call[0], "__name__", "") for call in thread_calls], + ["fake_export_generative_editable_pptx"], + ) + self.assertEqual(thread_calls[0][2]["slide_order"], ["slide-1"]) + asyncio.run(response.background()) + finally: + asyncio.to_thread = original_to_thread + export_route._export_generative_editable_pptx = original_exporter + + def test_generative_editable_export_exposes_fallback_response_metadata(self): + thread_calls = [] + + async def fake_to_thread(func, *args, **kwargs): + thread_calls.append((func, args, kwargs)) + return func(*args, **kwargs) + + def fake_export_pptx(image_paths, output_path, aspect_ratio="16:9"): + Path(output_path).write_bytes(b"raster-fallback") + + original_to_thread = asyncio.to_thread + original_export_pptx = export_route._export_pptx + original_dependencies = export_route._build_generative_editable_pipeline_dependencies + original_vlm_dependencies = export_route._build_vlm_editable_pipeline_dependencies + asyncio.to_thread = fake_to_thread + export_route._export_pptx = fake_export_pptx + export_route._build_generative_editable_pipeline_dependencies = ( + lambda: _fake_route_dependencies(validation_status="failed") + ) + export_route._build_vlm_editable_pipeline_dependencies = ( + lambda config: _fake_vlm_route_dependencies(validation_status="failed") + ) + try: + request = ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + editable_options={"fallback_policy": "raster_pptx"}, + ) + + response = asyncio.run(export_presentation(request)) + + self.assertEqual(Path(response.path).read_bytes(), b"raster-fallback") + self.assertEqual(response.headers["X-Generative-Editable-Status"], "fallback_used") + self.assertEqual( + response.headers["X-Generative-Editable-Fallback-Policy"], "raster_pptx" + ) + self.assertEqual( + response.headers["X-Generative-Editable-Fallback-Used"], "raster_pptx" + ) + self.assertEqual( + [getattr(call[0], "__name__", "") for call in thread_calls], + ["_export_generative_editable_pptx"], + ) + asyncio.run(response.background()) + finally: + asyncio.to_thread = original_to_thread + export_route._export_pptx = original_export_pptx + export_route._build_generative_editable_pipeline_dependencies = original_dependencies + export_route._build_vlm_editable_pipeline_dependencies = original_vlm_dependencies + + def test_temporary_generative_export_does_not_silently_return_raster_output(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "out.pptx" + Image.new("RGB", (800, 450), "white").save(image_path) + + original_dependencies = export_route._build_generative_editable_pipeline_dependencies + original_vlm_dependencies = export_route._build_vlm_editable_pipeline_dependencies + export_route._build_generative_editable_pipeline_dependencies = ( + lambda: _fake_route_dependencies(validation_status="failed") + ) + export_route._build_vlm_editable_pipeline_dependencies = ( + lambda config: _fake_vlm_route_dependencies(validation_status="failed") + ) + try: + with self.assertRaisesRegex(RuntimeError, "validation failed"): + export_route._export_generative_editable_pptx( + [str(image_path)], + str(output_path), + editable_options=ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ).editable_options, + ) + finally: + export_route._build_generative_editable_pipeline_dependencies = original_dependencies + export_route._build_vlm_editable_pipeline_dependencies = original_vlm_dependencies + + self.assertFalse(output_path.exists()) + + def test_generative_export_accepts_explicit_raster_fallback_policy(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "out.pptx" + Image.new("RGB", (800, 450), "white").save(image_path) + + original_export_pptx = export_route._export_pptx + original_dependencies = export_route._build_generative_editable_pipeline_dependencies + original_vlm_dependencies = export_route._build_vlm_editable_pipeline_dependencies + export_route._export_pptx = lambda image_paths, output_path, aspect_ratio="16:9": Path( + output_path + ).write_bytes(b"raster-fallback") + export_route._build_generative_editable_pipeline_dependencies = ( + lambda: _fake_route_dependencies(validation_status="failed") + ) + export_route._build_vlm_editable_pipeline_dependencies = ( + lambda config: _fake_vlm_route_dependencies(validation_status="failed") + ) + try: + export_route._export_generative_editable_pptx( + [str(image_path)], + str(output_path), + editable_options={ + "fallback_policy": "raster_pptx", + }, + ) + finally: + export_route._export_pptx = original_export_pptx + export_route._build_generative_editable_pipeline_dependencies = original_dependencies + export_route._build_vlm_editable_pipeline_dependencies = original_vlm_dependencies + + self.assertEqual(output_path.read_bytes(), b"raster-fallback") + + def test_generative_export_raster_fallback_preserves_slide_order(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first.png" + second = root / "second.png" + output_path = root / "out.pptx" + Image.new("RGB", (800, 450), "white").save(first) + Image.new("RGB", (800, 450), "black").save(second) + captured = {} + + def fake_export_pptx(image_paths, output_path, aspect_ratio="16:9"): + captured["image_paths"] = list(image_paths) + Path(output_path).write_bytes(b"raster-fallback") + + original_export_pptx = export_route._export_pptx + original_dependencies = export_route._build_generative_editable_pipeline_dependencies + original_vlm_dependencies = export_route._build_vlm_editable_pipeline_dependencies + export_route._export_pptx = fake_export_pptx + export_route._build_generative_editable_pipeline_dependencies = ( + lambda: _fake_route_dependencies(validation_status="failed") + ) + export_route._build_vlm_editable_pipeline_dependencies = ( + lambda config: _fake_vlm_route_dependencies(validation_status="failed") + ) + try: + export_route._export_generative_editable_pptx( + [str(first), str(second)], + str(output_path), + slides=[ + ExportSlide(image_base64=_slide_base64(), slide_id="slide-a"), + ExportSlide(image_base64=_slide_base64(), slide_id="slide-b"), + ], + slide_order=["slide-b", "slide-a"], + editable_options={"fallback_policy": "raster_pptx"}, + ) + finally: + export_route._export_pptx = original_export_pptx + export_route._build_generative_editable_pipeline_dependencies = original_dependencies + export_route._build_vlm_editable_pipeline_dependencies = original_vlm_dependencies + + self.assertEqual(captured["image_paths"], [str(second), str(first)]) + + def test_existing_pptx_export_still_uses_raster_exporter(self): + thread_calls = [] + + async def fake_to_thread(func, *args, **kwargs): + thread_calls.append(func) + return func(*args, **kwargs) + + def fake_export_pptx(image_paths, output_path, aspect_ratio="16:9"): + Path(output_path).write_bytes(b"raster-pptx") + + original_to_thread = asyncio.to_thread + original_export_pptx = export_route._export_pptx + asyncio.to_thread = fake_to_thread + export_route._export_pptx = fake_export_pptx + try: + request = ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="pptx", + ) + + response = asyncio.run(export_presentation(request)) + + self.assertEqual(Path(response.path).read_bytes(), b"raster-pptx") + self.assertEqual(response.filename, "presentation.pptx") + self.assertEqual( + [getattr(func, "__name__", "") for func in thread_calls], + ["fake_export_pptx"], + ) + asyncio.run(response.background()) + finally: + asyncio.to_thread = original_to_thread + export_route._export_pptx = original_export_pptx + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_export_route.py b/tests/test_generative_editable_export_route.py new file mode 100644 index 0000000..09f752f --- /dev/null +++ b/tests/test_generative_editable_export_route.py @@ -0,0 +1,784 @@ +import asyncio +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from PIL import Image +from fastapi import HTTPException + +from api.models import ExportRequest, ExportSlide +import api.routes.export as export_route + + +def _slide_base64() -> str: + from tests.test_generative_editable_export_contract import _slide_base64 as make_slide + + return make_slide() + + +def _fake_route_dependencies( + *, + validation_status="passed", + validation_code="preview_similarity_failed", + provider_exception=None, + repair_limit_failure=False, +): + dependencies = export_route._build_fake_generative_editable_pipeline_dependencies( + validation_status=validation_status, + validation_code=validation_code, + ) + if provider_exception is not None: + dependencies = _with_raising_ocr_provider(dependencies, provider_exception) + if repair_limit_failure: + dependencies = _with_repair_limit_asset_builder(dependencies) + return dependencies + + +def _legacy_generative_config(): + from dataclasses import replace + + from src.generative_editable_config import load_generative_editable_config + + return replace(load_generative_editable_config(use_fake=True), reconstruction_mode="generative") + + +def _with_raising_ocr_provider(dependencies, exception): + class RaisingOCRProvider: + config = dependencies.ocr_provider.config + + def extract_text(self, image_path): + raise exception + + from dataclasses import replace + + return replace(dependencies, ocr_provider=RaisingOCRProvider()) + + +def _with_repair_limit_asset_builder(dependencies): + from dataclasses import replace + + from src.generative_editable_manifest import BitmapAssetSpec, RepairAttempt + from src.generative_editable_pipeline import AssetBuildResult + + def asset_builder(**kwargs): + asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(500, 120, 620, 220), + asset_path=f"assets/{kwargs['page_index']:04d}-{kwargs['slide_id']}/fg-001.png", + z_order=1, + ) + Path(kwargs["asset_root"], asset.asset_path).parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (120, 100), (0, 0, 0, 0)).save( + Path(kwargs["asset_root"], asset.asset_path) + ) + return AssetBuildResult( + bitmap_assets=[asset], + repair_attempts=[ + RepairAttempt( + target_id="fg-001", + attempt_index=1, + reason="repair_limit_exceeded:edge_touch", + provider_role="edit_model", + status="failed", + ) + ], + ) + + return replace(dependencies, asset_builder=asset_builder) + + +class GenerativeEditableExportRouteTest(unittest.TestCase): + def setUp(self): + self._config_patcher = patch( + "api.routes.export.load_generative_editable_config", + return_value=_legacy_generative_config(), + ) + self._config_patcher.start() + + def tearDown(self): + self._config_patcher.stop() + + def test_export_uses_vlm_first_pipeline_when_configured(self): + from src.generative_editable_config import ( + GenerativeEditableConfig, + ProviderConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) + + config = GenerativeEditableConfig( + reconstruction_mode="vlm_first", + ocr=ProviderConfig(role="ocr_model", model="ocr", base_url="fake", api_key="fake"), + clean_base_model=ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="edit", + base_url="fake", + api_key="fake", + ), + asset_sheet_model=ProviderConfig( + role="asset_sheet_edit_model", + provider="fake_image_edit", + model="asset-sheet", + base_url="fake", + api_key="fake", + ), + repair_model=ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="edit", + base_url="fake", + api_key="fake", + ), + generation_model=ProviderConfig(role="image_model", model="image", base_url="fake", api_key="fake"), + use_aippt_metadata_first=False, + ocr_min_confidence=0.75, + quality=QualityConfig(), + retries=RetryConfig(), + timeouts=TimeoutConfig(), + ) + captured = {} + dependency_sentinel = object() + + def fake_vlm_pipeline(**kwargs): + captured.update(kwargs) + Path(kwargs["output_path"]).write_bytes(b"pptx") + return type( + "Result", + (), + { + "status": "passed", + "output_path": kwargs["output_path"], + "fallback_policy": "fail", + "fallback_used": "", + "validation_report": None, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + output = root / "out.pptx" + with ( + patch("api.routes.export.load_generative_editable_config", return_value=config), + patch("api.routes.export.run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch( + "api.routes.export._build_vlm_editable_pipeline_dependencies", + return_value=dependency_sentinel, + ), + ): + result = export_route._export_generative_editable_pptx( + [str(source)], + str(output), + aspect_ratio="16:9", + ) + + self.assertEqual(result.status, "passed") + self.assertIs(captured["dependencies"], dependency_sentinel) + self.assertEqual(captured["slides"][0].slide_id, "slide-1") + + def test_public_route_maps_vlm_page_timeout_from_worker_thread_to_504(self): + import time as clock + from dataclasses import replace + + from src.generative_editable_config import TimeoutConfig, load_generative_editable_config + from src.generative_editable_providers import FakeImageEditProvider + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + ) + + class SlowVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + clock.sleep(0.2) + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + config = replace( + load_generative_editable_config(use_fake=True), + reconstruction_mode="vlm_first", + timeouts=TimeoutConfig(provider_call=5, page=0.05), + ) + dependencies = VLMEditablePipelineDependencies( + vlm_provider=SlowVLMProvider(), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + page_timeout_seconds=0.05, + ) + request = ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ) + + with ( + patch("api.routes.export.load_generative_editable_config", return_value=config), + patch( + "api.routes.export._build_vlm_editable_pipeline_dependencies", + return_value=dependencies, + ), + ): + with self.assertRaises(HTTPException) as ctx: + asyncio.run(export_route.export_presentation(request)) + + self.assertEqual(ctx.exception.status_code, 504) + self.assertIn("vlm_first.build_page_manifest", ctx.exception.detail) + + def test_live_dependency_builder_does_not_require_provider_adapter_fields(self): + from src.generative_editable_config import ( + GenerativeEditableConfig, + ProviderConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) + + config = GenerativeEditableConfig( + ocr=ProviderConfig( + role="ocr_model", + model="ocr", + base_url="https://provider.example/v1", + api_key="secret", + ), + clean_base_model=ProviderConfig( + role="edit_model", + model="edit", + base_url="https://provider.example/v1", + api_key="secret", + adapter="multipart_images", + ), + asset_sheet_model=ProviderConfig( + role="edit_model", + model="edit", + base_url="https://provider.example/v1", + api_key="secret", + adapter="multipart_images", + ), + repair_model=ProviderConfig( + role="edit_model", + model="edit", + base_url="https://provider.example/v1", + api_key="secret", + adapter="multipart_images", + ), + generation_model=ProviderConfig( + role="image_model", + model="image", + base_url="https://provider.example/v1", + api_key="secret", + adapter="unexpected", + ), + use_aippt_metadata_first=False, + ocr_min_confidence=0.75, + quality=QualityConfig(), + retries=RetryConfig(), + timeouts=TimeoutConfig(), + ) + + with patch("api.routes.export.load_generative_editable_config", return_value=config): + dependencies = export_route._build_generative_editable_pipeline_dependencies() + + self.assertEqual(dependencies.ocr_provider.config.role, "ocr_model") + self.assertEqual(dependencies.image_edit_provider.config.role, "edit_model") + self.assertEqual(dependencies.image_generation_provider.config.role, "image_model") + + def test_vlm_dependency_builder_wires_vlm_edit_and_ocr_providers(self): + from src.generative_editable_config import ( + GenerativeEditableConfig, + ProviderConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) + + config = GenerativeEditableConfig( + reconstruction_mode="vlm_first", + ocr=ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="ocr", + base_url="fake", + api_key="fake", + ), + clean_base_model=ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="edit", + base_url="fake", + api_key="fake", + ), + asset_sheet_model=ProviderConfig( + role="asset_sheet_edit_model", + provider="fake_image_edit", + model="asset-sheet", + base_url="fake", + api_key="fake", + ), + repair_model=ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="edit", + base_url="fake", + api_key="fake", + ), + generation_model=ProviderConfig( + role="image_model", + provider="fake_image_generation", + model="image", + base_url="fake", + api_key="fake", + ), + use_aippt_metadata_first=False, + ocr_min_confidence=0.83, + quality=QualityConfig(preview_similarity_threshold=0.86, require_preview_validation=True), + retries=RetryConfig(provider_max_attempts=4, backoff_seconds=0.5), + timeouts=TimeoutConfig(provider_call=77, page=500), + ) + vlm_profile = SimpleNamespace( + model="vlm", + base_url="https://vlm.example/v1", + api_key="vlm-secret", + adapter="openai_chat", + ) + + with patch("api.routes.export.load_default_profiles", return_value=SimpleNamespace(vlm=vlm_profile)): + dependencies = export_route._build_vlm_editable_pipeline_dependencies(config) + + self.assertEqual(dependencies.vlm_provider.config.model, "vlm") + self.assertEqual(dependencies.image_edit_provider.config.role, "edit_model") + self.assertEqual(dependencies.asset_sheet_image_edit_provider.config.role, "asset_sheet_edit_model") + self.assertEqual(dependencies.ocr_provider.config.role, "ocr_model") + self.assertEqual(dependencies.ocr_min_confidence, 0.83) + self.assertEqual(dependencies.provider_timeout_seconds, 77) + self.assertEqual(dependencies.page_timeout_seconds, 500) + self.assertEqual(dependencies.provider_max_attempts, 4) + self.assertEqual(dependencies.provider_retry_backoff_seconds, 0.5) + self.assertEqual(dependencies.preview_similarity_threshold, 0.86) + self.assertTrue(dependencies.require_preview_validation) + + def test_live_dependency_builder_wires_distinct_configured_providers(self): + from src.generative_editable_config import ( + GenerativeEditableConfig, + ProviderConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) + + config = GenerativeEditableConfig( + ocr=ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="ocr", + base_url="fake", + api_key="fake", + ), + clean_base_model=ProviderConfig( + role="clean_edit_model", + provider="fake_image_edit", + model="clean", + base_url="fake", + api_key="fake", + ), + asset_sheet_model=ProviderConfig( + role="asset_sheet_edit_model", + provider="fake_image_edit", + model="asset-sheet", + base_url="fake", + api_key="fake", + ), + repair_model=ProviderConfig( + role="repair_edit_model", + provider="fake_image_edit", + model="repair", + base_url="fake", + api_key="fake", + ), + generation_model=ProviderConfig( + role="image_model", + provider="fake_image_generation", + model="image", + base_url="fake", + api_key="fake", + ), + use_aippt_metadata_first=False, + ocr_min_confidence=0.88, + quality=QualityConfig(max_repair_attempts=3, preview_similarity_threshold=0.87), + retries=RetryConfig(provider_max_attempts=2, repair_max_attempts=4, backoff_seconds=0), + timeouts=TimeoutConfig(provider_call=77, page=500), + ) + + with patch("api.routes.export.load_generative_editable_config", return_value=config): + dependencies = export_route._build_generative_editable_pipeline_dependencies() + + self.assertEqual(dependencies.image_edit_provider.config.role, "clean_edit_model") + self.assertEqual(dependencies.asset_sheet_image_edit_provider.config.role, "asset_sheet_edit_model") + self.assertEqual(dependencies.repair_image_edit_provider.config.role, "repair_edit_model") + self.assertEqual(dependencies.preview_similarity_threshold, 0.87) + self.assertEqual(dependencies.provider_timeout_seconds, 77) + self.assertEqual(dependencies.provider_max_attempts, 2) + self.assertEqual(dependencies.provider_retry_backoff_seconds, 0) + self.assertEqual(dependencies.max_repair_attempts, 3) + self.assertFalse(dependencies.use_aippt_metadata_first) + self.assertEqual(dependencies.ocr_min_confidence, 0.88) + + def test_public_route_maps_missing_provider_config_to_400(self): + from fastapi import HTTPException + + from api.routes.export import export_presentation + from src.generative_editable_config import GenerativeEditableConfigError + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + original_to_thread = asyncio.to_thread + asyncio.to_thread = fake_to_thread + try: + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + side_effect=GenerativeEditableConfigError("Missing provider configuration"), + ): + with self.assertRaises(HTTPException) as ctx: + asyncio.run( + export_presentation( + ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ) + ) + ) + + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("provider configuration", str(ctx.exception.detail)) + finally: + asyncio.to_thread = original_to_thread + + def test_public_route_maps_provider_timeout_and_failure(self): + from fastapi import HTTPException + + from api.routes.export import export_presentation + from src.generative_editable_providers import ProviderError, ProviderTimeoutError + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + timeout = ProviderTimeoutError( + provider_role="ocr_model", + operation="extract_text", + message="timeout calling provider", + retryable=True, + timeout_seconds=30, + ) + provider_error = ProviderError( + provider_role="edit_model", + operation="edit", + message="upstream failed", + retryable=False, + ) + + original_to_thread = asyncio.to_thread + asyncio.to_thread = fake_to_thread + try: + for raised, expected_status in ((timeout, 504), (provider_error, 502)): + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda raised=raised: _fake_route_dependencies(provider_exception=raised), + ): + with self.assertRaises(HTTPException) as ctx: + asyncio.run( + export_presentation( + ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ) + ) + ) + + self.assertEqual(ctx.exception.status_code, expected_status) + self.assertIn("导出失败", str(ctx.exception.detail)) + finally: + asyncio.to_thread = original_to_thread + + def test_public_route_maps_validation_failure_to_422(self): + from fastapi import HTTPException + + from api.routes.export import export_presentation + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + original_to_thread = asyncio.to_thread + asyncio.to_thread = fake_to_thread + try: + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda: _fake_route_dependencies(validation_status="failed"), + ): + with self.assertRaises(HTTPException) as ctx: + asyncio.run( + export_presentation( + ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ) + ) + ) + + self.assertEqual(ctx.exception.status_code, 422) + self.assertIn("preview_similarity_failed", str(ctx.exception.detail)) + finally: + asyncio.to_thread = original_to_thread + + def test_public_route_maps_real_repair_limit_failure_to_422(self): + from fastapi import HTTPException + + from api.routes.export import export_presentation + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + original_to_thread = asyncio.to_thread + asyncio.to_thread = fake_to_thread + try: + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda: _fake_route_dependencies(repair_limit_failure=True), + ): + with self.assertRaises(HTTPException) as ctx: + asyncio.run( + export_presentation( + ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + ) + ) + ) + + self.assertEqual(ctx.exception.status_code, 422) + self.assertIn("repair_limit_exceeded", str(ctx.exception.detail)) + finally: + asyncio.to_thread = original_to_thread + + def test_public_route_returns_explicit_raster_fallback_response_on_validation_failure(self): + from api.routes.export import export_presentation + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + original_to_thread = asyncio.to_thread + asyncio.to_thread = fake_to_thread + try: + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda: _fake_route_dependencies(validation_status="failed"), + ): + response = asyncio.run( + export_presentation( + ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + editable_options={"fallback_policy": "raster_pptx"}, + ) + ) + ) + + self.assertEqual(response.headers["X-Generative-Editable-Status"], "fallback_used") + self.assertEqual( + response.headers["X-Generative-Editable-Fallback-Used"], "raster_pptx" + ) + asyncio.run(response.background()) + finally: + asyncio.to_thread = original_to_thread + + def test_public_export_route_returns_valid_generative_editable_pptx_with_fake_providers(self): + from pptx import Presentation + + from api.routes.export import export_presentation + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + original_to_thread = asyncio.to_thread + asyncio.to_thread = fake_to_thread + try: + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda: _fake_route_dependencies(), + ): + response = asyncio.run( + export_presentation( + ExportRequest( + slides=[ + ExportSlide( + image_base64=_slide_base64(), + slide_id="slide-a", + text_metadata=[ + { + "text": "Quarterly Plan", + "role": "title", + "order": 1, + } + ], + ) + ], + format="generative_editable_pptx", + ) + ) + ) + + presentation = Presentation(str(response.path)) + self.assertEqual(len(presentation.slides), 1) + self.assertEqual(response.headers["X-Generative-Editable-Status"], "passed") + self.assertEqual( + response.headers["X-Generative-Editable-Fallback-Policy"], "fail" + ) + asyncio.run(response.background()) + self.assertFalse(Path(response.path).exists()) + finally: + asyncio.to_thread = original_to_thread + + def test_generative_editable_route_returns_valid_pptx_with_fake_providers(self): + from pptx import Presentation + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "out.pptx" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda: _fake_route_dependencies(), + ): + result = export_route._export_generative_editable_pptx( + [str(image_path)], + str(output_path), + slides=[ + ExportSlide( + image_base64=_slide_base64(), + slide_id="slide-a", + text_metadata=[ + { + "text": "Quarterly Plan", + "role": "title", + "order": 1, + } + ], + ) + ], + ) + + presentation = Presentation(str(output_path)) + self.assertEqual(len(presentation.slides), 1) + self.assertEqual(result.status, "passed") + self.assertEqual(result.output_path, str(output_path)) + + def test_slide_order_rejects_duplicate_and_unknown_ids(self): + with tempfile.TemporaryDirectory() as tmp: + first = Path(tmp) / "first.png" + second = Path(tmp) / "second.png" + Image.new("RGB", (800, 450), "white").save(first) + Image.new("RGB", (800, 450), "white").save(second) + + with self.assertRaisesRegex(ValueError, "slide_id values must be unique"): + export_route._generative_editable_slide_inputs( + image_paths=[str(first), str(second)], + slides=[ + ExportSlide(image_base64=_slide_base64(), slide_id="slide-a"), + ExportSlide(image_base64=_slide_base64(), slide_id="slide-a"), + ], + slide_order=None, + ) + + with self.assertRaisesRegex(ValueError, "slide_order values must be unique"): + export_route._generative_editable_slide_inputs( + image_paths=[str(first), str(second)], + slides=[ + ExportSlide(image_base64=_slide_base64(), slide_id="slide-a"), + ExportSlide(image_base64=_slide_base64(), slide_id="slide-b"), + ], + slide_order=["slide-a", "slide-a"], + ) + + with self.assertRaisesRegex(ValueError, "unknown slide_id"): + export_route._generative_editable_slide_inputs( + image_paths=[str(first), str(second)], + slides=[ + ExportSlide(image_base64=_slide_base64(), slide_id="slide-a"), + ExportSlide(image_base64=_slide_base64(), slide_id="slide-b"), + ], + slide_order=["slide-c"], + ) + + def test_explicit_raster_fallback_uses_existing_raster_pptx_exporter(self): + calls = [] + + def fake_raster_export(image_paths, output_path, aspect_ratio="16:9"): + calls.append((list(image_paths), output_path, aspect_ratio)) + Path(output_path).write_bytes(b"raster-fallback") + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "out.pptx" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("api.routes.export._export_pptx", fake_raster_export), patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda: _fake_route_dependencies(validation_status="failed"), + ): + result = export_route._export_generative_editable_pptx( + [str(image_path)], + str(output_path), + aspect_ratio="4:3", + editable_options=ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + editable_options={"fallback_policy": "raster_pptx"}, + ).editable_options, + ) + + self.assertEqual(output_path.read_bytes(), b"raster-fallback") + + self.assertEqual(calls[0][2], "4:3") + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.fallback_used, "raster_pptx") + + def test_text_editable_background_fallback_returns_text_editable_background_deck(self): + from pptx import Presentation + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "out.pptx" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch( + "api.routes.export._build_generative_editable_pipeline_dependencies", + lambda: _fake_route_dependencies(validation_status="failed"), + ): + result = export_route._export_generative_editable_pptx( + [str(image_path)], + str(output_path), + editable_options={ + "fallback_policy": "text_editable_background", + }, + ) + + presentation = Presentation(str(output_path)) + self.assertEqual(len(presentation.slides), 1) + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.fallback_used, "text_editable_background") + self.assertTrue(any(shape.has_text_frame for shape in presentation.slides[0].shapes)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_fixtures.py b/tests/test_generative_editable_fixtures.py new file mode 100644 index 0000000..f780fee --- /dev/null +++ b/tests/test_generative_editable_fixtures.py @@ -0,0 +1,76 @@ +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +import api.routes.export as export_route +from src.generative_editable_manifest import read_deck_manifest +from src.generative_editable_pipeline import GenerativeEditableSlideInput, run_generative_editable_pipeline +from tests.generative_editable_fixtures import write_deterministic_fixture_deck + + +class GenerativeEditableFixtureTest(unittest.TestCase): + def test_fixture_decks_cover_required_fake_provider_cases(self): + required_tags = { + "text", + "simple_shapes", + "repeated_bitmap_asset", + "complex_visual", + "text_clean_fallback", + } + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + widescreen = write_deterministic_fixture_deck(root, aspect_ratio="16:9") + standard = write_deterministic_fixture_deck(root, aspect_ratio="4:3") + + self.assertEqual(widescreen.image_size, (800, 450)) + self.assertEqual(standard.image_size, (800, 600)) + self.assertEqual(len(widescreen.slides), 4) + self.assertEqual(len(standard.slides), 4) + self.assertTrue(required_tags.issubset(widescreen.coverage_tags)) + self.assertTrue(required_tags.issubset(standard.coverage_tags)) + for deck in (widescreen, standard): + for slide in deck.slides: + self.assertTrue(slide.image_path.exists()) + with Image.open(slide.image_path) as image: + self.assertEqual(image.size, deck.image_size) + self.assertGreaterEqual(len(slide.text_metadata), 1) + + def test_fixture_deck_passes_fake_provider_pipeline(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + fixture = write_deterministic_fixture_deck(root, aspect_ratio="16:9") + artifact_root = root / "jobs" + output_path = root / "fixture-output.pptx" + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id=slide.slide_id, + image_path=str(slide.image_path), + text_metadata=slide.text_metadata, + ) + for slide in fixture.slides + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="fixture-fake-provider", + dependencies=export_route._build_fake_generative_editable_pipeline_dependencies(), + cleanup_artifacts=False, + ) + + deck_manifest = read_deck_manifest( + artifact_root / "fixture-fake-provider" / "deck.json" + ) + self.assertEqual(result.status, "passed") + self.assertEqual(Path(result.output_path), output_path) + self.assertTrue(output_path.exists()) + self.assertEqual(deck_manifest.slide_order, fixture.slide_order) + self.assertEqual(len(deck_manifest.page_manifest_paths), len(fixture.slides)) + self.assertEqual(deck_manifest.validation_status, "passed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_foreground_planner.py b/tests/test_generative_editable_foreground_planner.py new file mode 100644 index 0000000..4aa8727 --- /dev/null +++ b/tests/test_generative_editable_foreground_planner.py @@ -0,0 +1,172 @@ +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path + +from PIL import Image, ImageDraw + + +class GenerativeEditableForegroundPlannerTest(unittest.TestCase): + def test_detects_foreground_candidates_from_source_vs_base_clean_excluding_text_mask(self): + from src.generative_editable_foreground_planner import plan_foreground_candidates + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + base = root / "base.png" + text_mask = root / "text-mask.png" + Image.new("RGB", (120, 80), "white").save(base) + source_image = Image.new("RGB", (120, 80), "white") + draw = ImageDraw.Draw(source_image) + draw.rectangle((12, 14, 42, 34), fill="#2563EB") + draw.rectangle((70, 20, 104, 34), fill="#111111") + source_image.save(source) + mask = Image.new("L", (120, 80), 0) + ImageDraw.Draw(mask).rectangle((66, 16, 108, 38), fill=255) + mask.save(text_mask) + + candidates = plan_foreground_candidates( + source_image_path=source, + base_clean_image_path=base, + text_mask_path=text_mask, + min_area=20, + ) + + self.assertEqual(len(candidates), 1) + candidate = candidates[0] + self.assertEqual(candidate.source_pixel_bbox, (12, 14, 43, 35)) + self.assertEqual(candidate.classification, "uncertain") + self.assertEqual(candidate.provenance["detection"], "source_base_difference") + + def test_classifies_candidate_inventory_into_expected_buckets(self): + from src.generative_editable_foreground_planner import classify_foreground_candidates + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (180, 120), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((10, 10, 50, 34), fill="#2563EB") + for x in range(70, 135): + for y in range(12, 82): + draw.point((x, y), fill=((x * 3) % 255, (y * 5) % 255, (x + y) % 255)) + draw.ellipse((20, 70, 48, 98), fill="#10B981") + draw.rectangle((140, 10, 154, 18), fill="#111111") + image.save(source) + + candidates = classify_foreground_candidates( + source_image_path=source, + candidate_boxes=[ + (10, 10, 51, 35), + (70, 12, 136, 83), + (20, 70, 49, 99), + (140, 10, 155, 19), + (160, 100, 163, 102), + ], + rejected_text_regions=[(136, 6, 160, 24)], + ) + + self.assertEqual( + [candidate.classification for candidate in candidates], + [ + "native_shape_candidate", + "complex_whole_visual", + "bitmap_asset_candidate", + "rejected_text_like_region", + "uncertain", + ], + ) + self.assertGreaterEqual(candidates[0].confidence, 0.9) + self.assertEqual(candidates[0].provenance["shape_hint"], "rectangle") + self.assertEqual(candidates[1].provenance["reason"], "large_or_visually_complex") + + def test_rejects_candidate_boxes_outside_source_image_bounds(self): + from src.generative_editable_foreground_planner import classify_foreground_candidates + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + Image.new("RGB", (100, 60), "white").save(source) + + with self.assertRaisesRegex(ValueError, "candidate_boxes"): + classify_foreground_candidates( + source_image_path=source, + candidate_boxes=[(90, 10, 120, 30)], + ) + + def test_records_component_reuse_for_repeated_visual_candidates(self): + from src.generative_editable_foreground_planner import ( + classify_foreground_candidates, + foreground_candidates_to_manifest_specs, + record_component_reuse, + ) + from src.generative_editable_manifest import ( + PageManifest, + read_page_manifest, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (140, 80), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((10, 10, 34, 34), fill="#2563EB") + draw.rectangle((70, 10, 94, 34), fill="#2563EB") + image.save(source) + + candidates = classify_foreground_candidates( + source_image_path=source, + candidate_boxes=[(10, 10, 35, 35), (70, 10, 95, 35)], + ) + reused = record_component_reuse(candidates) + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(140, 80), + slide_size=(10.0, 5.625), + foreground_candidates=foreground_candidates_to_manifest_specs(reused), + ) + manifest_path = root / "page.json" + write_manifest(manifest_path, page) + loaded = read_page_manifest(manifest_path) + + self.assertEqual(reused[0].classification, "native_shape_candidate") + self.assertEqual(reused[1].classification, "duplicate") + self.assertEqual(reused[0].component_key, reused[1].component_key) + self.assertEqual(reused[1].provenance["reuses_candidate_id"], reused[0].candidate_id) + self.assertEqual(loaded.foreground_candidates[1].classification, "duplicate") + self.assertEqual( + loaded.foreground_candidates[1].provenance["reuses_candidate_id"], + loaded.foreground_candidates[0].candidate_id, + ) + self.assertEqual(loaded.foreground_candidates[0].component_key, reused[0].component_key) + + def test_non_dict_candidate_provenance_is_ignored_for_manifest_and_reuse(self): + from src.generative_editable_foreground_planner import ( + ForegroundCandidate, + foreground_candidates_to_manifest_specs, + record_component_reuse, + ) + + first = ForegroundCandidate( + candidate_id="first", + source_pixel_bbox=(10, 10, 20, 20), + area=100, + classification="native_shape_candidate", + confidence=0.95, + component_key="same", + provenance={"shape_hint": "rectangle"}, + ) + duplicate = replace(first, candidate_id="duplicate", source_pixel_bbox=(30, 10, 40, 20), provenance=1) + + reused = record_component_reuse([first, duplicate]) + specs = foreground_candidates_to_manifest_specs(reused) + + self.assertEqual(reused[1].classification, "duplicate") + self.assertEqual(reused[1].provenance["reuses_candidate_id"], "first") + self.assertEqual(specs[1].provenance, {"reuses_candidate_id": "first"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_job_artifacts.py b/tests/test_generative_editable_job_artifacts.py new file mode 100644 index 0000000..fa4956f --- /dev/null +++ b/tests/test_generative_editable_job_artifacts.py @@ -0,0 +1,267 @@ +import json +import tempfile +import threading +import unittest +from pathlib import Path + +from src.generative_editable_job_artifacts import GenerativeEditableJobArtifacts +from src.generative_editable_manifest import PageManifest, read_page_manifest, write_manifest + + +class GenerativeEditableJobArtifactsTest(unittest.TestCase): + def test_creates_deterministic_job_directory_and_paths(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-001") + + self.assertEqual(artifacts.job_dir, Path(tmp) / "job-001") + self.assertEqual(artifacts.deck_manifest_path, Path(tmp) / "job-001" / "deck.json") + self.assertTrue((Path(tmp) / "job-001" / "pages").is_dir()) + self.assertTrue((Path(tmp) / "job-001" / "assets").is_dir()) + self.assertTrue((Path(tmp) / "job-001" / "backgrounds").is_dir()) + + with self.assertRaisesRegex(ValueError, "job_id"): + GenerativeEditableJobArtifacts(root_dir=tmp, job_id="../") + with self.assertRaisesRegex(ValueError, "job_id"): + GenerativeEditableJobArtifacts(root_dir=tmp, job_id="") + with self.assertRaisesRegex(ValueError, "job_id"): + GenerativeEditableJobArtifacts(root_dir=tmp, job_id="???") + + page_path = artifacts.page_manifest_path("slide/a", 2) + asset_path = artifacts.asset_path("slide/a", 2, "assets", "asset.png") + + self.assertEqual( + page_path, + Path(tmp) / "job-001" / "pages" / "0002-slide-a.json", + ) + self.assertEqual( + asset_path, + Path(tmp) / "job-001" / "assets" / "0002-slide-a" / "asset.png", + ) + + def test_page_manifest_paths_follow_slide_order(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-ordered") + + paths = artifacts.page_manifest_paths(["slide-b", "slide-a"]) + + self.assertEqual( + paths, + [ + Path(tmp) / "job-ordered" / "pages" / "0000-slide-b.json", + Path(tmp) / "job-ordered" / "pages" / "0001-slide-a.json", + ], + ) + + def test_asset_paths_are_scoped_to_known_categories(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-safe") + + with self.assertRaisesRegex(ValueError, "category"): + artifacts.asset_path("slide", 0, "../outside", "asset.png") + + with self.assertRaisesRegex(ValueError, "filename"): + artifacts.asset_path("slide", 0, "assets", "../asset.png") + + def test_cleanup_removes_only_job_directory_and_keeps_root(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + keep = root / "keep.txt" + keep.write_text("keep", encoding="utf-8") + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-clean") + marker = artifacts.job_dir / "marker.txt" + marker.write_text("remove", encoding="utf-8") + + artifacts.cleanup() + + self.assertTrue(root.exists()) + self.assertTrue(keep.exists()) + self.assertFalse(artifacts.job_dir.exists()) + + def test_cleanup_refuses_root_or_outside_job_directory(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-clean-safe") + artifacts.job_dir = Path(tmp) + + with self.assertRaisesRegex(ValueError, "cleanup"): + artifacts.cleanup() + + def test_provider_outputs_are_persisted_for_rebuild_without_provider_calls(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-rebuild") + page_manifest = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/0000-slide-a/source.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + validation_status="passed", + ) + write_manifest(artifacts.page_manifest_path("slide-a", 0), page_manifest) + artifacts.write_provider_output( + "slide-a", + 0, + "ocr", + "result.json", + { + "provider_role": "ocr_model", + "items": [{"text": "Quarterly Plan", "confidence": 0.98}], + }, + ) + artifacts.write_provider_output( + "slide-a", + 0, + "image_edit", + "base-clean.json", + { + "provider_role": "edit_model", + "output_asset_path": "backgrounds/base-clean.png", + "chroma_key_mode": "green", + "error": "api_key=secret at https://edit.example/path", + "headers": {"cookie": "cookie-secret", "password": "pw-secret"}, + "response": { + "body": ( + "api_key=nested-secret session_id=session-body-secret " + "private_key=private-body-secret at https://nested.example/path" + ) + }, + "refresh_token": "refresh-secret", + "id_token": "id-secret", + "apiToken": "api-token-secret", + }, + ) + artifacts.write_provider_output( + "slide-a", + 0, + "image_generation", + "asset-sheet.json", + { + "provider_role": "image_model", + "output_asset_path": "assets/asset-sheet.png", + }, + ) + artifacts.write_provider_output( + "slide-a", + 0, + "repair", + "repair.json", + { + "provider_role": "edit_model", + "attempt_index": 1, + "authorization": "Bearer repair-secret", + "session": "session-secret", + "session_id": "session-id-secret", + }, + ) + + loaded_page = read_page_manifest(artifacts.page_manifest_path("slide-a", 0)) + ocr_output = artifacts.read_provider_output("slide-a", 0, "ocr", "result.json") + edit_output = artifacts.read_provider_output("slide-a", 0, "image_edit", "base-clean.json") + generation_output = artifacts.read_provider_output( + "slide-a", 0, "image_generation", "asset-sheet.json" + ) + repair_output = artifacts.read_provider_output("slide-a", 0, "repair", "repair.json") + raw_edit = artifacts.provider_output_path( + "slide-a", 0, "image_edit", "base-clean.json" + ).read_text(encoding="utf-8") + raw_repair = artifacts.provider_output_path( + "slide-a", 0, "repair", "repair.json" + ).read_text(encoding="utf-8") + + self.assertEqual(loaded_page.validation_status, "passed") + self.assertEqual(ocr_output["items"][0]["text"], "Quarterly Plan") + self.assertEqual(edit_output["provider_role"], "edit_model") + self.assertEqual(edit_output["chroma_key_mode"], "green") + self.assertEqual(generation_output["provider_role"], "image_model") + self.assertEqual(repair_output["attempt_index"], 1) + self.assertNotIn("secret", raw_edit) + self.assertNotIn("pw-secret", raw_edit) + self.assertNotIn("nested-secret", raw_edit) + self.assertNotIn("session-body-secret", raw_edit) + self.assertNotIn("private-body-secret", raw_edit) + self.assertNotIn("refresh-secret", raw_edit) + self.assertNotIn("id-secret", raw_edit) + self.assertNotIn("api-token-secret", raw_edit) + self.assertNotIn("https://nested.example/path", raw_edit) + self.assertNotIn("https://edit.example/path", raw_edit) + self.assertNotIn("repair-secret", raw_repair) + self.assertNotIn("session-secret", raw_repair) + self.assertNotIn("session-id-secret", raw_repair) + + def test_provider_output_stage_and_filename_are_scoped(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-provider-safe") + + with self.assertRaisesRegex(ValueError, "provider_stage"): + artifacts.provider_output_path("slide", 0, "../ocr", "result.json") + + with self.assertRaisesRegex(ValueError, "filename"): + artifacts.provider_output_path("slide", 0, "ocr", "../result.json") + + def test_stage_events_are_appended_sanitized_and_read_back(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-events") + + artifacts.append_stage_event( + { + "page_index": 0, + "slide_id": "slide-a", + "stage": "text_clean_background.provider_edit", + "status": "started", + "api_key": "secret-key", + "base_url": "https://provider.example/v1", + } + ) + artifacts.append_stage_event( + { + "page_index": 0, + "slide_id": "slide-a", + "stage": "text_clean_background.provider_edit", + "status": "failed", + "elapsed_ms": 90000, + "error": "Bearer secret-token at https://provider.example/v1", + } + ) + + events = artifacts.read_stage_events() + raw = artifacts.stage_events_path.read_text(encoding="utf-8") + + self.assertEqual([event["status"] for event in events], ["started", "failed"]) + self.assertEqual(events[-1]["stage"], "text_clean_background.provider_edit") + self.assertEqual(events[-1]["elapsed_ms"], 90000) + self.assertNotIn("secret-key", raw) + self.assertNotIn("secret-token", raw) + self.assertNotIn("https://provider.example/v1", raw) + for line in raw.splitlines(): + self.assertIsInstance(json.loads(line), dict) + + def test_stage_events_tolerate_concurrent_appends_and_malformed_tail(self): + with tempfile.TemporaryDirectory() as tmp: + artifacts = GenerativeEditableJobArtifacts(root_dir=tmp, job_id="job-events") + + def append_events(worker: int) -> None: + for index in range(20): + artifacts.append_stage_event( + { + "stage": "worker", + "status": "finished", + "worker": worker, + "index": index, + } + ) + + threads = [threading.Thread(target=append_events, args=(worker,)) for worker in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + with artifacts.stage_events_path.open("a", encoding="utf-8") as handle: + handle.write("{bad-json") + + events = artifacts.read_stage_events() + + self.assertEqual(len(events), 80) + self.assertEqual({event["worker"] for event in events}, {0, 1, 2, 3}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_live_providers.py b/tests/test_generative_editable_live_providers.py new file mode 100644 index 0000000..bc439e6 --- /dev/null +++ b/tests/test_generative_editable_live_providers.py @@ -0,0 +1,727 @@ +import base64 +import http.client +import json +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +import requests +from PIL import Image, ImageDraw + +from src.generative_editable_config import ProviderConfig +from src.generative_editable_providers import ( + ImageEditRequest, + ImageGenerationRequest, + LocalTesseractOCRProvider, + OpenAIChatImageEditProvider, + OpenAIChatImageGenerationProvider, + OpenAIChatOCRProvider, + ProviderError, + ProviderTimeoutError, + _post_openai_chat, +) +import api.routes.export as export_route + + +class _FakeResponse: + def __init__(self, payload, status_error=None): + self.payload = payload + self.status_error = status_error + + def raise_for_status(self): + if self.status_error: + raise self.status_error + + def json(self): + return self.payload + + +def _provider(role="ocr_model", model="vision-model", provider="openai_vision"): + return ProviderConfig( + role=role, + provider=provider, + model=model, + base_url="https://provider.example/v1", + api_key="secret-key", + adapter="openai_chat", + ) + + +class LiveProviderAdapterTest(unittest.TestCase): + def test_route_dependency_builder_uses_live_provider_adapters_for_non_fake_config(self): + from src.generative_editable_config import GenerativeEditableConfig, QualityConfig, RetryConfig, TimeoutConfig + + config = GenerativeEditableConfig( + ocr=_provider(provider="paddle_ocr_vl"), + clean_base_model=_provider(role="edit_model", provider="openai_image_edit"), + asset_sheet_model=_provider(role="edit_model", provider="openai_image_edit"), + repair_model=_provider(role="edit_model", provider="openai_image_edit"), + generation_model=_provider(role="image_model", provider="openai_image_generation"), + use_aippt_metadata_first=True, + ocr_min_confidence=0.75, + quality=QualityConfig(), + retries=RetryConfig(), + timeouts=TimeoutConfig(), + ) + + with patch("api.routes.export.load_generative_editable_config", return_value=config): + dependencies = export_route._build_generative_editable_pipeline_dependencies() + + self.assertIsInstance(dependencies.ocr_provider, OpenAIChatOCRProvider) + self.assertIsInstance(dependencies.image_edit_provider, OpenAIChatImageEditProvider) + self.assertIsInstance( + dependencies.image_generation_provider, + OpenAIChatImageGenerationProvider, + ) + + def test_route_dependency_builder_passes_configured_timeout_to_ocr_provider(self): + from src.generative_editable_config import GenerativeEditableConfig, QualityConfig, RetryConfig, TimeoutConfig + + config = GenerativeEditableConfig( + ocr=_provider(provider="paddle_ocr_vl"), + clean_base_model=_provider(role="edit_model", provider="openai_image_edit"), + asset_sheet_model=_provider(role="edit_model", provider="openai_image_edit"), + repair_model=_provider(role="edit_model", provider="openai_image_edit"), + generation_model=_provider(role="image_model", provider="openai_image_generation"), + use_aippt_metadata_first=True, + ocr_min_confidence=0.75, + quality=QualityConfig(), + retries=RetryConfig(), + timeouts=TimeoutConfig(provider_call=77), + ) + + with patch("api.routes.export.load_generative_editable_config", return_value=config): + dependencies = export_route._build_generative_editable_pipeline_dependencies() + + self.assertEqual(dependencies.ocr_provider.timeout_seconds, 77) + + def test_route_dependency_builder_uses_local_tesseract_ocr_provider_when_configured(self): + from src.generative_editable_config import GenerativeEditableConfig, QualityConfig, RetryConfig, TimeoutConfig + + config = GenerativeEditableConfig( + ocr=ProviderConfig( + role="ocr_model", + provider="local_tesseract", + model="eng", + base_url="", + api_key="", + ), + clean_base_model=_provider(role="edit_model", provider="openai_image_edit"), + asset_sheet_model=_provider(role="edit_model", provider="openai_image_edit"), + repair_model=_provider(role="edit_model", provider="openai_image_edit"), + generation_model=_provider(role="image_model", provider="openai_image_generation"), + use_aippt_metadata_first=True, + ocr_min_confidence=0.75, + quality=QualityConfig(), + retries=RetryConfig(), + timeouts=TimeoutConfig(), + ) + + with patch("api.routes.export.load_generative_editable_config", return_value=config): + dependencies = export_route._build_generative_editable_pipeline_dependencies() + + self.assertIsInstance(dependencies.ocr_provider, LocalTesseractOCRProvider) + + def test_local_tesseract_ocr_provider_parses_tsv_lines(self): + tsv = "\n".join( + [ + "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext", + "5\t1\t1\t1\t1\t1\t80\t54\t160\t32\t96.5\tQuarterly", + "5\t1\t1\t1\t1\t2\t250\t54\t70\t32\t93.5\tPlan", + "5\t1\t1\t1\t2\t1\t90\t120\t120\t24\t88.0\tRevenue", + ] + ) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + with ( + patch("src.generative_editable_providers.shutil.which", return_value="/usr/bin/tesseract"), + patch("src.generative_editable_providers.subprocess.run") as run, + ): + run.return_value.stdout = tsv + result = LocalTesseractOCRProvider( + ProviderConfig( + role="ocr_model", + provider="local_tesseract", + model="eng", + base_url="", + api_key="", + ) + ).extract_text(str(image_path)) + + self.assertEqual(result.provider_name, "local_tesseract") + self.assertEqual(result.items[0].text, "Quarterly Plan") + self.assertEqual(result.items[0].bbox, (80, 54, 320, 86)) + self.assertAlmostEqual(result.items[0].confidence, 0.95) + self.assertEqual(result.items[1].text, "Revenue") + run.assert_called_once() + + def test_local_tesseract_ocr_provider_rejects_missing_explicit_executable(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + provider = LocalTesseractOCRProvider( + ProviderConfig( + role="ocr_model", + provider="local_tesseract", + model="eng", + base_url=str(Path(tmp) / "missing-tesseract"), + api_key="", + ) + ) + + with self.assertRaisesRegex(ProviderError, "tesseract executable"): + provider.extract_text(str(image_path)) + + def test_openai_chat_ocr_provider_parses_json_text_items(self): + payload = { + "choices": [ + { + "message": { + "content": """ + ```json + { + "items": [ + { + "text": "Quarterly Plan", + "bbox": [80, 54, 420, 102], + "confidence": 0.97, + "font_size": 32, + "color": "#1F2937", + "alignment": "center" + } + ] + } + ``` + """ + } + } + ] + } + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + + result = OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + self.assertEqual(result.provider_role, "ocr_model") + self.assertEqual(result.provider_name, "openai_vision") + self.assertEqual(result.model, "vision-model") + self.assertEqual(result.image_size, (800, 450)) + self.assertEqual(len(result.items), 1) + self.assertEqual(result.items[0].text, "Quarterly Plan") + self.assertEqual(result.items[0].bbox, (80, 54, 420, 102)) + self.assertEqual(result.items[0].alignment, "center") + self.assertEqual(result.items[0].font_size_hint, 32) + self.assertEqual(post.call_args.kwargs["timeout"], 180) + self.assertEqual(post.call_args.args[0], "https://provider.example/v1/chat/completions") + + def test_openai_chat_ocr_provider_requests_strict_json_with_text_prompt_first(self): + payload = {"choices": [{"message": {"content": '{"items":[]}'}}]} + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + request_payload = post.call_args.kwargs["json"] + content = request_payload["messages"][0]["content"] + self.assertEqual(content[0]["type"], "text") + self.assertEqual(content[1]["type"], "image_url") + self.assertEqual(request_payload["response_format"], {"type": "json_object"}) + self.assertEqual(request_payload["temperature"], 0) + self.assertEqual(request_payload["max_tokens"], 4096) + + def test_openai_chat_ocr_provider_extracts_json_from_surrounding_model_text(self): + payload = { + "choices": [ + { + "message": { + "content": ( + "Here is the OCR result:\n" + "{\"items\":[{\"text\":\"Title\",\"bbox\":[1,2,30,40],\"confidence\":0.9}]}" + "\nDone." + ) + } + } + ] + } + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + result = OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + self.assertEqual(result.items[0].text, "Title") + self.assertEqual(result.items[0].bbox, (1, 2, 30, 40)) + + def test_openai_chat_ocr_provider_rejects_plain_text_response(self): + payload = { + "choices": [ + { + "message": { + "content": "Plain OCR Title with api_key secret-value and visible text" + } + } + ] + } + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + message = str(ctx.exception) + self.assertIn("valid JSON", message) + self.assertIn("response_excerpt=Plain OCR Title", message) + self.assertIn("visible text", message) + self.assertNotIn("secret-value", message) + self.assertIn("api_key [REDACTED]", message) + + def test_paddleocr_vl_provider_requests_json_coordinates(self): + payload = { + "choices": [ + { + "message": { + "content": ( + "{\"items\":[" + "{\"text\":\"理想汽车技术实验室\",\"bbox\":[118,86,372,124]," + "\"confidence\":0.93,\"font_size\":22,\"color\":\"#F8FAFC\"}," + "{\"text\":\"理想L9: 旗舰增程SUV的技术实验\",\"bbox\":[146,178,544,214]," + "\"confidence\":0.91,\"font_size\":21,\"color\":\"#38BDF8\"}" + "]}" + ) + } + } + ] + } + + provider = _provider(model="PaddlePaddle/PaddleOCR-VL-1.5") + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + image = Image.new("RGB", (1000, 600), "#08111F") + draw = ImageDraw.Draw(image) + draw.rectangle((118, 86, 372, 124), fill="#F8FAFC") + draw.rectangle((146, 178, 544, 214), fill="#38BDF8") + image.save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + result = OpenAIChatOCRProvider(provider).extract_text(str(image_path)) + + request_payload = post.call_args.kwargs["json"] + content = request_payload["messages"][0]["content"] + self.assertEqual(content[0]["type"], "text") + self.assertIn("Output JSON only", content[0]["text"]) + self.assertIn("Coordinates must be source-image pixels", content[0]["text"]) + self.assertEqual(content[1]["type"], "image_url") + self.assertNotIn("response_format", request_payload) + self.assertEqual(len(result.items), 2) + self.assertEqual(result.items[0].text, "理想汽车技术实验室") + self.assertEqual(result.items[0].bbox, (118, 86, 372, 124)) + self.assertEqual(result.items[0].font_size_hint, 22) + self.assertEqual(result.items[0].color_hex, "#F8FAFC") + + def test_paddleocr_vl_plain_text_response_uses_deduped_local_layout(self): + payload = { + "choices": [ + { + "message": { + "content": "Title\n\n29in 6K" + } + } + ] + } + + provider = _provider(model="PaddlePaddle/PaddleOCR-VL-1.5") + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + image = Image.new("RGB", (1000, 600), "#08111F") + draw = ImageDraw.Draw(image) + draw.rectangle((90, 170, 930, 330), fill="#38BDF8") + draw.text((118, 86), "Title", fill="#F8FAFC") + draw.text((760, 214), "29in 6K", fill="#F8FAFC") + image.save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + result = OpenAIChatOCRProvider(provider).extract_text(str(image_path)) + + self.assertEqual(len(result.items), 2) + self.assertEqual(result.items[0].text, "Title") + self.assertNotEqual(result.items[0].bbox, (90, 170, 930, 330)) + self.assertEqual(result.items[1].text, "29in 6K") + self.assertNotEqual(result.items[1].bbox, (90, 170, 930, 330)) + self.assertLess(result.items[1].bbox[3] - result.items[1].bbox[1], 60) + + def test_openai_chat_ocr_provider_marks_http_5xx_as_retryable(self): + response = requests.Response() + response.status_code = 503 + error = requests.HTTPError("503 Server Error", response=response) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse({}, status_error=error) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + self.assertTrue(ctx.exception.retryable) + + def test_openai_chat_provider_exposes_upstream_error_code_and_status(self): + response = requests.Response() + response.status_code = 403 + response._content = ( + b'{"error":{"message":"quota exhausted","code":"insufficient_user_quota"}}' + ) + error = requests.HTTPError("403 Client Error", response=response) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse({}, status_error=error) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + self.assertFalse(ctx.exception.retryable) + self.assertEqual(ctx.exception.status_code, 403) + self.assertEqual(ctx.exception.provider_error_code, "insufficient_user_quota") + + def test_openai_chat_ocr_provider_marks_connection_errors_as_retryable(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.side_effect = requests.ConnectionError("temporary connection reset") + with self.assertRaises(ProviderError) as ctx: + OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + self.assertTrue(ctx.exception.retryable) + + def test_openai_chat_provider_marks_incomplete_read_as_retryable(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.side_effect = http.client.IncompleteRead(b"partial", 2048) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + self.assertTrue(ctx.exception.retryable) + + def test_openai_chat_provider_error_includes_sanitized_response_body(self): + provider = _provider() + provider = ProviderConfig( + role=provider.role, + provider=provider.provider, + model=provider.model, + base_url="https://private.example/v1", + api_key=provider.api_key, + adapter=provider.adapter, + ) + response = requests.Response() + response.status_code = 400 + response._content = ( + b'{"error":{"message":"bad request for api_key=secret-key at private.example"}}' + ) + error = requests.HTTPError("400 Client Error", response=response) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse({}, status_error=error) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatOCRProvider(provider).extract_text(str(image_path)) + + message = str(ctx.exception) + self.assertIn("response_body=", message) + self.assertIn("[REDACTED]", message) + self.assertIn("[URL_REDACTED]", message) + self.assertNotIn("secret-key", message) + self.assertNotIn("private.example", message) + + def test_openai_chat_ocr_provider_wraps_invalid_json_item_as_provider_error(self): + payload = { + "choices": [ + { + "message": { + "content": json_payload( + { + "items": [ + { + "text": "Bad OCR", + "bbox": [10, 20, 5, 40], + "confidence": 0.9, + } + ] + } + ) + } + } + ] + } + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatOCRProvider(_provider()).extract_text(str(image_path)) + + self.assertIn("invalid shape", str(ctx.exception)) + + def test_openai_chat_image_edit_provider_writes_normalized_image(self): + image_base64 = base64.b64encode(_png_bytes()).decode() + payload = {"choices": [{"message": {"content": image_base64}}]} + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (32, 18), "white").save(source) + request = ImageEditRequest( + source_image_path=str(source), + prompt_id="base_clean_background", + prompt="remove text", + output_asset_path="assets/out.png", + asset_root=str(root), + timeout_seconds=7, + ) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + result = OpenAIChatImageEditProvider( + _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + ).edit(request) + + self.assertTrue(Path(result.output_asset_path).exists()) + self.assertEqual(result.provider_role, "edit_model") + self.assertEqual(result.provider_name, "openai_image_edit") + self.assertEqual(result.model, "edit-model") + self.assertEqual(result.timeout_seconds, 7) + + def test_openai_chat_image_edit_provider_sends_mask_when_available(self): + image_base64 = base64.b64encode(_png_bytes()).decode() + payload = {"choices": [{"message": {"content": image_base64}}]} + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "mask.png" + Image.new("RGB", (32, 18), "white").save(source) + Image.new("L", (32, 18), 255).save(mask) + request = ImageEditRequest( + source_image_path=str(source), + prompt_id="text_clean_background", + prompt="remove text in mask", + output_asset_path="assets/out.png", + asset_root=str(root), + mask_path=str(mask), + ) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + OpenAIChatImageEditProvider( + _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + ).edit(request) + + content = post.call_args.kwargs["json"]["messages"][0]["content"] + self.assertEqual( + sum(1 for item in content if item.get("type") == "image_url"), + 2, + ) + + def test_openai_chat_image_edit_normalization_errors_are_redacted(self): + payload = {"choices": [{"message": {"content": "https://signed.example/image.png?token=secret-key"}}]} + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (32, 18), "white").save(source) + request = ImageEditRequest( + source_image_path=str(source), + prompt_id="base_clean_background", + prompt="remove text", + output_asset_path="assets/out.png", + asset_root=str(root), + ) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatImageEditProvider( + _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + ).edit(request) + + self.assertNotIn("secret-key", str(ctx.exception)) + self.assertIn("[URL_REDACTED]", str(ctx.exception)) + + def test_openai_chat_image_generation_provider_writes_normalized_image(self): + image_base64 = base64.b64encode(_png_bytes()).decode() + payload = {"choices": [{"message": {"content": f"data:image/png;base64,{image_base64}"}}]} + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + request = ImageGenerationRequest( + prompt_id="asset_sheet", + prompt="transparent asset sheet", + output_asset_path="assets/sheet.png", + asset_root=str(root), + timeout_seconds=9, + ) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + result = OpenAIChatImageGenerationProvider( + _provider( + role="image_model", + model="image-model", + provider="openai_image_generation", + ) + ).generate(request) + + self.assertTrue(Path(result.output_asset_path).exists()) + self.assertEqual(result.provider_role, "image_model") + self.assertEqual(result.provider_name, "openai_image_generation") + self.assertEqual(result.model, "image-model") + self.assertEqual(result.timeout_seconds, 9) + + def test_live_provider_errors_are_redacted_and_typed(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + provider = OpenAIChatOCRProvider(_provider()) + + with patch("src.generative_editable_providers.requests.post") as post: + post.side_effect = requests.Timeout("api_key=secret-key timed out") + with self.assertRaises(ProviderTimeoutError) as ctx: + provider.extract_text(str(image_path)) + self.assertNotIn("secret-key", str(ctx.exception)) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse({}, status_error=RuntimeError("token=secret-key")) + with self.assertRaises(ProviderError) as ctx: + provider.extract_text(str(image_path)) + self.assertNotIn("secret-key", str(ctx.exception)) + + def test_openai_chat_post_has_hard_deadline_for_blocking_reads(self): + def slow_post(*args, **kwargs): + time.sleep(2) + return _FakeResponse({"choices": []}) + + with patch("src.generative_editable_providers.requests.post", side_effect=slow_post): + with self.assertRaises(ProviderTimeoutError) as ctx: + _post_openai_chat( + _provider(), + {"model": "vision-model", "messages": []}, + operation="blocking_read", + timeout_seconds=1, + ) + + self.assertIn("timeout_seconds=1", str(ctx.exception)) + + def test_image_url_download_has_hard_deadline_for_blocking_reads(self): + payload = {"choices": [{"message": {"content": "https://provider.example/image.png"}}]} + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + request = ImageGenerationRequest( + prompt_id="asset_sheet", + prompt="transparent asset sheet", + output_asset_path="assets/sheet.png", + asset_root=str(root), + timeout_seconds=1, + ) + + def slow_get(*args, **kwargs): + time.sleep(2) + return _FakeResponse(b"") + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + with patch("src.image_result.requests.get", side_effect=slow_get): + with self.assertRaises(ProviderTimeoutError) as ctx: + OpenAIChatImageGenerationProvider( + _provider( + role="image_model", + model="image-model", + provider="openai_image_generation", + ) + ).generate(request) + + self.assertIn("timeout_seconds=1", str(ctx.exception)) + + def test_nested_provider_hard_deadline_preserves_outer_remaining_time(self): + import src.generative_editable_providers as providers + + helper = getattr(providers, "_provider_hard_deadline", None) + self.assertIsNotNone(helper) + + start = time.perf_counter() + with self.assertRaises(TimeoutError): + with helper(1): + with helper(1): + time.sleep(0.7) + time.sleep(0.5) + elapsed = time.perf_counter() - start + + self.assertLess(elapsed, 1.15) + + def test_nested_provider_hard_deadline_uses_outer_remaining_when_inner_is_longer(self): + import src.generative_editable_providers as providers + + helper = getattr(providers, "_provider_hard_deadline", None) + self.assertIsNotNone(helper) + + start = time.perf_counter() + with self.assertRaises(TimeoutError): + with helper(1): + with helper(2): + time.sleep(1.2) + elapsed = time.perf_counter() - start + + self.assertLess(elapsed, 1.15) + + +def _png_bytes() -> bytes: + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "image.png" + Image.new("RGBA", (8, 8), (16, 185, 129, 255)).save(image_path) + return image_path.read_bytes() + + +def json_payload(payload) -> str: + import json + + return json.dumps(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_manifest.py b/tests/test_generative_editable_manifest.py new file mode 100644 index 0000000..0257973 --- /dev/null +++ b/tests/test_generative_editable_manifest.py @@ -0,0 +1,489 @@ +import tempfile +import unittest +from pathlib import Path + +from src.generative_editable_manifest import ( + AssetSheetSpec, + BitmapAssetSpec, + DeckManifest, + NativeShapeSpec, + PageManifest, + RepairAttempt, + TextBoxSpec, + read_deck_manifest, + read_page_manifest, + write_manifest, +) + + +class GenerativeEditableManifestTest(unittest.TestCase): + def test_deck_manifest_records_run_structure(self): + manifest = DeckManifest( + job_id="job-001", + slide_order=["slide-b", "slide-a"], + aspect_ratio="16:9", + provider_roles={ + "ocr": "ocr_model", + "clean_base": "edit_model", + "asset_sheet": "edit_model", + "repair": "edit_model", + "generation": "image_model", + }, + quality_settings={ + "preview_similarity_threshold": 0.92, + "max_repair_attempts": 2, + "require_preview_validation": True, + }, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-b.json", "pages/0001-slide-a.json"], + validation_status="pending", + ) + + self.assertEqual(manifest.slide_order, ["slide-b", "slide-a"]) + self.assertEqual(manifest.provider_roles["ocr"], "ocr_model") + self.assertEqual(manifest.quality_settings["max_repair_attempts"], 2) + self.assertEqual(manifest.fallback_policy, "fail") + self.assertEqual(manifest.validation_status, "pending") + + def test_page_manifest_records_source_pixel_structure_and_validation(self): + text_box = TextBoxSpec( + text="Quarterly Plan", + source_pixel_bbox=(80, 54, 420, 102), + source_pixel_polygon=((80, 54), (420, 54), (420, 102), (80, 102)), + font_family="Arial", + font_size=32, + color_hex="#1F2937", + alignment="center", + provenance={"source": "ocr"}, + ) + shape = NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(60, 140, 300, 220), + fill_color="#FFFFFF", + line_color="#CBD5E1", + radius=12, + confidence=0.95, + provenance={"classifier": "fake"}, + ) + bitmap = BitmapAssetSpec( + asset_id="asset-1", + source_pixel_bbox=(340, 120, 620, 300), + asset_path="assets/asset-1.png", + z_order=3, + provenance={"sheet_id": "sheet-1"}, + ) + sheet = AssetSheetSpec( + sheet_id="sheet-1", + asset_path="assets/sheet-1.png", + prompt_id="asset_sheet", + candidate_ids=["asset-1"], + provider_role="generation", + provenance={"model": "fake-image-generation"}, + ) + repair = RepairAttempt( + target_id="asset-1", + attempt_index=1, + reason="edge_touch", + provider_role="repair", + status="passed", + provenance={"prompt_id": "repair_asset"}, + ) + + manifest = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="slides/slide-a.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + text_clean_background="backgrounds/slide-a.text-clean.png", + base_clean_background="backgrounds/slide-a.base-clean.png", + chosen_background="backgrounds/slide-a.base-clean.png", + text_boxes=[text_box], + native_shapes=[shape], + bitmap_assets=[bitmap], + asset_sheets=[sheet], + repair_attempts=[repair], + provenance={"input_hash": "abc123"}, + validation_status="pending", + ) + + self.assertEqual(manifest.source_image_size, (800, 450)) + self.assertEqual(manifest.text_boxes[0].source_pixel_bbox, (80, 54, 420, 102)) + self.assertEqual(manifest.native_shapes[0].source_pixel_bbox, (60, 140, 300, 220)) + self.assertEqual(manifest.bitmap_assets[0].source_pixel_bbox, (340, 120, 620, 300)) + self.assertEqual(manifest.asset_sheets[0].provider_role, "generation") + self.assertEqual(manifest.repair_attempts[0].status, "passed") + self.assertEqual(manifest.validation_status, "pending") + + def test_legacy_page_manifest_without_text_opacity_defaults_to_visible_text(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + page_path = root / "pages" / "0000-slide-a.json" + page_path.parent.mkdir(parents=True) + page_path.write_text( + """{ + "slide_id": "slide-a", + "page_index": 0, + "source_image_path": "slides/slide-a.png", + "source_image_size": [800, 450], + "slide_size": [10.0, 5.625], + "text_boxes": [ + { + "text": "Legacy text", + "source_pixel_bbox": [80, 54, 420, 102], + "source_pixel_polygon": [[80, 54], [420, 54], [420, 102], [80, 102]], + "font_family": "Arial", + "font_size": 32, + "color_hex": "#1F2937", + "alignment": "center" + } + ] +} +""", + encoding="utf-8", + ) + + page = read_page_manifest(page_path) + + self.assertEqual(page.text_boxes[0].opacity, 1.0) + + def test_manifest_models_validate_status_policy_and_source_pixel_boxes(self): + with self.assertRaisesRegex(ValueError, "fallback_policy"): + DeckManifest( + job_id="job", + slide_order=[], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="best_effort", + page_manifest_paths=[], + validation_status="pending", + ) + + with self.assertRaisesRegex(ValueError, "aspect_ratio"): + DeckManifest( + job_id="job", + slide_order=[], + aspect_ratio="16-9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=[], + validation_status="pending", + ) + + with self.assertRaisesRegex(ValueError, "page_manifest_paths"): + DeckManifest( + job_id="job", + slide_order=["slide-a", "slide-b"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + validation_status="pending", + ) + + with self.assertRaisesRegex(ValueError, "page_manifest_paths"): + DeckManifest( + job_id="job", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-b.json"], + validation_status="pending", + ) + + def test_page_manifest_round_trips_through_json_without_losing_structure(self): + manifest = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="slides/slide-a.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + text_clean_background="backgrounds/slide-a.text-clean.png", + base_clean_background="backgrounds/slide-a.base-clean.png", + chosen_background="backgrounds/slide-a.base-clean.png", + text_boxes=[ + TextBoxSpec( + text="Visit https://example.com", + source_pixel_bbox=(80, 54, 420, 102), + source_pixel_polygon=((80, 54), (420, 54), (420, 102), (80, 102)), + style_hints={"keyline": True}, + provenance={"provider_role": "ocr_model"}, + ) + ], + native_shapes=[ + NativeShapeSpec( + shape_type="rectangle", + source_pixel_bbox=(60, 140, 300, 220), + fill_color="#FFFFFF", + confidence=0.95, + provenance={"classifier": "fake"}, + ) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="asset-1", + source_pixel_bbox=(340, 120, 620, 300), + asset_path="assets/asset-1.png", + z_order=3, + provenance={ + "provider_role": "generation", + "asset_key": "legitimate-layout-key", + }, + ) + ], + asset_sheets=[ + AssetSheetSpec( + sheet_id="sheet-1", + asset_path="assets/sheet-1.png", + prompt_id="asset_sheet", + candidate_ids=["asset-1"], + provider_role="generation", + provenance={"model": "fake-image-generation"}, + ) + ], + repair_attempts=[ + RepairAttempt( + target_id="asset-1", + attempt_index=1, + reason="edge_touch", + provider_role="repair", + status="passed", + provenance={"prompt_id": "repair_asset"}, + ) + ], + provider_output_paths={ + "ocr": "provider_outputs/ocr/0000-slide-a/result.json", + "image_edit": "provider_outputs/image_edit/0000-slide-a/base-clean.json", + "image_generation": "provider_outputs/image_generation/0000-slide-a/asset-sheet.json", + "repair": "provider_outputs/repair/0000-slide-a/repair.json", + }, + provenance={"input_hash": "abc123"}, + validation_status="passed", + ) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "page.json" + write_manifest(path, manifest) + loaded = read_page_manifest(path) + + self.assertEqual(loaded.text_boxes[0].text, "Visit https://example.com") + self.assertTrue(loaded.text_boxes[0].style_hints["keyline"]) + self.assertEqual(loaded.text_boxes[0].source_pixel_bbox, (80, 54, 420, 102)) + self.assertEqual( + loaded.text_boxes[0].source_pixel_polygon, + ((80, 54), (420, 54), (420, 102), (80, 102)), + ) + self.assertEqual(loaded.bitmap_assets[0].asset_path, "assets/asset-1.png") + self.assertEqual( + loaded.bitmap_assets[0].provenance["asset_key"], + "legitimate-layout-key", + ) + self.assertEqual(loaded.native_shapes[0].shape_type, "rectangle") + self.assertEqual(loaded.asset_sheets[0].provenance["model"], "fake-image-generation") + self.assertEqual(loaded.repair_attempts[0].provenance["prompt_id"], "repair_asset") + self.assertEqual(loaded.text_clean_background, "backgrounds/slide-a.text-clean.png") + self.assertEqual(loaded.base_clean_background, "backgrounds/slide-a.base-clean.png") + self.assertEqual(loaded.provider_output_paths["repair"], "provider_outputs/repair/0000-slide-a/repair.json") + self.assertEqual(loaded.provenance["input_hash"], "abc123") + self.assertEqual(loaded.validation_status, "passed") + + def test_deck_manifest_round_trips_through_json_without_losing_provider_metadata(self): + manifest = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={"ocr": "ocr_model", "generation": "image_model"}, + quality_settings={"preview_similarity_threshold": 0.92}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + validation_status="failed", + provenance={"validation_error": "preview similarity below threshold"}, + ) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "deck.json" + write_manifest(path, manifest) + loaded = read_deck_manifest(path) + + self.assertEqual(loaded.provider_roles["generation"], "image_model") + self.assertEqual(loaded.page_manifest_paths, ["pages/0000-slide-a.json"]) + self.assertEqual(loaded.validation_status, "failed") + self.assertEqual(loaded.provenance["validation_error"], "preview similarity below threshold") + + with self.assertRaisesRegex(ValueError, "validation_status"): + PageManifest( + slide_id="slide", + page_index=0, + source_image_path="slide.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + validation_status="unknown", + ) + + with self.assertRaisesRegex(ValueError, "source_pixel_bbox"): + TextBoxSpec( + text="bad box", + source_pixel_bbox=(100, 100, 50, 120), + source_pixel_polygon=((100, 100), (50, 100), (50, 120), (100, 120)), + ) + + with self.assertRaisesRegex(ValueError, "source_pixel_bbox"): + TextBoxSpec( + text="negative box", + source_pixel_bbox=(-1, 10, 50, 120), + source_pixel_polygon=((0, 10), (50, 10), (50, 120), (0, 120)), + ) + + with self.assertRaisesRegex(ValueError, "source_pixel_bbox"): + TextBoxSpec( + text="float box", + source_pixel_bbox=(1.5, 10, 50, 120), + source_pixel_polygon=((1, 10), (50, 10), (50, 120), (1, 120)), + ) + + with self.assertRaisesRegex(ValueError, "source_pixel_bbox"): + PageManifest( + slide_id="slide", + page_index=0, + source_image_path="slides/slide.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + text_boxes=[ + TextBoxSpec( + text="out of bounds", + source_pixel_bbox=(10, 10, 900, 120), + source_pixel_polygon=((10, 10), (900, 10), (900, 120), (10, 120)), + ) + ], + ) + + with self.assertRaisesRegex(ValueError, "confidence"): + NativeShapeSpec( + shape_type="rectangle", + source_pixel_bbox=(10, 10, 50, 50), + confidence=1.5, + ) + + with self.assertRaisesRegex(ValueError, "shape_type"): + NativeShapeSpec( + shape_type="freeform", + source_pixel_bbox=(10, 10, 50, 50), + ) + + with self.assertRaisesRegex(ValueError, "radius"): + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(10, 10, 50, 50), + radius=-1, + ) + + with self.assertRaisesRegex(ValueError, "attempt_index"): + RepairAttempt( + target_id="asset", + attempt_index=0, + reason="retry", + provider_role="repair", + status="pending", + ) + + def test_manifest_rejects_unscoped_artifact_paths_and_redacts_persisted_secrets(self): + with self.assertRaisesRegex(ValueError, "source_image_path"): + PageManifest( + slide_id="slide", + page_index=0, + source_image_path="../outside.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + ) + + with self.assertRaisesRegex(ValueError, "asset_path"): + BitmapAssetSpec( + asset_id="asset", + source_pixel_bbox=(10, 10, 50, 50), + asset_path="/tmp/asset.png", + z_order=1, + ) + + manifest = DeckManifest( + job_id="job-001", + slide_order=["???"], + aspect_ratio="16:9", + provider_roles={"ocr": "ocr_model"}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-item.json"], + provenance={"error": "api_key=secret at https://example.test/path"}, + ) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "deck.json" + write_manifest(path, manifest) + raw = path.read_text(encoding="utf-8") + + self.assertNotIn("secret", raw) + self.assertNotIn("https://example.test/path", raw) + self.assertIn("[REDACTED]", raw) + + with self.assertRaisesRegex(ValueError, "provider_output_paths"): + PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="slides/slide-a.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + provider_output_paths={ + "ocr": "provider_outputs/repair/0000-slide-a/result.json", + }, + ) + + with self.assertRaisesRegex(ValueError, "provider_output_paths"): + PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="slides/slide-a.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + provider_output_paths={ + "unknown": "provider_outputs/unknown/0000-slide-a/result.json", + }, + ) + + def test_manifest_persistence_drops_sensitive_key_substrings_in_provenance(self): + manifest = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={"ocr": "ocr_model"}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + provenance={ + "openai_api_key": "openai-secret", + "provider_api_key": "provider-secret", + "imageEditToken": "image-edit-secret", + "safe_note": "keep-me", + }, + ) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "deck.json" + write_manifest(path, manifest) + raw = path.read_text(encoding="utf-8") + loaded = read_deck_manifest(path) + + self.assertNotIn("openai-secret", raw) + self.assertNotIn("provider-secret", raw) + self.assertNotIn("image-edit-secret", raw) + self.assertNotIn("openai_api_key", loaded.provenance) + self.assertNotIn("provider_api_key", loaded.provenance) + self.assertNotIn("imageEditToken", loaded.provenance) + self.assertEqual(loaded.provenance["safe_note"], "keep-me") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_page_analysis.py b/tests/test_generative_editable_page_analysis.py new file mode 100644 index 0000000..cd7e8fe --- /dev/null +++ b/tests/test_generative_editable_page_analysis.py @@ -0,0 +1,53 @@ +from src.generative_editable_manifest import TextBoxSpec + + +def _box(text: str, bbox: tuple[int, int, int, int], *, approximate: bool = True) -> TextBoxSpec: + provenance = {"ocr_provenance": {"approximate_layout": True}} if approximate else {} + return TextBoxSpec( + text=text, + source_pixel_bbox=bbox, + source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + provenance=provenance, + ) + + +def test_page_text_analysis_keeps_visually_anchored_layouts_and_demotes_row_band_ocr_to_hints(): + from src.generative_editable_page_analysis import build_page_text_analysis + + analysis = build_page_text_analysis( + text_boxes=[ + _box("非高压", (80, 130, 138, 179)), + _box("日常操作", (150, 127, 339, 190)), + _box("精确文本", (500, 300, 620, 330), approximate=False), + ], + visual_text_candidates=[ + (90, 137, 1026, 181), + (150, 127, 339, 190), + (294, 21, 1086, 77), + ], + source_image_size=(1672, 941), + ) + + assert [item.text for item in analysis.accepted_text_boxes] == ["日常操作", "精确文本"] + assert [hint.text for hint in analysis.rejected_ocr_hints] == ["非高压"] + assert analysis.rejected_ocr_hints[0].reason == "unanchored_approximate_ocr" + + +def test_page_text_analysis_records_reference_sop_provenance_for_accepted_layouts(): + from src.generative_editable_page_analysis import build_page_text_analysis + + analysis = build_page_text_analysis( + text_boxes=[ + _box("紧急与维护", (801, 127, 1037, 190)), + ], + visual_text_candidates=[ + (801, 127, 1037, 190), + (294, 21, 1086, 77), + (150, 127, 339, 190), + ], + source_image_size=(1672, 941), + ) + + accepted = analysis.accepted_text_boxes[0] + assert accepted.provenance["layout_source"] == "visual_text_candidate" + assert accepted.provenance["ocr_layout_usage"] == "hint_only" diff --git a/tests/test_generative_editable_pipeline.py b/tests/test_generative_editable_pipeline.py new file mode 100644 index 0000000..4456154 --- /dev/null +++ b/tests/test_generative_editable_pipeline.py @@ -0,0 +1,7708 @@ +import json +import tempfile +import threading +import time +import unittest +from pathlib import Path + +from src.generative_editable_manifest import ( + BitmapAssetSpec, + NativeShapeSpec, + PageManifest, + RepairAttempt, + TextBoxSpec, +) +from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + + +class GenerativeEditablePipelineTest(unittest.TestCase): + def test_visual_text_coverage_reports_unmatched_left_label_candidate(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_visual_text_coverage_issues", None) + self.assertIsNotNone(helper) + + text_boxes = [ + TextBoxSpec( + text="智能域", + source_pixel_bbox=(286, 247, 391, 282), + source_pixel_polygon=((286, 247), (391, 247), (391, 282), (286, 282)), + ), + TextBoxSpec( + text="动力域", + source_pixel_bbox=(274, 747, 379, 782), + source_pixel_polygon=((274, 747), (379, 747), (379, 782), (274, 782)), + ), + ] + + issues = helper( + text_boxes, + visual_text_candidates=[ + (286, 247, 391, 282), + (286, 497, 391, 532), + (274, 747, 379, 782), + ], + ) + + self.assertEqual([issue["code"] for issue in issues], ["visual_text_candidate_missing_ocr_text"]) + self.assertEqual(issues[0]["source_pixel_bbox"], (286, 497, 391, 532)) + + def test_unanchored_approximate_text_boxes_are_filtered_when_local_candidates_exist(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + anchored = TextBoxSpec( + text="日常操作", + source_pixel_bbox=(150, 127, 339, 190), + source_pixel_polygon=((150, 127), (339, 127), (339, 190), (150, 190)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + unanchored = TextBoxSpec( + text="连接充电枪不可启动车辆", + source_pixel_bbox=(701, 87, 929, 97), + source_pixel_polygon=((701, 87), (929, 87), (929, 97), (701, 97)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + exact = TextBoxSpec( + text="人工元数据", + source_pixel_bbox=(20, 20, 120, 60), + source_pixel_polygon=((20, 20), (120, 20), (120, 60), (20, 60)), + ) + + kept, issues = helper( + [anchored, unanchored, exact], + visual_text_candidates=[ + (150, 127, 339, 190), + (801, 127, 1037, 190), + (279, 9, 551, 89), + ], + source_image_size=(1600, 900), + ) + + self.assertEqual([box.text for box in kept], ["日常操作", "人工元数据"]) + self.assertEqual(issues[0]["code"], "unanchored_approximate_ocr") + self.assertEqual(issues[0]["ocr_text"], "连接充电枪不可启动车辆") + self.assertEqual(issues[0]["severity"], "warning") + + def test_small_top_approximate_text_is_not_anchored_by_title_candidate(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + misplaced = TextBoxSpec( + text="连接充电枪不可启动车辆", + source_pixel_bbox=(701, 87, 929, 97), + source_pixel_polygon=((701, 87), (929, 87), (929, 97), (701, 97)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + + kept, issues = helper( + [misplaced], + visual_text_candidates=[ + (279, 9, 551, 89), + (609, 9, 693, 89), + (775, 9, 1299, 89), + (150, 127, 339, 190), + (801, 127, 1037, 190), + (560, 789, 740, 879), + ], + source_image_size=(1672, 941), + ) + + self.assertEqual(kept, []) + self.assertEqual(issues[0]["code"], "unanchored_approximate_ocr") + + def test_quality_gate_keeps_dashboard_card_heading_candidates(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_quality_gate_visual_text_candidates", None) + self.assertIsNotNone(helper) + + quality_candidates = helper( + [ + (150, 127, 339, 190), + (801, 127, 1037, 190), + (279, 9, 551, 89), + ], + (1672, 941), + ) + + self.assertEqual(quality_candidates, [(150, 127, 339, 190), (801, 127, 1037, 190)]) + + def test_quality_gate_keeps_general_text_like_candidates_for_focused_recovery(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_quality_gate_visual_text_candidates", None) + self.assertIsNotNone(helper) + + visual_candidates = [ + (279, 9, 551, 89), # oversized title fragment already covered elsewhere + (751, 381, 1072, 461), # flowchart node text + (1179, 381, 1548, 461), # flowchart node text + (762, 490, 1061, 557), # flowchart branch text + (1205, 490, 1525, 557), # flowchart branch text + (206, 807, 514, 842), # bottom card text + (1414, 814, 1593, 848), # bottom card text + ] + + quality_candidates = helper(visual_candidates, (1672, 941)) + + self.assertEqual( + quality_candidates, + [ + (751, 381, 1072, 461), + (1179, 381, 1548, 461), + (762, 490, 1061, 557), + (1205, 490, 1525, 557), + (206, 807, 514, 842), + (1414, 814, 1593, 848), + ], + ) + + def test_right_side_spec_stack_approximate_ocr_is_dropped_without_visual_candidates(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + stack = [ + TextBoxSpec( + text=text, + source_pixel_bbox=bbox, + source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + for text, bbox in [ + ("线控转向", (1250, 440, 1380, 467)), + ("后轮转向", (1291, 473, 1380, 497)), + ("空气悬架", (1292, 507, 1380, 530)), + ("Livis版800V主动悬架", (1292, 540, 1502, 563)), + ("EMB", (1292, 575, 1340, 595)), + ] + ] + + kept, issues = helper( + stack, + visual_text_candidates=[ + (328, 36, 704, 126), + (285, 245, 391, 283), + (278, 497, 385, 536), + ], + source_image_size=(1706, 960), + ) + + self.assertEqual(kept, []) + self.assertEqual([issue["ocr_text"] for issue in issues], [box.text for box in stack]) + + def test_right_side_spec_stack_approximate_ocr_requires_visual_column_anchors(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + stack = [ + TextBoxSpec( + text=text, + source_pixel_bbox=bbox, + source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + for text, bbox in [ + ("制动", (1332, 321, 1388, 349)), + ("车窗", (1326, 322, 1410, 350)), + ("调整坐姿 | 250mm", (1211, 404, 1548, 450)), + ("拔充电枪后启动", (1207, 502, 1433, 546)), + ] + ] + + kept, issues = helper( + stack, + visual_text_candidates=[ + (150, 127, 339, 190), + (801, 127, 1037, 190), + (257, 490, 555, 529), + (258, 580, 485, 616), + (256, 675, 543, 711), + ], + source_image_size=(1672, 941), + ) + + self.assertEqual(kept, []) + self.assertEqual( + [issue["ocr_text"] for issue in issues], + ["制动", "车窗", "调整坐姿 | 250mm", "拔充电枪后启动"], + ) + + def test_approximate_ocr_is_not_anchored_by_non_text_like_visual_candidates(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + text_boxes = [ + TextBoxSpec( + text="非高压", + source_pixel_bbox=(80, 130, 138, 179), + source_pixel_polygon=((80, 130), (138, 130), (138, 179), (80, 179)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="日常操作", + source_pixel_bbox=(150, 127, 339, 190), + source_pixel_polygon=((150, 127), (339, 127), (339, 190), (150, 190)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + ] + + kept, issues = helper( + text_boxes, + visual_text_candidates=[ + (80, 130, 138, 179), + (150, 127, 339, 190), + (294, 21, 1086, 77), + ], + source_image_size=(1672, 941), + ) + + self.assertEqual([box.text for box in kept], ["日常操作"]) + self.assertEqual([issue["ocr_text"] for issue in issues], ["非高压"]) + + def test_approximate_ocr_is_not_anchored_by_broad_row_band_candidates(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + misplaced = TextBoxSpec( + text="非高压", + source_pixel_bbox=(80, 130, 138, 179), + source_pixel_polygon=((80, 130), (138, 130), (138, 179), (80, 179)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + anchored = TextBoxSpec( + text="日常操作", + source_pixel_bbox=(150, 127, 339, 190), + source_pixel_polygon=((150, 127), (339, 127), (339, 190), (150, 190)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + + kept, issues = helper( + [misplaced, anchored], + visual_text_candidates=[ + (90, 137, 1026, 181), + (150, 127, 339, 190), + (294, 21, 1086, 77), + ], + source_image_size=(1672, 941), + ) + + self.assertEqual([box.text for box in kept], ["日常操作"]) + self.assertEqual([issue["ocr_text"] for issue in issues], ["非高压"]) + + def test_right_side_spec_stack_approximate_ocr_survives_row_band_boundaries(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + boxes = [ + TextBoxSpec( + text=text, + source_pixel_bbox=bbox, + source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + for text, bbox in [ + ("核心架构设计:增程、底盘与智能域", (347, 51, 1330, 111)), + ("智能域", (286, 247, 391, 282)), + ("29英寸6K屏", (1291, 208, 1406, 230)), + ("21英寸4K后舱屏", (1292, 240, 1448, 263)), + ("5440W音响", (1292, 273, 1404, 295)), + ("理想同学", (1291, 306, 1377, 328)), + ("线控转向", (1250, 440, 1380, 467)), + ("后轮转向", (1291, 473, 1380, 497)), + ("空气悬架", (1292, 507, 1380, 530)), + ("Livis版800V主动悬架", (1292, 540, 1502, 563)), + ("EMB", (1292, 575, 1340, 595)), + ("动力域", (274, 747, 379, 782)), + ("第三代增程器", (1292, 732, 1437, 757)), + ("72.7kWh 5C电池", (1292, 774, 1475, 799)), + ] + ] + + kept, issues = helper( + boxes, + visual_text_candidates=[ + (328, 36, 704, 126), + (345, 50, 1086, 111), + (555, 195, 598, 316), + (1297, 203, 1411, 234), + (1299, 235, 1411, 267), + (285, 245, 391, 283), + (1297, 268, 1409, 299), + (1291, 301, 1383, 333), + (750, 305, 807, 317), + (999, 380, 1067, 544), + (278, 497, 385, 536), + (941, 683, 1075, 833), + (299, 738, 376, 790), + (1392, 772, 1431, 800), + ], + source_image_size=(1672, 941), + ) + + kept_text = {box.text for box in kept} + self.assertIn("线控转向", kept_text) + self.assertIn("后轮转向", kept_text) + self.assertIn("Livis版800V主动悬架", kept_text) + self.assertIn("EMB", kept_text) + self.assertIn("第三代增程器", kept_text) + self.assertNotIn("unanchored_approximate_ocr", {issue["code"] for issue in issues}) + + def test_dense_approximate_ocr_drops_substantial_unanchored_text(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_filter_unanchored_approximate_text_boxes", None) + self.assertIsNotNone(helper) + + right_panel_text = TextBoxSpec( + text="调整坐姿 | 250mm", + source_pixel_bbox=(1211, 404, 1548, 450), + source_pixel_polygon=((1211, 404), (1548, 404), (1548, 450), (1211, 450)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + small_top_fragment = TextBoxSpec( + text="OTA并级", + source_pixel_bbox=(736, 131, 772, 153), + source_pixel_polygon=((736, 131), (772, 131), (772, 153), (736, 153)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + + kept, issues = helper( + [right_panel_text, small_top_fragment], + visual_text_candidates=[ + (279, 9, 551, 89), + (609, 9, 693, 89), + (775, 9, 1299, 89), + (150, 127, 339, 190), + (801, 127, 1037, 190), + (112, 468, 168, 513), + (111, 647, 164, 675), + (560, 789, 740, 879), + ], + source_image_size=(1672, 941), + ) + + self.assertEqual(kept, []) + self.assertEqual( + [issue["ocr_text"] for issue in issues], + ["调整坐姿 | 250mm", "OTA并级"], + ) + self.assertFalse(any(issue.get("kept") for issue in issues)) + + def test_default_visual_text_candidate_detector_uses_local_image_hints(self): + from PIL import Image, ImageDraw, ImageFont + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_pipeline import GenerativeEditablePipelineDependencies + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + font_path = Path("/System/Library/Fonts/STHeiti Medium.ttc") + if not font_path.exists(): + self.skipTest("system CJK font unavailable") + + config = load_generative_editable_config(use_fake=True) + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + ) + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + image = Image.new("RGB", (900, 300), "#030914") + draw = ImageDraw.Draw(image) + font = ImageFont.truetype(str(font_path), 34) + draw.rounded_rectangle((70, 110, 830, 198), radius=12, fill="#0B2340") + draw.text((155, 144), "底盘域", font=font, fill="#168BFF") + image.save(image_path) + + candidates = dependencies.visual_text_candidate_detector( + source_image_path=str(image_path), + source_image_size=image.size, + text_boxes=[], + ) + + self.assertTrue(candidates) + + def test_recovers_uncovered_visual_text_candidate_with_focused_crop_ocr(self): + from PIL import Image + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_config import ProviderConfig + from src.generative_editable_providers import OCRProvider, OCRResult, OCRTextItem + + helper = getattr(pipeline, "_recover_visual_text_candidates", None) + self.assertIsNotNone(helper) + + class CropOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(107, 39), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="底盘域", + bbox=(0, 0, 107, 39), + polygon=((0, 0), (107, 0), (107, 39), (0, 39)), + confidence=0.93, + font_size_hint=13, + color_hex="#168BFF", + ) + ], + ) + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + crop_dir = Path(tmp) / "crops" + Image.new("RGB", (800, 450), "#030914").save(source) + + recovered = helper( + source_image_path=source, + source_image_size=(800, 450), + text_boxes=[ + TextBoxSpec( + text="智能域", + source_pixel_bbox=(278, 90, 385, 129), + source_pixel_polygon=((278, 90), (385, 90), (385, 129), (278, 129)), + font_size=12.05, + color_hex="#2E88F5", + ) + ], + uncovered_candidates=[(278, 197, 385, 236)], + ocr_provider=CropOCRProvider( + ProviderConfig( + role="ocr_model", + provider="test_ocr", + model="test", + base_url="", + api_key="", + ) + ), + crop_dir=crop_dir, + ) + + self.assertEqual([box.text for box in recovered], ["底盘域"]) + self.assertEqual(recovered[0].source_pixel_bbox, (278, 197, 385, 236)) + self.assertEqual(recovered[0].font_size, 12.05) + self.assertEqual(recovered[0].style_hints["recovered_from_visual_candidate"], True) + + def test_recovery_skips_text_fragment_already_covered_by_existing_title(self): + from PIL import Image + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_config import ProviderConfig + from src.generative_editable_providers import OCRProvider, OCRResult, OCRTextItem + + helper = getattr(pipeline, "_recover_visual_text_candidates", None) + self.assertIsNotNone(helper) + + class FragmentOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(272, 80), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="落地建议:", + bbox=(0, 0, 272, 80), + polygon=((0, 0), (272, 0), (272, 80), (0, 80)), + confidence=0.91, + color_hex="#FFFFFF", + ) + ], + ) + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + crop_dir = Path(tmp) / "crops" + Image.new("RGB", (1672, 941), "#030914").save(source) + ignored_candidates: set[tuple[int, int, int, int]] = set() + + recovered = helper( + source_image_path=source, + source_image_size=(1672, 941), + text_boxes=[ + TextBoxSpec( + text="落地建议:用车、补能与维护的最佳实践", + source_pixel_bbox=(296, 22, 1283, 76), + source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), + font_size=18.59, + color_hex="#F7F7F8", + ) + ], + uncovered_candidates=[(279, 9, 551, 89)], + ocr_provider=FragmentOCRProvider( + ProviderConfig( + role="ocr_model", + provider="test_ocr", + model="test", + base_url="", + api_key="", + ) + ), + crop_dir=crop_dir, + ignored_noise_candidates=ignored_candidates, + ) + + self.assertEqual(recovered, []) + self.assertEqual(ignored_candidates, {(279, 9, 551, 89)}) + + def test_recovery_dedupes_overlapping_recovered_candidates_in_same_batch(self): + from PIL import Image + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_config import ProviderConfig + from src.generative_editable_providers import OCRProvider, OCRResult, OCRTextItem + + helper = getattr(pipeline, "_recover_visual_text_candidates", None) + self.assertIsNotNone(helper) + + class DuplicateOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(120, 40), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="日常操作", + bbox=(0, 0, 120, 40), + polygon=((0, 0), (120, 0), (120, 40), (0, 40)), + confidence=0.92, + color_hex="#FFFFFF", + ) + ], + ) + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + crop_dir = Path(tmp) / "crops" + Image.new("RGB", (800, 450), "#030914").save(source) + ignored_candidates: set[tuple[int, int, int, int]] = set() + + recovered = helper( + source_image_path=source, + source_image_size=(800, 450), + text_boxes=[], + uncovered_candidates=[ + (100, 100, 220, 140), + (104, 102, 224, 142), + ], + ocr_provider=DuplicateOCRProvider( + ProviderConfig( + role="ocr_model", + provider="test_ocr", + model="test", + base_url="", + api_key="", + ) + ), + crop_dir=crop_dir, + ignored_noise_candidates=ignored_candidates, + ) + + self.assertEqual([box.text for box in recovered], ["日常操作"]) + self.assertEqual(ignored_candidates, {(104, 102, 224, 142)}) + + def test_recovery_rejects_repetitive_numeric_noise(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_best_recovered_ocr_item", None) + self.assertIsNotNone(helper) + + item = OCRTextItem( + text="1. 1. 1. 1. 1. 1. 1. 1. 1. 1.", + bbox=(0, 0, 80, 24), + polygon=((0, 0), (80, 0), (80, 24), (0, 24)), + confidence=0.78, + ) + + self.assertIsNone(helper([item])) + + def test_recovery_rejects_repetitive_cjk_hallucination(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_best_recovered_ocr_item", None) + self.assertIsNotNone(helper) + + item = OCRTextItem( + text="2. 根据用户需求,根据需求,根据用户需求,根据需求,根据用户需求,根据用户需求," * 4, + bbox=(0, 0, 120, 62), + polygon=((0, 0), (120, 0), (120, 62), (0, 62)), + confidence=0.78, + ) + + self.assertIsNone(helper([item])) + + def test_recovery_rejects_short_repeated_cjk_noise_and_prompt_boilerplate(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_best_recovered_ocr_item", None) + self.assertIsNotNone(helper) + + repeated = OCRTextItem( + text="帮 帮 帮", + bbox=(0, 0, 40, 24), + polygon=((0, 0), (40, 0), (40, 24), (0, 24)), + confidence=0.78, + ) + boilerplate = OCRTextItem( + text="2. 根据用户需求,设计一个符合用户需求、易用易操作的网站。", + bbox=(0, 0, 240, 40), + polygon=((0, 0), (240, 0), (240, 40), (0, 40)), + confidence=0.78, + ) + + self.assertIsNone(helper([repeated])) + self.assertIsNone(helper([boilerplate])) + + def test_recovery_rejects_repeated_symbol_noise(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_best_recovered_ocr_item", None) + self.assertIsNotNone(helper) + + symbol_noise = OCRTextItem( + text="🔥" * 80, + bbox=(0, 0, 120, 32), + polygon=((0, 0), (120, 0), (120, 32), (0, 32)), + confidence=0.78, + ) + + self.assertIsNone(helper([symbol_noise])) + + def test_recovery_rejects_prompt_label_noise(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_best_recovered_ocr_item", None) + self.assertIsNotNone(helper) + + prompt_label = OCRTextItem( + text="1. 文本内容:", + bbox=(0, 0, 160, 32), + polygon=((0, 0), (160, 0), (160, 32), (0, 32)), + confidence=0.78, + ) + + self.assertIsNone(helper([prompt_label])) + + def test_recovery_rejects_low_confidence_crop_ocr(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_best_recovered_ocr_item", None) + self.assertIsNotNone(helper) + + low_confidence = OCRTextItem( + text="总结与持续进化", + bbox=(0, 0, 180, 36), + polygon=((0, 0), (180, 0), (180, 36), (0, 36)), + confidence=0.42, + ) + + self.assertIsNone(helper([low_confidence])) + + def test_recovered_summary_body_copy_font_size_is_capped_for_wrapped_text(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_recovered_text_box_from_candidate", None) + self.assertIsNotNone(helper) + + text_box = helper( + (418, 452, 1338, 501), + OCRTextItem( + text="增程架构重新定义豪华SUV,线控底盘与智能座舱建立新标杆;", + bbox=(0, 0, 920, 113), + polygon=((0, 0), (920, 0), (920, 113), (0, 113)), + confidence=0.82, + color_hex="#F8FAFC", + ), + source_image_size=(1672, 941), + text_boxes=[], + ) + + self.assertLessEqual(text_box.font_size or 0, 22.0) + + def test_focused_recovery_uses_all_visual_text_candidates_when_ocr_only_hallucinates(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_focused_recovery_candidate_bboxes", None) + self.assertIsNotNone(helper) + + visual_candidates = [ + (500, 210, 620, 260), + (445, 464, 810, 507), + (1411, 878, 1439, 905), + ] + text_issues = [ + {"code": "ignored_spurious_ocr", "ocr_text": "1. 了解用户需求"}, + {"code": "ignored_spurious_ocr", "ocr_text": "根据用户需求,设计一个符合用户需求" * 5}, + ] + + self.assertEqual( + helper( + text_boxes=[], + text_issues=text_issues, + visual_text_candidates=visual_candidates, + quality_gate_candidates=[], + source_image_size=(1672, 941), + ), + [ + (468, 207, 1204, 273), + (451, 301, 1271, 433), + (418, 452, 1338, 501), + (502, 499, 1254, 565), + (1354, 866, 1622, 922), + *visual_candidates, + ], + ) + + def test_summary_slide_recovery_body_copy_bboxes_cover_each_body_line(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_summary_slide_recovery_bboxes", None) + self.assertIsNotNone(helper) + + bboxes = helper((1672, 941)) + + self.assertIn((418, 452, 1338, 501), bboxes) + self.assertIn((502, 499, 1254, 565), bboxes) + self.assertNotIn((418, 452, 1338, 565), bboxes) + self.assertNotIn((418, 452, 1304, 508), bboxes) + + def test_focused_recovery_does_not_add_summary_crops_for_dashboard_headings(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_focused_recovery_candidate_bboxes", None) + self.assertIsNotNone(helper) + + visual_candidates = [ + (150, 127, 339, 190), + (801, 127, 1037, 190), + ] + text_issues = [ + {"code": "ignored_spurious_ocr", "ocr_text": "随机无关文本"}, + ] + + self.assertEqual( + helper( + text_boxes=[], + text_issues=text_issues, + visual_text_candidates=visual_candidates, + quality_gate_candidates=[], + source_image_size=(1672, 941), + ), + visual_candidates, + ) + + def test_focused_recovery_does_not_use_summary_crops_for_cover_with_huge_candidate(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_focused_recovery_candidate_bboxes", None) + self.assertIsNotNone(helper) + + visual_candidates = [ + (81, 66, 305, 102), + (40, 404, 1672, 858), + (261, 544, 339, 572), + (157, 545, 257, 571), + ] + text_issues = [ + {"code": "ignored_spurious_ocr", "ocr_text": "基于增程系统消除长距离出行焦虑"}, + {"code": "ignored_spurious_ocr", "ocr_text": "72.7kWh 5C电池 | CLTC纯电续航420km"}, + ] + + self.assertEqual( + helper( + text_boxes=[], + text_issues=text_issues, + visual_text_candidates=visual_candidates, + quality_gate_candidates=[], + source_image_size=(1706, 960), + ), + [ + (81, 66, 305, 102), + (261, 544, 339, 572), + (157, 545, 257, 571), + ], + ) + + def test_focused_recovery_keeps_quality_candidates_covered_only_by_approximate_ocr(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_focused_recovery_candidate_bboxes", None) + self.assertIsNotNone(helper) + + text_boxes = [ + TextBoxSpec( + text="驻车锁车", + source_pixel_bbox=(92, 138, 327, 180), + source_pixel_polygon=((92, 138), (327, 138), (327, 180), (92, 180)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="电量充足", + source_pixel_bbox=(743, 138, 1025, 180), + source_pixel_polygon=((743, 138), (1025, 138), (1025, 180), (743, 180)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + ] + + recovered_candidates = helper( + text_boxes=text_boxes, + text_issues=[], + visual_text_candidates=[ + (150, 127, 339, 190), + (801, 127, 1037, 190), + ], + quality_gate_candidates=[ + (150, 127, 339, 190), + (801, 127, 1037, 190), + ], + source_image_size=(1672, 941), + ) + + self.assertEqual(recovered_candidates, [(150, 127, 339, 190), (801, 127, 1037, 190)]) + + def test_focused_recovery_removes_overlapping_wrong_approximate_text_boxes(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_remove_approximate_text_boxes_replaced_by_focused_recovery", None) + self.assertIsNotNone(helper) + + original = [ + TextBoxSpec( + text="驻车锁车", + source_pixel_bbox=(92, 138, 327, 180), + source_pixel_polygon=((92, 138), (327, 138), (327, 180), (92, 180)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="落地建议:用车、补能与维护的最佳实践", + source_pixel_bbox=(296, 22, 1283, 76), + source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + ] + recovered = [ + TextBoxSpec( + text="日常操作", + source_pixel_bbox=(150, 127, 339, 190), + source_pixel_polygon=((150, 127), (339, 127), (339, 190), (150, 190)), + provenance={"content_source": "focused_crop_ocr"}, + ) + ] + + filtered = helper(original, recovered) + + self.assertEqual([box.text for box in filtered], ["落地建议:用车、补能与维护的最佳实践"]) + + def test_focused_recovery_replaces_offset_approximate_text_in_same_visual_region(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_remove_approximate_text_boxes_replaced_by_focused_recovery", None) + self.assertIsNotNone(helper) + + original = [ + TextBoxSpec( + text="高压事故", + source_pixel_bbox=(1207, 578, 1525, 665), + source_pixel_polygon=((1207, 578), (1525, 578), (1525, 665), (1207, 665)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="落地建议:用车、补能与维护的最佳实践", + source_pixel_bbox=(296, 22, 1283, 76), + source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + ] + recovered = [ + TextBoxSpec( + text="调整线路或现场处理", + source_pixel_bbox=(1205, 490, 1525, 557), + source_pixel_polygon=((1205, 490), (1525, 490), (1525, 557), (1205, 557)), + provenance={"content_source": "focused_crop_ocr"}, + ) + ] + + filtered = helper(original, recovered) + + self.assertEqual([box.text for box in filtered], ["落地建议:用车、补能与维护的最佳实践"]) + + def test_recovery_marks_noise_candidates_as_non_blocking(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_providers import OCRTextItem + + helper = getattr(pipeline, "_visual_text_coverage_issues", None) + self.assertIsNotNone(helper) + + issues = helper( + [], + visual_text_candidates=[(251, 236, 330, 258)], + non_blocking_visual_text_candidates={(251, 236, 330, 258)}, + ) + + self.assertEqual(issues, []) + + def test_prefers_text_clean_background_when_base_clean_removes_complex_visuals(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_choose_text_clean_background", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + base_clean = root / "base-clean.png" + text_clean = root / "text-clean.png" + + source_image = Image.new("RGB", (400, 240), "#020A18") + draw = ImageDraw.Draw(source_image) + draw.rounded_rectangle((120, 70, 280, 150), radius=12, fill="#586272") + draw.text((24, 20), "Title", fill="#FFFFFF") + source_image.save(source) + + Image.new("RGB", (400, 240), "#020A18").save(base_clean) + text_clean_image = source_image.copy() + ImageDraw.Draw(text_clean_image).rectangle((20, 18, 90, 42), fill="#020A18") + text_clean_image.save(text_clean) + + choose_text_clean = helper( + source_image_path=source, + base_clean_image_path=base_clean, + text_clean_image_path=text_clean, + text_boxes=[ + TextBoxSpec( + text="Title", + source_pixel_bbox=(20, 18, 90, 42), + source_pixel_polygon=((20, 18), (90, 18), (90, 42), (20, 42)), + ) + ], + ) + + self.assertTrue(choose_text_clean) + + def test_pipeline_chooses_text_clean_background_when_it_preserves_visuals_better(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + ImageEditProvider, + OCRProvider, + OCRResult, + OCRTextItem, + FakeImageGenerationProvider, + ) + + class TitleOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(400, 240), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="Title", + bbox=(20, 18, 90, 42), + polygon=((20, 18), (90, 18), (90, 42), (20, 42)), + confidence=0.96, + ) + ], + ) + + class DivergentBackgroundEditProvider(ImageEditProvider): + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + if request.prompt_id == "base_clean_background": + Image.new("RGB", (400, 240), "#020A18").save(output) + else: + with Image.open(request.source_image_path) as source: + image = source.convert("RGB") + ImageDraw.Draw(image).rectangle((20, 18, 90, 42), fill="#020A18") + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_path = root / "deck.pptx" + artifact_root = root / "jobs" + image = Image.new("RGB", (400, 240), "#020A18") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((120, 70, 280, 150), radius=12, fill="#586272") + draw.text((24, 20), "Title", fill="#FFFFFF") + image.save(source) + + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=TitleOCRProvider(config.ocr), + image_edit_provider=DivergentBackgroundEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=lambda **kwargs: [], + asset_builder=lambda **kwargs: AssetBuildResult( + bitmap_assets=[ + BitmapAssetSpec( + asset_id="photo", + source_pixel_bbox=(120, 70, 280, 150), + asset_path="assets/0000-slide-a/photo.png", + z_order=1, + ) + ] + ), + visual_text_candidate_detector=lambda **kwargs: [], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + + self.assertEqual(page.chosen_background, "backgrounds/0000-slide-a/text-clean.png") + + def test_pipeline_keeps_reconstruction_background_when_generated_backgrounds_lose_visuals(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageGenerationProvider, + ImageEditProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class LargeTitleOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(400, 240), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="Architecture", + bbox=(20, 18, 220, 80), + polygon=((20, 18), (220, 18), (220, 80), (20, 80)), + confidence=0.96, + ) + ], + ) + + class BadGeneratedBackgroundEditProvider(ImageEditProvider): + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + if request.prompt_id == "base_clean_background": + Image.new("RGB", (400, 240), "#020A18").save(output) + else: + Image.new("RGB", (400, 240), "#27496D").save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_path = root / "deck.pptx" + artifact_root = root / "jobs" + image = Image.new("RGB", (400, 240), "#020A18") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 18, 220, 80), fill="#FFFFFF") + draw.rounded_rectangle((120, 90, 280, 170), radius=12, fill="#586272") + image.save(source) + + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=LargeTitleOCRProvider(config.ocr), + image_edit_provider=BadGeneratedBackgroundEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=lambda **kwargs: [], + asset_builder=lambda **kwargs: AssetBuildResult( + bitmap_assets=[ + BitmapAssetSpec( + asset_id="complex-visual", + source_pixel_bbox=(120, 90, 280, 170), + asset_path="assets/0000-slide-a/complex-visual.png", + z_order=1, + ) + ] + ), + visual_text_candidate_detector=lambda **kwargs: [], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + + self.assertEqual(page.chosen_background, "backgrounds/0000-slide-a/base-clean.png") + self.assertNotEqual(page.provenance.get("chosen_background_kind"), "source_preserving_text_clean") + self.assertNotIn("source_preserving", page.provenance.get("backgrounds", {})) + + def test_pipeline_uses_source_raster_guardrail_when_approximate_ocr_layout_is_unreliable(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class UnreliableApproximateOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + items = [ + OCRTextItem( + text="落地建议:用车、补能与维护的最佳实践", + bbox=(54, 20, 330, 60), + polygon=((54, 20), (330, 20), (330, 60), (54, 60)), + confidence=0.95, + provenance={}, + ), + OCRTextItem( + text="核心架构设计:增程、底盘与智能域", + bbox=(20, 92, 380, 142), + polygon=((20, 92), (380, 92), (380, 142), (20, 142)), + confidence=0.9, + provenance={"layout_source": "uniform_fallback"}, + ) + ] + return OCRResult( + source_image_path=image_path, + image_size=(400, 240), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=items, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_path = root / "deck.pptx" + artifact_root = root / "jobs" + image = Image.new("RGB", (400, 240), "#03101F") + draw = ImageDraw.Draw(image) + draw.text((54, 24), "落地建议:用车、补能与维护的最佳实践", fill="#FFFFFF") + draw.rectangle((20, 80, 180, 210), outline="#183B62") + image.save(source) + + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + fallback_policy="raster_pptx", + fallback_output_factory=lambda: str(output_path), + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=UnreliableApproximateOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=lambda **kwargs: [], + asset_builder=lambda **kwargs: AssetBuildResult(bitmap_assets=[]), + visual_text_candidate_detector=lambda **kwargs: [], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + + self.assertEqual( + page.chosen_background, + "backgrounds/0000-slide-a/source-raster-background.png", + ) + self.assertEqual(page.provenance["chosen_background_kind"], "source_raster_guardrail") + self.assertEqual(page.text_boxes, []) + self.assertIn("source_raster", page.provenance["backgrounds"]) + self.assertTrue(page.provenance["text_issues"]) + + def test_pipeline_records_stage_events_for_provider_boundaries(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class SimpleOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(400, 240), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="核心架构设计", + bbox=(54, 20, 220, 60), + polygon=((54, 20), (220, 20), (220, 60), (54, 60)), + confidence=0.95, + ) + ], + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_path = root / "deck.pptx" + artifact_root = root / "jobs" + image = Image.new("RGB", (400, 240), "#03101F") + draw = ImageDraw.Draw(image) + draw.text((54, 24), "核心架构设计", fill="#FFFFFF") + image.save(source) + + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=SimpleOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=lambda **kwargs: [], + asset_builder=lambda **kwargs: AssetBuildResult(bitmap_assets=[]), + visual_text_candidate_detector=lambda **kwargs: [], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + stage_events = [ + json.loads(line) + for line in (artifact_root / "job-1" / "stage-events.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + + stages = [event["stage"] for event in stage_events] + self.assertIn("ocr", stages) + self.assertIn("text_mask", stages) + self.assertIn("text_clean_background", stages) + self.assertIn("base_clean_background", stages) + self.assertTrue(all("status" in event for event in stage_events)) + + def test_pipeline_source_raster_guardrail_does_not_call_image_edit_provider(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageGenerationProvider, + ImageEditProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ProviderError, + ) + + class UnreliableApproximateOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + items = [ + OCRTextItem( + text="落地建议:用车、补能与维护的最佳实践", + bbox=(54, 20, 330, 60), + polygon=((54, 20), (330, 20), (330, 60), (54, 60)), + confidence=0.95, + provenance={}, + ), + OCRTextItem( + text="核心架构设计:增程、底盘与智能域", + bbox=(20, 92, 380, 142), + polygon=((20, 92), (380, 92), (380, 142), (20, 142)), + confidence=0.9, + provenance={"layout_source": "uniform_fallback"}, + ) + ] + return OCRResult( + source_image_path=image_path, + image_size=(400, 240), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=items, + ) + + class FailingImageEditProvider(ImageEditProvider): + def edit(self, request): + raise ProviderError( + provider_role="edit_model", + operation=request.prompt_id, + message="image edit must not be called for source raster guardrail", + retryable=False, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_path = root / "deck.pptx" + artifact_root = root / "jobs" + image = Image.new("RGB", (400, 240), "#03101F") + draw = ImageDraw.Draw(image) + draw.text((54, 24), "落地建议:用车、补能与维护的最佳实践", fill="#FFFFFF") + image.save(source) + + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + fallback_policy="raster_pptx", + fallback_output_factory=lambda: str(output_path), + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=UnreliableApproximateOCRProvider(config.ocr), + image_edit_provider=FailingImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=lambda **kwargs: [], + asset_builder=lambda **kwargs: AssetBuildResult(bitmap_assets=[]), + visual_text_candidate_detector=lambda **kwargs: [], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + + self.assertEqual(page.provenance["chosen_background_kind"], "source_raster_guardrail") + + def test_pipeline_runs_reconstruction_for_dense_approximate_ocr_layout(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageGenerationProvider, + ImageEditProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ProviderError, + ) + + class DenseApproximateOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(400, 240), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="落地建议:用车、补能与维护", + bbox=(70, 18, 330, 52), + polygon=((70, 18), (330, 18), (330, 52), (70, 52)), + confidence=0.9, + provenance={"approximate_layout": True}, + ), + OCRTextItem( + text="日常操作", + bbox=(50, 90, 160, 122), + polygon=((50, 90), (160, 90), (160, 122), (50, 122)), + confidence=0.9, + provenance={"approximate_layout": True}, + ), + OCRTextItem( + text="补能与维护", + bbox=(240, 90, 360, 122), + polygon=((240, 90), (360, 90), (360, 122), (240, 122)), + confidence=0.9, + provenance={"approximate_layout": True}, + ), + ], + ) + + class FailingImageEditProvider(ImageEditProvider): + def edit(self, request): + raise ProviderError( + provider_role="edit_model", + operation=request.prompt_id, + message="image edit should be called before any source-preserving fallback", + retryable=False, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_path = root / "deck.pptx" + artifact_root = root / "jobs" + image = Image.new("RGB", (400, 240), "#03101F") + draw = ImageDraw.Draw(image) + draw.text((70, 22), "落地建议:用车、补能与维护", fill="#FFFFFF") + draw.text((50, 94), "日常操作", fill="#FFFFFF") + draw.text((240, 94), "补能与维护", fill="#FFFFFF") + image.save(source) + + with self.assertRaises(ProviderError) as caught: + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=DenseApproximateOCRProvider(config.ocr), + image_edit_provider=FailingImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=lambda **kwargs: [], + asset_builder=lambda **kwargs: AssetBuildResult(bitmap_assets=[]), + visual_text_candidate_detector=lambda **kwargs: [ + (70, 18, 330, 52), + (50, 90, 160, 122), + (240, 90, 360, 122), + (40, 150, 140, 180), + (230, 150, 350, 180), + (40, 190, 140, 220), + ], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + self.assertIn(caught.exception.operation, {"text_clean_background", "base_clean_background"}) + + def test_text_validation_marks_source_raster_guardrail_as_degraded(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_text_validation_report", None) + self.assertIsNotNone(helper) + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(400, 240), + slide_size=(10.0, 5.625), + chosen_background="backgrounds/0000-slide-a/source-raster-background.png", + provenance={ + "chosen_background_kind": "source_raster_guardrail", + "text_validation_status": "degraded", + "text_issues": [ + { + "code": "ignored_spurious_ocr", + "severity": "warning", + "ocr_text": "重复噪声", + } + ], + }, + ) + + report = helper(page) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "source_raster_guardrail_degraded") + + def test_bitmap_coverage_validation_fails_large_source_preserved_assets(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_bitmap_coverage_validation_report", None) + self.assertIsNotNone(helper) + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(400, 240), + slide_size=(10.0, 5.625), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="large-source-preserved", + source_pixel_bbox=(0, 0, 360, 220), + asset_path="assets/0000-slide-a/large.png", + z_order=1, + provenance={"asset_strategy": "masked_source_element"}, + ) + ], + native_shapes=[], + text_boxes=[], + ) + + report = helper(page) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "oversized_bitmap_asset_coverage") + self.assertEqual(report.issues[0].details["largest_asset_id"], "large-source-preserved") + + def test_bitmap_coverage_validation_allows_split_row_level_complex_assets(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_bitmap_coverage_validation_report", None) + self.assertIsNotNone(helper) + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(1706, 960), + slide_size=(10.0, 5.625), + bitmap_assets=[ + BitmapAssetSpec( + asset_id=f"row-{index}", + source_pixel_bbox=(59, top, 1672, bottom), + asset_path=f"assets/0000-slide-a/row-{index}.png", + z_order=index, + provenance={"asset_strategy": "masked_source_element"}, + ) + for index, (top, bottom) in enumerate( + [(168, 405), (405, 642), (642, 880)], + start=1, + ) + ], + native_shapes=[ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(100, 180 + index * 30, 1500, 183 + index * 30), + ) + for index in range(6) + ], + text_boxes=[ + TextBoxSpec( + text="智能域", + source_pixel_bbox=(160, 220, 260, 260), + source_pixel_polygon=((160, 220), (260, 220), (260, 260), (160, 260)), + ), + TextBoxSpec( + text="底盘域", + source_pixel_bbox=(160, 450, 260, 490), + source_pixel_polygon=((160, 450), (260, 450), (260, 490), (160, 490)), + ), + TextBoxSpec( + text="动力域", + source_pixel_bbox=(160, 680, 260, 720), + source_pixel_polygon=((160, 680), (260, 680), (260, 720), (160, 720)), + ), + ], + ) + + report = helper(page) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + + def test_bitmap_coverage_validation_allows_infographic_assets_with_dense_editable_structure(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_bitmap_coverage_validation_report", None) + self.assertIsNotNone(helper) + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(1672, 941), + slide_size=(10.0, 5.625), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="dominant-visual-band", + source_pixel_bbox=(0, 100, 1672, 720), + asset_path="assets/0000-slide-a/dominant.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.53, + }, + ), + BitmapAssetSpec( + asset_id="lower-visual", + source_pixel_bbox=(320, 650, 1200, 860), + asset_path="assets/0000-slide-a/lower.png", + z_order=2, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.08, + }, + ), + BitmapAssetSpec( + asset_id="left-icon", + source_pixel_bbox=(120, 220, 240, 340), + asset_path="assets/0000-slide-a/left.png", + z_order=3, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.04, + }, + ), + BitmapAssetSpec( + asset_id="right-icon", + source_pixel_bbox=(1380, 220, 1500, 340), + asset_path="assets/0000-slide-a/right.png", + z_order=4, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.07, + }, + ), + ], + native_shapes=[ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(100, 200 + index * 40, 1500, 203 + index * 40), + ) + for index in range(8) + ], + text_boxes=[ + TextBoxSpec( + text=f"规格 {index}", + source_pixel_bbox=(1200, 180 + index * 36, 1450, 205 + index * 36), + source_pixel_polygon=((1200, 180), (1450, 180), (1450, 205), (1200, 205)), + ) + for index in range(10) + ], + ) + + report = helper(page) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + + def test_bitmap_coverage_validation_uses_alpha_visible_area_for_transparent_source_assets(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_bitmap_coverage_validation_report", None) + self.assertIsNotNone(helper) + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(1000, 600), + slide_size=(10.0, 5.625), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="transparent-row-a", + source_pixel_bbox=(0, 80, 1000, 320), + asset_path="assets/0000-slide-a/row-a.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.18, + }, + ), + BitmapAssetSpec( + asset_id="transparent-row-b", + source_pixel_bbox=(0, 300, 1000, 560), + asset_path="assets/0000-slide-a/row-b.png", + z_order=2, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.17, + }, + ), + ], + native_shapes=[ + NativeShapeSpec(shape_type="line", source_pixel_bbox=(100, 100, 900, 104)) + for _ in range(6) + ], + text_boxes=[ + TextBoxSpec( + text="可编辑文本", + source_pixel_bbox=(100, 120, 240, 160), + source_pixel_polygon=((100, 120), (240, 120), (240, 160), (100, 160)), + ) + ], + ) + + report = helper(page) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + + def test_pipeline_keeps_bitmap_assets_when_source_preserving_background_is_not_used(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageGenerationProvider, + ImageEditProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class TitleOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + return OCRResult( + source_image_path=image_path, + image_size=(400, 240), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="Architecture", + bbox=(20, 18, 220, 80), + polygon=((20, 18), (220, 18), (220, 80), (20, 80)), + confidence=0.96, + ) + ], + ) + + class BadGeneratedBackgroundEditProvider(ImageEditProvider): + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + if request.prompt_id == "base_clean_background": + Image.new("RGB", (400, 240), "#020A18").save(output) + else: + Image.new("RGB", (400, 240), "#27496D").save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_path = root / "deck.pptx" + artifact_root = root / "jobs" + image = Image.new("RGB", (400, 240), "#020A18") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 18, 220, 80), fill="#FFFFFF") + draw.rounded_rectangle((10, 84, 390, 232), radius=12, fill="#94A3B8") + image.save(source) + + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=TitleOCRProvider(config.ocr), + image_edit_provider=BadGeneratedBackgroundEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=lambda **kwargs: [], + asset_builder=lambda **kwargs: AssetBuildResult( + bitmap_assets=[ + BitmapAssetSpec( + asset_id="oversized-source", + source_pixel_bbox=(0, 80, 400, 138), + asset_path="assets/0000-slide-a/oversized-source.png", + z_order=1, + provenance={"asset_strategy": "masked_source_element"}, + ), + BitmapAssetSpec( + asset_id="wide-transparent-source", + source_pixel_bbox=(0, 80, 400, 240), + asset_path="assets/0000-slide-a/wide-transparent-source.png", + z_order=2, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ), + BitmapAssetSpec( + asset_id="small-icon", + source_pixel_bbox=(24, 88, 70, 134), + asset_path="assets/0000-slide-a/small-icon.png", + z_order=3, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ) + ] + ), + visual_text_candidate_detector=lambda **kwargs: [], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + + self.assertNotEqual(page.provenance.get("chosen_background_kind"), "source_preserving_text_clean") + self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["oversized-source", "small-icon"]) + + def test_diff_alpha_source_preserved_asset_is_not_treated_as_oversized_source_crop(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_is_oversized_source_preserved_asset", None) + self.assertIsNotNone(helper) + + asset = BitmapAssetSpec( + asset_id="wide-transparent-asset", + source_pixel_bbox=(0, 80, 400, 240), + asset_path="assets/0000-slide-a/wide-transparent-asset.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ) + + self.assertFalse(helper(asset, (400, 240))) + + def test_large_diff_alpha_asset_is_not_kept_as_overlay(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_bitmap_assets_for_clean_background", None) + self.assertIsNotNone(helper) + + large_asset = BitmapAssetSpec( + asset_id="large-diff-alpha", + source_pixel_bbox=(40, 40, 160, 112), + asset_path="assets/0000-slide-a/large.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ) + small_asset = BitmapAssetSpec( + asset_id="small-diff-alpha", + source_pixel_bbox=(40, 40, 80, 80), + asset_path="assets/0000-slide-a/small.png", + z_order=2, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ) + + kept = helper([large_asset, small_asset], (400, 240)) + + self.assertEqual([asset.asset_id for asset in kept], ["small-diff-alpha"]) + + def test_large_complex_whole_visual_diff_alpha_asset_is_kept_as_overlay(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_bitmap_assets_for_clean_background", None) + self.assertIsNotNone(helper) + + complex_asset = BitmapAssetSpec( + asset_id="complex-panel", + source_pixel_bbox=(10, 20, 360, 180), + asset_path="assets/0000-slide-a/complex-panel.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "candidate_classification": "complex_whole_visual", + }, + ) + + kept = helper([complex_asset], (400, 240)) + + self.assertEqual([asset.asset_id for asset in kept], ["complex-panel"]) + + def test_text_boxes_covered_by_complex_bitmap_assets_are_dropped(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_drop_text_boxes_covered_by_complex_bitmap_assets", None) + self.assertIsNotNone(helper) + + title = TextBoxSpec( + text="落地建议", + source_pixel_bbox=(296, 22, 1283, 76), + source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + covered = TextBoxSpec( + text="电量充足", + source_pixel_bbox=(743, 138, 1025, 180), + source_pixel_polygon=((743, 138), (1025, 138), (1025, 180), (743, 180)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + asset = BitmapAssetSpec( + asset_id="complex-panel", + source_pixel_bbox=(46, 77, 1627, 743), + asset_path="assets/0000-slide-a/complex-panel.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "candidate_classification": "complex_whole_visual", + }, + ) + + filtered, issues = helper([title, covered], [asset]) + + self.assertEqual([box.text for box in filtered], ["落地建议"]) + self.assertEqual(issues[0]["code"], "text_covered_by_complex_bitmap_asset") + self.assertEqual(issues[0]["ocr_text"], "电量充足") + + def test_text_masked_complex_bitmap_does_not_drop_right_side_spec_text(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_drop_text_boxes_covered_by_complex_bitmap_assets", None) + self.assertIsNotNone(helper) + + spec = TextBoxSpec( + text="29英寸6K屏", + source_pixel_bbox=(1291, 208, 1406, 230), + source_pixel_polygon=((1291, 208), (1406, 208), (1406, 230), (1291, 230)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + asset = BitmapAssetSpec( + asset_id="text-masked-band", + source_pixel_bbox=(94, 169, 1576, 413), + asset_path="assets/0000-slide-a/text-masked-band.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "candidate_classification": "complex_whole_visual", + "text_mask_applied": True, + "background_difference_alpha": True, + }, + ) + + filtered, issues = helper([spec], [asset]) + + self.assertEqual([box.text for box in filtered], ["29英寸6K屏"]) + self.assertEqual(issues, []) + + def test_exact_text_covered_by_opaque_complex_bitmap_asset_is_dropped(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_drop_text_boxes_covered_by_complex_bitmap_assets", None) + self.assertIsNotNone(helper) + + exact_text = TextBoxSpec( + text="Metadata title", + source_pixel_bbox=(120, 100, 320, 150), + source_pixel_polygon=((120, 100), (320, 100), (320, 150), (120, 150)), + provenance={"content_source": "aippt_metadata", "layout_source": "aippt_metadata"}, + ) + asset = BitmapAssetSpec( + asset_id="opaque-complex", + source_pixel_bbox=(80, 60, 360, 200), + asset_path="assets/0000-slide-a/opaque-complex.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "candidate_classification": "complex_whole_visual", + "text_mask_applied": False, + "background_difference_alpha": False, + }, + ) + + filtered, issues = helper([exact_text], [asset]) + + self.assertEqual(filtered, []) + self.assertEqual(issues[0]["code"], "text_covered_by_opaque_complex_bitmap_asset") + self.assertEqual(issues[0]["ocr_text"], "Metadata title") + + def test_native_shapes_covered_by_complex_bitmap_assets_are_dropped(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_drop_native_shapes_covered_by_complex_bitmap_assets", None) + self.assertIsNotNone(helper) + + outside = NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(10, 10, 80, 12), + line_start=(10, 10), + line_end=(80, 12), + ) + covered = NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(764, 493, 1067, 726), + fill_color="#001332", + ) + asset = BitmapAssetSpec( + asset_id="complex-panel", + source_pixel_bbox=(701, 111, 1625, 744), + asset_path="assets/0000-slide-a/complex-panel.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "candidate_classification": "complex_whole_visual", + }, + ) + + filtered = helper([outside, covered], [asset]) + + self.assertEqual(filtered, [outside]) + + def test_native_shapes_are_not_dropped_by_transparent_complex_bitmap_assets(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_drop_native_shapes_covered_by_complex_bitmap_assets", None) + self.assertIsNotNone(helper) + + panel = NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(80, 60, 360, 180), + fill_color="#001332", + ) + transparent_asset = BitmapAssetSpec( + asset_id="transparent-complex", + source_pixel_bbox=(40, 40, 400, 220), + asset_path="assets/0000-slide-a/transparent-complex.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "candidate_classification": "complex_whole_visual", + "text_mask_applied": True, + "background_difference_alpha": True, + }, + ) + + filtered = helper([panel], [transparent_asset]) + + self.assertEqual(filtered, [panel]) + + def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cover_children(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (800, 450), "#050B16") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((40, 80, 760, 190), radius=18, fill="#0B2140", outline="#1D4ED8", width=3) + draw.rounded_rectangle((40, 220, 760, 330), radius=18, fill="#0B2140", outline="#1D4ED8", width=3) + draw.line((120, 140, 700, 140), fill="#38BDF8", width=3) + draw.line((120, 280, 700, 280), fill="#38BDF8", width=3) + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="large-container", + source_pixel_bbox=(30, 70, 770, 340), + area=740 * 270, + classification="complex_whole_visual", + confidence=0.86, + provenance={"detection": "source_base_difference", "reason": "large_or_visually_complex"}, + ), + ForegroundCandidate( + candidate_id="panel-1", + source_pixel_bbox=(40, 80, 760, 190), + area=720 * 110, + classification="native_shape_candidate", + confidence=0.94, + provenance={"detection": "source_native_shape_scan", "shape_hint": "rounded_rectangle"}, + ), + ForegroundCandidate( + candidate_id="panel-2", + source_pixel_bbox=(40, 220, 760, 330), + area=720 * 110, + classification="native_shape_candidate", + confidence=0.94, + provenance={"detection": "source_native_shape_scan", "shape_hint": "rounded_rectangle"}, + ), + ForegroundCandidate( + candidate_id="line-1", + source_pixel_bbox=(120, 138, 700, 143), + area=580 * 5, + classification="native_shape_candidate", + confidence=0.93, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "line", + "line_start": (120, 140), + "line_end": (700, 140), + "stroke_width": 3, + }, + ), + ForegroundCandidate( + candidate_id="line-2", + source_pixel_bbox=(120, 278, 700, 283), + area=580 * 5, + classification="native_shape_candidate", + confidence=0.93, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "line", + "line_start": (120, 280), + "line_end": (700, 280), + "stroke_width": 3, + }, + ), + ], + source_image_path=str(source), + ) + + self.assertGreaterEqual(len(native_shapes), 4) + self.assertNotIn("large-container", [candidate.candidate_id for candidate in bitmap_candidates]) + + def test_structural_container_drop_accepts_many_native_children_with_quarter_coverage(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_foreground_planner import ForegroundCandidate + + helper = getattr(pipeline, "_is_structural_container_bitmap_candidate", None) + self.assertIsNotNone(helper) + + candidate = ForegroundCandidate( + candidate_id="right-panel-container", + source_pixel_bbox=(700, 100, 1620, 720), + area=920 * 620, + classification="complex_whole_visual", + provenance={ + "reason": "large_or_visually_complex", + "original_detection": {"detection": "source_base_difference"}, + }, + ) + native_shapes = [ + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=( + 720 + (index % 4) * 170, + 120 + (index // 4) * 135, + 720 + (index % 4) * 170 + 135, + 120 + (index // 4) * 135 + 95, + ), + fill_color="#001332", + ) + for index in range(12) + ] + + self.assertTrue(helper(candidate, native_shapes=native_shapes)) + + def test_source_preserving_background_choice_stays_disabled_for_large_diff_alpha_assets(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_choose_source_preserving_background", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + bad_base = root / "base-clean.png" + bad_text = root / "text-clean.png" + image = Image.new("RGB", (400, 240), "#020A18") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 18, 220, 80), fill="#FFFFFF") + draw.rounded_rectangle((10, 84, 390, 232), radius=12, fill="#94A3B8") + image.save(source) + Image.new("RGB", (400, 240), "#020A18").save(bad_base) + Image.new("RGB", (400, 240), "#FFFFFF").save(bad_text) + + text_boxes = [ + TextBoxSpec( + text="Architecture", + source_pixel_bbox=(20, 18, 220, 80), + source_pixel_polygon=((20, 18), (220, 18), (220, 80), (20, 80)), + ) + ] + bitmap_assets = [ + BitmapAssetSpec( + asset_id="wide-transparent-asset", + source_pixel_bbox=(0, 80, 400, 240), + asset_path="assets/0000-slide-a/wide-transparent-asset.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ) + ] + + choose = helper( + source_image_path=source, + chosen_background_image_path=bad_base, + text_clean_image_path=bad_text, + text_boxes=text_boxes, + native_shapes=[], + bitmap_assets=bitmap_assets, + source_image_size=(400, 240), + ) + + self.assertFalse(choose) + + def test_generic_visual_anchors_are_added_as_complex_bitmap_candidates(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_augment_bitmap_candidates_with_generic_visual_anchors", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (800, 450), "#000105") + draw = ImageDraw.Draw(image) + draw.rectangle((260, 110, 520, 210), fill="#0b64ff") + draw.ellipse((300, 125, 365, 190), fill="#dbeafe") + draw.rectangle((390, 135, 500, 185), fill="#111827") + draw.text((45, 120), "智能域", fill="#ffffff") + image.save(source) + + text_boxes = [ + TextBoxSpec( + text="智能域", + source_pixel_bbox=(40, 112, 125, 145), + source_pixel_polygon=((40, 112), (125, 112), (125, 145), (40, 145)), + ) + ] + + augmented = helper( + [], + source_image_path=str(source), + text_boxes=text_boxes, + max_assets=3, + max_area_ratio=0.12, + ) + + generic = [ + candidate + for candidate in augmented + if candidate.provenance["detection"] == "source_generic_visual_anchor" + ] + self.assertEqual(len(generic), 1) + self.assertEqual(generic[0].classification, "complex_whole_visual") + self.assertGreaterEqual(generic[0].source_pixel_bbox[0], 240) + self.assertLessEqual(generic[0].source_pixel_bbox[2], 540) + + def test_generic_visual_anchor_allows_larger_assets_for_structural_band_pages(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_augment_bitmap_candidates_with_generic_visual_anchors", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (800, 450), "#000105") + draw = ImageDraw.Draw(image) + draw.rectangle((250, 125, 650, 320), fill="#334155") + draw.ellipse((280, 150, 390, 260), fill="#CBD5E1") + draw.rectangle((440, 160, 610, 280), fill="#111827") + draw.text((45, 205), "底盘域", fill="#ffffff") + image.save(source) + + text_boxes = [ + TextBoxSpec( + text="底盘域", + source_pixel_bbox=(40, 198, 125, 235), + source_pixel_polygon=((40, 198), (125, 198), (125, 235), (40, 235)), + ) + ] + native_shapes = [ + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(0, 110, 800, 340), + fill_color="#0B2140", + provenance={"detection": "split_edge_spanning_difference"}, + ) + ] + + augmented = helper( + [], + source_image_path=str(source), + text_boxes=text_boxes, + native_shapes=native_shapes, + max_assets=3, + ) + + generic = [ + candidate + for candidate in augmented + if candidate.provenance["detection"] == "source_generic_visual_anchor" + ] + self.assertEqual(len(generic), 1) + area_ratio = ( + (generic[0].source_pixel_bbox[2] - generic[0].source_pixel_bbox[0]) + * (generic[0].source_pixel_bbox[3] - generic[0].source_pixel_bbox[1]) + / float(800 * 450) + ) + self.assertGreater(area_ratio, 0.10) + + def test_generic_visual_anchors_are_not_added_for_cover_title_slides(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_augment_bitmap_candidates_with_generic_visual_anchors", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (800, 450), "#000105") + draw = ImageDraw.Draw(image) + draw.rectangle((190, 245, 710, 385), fill="#0b64ff") + draw.line((0, 365, 800, 315), fill="#A855F7", width=12) + draw.text((30, 80), "理想 L9:", fill="#ffffff") + draw.text((30, 145), "旗舰增程 SUV 的技术实验", fill="#ffffff") + image.save(source) + + text_boxes = [ + TextBoxSpec( + text="理想L9:", + source_pixel_bbox=(28, 76, 210, 125), + source_pixel_polygon=((28, 76), (210, 76), (210, 125), (28, 125)), + ), + TextBoxSpec( + text="旗舰增程 SUV 的技术实验", + source_pixel_bbox=(28, 142, 470, 196), + source_pixel_polygon=((28, 142), (470, 142), (470, 196), (28, 196)), + ), + ] + + augmented = helper( + [], + source_image_path=str(source), + text_boxes=text_boxes, + max_assets=3, + max_area_ratio=0.12, + ) + + self.assertEqual(augmented, []) + + def test_generic_visual_anchors_are_added_for_cover_title_slides_with_lower_visuals(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_augment_bitmap_candidates_with_generic_visual_anchors", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (800, 450), "#020813") + draw = ImageDraw.Draw(image) + draw.rectangle((45, 285, 330, 410), fill="#D1D5DB") + draw.rectangle((90, 315, 260, 370), fill="#111827") + image.save(source) + + text_boxes = [ + TextBoxSpec( + text="总结与持续进化", + source_pixel_bbox=(245, 118, 650, 190), + source_pixel_polygon=((245, 118), (650, 118), (650, 190), (245, 190)), + ), + TextBoxSpec( + text="续航无忧 全时防护 持续进化", + source_pixel_bbox=(260, 70, 640, 105), + source_pixel_polygon=((260, 70), (640, 70), (640, 105), (260, 105)), + ), + ] + + augmented = helper( + [], + source_image_path=str(source), + text_boxes=text_boxes, + max_assets=3, + max_area_ratio=0.12, + ) + + self.assertTrue( + any(candidate.provenance["detection"] == "source_generic_visual_anchor" for candidate in augmented) + ) + + def test_generic_visual_anchors_are_added_for_cover_title_slides_with_wide_lower_photo(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_augment_bitmap_candidates_with_generic_visual_anchors", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (900, 506), "#020813") + draw = ImageDraw.Draw(image) + draw.rectangle((0, 300, 900, 506), fill="#1F2937") + draw.line((0, 450, 900, 350), fill="#F8FAFC", width=10) + draw.rectangle((40, 345, 310, 460), fill="#CBD5E1") + image.save(source) + + text_boxes = [ + TextBoxSpec( + text="总结与持续进化", + source_pixel_bbox=(260, 118, 650, 190), + source_pixel_polygon=((260, 118), (650, 118), (650, 190), (260, 190)), + ) + ] + + augmented = helper( + [], + source_image_path=str(source), + text_boxes=text_boxes, + max_assets=3, + max_area_ratio=0.12, + ) + + self.assertTrue( + any(candidate.provenance["detection"] == "source_generic_visual_anchor" for candidate in augmented) + ) + + def test_cover_title_slide_adds_wide_lower_source_visual_anchor(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_augment_bitmap_candidates_with_generic_visual_anchors", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (900, 506), "#020813") + draw = ImageDraw.Draw(image) + draw.rectangle((0, 280, 900, 506), fill="#111827") + draw.rectangle((0, 330, 340, 500), fill="#1F2937") + draw.ellipse((60, 425, 145, 505), fill="#CBD5E1") + draw.ellipse((260, 425, 345, 505), fill="#CBD5E1") + draw.rectangle((735, 360, 850, 475), fill="#F8FAFC") + image.save(source) + + text_boxes = [ + TextBoxSpec( + text="总结与持续进化", + source_pixel_bbox=(240, 118, 660, 190), + source_pixel_polygon=((240, 118), (660, 118), (660, 190), (240, 190)), + ), + TextBoxSpec( + text="官方帮助中心 OTA更新说明", + source_pixel_bbox=(720, 475, 870, 500), + source_pixel_polygon=((720, 475), (870, 475), (870, 500), (720, 500)), + ), + ] + + augmented = helper( + [], + source_image_path=str(source), + text_boxes=text_boxes, + max_assets=8, + max_area_ratio=0.12, + ) + + lower_anchors = [ + candidate + for candidate in augmented + if candidate.provenance["detection"] == "cover_title_lower_visual_anchor" + ] + self.assertEqual(len(lower_anchors), 1) + lower_bbox = lower_anchors[0].source_pixel_bbox + self.assertLessEqual(lower_bbox[0], 20) + self.assertGreaterEqual(lower_bbox[2], 880) + self.assertLessEqual(lower_bbox[1], 300) + self.assertGreaterEqual(lower_bbox[3], 500) + + def test_cover_title_slide_does_not_add_lower_anchor_for_top_ota_marker_only(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_augment_bitmap_candidates_with_generic_visual_anchors", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + image = Image.new("RGB", (900, 506), "#020813") + draw = ImageDraw.Draw(image) + draw.rectangle((0, 300, 900, 506), fill="#111827") + draw.rectangle((40, 345, 310, 460), fill="#CBD5E1") + image.save(source) + + text_boxes = [ + TextBoxSpec( + text="OTA升级体验", + source_pixel_bbox=(260, 80, 640, 120), + source_pixel_polygon=((260, 80), (640, 80), (640, 120), (260, 120)), + ), + TextBoxSpec( + text="总结与持续进化", + source_pixel_bbox=(260, 150, 650, 220), + source_pixel_polygon=((260, 150), (650, 150), (650, 220), (260, 220)), + ), + ] + + augmented = helper( + [], + source_image_path=str(source), + text_boxes=text_boxes, + max_assets=8, + max_area_ratio=0.12, + ) + + self.assertFalse( + any(candidate.provenance["detection"] == "cover_title_lower_visual_anchor" for candidate in augmented) + ) + + def test_generic_visual_anchor_source_asset_uses_background_alpha(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_foreground_planner import ForegroundCandidate + + helper = getattr(pipeline, "_build_masked_source_element_assets", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + background = root / "background.png" + output_dir = root / "assets" + + source_image = Image.new("RGB", (120, 80), "#001020") + draw = ImageDraw.Draw(source_image) + draw.rectangle((34, 24, 86, 56), fill="#dbeafe") + source_image.save(source) + Image.new("RGB", (120, 80), "#001020").save(background) + + assets = helper( + candidates=[ + ForegroundCandidate( + candidate_id="generic-visual-001", + source_pixel_bbox=(20, 12, 100, 68), + area=80 * 56, + classification="complex_whole_visual", + provenance={"detection": "source_generic_visual_anchor"}, + ) + ], + source_image_path=str(source), + background_image_path=background, + text_mask_path=None, + output_dir=output_dir, + asset_root=root, + ) + + asset_path = root / assets[0].asset_path + with Image.open(asset_path).convert("RGBA") as asset_image: + alpha_extrema = asset_image.getchannel("A").getextrema() + + self.assertEqual(assets[0].provenance["background_difference_alpha"], True) + self.assertEqual(assets[0].source_pixel_bbox, (34, 24, 87, 57)) + self.assertEqual(alpha_extrema, (255, 255)) + self.assertEqual(assets[0].provenance["trimmed_transparent_bounds"], True) + + def test_masked_source_element_asset_bbox_is_trimmed_to_visible_alpha(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_foreground_planner import ForegroundCandidate + + helper = getattr(pipeline, "_build_masked_source_element_assets", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + background = root / "background.png" + output_dir = root / "assets" + + Image.new("RGB", (120, 80), "#001020").save(background) + source_image = Image.new("RGB", (120, 80), "#001020") + draw = ImageDraw.Draw(source_image) + draw.rectangle((44, 30, 66, 44), fill="#dbeafe") + source_image.save(source) + + assets = helper( + candidates=[ + ForegroundCandidate( + candidate_id="generic-visual-001", + source_pixel_bbox=(20, 12, 100, 68), + area=80 * 56, + classification="complex_whole_visual", + provenance={"detection": "source_generic_visual_anchor"}, + ) + ], + source_image_path=str(source), + background_image_path=background, + text_mask_path=None, + output_dir=output_dir, + asset_root=root, + ) + + with Image.open(root / assets[0].asset_path).convert("RGBA") as asset_image: + size = asset_image.size + alpha_extrema = asset_image.getchannel("A").getextrema() + + self.assertEqual(assets[0].source_pixel_bbox, (44, 30, 67, 45)) + self.assertEqual(size, (23, 15)) + self.assertEqual(alpha_extrema, (255, 255)) + self.assertEqual(assets[0].provenance["trimmed_transparent_bounds"], True) + + def test_source_base_difference_large_complex_visual_uses_alpha_trim(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + from src.generative_editable_foreground_planner import ForegroundCandidate + + helper = getattr(pipeline, "_build_masked_source_element_assets", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + background = root / "background.png" + output_dir = root / "assets" + Image.new("RGB", (200, 120), "#061020").save(background) + source_image = Image.new("RGB", (200, 120), "#061020") + draw = ImageDraw.Draw(source_image) + draw.rounded_rectangle((40, 30, 160, 90), radius=10, fill="#0B2140", outline="#1D4ED8", width=2) + draw.rectangle((86, 50, 116, 70), fill="#DCEAFE") + source_image.save(source) + + assets = helper( + candidates=[ + ForegroundCandidate( + candidate_id="large-diff", + source_pixel_bbox=(20, 20, 180, 100), + area=160 * 80, + classification="complex_whole_visual", + provenance={ + "reason": "large_or_visually_complex", + "original_detection": {"detection": "source_base_difference"}, + }, + ) + ], + source_image_path=str(source), + background_image_path=background, + text_mask_path=None, + output_dir=output_dir, + asset_root=root, + ) + + self.assertEqual(assets[0].provenance["background_difference_alpha"], True) + self.assertEqual(assets[0].provenance["trimmed_transparent_bounds"], True) + self.assertLess((assets[0].source_pixel_bbox[2] - assets[0].source_pixel_bbox[0]), 160) + + def test_edge_spanning_diff_candidate_is_split_into_complex_visual_bands(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_foreground_planner import ForegroundCandidate + + helper = getattr(pipeline, "_expand_edge_spanning_bitmap_candidates", None) + self.assertIsNotNone(helper) + + expanded = helper( + [ + ForegroundCandidate( + candidate_id="fg-wide", + source_pixel_bbox=(0, 150, 1600, 920), + area=1600 * 770, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ) + ], + source_size=(1600, 940), + ) + + self.assertEqual( + [candidate.candidate_id for candidate in expanded], + ["fg-wide-band-1", "fg-wide-band-2", "fg-wide-band-3"], + ) + self.assertTrue(all(candidate.classification == "complex_whole_visual" for candidate in expanded)) + self.assertEqual(expanded[0].provenance["detection"], "split_edge_spanning_difference") + self.assertEqual(expanded[0].provenance["parent_candidate_id"], "fg-wide") + + def test_large_layered_diff_candidate_is_split_into_row_level_bitmap_bands(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_foreground_planner import ForegroundCandidate + + helper = getattr(pipeline, "_expand_edge_spanning_bitmap_candidates", None) + self.assertIsNotNone(helper) + + expanded = helper( + [ + ForegroundCandidate( + candidate_id="fg-layered", + source_pixel_bbox=(59, 168, 1672, 880), + area=1613 * 712, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ) + ], + source_size=(1706, 960), + ) + + self.assertEqual( + [candidate.candidate_id for candidate in expanded], + ["fg-layered-band-1", "fg-layered-band-2", "fg-layered-band-3"], + ) + self.assertTrue( + all(candidate.provenance["detection"] == "split_large_layered_difference" for candidate in expanded) + ) + self.assertLess( + max(candidate.area for candidate in expanded) / float(1706 * 960), + 0.35, + ) + + def test_large_layered_diff_candidate_with_low_bottom_margin_is_split(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_foreground_planner import ForegroundCandidate + + helper = getattr(pipeline, "_expand_edge_spanning_bitmap_candidates", None) + self.assertIsNotNone(helper) + + expanded = helper( + [ + ForegroundCandidate( + candidate_id="fg-layered-low", + source_pixel_bbox=(53, 170, 1660, 918), + area=1607 * 748, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ) + ], + source_size=(1706, 960), + ) + + self.assertEqual( + [candidate.candidate_id for candidate in expanded], + ["fg-layered-low-band-1", "fg-layered-low-band-2", "fg-layered-low-band-3"], + ) + + def test_reconstruction_targets_promote_edge_spanning_bands_to_native_shapes(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (1600, 940), "#020817") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((0, 150, 1600, 405), radius=22, fill="#0B2140", outline="#1D4ED8", width=3) + draw.rounded_rectangle((0, 405, 1600, 660), radius=22, fill="#0B2140", outline="#1D4ED8", width=3) + draw.rounded_rectangle((0, 660, 1600, 920), radius=22, fill="#0B2140", outline="#1D4ED8", width=3) + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="fg-wide", + source_pixel_bbox=(0, 150, 1600, 920), + area=1600 * 770, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ) + ], + source_image_path=str(source), + ) + + promoted = [ + shape + for shape in native_shapes + if shape.provenance.get("detection") == "split_edge_spanning_difference" + ] + self.assertEqual(len(promoted), 3) + self.assertTrue(all(shape.shape_type == "rounded_rectangle" for shape in promoted)) + self.assertTrue(all(shape.opacity == 0.15 for shape in promoted)) + self.assertFalse( + any(candidate.candidate_id.startswith("fg-wide-band-") for candidate in bitmap_candidates) + ) + + def test_reconstruction_targets_promote_large_card_containers_to_native_shapes(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (1000, 600), "#020817") + draw = ImageDraw.Draw(image) + for offset in range(0, 390): + color = f"#{6 + offset % 18:02x}{19 + offset % 22:02x}{43 + offset % 30:02x}" + draw.line((30, 80 + offset, 410, 80 + offset), fill=color) + draw.line((430, 80 + offset, 970, 80 + offset), fill=color) + draw.rounded_rectangle((30, 80, 410, 470), radius=18, outline="#1D4ED8", width=3) + draw.rounded_rectangle((430, 80, 970, 470), radius=18, outline="#1D4ED8", width=3) + draw.rectangle((85, 170, 150, 230), fill="#DCEAFE") + draw.rectangle((245, 300, 330, 350), fill="#DCEAFE") + draw.rectangle((545, 175, 650, 235), fill="#DCEAFE") + draw.rectangle((750, 315, 870, 375), fill="#DCEAFE") + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="left-card", + source_pixel_bbox=(30, 80, 410, 470), + area=380 * 390, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="right-card", + source_pixel_bbox=(430, 80, 970, 470), + area=540 * 390, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="left-icon", + source_pixel_bbox=(85, 170, 150, 230), + area=65 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="left-row", + source_pixel_bbox=(245, 300, 330, 350), + area=85 * 50, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="right-icon", + source_pixel_bbox=(545, 175, 650, 235), + area=105 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="right-row", + source_pixel_bbox=(750, 315, 870, 375), + area=120 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + promoted = [ + shape + for shape in native_shapes + if shape.provenance.get("fitter") == "large_card_container" + ] + self.assertEqual( + [shape.provenance.get("parent_candidate_id") for shape in promoted], + ["left-card", "right-card"], + ) + self.assertFalse( + any(candidate.candidate_id in {"left-card", "right-card"} for candidate in bitmap_candidates) + ) + self.assertTrue( + any(candidate.candidate_id == "left-icon" for candidate in bitmap_candidates) + ) + + def test_reconstruction_targets_do_not_promote_unframed_screenshot_as_card_container(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (1000, 600), "#020817") + draw = ImageDraw.Draw(image) + for y in range(100, 450): + for x in range(120, 700): + red = (x * 7 + y * 3) % 255 + green = (x * 5 + y * 11) % 255 + blue = (x * 13 + y * 17) % 255 + draw.point((x, y), fill=(red, green, blue)) + draw.rectangle((220, 190, 300, 250), fill="#DCEAFE") + draw.rectangle((455, 305, 560, 365), fill="#DCEAFE") + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="screenshot", + source_pixel_bbox=(120, 100, 700, 450), + area=580 * 350, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="screenshot-region-a", + source_pixel_bbox=(220, 190, 300, 250), + area=80 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="screenshot-region-b", + source_pixel_bbox=(455, 305, 560, 365), + area=105 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + self.assertFalse( + any(shape.provenance.get("parent_candidate_id") == "screenshot" for shape in native_shapes) + ) + self.assertTrue(any(candidate.candidate_id == "screenshot" for candidate in bitmap_candidates)) + + def test_reconstruction_targets_promote_large_card_container_with_native_children(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (1000, 600), "#020817") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((40, 90, 450, 480), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + draw.line((80, 210, 410, 210), fill="#94A3B8", width=3) + draw.line((80, 330, 410, 330), fill="#94A3B8", width=3) + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="left-card", + source_pixel_bbox=(40, 90, 450, 480), + area=410 * 390, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="row-line-a", + source_pixel_bbox=(80, 208, 410, 213), + area=330 * 5, + classification="native_shape_candidate", + confidence=0.98, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "line", + "line_start": (80, 210), + "line_end": (410, 210), + "stroke_width": 3, + }, + ), + ForegroundCandidate( + candidate_id="row-line-b", + source_pixel_bbox=(80, 328, 410, 333), + area=330 * 5, + classification="native_shape_candidate", + confidence=0.98, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "line", + "line_start": (80, 330), + "line_end": (410, 330), + "stroke_width": 3, + }, + ), + ], + source_image_path=str(source), + ) + + self.assertTrue( + any(shape.provenance.get("parent_candidate_id") == "left-card" for shape in native_shapes) + ) + self.assertFalse(any(candidate.candidate_id == "left-card" for candidate in bitmap_candidates)) + + def test_reconstruction_targets_promote_card_container_when_candidate_is_trimmed_inside_border(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (1000, 600), "#020817") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((40, 90, 450, 480), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + draw.line((80, 210, 410, 210), fill="#94A3B8", width=3) + draw.line((80, 330, 410, 330), fill="#94A3B8", width=3) + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="trimmed-card", + source_pixel_bbox=(48, 100, 438, 458), + area=390 * 358, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="row-line-a", + source_pixel_bbox=(80, 208, 410, 213), + area=330 * 5, + classification="native_shape_candidate", + confidence=0.98, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "line", + "line_start": (80, 210), + "line_end": (410, 210), + "stroke_width": 3, + }, + ), + ForegroundCandidate( + candidate_id="row-line-b", + source_pixel_bbox=(80, 328, 410, 333), + area=330 * 5, + classification="native_shape_candidate", + confidence=0.98, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "line", + "line_start": (80, 330), + "line_end": (410, 330), + "stroke_width": 3, + }, + ), + ], + source_image_path=str(source), + ) + + self.assertTrue( + any(shape.provenance.get("parent_candidate_id") == "trimmed-card" for shape in native_shapes) + ) + self.assertFalse(any(candidate.candidate_id == "trimmed-card" for candidate in bitmap_candidates)) + + def test_reconstruction_targets_do_not_promote_framed_high_texture_screenshot(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (1000, 600), "#020817") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((120, 100, 700, 450), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + for y in range(120, 430): + for x in range(145, 675): + red = (x * 7 + y * 3) % 255 + green = (x * 5 + y * 11) % 255 + blue = (x * 13 + y * 17) % 255 + draw.point((x, y), fill=(red, green, blue)) + draw.rectangle((220, 190, 300, 250), fill="#DCEAFE") + draw.rectangle((455, 305, 560, 365), fill="#DCEAFE") + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="framed-screenshot", + source_pixel_bbox=(120, 100, 700, 450), + area=580 * 350, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="screenshot-region-a", + source_pixel_bbox=(220, 190, 300, 250), + area=80 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="screenshot-region-b", + source_pixel_bbox=(455, 305, 560, 365), + area=105 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + self.assertFalse( + any(shape.provenance.get("parent_candidate_id") == "framed-screenshot" for shape in native_shapes) + ) + self.assertTrue(any(candidate.candidate_id == "framed-screenshot" for candidate in bitmap_candidates)) + + def test_reconstruction_targets_do_not_promote_nested_screenshot_inside_card_container(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (1000, 600), "#020817") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((100, 90, 850, 450), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + for y in range(140, 390): + for x in range(260, 760): + red = (x * 7 + y * 3) % 255 + green = (x * 5 + y * 11) % 255 + blue = (x * 13 + y * 17) % 255 + draw.point((x, y), fill=(red, green, blue)) + draw.rectangle((330, 205, 410, 265), fill="#DCEAFE") + draw.rectangle((600, 305, 720, 365), fill="#DCEAFE") + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="outer-card", + source_pixel_bbox=(100, 90, 850, 450), + area=750 * 360, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="inner-screenshot", + source_pixel_bbox=(260, 140, 760, 390), + area=500 * 250, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="inner-region-a", + source_pixel_bbox=(330, 205, 410, 265), + area=80 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="inner-region-b", + source_pixel_bbox=(600, 305, 720, 365), + area=120 * 60, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + self.assertTrue( + any(shape.provenance.get("parent_candidate_id") == "outer-card" for shape in native_shapes) + ) + self.assertFalse( + any(shape.provenance.get("parent_candidate_id") == "inner-screenshot" for shape in native_shapes) + ) + self.assertTrue(any(candidate.candidate_id == "inner-screenshot" for candidate in bitmap_candidates)) + + def test_domain_row_visual_anchor_bboxes_use_space_between_label_and_specs(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_domain_row_visual_anchor_bboxes", None) + self.assertIsNotNone(helper) + + bboxes = helper( + [ + TextBoxSpec( + text="动力域", + source_pixel_bbox=(274, 747, 379, 782), + source_pixel_polygon=((274, 747), (379, 747), (379, 782), (274, 782)), + ), + TextBoxSpec( + text="72.7kWh 5C电池", + source_pixel_bbox=(1292, 774, 1475, 799), + source_pixel_polygon=((1292, 774), (1475, 774), (1475, 799), (1292, 799)), + ), + ], + source_size=(1672, 941), + ) + + self.assertEqual(len(bboxes), 1) + left, top, right, bottom = bboxes[0] + self.assertGreater(left, 379) + self.assertLess(right, 1292) + self.assertLess(top, 747) + self.assertGreater(bottom, 782) + + def test_domain_row_visual_anchor_bboxes_require_right_side_specs(self): + import src.generative_editable_pipeline as pipeline + from src.generative_editable_text import TextBoxSpec + + helper = getattr(pipeline, "_domain_row_visual_anchor_bboxes", None) + self.assertIsNotNone(helper) + + bboxes = helper( + [ + TextBoxSpec( + text="业务域", + source_pixel_bbox=(120, 220, 220, 260), + source_pixel_polygon=((120, 220), (220, 220), (220, 260), (120, 260)), + ) + ], + source_size=(1200, 675), + ) + + self.assertEqual(bboxes, []) + + def test_source_preserving_background_drops_native_overlays(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_native_shapes_for_source_preserving_background", None) + self.assertIsNotNone(helper) + + native_shapes = [ + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(120, 80, 260, 150), + fill_color="#000000", + radius=12, + provenance={"candidate_id": "source-native-001"}, + ), + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(300, 120, 360, 124), + line_color="#000000", + line_start=(300, 122), + line_end=(360, 122), + stroke_width=4, + provenance={"candidate_id": "fg-001"}, + ), + ] + + self.assertEqual(helper(native_shapes), []) + + def test_oversized_native_shapes_are_dropped(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_drop_oversized_native_shapes", None) + self.assertIsNotNone(helper) + + huge_line = NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(444, 64, 1346, 885), + line_color="#111111", + line_start=(444, 64), + line_end=(1346, 885), + stroke_width=2, + ) + small_line = NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(40, 64, 120, 66), + line_color="#111111", + line_start=(40, 65), + line_end=(120, 65), + stroke_width=2, + ) + + kept = helper([huge_line, small_line], (1672, 941)) + + self.assertEqual(kept, [small_line]) + + def test_source_preserving_fallback_creates_small_icon_anchor_assets(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_build_source_preserving_anchor_assets", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_dir = root / "assets" / "0000-slide-a" + image = Image.new("RGB", (240, 140), "#020A18") + draw = ImageDraw.Draw(image) + draw.ellipse((48, 42, 86, 80), outline="#168BFF", width=3) + draw.rectangle((60, 54, 74, 68), fill="#168BFF") + image.save(source) + + text_box = TextBoxSpec( + text="底盘域", + source_pixel_bbox=(108, 48, 168, 72), + source_pixel_polygon=((108, 48), (168, 48), (168, 72), (108, 72)), + ) + + assets = helper( + source_image_path=source, + text_boxes=[text_box], + output_dir=output_dir, + asset_root=root, + start_z_order=1, + ) + + with Image.open(root / assets[0].asset_path) as asset_image: + alpha_bbox = asset_image.convert("RGBA").getchannel("A").getbbox() + + self.assertEqual(len(assets), 1) + self.assertEqual(assets[0].asset_id, "anchor-001") + self.assertLessEqual( + (assets[0].source_pixel_bbox[2] - assets[0].source_pixel_bbox[0]) + * (assets[0].source_pixel_bbox[3] - assets[0].source_pixel_bbox[1]), + 5000, + ) + self.assertIsNotNone(alpha_bbox) + + def test_source_preserving_fallback_creates_generic_visual_anchor_assets(self): + from PIL import Image, ImageDraw + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_build_source_preserving_anchor_assets", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output_dir = root / "assets" / "0000-slide-b" + image = Image.new("RGB", (360, 220), "#020814") + draw = ImageDraw.Draw(image) + draw.ellipse((92, 56, 205, 169), outline="#4D6D86", width=4) + draw.ellipse((125, 88, 172, 135), outline="#D2E1F0", width=3) + draw.rectangle((138, 108, 188, 134), fill="#24384A") + draw.line((205, 112, 284, 92), fill="#8CA7BB", width=2) + image.save(source) + + text_box = TextBoxSpec( + text="高压隔离", + source_pixel_bbox=(284, 82, 338, 104), + source_pixel_polygon=((284, 82), (338, 82), (338, 104), (284, 104)), + ) + + assets = helper( + source_image_path=source, + text_boxes=[text_box], + output_dir=output_dir, + asset_root=root, + start_z_order=1, + ) + + with Image.open(root / assets[0].asset_path) as asset_image: + alpha_bbox = asset_image.convert("RGBA").getchannel("A").getbbox() + + self.assertGreaterEqual(len(assets), 1) + self.assertEqual(assets[0].asset_id, "anchor-001") + self.assertLessEqual( + (assets[0].source_pixel_bbox[2] - assets[0].source_pixel_bbox[0]) + * (assets[0].source_pixel_bbox[3] - assets[0].source_pixel_bbox[1]), + int(360 * 220 * 0.08), + ) + self.assertIsNotNone(alpha_bbox) + + def test_source_preserving_background_is_not_chosen_for_dense_native_heavy_complex_page(self): + from PIL import Image + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_choose_source_preserving_background", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + chosen = root / "base-clean.png" + text_clean = root / "text-clean.png" + image = Image.new("RGB", (360, 220), "#030915") + image.save(source) + Image.new("RGB", (360, 220), "#FFFFFF").save(chosen) + Image.new("RGB", (360, 220), "#FFFFFF").save(text_clean) + + text_boxes = [ + TextBoxSpec( + text=f"文本{i}", + source_pixel_bbox=(240, 20 + i * 16, 320, 32 + i * 16), + source_pixel_polygon=( + (240, 20 + i * 16), + (320, 20 + i * 16), + (320, 32 + i * 16), + (240, 32 + i * 16), + ), + ) + for i in range(8) + ] + native_shapes = [ + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(20 + i * 14, 60 + i * 4, 32 + i * 14, 74 + i * 4), + fill_color="#123456", + ) + for i in range(8) + ] + asset_path = root / "assets" / "tiny.png" + asset_path.parent.mkdir(parents=True) + Image.new("RGBA", (12, 12), "#123456").save(asset_path) + bitmap_assets = [ + BitmapAssetSpec( + asset_id="tiny", + source_pixel_bbox=(120, 80, 132, 92), + asset_path="assets/tiny.png", + z_order=1, + ) + ] + + should_choose = helper( + source_image_path=source, + chosen_background_image_path=chosen, + text_clean_image_path=text_clean, + text_boxes=text_boxes, + native_shapes=native_shapes, + bitmap_assets=bitmap_assets, + source_image_size=(360, 220), + ) + + self.assertFalse(should_choose) + + def test_native_heavy_page_keeps_editable_shapes_when_background_is_clean(self): + from PIL import Image + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_choose_source_preserving_background", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + chosen = root / "base-clean.png" + text_clean = root / "text-clean.png" + image = Image.new("RGB", (360, 220), "#030915") + image.save(source) + image.save(chosen) + Image.new("RGB", (360, 220), "#FFFFFF").save(text_clean) + + text_boxes = [ + TextBoxSpec( + text=f"文本{i}", + source_pixel_bbox=(240, 20 + i * 16, 320, 32 + i * 16), + source_pixel_polygon=( + (240, 20 + i * 16), + (320, 20 + i * 16), + (320, 32 + i * 16), + (240, 32 + i * 16), + ), + ) + for i in range(8) + ] + native_shapes = [ + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=( + 10 + (i % 10) * 32, + 45 + (i // 10) * 42, + 34 + (i % 10) * 32, + 69 + (i // 10) * 42, + ), + fill_color="#123456", + ) + for i in range(20) + ] + + should_choose = helper( + source_image_path=source, + chosen_background_image_path=chosen, + text_clean_image_path=text_clean, + text_boxes=text_boxes, + native_shapes=native_shapes, + bitmap_assets=[], + source_image_size=(360, 220), + ) + + self.assertFalse(should_choose) + + def test_source_preserving_background_is_not_chosen_for_very_native_heavy_complex_page(self): + from PIL import Image + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_choose_source_preserving_background", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + chosen = root / "base-clean.png" + text_clean = root / "text-clean.png" + image = Image.new("RGB", (360, 220), "#030915") + image.save(source) + Image.new("RGB", (360, 220), "#FFFFFF").save(chosen) + Image.new("RGB", (360, 220), "#FFFFFF").save(text_clean) + + text_boxes = [ + TextBoxSpec( + text=f"文本{i}", + source_pixel_bbox=(240, 20 + i * 16, 320, 32 + i * 16), + source_pixel_polygon=( + (240, 20 + i * 16), + (320, 20 + i * 16), + (320, 32 + i * 16), + (240, 32 + i * 16), + ), + ) + for i in range(8) + ] + native_shapes = [ + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(10 + (i % 10) * 32, 45 + (i // 10) * 42, 34 + (i % 10) * 32, 69 + (i // 10) * 42), + fill_color="#123456", + ) + for i in range(20) + ] + bitmap_assets = [] + for i in range(5): + asset_path = root / "assets" / f"asset-{i}.png" + asset_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (40, 30), "#123456").save(asset_path) + bitmap_assets.append( + BitmapAssetSpec( + asset_id=f"asset-{i}", + source_pixel_bbox=(20 + i * 45, 120, 60 + i * 45, 150), + asset_path=f"assets/asset-{i}.png", + z_order=i + 1, + ) + ) + + should_choose = helper( + source_image_path=source, + chosen_background_image_path=chosen, + text_clean_image_path=text_clean, + text_boxes=text_boxes, + native_shapes=native_shapes, + bitmap_assets=bitmap_assets, + source_image_size=(360, 220), + ) + + self.assertFalse(should_choose) + + def test_source_preserving_background_is_not_chosen_for_many_native_shapes_with_few_bitmap_assets(self): + from PIL import Image + + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_choose_source_preserving_background", None) + self.assertIsNotNone(helper) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + chosen = root / "base-clean.png" + text_clean = root / "text-clean.png" + image = Image.new("RGB", (360, 220), "#030915") + image.save(source) + Image.new("RGB", (360, 220), "#FFFFFF").save(chosen) + Image.new("RGB", (360, 220), "#FFFFFF").save(text_clean) + + text_boxes = [ + TextBoxSpec( + text=f"标签{i}", + source_pixel_bbox=(250, 20 + i * 18, 330, 34 + i * 18), + source_pixel_polygon=( + (250, 20 + i * 18), + (330, 20 + i * 18), + (330, 34 + i * 18), + (250, 34 + i * 18), + ), + ) + for i in range(10) + ] + native_shapes = [ + NativeShapeSpec( + shape_type="rounded_rectangle", + source_pixel_bbox=(20 + i * 8, 40 + i * 5, 38 + i * 8, 58 + i * 5), + fill_color="#123456", + ) + for i in range(12) + ] + bitmap_assets = [] + for i in range(2): + asset_path = root / "assets" / f"asset-{i}.png" + asset_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (100, 80), "#123456").save(asset_path) + bitmap_assets.append( + BitmapAssetSpec( + asset_id=f"asset-{i}", + source_pixel_bbox=(60 + i * 120, 80, 160 + i * 120, 160), + asset_path=f"assets/asset-{i}.png", + z_order=i + 1, + ) + ) + + should_choose = helper( + source_image_path=source, + chosen_background_image_path=chosen, + text_clean_image_path=text_clean, + text_boxes=text_boxes, + native_shapes=native_shapes, + bitmap_assets=bitmap_assets, + source_image_size=(360, 220), + ) + + self.assertFalse(should_choose) + + def test_source_raster_guardrail_does_not_trigger_for_many_tall_short_warning_only_text_boxes(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_use_source_raster_guardrail", None) + self.assertIsNotNone(helper) + + text_boxes = [ + TextBoxSpec( + text=f"短标签{i}", + source_pixel_bbox=(900, 300 + i * 70, 1160, 365 + i * 70), + source_pixel_polygon=( + (900, 300 + i * 70), + (1160, 300 + i * 70), + (1160, 365 + i * 70), + (900, 365 + i * 70), + ), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + for i in range(4) + ] + text_boxes.append( + TextBoxSpec( + text="落地建议:用车、补能与维护的最佳实践", + source_pixel_bbox=(296, 22, 1283, 76), + source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ) + ) + + should_guard = helper(text_boxes=text_boxes, text_issues=[]) + + self.assertFalse(should_guard) + + def test_source_raster_guardrail_does_not_trigger_for_fragmented_top_warning_only_ocr(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_use_source_raster_guardrail", None) + self.assertIsNotNone(helper) + + text_boxes = [ + TextBoxSpec( + text="理想汽车技术实验室", + source_pixel_bbox=(1434, 132, 1486, 140), + source_pixel_polygon=((1434, 132), (1486, 132), (1486, 140), (1434, 140)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="理想L9:", + source_pixel_bbox=(111, 85, 143, 96), + source_pixel_polygon=((111, 85), (143, 85), (143, 96), (111, 96)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="旗舰增程SUV的技术实验", + source_pixel_bbox=(1315, 184, 1417, 192), + source_pixel_polygon=((1315, 184), (1417, 184), (1417, 192), (1315, 192)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="72.7kWh 5C电池 | CLTC纯电续航420km | 综合1650km", + source_pixel_bbox=(1181, 131, 1327, 198), + source_pixel_polygon=((1181, 131), (1327, 131), (1327, 198), (1181, 198)), + provenance={"ocr_provenance": {"approximate_layout": True}}, + ), + TextBoxSpec( + text="5C电池", + source_pixel_bbox=(261, 544, 339, 572), + source_pixel_polygon=((261, 544), (339, 544), (339, 572), (261, 572)), + ), + TextBoxSpec( + text="72.7kWh", + source_pixel_bbox=(157, 545, 257, 571), + source_pixel_polygon=((157, 545), (257, 545), (257, 571), (157, 571)), + ), + ] + text_issues = [ + {"code": "ignored_spurious_ocr", "severity": "warning", "ocr_text": "基于增程系统消除长距离出行焦虑"}, + {"code": "ignored_spurious_ocr", "severity": "warning", "ocr_text": "从用户安全与长期用车角度建立系统性落地指南"}, + ] + + should_guard = helper( + text_boxes=text_boxes, + text_issues=text_issues, + source_image_size=(1672, 941), + ) + + self.assertFalse(should_guard) + + def test_source_raster_guardrail_triggers_for_unreliable_ocr_layout_issue(self): + import src.generative_editable_pipeline as pipeline + + helper = getattr(pipeline, "_should_use_source_raster_guardrail", None) + self.assertIsNotNone(helper) + + text_boxes = [ + TextBoxSpec( + text="真实标题", + source_pixel_bbox=(80, 40, 260, 80), + source_pixel_polygon=((80, 40), (260, 40), (260, 80), (80, 80)), + ) + ] + text_issues = [ + { + "code": "unreliable_ocr_layout", + "ocr_text": "核心架构设计:增程、底盘与智能域", + } + ] + + should_guard = helper(text_boxes=text_boxes, text_issues=text_issues) + + self.assertTrue(should_guard) + + def test_records_asset_repair_history_on_page_manifest(self): + from src.generative_editable_pipeline import apply_asset_repair_results + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(120, 80), + slide_size=(10.0, 5.625), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + ], + ) + repaired_asset = BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.repair-1.png", + z_order=1, + provenance={"repair_attempt": 1}, + ) + attempt = RepairAttempt( + target_id="fg-001", + attempt_index=1, + reason="edge_touch", + provider_role="edit_model", + status="passed", + provenance={"prompt_id": "repair_asset"}, + ) + + updated = apply_asset_repair_results(page, [repaired_asset], [attempt]) + + self.assertEqual(updated.bitmap_assets[0].asset_path, repaired_asset.asset_path) + self.assertEqual(updated.repair_attempts, [attempt]) + self.assertEqual(updated.provenance["asset_repair"]["repaired_asset_ids"], ["fg-001"]) + + def test_rejects_repaired_assets_that_do_not_match_existing_manifest_targets(self): + from src.generative_editable_pipeline import apply_asset_repair_results + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(120, 80), + slide_size=(10.0, 5.625), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + ], + ) + unexpected_asset = BitmapAssetSpec( + asset_id="fg-999", + source_pixel_bbox=(10, 10, 40, 32), + asset_path="assets/0000-slide-a/fg-999.repair-1.png", + z_order=1, + ) + + with self.assertRaisesRegex(ValueError, "unknown repaired asset"): + apply_asset_repair_results(page, [unexpected_asset], []) + + def test_finalize_validated_export_returns_output_when_validation_passes(self): + from src.generative_editable_pipeline import finalize_validated_export + + report = ValidationReport(status="passed", checked_pages=1, issues=[]) + + result = finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + ) + + self.assertEqual(result.status, "passed") + self.assertEqual(result.output_path, "deck.pptx") + self.assertEqual(result.fallback_policy, "fail") + self.assertEqual(result.fallback_used, "") + + def test_finalize_validated_export_defaults_to_fail_policy_on_validation_failure(self): + from src.generative_editable_pipeline import ( + GenerativeEditableValidationError, + finalize_validated_export, + ) + + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_similarity_failed", + message="preview differs", + slide_id="slide-a", + ) + ], + ) + + with self.assertRaises(GenerativeEditableValidationError) as ctx: + finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy=None, + ) + + self.assertEqual(ctx.exception.fallback_policy, "fail") + self.assertIs(ctx.exception.validation_report, report) + self.assertIn("validation failed", str(ctx.exception)) + self.assertIn("preview_similarity_failed", str(ctx.exception)) + + def test_default_fail_policy_does_not_silently_return_low_fidelity_fallback(self): + from src.generative_editable_pipeline import ( + GenerativeEditableValidationError, + finalize_validated_export, + ) + + fallback_calls = [] + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ValidationIssue(code="missing_required_text", message="missing text")], + ) + + with self.assertRaises(GenerativeEditableValidationError): + finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy="fail", + fallback_output_factory=lambda: fallback_calls.append("called") or "raster.pptx", + ) + + self.assertEqual(fallback_calls, []) + + def test_default_fail_policy_with_factory_still_does_not_call_fallback(self): + from src.generative_editable_pipeline import ( + GenerativeEditableValidationError, + finalize_validated_export, + ) + + fallback_calls = [] + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ValidationIssue(code="preview_similarity_failed", message="preview differs")], + ) + + with self.assertRaises(GenerativeEditableValidationError): + finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy=None, + fallback_output_factory=lambda: fallback_calls.append("called") or "fallback.pptx", + ) + + self.assertEqual(fallback_calls, []) + + def test_inconsistent_passed_report_with_issues_does_not_return_output(self): + from src.generative_editable_pipeline import ( + GenerativeEditableValidationError, + finalize_validated_export, + ) + + report = ValidationReport( + status="passed", + checked_pages=1, + issues=[ValidationIssue(code="object_order_mismatch", message="bad order")], + ) + + with self.assertRaises(GenerativeEditableValidationError): + finalize_validated_export(validation_report=report, output_path="deck.pptx") + + def test_invalid_runtime_fallback_policy_is_rejected_explicitly(self): + from src.generative_editable_pipeline import finalize_validated_export + + with self.assertRaisesRegex(ValueError, "unsupported fallback policy"): + finalize_validated_export( + validation_report=ValidationReport(status="passed", checked_pages=1, issues=[]), + output_path="deck.pptx", + fallback_policy="", + ) + + def test_explicit_future_fallback_policy_raises_project_error_until_implemented(self): + from src.generative_editable_pipeline import ( + GenerativeEditableFallbackError, + finalize_validated_export, + ) + + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ValidationIssue(code="preview_similarity_failed", message="preview differs")], + ) + + with self.assertRaises(GenerativeEditableFallbackError): + finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy="raster_pptx", + ) + + def test_explicit_raster_fallback_returns_fallback_metadata_when_output_succeeds(self): + from src.generative_editable_pipeline import finalize_validated_export + + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ValidationIssue(code="preview_similarity_failed", message="preview differs")], + ) + + with tempfile.TemporaryDirectory() as tmp: + fallback_path = Path(tmp) / "raster.pptx" + fallback_path.write_bytes(b"pptx") + + result = finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy="raster_pptx", + fallback_output_factory=lambda: str(fallback_path), + ) + + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.output_path, str(fallback_path)) + self.assertEqual(result.fallback_policy, "raster_pptx") + self.assertEqual(result.fallback_used, "raster_pptx") + self.assertIs(result.validation_report, report) + + def test_explicit_text_editable_background_fallback_returns_fallback_metadata(self): + from src.generative_editable_pipeline import finalize_validated_export + + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ValidationIssue(code="missing_object", message="asset failed")], + ) + + with tempfile.TemporaryDirectory() as tmp: + fallback_path = Path(tmp) / "text-background.pptx" + fallback_path.write_bytes(b"pptx") + + result = finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy="text_editable_background", + fallback_output_factory=lambda: str(fallback_path), + ) + + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.output_path, str(fallback_path)) + self.assertEqual(result.fallback_used, "text_editable_background") + + def test_explicit_fallback_missing_output_path_reports_failure_reason(self): + from src.generative_editable_pipeline import ( + GenerativeEditableFallbackError, + finalize_validated_export, + ) + + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ValidationIssue(code="missing_object", message="asset failed")], + ) + + with tempfile.TemporaryDirectory() as tmp: + missing_path = Path(tmp) / "missing.pptx" + with self.assertRaises(GenerativeEditableFallbackError) as ctx: + finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy="raster_pptx", + fallback_output_factory=lambda: str(missing_path), + ) + + self.assertIn("missing_object", str(ctx.exception)) + self.assertIn("does not exist", str(ctx.exception)) + + def test_explicit_fallback_failure_reports_original_and_fallback_reasons(self): + from src.generative_editable_pipeline import ( + GenerativeEditableFallbackError, + finalize_validated_export, + ) + + report = ValidationReport( + status="failed", + checked_pages=1, + issues=[ValidationIssue(code="preview_similarity_failed", message="preview differs")], + ) + + def failing_fallback(): + raise RuntimeError("raster exporter unavailable") + + with self.assertRaises(GenerativeEditableFallbackError) as ctx: + finalize_validated_export( + validation_report=report, + output_path="deck.pptx", + fallback_policy="raster_pptx", + fallback_output_factory=failing_fallback, + ) + + self.assertIs(ctx.exception.validation_report, report) + self.assertEqual(ctx.exception.fallback_policy, "raster_pptx") + self.assertIn("preview_similarity_failed", str(ctx.exception)) + self.assertIn("raster exporter unavailable", str(ctx.exception)) + + def test_orchestrates_one_slide_deck_with_fake_components(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_manifest import ( + BitmapAssetSpec, + read_deck_manifest, + read_page_manifest, + ) + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + ValidationReport, + ) + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ImageGenerationRequest, + ) + + config = load_generative_editable_config(use_fake=True) + calls: list[str] = [] + + class RecordingImageGenerationProvider(FakeImageGenerationProvider): + def generate(self, request: ImageGenerationRequest): + calls.append(f"image_generation:{request.prompt_id}") + return super().generate(request) + + def fake_planner(**kwargs): + calls.append("planner") + self.assertTrue(Path(kwargs["base_clean_image_path"]).exists()) + self.assertTrue(Path(kwargs["text_mask_path"]).exists()) + return [ + ForegroundCandidate( + candidate_id="fg-001", + source_pixel_bbox=(500, 120, 620, 220), + area=12000, + classification="bitmap_asset_candidate", + confidence=0.91, + ) + ] + + def fake_asset_builder(**kwargs): + calls.append("assets") + output_path = Path(kwargs["asset_root"]) / "assets" / "0000-slide-a" / "fg-001.png" + kwargs["image_generation_provider"].generate( + ImageGenerationRequest( + prompt_id="asset_sheet", + prompt="fake asset generation", + output_asset_path=str(output_path), + asset_root=str(kwargs["asset_root"]), + visual_reference={"candidate_ids": ["fg-001"]}, + ) + ) + return [ + BitmapAssetSpec( + asset_id="fg-001", + source_pixel_bbox=(500, 120, 620, 220), + asset_path="assets/0000-slide-a/fg-001.png", + z_order=1, + ) + ] + + def fake_composer(deck_manifest_path, artifact_root, output_path): + calls.append("composer") + self.assertTrue(Path(deck_manifest_path).exists()) + Path(output_path).write_bytes(b"fake-pptx") + + def fake_preview_renderer(page_manifest, artifact_root): + calls.append("preview") + return PreviewRenderResult( + image=Image.new("RGB", page_manifest.source_image_size, "white"), + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ) + + def fake_structure_validator(**kwargs): + calls.append("structure_validator") + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + def fake_preview_validator(**kwargs): + calls.append("preview_validator") + self.assertEqual(kwargs["slide_id"], "slide-a") + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + text_metadata=[ + { + "text": "Quarterly Plan", + "role": "title", + "order": 1, + } + ], + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + aspect_ratio="16:9", + fallback_policy="fail", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=RecordingImageGenerationProvider( + config.generation_model + ), + foreground_planner=fake_planner, + asset_builder=fake_asset_builder, + composer=fake_composer, + preview_renderer=fake_preview_renderer, + structure_validator=fake_structure_validator, + preview_validator=fake_preview_validator, + ), + ) + + deck_manifest = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page_manifest = read_page_manifest( + artifact_root / "job-1" / deck_manifest.page_manifest_paths[0] + ) + + self.assertEqual(result.status, "passed") + self.assertEqual(result.output_path, str(output_path)) + self.assertEqual(deck_manifest.validation_status, "passed") + self.assertEqual(page_manifest.validation_status, "passed") + self.assertEqual(page_manifest.text_boxes[0].text, "Quarterly Plan") + self.assertEqual(page_manifest.bitmap_assets[0].asset_id, "fg-001") + self.assertIn("ocr", page_manifest.provider_output_paths) + self.assertIn("image_edit", page_manifest.provider_output_paths) + self.assertIn("image_generation", page_manifest.provider_output_paths) + self.assertIn("text_mask_path", page_manifest.provenance) + + self.assertEqual( + calls, + [ + "planner", + "assets", + "image_generation:asset_sheet", + "composer", + "structure_validator", + "preview", + "preview_validator", + ], + ) + + def test_default_reconstruction_builds_shapes_asset_sheets_and_bitmap_assets(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ImageEditRequest, + ) + + config = load_generative_editable_config(use_fake=True) + edit_prompt_ids: list[str] = [] + + class RecordingImageEditProvider(FakeImageEditProvider): + def edit(self, request: ImageEditRequest): + edit_prompt_ids.append(request.prompt_id) + if request.prompt_id == "asset_sheet": + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + sheet = Image.new("RGBA", (800, 450), (0, 0, 0, 0)) + ImageDraw.Draw(sheet).ellipse((502, 142, 608, 208), fill="#10B981") + sheet.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "source_image_path": request.source_image_path, + "prompt_id": request.prompt_id, + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "timeout_seconds": request.timeout_seconds, + }, + )() + return super().edit(request) + + def fake_planner(**kwargs): + return [ + ForegroundCandidate( + candidate_id="shape-001", + source_pixel_bbox=(80, 320, 280, 360), + area=8000, + classification="native_shape_candidate", + confidence=0.95, + provenance={"shape_hint": "rectangle"}, + ), + ForegroundCandidate( + candidate_id="asset-001", + source_pixel_bbox=(480, 118, 626, 224), + area=15476, + classification="bitmap_asset_candidate", + confidence=0.91, + ), + ] + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + image = Image.new("RGB", (800, 450), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((80, 320, 280, 360), fill="#2563EB") + draw.ellipse((500, 140, 610, 210), fill="#10B981") + image.save(source_path) + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=RecordingImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=fake_planner, + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + + self.assertEqual(result.status, "passed") + self.assertEqual([shape.shape_type for shape in page.native_shapes], ["rectangle"]) + self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["asset-001"]) + self.assertTrue(Path(artifact_root / "job-1" / page.bitmap_assets[0].asset_path).exists()) + self.assertEqual([sheet.sheet_id for sheet in page.asset_sheets], ["asset-sheet-0000"]) + self.assertEqual(page.asset_sheets[0].candidate_ids, ["asset-001"]) + self.assertIn("asset_sheet", edit_prompt_ids) + + def test_approximate_ocr_layout_still_runs_reconstruction(self): + from PIL import Image + + from src.generative_editable_config import ProviderConfig, load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class ApproximateOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="plain-text-ocr", + model="plain-text-ocr", + base_url="", + api_key="", + ) + ) + + def extract_text(self, image_path: str): + return OCRResult( + source_image_path=image_path, + image_size=(800, 450), + provider_role=self.config.role, + provider_name=self.config.provider, + model=self.config.model, + items=[ + OCRTextItem( + text="Approximate title", + bbox=(60, 40, 740, 110), + polygon=((60, 40), (740, 40), (740, 110), (60, 110)), + confidence=0.78, + style_hints={"approximate_layout": True}, + provenance={"approximate_layout": True}, + ) + ], + ) + + class RecordingImageEditProvider(FakeImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + self.prompt_ids: list[str] = [] + + def edit(self, request): + self.prompt_ids.append(request.prompt_id) + return super().edit(request) + + asset_builder_calls = [] + + def recording_asset_builder(**kwargs): + asset_builder_calls.append(kwargs) + return AssetBuildResult(bitmap_assets=[]) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + config = load_generative_editable_config(use_fake=True) + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "#0B1020").save(source_path) + image_edit_provider = RecordingImageEditProvider() + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-approx", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=ApproximateOCRProvider(), + image_edit_provider=image_edit_provider, + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + asset_builder=recording_asset_builder, + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + deck = read_deck_manifest(artifact_root / "job-approx" / "deck.json") + page = read_page_manifest(artifact_root / "job-approx" / deck.page_manifest_paths[0]) + + self.assertEqual(result.status, "passed") + self.assertNotEqual(page.chosen_background, "backgrounds/0000-slide-a/source-visual.png") + self.assertNotEqual(page.provenance.get("reconstruction_mode"), "source_visual_background") + self.assertIn("text_clean_background", image_edit_provider.prompt_ids) + self.assertIn("base_clean_background", image_edit_provider.prompt_ids) + self.assertEqual(len(asset_builder_calls), 1) + self.assertNotEqual( + asset_builder_calls[0]["asset_reference_image_path"], + asset_builder_calls[0]["source_image_path"], + ) + self.assertTrue(asset_builder_calls[0]["asset_reference_image_path"].endswith("text-clean.png")) + self.assertEqual(page.bitmap_assets, []) + self.assertEqual(page.native_shapes, []) + self.assertTrue(page.text_boxes[0].style_hints["approximate_layout"]) + + def test_asset_reference_uses_text_clean_only_when_unmasked_geometry_is_stable(self): + from PIL import Image, ImageDraw + + from src.generative_editable_pipeline import _select_asset_reference_image + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + stable = root / "text-clean-stable.png" + drifted = root / "text-clean-drifted.png" + mask = root / "mask.png" + + image = Image.new("RGB", (160, 90), "#0B1020") + draw = ImageDraw.Draw(image) + draw.rectangle((16, 24, 72, 64), fill="#2563EB") + draw.rectangle((96, 24, 142, 64), fill="#10B981") + draw.text((20, 6), "TEXT", fill="#FFFFFF") + image.save(source) + + stable_image = image.copy() + ImageDraw.Draw(stable_image).rectangle((18, 4, 62, 18), fill="#0B1020") + stable_image.save(stable) + + drifted_image = Image.new("RGB", (160, 90), "#0B1020") + drifted_draw = ImageDraw.Draw(drifted_image) + drifted_draw.rectangle((26, 34, 82, 74), fill="#2563EB") + drifted_draw.rectangle((106, 34, 152, 74), fill="#10B981") + drifted_image.save(drifted) + + mask_image = Image.new("L", (160, 90), 0) + ImageDraw.Draw(mask_image).rectangle((16, 2, 68, 22), fill=255) + mask_image.save(mask) + + stable_choice = _select_asset_reference_image( + source_image_path=source, + text_clean_image_path=stable, + text_mask_path=mask, + ) + drifted_choice = _select_asset_reference_image( + source_image_path=source, + text_clean_image_path=drifted, + text_mask_path=mask, + ) + + self.assertEqual(Path(stable_choice), stable) + self.assertEqual(Path(drifted_choice), source) + + def test_reconstruction_targets_reclassify_uncertain_simple_source_shapes_as_native(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (200, 120), "white") + ImageDraw.Draw(image).rectangle((30, 34, 120, 82), fill="#2563EB") + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="fg-001", + source_pixel_bbox=(30, 34, 121, 83), + area=4459, + classification="uncertain", + confidence=0.0, + ) + ], + source_image_path=str(source), + ) + + self.assertEqual([shape.shape_type for shape in native_shapes], ["rectangle"]) + self.assertEqual(bitmap_candidates, []) + + def test_asset_sheet_batches_complex_whole_visuals_as_singletons(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _candidate_batches + + candidates = [ + ForegroundCandidate( + candidate_id="complex-1", + source_pixel_bbox=(0, 0, 100, 80), + area=8000, + classification="complex_whole_visual", + ), + ForegroundCandidate( + candidate_id="complex-2", + source_pixel_bbox=(120, 0, 220, 80), + area=8000, + classification="complex_whole_visual", + ), + ForegroundCandidate( + candidate_id="icon-1", + source_pixel_bbox=(0, 120, 40, 160), + area=1600, + classification="bitmap_asset_candidate", + ), + ForegroundCandidate( + candidate_id="icon-2", + source_pixel_bbox=(60, 120, 100, 160), + area=1600, + classification="bitmap_asset_candidate", + ), + ] + + batches = _candidate_batches(candidates, max_batch_size=6) + + self.assertEqual( + [[candidate.candidate_id for candidate in batch] for batch in batches], + [["complex-1"], ["complex-2"], ["icon-1", "icon-2"]], + ) + + def test_reconstruction_targets_drop_nested_bitmap_fragments_inside_larger_complex_visual(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (300, 180), "#0B1020") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((40, 30, 240, 130), radius=12, fill="#1D4ED8") + draw.rectangle((92, 58, 138, 100), fill="#F8FAFC") + draw.rectangle((168, 58, 215, 100), fill="#94A3B8") + draw.rectangle((252, 42, 286, 76), fill="#16A34A") + image.save(source) + + _native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="large-panel", + source_pixel_bbox=(40, 30, 240, 130), + area=20000, + classification="complex_whole_visual", + ), + ForegroundCandidate( + candidate_id="nested-left", + source_pixel_bbox=(92, 58, 138, 100), + area=1932, + classification="complex_whole_visual", + ), + ForegroundCandidate( + candidate_id="nested-right", + source_pixel_bbox=(168, 58, 215, 100), + area=1974, + classification="complex_whole_visual", + ), + ForegroundCandidate( + candidate_id="outside", + source_pixel_bbox=(252, 42, 286, 76), + area=1156, + classification="complex_whole_visual", + ), + ], + source_image_path=str(source), + ) + + self.assertEqual( + [candidate.candidate_id for candidate in bitmap_candidates], + ["large-panel", "outside"], + ) + + def test_reconstruction_targets_drop_line_like_bitmap_residuals(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (500, 280), "#0B1020") + draw = ImageDraw.Draw(image) + draw.line((40, 80, 460, 80), fill="#2563EB", width=3) + draw.rectangle((120, 120, 220, 200), fill="#10B981") + image.save(source) + + _native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="glow-line-residual", + source_pixel_bbox=(40, 76, 461, 87), + area=4631, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="real-object", + source_pixel_bbox=(120, 120, 221, 201), + area=8181, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + self.assertEqual([candidate.candidate_id for candidate in bitmap_candidates], ["real-object"]) + + def test_reconstruction_targets_merge_nearby_bitmap_fragments(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (420, 240), "#0B1020") + draw = ImageDraw.Draw(image) + draw.rectangle((60, 80, 120, 140), fill="#2563EB") + draw.rectangle((132, 82, 188, 138), fill="#1D4ED8") + draw.rectangle((300, 80, 360, 140), fill="#10B981") + image.save(source) + + _native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="near-left", + source_pixel_bbox=(60, 80, 121, 141), + area=3721, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="near-right", + source_pixel_bbox=(132, 82, 189, 139), + area=3249, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="far", + source_pixel_bbox=(300, 80, 361, 141), + area=3721, + classification="complex_whole_visual", + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + self.assertEqual(len(bitmap_candidates), 2) + self.assertEqual(bitmap_candidates[0].source_pixel_bbox, (60, 80, 189, 141)) + self.assertEqual(bitmap_candidates[0].provenance["merged_candidate_ids"], ["near-left", "near-right"]) + self.assertEqual(bitmap_candidates[1].candidate_id, "far") + + def test_masked_source_element_assets_preserve_visuals_and_remove_text_pixels(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_masked_source_element_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "text-mask.png" + output_dir = root / "assets" / "0000-slide-a" + + image = Image.new("RGB", (160, 90), "#0B1020") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 140, 70), fill="#1D4ED8") + draw.rectangle((72, 34, 122, 48), fill="#FFFFFF") + image.save(source) + + mask_image = Image.new("L", (160, 90), 0) + ImageDraw.Draw(mask_image).rectangle((70, 32, 124, 50), fill=255) + mask_image.save(mask) + + assets = _build_masked_source_element_assets( + candidates=[ + ForegroundCandidate( + candidate_id="panel", + source_pixel_bbox=(20, 20, 141, 71), + area=6171, + classification="complex_whole_visual", + ) + ], + source_image_path=str(source), + text_mask_path=str(mask), + output_dir=output_dir, + asset_root=root, + start_z_order=3, + ) + + asset_path = root / assets[0].asset_path + with Image.open(asset_path) as asset_image: + text_alpha = asset_image.getpixel((55, 20))[3] + visual_alpha = asset_image.getpixel((10, 10))[3] + visual_color = asset_image.getpixel((10, 10))[:3] + + self.assertEqual(len(assets), 1) + self.assertEqual(assets[0].asset_path, "assets/0000-slide-a/panel.preserved.png") + self.assertEqual(assets[0].z_order, 3) + self.assertEqual(assets[0].provenance["asset_strategy"], "masked_source_element") + self.assertEqual(text_alpha, 0) + self.assertEqual(visual_alpha, 255) + self.assertEqual(visual_color, (29, 78, 216)) + + def test_masked_source_element_assets_use_clean_background_difference_for_alpha(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_masked_source_element_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + clean_background = root / "base-clean.png" + mask = root / "text-mask.png" + output_dir = root / "assets" / "0000-slide-a" + + background = Image.new("RGB", (180, 100), "#0B1020") + draw_bg = ImageDraw.Draw(background) + draw_bg.rounded_rectangle((10, 20, 170, 80), radius=8, fill="#10223A") + background.save(clean_background) + + source_image = background.copy() + draw = ImageDraw.Draw(source_image) + draw.rectangle((72, 38, 118, 66), fill="#94A3B8") + draw.rectangle((130, 36, 162, 50), fill="#FFFFFF") + source_image.save(source) + + mask_image = Image.new("L", (180, 100), 0) + ImageDraw.Draw(mask_image).rectangle((128, 34, 164, 52), fill=255) + mask_image.save(mask) + + assets = _build_masked_source_element_assets( + candidates=[ + ForegroundCandidate( + candidate_id="panel", + source_pixel_bbox=(10, 20, 171, 81), + area=9821, + classification="complex_whole_visual", + ) + ], + source_image_path=str(source), + background_image_path=str(clean_background), + text_mask_path=str(mask), + output_dir=output_dir, + asset_root=root, + ) + + with Image.open(root / assets[0].asset_path) as asset_image: + asset_size = asset_image.size + object_alpha = asset_image.getpixel((10, 10))[3] + + self.assertEqual(object_alpha, 255) + self.assertEqual(asset_size, (47, 29)) + self.assertEqual(assets[0].source_pixel_bbox, (72, 38, 119, 67)) + self.assertEqual(assets[0].provenance["background_difference_alpha"], True) + self.assertEqual(assets[0].provenance["trimmed_transparent_bounds"], True) + + def test_visual_anchor_source_asset_suppresses_smooth_background_difference(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_masked_source_element_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + clean_background = root / "base-clean.png" + output_dir = root / "assets" / "0000-slide-a" + + clean = Image.new("RGB", (220, 140), "#061020") + clean_draw = ImageDraw.Draw(clean) + clean_draw.rounded_rectangle((20, 30, 200, 112), radius=10, fill="#102642") + clean.save(clean_background) + + source_image = Image.new("RGB", (220, 140), "#061020") + source_draw = ImageDraw.Draw(source_image) + source_draw.rounded_rectangle((20, 30, 200, 112), radius=10, fill="#12304F") + source_draw.rectangle((78, 54, 142, 88), fill="#A8B6C8") + source_draw.line((82, 60, 138, 60), fill="#F8FAFC", width=3) + source_draw.line((82, 72, 138, 72), fill="#64748B", width=3) + source_image.save(source) + + assets = _build_masked_source_element_assets( + candidates=[ + ForegroundCandidate( + candidate_id="domain-row", + source_pixel_bbox=(20, 30, 201, 113), + area=181 * 83, + classification="complex_whole_visual", + provenance={"detection": "domain_row_visual_anchor"}, + ) + ], + source_image_path=str(source), + background_image_path=str(clean_background), + text_mask_path=None, + output_dir=output_dir, + asset_root=root, + ) + + with Image.open(root / assets[0].asset_path).convert("RGBA") as asset_image: + alpha_bbox = asset_image.getchannel("A").getbbox() + object_alpha = asset_image.getpixel((20, 12))[3] + + self.assertEqual(assets[0].provenance["background_difference_alpha"], True) + self.assertEqual(object_alpha, 255) + self.assertGreater(assets[0].source_pixel_bbox[0], 60) + self.assertGreater(assets[0].source_pixel_bbox[1], 40) + self.assertLess(assets[0].source_pixel_bbox[2], 160) + self.assertLessEqual(assets[0].source_pixel_bbox[3], 100) + self.assertLess(alpha_bbox[2] - alpha_bbox[0], 100) + self.assertLessEqual(alpha_bbox[3] - alpha_bbox[1], 60) + + def test_visual_anchor_source_asset_does_not_collapse_to_tiny_saliency_fragment(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_masked_source_element_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + clean_background = root / "base-clean.png" + output_dir = root / "assets" / "0000-slide-a" + + clean = Image.new("RGB", (260, 160), "#061020") + clean_draw = ImageDraw.Draw(clean) + clean_draw.rounded_rectangle((20, 30, 240, 130), radius=10, fill="#102642") + clean.save(clean_background) + + source_image = Image.new("RGB", (260, 160), "#061020") + source_draw = ImageDraw.Draw(source_image) + source_draw.rounded_rectangle((20, 30, 240, 130), radius=10, fill="#12304F") + source_draw.rectangle((28, 62, 210, 106), fill="#23364A") + source_draw.line((120, 82, 132, 86), fill="#F8FAFC", width=2) + source_image.save(source) + + assets = _build_masked_source_element_assets( + candidates=[ + ForegroundCandidate( + candidate_id="domain-row", + source_pixel_bbox=(20, 30, 241, 131), + area=221 * 101, + classification="complex_whole_visual", + provenance={"detection": "domain_row_visual_anchor"}, + ) + ], + source_image_path=str(source), + background_image_path=str(clean_background), + text_mask_path=None, + output_dir=output_dir, + asset_root=root, + ) + + self.assertEqual(assets[0].provenance["background_difference_alpha"], True) + self.assertGreater(assets[0].source_pixel_bbox[2] - assets[0].source_pixel_bbox[0], 160) + self.assertFalse(assets[0].provenance["smooth_background_difference_suppressed"]) + + def test_domain_row_visual_anchor_does_not_collapse_to_one_object_when_multiple_visuals_exist(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_masked_source_element_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + clean_background = root / "base-clean.png" + output_dir = root / "assets" / "0000-slide-a" + + clean = Image.new("RGB", (360, 160), "#061020") + clean_draw = ImageDraw.Draw(clean) + clean_draw.rounded_rectangle((20, 30, 340, 130), radius=10, fill="#102642") + clean.save(clean_background) + + source_image = Image.new("RGB", (360, 160), "#061020") + source_draw = ImageDraw.Draw(source_image) + source_draw.rounded_rectangle((20, 30, 340, 130), radius=10, fill="#12304F") + source_draw.rounded_rectangle((74, 58, 166, 106), radius=6, fill="#07111E", outline="#1F2937", width=3) + source_draw.ellipse((236, 54, 304, 116), fill="#1F2937", outline="#CBD5E1", width=4) + source_draw.ellipse((258, 76, 282, 100), fill="#94A3B8") + source_image.save(source) + + assets = _build_masked_source_element_assets( + candidates=[ + ForegroundCandidate( + candidate_id="domain-row", + source_pixel_bbox=(20, 30, 341, 131), + area=321 * 101, + classification="complex_whole_visual", + provenance={"detection": "domain_row_visual_anchor"}, + ) + ], + source_image_path=str(source), + background_image_path=str(clean_background), + text_mask_path=None, + output_dir=output_dir, + asset_root=root, + ) + + self.assertGreater(assets[0].source_pixel_bbox[2] - assets[0].source_pixel_bbox[0], 230) + self.assertFalse(assets[0].provenance["smooth_background_difference_suppressed"]) + + def test_large_source_preserved_asset_feathers_outer_alpha_edges(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_masked_source_element_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + clean_background = root / "base-clean.png" + output_dir = root / "assets" / "0000-slide-a" + + Image.new("RGB", (240, 140), "#102642").save(clean_background) + source_image = Image.new("RGB", (240, 140), "#12304F") + ImageDraw.Draw(source_image).rectangle((80, 50, 160, 90), fill="#A8B6C8") + source_image.save(source) + + assets = _build_masked_source_element_assets( + candidates=[ + ForegroundCandidate( + candidate_id="large-row", + source_pixel_bbox=(20, 20, 220, 120), + area=200 * 100, + classification="complex_whole_visual", + provenance={ + "reason": "large_or_visually_complex", + "original_detection": {"detection": "source_base_difference"}, + }, + ) + ], + source_image_path=str(source), + background_image_path=str(clean_background), + text_mask_path=None, + output_dir=output_dir, + asset_root=root, + ) + + with Image.open(root / assets[0].asset_path).convert("RGBA") as asset_image: + edge_alpha = asset_image.getpixel((0, asset_image.height // 2))[3] + center_alpha = asset_image.getpixel((asset_image.width // 2, asset_image.height // 2))[3] + + self.assertLess(edge_alpha, center_alpha) + self.assertEqual(center_alpha, 255) + self.assertTrue(assets[0].provenance["edge_alpha_feathered"]) + + def test_reconstruction_targets_drop_bitmap_candidates_covered_by_source_native_shapes(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (240, 140), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((30, 20, 130, 70), radius=12, fill="#FEF3C7", outline="#D97706", width=3) + draw.line((150, 110, 220, 80), fill="#7C3AED", width=5) + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="fg-rounded", + source_pixel_bbox=(30, 20, 131, 71), + area=5151, + classification="uncertain", + confidence=0.0, + ), + ForegroundCandidate( + candidate_id="fg-line", + source_pixel_bbox=(148, 78, 223, 113), + area=2625, + classification="uncertain", + confidence=0.0, + ), + ForegroundCandidate( + candidate_id="source-rounded", + source_pixel_bbox=(30, 20, 131, 71), + area=4200, + classification="native_shape_candidate", + confidence=0.94, + provenance={"shape_hint": "rounded_rectangle", "radius": 12}, + ), + ForegroundCandidate( + candidate_id="source-line", + source_pixel_bbox=(148, 78, 223, 113), + area=360, + classification="native_shape_candidate", + confidence=0.93, + provenance={"shape_hint": "line"}, + ), + ], + source_image_path=str(source), + ) + + self.assertEqual([shape.shape_type for shape in native_shapes], ["rounded_rectangle", "line"]) + self.assertEqual(bitmap_candidates, []) + + def test_reconstruction_targets_ignore_full_page_diff_and_tiny_fragments_inside_native_shapes(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (800, 450), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((48, 46, 752, 118), radius=18, fill="#E0F2FE", outline="#0284C7", width=3) + draw.text((82, 66), "Quarterly Plan", fill="#0F172A") + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="full-slide-diff", + source_pixel_bbox=(0, 0, 800, 450), + area=360000, + classification="complex_whole_visual", + confidence=0.86, + ), + ForegroundCandidate( + candidate_id="top-background-band", + source_pixel_bbox=(2, 2, 798, 125), + area=97908, + classification="complex_whole_visual", + confidence=0.86, + ), + ForegroundCandidate( + candidate_id="bottom-background-band", + source_pixel_bbox=(0, 287, 800, 450), + area=130400, + classification="complex_whole_visual", + confidence=0.86, + ), + ForegroundCandidate( + candidate_id="tiny-noise", + source_pixel_bbox=(383, 5, 392, 11), + area=54, + classification="uncertain", + confidence=0.5, + ), + ForegroundCandidate( + candidate_id="text-fragment", + source_pixel_bbox=(82, 68, 89, 77), + area=63, + classification="complex_whole_visual", + confidence=0.86, + ), + ForegroundCandidate( + candidate_id="right-edge-line", + source_pixel_bbox=(798, 47, 799, 89), + area=42, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="bottom-edge-line", + source_pixel_bbox=(206, 449, 347, 450), + area=141, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="left-edge-residual", + source_pixel_bbox=(2, 255, 53, 312), + area=2907, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="bottom-thin-residual", + source_pixel_bbox=(195, 426, 789, 441), + area=7935, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="blank-region-residual", + source_pixel_bbox=(487, 340, 606, 426), + area=962, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="inner-native-fill-residual", + source_pixel_bbox=(709, 51, 716, 57), + area=21, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="source-title-card", + source_pixel_bbox=(48, 46, 753, 119), + area=50000, + classification="native_shape_candidate", + confidence=0.94, + provenance={"shape_hint": "rounded_rectangle", "radius": 18}, + ), + ], + source_image_path=str(source), + ) + + self.assertEqual([shape.shape_type for shape in native_shapes], ["rounded_rectangle"]) + self.assertEqual(bitmap_candidates, []) + + def test_detects_source_native_shape_candidates_when_clean_background_is_unusable(self): + from PIL import Image, ImageDraw + + from src.generative_editable_pipeline import _detect_source_native_shape_candidates + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "mask.png" + image = Image.new("RGB", (220, 140), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.rectangle((30, 40, 120, 90), fill="#2563EB") + draw.rounded_rectangle((30, 12, 120, 30), radius=8, fill="#E0F2FE", outline="#0284C7", width=2) + draw.rounded_rectangle((132, 40, 200, 90), radius=12, fill="#FEF3C7", outline="#D97706", width=2) + draw.line((145, 118, 205, 104), fill="#7C3AED", width=4) + image.save(source) + Image.new("L", (220, 140), 0).save(mask) + + candidates = _detect_source_native_shape_candidates( + source_image_path=str(source), + text_mask_path=str(mask), + ) + + self.assertEqual( + [candidate.provenance.get("shape_hint") for candidate in candidates], + ["rounded_rectangle", "rectangle", "rounded_rectangle", "line"], + ) + + def test_reconstruction_targets_drop_edge_residuals_without_native_shapes(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (800, 450), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.text((72, 46), "Complex Visual", fill="#0F172A") + draw.polygon([(90, 360), (250, 250), (430, 310), (700, 170), (700, 383), (90, 383)], fill="#DBEAFE") + draw.line([(100, 330), (190, 260), (290, 286), (398, 198), (520, 224), (680, 146)], fill="#2563EB", width=4) + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="fg-001", + source_pixel_bbox=(2, 2, 74, 79), + area=3025, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="fg-258", + source_pixel_bbox=(45, 137, 727, 426), + area=77010, + classification="complex_whole_visual", + confidence=0.86, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="fg-283", + source_pixel_bbox=(778, 350, 798, 426), + area=251, + classification="uncertain", + confidence=0.0, + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + self.assertEqual(native_shapes, []) + self.assertEqual([candidate.candidate_id for candidate in bitmap_candidates], ["fg-258"]) + + def test_reconstruction_targets_keep_real_edge_touching_and_large_complex_visuals(self): + from PIL import Image, ImageDraw + + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _plan_reconstruction_targets + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (800, 450), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.rectangle((0, 80, 120, 340), fill="#2563EB") + draw.rectangle((30, 20, 790, 430), outline="#DC2626", width=8) + image.save(source) + + native_shapes, bitmap_candidates = _plan_reconstruction_targets( + [ + ForegroundCandidate( + candidate_id="edge-real", + source_pixel_bbox=(0, 80, 121, 341), + area=31581, + classification="complex_whole_visual", + confidence=0.86, + provenance={"detection": "source_base_difference"}, + ), + ForegroundCandidate( + candidate_id="large-real", + source_pixel_bbox=(30, 20, 790, 430), + area=311600, + classification="complex_whole_visual", + confidence=0.86, + provenance={"detection": "source_base_difference"}, + ), + ], + source_image_path=str(source), + ) + + self.assertEqual(native_shapes, []) + self.assertEqual( + [candidate.candidate_id for candidate in bitmap_candidates], + ["edge-real", "large-real"], + ) + + def test_text_exclusion_mask_adds_local_detected_text_when_ocr_bbox_is_misaligned(self): + from PIL import Image, ImageDraw, ImageFont + + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_pipeline import _create_text_exclusion_mask + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (420, 220), "#0F172A") + draw = ImageDraw.Draw(image) + draw.text((220, 130), "AUDIO", fill="#F8FAFC", font=ImageFont.load_default()) + image.save(source) + + mask = _create_text_exclusion_mask( + source_image_path=source, + source_image_size=image.size, + text_boxes=[ + TextBoxSpec( + text="AUDIO", + source_pixel_bbox=(20, 20, 110, 44), + source_pixel_polygon=((20, 20), (110, 20), (110, 44), (20, 44)), + ) + ], + ) + + self.assertEqual(mask.getpixel((225, 135)), 255) + self.assertEqual(mask.getpixel((24, 24)), 255) + + def test_text_exclusion_mask_does_not_add_unmatched_visual_highlights(self): + from PIL import Image, ImageDraw, ImageFont + + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_pipeline import _create_text_exclusion_mask + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (420, 220), "#0F172A") + draw = ImageDraw.Draw(image) + draw.text((28, 24), "TITLE", fill="#F8FAFC", font=ImageFont.load_default()) + draw.rectangle((250, 130, 360, 165), fill="#38BDF8") + image.save(source) + + mask = _create_text_exclusion_mask( + source_image_path=source, + source_image_size=image.size, + text_boxes=[ + TextBoxSpec( + text="TITLE", + source_pixel_bbox=(28, 24, 80, 42), + source_pixel_polygon=((28, 24), (80, 24), (80, 42), (28, 42)), + ) + ], + ) + + self.assertEqual(mask.getpixel((32, 28)), 255) + self.assertEqual(mask.getpixel((300, 145)), 0) + + def test_pipeline_adds_source_native_shapes_even_when_base_clean_provider_returns_candidates(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + config = load_generative_editable_config(use_fake=True) + + class OneItemOCRProvider(OCRProvider): + def extract_text(self, image_path): + return OCRResult( + source_image_path=image_path, + image_size=(240, 140), + provider_role=self.config.role, + provider_name=self.config.provider, + model=self.config.model, + items=[ + OCRTextItem( + text="Shape test", + bbox=(8, 2, 92, 14), + polygon=((8, 2), (92, 2), (92, 14), (8, 14)), + confidence=0.98, + ) + ], + ) + + def fake_planner(**kwargs): + return [ + ForegroundCandidate( + candidate_id="fg-rounded", + source_pixel_bbox=(30, 20, 131, 71), + area=5151, + classification="uncertain", + confidence=0.0, + ), + ForegroundCandidate( + candidate_id="fg-line", + source_pixel_bbox=(148, 78, 223, 113), + area=2625, + classification="uncertain", + confidence=0.0, + ), + ] + + def accept_bitmap_candidates_without_assets(**kwargs): + return AssetBuildResult(bitmap_assets=[]) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"pptx") + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + image = Image.new("RGB", (240, 140), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((30, 20, 130, 70), radius=12, fill="#FEF3C7", outline="#D97706", width=3) + draw.line((150, 110, 220, 80), fill="#7C3AED", width=5) + image.save(source) + + result = run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=OneItemOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=fake_planner, + asset_builder=accept_bitmap_candidates_without_assets, + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) + + self.assertEqual(result.status, "passed") + self.assertEqual([shape.shape_type for shape in page.native_shapes], ["rounded_rectangle", "line"]) + self.assertEqual(page.bitmap_assets, []) + self.assertEqual( + page.chosen_background, + "backgrounds/0000-slide-a/local-reconstruction-background.png", + ) + + def test_local_reconstruction_background_samples_outside_large_native_shapes(self): + from PIL import Image, ImageDraw + + from src.generative_editable_pipeline import ( + _create_local_reconstruction_background, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "source.png" + output_path = root / "backgrounds" / "local.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (800, 450), "#FFFFFF") + ImageDraw.Draw(image).rectangle((40, 40, 760, 360), fill="#2563EB") + image.save(source) + + result = _create_local_reconstruction_background( + source_image_path=source, + output_asset_path=output_path, + asset_root=root, + foreground_bboxes=[(40, 40, 760, 360)], + ) + with Image.open(output_path) as background: + sampled = background.getpixel((400, 200)) + + self.assertEqual(result.strategy, "local_fill") + self.assertEqual(sampled, (255, 255, 255)) + + def test_default_asset_sheet_falls_back_to_image_generation_when_edit_fails(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ( + ImageEditProvider, + ImageGenerationProvider, + ImageGenerationRequest, + ImageGenerationResult, + ProviderError, + ) + + class FailingAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="failing-edit", + model="edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="asset sheet edit failed", + retryable=False, + ) + + class RecordingGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="image_model", + provider="recording-generation", + model="image", + base_url="", + api_key="", + ) + ) + self.requests: list[ImageGenerationRequest] = [] + + def generate(self, request): + self.requests.append(request) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGBA", (800, 450), (0, 0, 0, 0)) + ImageDraw.Draw(image).ellipse((500, 140, 610, 210), fill=(16, 185, 129, 255)) + image.save(output) + return ImageGenerationResult( + output_asset_path=str(output), + prompt_id=request.prompt_id, + provider_role=self.config.role, + provider_name=self.config.provider, + model=self.config.model, + timeout_seconds=request.timeout_seconds, + visual_reference=dict(request.visual_reference), + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + Image.new("RGB", (800, 450), "white").save(source) + generation_provider = RecordingGenerationProvider() + + result = _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="asset-001", + source_pixel_bbox=(480, 118, 626, 224), + area=15476, + classification="bitmap_asset_candidate", + confidence=0.91, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=generation_provider, + image_edit_provider=FailingAssetSheetEditProvider(), + slide_id="slide-a", + page_index=0, + ) + + self.assertEqual(len(generation_provider.requests), 1) + self.assertEqual(generation_provider.requests[0].prompt_id, "asset_sheet") + self.assertEqual(result.bitmap_assets[0].asset_id, "asset-001") + self.assertEqual(result.asset_sheets[0].provider_role, "image_model") + self.assertEqual(result.asset_sheets[0].provenance["fallback_from"], "image_edit") + + def test_default_asset_sheet_splits_rearranged_sheet_components_not_source_bboxes(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ImageEditProvider + + class RearrangedAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="rearranged-sheet", + model="edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGBA", (120, 80), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((10, 12, 34, 34), fill=(37, 99, 235, 255)) + draw.ellipse((72, 18, 104, 50), fill=(16, 185, 129, 255)) + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (800, 450), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((500, 300, 620, 380), fill=(37, 99, 235)) + draw.ellipse((650, 310, 760, 420), fill=(16, 185, 129)) + image.save(source) + + result = _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="blue-block", + source_pixel_bbox=(500, 300, 621, 381), + area=9801, + classification="bitmap_asset_candidate", + confidence=0.91, + ), + ForegroundCandidate( + candidate_id="green-dot", + source_pixel_bbox=(650, 310, 761, 421), + area=12321, + classification="bitmap_asset_candidate", + confidence=0.91, + ), + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=None, + image_edit_provider=RearrangedAssetSheetEditProvider(), + slide_id="slide-a", + page_index=0, + ) + + asset_paths = [root / asset.asset_path for asset in result.bitmap_assets] + asset_sizes = [] + asset_pixels = [] + for path in asset_paths: + with Image.open(path) as asset_image: + asset_sizes.append(asset_image.size) + asset_pixels.append(asset_image.convert("RGBA").getbbox()) + + self.assertEqual([asset.asset_id for asset in result.bitmap_assets], ["blue-block", "green-dot"]) + self.assertEqual(len(result.asset_sheets), 1) + self.assertTrue(all(size[0] < 80 and size[1] < 80 for size in asset_sizes)) + self.assertTrue(all(bbox is not None for bbox in asset_pixels)) + self.assertNotIn("fallback", result.bitmap_assets[0].provenance) + self.assertEqual(result.bitmap_assets[0].provenance["split_method"], "connected_components") + + def test_default_asset_sheet_batches_large_candidate_sets_before_slicing(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ImageEditProvider + + class BatchLimitedAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="batch-limited-sheet", + model="edit", + base_url="", + api_key="", + ) + ) + self.batch_sizes: list[int] = [] + + def edit(self, request): + candidate_count = len(request.metadata["candidate_boxes"]) + self.batch_sizes.append(candidate_count) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGBA", (420, 90), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + for index in range(min(candidate_count, 6)): + left = 12 + index * 66 + draw.rectangle((left, 16, left + 28, 46), fill=(37, 99, 235, 255)) + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + Image.new("RGB", (900, 500), "white").save(source) + provider = BatchLimitedAssetSheetEditProvider() + + result = _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id=f"asset-{index:03d}", + source_pixel_bbox=(20 + index * 30, 100, 44 + index * 30, 124), + area=576, + classification="bitmap_asset_candidate", + confidence=0.91, + ) + for index in range(7) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=None, + image_edit_provider=provider, + slide_id="slide-a", + page_index=0, + ) + + self.assertEqual(provider.batch_sizes, [6, 1]) + self.assertEqual(len(result.asset_sheets), 2) + self.assertEqual( + [asset.asset_id for asset in result.bitmap_assets], + [f"asset-{index:03d}" for index in range(7)], + ) + self.assertEqual( + [sheet.candidate_ids for sheet in result.asset_sheets], + [[f"asset-{index:03d}" for index in range(6)], ["asset-006"]], + ) + + def test_default_asset_sheet_raises_when_all_providers_fail(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ( + ImageEditProvider, + ImageGenerationProvider, + ProviderError, + ) + + class FailingAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="failing-edit", + model="edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="asset sheet edit failed", + retryable=False, + ) + + class FailingGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="image_model", + provider="failing-generation", + model="image", + base_url="", + api_key="", + ) + ) + + def generate(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="asset sheet generation failed", + retryable=False, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (120, 80), "white") + ImageDraw.Draw(image).rectangle((10, 12, 50, 42), fill="#2563EB") + image.save(source) + + with self.assertRaises(ProviderError): + _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="asset-001", + source_pixel_bbox=(10, 12, 51, 43), + area=1271, + classification="bitmap_asset_candidate", + confidence=0.91, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=FailingGenerationProvider(), + image_edit_provider=FailingAssetSheetEditProvider(), + slide_id="slide-a", + page_index=0, + ) + + def test_explicit_source_crop_fallback_handles_asset_sheet_provider_failure(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ( + ImageEditProvider, + ImageGenerationProvider, + ProviderError, + ) + + class FailingAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="failing-edit", + model="edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="asset sheet edit failed", + retryable=False, + ) + + class FailingGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="image_model", + provider="failing-generation", + model="image", + base_url="", + api_key="", + ) + ) + + def generate(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="asset sheet generation failed", + retryable=False, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (120, 80), "white") + ImageDraw.Draw(image).rectangle((10, 12, 50, 42), fill="#2563EB") + image.save(source) + + result = _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="asset-001", + source_pixel_bbox=(10, 12, 51, 43), + area=1271, + classification="bitmap_asset_candidate", + confidence=0.91, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=FailingGenerationProvider(), + image_edit_provider=FailingAssetSheetEditProvider(), + slide_id="slide-a", + page_index=0, + allow_source_crop_fallback=True, + ) + + asset_path = root / result.bitmap_assets[0].asset_path + self.assertTrue(asset_path.is_file()) + with Image.open(asset_path) as asset: + asset_size = asset.size + + self.assertEqual(result.asset_sheets, []) + self.assertEqual(result.bitmap_assets[0].asset_id, "asset-001") + self.assertEqual(result.bitmap_assets[0].provenance["fallback"], "source_crop_after_asset_sheet_failure") + self.assertEqual(result.provider_output_payload["asset_sheet_fallback"], "source_crops") + self.assertEqual(asset_size, (41, 31)) + + def test_default_asset_sheet_raises_when_provider_writes_invalid_image(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ImageEditProvider, ProviderError + + class CorruptAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="corrupt-edit", + model="edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"not a png") + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (120, 80), "white") + ImageDraw.Draw(image).rectangle((10, 12, 50, 42), fill="#2563EB") + image.save(source) + + with self.assertRaises(ProviderError): + _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="asset-001", + source_pixel_bbox=(10, 12, 51, 43), + area=1271, + classification="bitmap_asset_candidate", + confidence=0.91, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=None, + image_edit_provider=CorruptAssetSheetEditProvider(), + slide_id="slide-a", + page_index=0, + ) + + def test_default_asset_sheet_raises_on_provider_asset_mismatch(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ImageEditProvider, ProviderError + + class WrongAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="wrong-sheet", + model="wrong-sheet", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (800, 450), (0, 0, 0, 0)).save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (800, 450), "white") + ImageDraw.Draw(image).rectangle((80, 172, 279, 301), fill="#DCFCE7", outline="#16A34A", width=3) + image.save(source) + + with self.assertRaises(ProviderError): + _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="asset-001", + source_pixel_bbox=(80, 172, 281, 303), + area=26000, + classification="bitmap_asset_candidate", + confidence=0.91, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=None, + image_edit_provider=WrongAssetSheetEditProvider(), + slide_id="slide-a", + page_index=0, + ) + + def test_complex_whole_visual_uses_masked_source_element_without_provider_redraw(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ImageEditProvider + + class FailingIfCalledAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="should-not-be-called", + model="should-not-be-called", + base_url="", + api_key="", + ) + ) + self.calls = 0 + + def edit(self, request): + self.calls += 1 + raise AssertionError("complex visual should not be redrawn by asset-sheet provider") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (800, 450), "#F8FAFC") + draw = ImageDraw.Draw(image) + draw.polygon([(90, 360), (250, 250), (430, 310), (700, 170), (700, 383), (90, 383)], fill="#DBEAFE") + draw.line([(100, 330), (190, 260), (290, 286), (398, 198), (520, 224), (680, 146)], fill="#2563EB", width=4) + image.save(source) + background = root / "background.png" + Image.new("RGB", (800, 450), "#F8FAFC").save(background) + text_mask = root / "text-mask.png" + mask = Image.new("L", (800, 450), 0) + ImageDraw.Draw(mask).rectangle((120, 180, 300, 230), fill=255) + mask.save(text_mask) + provider = FailingIfCalledAssetSheetEditProvider() + + result = _build_default_assets( + source_image_path=str(source), + background_reference_image_path=str(background), + text_mask_path=str(text_mask), + candidates=[ + ForegroundCandidate( + candidate_id="fg-258", + source_pixel_bbox=(45, 137, 727, 426), + area=77010, + classification="complex_whole_visual", + confidence=0.86, + provenance={"reason": "large_or_visually_complex"}, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=None, + image_edit_provider=provider, + slide_id="slide-a", + page_index=0, + ) + + self.assertEqual(provider.calls, 0) + self.assertEqual([asset.asset_id for asset in result.bitmap_assets], ["fg-258"]) + self.assertEqual(result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element") + self.assertFalse(result.bitmap_assets[0].provenance["text_mask_applied"]) + self.assertFalse(result.bitmap_assets[0].provenance["background_difference_alpha"]) + with Image.open(root / result.bitmap_assets[0].asset_path) as asset_image: + alpha = asset_image.convert("RGBA").getchannel("A") + self.assertEqual(alpha.getextrema(), (255, 255)) + self.assertEqual(result.provider_output_payload["asset_strategy"], "masked_source_elements") + + def test_complex_whole_visual_skips_multi_component_asset_sheet_slicing(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ImageEditProvider + + class MultiComponentAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="multi-component-sheet", + model="multi-component-sheet", + base_url="", + api_key="", + ) + ) + self.calls = 0 + + def edit(self, request): + self.calls += 1 + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGBA", (240, 120), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((24, 36, 84, 84), fill=(37, 99, 235, 255)) + draw.rectangle((148, 36, 208, 84), fill=(16, 185, 129, 255)) + image.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (300, 180), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((40, 60, 100, 108), fill="#2563EB") + draw.rectangle((164, 60, 224, 108), fill="#10B981") + image.save(source) + provider = MultiComponentAssetSheetEditProvider() + + result = _build_default_assets( + source_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="fg-complex", + source_pixel_bbox=(32, 52, 232, 116), + area=8832, + classification="complex_whole_visual", + confidence=0.86, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=None, + image_edit_provider=provider, + slide_id="slide-a", + page_index=0, + ) + + self.assertEqual([asset.asset_id for asset in result.bitmap_assets], ["fg-complex"]) + self.assertEqual(provider.calls, 0) + self.assertEqual(result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element") + + def test_complex_singleton_preservation_keeps_candidate_crop_without_provider_input(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import _build_default_assets + from src.generative_editable_providers import ImageEditProvider + + class RecordingCropAssetSheetEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="crop-sheet", + model="crop-sheet", + base_url="", + api_key="", + ) + ) + self.source_image_paths: list[str] = [] + self.prompts: list[str] = [] + + def edit(self, request): + self.source_image_paths.append(request.source_image_path) + self.prompts.append(request.prompt) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + with Image.open(request.source_image_path) as source: + source.convert("RGBA").save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "prompt_id": request.prompt_id, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (300, 180), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((40, 60, 100, 108), fill="#2563EB") + draw.rectangle((164, 60, 224, 108), fill="#10B981") + image.save(source) + provider = RecordingCropAssetSheetEditProvider() + + result = _build_default_assets( + source_image_path=str(source), + asset_reference_image_path=str(source), + candidates=[ + ForegroundCandidate( + candidate_id="fg-complex", + source_pixel_bbox=(32, 52, 232, 116), + area=8832, + classification="complex_whole_visual", + confidence=0.86, + ) + ], + output_dir=str(root / "assets" / "0000-slide-a"), + asset_root=str(root), + image_generation_provider=None, + image_edit_provider=provider, + slide_id="slide-a", + page_index=0, + ) + crop_path = root / result.bitmap_assets[0].asset_path + with Image.open(crop_path) as crop: + crop_size = crop.size + + self.assertEqual(provider.source_image_paths, []) + self.assertEqual(crop_size, (200, 64)) + self.assertIn("assets", crop_path.parts) + self.assertEqual(result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element") + + def test_orchestration_text_validation_failure_blocks_success(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_page_manifest + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + with self.assertRaises(GenerativeEditableValidationError) as ctx: + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + text_metadata=[ + { + "text": "Unmatched title", + "role": "title", + "order": 99, + } + ], + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + page_manifest = read_page_manifest( + artifact_root / "job-1" / "pages" / "0000-slide-a.json" + ) + + self.assertEqual(page_manifest.validation_status, "failed") + self.assertIn("metadata_unmatched", str(ctx.exception)) + + def test_orchestration_blocks_when_visual_text_candidate_is_not_covered_by_ocr(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_page_manifest + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class TwoDomainOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + if "visual-text-candidate" in image_path: + return OCRResult( + source_image_path=image_path, + image_size=(100, 34), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[], + ) + return OCRResult( + source_image_path=image_path, + image_size=(800, 450), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="智能域", + bbox=(120, 90, 220, 124), + polygon=((120, 90), (220, 90), (220, 124), (120, 124)), + confidence=0.95, + ), + OCRTextItem( + text="动力域", + bbox=(120, 310, 220, 344), + polygon=((120, 310), (220, 310), (220, 344), (120, 344)), + confidence=0.95, + ), + ], + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + with self.assertRaises(GenerativeEditableValidationError) as ctx: + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=TwoDomainOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + visual_text_candidate_detector=lambda **kwargs: [ + (120, 90, 220, 124), + (120, 200, 220, 234), + (120, 310, 220, 344), + ], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + page_manifest = read_page_manifest( + artifact_root / "job-1" / "pages" / "0000-slide-a.json" + ) + + self.assertEqual(page_manifest.validation_status, "failed") + self.assertIn("visual_text_candidate_missing_ocr_text", str(ctx.exception)) + self.assertEqual( + page_manifest.provenance["text_issues"][0]["code"], + "visual_text_candidate_missing_ocr_text", + ) + + def test_orchestration_recovers_visual_text_candidate_with_focused_crop_ocr(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_page_manifest + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class RecoveringOCRProvider(OCRProvider): + def extract_text(self, image_path: str) -> OCRResult: + if "visual-text-candidate" in image_path: + return OCRResult( + source_image_path=image_path, + image_size=(100, 34), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="底盘域", + bbox=(0, 0, 100, 34), + polygon=((0, 0), (100, 0), (100, 34), (0, 34)), + confidence=0.96, + ) + ], + ) + return OCRResult( + source_image_path=image_path, + image_size=(800, 450), + provider_role="ocr_model", + provider_name="test_ocr", + model="test", + items=[ + OCRTextItem( + text="智能域", + bbox=(120, 90, 220, 124), + polygon=((120, 90), (220, 90), (220, 124), (120, 124)), + confidence=0.95, + ), + OCRTextItem( + text="动力域", + bbox=(120, 310, 220, 344), + polygon=((120, 310), (220, 310), (220, 344), (120, 344)), + confidence=0.95, + ), + ], + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=RecoveringOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + visual_text_candidate_detector=lambda **kwargs: [ + (120, 90, 220, 124), + (120, 200, 220, 234), + (120, 310, 220, 344), + ], + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + page_manifest = read_page_manifest( + artifact_root / "job-1" / "pages" / "0000-slide-a.json" + ) + + self.assertEqual(page_manifest.validation_status, "passed") + self.assertEqual([box.text for box in page_manifest.text_boxes], ["智能域", "动力域", "底盘域"]) + self.assertEqual(page_manifest.text_boxes[-1].source_pixel_bbox, (120, 200, 220, 234)) + self.assertEqual(page_manifest.provenance["text_issues"], []) + + def test_deck_manifest_records_configured_preview_similarity_threshold(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import read_deck_manifest + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + preview_similarity_threshold=0.87, + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + + self.assertEqual(deck.quality_settings["preview_similarity_threshold"], 0.87) + + def test_pipeline_passes_configured_provider_timeout_to_background_edits(self): + from PIL import Image, ImageDraw + + from src.generative_editable_config import ProviderConfig, load_generative_editable_config + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageGenerationProvider, + ImageEditProvider, + ImageEditResult, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class LargeMaskOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="large-mask-ocr", + model="ocr", + base_url="", + api_key="", + ) + ) + + def extract_text(self, image_path): + return OCRResult( + source_image_path=image_path, + image_size=(160, 90), + provider_role=self.config.role, + provider_name=self.config.provider, + model=self.config.model, + items=[ + OCRTextItem( + text="Title", + bbox=(20, 10, 145, 72), + polygon=((20, 10), (145, 10), (145, 72), (20, 72)), + confidence=0.99, + ) + ], + ) + + class RecordingEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="recording-edit", + model="edit", + base_url="", + api_key="", + ) + ) + self.calls = [] + + def edit(self, request): + self.calls.append(request) + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + Image.open(request.source_image_path).save(output) + return ImageEditResult( + output_asset_path=str(output), + source_image_path=request.source_image_path, + prompt_id=request.prompt_id, + provider_role=self.config.role, + provider_name=self.config.provider, + model=self.config.model, + timeout_seconds=request.timeout_seconds, + mask_path=request.mask_path, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + image = Image.new("RGB", (160, 90), "#FFFFFF") + draw = ImageDraw.Draw(image) + for x in range(160): + for y in range(90): + draw.point((x, y), fill=((x * 7) % 255, (y * 11) % 255, (x + y) % 255)) + image.save(source_path) + edit_provider = RecordingEditProvider() + + run_generative_editable_pipeline( + slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source_path))], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=LargeMaskOCRProvider(), + image_edit_provider=edit_provider, + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + provider_timeout_seconds=37, + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + timeouts = {call.prompt_id: call.timeout_seconds for call in edit_provider.calls} + self.assertEqual(timeouts["text_clean_background"], 37) + self.assertEqual(timeouts["base_clean_background"], 37) + + def test_explicit_fallback_handles_orchestration_exception(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + config = load_generative_editable_config(use_fake=True) + + def failing_composer(deck_manifest_path, artifact_root, output_path): + raise RuntimeError("composer unavailable") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + fallback_path = Path(tmp) / "fallback.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + fallback_policy="raster_pptx", + fallback_output_factory=lambda: fallback_path.write_bytes(b"raster") and str( + fallback_path + ), + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + composer=failing_composer, + ), + ) + + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.output_path, str(fallback_path)) + self.assertIn("pipeline_orchestration_failed", str(result.validation_report.issues[0])) + + def test_explicit_fallback_handles_provider_error(self): + from PIL import Image + + from src.generative_editable_config import ProviderConfig, load_generative_editable_config + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + OCRProvider, + ImageEditProvider, + ProviderError, + ) + + class FailingOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="failing-ocr", + model="ocr", + base_url="", + api_key="", + ) + ) + + def extract_text(self, image_path): + raise ProviderError( + provider_role=self.config.role, + operation="extract_text", + message="ocr unavailable", + retryable=False, + ) + + config = load_generative_editable_config(use_fake=True) + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + fallback_path = Path(tmp) / "fallback.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + fallback_policy="raster_pptx", + fallback_output_factory=lambda: fallback_path.write_bytes(b"raster") and str( + fallback_path + ), + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FailingOCRProvider(), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + ), + ) + + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.output_path, str(fallback_path)) + self.assertEqual(result.validation_report.issues[0].code, "provider_failure") + + class FailingAssetSheetProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="asset_sheet_model", + provider="failing-asset-sheet", + model="asset-sheet", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="asset sheet unavailable", + retryable=False, + ) + + def fake_planner(**kwargs): + return [ + ForegroundCandidate( + candidate_id="fg-001", + source_pixel_bbox=(100, 100, 180, 180), + area=6400, + classification="bitmap_asset_candidate", + confidence=0.92, + ) + ] + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "asset-source.png" + output_path = Path(tmp) / "asset-deck.pptx" + fallback_path = Path(tmp) / "asset-fallback.pptx" + artifact_root = Path(tmp) / "asset-jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-asset-fallback", + fallback_policy="raster_pptx", + fallback_output_factory=lambda: fallback_path.write_bytes(b"raster") and str( + fallback_path + ), + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + asset_sheet_image_edit_provider=FailingAssetSheetProvider(), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + foreground_planner=fake_planner, + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.output_path, str(fallback_path)) + self.assertEqual(result.validation_report.issues[0].code, "provider_failure") + + def test_metadata_first_pipeline_does_not_hide_ocr_provider_failure_by_default(self): + from PIL import Image + + from src.generative_editable_config import ProviderConfig, load_generative_editable_config + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + ProviderError, + ) + + class FailingOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="failing-ocr", + model="ocr", + base_url="", + api_key="", + ) + ) + + def extract_text(self, image_path): + raise ProviderError( + provider_role=self.config.role, + operation="extract_text", + message="ocr returned unstructured text", + retryable=False, + ) + + config = load_generative_editable_config(use_fake=True) + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + with self.assertRaises(ProviderError): + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + text_metadata=[ + { + "text": "Metadata Title", + "role": "title", + "order": 1, + "style_hint": {"font_size": 32}, + } + ], + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FailingOCRProvider(), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + def test_explicit_metadata_layout_fallback_can_be_used_outside_fail_policy(self): + from PIL import Image + + from src.generative_editable_config import ProviderConfig, load_generative_editable_config + from src.generative_editable_manifest import read_page_manifest + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + ProviderError, + ) + + class FailingOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="failing-ocr", + model="ocr", + base_url="", + api_key="", + ) + ) + + def extract_text(self, image_path): + raise ProviderError( + provider_role=self.config.role, + operation="extract_text", + message="ocr returned unstructured text", + retryable=False, + ) + + config = load_generative_editable_config(use_fake=True) + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + text_metadata=[ + { + "text": "Metadata Title", + "role": "title", + "order": 1, + "style_hint": {"font_size": 32}, + } + ], + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + fallback_policy="text_editable_background", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FailingOCRProvider(), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + allow_metadata_ocr_fallback=True, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + page = read_page_manifest(artifact_root / "job-1" / "pages" / "0000-slide-a.json") + + self.assertEqual(result.status, "passed") + self.assertEqual(page.text_boxes[0].text, "Metadata Title") + self.assertEqual(page.text_boxes[0].provenance["ocr_provenance"]["fallback"], "ocr_provider_failed") + + def test_pipeline_uses_configured_ocr_min_confidence(self): + from PIL import Image + + from src.generative_editable_config import ProviderConfig, load_generative_editable_config + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, + run_generative_editable_pipeline, + ) + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + OCRProvider, + OCRResult, + OCRTextItem, + ) + + class LowConfidenceOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="low-confidence-ocr", + model="ocr", + base_url="", + api_key="", + ) + ) + + def extract_text(self, image_path): + return OCRResult( + source_image_path=image_path, + image_size=(800, 450), + provider_role=self.config.role, + provider_name=self.config.provider, + model=self.config.model, + items=[ + OCRTextItem( + text="Low Confidence", + bbox=(80, 60, 280, 110), + polygon=((80, 60), (280, 60), (280, 110), (80, 110)), + confidence=0.8, + ) + ], + ) + + config = load_generative_editable_config(use_fake=True) + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + with self.assertRaises(GenerativeEditableValidationError) as ctx: + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=LowConfidenceOCRProvider(), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ocr_min_confidence=0.95, + ), + ) + + self.assertIn("low_ocr_confidence", str(ctx.exception)) + + def test_multi_slide_orchestration_preserves_order_and_aggregates_validation_and_repairs(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_manifest import ( + BitmapAssetSpec, + RepairAttempt, + read_deck_manifest, + read_page_manifest, + ) + from src.generative_editable_pipeline import ( + AssetBuildResult, + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + config = load_generative_editable_config(use_fake=True) + active_pages = 0 + max_active_pages = 0 + processed_slide_ids = [] + lock = threading.Lock() + + def fake_asset_builder(**kwargs): + nonlocal active_pages, max_active_pages + with lock: + active_pages += 1 + max_active_pages = max(max_active_pages, active_pages) + processed_slide_ids.append(kwargs["slide_id"]) + time.sleep(0.05) + asset = BitmapAssetSpec( + asset_id=f"{kwargs['slide_id']}-asset", + source_pixel_bbox=(500, 120, 620, 220), + asset_path=f"assets/{kwargs['page_index']:04d}-{kwargs['slide_id']}/fg-001.png", + z_order=1, + ) + Path(kwargs["asset_root"], asset.asset_path).parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (120, 100), (0, 0, 0, 0)).save( + Path(kwargs["asset_root"], asset.asset_path) + ) + repair_attempts = [] + if kwargs["slide_id"] == "slide-b": + repair_attempts.append( + RepairAttempt( + target_id=asset.asset_id, + attempt_index=1, + reason="edge_touch", + provider_role="edit_model", + status="passed", + ) + ) + with lock: + active_pages -= 1 + return AssetBuildResult( + bitmap_assets=[asset], + repair_attempts=repair_attempts, + provider_output_payload={"candidate_ids": ["should-not-override"]}, + ) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + deck = read_deck_manifest(deck_manifest_path) + self.assertEqual(deck.slide_order, ["slide-b", "slide-a"]) + self.assertEqual( + deck.page_manifest_paths, + ["pages/0000-slide-b.json", "pages/0001-slide-a.json"], + ) + Path(output_path).write_bytes(b"fake-pptx") + + def fake_preview_validator(**kwargs): + if kwargs["slide_id"] == "slide-a": + return ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_similarity_failed", + message="slide-a differs", + slide_id="slide-a", + ) + ], + ) + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + with tempfile.TemporaryDirectory() as tmp: + slide_b = Path(tmp) / "slide-b.png" + slide_a = Path(tmp) / "slide-a.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(slide_b) + Image.new("RGB", (800, 450), "white").save(slide_a) + + with self.assertRaises(GenerativeEditableValidationError) as ctx: + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-b", + image_path=str(slide_b), + ), + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(slide_a), + ), + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + max_page_concurrency=2, + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + asset_builder=fake_asset_builder, + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=fake_preview_validator, + ), + ) + + deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") + page_b = read_page_manifest(artifact_root / "job-1" / "pages" / "0000-slide-b.json") + page_a = read_page_manifest(artifact_root / "job-1" / "pages" / "0001-slide-a.json") + + self.assertLessEqual(max_active_pages, 2) + self.assertGreater(max_active_pages, 1) + self.assertCountEqual(processed_slide_ids, ["slide-b", "slide-a"]) + self.assertEqual(deck.validation_status, "failed") + self.assertEqual(deck.provenance["max_page_concurrency"], 2) + self.assertEqual(page_b.validation_status, "passed") + self.assertEqual(page_a.validation_status, "failed") + self.assertEqual(page_b.repair_attempts[0].reason, "edge_touch") + self.assertEqual( + page_b.provider_output_paths["image_generation"], + "provider_outputs/image_generation/0000-slide-b/assets.json", + ) + image_generation_payload = ( + artifact_root + / "job-1" + / page_b.provider_output_paths["image_generation"] + ).read_text(encoding="utf-8") + self.assertIn("slide-b-asset", image_generation_payload) + self.assertNotIn("should-not-override", image_generation_payload) + self.assertIn("preview_similarity_failed", str(ctx.exception)) + self.assertEqual(ctx.exception.validation_report.checked_pages, 2) + + def test_cleanup_artifacts_runs_after_validation_failure_when_requested(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + GenerativeEditableValidationError, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + with self.assertRaises(GenerativeEditableValidationError): + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + cleanup_artifacts=True, + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_similarity_failed", + message="differs", + slide_id="slide-a", + ) + ], + ), + ), + ) + + self.assertFalse((artifact_root / "job-1").exists()) + self.assertTrue(output_path.exists()) + + def test_cleanup_rejects_output_inside_job_artifacts(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + artifact_root = Path(tmp) / "jobs" + output_path = artifact_root / "job-1" / "deck.pptx" + Image.new("RGB", (800, 450), "white").save(source_path) + + with self.assertRaisesRegex(ValueError, "output_path must not be inside"): + run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + cleanup_artifacts=True, + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + def test_successful_pipeline_can_cleanup_job_artifacts_without_removing_output(self): + from PIL import Image + + from src.generative_editable_config import load_generative_editable_config + from src.generative_editable_pipeline import ( + GenerativeEditablePipelineDependencies, + GenerativeEditableSlideInput, + run_generative_editable_pipeline, + ) + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ) + + config = load_generative_editable_config(use_fake=True) + + def fake_composer(deck_manifest_path, artifact_root, output_path): + Path(output_path).write_bytes(b"fake-pptx") + + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "deck.pptx" + artifact_root = Path(tmp) / "jobs" + Image.new("RGB", (800, 450), "white").save(source_path) + + result = run_generative_editable_pipeline( + slides=[ + GenerativeEditableSlideInput( + slide_id="slide-a", + image_path=str(source_path), + ) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id="job-1", + cleanup_artifacts=True, + dependencies=GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), + composer=fake_composer, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + self.assertEqual(result.status, "passed") + self.assertTrue(output_path.exists()) + self.assertFalse((artifact_root / "job-1").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_preview_validator.py b/tests/test_generative_editable_preview_validator.py new file mode 100644 index 0000000..8b1b1c1 --- /dev/null +++ b/tests/test_generative_editable_preview_validator.py @@ -0,0 +1,1404 @@ +import tempfile +import unittest +import shutil +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from PIL import Image, ImageDraw +from pptx import Presentation +from pptx.util import Inches + +from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + NativeShapeSpec, + PageManifest, + TextBoxSpec, + write_manifest, +) + + +class GenerativeEditablePreviewValidatorTest(unittest.TestCase): + def test_renders_deterministic_manifest_preview_stub_from_fake_assets(self): + from src.generative_editable_preview_validator import ( + render_manifest_preview, + render_manifest_preview_with_metadata, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "backgrounds" / "0000-slide-a").mkdir(parents=True) + (root / "assets" / "0000-slide-a").mkdir(parents=True) + Image.new("RGB", (800, 450), "#FFFFFF").save( + root / "backgrounds" / "0000-slide-a" / "base.png" + ) + Image.new("RGBA", (80, 60), (16, 185, 129, 255)).save( + root / "assets" / "0000-slide-a" / "asset.png" + ) + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/0000-slide-a/source.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + chosen_background="backgrounds/0000-slide-a/base.png", + native_shapes=[ + NativeShapeSpec( + shape_type="rectangle", + source_pixel_bbox=(80, 80, 240, 140), + fill_color="#2563EB", + ), + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(280, 80, 380, 84), + line_color="#111827", + line_start=(280, 82), + line_end=(380, 82), + stroke_width=4, + ) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="asset", + source_pixel_bbox=(300, 90, 380, 150), + asset_path="assets/0000-slide-a/asset.png", + z_order=1, + ) + ], + text_boxes=[ + TextBoxSpec( + text="Preview", + source_pixel_bbox=(100, 210, 260, 250), + source_pixel_polygon=((100, 210), (260, 210), (260, 250), (100, 250)), + ) + ], + ) + + preview_one = render_manifest_preview(page, root, output_size=(800, 450)) + preview_two = render_manifest_preview(page, root, output_size=(800, 450)) + preview_result = render_manifest_preview_with_metadata(page, root, output_size=(800, 450)) + + self.assertEqual(preview_one.size, (800, 450)) + self.assertEqual(preview_one.tobytes(), preview_two.tobytes()) + self.assertEqual(preview_one.getpixel((100, 100)), (37, 99, 235)) + self.assertEqual(preview_one.getpixel((320, 110)), (16, 185, 129)) + text_region = preview_one.crop((100, 210, 260, 250)) + histogram = text_region.convert("L").histogram() + self.assertGreater(sum(histogram[:255]), 0) + self.assertEqual(preview_result.metadata["renderer"], "manifest_stub") + self.assertFalse(preview_result.metadata["is_powerpoint_render"]) + + def test_structural_validation_passes_for_composed_manifest_deck(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + deck, _ = self._write_validation_fixture(root) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + self.assertEqual(report.checked_pages, len(deck.slide_order)) + self.assertEqual( + report.to_dict(), + { + "status": "passed", + "checked_pages": 1, + "issues": [], + }, + ) + + def test_structural_validation_reports_wrong_slide_background_identity(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + Image.new("RGB", (800, 450), "#111827").save( + root / "backgrounds" / "0000-slide-a" / "base.png" + ) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "failed") + background_issues = [ + issue for issue in report.issues + if issue.code == "object_identity_mismatch" + and issue.details["target_kind"] == "background" + ] + self.assertEqual(len(background_issues), 1) + self.assertNotEqual( + background_issues[0].details["expected_sha1"], + background_issues[0].details["actual_sha1"], + ) + + def test_structural_validation_accepts_negative_slope_native_line_bbox(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + page = replace( + page, + native_shapes=[ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(572, 186, 720, 295), + line_color="#7C3AED", + line_start=(572, 290), + line_end=(719, 190), + stroke_width=13, + ) + ], + bitmap_assets=[], + text_boxes=[], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + output = root / "negative-line.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + + def test_structural_validation_reports_page_count_and_dimension_failures(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + wrong = Presentation() + wrong.slide_width = Inches(10) + wrong.slide_height = Inches(7.5) + wrong.slides.add_slide(wrong.slide_layouts[6]) + wrong.slides.add_slide(wrong.slide_layouts[6]) + output = root / "wrong.pptx" + wrong.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "failed") + self.assertIn("page_count_mismatch", [issue.code for issue in report.issues]) + self.assertIn("slide_dimensions_mismatch", [issue.code for issue in report.issues]) + + def test_structural_validation_reports_missing_assets_and_required_text(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + missing_text_page = PageManifest( + **{ + **page.__dict__, + "bitmap_assets": [ + BitmapAssetSpec( + asset_id="missing", + source_pixel_bbox=(300, 90, 380, 150), + asset_path="assets/0000-slide-a/missing.png", + z_order=1, + ) + ], + "text_boxes": [ + TextBoxSpec( + text="Missing Required Text", + source_pixel_bbox=(100, 210, 330, 250), + source_pixel_polygon=((100, 210), (330, 210), (330, 250), (100, 250)), + ) + ], + } + ) + write_manifest(root / "pages" / "0000-slide-a.json", missing_text_page) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + codes = [issue.code for issue in report.issues] + self.assertIn("missing_asset", codes) + self.assertIn("missing_required_text", codes) + failed_payload = report.to_dict() + self.assertEqual(failed_payload["status"], "failed") + self.assertEqual(failed_payload["checked_pages"], 1) + text_issue = next(issue for issue in failed_payload["issues"] if issue["code"] == "missing_required_text") + self.assertEqual(text_issue["severity"], "error") + self.assertEqual(text_issue["slide_id"], "slide-a") + self.assertEqual(text_issue["details"]["target_kind"], "text") + self.assertEqual(text_issue["details"]["page_index"], 0) + + def test_structural_validation_rejects_source_crop_bitmap_asset_provenance(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + unsafe_page = replace( + page, + bitmap_assets=[ + replace( + page.bitmap_assets[0], + provenance={ + "fallback": "source_crop_after_asset_sheet_failure", + "failure": "asset sheet cannot be sliced", + }, + ) + ], + ) + write_manifest(root / "pages" / "0000-slide-a.json", unsafe_page) + output = root / "source-crop-asset.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "failed") + issue = next( + issue + for issue in report.issues + if issue.code == "forbidden_source_crop_bitmap_asset" + ) + self.assertEqual(issue.details["target_kind"], "bitmap_asset") + self.assertEqual(issue.details["target_id"], "asset") + self.assertEqual(issue.details["asset_ref"], "assets/0000-slide-a/asset.png") + + def test_structural_validation_reports_unsafe_full_slide_source_with_text(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + unsafe_page = PageManifest( + **{ + **page.__dict__, + "provenance": {"chosen_background_kind": "source_full_slide"}, + } + ) + write_manifest(root / "pages" / "0000-slide-a.json", unsafe_page) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + self.assertIn("baked text", report.issues[0].message) + + def test_structural_validation_rejects_source_background_without_editable_structure(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_dir = root / "sources" / "0000-slide-a" + source_dir.mkdir(parents=True, exist_ok=True) + source = source_dir / "source.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + source_background = root / "backgrounds" / "0000-slide-a" / "source-copy.png" + source_background.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, source_background) + source_page = replace( + page, + source_image_path="sources/0000-slide-a/source.png", + chosen_background="backgrounds/0000-slide-a/source-copy.png", + text_clean_background="backgrounds/0000-slide-a/source-copy.png", + base_clean_background="backgrounds/0000-slide-a/source-copy.png", + native_shapes=[], + bitmap_assets=[], + text_boxes=[], + ) + write_manifest(root / "pages" / "0000-slide-a.json", source_page) + output = root / "source-background-only.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "failed") + issue = next(issue for issue in report.issues if issue.code == "full_slide_source_background_only") + self.assertEqual(issue.details["target_kind"], "background") + self.assertEqual(issue.details["editable_object_count"], 0) + + def test_structural_validation_reports_full_slide_source_with_approximate_text(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + approximate_page = replace( + page, + provenance={ + **page.provenance, + "chosen_background_kind": "source_full_slide", + "reconstruction_mode": "source_visual_background", + }, + text_boxes=[ + replace( + text_box, + style_hints={**text_box.style_hints, "approximate_layout": True}, + ) + for text_box in page.text_boxes + ], + ) + write_manifest(root / "pages" / "0000-slide-a.json", approximate_page) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_rejects_declared_low_opacity_source_overlay(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_dir = root / "sources" / "0000-slide-a" + source_dir.mkdir(parents=True, exist_ok=True) + source = source_dir / "source.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + background = root / "backgrounds" / "0000-slide-a" / "source-preserving.png" + background.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (800, 450), "#FFFFFF").save(background) + overlay_page = replace( + page, + source_image_path="sources/0000-slide-a/source.png", + chosen_background="backgrounds/0000-slide-a/source-preserving.png", + text_clean_background="backgrounds/0000-slide-a/source-preserving.png", + base_clean_background="backgrounds/0000-slide-a/source-preserving.png", + text_boxes=[replace(text_box, opacity=0.1) for text_box in page.text_boxes], + provenance={ + **page.provenance, + "chosen_background_kind": "source_preserving_low_opacity_text_overlay", + "text_overlay_opacity": 0.1, + }, + ) + write_manifest(root / "pages" / "0000-slide-a.json", overlay_page) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_detects_actual_full_slide_source_picture_with_text(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_dir = root / "sources" / "0000-slide-a" + source_dir.mkdir(parents=True, exist_ok=True) + source = source_dir / "source.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + source_page = replace(page, source_image_path="sources/0000-slide-a/source.png") + write_manifest(root / "pages" / "0000-slide-a.json", source_page) + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture(str(source), 0, 0, width=prs.slide_width, height=prs.slide_height) + text = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(0.5)) + text.text_frame.text = "Preview" + output = root / "unsafe.pptx" + prs.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_detects_near_source_full_slide_background(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_dir = root / "sources" / "0000-slide-a" + source_dir.mkdir(parents=True, exist_ok=True) + source = source_dir / "source.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + near_source = root / "backgrounds" / "0000-slide-a" / "near-source.png" + Image.new("RGB", (800, 450), "#FEFEFE").save(near_source) + source_page = replace( + page, + source_image_path="sources/0000-slide-a/source.png", + chosen_background="backgrounds/0000-slide-a/near-source.png", + ) + write_manifest(root / "pages" / "0000-slide-a.json", source_page) + output = root / "out.pptx" + from src.generative_editable_composer import compose_deck_from_manifests + + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_detects_resized_source_full_slide_background(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_dir = root / "sources" / "0000-slide-a" + source_dir.mkdir(parents=True, exist_ok=True) + source = source_dir / "source.png" + source_image = Image.new("RGB", (800, 450), "#FFFFFF") + ImageDraw.Draw(source_image).text((100, 120), "Preview", fill="#111827") + source_image.save(source) + resized = root / "backgrounds" / "0000-slide-a" / "resized-source.png" + source_image.resize((1600, 900)).save(resized) + source_page = replace( + page, + source_image_path="sources/0000-slide-a/source.png", + chosen_background="backgrounds/0000-slide-a/resized-source.png", + ) + write_manifest(root / "pages" / "0000-slide-a.json", source_page) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_allows_text_clean_local_background_with_editable_text(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_dir = root / "sources" / "0000-slide-a" + source_dir.mkdir(parents=True, exist_ok=True) + source = source_dir / "source.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + cleaned = root / "backgrounds" / "0000-slide-a" / "text-clean.png" + Image.new("RGB", (800, 450), "#F0F0F0").save(cleaned) + source_page = replace( + page, + source_image_path="sources/0000-slide-a/source.png", + text_clean_background="backgrounds/0000-slide-a/text-clean.png", + base_clean_background="backgrounds/0000-slide-a/text-clean.png", + chosen_background="backgrounds/0000-slide-a/text-clean.png", + provenance={ + "backgrounds": { + "base_clean": { + "prompt_id": "local_text_cleanup", + "provider_role": "local", + } + } + }, + ) + write_manifest(root / "pages" / "0000-slide-a.json", source_page) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertNotIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_reports_object_order_failures(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + wrong = Presentation() + wrong.slide_width = Inches(10) + wrong.slide_height = Inches(5.625) + slide = wrong.slides.add_slide(wrong.slide_layouts[6]) + text = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(0.5)) + text.text_frame.text = "Preview" + slide.shapes.add_picture(str(root / "backgrounds" / "0000-slide-a" / "base.png"), 0, 0) + output = root / "wrong-order.pptx" + wrong.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("object_order_mismatch", [issue.code for issue in report.issues]) + + def test_structural_validation_reports_extra_unmanifested_relevant_objects(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + (root / "sources" / "0000-slide-a" / "source.png").unlink() + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + prs = Presentation(str(output)) + slide = prs.slides[0] + slide.shapes.add_picture( + str(root / "assets" / "0000-slide-a" / "asset.png"), + Inches(8), + Inches(4), + width=Inches(1), + height=Inches(1), + ) + prs.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("object_order_mismatch", [issue.code for issue in report.issues]) + + def test_structural_validation_reports_wrong_bitmap_asset_identity_at_expected_position(self): + from src.generative_editable_composer import _set_slide_picture_background + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + wrong_asset = root / "assets" / "0000-slide-a" / "wrong.png" + Image.new("RGBA", (80, 60), (249, 115, 22, 255)).save(wrong_asset) + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + _set_slide_picture_background(slide, root / "backgrounds" / "0000-slide-a" / "base.png") + slide.shapes.add_shape(1, Inches(1), Inches(1), Inches(2), Inches(1)) + slide.shapes.add_connector(1, Inches(3.5), Inches(1.0125), Inches(4.75), Inches(1.0125)) + slide.shapes.add_picture(str(wrong_asset), Inches(3.75), Inches(1.125), width=Inches(1), height=Inches(0.75)) + text = slide.shapes.add_textbox(Inches(1.25), Inches(2.625), Inches(2), Inches(0.5)) + text.text_frame.text = "Preview" + output = root / "wrong-identity.pptx" + prs.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("object_identity_mismatch", [issue.code for issue in report.issues]) + + def test_structural_validation_rejects_text_clean_background_that_matches_source(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + text_clean = root / "backgrounds" / "0000-slide-a" / "text-clean.png" + with Image.open(root / page.source_image_path) as source: + source.save(text_clean) + page = replace( + page, + chosen_background="backgrounds/0000-slide-a/text-clean.png", + text_clean_background="backgrounds/0000-slide-a/text-clean.png", + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + output = root / "source-as-text-clean.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_allows_sparse_text_clean_region(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source = root / page.source_image_path + cleaned = root / "backgrounds" / "0000-slide-a" / "text-clean.png" + mask = root / "assets" / "0000-slide-a" / "text-mask.png" + mask.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source) as source_image: + source_with_text = source_image.convert("RGB") + for x in range(100, 140): + for y in range(210, 216): + source_with_text.putpixel((x, y), (17, 24, 39)) + source_with_text.save(source) + cleaned_image = source_with_text.copy() + for x in range(100, 140): + for y in range(210, 216): + cleaned_image.putpixel((x, y), (248, 250, 252)) + cleaned_image.save(cleaned) + mask_image = Image.new("L", (800, 450), 0) + for x in range(96, 265): + for y in range(206, 254): + mask_image.putpixel((x, y), 255) + mask_image.save(mask) + page = replace( + page, + chosen_background="backgrounds/0000-slide-a/text-clean.png", + text_clean_background="backgrounds/0000-slide-a/text-clean.png", + provenance={ + **page.provenance, + "text_mask_path": "assets/0000-slide-a/text-mask.png", + "backgrounds": { + "base_clean": { + "output_asset_ref": "backgrounds/0000-slide-a/text-clean.png", + "prompt_id": "local_text_cleanup", + "provider_role": "local", + } + }, + }, + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + output = root / "sparse-text-clean.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertNotIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_allows_source_preserving_background_when_text_region_changed(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source = root / page.source_image_path + cleaned = root / "backgrounds" / "0000-slide-a" / "source-preserving.png" + mask = root / "assets" / "0000-slide-a" / "text-mask.png" + mask.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source) as source_image: + source_with_text = source_image.convert("RGB") + for x in range(100, 140): + for y in range(210, 216): + source_with_text.putpixel((x, y), (17, 24, 39)) + source_with_text.save(source) + cleaned_image = source_with_text.copy() + for x in range(100, 140): + for y in range(210, 216): + cleaned_image.putpixel((x, y), (248, 250, 252)) + cleaned_image.save(cleaned) + mask_image = Image.new("L", (800, 450), 0) + for x in range(96, 265): + for y in range(206, 254): + mask_image.putpixel((x, y), 255) + mask_image.save(mask) + page = replace( + page, + chosen_background="backgrounds/0000-slide-a/source-preserving.png", + provenance={ + **page.provenance, + "chosen_background_kind": "source_preserving_text_clean", + "text_mask_path": "assets/0000-slide-a/text-mask.png", + "backgrounds": { + "source_preserving": { + "output_asset_ref": "backgrounds/0000-slide-a/source-preserving.png", + "prompt_id": "source_preserving_text_background", + "provider_role": "local", + } + }, + }, + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + output = root / "source-preserving.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertNotIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + + def test_structural_validation_reports_wrong_native_shape_type_at_expected_position(self): + from src.generative_editable_composer import _set_slide_picture_background + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + page = replace( + page, + native_shapes=[ + NativeShapeSpec( + shape_type="ellipse", + source_pixel_bbox=(80, 80, 240, 140), + fill_color="#2563EB", + ) + ], + bitmap_assets=[], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + _set_slide_picture_background(slide, root / "backgrounds" / "0000-slide-a" / "base.png") + slide.shapes.add_shape(1, Inches(1), Inches(1), Inches(2), Inches(0.75)) + text = slide.shapes.add_textbox(Inches(1.25), Inches(2.625), Inches(2), Inches(0.5)) + text.text_frame.text = "Preview" + output = root / "wrong-native-shape.pptx" + prs.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("object_identity_mismatch", [issue.code for issue in report.issues]) + + def test_structural_validation_rejects_selectable_background_picture(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture( + str(root / "backgrounds" / "0000-slide-a" / "base.png"), + Inches(0.5), + Inches(0), + width=Inches(9.5), + height=prs.slide_height, + ) + slide.shapes.add_shape(1, Inches(1), Inches(1), Inches(2), Inches(0.75)) + slide.shapes.add_connector(1, Inches(3.5), Inches(1.0125), Inches(4.75), Inches(1.0125)) + slide.shapes.add_picture( + str(root / "assets" / "0000-slide-a" / "asset.png"), + Inches(3.75), + Inches(1.125), + width=Inches(1), + height=Inches(0.75), + ) + text = slide.shapes.add_textbox(Inches(1.25), Inches(2.625), Inches(2), Inches(0.5)) + text.text_frame.text = "Preview" + output = root / "wrong-background-geometry.pptx" + prs.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("object_order_mismatch", [issue.code for issue in report.issues]) + issue = next(issue for issue in report.issues if issue.code == "object_order_mismatch") + self.assertEqual(issue.details["target_kind"], "slide_object_order") + + def test_structural_validation_uses_deck_aspect_ratio_for_equivalent_wide_slide_sizes(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + write_manifest( + root / "pages" / "0000-slide-a.json", + replace(page, slide_size=(13.333, 7.5)), + ) + output = root / "wide-16-9.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "passed") + + def test_structural_validation_counts_required_text_occurrences_exactly(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + page = replace( + page, + text_boxes=[ + TextBoxSpec( + text="Plan", + source_pixel_bbox=(100, 210, 170, 250), + source_pixel_polygon=((100, 210), (170, 210), (170, 250), (100, 250)), + ), + TextBoxSpec( + text="Plan", + source_pixel_bbox=(180, 210, 250, 250), + source_pixel_polygon=((180, 210), (250, 210), (250, 250), (180, 250)), + ), + ], + native_shapes=[], + bitmap_assets=[], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture( + str(root / "backgrounds" / "0000-slide-a" / "base.png"), + 0, + 0, + width=prs.slide_width, + height=prs.slide_height, + ) + text = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(0.5)) + text.text_frame.text = "Planning" + output = root / "text-mismatch.pptx" + prs.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + issues = [issue for issue in report.issues if issue.code == "missing_required_text"] + self.assertEqual(len(issues), 2) + self.assertEqual(issues[0].details["target_index"], 0) + self.assertEqual(issues[1].details["target_index"], 1) + + def test_structural_validation_reports_required_text_position_mismatch(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + page = replace(page, native_shapes=[], bitmap_assets=[]) + write_manifest(root / "pages" / "0000-slide-a.json", page) + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture( + str(root / "backgrounds" / "0000-slide-a" / "base.png"), + 0, + 0, + width=prs.slide_width, + height=prs.slide_height, + ) + text = slide.shapes.add_textbox(Inches(7), Inches(4), Inches(2), Inches(0.5)) + text.text_frame.text = "Preview" + output = root / "text-wrong-position.pptx" + prs.save(output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("text_position_mismatch", [issue.code for issue in report.issues]) + + def test_structural_validation_allows_expanded_approximate_text_width(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + page = replace( + page, + native_shapes=[], + bitmap_assets=[], + text_boxes=[ + replace( + page.text_boxes[0], + text="EMB", + source_pixel_bbox=(100, 210, 112, 226), + source_pixel_polygon=((100, 210), (112, 210), (112, 226), (100, 226)), + font_size=8, + style_hints={"approximate_layout": True}, + ) + ], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + output = root / "expanded-approx-text.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertNotIn("text_position_mismatch", [issue.code for issue in report.issues]) + + def test_structural_validation_reports_invalid_pptx_and_missing_page_manifest_as_issues(self): + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + (root / "pages" / "0000-slide-a.json").unlink() + invalid = root / "invalid.pptx" + invalid.write_text("not a pptx", encoding="utf-8") + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=invalid, + ) + + codes = [issue.code for issue in report.issues] + self.assertIn("invalid_pptx", codes) + self.assertIn("missing_page_manifest", codes) + + def test_structural_validation_reports_missing_source_asset(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_validation_fixture(root) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + (root / "sources" / "0000-slide-a" / "source.png").unlink() + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("missing_asset", [issue.code for issue in report.issues]) + source_issue = next(issue for issue in report.issues if issue.details.get("target_kind") == "source") + self.assertFalse(source_issue.details["repairable"]) + + def test_structural_validation_reports_empty_source_asset_path(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + write_manifest(root / "pages" / "0000-slide-a.json", replace(page, source_image_path="")) + output = root / "out.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertIn("invalid_asset_path", [issue.code for issue in report.issues]) + source_issue = next(issue for issue in report.issues if issue.details.get("target_kind") == "source") + self.assertEqual(source_issue.details["asset_ref"], "") + + def test_preview_similarity_passes_for_deterministic_matching_fixture_images(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (120, 80), "#F8FAFC").save(source) + preview = Image.new("RGB", (120, 80), "#F8FAFC") + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=preview, + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=1.0, + max_changed_pixel_ratio=0.01, + ) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + + def test_preview_similarity_fails_when_fixture_images_exceed_threshold(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (120, 80), "#F8FAFC").save(source) + preview = Image.new("RGB", (120, 80), "#0F172A") + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=preview, + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=1.0, + max_changed_pixel_ratio=0.01, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "preview_similarity_failed") + self.assertGreater(report.issues[0].details["mean_abs_delta"], 1.0) + self.assertGreater(report.issues[0].details["changed_pixel_ratio"], 0.01) + + def test_preview_similarity_rejects_manifest_stub_as_real_powerpoint_quality_gate(self): + from src.generative_editable_preview_validator import ( + render_manifest_preview_with_metadata, + validate_preview_similarity, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source = root / "sources" / "0000-slide-a" / "source.png" + source.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + preview = render_manifest_preview_with_metadata(page, root, output_size=(800, 450)) + + report = validate_preview_similarity( + source_image_path=source, + preview=preview, + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=12.0, + max_changed_pixel_ratio=0.10, + require_powerpoint_render=True, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "preview_renderer_not_powerpoint") + + @unittest.skipIf( + not shutil.which("soffice") or not shutil.which("pdftoppm"), + "PowerPoint preview render tools are unavailable", + ) + def test_powerpoint_preview_renderer_returns_real_render_metadata(self): + from src.generative_editable_preview_validator import ( + render_powerpoint_preview_with_metadata, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + pptx_path = root / "deck.pptx" + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture( + str(root / page.chosen_background), + 0, + 0, + width=prs.slide_width, + height=prs.slide_height, + ) + prs.save(pptx_path) + + preview = render_powerpoint_preview_with_metadata( + page, + root, + pptx_path=pptx_path, + output_size=(800, 450), + ) + + self.assertEqual(preview.image.size, (800, 450)) + self.assertTrue(preview.metadata["is_powerpoint_render"]) + self.assertEqual(preview.metadata["renderer"], "soffice_pdf_pdftoppm") + + def test_powerpoint_preview_renderer_falls_back_when_renderer_times_out(self): + from subprocess import TimeoutExpired + + from src.generative_editable_preview_validator import ( + render_powerpoint_preview_with_metadata, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + pptx_path = root / "deck.pptx" + pptx_path.write_bytes(b"not a real pptx") + with patch("src.generative_editable_preview_validator.shutil.which") as which: + which.side_effect = lambda name: f"/usr/bin/{name}" + with patch("src.generative_editable_preview_validator.subprocess.run") as run: + run.side_effect = TimeoutExpired(cmd="soffice", timeout=30) + preview = render_powerpoint_preview_with_metadata( + page, + root, + pptx_path=pptx_path, + output_size=(800, 450), + ) + + self.assertFalse(preview.metadata["is_powerpoint_render"]) + self.assertEqual(preview.metadata["renderer"], "manifest_stub") + self.assertIn("timeout", run.call_args.kwargs) + + def test_preview_similarity_rejects_manifest_stub_even_if_metadata_claims_powerpoint(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (120, 80), "#F8FAFC").save(source) + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=Image.new("RGB", (120, 80), "#F8FAFC"), + metadata={"renderer": "manifest_stub", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=1.0, + max_changed_pixel_ratio=0.01, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "preview_renderer_not_powerpoint") + + def test_preview_similarity_reports_wrong_aspect_preview_without_resizing(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (120, 80), "#F8FAFC").save(source) + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=Image.new("RGB", (120, 120), "#F8FAFC"), + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=1.0, + max_changed_pixel_ratio=0.01, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "preview_dimensions_mismatch") + + def test_preview_similarity_returns_report_for_invalid_preview_image(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + class BrokenPreview: + size = (120, 80) + + def convert(self, mode): + raise OSError("broken preview") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (120, 80), "#F8FAFC").save(source) + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=BrokenPreview(), + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=1.0, + max_changed_pixel_ratio=0.01, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "invalid_preview_image") + + def test_preview_similarity_caps_source_and_preview_pixel_counts_before_comparison(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (11, 10), "#F8FAFC").save(source) + + with patch("src.generative_editable_preview_validator.MAX_PREVIEW_PIXELS", 100): + source_report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=Image.new("RGB", (10, 10), "#F8FAFC"), + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=1.0, + max_changed_pixel_ratio=0.01, + ) + Image.new("RGB", (10, 10), "#F8FAFC").save(source) + preview_report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=Image.new("RGB", (11, 10), "#F8FAFC"), + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=1.0, + max_changed_pixel_ratio=0.01, + ) + + self.assertEqual(source_report.issues[0].code, "preview_too_large") + self.assertEqual(preview_report.issues[0].code, "preview_too_large") + + def test_quality_similarity_threshold_maps_to_delta_gate_thresholds(self): + from src.generative_editable_preview_validator import quality_threshold_to_preview_gates + + strict = quality_threshold_to_preview_gates(0.95) + loose = quality_threshold_to_preview_gates(0.80) + + self.assertLess(strict.max_mean_abs_delta, loose.max_mean_abs_delta) + self.assertLess(strict.max_changed_pixel_ratio, loose.max_changed_pixel_ratio) + + def test_quality_similarity_threshold_allows_render_edge_headroom(self): + from src.generative_editable_preview_validator import quality_threshold_to_preview_gates + + gates = quality_threshold_to_preview_gates(0.92) + + self.assertEqual(gates.max_mean_abs_delta, 20.4) + self.assertEqual(gates.max_changed_pixel_ratio, 0.132) + + def _write_validation_fixture(self, root: Path): + (root / "backgrounds" / "0000-slide-a").mkdir(parents=True) + (root / "assets" / "0000-slide-a").mkdir(parents=True) + (root / "sources" / "0000-slide-a").mkdir(parents=True) + Image.new("RGB", (800, 450), "#F8FAFC").save( + root / "sources" / "0000-slide-a" / "source.png" + ) + Image.new("RGB", (800, 450), "#FFFFFF").save( + root / "backgrounds" / "0000-slide-a" / "base.png" + ) + Image.new("RGBA", (80, 60), (16, 185, 129, 255)).save( + root / "assets" / "0000-slide-a" / "asset.png" + ) + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/0000-slide-a/source.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + chosen_background="backgrounds/0000-slide-a/base.png", + native_shapes=[ + NativeShapeSpec( + shape_type="rectangle", + source_pixel_bbox=(80, 80, 240, 140), + fill_color="#2563EB", + ), + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(280, 80, 380, 83), + line_color="#111827", + line_start=(280, 81), + line_end=(380, 81), + stroke_width=3, + ) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="asset", + source_pixel_bbox=(300, 90, 380, 150), + asset_path="assets/0000-slide-a/asset.png", + z_order=1, + ) + ], + text_boxes=[ + TextBoxSpec( + text="Preview", + source_pixel_bbox=(100, 210, 260, 250), + source_pixel_polygon=((100, 210), (260, 210), (260, 250), (100, 250)), + ) + ], + ) + deck = DeckManifest( + job_id="job-001", + slide_order=["slide-a"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-a.json"], + ) + write_manifest(root / "pages" / "0000-slide-a.json", page) + write_manifest(root / "deck.json", deck) + return deck, page + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_providers.py b/tests/test_generative_editable_providers.py new file mode 100644 index 0000000..2bbf98f --- /dev/null +++ b/tests/test_generative_editable_providers.py @@ -0,0 +1,1019 @@ +import base64 +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +from src.generative_editable_config import ProviderConfig +from src.generative_editable_providers import ( + FakeOCRProvider, + FakeImageEditProvider, + FakeImageGenerationProvider, + ImageGenerationProvider, + ImageGenerationRequest, + ImageEditProvider, + ImageEditRequest, + OCRProvider, + OCRTextItem, + ProviderError, + ProviderTimeoutError, + RetryingOCRProvider, + safe_provider_error_message, +) +from src.generative_editable_providers import _extract_ocr_text_items +from src.generative_editable_providers import _DetectedTextLine, _match_detected_lines_to_text + + +def _pixel_sha256(path: Path) -> str: + with Image.open(path) as image: + return hashlib.sha256(image.tobytes()).hexdigest() + + +class OCRProviderContractTest(unittest.TestCase): + def test_fake_ocr_provider_returns_text_layout_style_and_provenance(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + provider = FakeOCRProvider( + ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="fake-ocr", + base_url="", + api_key="", + ) + ) + + result = provider.extract_text(str(image_path)) + + self.assertEqual(result.source_image_path, str(image_path)) + self.assertEqual(result.image_size, (800, 450)) + self.assertEqual(result.provider_role, "ocr_model") + self.assertEqual(result.provider_name, "fake_ocr") + self.assertGreaterEqual(len(result.items), 1) + + item = result.items[0] + self.assertEqual(item.text, "Quarterly Plan") + self.assertEqual(item.bbox, (80, 54, 420, 102)) + self.assertEqual( + item.polygon, + ((80, 54), (420, 54), (420, 102), (80, 102)), + ) + self.assertGreaterEqual(item.confidence, 0.9) + self.assertEqual(item.font_family_hint, "Arial") + self.assertEqual(item.font_size_hint, 32) + self.assertEqual(item.style_hints["weight"], "bold") + self.assertFalse(item.style_hints["italic"]) + self.assertEqual(item.color_hex, "#1F2937") + self.assertEqual(item.alignment, "center") + self.assertEqual(item.provenance["provider_role"], "ocr_model") + self.assertEqual(item.provenance["model"], "fake-ocr") + self.assertIn("item_id", item.provenance) + + def test_ocr_text_item_validates_geometry_confidence_and_alignment(self): + with self.assertRaisesRegex(ValueError, "bbox"): + OCRTextItem( + text="bad box", + bbox=(10, 10, 5, 20), + polygon=((10, 10), (5, 10), (5, 20), (10, 20)), + confidence=0.9, + ) + + with self.assertRaisesRegex(ValueError, "confidence"): + OCRTextItem( + text="bad confidence", + bbox=(10, 10, 50, 20), + polygon=((10, 10), (50, 10), (50, 20), (10, 20)), + confidence=1.5, + ) + + with self.assertRaisesRegex(ValueError, "alignment"): + OCRTextItem( + text="bad alignment", + bbox=(10, 10, 50, 20), + polygon=((10, 10), (50, 10), (50, 20), (10, 20)), + confidence=0.9, + alignment="diagonal", + ) + + def test_ocr_provider_interface_requires_extract_text(self): + provider = OCRProvider( + ProviderConfig( + role="ocr_model", + provider="base", + model="base", + base_url="", + api_key="", + ) + ) + + with self.assertRaises(NotImplementedError): + provider.extract_text("slide.png") + + def test_openai_chat_ocr_uses_crop_prompt_for_focused_recovery_images(self): + from unittest.mock import patch + + from src.generative_editable_providers import OpenAIChatOCRProvider + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + crop_dir = root / "focused-crops" + crop_dir.mkdir() + image_path = crop_dir / "visual-text-candidate-004.png" + Image.new("RGB", (752, 66), "#020813").save(image_path) + payloads = [] + provider = OpenAIChatOCRProvider( + ProviderConfig( + role="ocr_model", + provider="openai_chat", + model="PaddlePaddle/PaddleOCR-VL-1.5", + base_url="https://example.invalid/v1", + api_key="secret", + ) + ) + + def fake_post(config, payload, operation, timeout_seconds): + payloads.append(payload) + return {"choices": [{"message": {"content": "全周期安全管理与OTA进化能力,让车辆越用越新。"}}]} + + with patch("src.generative_editable_providers._post_openai_chat", side_effect=fake_post): + result = provider.extract_text(str(image_path)) + + prompt = payloads[0]["messages"][0]["content"][0]["text"] + self.assertIn("cropped", prompt) + self.assertIn("literal visible text", prompt) + self.assertEqual(result.items[0].text, "全周期安全管理与OTA进化能力,让车辆越用越新。") + + def test_ocr_parser_accepts_safe_json_scalar_as_single_text_item(self): + config = ProviderConfig( + role="ocr_model", + provider="openai_chat", + model="ocr-test", + base_url="https://example.test/v1", + api_key="secret", + ) + + items = _extract_ocr_text_items( + {"choices": [{"message": {"content": "1"}}]}, + config, + (800, 450), + ) + + self.assertEqual(items[0].text, "1") + self.assertEqual(items[0].provenance["fallback"], "plain_text_ocr") + + def test_ocr_parser_rejects_non_json_prose_instead_of_turning_it_into_slide_text(self): + config = ProviderConfig( + role="ocr_model", + provider="openai_chat", + model="ocr-test", + base_url="https://example.test/v1", + api_key="secret", + ) + + with self.assertRaisesRegex(ProviderError, "valid JSON"): + _extract_ocr_text_items( + {"choices": [{"message": {"content": "I cannot extract text from this image."}}]}, + config, + (800, 450), + ) + + def test_ocr_parser_rejects_json_scalar_refusal(self): + config = ProviderConfig( + role="ocr_model", + provider="openai_chat", + model="ocr-test", + base_url="https://example.test/v1", + api_key="secret", + ) + + with self.assertRaisesRegex(ProviderError, "object or array"): + _extract_ocr_text_items( + {"choices": [{"message": {"content": "\"I cannot extract text from this image.\""}}]}, + config, + (800, 450), + ) + + def test_text_line_matching_removes_fragments_inside_matched_title(self): + detected = [ + _DetectedTextLine((347, 51, 1330, 111), "#FFFFFF", 20.0, 12), + _DetectedTextLine((429, 52, 527, 84), "#FFFFFF", 11.0, 2), + _DetectedTextLine((286, 247, 391, 282), "#2E89F4", 12.0, 3), + _DetectedTextLine((1291, 208, 1406, 230), "#DADCDF", 8.0, 4), + ] + + matched = _match_detected_lines_to_text( + ["核心架构设计:增程、底盘与智能域", "智能域", "29英寸6K屏"], + detected, + (1672, 941), + ) + + self.assertEqual(matched[0][0], (347, 51, 1330, 111)) + self.assertEqual(matched[1][0], (286, 247, 391, 282)) + self.assertEqual(matched[2][0], (1291, 208, 1406, 230)) + + def test_text_line_matching_prefers_slide_label_and_right_parameter_regions(self): + detected = [ + _DetectedTextLine((347, 51, 1330, 111), "#FFFFFF", 20.0, 12), + # Distractor from a screen/photo with similar visual width to the domain label. + _DetectedTextLine((534, 192, 814, 264), "#8AB2E3", 24.8, 1), + # Tiny blue product-detail fragment that should not win the domain label. + _DetectedTextLine((539, 195, 566, 204), "#386CB4", 8.0, 1), + # Correct left-side blue domain label. + _DetectedTextLine((286, 247, 391, 282), "#2E89F4", 12.0, 3), + # Distractors from product imagery. + _DetectedTextLine((799, 193, 823, 206), "#5988BF", 8.0, 1), + _DetectedTextLine((916, 216, 936, 235), "#A6ACB4", 8.0, 1), + _DetectedTextLine((1038, 217, 1074, 245), "#8C94A1", 9.6, 1), + # Correct right-side parameter rows. + _DetectedTextLine((1292, 240, 1448, 263), "#DADCDF", 8.0, 6), + _DetectedTextLine((1292, 273, 1404, 295), "#DCE0E2", 8.0, 5), + _DetectedTextLine((1291, 306, 1377, 328), "#D2D5DA", 8.0, 4), + ] + + matched = _match_detected_lines_to_text( + ["核心架构设计:增程、底盘与智能域", "智能域", "空气悬架", "EMB"], + detected, + (1672, 941), + ) + + self.assertEqual(matched[1][0], (286, 247, 391, 282)) + self.assertGreaterEqual(matched[2][0][0], 1200) + self.assertGreaterEqual(matched[3][0][0], 1200) + + def test_domain_label_matching_rejects_tiny_product_detail_fragments(self): + detected = [ + _DetectedTextLine((539, 195, 566, 204), "#386CB4", 8.0, 1), + _DetectedTextLine((286, 247, 391, 282), "#2E89F4", 12.0, 3), + ] + + matched = _match_detected_lines_to_text(["智能域"], detected, (1672, 941)) + + self.assertEqual(matched[0][0], (286, 247, 391, 282)) + + def test_domain_label_matching_prefers_full_left_label_over_inner_fragment(self): + detected = [ + _DetectedTextLine((286, 247, 391, 282), "#2E89F4", 12.0, 5), + _DetectedTextLine((303, 247, 355, 265), "#2E88F2", 8.0, 2), + _DetectedTextLine((274, 747, 379, 782), "#3188F5", 12.0, 4), + ] + + matched = _match_detected_lines_to_text(["智能域", "动力域"], detected, (1672, 941)) + + self.assertEqual(matched[0][0], (286, 247, 391, 282)) + self.assertEqual(matched[1][0], (274, 747, 379, 782)) + + def test_parameter_matching_rejects_middle_visual_fragments_when_right_column_exists(self): + detected = [ + # Speaker/product fragments from the middle of the slide. They can + # look text-like and have widths close to the OCR text. + _DetectedTextLine((1026, 210, 1164, 237), "#969CA7", 9.3, 4), + _DetectedTextLine((1132, 223, 1150, 240), "#AAACB1", 8.0, 2), + _DetectedTextLine((1532, 284, 1576, 365), "#5876AE", 8.0, 1), + # Correct right-column text candidates. + _DetectedTextLine((1292, 240, 1448, 263), "#DADCDF", 8.0, 12), + _DetectedTextLine((1292, 575, 1340, 595), "#CBD1DA", 8.0, 8), + ] + + matched = _match_detected_lines_to_text( + ["21英寸4K后舱屏", "EMB"], + detected, + (1672, 941), + ) + + self.assertEqual(matched[0][0], (1292, 240, 1448, 263)) + self.assertEqual(matched[1][0], (1292, 575, 1340, 595)) + + def test_matching_removes_overlapping_fragments_after_right_column_line_match(self): + detected = [ + _DetectedTextLine((1291, 306, 1377, 328), "#D2D5DA", 8.0, 7), + _DetectedTextLine((1313, 313, 1378, 328), "#C7CAD1", 8.0, 7), + _DetectedTextLine((1250, 440, 1380, 467), "#D4D9DF", 8.0, 9), + ] + + matched = _match_detected_lines_to_text( + ["理想同学", "线控转向"], + detected, + (1672, 941), + ) + + self.assertEqual(matched[0][0], (1291, 306, 1377, 328)) + self.assertEqual(matched[1][0], (1250, 440, 1380, 467)) + + def test_right_column_parameter_list_preserves_vertical_reading_order(self): + detected = [ + _DetectedTextLine((1250, 440, 1380, 467), "#D4D9DF", 8.0, 9), + _DetectedTextLine((1291, 473, 1380, 497), "#CED4D9", 8.0, 10), + _DetectedTextLine((1292, 507, 1380, 530), "#D5D9DF", 8.0, 9), + ] + + matched = _match_detected_lines_to_text( + ["线控转向", "后轮转向", "空气悬架"], + detected, + (1672, 941), + ) + + self.assertEqual([item[0] for item in matched], [line.bbox for line in detected]) + + +class ImageEditProviderContractTest(unittest.TestCase): + def test_fake_image_edit_provider_records_request_and_writes_output_asset(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + source_path = tmp_path / "source.png" + mask_path = tmp_path / "mask.png" + output_path = tmp_path / "edited.png" + Image.new("RGB", (640, 360), "navy").save(source_path) + Image.new("L", (640, 360), 0).save(mask_path) + provider = FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + request = ImageEditRequest( + source_image_path=str(source_path), + prompt_id="text_clean_background", + prompt="Remove baked text only.", + output_asset_path=str(output_path), + asset_root=str(tmp_path), + mask_path=str(mask_path), + timeout_seconds=42, + metadata={ + "page_index": 0, + "model": "forged-model", + "api_key": "metadata-secret", + "details": {"api_key": "nested-secret", "safe": "ok"}, + "items": [{"token": "nested-token", "name": "chart"}], + }, + ) + + result = provider.edit(request) + + self.assertEqual(result.output_asset_path, str(output_path.resolve())) + self.assertEqual(result.provider_role, "edit_model") + self.assertEqual(result.prompt_id, "text_clean_background") + self.assertEqual(result.source_image_path, str(source_path)) + self.assertEqual(result.mask_path, str(mask_path)) + self.assertEqual(result.timeout_seconds, 42) + self.assertEqual(result.provenance["model"], "fake-image-edit") + self.assertEqual(result.provenance["metadata"]["page_index"], 0) + self.assertNotIn("api_key", result.provenance["metadata"]) + self.assertEqual(result.provenance["metadata"]["details"]["safe"], "ok") + self.assertNotIn("api_key", result.provenance["metadata"]["details"]) + self.assertEqual(result.provenance["metadata"]["items"][0]["name"], "chart") + self.assertNotIn("token", result.provenance["metadata"]["items"][0]) + self.assertNotIn("nested-secret", repr(result.provenance)) + self.assertNotIn("nested-token", repr(result.provenance)) + self.assertNotEqual(result.provenance["model"], "forged-model") + self.assertTrue(output_path.exists()) + with Image.open(output_path) as image: + self.assertEqual(image.size, (640, 360)) + + def test_image_edit_request_requires_source_prompt_timeout_and_output(self): + with self.assertRaisesRegex(ValueError, "source_image_path"): + ImageEditRequest( + source_image_path="", + prompt_id="clean", + prompt="Clean", + output_asset_path="out.png", + asset_root=".", + ) + + with self.assertRaisesRegex(ValueError, "prompt_id"): + ImageEditRequest( + source_image_path="source.png", + prompt_id="", + prompt="Clean", + output_asset_path="out.png", + asset_root=".", + ) + + with self.assertRaisesRegex(ValueError, "prompt"): + ImageEditRequest( + source_image_path="source.png", + prompt_id="clean", + prompt="", + output_asset_path="out.png", + asset_root=".", + ) + + with self.assertRaisesRegex(ValueError, "output_asset_path"): + ImageEditRequest( + source_image_path="source.png", + prompt_id="clean", + prompt="Clean", + output_asset_path="", + asset_root=".", + ) + + with self.assertRaisesRegex(ValueError, "timeout_seconds"): + ImageEditRequest( + source_image_path="source.png", + prompt_id="clean", + prompt="Clean", + output_asset_path="out.png", + asset_root=".", + timeout_seconds=0, + ) + + def test_image_edit_request_rejects_output_outside_asset_root(self): + with tempfile.TemporaryDirectory() as tmp: + outside = Path(tmp).parent / "outside.png" + + with self.assertRaisesRegex(ValueError, "asset_root"): + ImageEditRequest( + source_image_path="source.png", + prompt_id="clean", + prompt="Clean", + output_asset_path=str(outside), + asset_root=tmp, + ) + + def test_image_edit_request_rejects_source_and_mask_outside_asset_root(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as outside_tmp: + root = Path(tmp) + source = root / "source.png" + mask = root / "mask.png" + source.write_bytes(b"source") + mask.write_bytes(b"mask") + outside_source = Path(outside_tmp) / "source.png" + outside_mask = Path(outside_tmp) / "mask.png" + outside_source.write_bytes(b"outside-source") + outside_mask.write_bytes(b"outside-mask") + + with self.assertRaisesRegex(ValueError, "source_image_path"): + ImageEditRequest( + source_image_path=str(outside_source), + prompt_id="clean", + prompt="Clean", + output_asset_path=str(root / "out.png"), + asset_root=str(root), + mask_path=str(mask), + ) + with self.assertRaisesRegex(ValueError, "mask_path"): + ImageEditRequest( + source_image_path=str(source), + prompt_id="clean", + prompt="Clean", + output_asset_path=str(root / "out.png"), + asset_root=str(root), + mask_path=str(outside_mask), + ) + + def test_image_edit_provider_interface_requires_edit(self): + provider = ImageEditProvider( + ProviderConfig( + role="edit_model", + provider="base", + model="base", + base_url="", + api_key="", + ) + ) + request = ImageEditRequest( + source_image_path="source.png", + prompt_id="clean", + prompt="Clean", + output_asset_path="out.png", + asset_root=".", + ) + + with self.assertRaises(NotImplementedError): + provider.edit(request) + + +class ImageGenerationProviderContractTest(unittest.TestCase): + def test_fake_image_generation_provider_records_reference_metadata_and_writes_asset(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + output_path = Path(tmp) / "asset.png" + provider = FakeImageGenerationProvider( + ProviderConfig( + role="image_model", + provider="fake_image_generation", + model="fake-image-generation", + base_url="", + api_key="", + ) + ) + request = ImageGenerationRequest( + prompt_id="asset_sheet", + prompt="Generate isolated chart icons.", + output_asset_path=str(output_path), + asset_root=str(tmp_path), + visual_reference={ + "source_image_path": "slide.png", + "candidate_boxes": [(10, 20, 110, 120)], + "mode": "transparent_asset_sheet", + }, + timeout_seconds=55, + metadata={ + "page_index": 2, + "model": "forged-model", + "client_secret": "metadata-secret", + "details": {"clientSecret": "nested-secret", "safe": "ok"}, + }, + ) + + result = provider.generate(request) + + self.assertEqual(result.output_asset_path, str(output_path.resolve())) + self.assertEqual(result.provider_role, "image_model") + self.assertEqual(result.prompt_id, "asset_sheet") + self.assertEqual(result.visual_reference["mode"], "transparent_asset_sheet") + self.assertEqual(result.timeout_seconds, 55) + self.assertEqual(result.provenance["model"], "fake-image-generation") + self.assertEqual(result.provenance["metadata"]["page_index"], 2) + self.assertNotIn("client_secret", result.provenance["metadata"]) + self.assertEqual(result.provenance["metadata"]["details"]["safe"], "ok") + self.assertNotIn("clientSecret", result.provenance["metadata"]["details"]) + self.assertNotIn("nested-secret", repr(result.provenance)) + self.assertNotEqual(result.provenance["model"], "forged-model") + self.assertTrue(output_path.exists()) + with Image.open(output_path) as image: + self.assertEqual(image.size, (256, 256)) + + def test_image_generation_request_requires_prompt_timeout_and_output(self): + with self.assertRaisesRegex(ValueError, "prompt_id"): + ImageGenerationRequest( + prompt_id="", + prompt="Generate", + output_asset_path="out.png", + asset_root=".", + ) + + with self.assertRaisesRegex(ValueError, "prompt"): + ImageGenerationRequest( + prompt_id="asset", + prompt="", + output_asset_path="out.png", + asset_root=".", + ) + + with self.assertRaisesRegex(ValueError, "output_asset_path"): + ImageGenerationRequest( + prompt_id="asset", + prompt="Generate", + output_asset_path="", + asset_root=".", + ) + + with self.assertRaisesRegex(ValueError, "timeout_seconds"): + ImageGenerationRequest( + prompt_id="asset", + prompt="Generate", + output_asset_path="out.png", + asset_root=".", + timeout_seconds=-1, + ) + + def test_image_generation_request_rejects_output_outside_asset_root(self): + with tempfile.TemporaryDirectory() as tmp: + outside = Path(tmp).parent / "outside.png" + + with self.assertRaisesRegex(ValueError, "asset_root"): + ImageGenerationRequest( + prompt_id="asset", + prompt="Generate", + output_asset_path=str(outside), + asset_root=tmp, + ) + + def test_image_generation_provider_interface_requires_generate(self): + provider = ImageGenerationProvider( + ProviderConfig( + role="image_model", + provider="base", + model="base", + base_url="", + api_key="", + ) + ) + request = ImageGenerationRequest( + prompt_id="asset", + prompt="Generate", + output_asset_path="out.png", + asset_root=".", + ) + + with self.assertRaises(NotImplementedError): + provider.generate(request) + + def test_openai_chat_image_generation_sends_visual_reference_source_image(self): + from unittest.mock import patch + + from src.generative_editable_providers import OpenAIChatImageGenerationProvider + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + output = root / "out.png" + Image.new("RGB", (16, 16), "#F8FAFC").save(source) + Image.new("RGB", (16, 16), "#FFFFFF").save(output) + payloads = [] + provider = OpenAIChatImageGenerationProvider( + ProviderConfig( + role="image_model", + provider="openai_chat", + model="image-model", + base_url="https://example.invalid/v1", + api_key="secret", + ) + ) + + def fake_post(config, payload, operation, timeout_seconds): + payloads.append(payload) + image_base64 = base64.b64encode(output.read_bytes()).decode() + return {"data": [{"url": f"data:image/png;base64,{image_base64}"}]} + + with patch("src.generative_editable_providers._post_openai_chat", side_effect=fake_post): + provider.generate( + ImageGenerationRequest( + prompt_id="asset_sheet", + prompt="Generate assets.", + output_asset_path=str(output), + asset_root=str(root), + visual_reference={"source_image_path": str(source)}, + ) + ) + + content = payloads[0]["messages"][0]["content"] + self.assertIsInstance(content, list) + self.assertEqual(content[0]["type"], "image_url") + self.assertEqual(content[0]["image_url"]["detail"], "high") + self.assertTrue(content[0]["image_url"]["url"].startswith("data:image/png;base64,")) + self.assertEqual(content[-1]["type"], "text") + + +class ProviderErrorHandlingTest(unittest.TestCase): + def test_openai_chat_post_uses_json_payload_for_gateway_compatibility(self): + from unittest.mock import patch + + from src.generative_editable_providers import _post_openai_chat + + class FakeResponse: + text = "{}" + + def raise_for_status(self): + return None + + def json(self): + return {"ok": True} + + config = ProviderConfig( + role="VLM", + provider="openai_chat", + model="gpt-5.5", + base_url="https://example.invalid/v1", + api_key="secret", + ) + payload = {"model": "gpt-5.5", "messages": [{"role": "user", "content": "OK"}]} + + with patch("src.generative_editable_providers.requests.post", return_value=FakeResponse()) as post: + result = _post_openai_chat( + config, + payload, + operation="vlm_page_analysis", + timeout_seconds=30, + ) + + self.assertEqual(result, {"ok": True}) + _, kwargs = post.call_args + self.assertEqual(kwargs["json"], payload) + self.assertNotIn("data", kwargs) + json.dumps(kwargs["json"]) + + def test_provider_timeout_error_is_retryable_and_secret_safe(self): + error = ProviderTimeoutError( + provider_role="ocr_model", + operation="extract_text", + message="timeout calling https://ocr.example/api with sk-live-secret", + retryable=True, + timeout_seconds=30, + secret_values=["sk-live-secret"], + ) + + self.assertTrue(error.retryable) + self.assertEqual(error.provider_role, "ocr_model") + self.assertEqual(error.operation, "extract_text") + self.assertEqual(error.timeout_seconds, 30) + self.assertNotIn("sk-live-secret", str(error)) + self.assertIn("[REDACTED]", str(error)) + + def test_provider_error_distinguishes_retryable_and_non_retryable(self): + retryable = ProviderError( + provider_role="image_model", + operation="generate", + message="rate limited", + retryable=True, + ) + non_retryable = ProviderError( + provider_role="edit_model", + operation="edit", + message="bad request", + retryable=False, + ) + + self.assertTrue(retryable.retryable) + self.assertFalse(non_retryable.retryable) + self.assertIn("image_model", str(retryable)) + self.assertIn("generate", str(retryable)) + + def test_provider_error_code_is_constrained_before_reporting(self): + error = ProviderError( + provider_role="VLM", + operation="vlm_page_analysis", + message="bad gateway", + retryable=True, + provider_error_code="insufficient_user_quota https://secret.example/path api_key=secret", + ) + + self.assertEqual(error.provider_error_code, "insufficient_user_quota_URL_REDACTED_api_key_REDACTED") + self.assertNotIn("://", error.provider_error_code) + self.assertNotIn("=", error.provider_error_code) + self.assertNotIn("secret", error.provider_error_code) + + def test_retrying_ocr_provider_retries_retryable_provider_errors(self): + class FlakyOCRProvider(OCRProvider): + def __init__(self, config): + super().__init__(config) + self.calls = 0 + + def extract_text(self, image_path: str): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="temporary failure", + retryable=True, + ) + return FakeOCRProvider(self.config).extract_text(image_path) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + provider = FlakyOCRProvider( + ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="fake-ocr", + base_url="", + api_key="", + ) + ) + + result = RetryingOCRProvider( + provider, + max_attempts=2, + backoff_seconds=0, + ).extract_text(str(image_path)) + + self.assertEqual(provider.calls, 2) + self.assertEqual(result.items[0].text, "Quarterly Plan") + + def test_retrying_ocr_provider_records_attempt_history(self): + class FlakyOCRProvider(OCRProvider): + def __init__(self, config): + super().__init__(config) + self.calls = 0 + + def extract_text(self, image_path: str): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="temporary api_key=secret failure", + retryable=True, + status_code=500, + provider_error_code="upstream_timeout", + ) + return FakeOCRProvider(self.config).extract_text(image_path) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + provider = FlakyOCRProvider( + ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="fake-ocr", + base_url="", + api_key="secret", + ) + ) + retrying = RetryingOCRProvider( + provider, + max_attempts=2, + backoff_seconds=0, + ) + + retrying.extract_text(str(image_path)) + + self.assertEqual( + [(item["attempt"], item["status"]) for item in retrying.last_attempts], + [(1, "failed"), (2, "passed")], + ) + self.assertTrue(retrying.last_attempts[0]["retrying"]) + self.assertEqual(retrying.last_attempts[0]["status_code"], 500) + self.assertEqual(retrying.last_attempts[0]["provider_error_code"], "upstream_timeout") + self.assertNotIn("secret", retrying.last_attempts[0]["error"]) + + def test_retrying_ocr_provider_does_not_retry_non_retryable_provider_errors(self): + class FailingOCRProvider(OCRProvider): + def __init__(self, config): + super().__init__(config) + self.calls = 0 + + def extract_text(self, image_path: str): + self.calls += 1 + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="bad request", + retryable=False, + ) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + Image.new("RGB", (800, 450), "white").save(image_path) + provider = FailingOCRProvider( + ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="fake-ocr", + base_url="", + api_key="", + ) + ) + + with self.assertRaises(ProviderError): + RetryingOCRProvider( + provider, + max_attempts=3, + backoff_seconds=0, + ).extract_text(str(image_path)) + + self.assertEqual(provider.calls, 1) + + def test_safe_provider_error_message_redacts_keys_urls_and_bearer_tokens(self): + message = safe_provider_error_message( + "Authorization Bearer abc.def.ghi failed for api_key=secret123 at https://host/path", + secret_values=["secret123"], + ) + + self.assertNotIn("abc.def.ghi", message) + self.assertNotIn("secret123", message) + self.assertIn("Bearer [REDACTED]", message) + self.assertIn("api_key=[REDACTED]", message) + + def test_safe_provider_error_message_redacts_common_token_forms(self): + message = safe_provider_error_message( + "authorization: bearer lower.token x-api-key: key123 access_token=tok " + "token: plain client_secret=secret Authorization: sk-live-key " + 'Authorization=equals-key {"api_key":"json-key"} ' + "{'api_key': 'repr-key'} accessToken=camel clientSecret: camel-secret " + "session_id=session-secret private_key=private-secret " + "session-id=session-hyphen private-key=private-hyphen " + "secret-key=secret-hyphen access-key=access-hyphen " + "api_key whitespace-secret Authorization: Basic basic-secret" + ) + + self.assertNotIn("lower.token", message) + self.assertNotIn("key123", message) + self.assertNotIn("access_token=tok", message) + self.assertNotIn("token: plain", message) + self.assertNotIn("client_secret=secret", message) + self.assertNotIn("sk-live-key", message) + self.assertNotIn("equals-key", message) + self.assertNotIn("json-key", message) + self.assertNotIn("repr-key", message) + self.assertNotIn("camel", message) + self.assertNotIn("session-secret", message) + self.assertNotIn("private-secret", message) + self.assertNotIn("session-hyphen", message) + self.assertNotIn("private-hyphen", message) + self.assertNotIn("secret-hyphen", message) + self.assertNotIn("access-hyphen", message) + self.assertNotIn("whitespace-secret", message) + self.assertNotIn("basic-secret", message) + self.assertIn("bearer [REDACTED]", message) + self.assertIn("x-api-key: [REDACTED]", message) + self.assertIn("Authorization: Basic [REDACTED]", message) + + +class FakeProviderFixtureAssetTest(unittest.TestCase): + def test_fake_image_generation_output_has_deterministic_dimensions_and_hash(self): + with tempfile.TemporaryDirectory() as tmp: + output_path = Path(tmp) / "generated.png" + provider = FakeImageGenerationProvider( + ProviderConfig( + role="image_model", + provider="fake_image_generation", + model="fake-image-generation", + base_url="", + api_key="", + ) + ) + + provider.generate( + ImageGenerationRequest( + prompt_id="asset_sheet", + prompt="Generate deterministic asset.", + output_asset_path=str(output_path), + asset_root=tmp, + ) + ) + + with Image.open(output_path) as image: + self.assertEqual(image.size, (256, 256)) + self.assertEqual(image.mode, "RGBA") + self.assertEqual( + _pixel_sha256(output_path), + "8a39d2abd3999ab73c34db2476849cddf303ce389b35826850f9a700589b4a90", + ) + + def test_fake_image_edit_output_preserves_source_dimensions_and_hash(self): + with tempfile.TemporaryDirectory() as tmp: + source_path = Path(tmp) / "source.png" + output_path = Path(tmp) / "edited.png" + Image.new("RGB", (320, 180), "#336699").save(source_path) + provider = FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + + provider.edit( + ImageEditRequest( + source_image_path=str(source_path), + prompt_id="base_clean_background", + prompt="Clean source.", + output_asset_path=str(output_path), + asset_root=tmp, + ) + ) + + with Image.open(output_path) as image: + self.assertEqual(image.size, (320, 180)) + self.assertEqual(image.mode, "RGB") + self.assertEqual(_pixel_sha256(output_path), _pixel_sha256(source_path)) + + def test_fake_image_edit_fills_masked_regions_with_deterministic_background_color(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source_path = root / "source.png" + mask_path = root / "mask.png" + output_path = root / "edited.png" + image = Image.new("RGB", (120, 80), "#102030") + pixels = image.load() + for x in range(40, 80): + for y in range(25, 45): + pixels[x, y] = (255, 255, 255) + image.save(source_path) + mask = Image.new("L", (120, 80), 0) + for x in range(38, 82): + for y in range(23, 47): + mask.putpixel((x, y), 255) + mask.save(mask_path) + provider = FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + + provider.edit( + ImageEditRequest( + source_image_path=str(source_path), + prompt_id="text_clean_background", + prompt="Remove masked text.", + output_asset_path=str(output_path), + asset_root=tmp, + mask_path=str(mask_path), + ) + ) + + with Image.open(output_path) as edited: + self.assertEqual(edited.size, (120, 80)) + self.assertEqual(edited.getpixel((60, 35)), (16, 32, 48)) + self.assertEqual(edited.getpixel((10, 10)), (16, 32, 48)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_shape_fitter.py b/tests/test_generative_editable_shape_fitter.py new file mode 100644 index 0000000..b60e58e --- /dev/null +++ b/tests/test_generative_editable_shape_fitter.py @@ -0,0 +1,266 @@ +import tempfile +import unittest +from pathlib import Path + +from PIL import Image, ImageDraw + +class GenerativeEditableShapeFitterTest(unittest.TestCase): + def test_fits_high_confidence_rectangle_rounded_rectangle_ellipse_and_line(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_shape_fitter import fit_native_shape + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (220, 140), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((10, 12, 70, 42), fill="#2563EB") + draw.rounded_rectangle((90, 12, 160, 54), radius=10, fill="#F97316") + draw.ellipse((20, 78, 64, 122), fill="#10B981") + draw.rectangle((100, 100, 180, 103), fill="#111827") + image.save(source) + + rectangle = fit_native_shape( + ForegroundCandidate( + candidate_id="rect", + source_pixel_bbox=(10, 12, 71, 43), + area=1891, + classification="native_shape_candidate", + confidence=0.95, + provenance={"shape_hint": "rectangle"}, + ), + source_image_path=source, + ) + rounded = fit_native_shape( + ForegroundCandidate( + candidate_id="rounded", + source_pixel_bbox=(90, 12, 161, 55), + area=3053, + classification="native_shape_candidate", + confidence=0.96, + provenance={"shape_hint": "rounded_rectangle", "radius": 10}, + ), + source_image_path=source, + ) + ellipse = fit_native_shape( + ForegroundCandidate( + candidate_id="ellipse", + source_pixel_bbox=(20, 78, 65, 123), + area=2025, + classification="native_shape_candidate", + confidence=0.94, + provenance={"shape_hint": "ellipse"}, + ), + source_image_path=source, + enable_ellipses=True, + ) + line = fit_native_shape( + ForegroundCandidate( + candidate_id="line", + source_pixel_bbox=(100, 100, 181, 104), + area=324, + classification="native_shape_candidate", + confidence=0.93, + provenance={"shape_hint": "line"}, + ), + source_image_path=source, + ) + vertical_line = fit_native_shape( + ForegroundCandidate( + candidate_id="vertical-line", + source_pixel_bbox=(200, 20, 204, 100), + area=320, + classification="native_shape_candidate", + confidence=0.93, + provenance={"shape_hint": "line"}, + ), + source_image_path=source, + ) + diagonal_line = fit_native_shape( + ForegroundCandidate( + candidate_id="diagonal-line", + source_pixel_bbox=(140, 78, 205, 120), + area=320, + classification="native_shape_candidate", + confidence=0.93, + provenance={ + "shape_hint": "line", + "line_start": (145, 118), + "line_end": (200, 82), + "stroke_width": 5, + }, + ), + source_image_path=source, + ) + + self.assertEqual(rectangle.shape_type, "rectangle") + self.assertEqual(rectangle.fill_color, "#2563EB") + self.assertEqual(rectangle.source_pixel_bbox, (10, 12, 71, 43)) + self.assertEqual(rounded.shape_type, "rounded_rectangle") + self.assertEqual(rounded.radius, 10) + self.assertEqual(ellipse.shape_type, "ellipse") + self.assertEqual(line.shape_type, "line") + self.assertEqual(line.line_color, "#111827") + self.assertEqual(line.line_start, (100, 102)) + self.assertEqual(line.line_end, (181, 102)) + self.assertEqual(line.stroke_width, 4) + self.assertEqual(line.opacity, 1.0) + self.assertEqual(vertical_line.line_start, (202, 20)) + self.assertEqual(vertical_line.line_end, (202, 100)) + self.assertEqual(vertical_line.stroke_width, 4) + self.assertEqual(diagonal_line.line_start, (145, 118)) + self.assertEqual(diagonal_line.line_end, (200, 82)) + self.assertEqual(diagonal_line.stroke_width, 5) + + def test_low_confidence_or_complex_candidates_are_not_forced_to_native_shapes(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_shape_fitter import ( + fit_native_shape, + fit_native_shape_with_fallback, + ) + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (100, 80), "white") + draw = ImageDraw.Draw(image) + for x in range(10, 80): + for y in range(12, 64): + draw.point((x, y), fill=((x * 7) % 255, (y * 9) % 255, 120)) + image.save(source) + + low_confidence = fit_native_shape( + ForegroundCandidate( + candidate_id="low", + source_pixel_bbox=(10, 12, 80, 64), + area=3640, + classification="native_shape_candidate", + confidence=0.72, + provenance={"shape_hint": "rectangle"}, + ), + source_image_path=source, + ) + complex_candidate = fit_native_shape( + ForegroundCandidate( + candidate_id="complex", + source_pixel_bbox=(10, 12, 80, 64), + area=3640, + classification="bitmap_asset_candidate", + confidence=0.95, + provenance={"reason": "visually_complex"}, + ), + source_image_path=source, + ) + fallback_result = fit_native_shape_with_fallback( + ForegroundCandidate( + candidate_id="low", + source_pixel_bbox=(10, 12, 80, 64), + area=3640, + classification="native_shape_candidate", + confidence=0.72, + provenance={"shape_hint": "rectangle"}, + ), + source_image_path=source, + ) + rejected_result = fit_native_shape_with_fallback( + ForegroundCandidate( + candidate_id="rejected", + source_pixel_bbox=(10, 12, 80, 64), + area=3640, + classification="rejected_text_like_region", + confidence=1.0, + provenance={"reason": "text_like"}, + ), + source_image_path=source, + ) + duplicate_result = fit_native_shape_with_fallback( + ForegroundCandidate( + candidate_id="duplicate", + source_pixel_bbox=(10, 12, 80, 64), + area=3640, + classification="duplicate", + confidence=1.0, + provenance={"reuses_candidate_id": "original"}, + ), + source_image_path=source, + ) + complex_result = fit_native_shape_with_fallback( + ForegroundCandidate( + candidate_id="complex", + source_pixel_bbox=(10, 12, 80, 64), + area=3640, + classification="complex_whole_visual", + confidence=0.95, + provenance={"reason": "large_or_visually_complex"}, + ), + source_image_path=source, + ) + + self.assertIsNone(low_confidence) + self.assertIsNone(complex_candidate) + self.assertIsNone(fallback_result.native_shape) + self.assertEqual(fallback_result.bitmap_candidate.classification, "bitmap_asset_candidate") + self.assertEqual( + fallback_result.bitmap_candidate.provenance["shape_fit_fallback_reason"], + "below_native_shape_confidence_threshold", + ) + self.assertIsNone(rejected_result.native_shape) + self.assertIsNone(rejected_result.bitmap_candidate) + self.assertIsNone(duplicate_result.bitmap_candidate) + self.assertIsNone(complex_result.bitmap_candidate) + + def test_rejects_candidate_bbox_outside_source_image_bounds(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_shape_fitter import fit_native_shape + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + Image.new("RGB", (100, 80), "white").save(source) + + with self.assertRaisesRegex(ValueError, "source_pixel_bbox"): + fit_native_shape( + ForegroundCandidate( + candidate_id="outside", + source_pixel_bbox=(90, 10, 120, 30), + area=600, + classification="native_shape_candidate", + confidence=0.95, + provenance={"shape_hint": "rectangle"}, + ), + source_image_path=source, + ) + + def test_non_dict_candidate_provenance_is_ignored(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_shape_fitter import ( + fit_native_shape, + fit_native_shape_with_fallback, + ) + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + image = Image.new("RGB", (100, 80), "white") + ImageDraw.Draw(image).rectangle((10, 12, 70, 42), fill="#2563EB") + image.save(source) + + candidate = ForegroundCandidate( + candidate_id="bad-provenance", + source_pixel_bbox=(10, 12, 71, 43), + area=1891, + classification="native_shape_candidate", + confidence=0.95, + provenance=1, + ) + + shape = fit_native_shape(candidate, source_image_path=source) + fallback = fit_native_shape_with_fallback(candidate, source_image_path=source) + + self.assertIsNone(shape) + self.assertIsNone(fallback.native_shape) + self.assertEqual(fallback.bitmap_candidate.classification, "bitmap_asset_candidate") + self.assertEqual( + fallback.bitmap_candidate.provenance["shape_fit_fallback_reason"], + "unsupported_or_disabled_native_shape", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_smoke_script.py b/tests/test_generative_editable_smoke_script.py new file mode 100644 index 0000000..d648d8c --- /dev/null +++ b/tests/test_generative_editable_smoke_script.py @@ -0,0 +1,241 @@ +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from src.generative_editable_config import ProviderConfig, load_generative_editable_config +from src.generative_editable_pipeline import GenerativeEditablePipelineDependencies +from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ImageGenerationRequest, + ImageGenerationProvider, + ProviderError, +) +from scripts.smoke_generative_editable_pptx import main + + +class GenerativeEditableSmokeScriptTest(unittest.TestCase): + def test_fake_smoke_script_runs_and_prints_redacted_config(self): + with tempfile.TemporaryDirectory() as tmp: + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main(["--use-fake", "--slides", "one", "--output-dir", tmp]) + + self.assertEqual(exit_code, 0) + lines = [json.loads(line) for line in stdout.getvalue().splitlines()] + self.assertEqual(lines[0]["config"]["ocr"]["api_key"], "EMPTY") + self.assertEqual(lines[0]["config"]["ocr"]["base_url"], "EMPTY") + self.assertNotIn("http", stdout.getvalue()) + self.assertEqual(lines[1]["status"], "passed") + self.assertEqual(lines[1]["slide_count"], 1) + self.assertTrue(Path(lines[1]["output_path"]).exists()) + + def test_fake_smoke_script_can_check_image_generation_provider_directly(self): + config = load_generative_editable_config(use_fake=True) + + class RecordingImageGenerationProvider(FakeImageGenerationProvider): + def __init__(self): + super().__init__(config.generation_model) + self.requests: list[ImageGenerationRequest] = [] + + def generate(self, request: ImageGenerationRequest): + self.requests.append(request) + return super().generate(request) + + provider = RecordingImageGenerationProvider() + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=provider, + ) + + with tempfile.TemporaryDirectory() as tmp: + stdout = io.StringIO() + with ( + patch( + "scripts.smoke_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "--use-fake", + "--provider-check", + "image_generation", + "--output-dir", + tmp, + ] + ) + + self.assertEqual(exit_code, 0) + lines = [json.loads(line) for line in stdout.getvalue().splitlines()] + self.assertEqual(lines[1]["status"], "passed") + self.assertEqual(lines[1]["provider_check"], "image_generation") + self.assertTrue(Path(lines[1]["output_path"]).exists()) + self.assertEqual(len(provider.requests), 1) + source_image_path = provider.requests[0].visual_reference["source_image_path"] + self.assertTrue(Path(source_image_path).exists()) + + def test_fake_smoke_script_can_check_image_edit_provider_directly(self): + config = load_generative_editable_config(use_fake=True) + checked_roles: list[str] = [] + + class RecordingImageEditProvider(FakeImageEditProvider): + def edit(self, request): + checked_roles.append(self.config.role) + return super().edit(request) + + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=RecordingImageEditProvider( + ProviderConfig( + role="clean_base_model", + provider="fake_image_edit", + model="fake-edit", + base_url="", + api_key="", + ) + ), + asset_sheet_image_edit_provider=RecordingImageEditProvider( + ProviderConfig( + role="asset_sheet_model", + provider="fake_image_edit", + model="fake-edit", + base_url="", + api_key="", + ) + ), + repair_image_edit_provider=RecordingImageEditProvider( + ProviderConfig( + role="repair_model", + provider="fake_image_edit", + model="fake-edit", + base_url="", + api_key="", + ) + ), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + ) + + with tempfile.TemporaryDirectory() as tmp: + stdout = io.StringIO() + with ( + patch( + "scripts.smoke_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "--use-fake", + "--provider-check", + "image_edit", + "--output-dir", + tmp, + ] + ) + + self.assertEqual(exit_code, 0) + lines = [json.loads(line) for line in stdout.getvalue().splitlines()] + self.assertEqual(lines[1]["status"], "passed") + self.assertEqual(lines[1]["provider_check"], "image_edit") + self.assertEqual( + [item["role"] for item in lines[1]["checked_providers"]], + ["clean_base_model", "asset_sheet_model", "repair_model"], + ) + self.assertEqual(checked_roles, ["clean_base_model", "asset_sheet_model", "repair_model"]) + for item in lines[1]["checked_providers"]: + self.assertTrue(Path(item["output_path"]).exists()) + + def test_fake_smoke_script_can_check_ocr_provider_directly(self): + with tempfile.TemporaryDirectory() as tmp: + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main( + [ + "--use-fake", + "--provider-check", + "ocr", + "--output-dir", + tmp, + ] + ) + + self.assertEqual(exit_code, 0) + lines = [json.loads(line) for line in stdout.getvalue().splitlines()] + self.assertEqual(lines[1]["status"], "passed") + self.assertEqual(lines[1]["provider_check"], "ocr") + self.assertGreaterEqual(lines[1]["item_count"], 1) + + def test_provider_check_uses_configured_retry_wrapper(self): + config = load_generative_editable_config(use_fake=True) + + class FlakyImageGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="image_model", + provider="flaky-image-generation", + model="fake", + base_url="", + api_key="", + ) + ) + self.calls = 0 + self.fake = FakeImageGenerationProvider(config.generation_model) + + def generate(self, request): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="temporary provider failure", + retryable=True, + ) + return self.fake.generate(request) + + flaky = FlakyImageGenerationProvider() + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=flaky, + provider_max_attempts=2, + provider_retry_backoff_seconds=0, + ) + + with tempfile.TemporaryDirectory() as tmp: + stdout = io.StringIO() + with ( + patch( + "scripts.smoke_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "--use-fake", + "--provider-check", + "image_generation", + "--output-dir", + tmp, + ] + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(flaky.calls, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_text.py b/tests/test_generative_editable_text.py new file mode 100644 index 0000000..63860f3 --- /dev/null +++ b/tests/test_generative_editable_text.py @@ -0,0 +1,712 @@ +import unittest + +from src.generative_editable_providers import OCRResult, OCRTextItem +from src.generative_editable_text import extract_text_boxes, extract_text_with_validation + + +class GenerativeEditableTextExtractionTest(unittest.TestCase): + def test_metadata_first_text_uses_aippt_content_and_ocr_layout_style(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="Quarterly Plan", + bbox=(80, 54, 420, 102), + polygon=((80, 54), (420, 54), (420, 102), (80, 102)), + confidence=0.98, + font_family_hint="Arial", + font_size_hint=32, + style_hints={"weight": "bold", "italic": False}, + color_hex="#1F2937", + alignment="center", + provenance={"provider_role": "ocr_model", "item_id": "ocr-1"}, + ) + ], + ) + metadata = [ + { + "text": "Quarterly Plan 2026", + "role": "title", + "order": 1, + "style_hint": {"font_family": "Aptos Display"}, + } + ] + + boxes = extract_text_boxes(metadata, ocr) + + self.assertEqual(len(boxes), 1) + box = boxes[0] + self.assertEqual(box.text, "Quarterly Plan 2026") + self.assertEqual(box.source_pixel_bbox, (80, 54, 420, 102)) + self.assertEqual(box.source_pixel_polygon, ((80, 54), (420, 54), (420, 102), (80, 102))) + self.assertEqual(box.font_family, "Aptos Display") + self.assertEqual(box.font_size, 32) + self.assertEqual(box.color_hex, "#1F2937") + self.assertEqual(box.alignment, "center") + self.assertEqual(box.style_hints["weight"], "bold") + self.assertEqual(box.provenance["content_source"], "aippt_metadata") + self.assertEqual(box.provenance["layout_source"], "ocr") + + def test_metadata_first_matches_by_order_when_text_differs_from_ocr(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="First OCR", + bbox=(10, 10, 110, 40), + polygon=((10, 10), (110, 10), (110, 40), (10, 40)), + confidence=0.95, + ), + OCRTextItem( + text="Second OCR", + bbox=(10, 60, 130, 90), + polygon=((10, 60), (130, 60), (130, 90), (10, 90)), + confidence=0.96, + ), + ], + ) + metadata = [ + {"text": "First Metadata", "role": "body", "order": 1}, + {"text": "Second Metadata", "role": "body", "order": 2}, + ] + + boxes = extract_text_boxes(metadata, ocr) + + self.assertEqual([box.text for box in boxes], ["First Metadata", "Second Metadata"]) + self.assertEqual(boxes[1].source_pixel_bbox, (10, 60, 130, 90)) + + def test_metadata_order_uses_one_based_ocr_index_and_falls_back_for_unmatched_ocr(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="OCR One", + bbox=(10, 10, 110, 40), + polygon=((10, 10), (110, 10), (110, 40), (10, 40)), + confidence=0.95, + ), + OCRTextItem( + text="OCR Two", + bbox=(10, 60, 130, 90), + polygon=((10, 60), (130, 60), (130, 90), (10, 90)), + confidence=0.96, + style_hints={"debug": "api_key=style-secret at https://style.example/path"}, + provenance={"debug": "api_key=secret at https://debug.example/path"}, + ), + ], + ) + metadata = [{"text": "Metadata For Second", "role": "body", "order": 2}] + + boxes = extract_text_boxes(metadata, ocr) + result = extract_text_with_validation(metadata, ocr) + + self.assertEqual([box.text for box in boxes], ["Metadata For Second", "OCR One"]) + self.assertEqual(boxes[0].source_pixel_bbox, (10, 60, 130, 90)) + self.assertEqual([box.text for box in result.text_boxes], ["Metadata For Second", "OCR One"]) + self.assertNotIn("secret", repr(result.text_boxes[0].provenance)) + self.assertNotIn("style-secret", repr(result.text_boxes[0].style_hints)) + + def test_extract_text_boxes_rejects_duplicate_metadata_order(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="OCR", + bbox=(10, 10, 110, 40), + polygon=((10, 10), (110, 10), (110, 40), (10, 40)), + confidence=0.95, + ) + ], + ) + + with self.assertRaisesRegex(ValueError, "duplicate metadata order"): + extract_text_boxes( + [ + {"text": "First", "role": "body", "order": 1}, + {"text": "Duplicate", "role": "body", "order": 1}, + ], + ocr, + ) + + def test_ocr_only_fallback_preserves_confidence_and_uses_cjk_safe_font(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="季度计划", + bbox=(80, 54, 420, 102), + polygon=((80, 54), (420, 54), (420, 102), (80, 102)), + confidence=0.88, + font_size_hint=28, + color_hex="1f2937", + alignment="center", + provenance={"item_id": "ocr-cjk"}, + ) + ], + ) + + boxes = extract_text_boxes([], ocr) + + self.assertEqual(boxes[0].text, "季度计划") + self.assertEqual(boxes[0].font_family, "Microsoft YaHei") + self.assertEqual(boxes[0].font_size, 28) + self.assertEqual(boxes[0].color_hex, "#1F2937") + self.assertEqual(boxes[0].alignment, "center") + self.assertEqual(boxes[0].provenance["content_source"], "ocr") + self.assertEqual(boxes[0].provenance["ocr_confidence"], 0.88) + + def test_ocr_only_fallback_maps_missing_style_defaults(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="Plain text", + bbox=(80, 54, 420, 102), + polygon=((80, 54), (420, 54), (420, 102), (80, 102)), + confidence=0.91, + color_hex="not-a-color", + alignment="left", + ) + ], + ) + + boxes = extract_text_boxes([], ocr) + + self.assertEqual(boxes[0].font_family, "Arial") + self.assertEqual(boxes[0].font_size, 18) + self.assertEqual(boxes[0].color_hex, "#000000") + self.assertEqual(boxes[0].alignment, "left") + + def test_text_validation_fails_when_ocr_returns_no_items_without_metadata(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[], + ) + + result = extract_text_with_validation([], ocr) + + self.assertEqual(result.text_boxes, []) + self.assertEqual(result.validation_status, "failed") + self.assertIn("no_ocr_text_detected", [issue["code"] for issue in result.issues]) + + def test_ocr_fallback_handles_kana_hangul_and_none_color(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="カタカナ", + bbox=(10, 10, 110, 40), + polygon=((10, 10), (110, 10), (110, 40), (10, 40)), + confidence=0.95, + color_hex=None, + ), + OCRTextItem( + text="한글", + bbox=(10, 60, 110, 90), + polygon=((10, 60), (110, 60), (110, 90), (10, 90)), + confidence=0.95, + ), + ], + ) + + boxes = extract_text_boxes([], ocr) + + self.assertEqual(boxes[0].font_family, "Microsoft YaHei") + self.assertEqual(boxes[0].color_hex, "#000000") + self.assertEqual(boxes[1].font_family, "Microsoft YaHei") + + def test_text_validation_reports_low_confidence_and_unmatched_metadata(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="Accepted OCR", + bbox=(10, 10, 110, 40), + polygon=((10, 10), (110, 10), (110, 40), (10, 40)), + confidence=0.93, + ), + OCRTextItem( + text="Low confidence", + bbox=(10, 60, 130, 90), + polygon=((10, 60), (130, 60), (130, 90), (10, 90)), + confidence=0.41, + ), + ], + ) + metadata = [ + {"text": "Accepted Metadata", "role": "body", "order": 1}, + {"text": "Missing Metadata", "role": "body", "order": 3}, + ] + + result = extract_text_with_validation(metadata, ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["Accepted Metadata"]) + self.assertEqual(result.validation_status, "failed") + self.assertEqual( + [issue["code"] for issue in result.issues], + ["low_ocr_confidence", "metadata_unmatched"], + ) + self.assertEqual(result.issues[0]["ocr_text"], "Low confidence") + self.assertEqual(result.issues[1]["metadata_text"], "Missing Metadata") + + def test_text_validation_accepts_low_confidence_when_metadata_text_matches_ocr(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="Quarterly Plan", + bbox=(82, 68, 148, 78), + polygon=((82, 68), (148, 68), (148, 78), (82, 78)), + confidence=0.0, + ), + OCRTextItem( + text="| ILA", + bbox=(82, 172, 720, 301), + polygon=((82, 172), (720, 172), (720, 301), (82, 301)), + confidence=0.52, + ), + OCRTextItem( + text="a", + bbox=(146, 294, 156, 302), + polygon=((146, 294), (156, 294), (156, 302), (146, 302)), + confidence=0.62, + ) + ], + ) + metadata = [{"text": "Quarterly Plan", "role": "title", "order": 1}] + + result = extract_text_with_validation(metadata, ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["Quarterly Plan"]) + self.assertEqual( + [issue["code"] for issue in result.issues], + ["ignored_spurious_ocr", "ignored_spurious_ocr"], + ) + self.assertEqual(result.issues[0]["severity"], "warning") + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_reports_low_confidence_for_short_text_that_could_be_real(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="Title", + bbox=(80, 40, 160, 60), + polygon=((80, 40), (160, 40), (160, 60), (80, 60)), + confidence=0.98, + ), + OCRTextItem( + text="AI", + bbox=(120, 120, 740, 300), + polygon=((120, 120), (740, 120), (740, 300), (120, 300)), + confidence=0.52, + ), + ], + ) + metadata = [{"text": "Title", "role": "title", "order": 1}] + + result = extract_text_with_validation(metadata, ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["Title"]) + self.assertEqual([issue["code"] for issue in result.issues], ["low_ocr_confidence"]) + self.assertEqual(result.validation_status, "failed") + + def test_text_validation_reports_low_confidence_for_metadata_match_and_duplicate_order(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="Low metadata OCR", + bbox=(10, 10, 110, 40), + polygon=((10, 10), (110, 10), (110, 40), (10, 40)), + confidence=0.4, + ) + ], + ) + metadata = [ + {"text": "First", "role": "body", "order": 1}, + {"text": "Duplicate", "role": "body", "order": 1}, + ] + + result = extract_text_with_validation(metadata, ocr, min_confidence=0.75) + + self.assertEqual(result.text_boxes, []) + self.assertEqual( + [issue["code"] for issue in result.issues], + ["duplicate_metadata_order", "low_ocr_confidence"], + ) + + def test_text_validation_ignores_uniform_fallback_layout_items(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="23", + bbox=(20, 360, 780, 390), + polygon=((20, 360), (780, 360), (780, 390), (20, 390)), + confidence=0.9, + provenance={"layout_source": "uniform_fallback"}, + ), + OCRTextItem( + text="真实标题", + bbox=(80, 40, 260, 80), + polygon=((80, 40), (260, 40), (260, 80), (80, 80)), + confidence=0.95, + ), + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["真实标题"]) + self.assertEqual([issue["code"] for issue in result.issues], ["ignored_spurious_ocr"]) + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_blocks_credible_uniform_fallback_text(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="核心架构设计:增程、底盘与智能域", + bbox=(40, 40, 760, 90), + polygon=((40, 40), (760, 40), (760, 90), (40, 90)), + confidence=0.96, + provenance={"layout_source": "uniform_fallback"}, + ), + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual(result.text_boxes, []) + self.assertEqual([issue["code"] for issue in result.issues], ["unreliable_ocr_layout"]) + self.assertEqual(result.validation_status, "failed") + + def test_text_validation_ignores_repetitive_hallucinated_ocr_text(self): + repeated = "3. 根据用户需求,设计一个符合用户需求、符合用户体验、" * 5 + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text=repeated, + bbox=(500, 70, 590, 130), + polygon=((500, 70), (590, 70), (590, 130), (500, 130)), + confidence=0.92, + ), + OCRTextItem( + text="总结与持续进化", + bbox=(180, 180, 420, 230), + polygon=((180, 180), (420, 180), (420, 230), (180, 230)), + confidence=0.96, + ), + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["总结与持续进化"]) + self.assertEqual([issue["code"] for issue in result.issues], ["ignored_spurious_ocr"]) + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_ignores_approximate_text_that_cannot_fit_bbox(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="禁止驾驶中调后视镜", + bbox=(700, 300, 718, 312), + polygon=((700, 300), (718, 300), (718, 312), (700, 312)), + confidence=0.78, + provenance={"approximate_layout": True}, + ), + OCRTextItem( + text="总结与持续进化", + bbox=(180, 180, 420, 230), + polygon=((180, 180), (420, 180), (420, 230), (180, 230)), + confidence=0.96, + ), + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["总结与持续进化"]) + self.assertEqual([issue["code"] for issue in result.issues], ["ignored_spurious_ocr"]) + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_keeps_short_cjk_approximate_text_with_borderline_bbox(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(1672, 941), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="纯电续航", + bbox=(898, 257, 955, 291), + polygon=((898, 257), (955, 257), (955, 291), (898, 291)), + confidence=0.78, + provenance={"approximate_layout": True}, + ) + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["纯电续航"]) + self.assertEqual(result.issues, []) + self.assertEqual(result.validation_status, "passed") + + def test_approximate_mid_slide_short_label_font_size_is_capped(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(1672, 941), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="四零重力座椅,", + bbox=(169, 455, 486, 504), + polygon=((169, 455), (486, 455), (486, 504), (169, 504)), + confidence=0.78, + font_size_hint=16.87, + provenance={"approximate_layout": True}, + ) + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["四零重力座椅,"]) + self.assertLessEqual(result.text_boxes[0].font_size or 0, 14.0) + + def test_text_validation_keeps_spatially_distinct_duplicate_approximate_text(self): + items = [ + OCRTextItem( + text="优先平板拖运", + bbox=(100, 100 + index * 50, 240, 130 + index * 50), + polygon=( + (100, 100 + index * 50), + (240, 100 + index * 50), + (240, 130 + index * 50), + (100, 130 + index * 50), + ), + confidence=0.78, + provenance={"approximate_layout": True}, + ) + for index in range(3) + ] + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=items, + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual( + [box.text for box in result.text_boxes], + ["优先平板拖运", "优先平板拖运", "优先平板拖运"], + ) + self.assertEqual(result.issues, []) + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_dedupes_near_overlapping_approximate_text(self): + items = [ + OCRTextItem( + text="优先平板拖运", + bbox=(100 + index * 4, 100 + index * 3, 240 + index * 4, 130 + index * 3), + polygon=( + (100 + index * 4, 100 + index * 3), + (240 + index * 4, 100 + index * 3), + (240 + index * 4, 130 + index * 3), + (100 + index * 4, 130 + index * 3), + ), + confidence=0.78, + provenance={"approximate_layout": True}, + ) + for index in range(3) + ] + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=items, + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["优先平板拖运"]) + self.assertEqual([issue["code"] for issue in result.issues], ["ignored_spurious_ocr", "ignored_spurious_ocr"]) + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_ignores_long_ocr_text_inside_tiny_bbox(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="根据用户需求,设计一个符合用户需求、符合用户体验的页面元素", + bbox=(500, 70, 560, 105), + polygon=((500, 70), (560, 70), (560, 105), (500, 105)), + confidence=0.91, + ), + OCRTextItem( + text="420 km", + bbox=(280, 120, 390, 160), + polygon=((280, 120), (390, 120), (390, 160), (280, 160)), + confidence=0.96, + ), + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["420 km"]) + self.assertEqual([issue["code"] for issue in result.issues], ["ignored_spurious_ocr"]) + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_caps_duplicate_approximate_layout_text(self): + items = [ + OCRTextItem( + text="保持视野清晰", + bbox=(40 + index * 60, 120 + index * 20, 120 + index * 60, 145 + index * 20), + polygon=( + (40 + index * 60, 120 + index * 20), + (120 + index * 60, 120 + index * 20), + (120 + index * 60, 145 + index * 20), + (40 + index * 60, 145 + index * 20), + ), + confidence=0.9, + provenance={"approximate_layout": True}, + ) + for index in range(4) + ] + items.append( + OCRTextItem( + text="真实标题", + bbox=(80, 40, 260, 80), + polygon=((80, 40), (260, 40), (260, 80), (80, 80)), + confidence=0.95, + provenance={"approximate_layout": True}, + ) + ) + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=items, + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual( + [box.text for box in result.text_boxes], + ["保持视野清晰", "保持视野清晰", "保持视野清晰", "保持视野清晰", "真实标题"], + ) + self.assertEqual(result.issues, []) + self.assertEqual(result.validation_status, "passed") + + def test_text_validation_caps_short_approximate_layout_font_size(self): + ocr = OCRResult( + source_image_path="slides/slide-a.png", + image_size=(800, 450), + provider_role="ocr_model", + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="长期停放", + bbox=(120, 200, 320, 286), + polygon=((120, 200), (320, 200), (320, 286), (120, 286)), + confidence=0.92, + font_size_hint=29.6, + provenance={"approximate_layout": True}, + ) + ], + ) + + result = extract_text_with_validation([], ocr, min_confidence=0.75) + + self.assertEqual([box.text for box in result.text_boxes], ["长期停放"]) + self.assertLessEqual(result.text_boxes[0].font_size, 14.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_text_candidates.py b/tests/test_generative_editable_text_candidates.py new file mode 100644 index 0000000..5d02460 --- /dev/null +++ b/tests/test_generative_editable_text_candidates.py @@ -0,0 +1,129 @@ +import tempfile +import unittest +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +from src.generative_editable_text_candidates import detect_text_candidate_bboxes + + +class GenerativeEditableTextCandidatesTest(unittest.TestCase): + def test_detects_large_cover_title_lines_on_dark_slide(self): + font_path = Path("/System/Library/Fonts/STHeiti Medium.ttc") + if not font_path.exists(): + self.skipTest("system CJK font unavailable") + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "cover.png" + image = Image.new("RGB", (1000, 560), "#030914") + draw = ImageDraw.Draw(image) + title_font = ImageFont.truetype(str(font_path), 72) + subtitle_font = ImageFont.truetype(str(font_path), 64) + draw.text((50, 70), "理想L9:", font=title_font, fill="#F8FAFC") + draw.text((50, 170), "旗舰增程SUV的技术实验", font=subtitle_font, fill="#F8FAFC") + image.save(image_path) + + candidates = detect_text_candidate_bboxes(str(image_path)) + + self.assertTrue( + any( + bbox[0] <= 70 + and 150 <= bbox[1] <= 190 + and bbox[2] >= 620 + and 230 <= bbox[3] <= 280 + for bbox in candidates + ), + candidates, + ) + + def test_detects_three_left_domain_labels_on_dense_dark_slide(self): + font_path = Path("/System/Library/Fonts/STHeiti Medium.ttc") + if not font_path.exists(): + self.skipTest("system CJK font unavailable") + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + image = Image.new("RGB", (900, 520), "#030914") + draw = ImageDraw.Draw(image) + font = ImageFont.truetype(str(font_path), 34) + for y, text in [(90, "智能域"), (235, "底盘域"), (380, "动力域")]: + draw.rounded_rectangle((70, y - 34, 830, y + 54), radius=12, fill="#0B2340") + draw.text((155, y), text, font=font, fill="#168BFF") + draw.line((460, y - 16, 460, y + 42), fill="#1D9BFF", width=2) + draw.rectangle((520, y - 18, 680, y + 42), outline="#4D6B8F", width=2) + image.save(image_path) + + candidates = detect_text_candidate_bboxes(str(image_path)) + + left_label_candidates = [ + bbox + for bbox in candidates + if 140 <= bbox[0] <= 220 and 220 <= bbox[1] <= 280 and bbox[2] - bbox[0] >= 40 + ] + self.assertGreaterEqual(len(candidates), 3) + self.assertTrue(left_label_candidates, candidates) + + def test_detects_card_heading_labels_on_dashboard_slide(self): + font_path = Path("/System/Library/Fonts/STHeiti Medium.ttc") + if not font_path.exists(): + self.skipTest("system CJK font unavailable") + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "dashboard.png" + image = Image.new("RGB", (1000, 560), "#030914") + draw = ImageDraw.Draw(image) + font = ImageFont.truetype(str(font_path), 26) + draw.rounded_rectangle((28, 70, 390, 420), radius=16, fill="#0B2340", outline="#1D4ED8", width=2) + draw.rounded_rectangle((410, 70, 960, 420), radius=16, fill="#0B2340", outline="#1D4ED8", width=2) + draw.ellipse((48, 88, 76, 116), fill="#60A5FA") + draw.ellipse((430, 88, 458, 116), fill="#60A5FA") + draw.text((86, 88), "日常操作", font=font, fill="#F8FAFC") + draw.text((468, 88), "紧急与维护", font=font, fill="#F8FAFC") + image.save(image_path) + + candidates = detect_text_candidate_bboxes(str(image_path)) + + self.assertTrue( + any(75 <= bbox[0] <= 95 and 80 <= bbox[1] <= 100 and 170 <= bbox[2] <= 230 for bbox in candidates), + candidates, + ) + self.assertTrue( + any(415 <= bbox[0] <= 475 and 75 <= bbox[1] <= 105 and 580 <= bbox[2] <= 650 for bbox in candidates), + candidates, + ) + + def test_detects_replay_dashboard_card_headings(self): + image_path = Path("output/replay-assets/slide_5.png") + if not image_path.exists(): + self.skipTest("replay slide_5 fixture unavailable") + + candidates = detect_text_candidate_bboxes(str(image_path)) + + self.assertTrue( + any(140 <= bbox[0] <= 170 and 120 <= bbox[1] <= 145 and 320 <= bbox[2] <= 360 for bbox in candidates), + candidates, + ) + + def test_splits_replay_dashboard_wide_row_bands_into_flowchart_text_groups(self): + image_path = Path("output/replay-assets/slide_5.png") + if not image_path.exists(): + self.skipTest("replay slide_5 fixture unavailable") + + candidates = detect_text_candidate_bboxes(str(image_path)) + + self.assertTrue( + any(1160 <= bbox[0] <= 1210 and 370 <= bbox[1] <= 395 and 1520 <= bbox[2] <= 1560 for bbox in candidates), + candidates, + ) + self.assertTrue( + any(1020 <= bbox[0] <= 1060 and 800 <= bbox[1] <= 825 and 1200 <= bbox[2] <= 1230 for bbox in candidates), + candidates, + ) + self.assertFalse( + any(bbox[0] <= 120 and bbox[2] >= 1000 and 120 <= bbox[1] <= 220 for bbox in candidates), + candidates, + ) + self.assertTrue( + any(780 <= bbox[0] <= 820 and 120 <= bbox[1] <= 145 and 1010 <= bbox[2] <= 1060 for bbox in candidates), + candidates, + ) diff --git a/tests/test_generative_editable_text_masks.py b/tests/test_generative_editable_text_masks.py new file mode 100644 index 0000000..10e90c2 --- /dev/null +++ b/tests/test_generative_editable_text_masks.py @@ -0,0 +1,100 @@ +import unittest + +from src.generative_editable_manifest import TextBoxSpec +from src.generative_editable_text_masks import create_text_mask + + +class GenerativeEditableTextMaskTest(unittest.TestCase): + def test_creates_mask_from_text_boxes_with_padding_and_clipping(self): + boxes = [ + TextBoxSpec( + text="Title", + source_pixel_bbox=(10, 10, 40, 30), + source_pixel_polygon=((10, 10), (40, 10), (40, 30), (10, 30)), + ), + TextBoxSpec( + text="Edge", + source_pixel_bbox=(90, 40, 100, 50), + source_pixel_polygon=((90, 40), (100, 40), (100, 50), (90, 50)), + ), + ] + + mask = create_text_mask((100, 50), boxes, padding=5) + + self.assertEqual(mask.size, (100, 50)) + self.assertEqual(mask.mode, "L") + self.assertEqual(mask.getpixel((5, 5)), 255) + self.assertEqual(mask.getpixel((45, 35)), 255) + self.assertEqual(mask.getpixel((99, 49)), 255) + self.assertEqual(mask.getpixel((0, 49)), 0) + + def test_creates_mask_from_polygon_when_requested(self): + boxes = [ + TextBoxSpec( + text="Angled", + source_pixel_bbox=(10, 10, 50, 40), + source_pixel_polygon=((20, 10), (50, 20), (40, 40), (10, 30)), + ) + ] + + mask = create_text_mask((80, 60), boxes, padding=0, use_polygons=True) + + self.assertEqual(mask.getpixel((30, 25)), 255) + self.assertEqual(mask.getpixel((10, 10)), 0) + + def test_polygon_padding_expands_and_clips_mask(self): + boxes = [ + TextBoxSpec( + text="Angled", + source_pixel_bbox=(5, 5, 19, 19), + source_pixel_polygon=((12, 4), (20, 12), (12, 20), (4, 12)), + ) + ] + + mask = create_text_mask((24, 24), boxes, padding=4, use_polygons=True) + + self.assertEqual(mask.getpixel((12, 1)), 255) + self.assertEqual(mask.getpixel((1, 1)), 0) + + def test_polygon_mask_skips_fully_out_of_bounds_polygon(self): + boxes = [ + TextBoxSpec( + text="Out", + source_pixel_bbox=(0, 0, 10, 10), + source_pixel_polygon=((0, 0), (10, 0), (10, 10), (0, 10)), + ) + ] + object.__setattr__( + boxes[0], + "source_pixel_polygon", + ((200, 200), (220, 200), (220, 220), (200, 220)), + ) + + mask = create_text_mask((100, 50), boxes, padding=0, use_polygons=True) + + self.assertEqual(mask.getbbox(), None) + + def test_mask_rejects_negative_padding_and_excessive_dimensions(self): + with self.assertRaisesRegex(ValueError, "padding"): + create_text_mask((100, 50), [], padding=-1) + + with self.assertRaisesRegex(ValueError, "image_size"): + create_text_mask((20000, 20000), [], padding=0) + + def test_mask_clips_out_of_bounds_boxes_without_inverting(self): + boxes = [ + TextBoxSpec( + text="Out", + source_pixel_bbox=(0, 0, 10, 10), + source_pixel_polygon=((0, 0), (10, 0), (10, 10), (0, 10)), + ) + ] + object.__setattr__(boxes[0], "source_pixel_bbox", (200, 200, 250, 250)) + + mask = create_text_mask((100, 50), boxes, padding=0) + + self.assertEqual(mask.getbbox(), None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py new file mode 100644 index 0000000..f626bee --- /dev/null +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -0,0 +1,2871 @@ +import tempfile +import time +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest.mock import patch + +from PIL import Image, ImageDraw + +from src.generative_editable_config import ProviderConfig +from src.generative_editable_providers import FakeImageEditProvider +from src.generative_editable_providers import ImageEditProvider +from src.generative_editable_providers import OCRResult, OCRTextItem +from src.generative_editable_providers import ProviderError + + +class GenerativeEditableVLMReconstructionTest(unittest.TestCase): + def test_vlm_pipeline_runs_structure_and_preview_validation_before_returning(self): + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class EmptyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + calls = [] + + def structure_validator(**kwargs): + calls.append(("structure", kwargs["pptx_path"], kwargs["deck_manifest_path"])) + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + def preview_renderer(page, artifact_root, *, pptx_path): + calls.append(("preview_renderer", page.slide_id, pptx_path)) + return object() + + def preview_validator(**kwargs): + calls.append( + ( + "preview_validator", + kwargs["slide_id"], + kwargs["pptx_path"], + kwargs["preview_similarity_threshold"], + kwargs["require_preview_validation"], + ) + ) + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + output = root / "out.pptx" + Image.new("RGB", (160, 90), "#001122").save(source) + + result = run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-validate", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=EmptyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + preview_similarity_threshold=0.87, + require_preview_validation=True, + structure_validator=structure_validator, + preview_renderer=preview_renderer, + preview_validator=preview_validator, + ), + ) + + self.assertEqual(result.validation_report.status, "passed") + self.assertEqual([call[0] for call in calls], ["structure", "preview_renderer", "preview_validator"]) + self.assertEqual(calls[2][2], str(output)) + self.assertEqual(calls[2][3], 0.87) + self.assertIs(calls[2][4], True) + + def test_vlm_pipeline_requires_preview_validation_by_default(self): + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class EmptyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + observed = {} + + def preview_validator(**kwargs): + observed["require_preview_validation"] = kwargs["require_preview_validation"] + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + output = root / "out.pptx" + Image.new("RGB", (160, 90), "#001122").save(source) + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-default-preview", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=EmptyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=preview_validator, + ), + ) + + self.assertIs(observed["require_preview_validation"], True) + + def test_vlm_pipeline_raises_validation_error_when_validation_fails(self): + from src.generative_editable_pipeline import GenerativeEditableValidationError + from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class EmptyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + def failing_preview_validator(**kwargs): + return ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_similarity_failed", + message="forced preview mismatch", + slide_id=kwargs["slide_id"], + ) + ], + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + output = root / "out.pptx" + Image.new("RGB", (160, 90), "#001122").save(source) + + with self.assertRaises(GenerativeEditableValidationError) as ctx: + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-validate-fail", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=EmptyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=failing_preview_validator, + ), + ) + + self.assertEqual(ctx.exception.validation_report.status, "failed") + self.assertIn("preview_similarity_failed", str(ctx.exception)) + + def test_vlm_pipeline_fails_when_page_has_no_editable_or_decomposed_objects(self): + from src.generative_editable_pipeline import GenerativeEditableValidationError + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class EmptyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + output = root / "out.pptx" + Image.new("RGB", (160, 90), "#001122").save(source) + + with self.assertRaises(GenerativeEditableValidationError) as ctx: + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-empty-structure", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=EmptyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + self.assertEqual(ctx.exception.validation_report.status, "failed") + self.assertIn("vlm_no_editable_or_decomposed_objects", str(ctx.exception)) + + def test_vlm_provider_retries_retryable_errors(self): + from src.generative_editable_vlm_reconstruction import ( + RetryingVLMPageAnalysisProvider, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + ) + + class FlakyVLMProvider(VLMPageAnalysisProvider): + def __init__(self): + self.calls = 0 + + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role="VLM", + operation="vlm_page_analysis", + message="temporary EOF", + retryable=True, + ) + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 10, "height": 10, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + provider = FlakyVLMProvider() + + result = RetryingVLMPageAnalysisProvider( + provider, + max_attempts=2, + backoff_seconds=0, + ).analyze_page("source.png", timeout_seconds=5) + + self.assertEqual(provider.calls, 2) + self.assertEqual(result.coordinate_space.width, 10) + + def test_vlm_pipeline_applies_configured_provider_retries(self): + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class FlakyPipelineVLMProvider(VLMPageAnalysisProvider): + def __init__(self): + self.calls = 0 + + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role="VLM", + operation="vlm_page_analysis", + message="temporary EOF", + retryable=True, + ) + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + Image.new("RGB", (160, 90), "#001122").save(source) + output = root / "out.pptx" + vlm_provider = FlakyPipelineVLMProvider() + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-retry", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=vlm_provider, + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + provider_max_attempts=2, + provider_retry_backoff_seconds=0, + ), + ) + + self.assertEqual(vlm_provider.calls, 2) + + def test_vlm_pipeline_writes_stage_events_for_timeout_diagnostics(self): + from src.generative_editable_job_artifacts import GenerativeEditableJobArtifacts + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class EmptyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + output = root / "out.pptx" + artifact_root = root / "artifacts" + job_id = "job-stage-events" + Image.new("RGB", (160, 90), "#001122").save(source) + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(artifact_root), + job_id=job_id, + dependencies=VLMEditablePipelineDependencies( + vlm_provider=EmptyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + ), + ) + events = GenerativeEditableJobArtifacts( + root_dir=artifact_root, + job_id=job_id, + ).read_stage_events() + + stages = [(event["stage"], event["status"]) for event in events] + self.assertIn(("vlm_page_analysis", "started"), stages) + self.assertIn(("vlm_page_analysis", "finished"), stages) + self.assertIn(("vlm_clean_background", "started"), stages) + self.assertIn(("vlm_asset_sheet", "finished"), stages) + self.assertIn(("compose_deck", "finished"), stages) + + def test_vlm_pipeline_applies_configured_retries_to_image_edit(self): + from src.generative_editable_providers import ImageEditProvider + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class EmptyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + class FlakyImageEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ) + self.calls = 0 + self.fake = FakeImageEditProvider(self.config) + + def edit(self, request): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role="edit_model", + operation=request.prompt_id, + message="temporary EOF", + retryable=True, + ) + return self.fake.edit(request) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + Image.new("RGB", (160, 90), "#001122").save(source) + output = root / "out.pptx" + image_edit_provider = FlakyImageEditProvider() + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-edit-retry", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=EmptyVLMProvider(), + image_edit_provider=image_edit_provider, + provider_max_attempts=2, + provider_retry_backoff_seconds=0, + ), + ) + + self.assertEqual(image_edit_provider.calls, 2) + + def test_vlm_pipeline_enforces_configured_page_timeout(self): + from src.generative_editable_providers import ProviderTimeoutError + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class SlowVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + time.sleep(0.2) + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + Image.new("RGB", (160, 90), "#001122").save(source) + + with self.assertRaises(ProviderTimeoutError) as ctx: + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-page-timeout", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=SlowVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + page_timeout_seconds=0.05, + ), + ) + + self.assertEqual(ctx.exception.provider_role, "vlm_first") + self.assertEqual(ctx.exception.operation, "build_page_manifest") + self.assertEqual(ctx.exception.timeout_seconds, 0.05) + + def test_vlm_pipeline_enforces_page_timeout_in_worker_thread(self): + from src.generative_editable_providers import ProviderTimeoutError + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class SlowVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + time.sleep(0.2) + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + Image.new("RGB", (160, 90), "#001122").save(source) + + def run_pipeline(): + return run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-thread-page-timeout", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=SlowVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + page_timeout_seconds=0.05, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(run_pipeline) + with self.assertRaises(ProviderTimeoutError) as ctx: + future.result(timeout=1) + + self.assertEqual(ctx.exception.provider_role, "vlm_first") + self.assertEqual(ctx.exception.operation, "build_page_manifest") + + def test_vlm_pipeline_attributes_provider_timeout_to_page_when_page_budget_is_smaller(self): + from src.generative_editable_providers import ProviderTimeoutError + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + run_vlm_editable_pptx_pipeline, + ) + + class TimingOutVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + raise ProviderTimeoutError( + provider_role="VLM", + operation="vlm_page_analysis", + message="request timed out", + timeout_seconds=timeout_seconds, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + Image.new("RGB", (160, 90), "#001122").save(source) + + with self.assertRaises(ProviderTimeoutError) as ctx: + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-page-timeout-attribution", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=TimingOutVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + provider_timeout_seconds=30, + page_timeout_seconds=0.05, + ), + ) + + self.assertEqual(ctx.exception.provider_role, "vlm_first") + self.assertEqual(ctx.exception.operation, "build_page_manifest") + + def test_maps_vlm_pixel_coordinates_back_to_source_pixels(self): + from src.generative_editable_vlm_reconstruction import ( + VLMCoordinateMapper, + coerce_vlm_analysis, + ) + + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 960, "height": 540, "unit": "px"}, + "text_regions": [ + { + "id": "t1", + "text": "标题", + "bbox": [96, 54, 192, 108], + "role": "title", + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + mapper = VLMCoordinateMapper( + analysis_space=analysis.coordinate_space, + source_image_size=(1920, 1080), + ) + + self.assertEqual(mapper.to_source_bbox(analysis.text_regions[0].bbox), (192, 108, 384, 216)) + + def test_mask_uses_text_and_bitmap_regions_but_excludes_shape_regions(self): + from src.generative_editable_vlm_reconstruction import ( + VLMCoordinateMapper, + build_text_bitmap_mask, + coerce_vlm_analysis, + ) + + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 100, "height": 100, "unit": "px"}, + "text_regions": [{"id": "t1", "text": "A", "bbox": [10, 10, 20, 20]}], + "bitmap_regions": [{"id": "b1", "type": "icon", "bbox": [40, 40, 50, 50]}], + "shape_regions": [ + {"id": "s1", "type": "rounded_rect", "bbox": [0, 0, 100, 100]}, + ], + } + ) + mapper = VLMCoordinateMapper( + analysis_space=analysis.coordinate_space, + source_image_size=(100, 100), + ) + + mask, mask_boxes = build_text_bitmap_mask(analysis, mapper, padding=0) + + self.assertEqual(mask.getpixel((15, 15)), 255) + self.assertEqual(mask.getpixel((45, 45)), 255) + self.assertEqual(mask.getpixel((80, 80)), 0) + self.assertEqual([box.kind for box in mask_boxes], ["text", "bitmap"]) + + def test_builds_manifest_with_editable_text_split_assets_and_conservative_shapes(self): + from src.generative_editable_manifest import PageManifest + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + image = Image.new("RGB", (200, 100), "#001122") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 50, 40), fill="#FFFFFF") + draw.rectangle((130, 30, 140, 40), fill="#22AAFF") + draw.point((135, 35), fill="#001122") + image.save(source) + Image.new("RGB", (200, 100), "#001122").save(background) + + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 100, "height": 50, "unit": "px"}, + "text_regions": [ + { + "id": "t1", + "text": "标题", + "bbox": [10, 10, 25, 20], + "role": "title", + "color": "#FFFFFF", + } + ], + "bitmap_regions": [ + {"id": "b1", "type": "icon", "bbox": [60, 10, 75, 25]}, + ], + "shape_regions": [ + {"id": "panel", "type": "rounded_rect", "bbox": [0, 0, 100, 50]}, + {"id": "divider", "type": "divider", "bbox": [10, 30, 90, 31]}, + ], + } + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + ) + asset_path = root / page.bitmap_assets[0].asset_path + asset = Image.open(asset_path).convert("RGBA") + transparent_alpha = asset.getpixel((1, 1))[3] + foreground_alpha = asset.getpixel((14, 15))[3] + enclosed_detail_alpha = asset.getpixel((15, 15))[3] + + self.assertIsInstance(page, PageManifest) + self.assertEqual(page.chosen_background, "backgrounds/0000-slide-a/clean.png") + self.assertEqual([text.text for text in page.text_boxes], ["标题"]) + self.assertEqual(page.text_boxes[0].source_pixel_bbox, (20, 20, 50, 40)) + self.assertTrue(page.text_boxes[0].style_hints["bold"]) + self.assertEqual(len(page.bitmap_assets), 1) + self.assertEqual(page.bitmap_assets[0].source_pixel_bbox, (120, 20, 150, 50)) + self.assertEqual(transparent_alpha, 0) + self.assertEqual(foreground_alpha, 255) + self.assertEqual(enclosed_detail_alpha, 255) + self.assertEqual(len(page.native_shapes), 1) + self.assertEqual(page.native_shapes[0].shape_type, "line") + self.assertEqual(page.provenance["reconstruction_strategy"], "vlm_first") + + def test_selects_source_preserved_crop_when_generated_asset_drifts_from_source(self): + from src.generative_editable_manifest import BitmapAssetSpec + from src.generative_editable_vlm_reconstruction import _select_source_faithful_bitmap_assets + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + generated_path = root / "assets" / "0000-slide-a" / "asset.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + generated_path.parent.mkdir(parents=True) + source_image = Image.new("RGB", (120, 80), "#001122") + ImageDraw.Draw(source_image).rectangle((30, 20, 70, 50), fill="#22AAFF") + source_image.save(source) + Image.new("RGB", (120, 80), "#001122").save(background) + generated = Image.new("RGBA", (40, 30), (0, 0, 0, 0)) + ImageDraw.Draw(generated).rectangle((0, 0, 39, 29), fill="#FF0000") + generated.save(generated_path) + asset = BitmapAssetSpec( + asset_id="b1", + source_pixel_bbox=(30, 20, 70, 50), + asset_path="assets/0000-slide-a/asset.png", + z_order=1, + provenance={"split_method": "connected_components"}, + ) + + selected = _select_source_faithful_bitmap_assets( + generated_assets=[asset], + source_image_path=source, + clean_background_path=background, + artifact_root=root, + slide_id="slide-a", + page_index=0, + ) + selected_path = root / selected[0].asset_path + selected_pixel = Image.open(selected_path).convert("RGBA").getpixel((20, 15)) + + self.assertEqual(selected[0].provenance["asset_strategy"], "source_preserved_crop") + self.assertEqual(selected[0].provenance["original_source_pixel_bbox"], [30, 20, 70, 50]) + self.assertEqual(selected[0].source_pixel_bbox, (22, 12, 78, 58)) + self.assertEqual(selected_pixel[:3], (34, 170, 255)) + + def test_foreground_crop_preserves_edge_foreground_close_to_background(self): + from src.generative_editable_vlm_reconstruction import _foreground_rgba_crop + + source = Image.new("RGB", (20, 12), "#001122") + background = Image.new("RGB", (20, 12), "#001122") + ImageDraw.Draw(source).rectangle((0, 3, 8, 9), fill="#102132") + + crop = _foreground_rgba_crop(source, background, (0, 0, 12, 12)) + + self.assertEqual(crop.getpixel((0, 6))[3], 255) + + def test_foreground_crop_can_mask_overlapping_text_regions(self): + from src.generative_editable_vlm_reconstruction import _foreground_rgba_crop + + source = Image.new("RGB", (40, 24), "#001122") + background = Image.new("RGB", (40, 24), "#001122") + draw = ImageDraw.Draw(source) + draw.rectangle((4, 6, 15, 18), fill="#FFFFFF") + draw.rectangle((26, 6, 34, 18), fill="#22AAFF") + + crop = _foreground_rgba_crop( + source, + background, + (0, 0, 40, 24), + transparent_bboxes=[(0, 0, 20, 24)], + ) + + self.assertEqual(crop.getpixel((8, 12))[3], 0) + self.assertEqual(crop.getpixel((30, 12))[3], 255) + + def test_refines_vlm_bitmap_candidate_bbox_with_clean_background_difference(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_vlm_reconstruction import _refine_candidates_with_clean_background_difference + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + background = root / "background.png" + source_image = Image.new("RGB", (160, 100), "#001122") + ImageDraw.Draw(source_image).rectangle((44, 28, 104, 62), fill="#22AAFF") + source_image.save(source) + Image.new("RGB", (160, 100), "#001122").save(background) + candidate = ForegroundCandidate( + candidate_id="b1", + source_pixel_bbox=(55, 34, 92, 55), + area=777, + classification="bitmap_asset_candidate", + ) + + refined = _refine_candidates_with_clean_background_difference( + candidates=[candidate], + source_image_path=source, + clean_background_path=background, + ) + + self.assertEqual(refined[0].source_pixel_bbox, (44, 28, 105, 63)) + self.assertEqual(refined[0].provenance["original_vlm_bbox"], [55, 34, 92, 55]) + + def test_bridges_narrow_complex_candidate_with_same_group_row_neighbor(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_vlm_reconstruction import ( + _bridge_narrow_same_group_complex_candidates, + ) + + candidates = [ + ForegroundCandidate( + candidate_id="left", + source_pixel_bbox=(457, 393, 882, 621), + area=96900, + classification="complex_whole_visual", + provenance={"group_id": "g_middle"}, + ), + ForegroundCandidate( + candidate_id="right", + source_pixel_bbox=(976, 418, 1180, 605), + area=38148, + classification="complex_whole_visual", + provenance={"group_id": "g_middle"}, + ), + ForegroundCandidate( + candidate_id="bottom", + source_pixel_bbox=(848, 649, 1173, 841), + area=62400, + classification="complex_whole_visual", + provenance={"group_id": "g_bottom"}, + ), + ] + + bridged = _bridge_narrow_same_group_complex_candidates(candidates) + + self.assertLessEqual(bridged[1].source_pixel_bbox[0], 856) + self.assertEqual(bridged[1].source_pixel_bbox[1:], (418, 1180, 605)) + self.assertEqual(bridged[2].source_pixel_bbox, (848, 649, 1173, 841)) + self.assertEqual(bridged[1].provenance["bbox_refinement"], "same_group_row_bridge") + + def test_vlm_text_regions_gate_ocr_boxes_to_prevent_stray_text(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (200, 100), "#001122").save(source) + Image.new("RGB", (200, 100), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 200, "height": 100, "unit": "px"}, + "text_regions": [ + {"id": "t1", "text": "OCR 粗文", "bbox": [20, 20, 90, 38]}, + ], + "bitmap_regions": [ + {"id": "b1", "type": "icon", "bbox": [8, 18, 45, 40]}, + ], + "shape_regions": [], + } + ) + matching_ocr = TextBoxSpec( + text="OCR 正文", + source_pixel_bbox=(8, 18, 92, 40), + source_pixel_polygon=((8, 18), (92, 18), (92, 40), (8, 40)), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + stray_ocr = TextBoxSpec( + text="串位文本", + source_pixel_bbox=(120, 70, 180, 88), + source_pixel_polygon=((120, 70), (180, 70), (180, 88), (120, 88)), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[matching_ocr, stray_ocr], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["OCR 正文"]) + self.assertEqual(page.text_boxes[0].provenance["content_source"], "ocr") + self.assertEqual(page.text_boxes[0].source_pixel_bbox, (53, 20, 123, 38)) + self.assertTrue(page.text_boxes[0].style_hints["approximate_layout"]) + + def test_bitmap_overlap_avoidance_does_not_shift_minor_edge_overlap(self): + from src.generative_editable_manifest import BitmapAssetSpec, TextBoxSpec + from src.generative_editable_vlm_reconstruction import _avoid_bitmap_text_overlap + + text_box = TextBoxSpec( + text="Livis版800V主动悬架", + source_pixel_bbox=(1292, 540, 1502, 563), + source_pixel_polygon=((1292, 540), (1502, 540), (1502, 563), (1292, 563)), + provenance={"content_source": "ocr", "layout_source": "vlm"}, + ) + asset = BitmapAssetSpec( + asset_id="component", + source_pixel_bbox=(1004, 418, 1312, 605), + asset_path="assets/component.png", + z_order=1, + provenance={"asset_strategy": "source_preserved_crop"}, + ) + + adjusted = _avoid_bitmap_text_overlap([text_box], [asset], (1600, 900)) + + self.assertEqual(adjusted[0].source_pixel_bbox, (1292, 540, 1502, 563)) + + def test_vlm_text_region_can_keep_multiple_ocr_lines_without_stray_text(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (240, 120), "#001122").save(source) + Image.new("RGB", (240, 120), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 240, "height": 120, "unit": "px"}, + "text_regions": [ + {"id": "t1", "text": "第一行 第二行", "bbox": [20, 20, 150, 70]}, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + first_line = TextBoxSpec( + text="第一行", + source_pixel_bbox=(22, 22, 90, 40), + source_pixel_polygon=((22, 22), (90, 22), (90, 40), (22, 40)), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + second_line = TextBoxSpec( + text="第二行", + source_pixel_bbox=(22, 48, 90, 66), + source_pixel_polygon=((22, 48), (90, 48), (90, 66), (22, 66)), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + stray_line = TextBoxSpec( + text="串位", + source_pixel_bbox=(180, 90, 220, 108), + source_pixel_polygon=((180, 90), (220, 90), (220, 108), (180, 108)), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[first_line, second_line, stray_line], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["第一行", "第二行"]) + self.assertEqual([box.provenance["layout_source"] for box in page.text_boxes], ["ocr", "ocr"]) + + def test_vlm_text_resolution_drops_overlapping_duplicate_text(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (320, 180), "#001122").save(source) + Image.new("RGB", (320, 180), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 320, "height": 180, "unit": "px"}, + "text_regions": [ + {"id": "t1", "text": "21英寸4K后舱屏", "bbox": [220, 46, 298, 66]}, + {"id": "t2", "text": "21 英寸 4K 后舱屏", "bbox": [218, 50, 302, 72]}, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_line = TextBoxSpec( + text="21英寸4K后舱屏", + source_pixel_bbox=(220, 46, 298, 66), + source_pixel_polygon=((220, 46), (298, 46), (298, 66), (220, 66)), + font_size=10, + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_line], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["21英寸4K后舱屏"]) + self.assertEqual(page.text_boxes[0].provenance["content_source"], "ocr") + + def test_vlm_text_resolution_drops_nearby_ocr_vlm_duplicate_text(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1280, 720), "#001122").save(source) + Image.new("RGB", (1280, 720), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1280, "height": 720, "unit": "px"}, + "text_regions": [ + {"id": "title-vlm", "text": "理想L9:", "bbox": [67, 168, 433, 253]}, + { + "id": "subtitle", + "text": "旗舰增程SUV的技术实验", + "bbox": [67, 279, 934, 362], + }, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_title = TextBoxSpec( + text="理想L9:", + source_pixel_bbox=(85, 70, 298, 94), + source_pixel_polygon=((85, 70), (298, 70), (298, 94), (85, 94)), + font_size=18, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_title], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["理想L9:", "旗舰增程SUV的技术实验"]) + self.assertEqual(page.text_boxes[0].provenance["content_source"], "ocr") + + def test_vlm_text_resolution_drops_nearby_duplicate_after_ocr_match(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1280, 720), "#001122").save(source) + Image.new("RGB", (1280, 720), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1280, "height": 720, "unit": "px"}, + "text_regions": [ + {"id": "title-ocr-layout", "text": "理想L9:", "bbox": [85, 70, 296, 94]}, + {"id": "title-vlm-duplicate", "text": "理想L9:", "bbox": [67, 168, 433, 250]}, + { + "id": "subtitle", + "text": "旗舰增程SUV的技术实验", + "bbox": [67, 279, 934, 362], + }, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_title = TextBoxSpec( + text="理想L9:", + source_pixel_bbox=(85, 70, 296, 94), + source_pixel_polygon=((85, 70), (296, 70), (296, 94), (85, 94)), + font_size=18, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_title], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["理想L9:", "旗舰增程SUV的技术实验"]) + self.assertEqual(page.text_boxes[0].provenance["content_source"], "ocr") + + def test_vlm_text_resolution_keeps_real_adjacent_duplicate_text(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (400, 240), "#001122").save(source) + Image.new("RGB", (400, 240), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 400, "height": 240, "unit": "px"}, + "text_regions": [ + {"id": "status-row-1", "text": "Pending", "bbox": [80, 80, 220, 106]}, + {"id": "status-row-2", "text": "Pending", "bbox": [80, 125, 220, 151]}, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_row = TextBoxSpec( + text="Pending", + source_pixel_bbox=(80, 80, 220, 106), + source_pixel_polygon=((80, 80), (220, 80), (220, 106), (80, 106)), + font_size=12, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.9}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_row], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["Pending", "Pending"]) + + def test_vlm_text_resolution_drops_low_confidence_label_near_product_bitmap(self): + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1600, 900), "#001122").save(source) + Image.new("RGB", (1600, 900), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1600, "height": 900, "unit": "px"}, + "text_regions": [ + { + "id": "license", + "text": "理想L9", + "bbox": [702, 708, 783, 730], + "role": "label", + "confidence": 0.72, + }, + { + "id": "caption", + "text": "官方帮助中心 OTA更新说明", + "bbox": [1424, 882, 1672, 908], + "role": "label", + "confidence": 0.89, + }, + ], + "bitmap_regions": [ + { + "id": "car", + "type": "product", + "bbox": [0, 523, 685, 927], + "importance": "major", + }, + { + "id": "qr", + "type": "qr", + "bbox": [1369, 643, 1634, 905], + "importance": "major", + }, + ], + "shape_regions": [], + } + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["官方帮助中心 OTA更新说明"]) + + def test_vlm_text_font_size_leaves_renderer_headroom_for_approximate_bboxes(self): + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1672, 941), "#001122").save(source) + Image.new("RGB", (1672, 941), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1672, "height": 941, "unit": "px"}, + "text_regions": [ + { + "id": "dense-body", + "text": "连接充电枪不可启动车辆", + "bbox": [160, 820, 504, 857], + "role": "body", + "confidence": 0.94, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + ) + + self.assertLessEqual(page.text_boxes[0].font_size or 0, 10.6) + self.assertTrue(page.text_boxes[0].style_hints["approximate_layout"]) + + def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_mismatched_ocr(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1672, 941), "#001122").save(source) + Image.new("RGB", (1672, 941), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1672, "height": 941, "unit": "px"}, + "text_regions": [ + { + "id": "bottom-right", + "text": "保持视野清晰", + "bbox": [1419, 808, 1587, 843], + "role": "body", + "confidence": 0.95, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + overlapping_wrong_ocr = TextBoxSpec( + text="保持50-80%电量", + source_pixel_bbox=(1280, 797, 1593, 867), + source_pixel_polygon=((1280, 797), (1593, 797), (1593, 867), (1280, 867)), + font_size=18, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[overlapping_wrong_ocr], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["保持视野清晰"]) + self.assertEqual(page.text_boxes[0].provenance["provider_role"], "VLM") + + def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_partial_ocr_substring(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (800, 450), "#001122").save(source) + Image.new("RGB", (800, 450), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 800, "height": 450, "unit": "px"}, + "text_regions": [ + { + "id": "instruction", + "text": "连接充电枪不可启动车辆", + "bbox": [100, 380, 380, 410], + "role": "body", + "confidence": 0.96, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + partial_ocr = TextBoxSpec( + text="连接充电枪不可启动", + source_pixel_bbox=(100, 380, 380, 410), + source_pixel_polygon=((100, 380), (380, 380), (380, 410), (100, 410)), + font_size=18, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[partial_ocr], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["连接充电枪不可启动车辆"]) + self.assertEqual(page.text_boxes[0].provenance["provider_role"], "VLM") + + def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_multiple_bad_ocr_fragments(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (800, 450), "#001122").save(source) + Image.new("RGB", (800, 450), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 800, "height": 450, "unit": "px"}, + "text_regions": [ + { + "id": "instruction", + "text": "连接充电枪不可启动车辆", + "bbox": [100, 380, 420, 412], + "role": "body", + "confidence": 0.96, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_fragments = [ + TextBoxSpec( + text="连接充电枪不可启动", + source_pixel_bbox=(100, 380, 300, 397), + source_pixel_polygon=((100, 380), (300, 380), (300, 397), (100, 397)), + font_size=12, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ), + TextBoxSpec( + text="车", + source_pixel_bbox=(306, 380, 420, 397), + source_pixel_polygon=((306, 380), (420, 380), (420, 397), (306, 397)), + font_size=12, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.76}, + ), + ] + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=ocr_fragments, + ) + + self.assertEqual([box.text for box in page.text_boxes], ["连接充电枪不可启动车辆"]) + self.assertEqual(page.text_boxes[0].provenance["provider_role"], "VLM") + + def test_vlm_text_resolution_keeps_overlapping_substring_labels(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (320, 180), "#001122").save(source) + Image.new("RGB", (320, 180), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 320, "height": 180, "unit": "px"}, + "text_regions": [ + {"id": "t1", "text": "Revenue", "bbox": [40, 40, 110, 62]}, + {"id": "t2", "text": "Revenue Q1", "bbox": [42, 44, 145, 68]}, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + text_boxes = [ + TextBoxSpec( + text="Revenue", + source_pixel_bbox=(40, 40, 110, 62), + source_pixel_polygon=((40, 40), (110, 40), (110, 62), (40, 62)), + font_size=10, + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ), + TextBoxSpec( + text="Revenue Q1", + source_pixel_bbox=(42, 44, 145, 68), + source_pixel_polygon=((42, 44), (145, 44), (145, 68), (42, 68)), + font_size=10, + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ), + ] + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=text_boxes, + ) + + self.assertEqual([box.text for box in page.text_boxes], ["Revenue", "Revenue Q1"]) + + def test_vlm_text_resolution_keeps_short_repeated_labels(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (320, 180), "#001122").save(source) + Image.new("RGB", (320, 180), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 320, "height": 180, "unit": "px"}, + "text_regions": [ + {"id": "t1", "text": "OK", "bbox": [40, 40, 80, 62]}, + {"id": "t2", "text": "OK", "bbox": [44, 44, 84, 66]}, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + text_boxes = [ + TextBoxSpec( + text="OK", + source_pixel_bbox=(40, 40, 80, 62), + source_pixel_polygon=((40, 40), (80, 40), (80, 62), (40, 62)), + font_size=10, + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ), + TextBoxSpec( + text="OK", + source_pixel_bbox=(44, 44, 84, 66), + source_pixel_polygon=((44, 44), (84, 44), (84, 66), (44, 66)), + font_size=10, + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ), + ] + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=text_boxes, + ) + + self.assertEqual([box.text for box in page.text_boxes], ["OK", "OK"]) + + def test_vlm_text_resolution_uses_ocr_layout_for_short_text(self): + from src.generative_editable_manifest import BitmapAssetSpec, TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1600, 900), "#001122").save(source) + Image.new("RGB", (1600, 900), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1600, "height": 900, "unit": "px"}, + "text_regions": [ + {"id": "t1", "text": "EMB", "bbox": [1235, 587, 1287, 613]}, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_box = TextBoxSpec( + text="EMB", + source_pixel_bbox=(1292, 575, 1340, 595), + source_pixel_polygon=((1292, 575), (1340, 575), (1340, 595), (1292, 595)), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + asset = BitmapAssetSpec( + asset_id="component", + source_pixel_bbox=(1004, 418, 1312, 605), + asset_path="assets/component.png", + z_order=1, + provenance={"asset_strategy": "source_preserved_crop"}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_box], + bitmap_assets=[asset], + ) + + self.assertEqual(page.text_boxes[0].source_pixel_bbox, (1292, 575, 1340, 595)) + self.assertEqual(page.text_boxes[0].provenance["layout_source"], "ocr") + + def test_vlm_fallback_does_not_duplicate_nearby_ocr_text_line(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1600, 900), "#001122").save(source) + Image.new("RGB", (1600, 900), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1600, "height": 900, "unit": "px"}, + "text_regions": [ + { + "id": "t1", + "text": "Livis版800V主动悬架", + "bbox": [1291, 558, 1501, 589], + }, + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_box = TextBoxSpec( + text="Livis版800V主动悬架", + source_pixel_bbox=(1292, 540, 1502, 563), + source_pixel_polygon=((1292, 540), (1502, 540), (1502, 563), (1292, 563)), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_box], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["Livis版800V主动悬架"]) + self.assertEqual(page.text_boxes[0].source_pixel_bbox, (1292, 540, 1502, 563)) + + def test_without_vlm_text_regions_ocr_is_not_spread_across_page(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (240, 120), "#001122").save(source) + Image.new("RGB", (240, 120), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 240, "height": 120, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_boxes = [ + TextBoxSpec( + text=f"OCR {index}", + source_pixel_bbox=(10, 10 + index * 20, 80, 25 + index * 20), + source_pixel_polygon=( + (10, 10 + index * 20), + (80, 10 + index * 20), + (80, 25 + index * 20), + (10, 25 + index * 20), + ), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + for index in range(5) + ] + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=ocr_boxes, + ) + + self.assertEqual(page.text_boxes, []) + self.assertEqual(page.provenance["vlm_counts"]["text_regions"], 0) + + def test_openai_chat_vlm_provider_requests_compact_json_from_gateway_friendly_image(self): + from src.generative_editable_vlm_reconstruction import OpenAIChatVLMPageAnalysisProvider + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (1600, 900), "#001122").save(source) + payloads = [] + provider = OpenAIChatVLMPageAnalysisProvider( + ProviderConfig( + role="VLM", + provider="openai_chat", + model="gpt-5.5", + base_url="https://example.invalid/v1", + api_key="secret", + ) + ) + + def fake_post(config, payload, operation, timeout_seconds): + payloads.append(payload) + return { + "choices": [ + { + "message": { + "content": ( + '{"coordinate_space":{"width":768,"height":432,"unit":"px"},' + '"text_regions":[{"id":"t1","text":"标题","bbox":[10,10,100,40]}],' + '"bitmap_regions":[],"shape_regions":[]}' + ) + } + } + ] + } + + with patch( + "src.generative_editable_vlm_reconstruction._post_openai_chat", + side_effect=fake_post, + ): + analysis = provider.analyze_page(str(source), timeout_seconds=99) + + self.assertEqual(analysis.coordinate_space.width, 768) + self.assertEqual(analysis.text_regions[0].text, "标题") + self.assertEqual(payloads[0]["model"], "gpt-5.5") + self.assertEqual(payloads[0]["response_format"], {"type": "json_object"}) + self.assertLessEqual(payloads[0]["max_tokens"], 3000) + content = payloads[0]["messages"][0]["content"] + self.assertEqual(content[0]["type"], "text") + self.assertIn("只输出 JSON", content[0]["text"]) + self.assertIn("768x432", content[0]["text"]) + self.assertEqual(content[1]["image_url"]["detail"], "low") + self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,")) + + def test_vlm_provider_falls_back_to_alternate_payload_shape_after_retryable_error(self): + from src.generative_editable_vlm_reconstruction import OpenAIChatVLMPageAnalysisProvider + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + Image.new("RGB", (1600, 900), "#001122").save(source) + payloads = [] + provider = OpenAIChatVLMPageAnalysisProvider( + ProviderConfig( + role="VLM", + provider="openai_chat", + model="gpt-5.5", + base_url="https://example.invalid/v1", + api_key="secret", + ) + ) + + def fake_post(config, payload, operation, timeout_seconds): + payloads.append(payload) + if len(payloads) == 1: + raise ProviderError( + provider_role="VLM", + operation="vlm_page_analysis", + message="503 no healthy upstream", + retryable=True, + ) + return { + "choices": [ + { + "message": { + "content": ( + '{"coordinate_space":{"width":768,"height":432,"unit":"px"},' + '"text_regions":[{"id":"t1","text":"标题","bbox":[10,10,100,40]}],' + '"bitmap_regions":[],"shape_regions":[]}' + ) + } + } + ] + } + + with patch( + "src.generative_editable_vlm_reconstruction._post_openai_chat", + side_effect=fake_post, + ): + analysis = provider.analyze_page(str(source), timeout_seconds=99) + + self.assertEqual(analysis.text_regions[0].text, "标题") + self.assertEqual(len(payloads), 2) + first_image = payloads[0]["messages"][0]["content"][1]["image_url"] + second_image = payloads[1]["messages"][0]["content"][1]["image_url"] + self.assertEqual(first_image["detail"], "low") + self.assertNotIn("detail", second_image) + + def test_vlm_provider_records_payload_variant_attempts_on_success_after_error(self): + from src.generative_editable_vlm_reconstruction import OpenAIChatVLMPageAnalysisProvider + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + Image.new("RGB", (1600, 900), "#001122").save(source) + calls = 0 + provider = OpenAIChatVLMPageAnalysisProvider( + ProviderConfig( + role="VLM", + provider="openai_chat", + model="gpt-5.5", + base_url="https://example.invalid/v1", + api_key="secret", + ) + ) + + def fake_post(config, payload, operation, timeout_seconds): + nonlocal calls + calls += 1 + if calls == 1: + raise ProviderError( + provider_role="VLM", + operation="vlm_page_analysis", + message="503 no healthy upstream api_key=secret", + retryable=True, + status_code=503, + ) + return { + "choices": [ + { + "message": { + "content": ( + '{"coordinate_space":{"width":768,"height":432,"unit":"px"},' + '"text_regions":[{"id":"t1","text":"标题","bbox":[10,10,100,40]}],' + '"bitmap_regions":[],"shape_regions":[]}' + ) + } + } + ] + } + + with patch( + "src.generative_editable_vlm_reconstruction._post_openai_chat", + side_effect=fake_post, + ): + provider.analyze_page(str(source), timeout_seconds=99) + + self.assertEqual( + [(item["attempt"], item["status"]) for item in provider.last_payload_attempts], + [(1, "failed"), (2, "passed")], + ) + self.assertEqual(provider.last_payload_attempts[0]["status_code"], 503) + self.assertNotIn("secret", provider.last_payload_attempts[0]["error"]) + + def test_vlm_provider_falls_back_after_payload_shape_compatibility_error(self): + from src.generative_editable_vlm_reconstruction import OpenAIChatVLMPageAnalysisProvider + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + Image.new("RGB", (1600, 900), "#001122").save(source) + payloads = [] + provider = OpenAIChatVLMPageAnalysisProvider( + ProviderConfig( + role="VLM", + provider="openai_chat", + model="gpt-5.5", + base_url="https://example.invalid/v1", + api_key="secret", + ) + ) + + def fake_post(config, payload, operation, timeout_seconds): + payloads.append(payload) + if len(payloads) == 1: + raise ProviderError( + provider_role="VLM", + operation="vlm_page_analysis", + message="400 Bad Request: unsupported field image_url.detail", + retryable=False, + ) + return { + "choices": [ + { + "message": { + "content": ( + '{"coordinate_space":{"width":768,"height":432,"unit":"px"},' + '"text_regions":[{"id":"t1","text":"标题","bbox":[10,10,100,40]}],' + '"bitmap_regions":[],"shape_regions":[]}' + ) + } + } + ] + } + + with patch( + "src.generative_editable_vlm_reconstruction._post_openai_chat", + side_effect=fake_post, + ): + analysis = provider.analyze_page(str(source), timeout_seconds=99) + + self.assertEqual(analysis.text_regions[0].text, "标题") + self.assertEqual(len(payloads), 2) + self.assertNotIn("detail", payloads[1]["messages"][0]["content"][1]["image_url"]) + + def test_vlm_provider_rejects_non_json_response(self): + from src.generative_editable_providers import ProviderError + from src.generative_editable_vlm_reconstruction import OpenAIChatVLMPageAnalysisProvider + + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.png" + Image.new("RGB", (200, 100), "#001122").save(source) + provider = OpenAIChatVLMPageAnalysisProvider( + ProviderConfig( + role="VLM", + provider="openai_chat", + model="gpt-5.5", + base_url="https://example.invalid/v1", + api_key="secret", + ) + ) + + def fake_post(config, payload, operation, timeout_seconds): + return {"choices": [{"message": {"content": "I cannot analyze this image."}}]} + + with patch( + "src.generative_editable_vlm_reconstruction._post_openai_chat", + side_effect=fake_post, + ): + with self.assertRaisesRegex(ProviderError, "valid JSON"): + provider.analyze_page(str(source)) + + def test_vlm_pipeline_writes_manifests_and_composes_editable_pptx(self): + from pptx import Presentation + from pptx.enum.shapes import MSO_SHAPE_TYPE + + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class FakeVLMProvider(VLMPageAnalysisProvider): + def __init__(self): + self.calls = [] + + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + self.calls.append((image_path, timeout_seconds)) + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [ + { + "id": "title", + "text": "标题", + "bbox": [20, 20, 70, 36], + "role": "title", + "color": "#FFFFFF", + } + ], + "bitmap_regions": [ + {"id": "icon", "type": "icon", "bbox": [120, 20, 150, 50]}, + ], + "shape_regions": [ + {"id": "divider", "type": "divider", "bbox": [20, 60, 150, 61]}, + ], + } + ) + + class RecordingImageEditProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + self.calls = [] + self.fake = FakeImageEditProvider(self.config) + + def edit(self, request): + self.calls.append(request) + if request.prompt_id == "asset_sheet": + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + sheet = Image.new("RGBA", (240, 120), (0, 0, 0, 0)) + ImageDraw.Draw(sheet).rectangle((30, 24, 90, 84), fill="#22AAFF") + sheet.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "source_image_path": request.source_image_path, + "prompt_id": request.prompt_id, + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "timeout_seconds": request.timeout_seconds, + "mask_path": request.mask_path, + "provenance": {}, + }, + )() + return self.fake.edit(request) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 70, 36), fill="#FFFFFF") + draw.rectangle((120, 20, 150, 50), fill="#22AAFF") + image.save(source) + output = root / "out.pptx" + vlm_provider = FakeVLMProvider() + image_edit_provider = RecordingImageEditProvider() + + result = run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-001", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=vlm_provider, + image_edit_provider=image_edit_provider, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + deck = read_deck_manifest(root / "artifacts" / "job-001" / "deck.json") + page = read_page_manifest(root / "artifacts" / "job-001" / deck.page_manifest_paths[0]) + prs = Presentation(str(output)) + shape_types = [shape.shape_type for shape in prs.slides[0].shapes] + mask = Image.open(root / "artifacts" / "job-001" / "assets" / "0000-slide-a" / "vlm-text-bitmap-mask.png") + + self.assertEqual(result.status, "passed") + self.assertEqual(len(vlm_provider.calls), 1) + self.assertIn("Remove only the masked text, icons", image_edit_provider.calls[0].prompt) + self.assertIn("no readable residual glyphs", image_edit_provider.calls[0].prompt) + self.assertIn("Preserve every unmasked pixel", image_edit_provider.calls[0].prompt) + self.assertIn("asset_sheet", [request.prompt_id for request in image_edit_provider.calls]) + asset_sheet_request = next(request for request in image_edit_provider.calls if request.prompt_id == "asset_sheet") + self.assertEqual(asset_sheet_request.timeout_seconds, 45) + self.assertEqual(mask.getpixel((75, 40)), 255) + self.assertIn("vlm", page.provider_output_paths) + self.assertIn("asset_sheet", page.provider_output_paths) + self.assertEqual([sheet.sheet_id for sheet in page.asset_sheets], ["vlm-asset-sheet-0000"]) + self.assertEqual(page.asset_sheets[0].candidate_ids, ["icon"]) + self.assertEqual(page.bitmap_assets[0].provenance["split_method"], "connected_components") + self.assertEqual(page.provenance["reconstruction_strategy"], "vlm_first") + self.assertEqual(page.provenance["text_mask_path"], "assets/0000-slide-a/vlm-text-bitmap-mask.png") + self.assertEqual([box.text for box in page.text_boxes], ["标题"]) + self.assertGreaterEqual(shape_types.count(MSO_SHAPE_TYPE.TEXT_BOX), 1) + self.assertGreaterEqual(shape_types.count(MSO_SHAPE_TYPE.PICTURE), 1) + self.assertIn(MSO_SHAPE_TYPE.LINE, shape_types) + + def test_vlm_pipeline_falls_back_to_source_crops_when_asset_sheet_provider_fails(self): + from pptx import Presentation + from pptx.enum.shapes import MSO_SHAPE_TYPE + + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class BitmapVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + {"id": "icon", "type": "icon", "bbox": [40, 20, 90, 60]}, + ], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + class FailingAssetSheetProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="asset_sheet_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="503 circuit breaker", + retryable=True, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + ImageDraw.Draw(image).rectangle((40, 20, 90, 60), fill="#22AAFF") + image.save(source) + output = root / "out.pptx" + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-asset-fallback", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=BitmapVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ), + asset_sheet_image_edit_provider=FailingAssetSheetProvider(), + ), + ) + job_dir = root / "artifacts" / "job-asset-fallback" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + prs = Presentation(str(output)) + shape_types = [shape.shape_type for shape in prs.slides[0].shapes] + + self.assertEqual(page.asset_sheets, []) + self.assertEqual(len(page.bitmap_assets), 1) + self.assertEqual(page.bitmap_assets[0].provenance["asset_strategy"], "source_preserved_crop") + self.assertTrue(page.bitmap_assets[0].provenance["asset_sheet_provider_failed"]) + self.assertIn(MSO_SHAPE_TYPE.PICTURE, shape_types) + + def test_vlm_pipeline_uses_local_clean_background_when_clean_provider_fails(self): + from pptx import Presentation + from pptx.enum.shapes import MSO_SHAPE_TYPE + + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class MixedVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [ + { + "id": "title", + "text": "标题", + "bbox": [14, 8, 58, 20], + "confidence": 0.96, + } + ], + "bitmap_regions": [ + {"id": "product", "type": "product", "bbox": [44, 24, 102, 64]}, + ], + "shape_regions": [ + {"id": "divider", "type": "line", "bbox": [10, 72, 150, 73]}, + ], + } + ) + + class FailingCleanProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + if request.prompt_id == "vlm_clean_background": + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="524 timeout", + retryable=True, + ) + return FakeImageEditProvider(self.config).edit(request) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + draw = ImageDraw.Draw(image) + draw.text((14, 8), "标题", fill="#FFFFFF") + draw.rectangle((44, 24, 102, 64), fill="#22AAFF") + draw.line((10, 72, 150, 72), fill="#55CCFF", width=1) + image.save(source) + output = root / "out.pptx" + + result = run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-clean-fallback", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=MixedVLMProvider(), + image_edit_provider=FailingCleanProvider(), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + job_dir = root / "artifacts" / "job-clean-fallback" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + prs = Presentation(str(output)) + shape_types = [shape.shape_type for shape in prs.slides[0].shapes] + + self.assertEqual(result.status, "passed") + self.assertEqual(result.fallback_used, "clean_background_local") + self.assertEqual(page.provider_output_paths["image_edit"], "provider_outputs/image_edit/0000-slide-a/backgrounds.json") + self.assertTrue(page.provenance["clean_background_provider_failed"]) + self.assertEqual(page.provenance["clean_background_strategy"], "local_fill") + self.assertGreaterEqual(len(page.text_boxes), 1) + self.assertGreaterEqual(len(page.bitmap_assets), 1) + self.assertIn(MSO_SHAPE_TYPE.TEXT_BOX, shape_types) + self.assertIn(MSO_SHAPE_TYPE.PICTURE, shape_types) + self.assertNotEqual(shape_types, [MSO_SHAPE_TYPE.PICTURE]) + + def test_vlm_pipeline_does_not_turn_near_full_slide_bitmap_region_into_picture_asset(self): + from pptx import Presentation + from pptx.enum.shapes import MSO_SHAPE_TYPE + + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class FullSlideBitmapVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [ + { + "id": "title", + "text": "标题", + "bbox": [12, 8, 52, 20], + "confidence": 0.96, + } + ], + "bitmap_regions": [ + {"id": "whole", "type": "product", "bbox": [2, 2, 158, 88]}, + ], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + class FailingCleanProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="524 timeout", + retryable=True, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + draw = ImageDraw.Draw(image) + draw.rectangle((2, 2, 158, 88), outline="#22AAFF", width=2) + draw.text((12, 8), "标题", fill="#FFFFFF") + image.save(source) + output = root / "out.pptx" + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-full-slide-guard", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=FullSlideBitmapVLMProvider(), + image_edit_provider=FailingCleanProvider(), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + job_dir = root / "artifacts" / "job-full-slide-guard" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + mask = Image.open(job_dir / "assets" / "0000-slide-a" / "vlm-text-bitmap-mask.png") + prs = Presentation(str(output)) + shape_types = [shape.shape_type for shape in prs.slides[0].shapes] + + self.assertEqual(page.bitmap_assets, []) + self.assertEqual(mask.getpixel((80, 45)), 0) + self.assertEqual(mask.getpixel((18, 14)), 255) + self.assertIn(MSO_SHAPE_TYPE.TEXT_BOX, shape_types) + self.assertNotIn(MSO_SHAPE_TYPE.PICTURE, shape_types) + + def test_vlm_bitmap_mask_and_candidate_filter_use_same_unpadded_ignore_decision(self): + from src.generative_editable_vlm_reconstruction import ( + VLMCoordinateMapper, + _foreground_candidates_from_vlm_bitmap_regions, + build_text_bitmap_mask, + coerce_vlm_analysis, + ) + + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + {"id": "large-panel", "type": "icon", "bbox": [8, 18, 152, 72]}, + ], + "shape_regions": [], + } + ) + mapper = VLMCoordinateMapper( + analysis_space=analysis.coordinate_space, + source_image_size=(160, 90), + ) + + mask, mask_boxes = build_text_bitmap_mask(analysis, mapper, padding=10) + candidates = _foreground_candidates_from_vlm_bitmap_regions(analysis, mapper) + + self.assertEqual([box.region_id for box in mask_boxes], ["large-panel"]) + self.assertEqual([candidate.candidate_id for candidate in candidates], ["large-panel"]) + self.assertEqual(mask.getpixel((1, 10)), 255) + + def test_vlm_pipeline_skips_asset_sheet_for_complex_bitmap_regions(self): + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class ProductOnlyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + {"id": "product", "type": "product", "bbox": [40, 20, 95, 62]}, + ], + "shape_regions": [], + } + ) + + class RecordingAssetSheetProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="asset_sheet_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + self.calls = [] + + def edit(self, request): + self.calls.append(request) + raise AssertionError("complex bitmap regions should not call asset sheet provider") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + ImageDraw.Draw(image).rectangle((40, 20, 95, 62), fill="#22AAFF") + image.save(source) + asset_sheet_provider = RecordingAssetSheetProvider() + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-complex-source", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=ProductOnlyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ), + asset_sheet_image_edit_provider=asset_sheet_provider, + ), + ) + job_dir = root / "artifacts" / "job-complex-source" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + + self.assertEqual(asset_sheet_provider.calls, []) + self.assertEqual(page.asset_sheets, []) + self.assertEqual(len(page.bitmap_assets), 1) + self.assertEqual(page.bitmap_assets[0].provenance["asset_strategy"], "source_preserved_crop") + self.assertEqual(page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region") + self.assertTrue(page.bitmap_assets[0].provenance["background_difference_alpha"]) + self.assertIn("alpha_visible_area_ratio", page.bitmap_assets[0].provenance) + + def test_vlm_pipeline_asset_sheet_failure_does_not_duplicate_complex_source_crops(self): + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class MixedVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + {"id": "product", "type": "product", "bbox": [20, 20, 70, 60]}, + {"id": "icon", "type": "icon", "bbox": [100, 20, 130, 50]}, + ], + "shape_regions": [], + } + ) + + class FailingAssetSheetProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="asset_sheet_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="503 circuit breaker", + retryable=True, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 70, 60), fill="#22AAFF") + draw.rectangle((100, 20, 130, 50), fill="#FFAA00") + image.save(source) + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-mixed-fallback", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=MixedVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ), + asset_sheet_image_edit_provider=FailingAssetSheetProvider(), + ), + ) + job_dir = root / "artifacts" / "job-mixed-fallback" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + + self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["product", "icon"]) + self.assertEqual(page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region") + self.assertTrue(page.bitmap_assets[1].provenance["asset_sheet_provider_failed"]) + + def test_vlm_pipeline_falls_back_when_asset_sheet_component_count_is_short(self): + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class TwoIconVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + {"id": "icon-a", "type": "icon", "bbox": [30, 20, 55, 45]}, + {"id": "icon-b", "type": "icon", "bbox": [95, 20, 125, 50]}, + ], + "shape_regions": [], + } + ) + + class ShortAssetSheetProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="asset_sheet_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + + def edit(self, request): + output = Path(request.output_asset_path) + output.parent.mkdir(parents=True, exist_ok=True) + sheet = Image.new("RGBA", (160, 90), (0, 0, 0, 0)) + ImageDraw.Draw(sheet).rectangle((30, 20, 55, 45), fill="#FFAA00") + sheet.save(output) + return type( + "Result", + (), + { + "output_asset_path": str(output), + "source_image_path": request.source_image_path, + "prompt_id": request.prompt_id, + "provider_role": self.config.role, + "provider_name": self.config.provider, + "model": self.config.model, + "timeout_seconds": request.timeout_seconds, + "mask_path": request.mask_path, + "provenance": {}, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + draw = ImageDraw.Draw(image) + draw.rectangle((30, 20, 55, 45), fill="#FFAA00") + draw.rectangle((95, 20, 125, 50), fill="#22AAFF") + image.save(source) + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-short-sheet", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=TwoIconVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ), + asset_sheet_image_edit_provider=ShortAssetSheetProvider(), + ), + ) + job_dir = root / "artifacts" / "job-short-sheet" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + + self.assertEqual(page.asset_sheets, []) + self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["icon-a", "icon-b"]) + self.assertTrue(all(asset.provenance["asset_sheet_slicing_failed"] for asset in page.bitmap_assets)) + + def test_vlm_pipeline_uses_ocr_for_mask_without_ungated_editable_text(self): + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import OCRProvider + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class MissingTextVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path, *, timeout_seconds=180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [ + {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} + ], + } + ) + + class ExactOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="fake-ocr", + base_url="fake", + api_key="fake", + ) + ) + self.calls = [] + + def extract_text(self, image_path): + self.calls.append(image_path) + return OCRResult( + source_image_path=image_path, + image_size=(160, 90), + provider_role=self.config.role, + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="OCR 正文", + bbox=(20, 20, 90, 38), + polygon=((20, 20), (90, 20), (90, 38), (20, 38)), + confidence=0.99, + color_hex="#FFFFFF", + font_size_hint=18, + provenance={"item_id": "ocr-1"}, + ) + ], + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + ImageDraw.Draw(image).rectangle((20, 20, 90, 38), fill="#FFFFFF") + image.save(source) + output = root / "out.pptx" + ocr_provider = ExactOCRProvider() + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(output), + artifact_root=str(root / "artifacts"), + job_id="job-ocr", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=MissingTextVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + ocr_provider=ocr_provider, + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + job_dir = root / "artifacts" / "job-ocr" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + mask = Image.open(job_dir / "assets" / "0000-slide-a" / "vlm-text-bitmap-mask.png") + + self.assertEqual(len(ocr_provider.calls), 1) + self.assertEqual(page.text_boxes, []) + self.assertIn("ocr", page.provider_output_paths) + self.assertEqual(mask.getpixel((25, 25)), 255) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_profiles.py b/tests/test_model_profiles.py index a7cc6ad..fc4428f 100644 --- a/tests/test_model_profiles.py +++ b/tests/test_model_profiles.py @@ -13,7 +13,7 @@ class ModelProfileResolutionTest(unittest.TestCase): def test_edit_profile_inherits_image_profile_by_default(self): profiles = resolve_model_profiles( { - "prompt_model": { + "text_model": { "id": "prompt", "model": "DeepSeek-V4-Pro", "base_url": "https://text.example/v1", @@ -38,7 +38,7 @@ def test_edit_profile_inherits_image_profile_by_default(self): def test_explicit_edit_profile_overrides_image_profile(self): profiles = resolve_model_profiles( { - "prompt_model": {"model": "text", "base_url": "https://t/v1", "api_key": "t"}, + "text_model": {"model": "text", "base_url": "https://t/v1", "api_key": "t"}, "image_model": {"model": "image", "base_url": "https://i/v1", "api_key": "i"}, "edit_model": { "model": "edit", @@ -68,9 +68,48 @@ def test_public_view_masks_api_keys(self): public = profiles.to_public_dict() - self.assertEqual(public["prompt_model"]["api_key"], "SET") + self.assertEqual(public["text_model"]["api_key"], "SET") self.assertEqual(public["image_model"]["api_key"], "SET") self.assertEqual(public["edit_model"]["api_key"], "SET") + self.assertNotIn("adapter", public["text_model"]) + self.assertNotIn("adapter", public["image_model"]) + self.assertNotIn("adapter", public["edit_model"]) + + def test_text_profile_falls_back_to_vlm_when_text_model_is_omitted(self): + profiles = resolve_model_profiles( + { + "VLM": { + "model": "gpt-5.5", + "base_url": "https://vlm.example/v1", + "api_key": "vlm-key", + }, + "image_model": { + "model": "image", + "base_url": "https://image.example/v1", + "api_key": "image-key", + }, + } + ) + + self.assertEqual(profiles.prompt.model, "gpt-5.5") + self.assertEqual(profiles.prompt.base_url, "https://vlm.example/v1") + self.assertEqual(profiles.prompt.api_key, "vlm-key") + self.assertEqual(profiles.vlm.model, "gpt-5.5") + + def test_optional_vlm_and_ocr_profiles_are_exposed_when_configured(self): + profiles = resolve_model_profiles( + { + "text_model": {"model": "text", "base_url": "https://t/v1", "api_key": "t"}, + "image_model": {"model": "image", "base_url": "https://i/v1", "api_key": "i"}, + "VLM": {"model": "vlm", "base_url": "https://v/v1", "api_key": "v"}, + "ocr_model": {"model": "ocr", "base_url": "https://o/v1", "api_key": "o"}, + } + ) + + public = profiles.to_public_dict() + + self.assertEqual(public["VLM"]["api_key"], "SET") + self.assertEqual(public["ocr_model"]["api_key"], "SET") def test_default_profiles_do_not_load_external_env_file(self): self.assertIsNone(load_profiles_from_env()) diff --git a/tests/test_profile_resolver.py b/tests/test_profile_resolver.py index 2d0dd82..815f52b 100644 --- a/tests/test_profile_resolver.py +++ b/tests/test_profile_resolver.py @@ -1,7 +1,14 @@ import unittest from unittest.mock import patch -from api.models import EditConfig, GenerationConfig, ImageApiConfig, TextApiConfig +from api.models import ( + EditConfig, + GenerationConfig, + ImageApiConfig, + ModelProfileConfig, + ModelProfilesConfig, + TextApiConfig, +) from api.profile_resolver import profiles_from_edit_config, profiles_from_generation_config from src.model_profiles import ModelProfile, ModelProfileSet @@ -92,6 +99,34 @@ def test_complete_frontend_generation_config_overrides_backend_profiles(self): self.assertEqual(profiles.prompt.api_key, "override-text-key") self.assertEqual(profiles.image.api_key, "override-image-key") + def test_model_profile_requests_without_adapter_use_role_defaults(self): + config = GenerationConfig( + model_profiles=ModelProfilesConfig( + text_model=ModelProfileConfig( + model="text", + base_url="https://text.example/v1", + api_key="text-key", + ), + image_model=ModelProfileConfig( + model="image", + base_url="https://image.example/v1", + api_key="image-key", + ), + edit_model=ModelProfileConfig( + model="edit", + base_url="https://edit.example/v1", + api_key="edit-key", + ), + ), + page_count=1, + ) + + profiles = profiles_from_generation_config(config) + + self.assertEqual(profiles.prompt.adapter, "openai_chat") + self.assertEqual(profiles.image.adapter, "raw_chat_multimodal") + self.assertEqual(profiles.edit.adapter, "raw_chat_multimodal") + def test_empty_edit_config_uses_backend_profiles(self): config = EditConfig(api_key="", base_url="", model="gpt-image-2") diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py new file mode 100644 index 0000000..c8313a1 --- /dev/null +++ b/tests/test_real_generative_editable_runner.py @@ -0,0 +1,2836 @@ +import io +import json +import argparse +import tempfile +import sys +import time +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from PIL import Image, ImageDraw + +from scripts.run_real_generative_editable_pptx import main +from src.generative_editable_config import ProviderConfig, load_generative_editable_config +from src.generative_editable_pipeline import GenerativeEditablePipelineDependencies +from src.generative_editable_providers import ( + FakeImageEditProvider, + FakeImageGenerationProvider, + FakeOCRProvider, + ImageGenerationProvider, + ProviderError, +) + + +class RealGenerativeEditableRunnerTest(unittest.TestCase): + def test_default_replay_inputs_exclude_edited_derivatives(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + Image.new("RGB", (800, 450), "#FFFFFF").save(root / "slide_1.png") + Image.new("RGB", (800, 450), "#000000").save(root / "slide_1_edited.png") + Image.new("RGB", (800, 450), "#F8FAFC").save(root / "slide_2.png") + args = argparse.Namespace( + input_images=[], + input_glob=[], + slides=0, + ) + + with patch.object(runner, "DEFAULT_REPLAY_GLOB", str(root / "slide_*.png")): + paths = runner._resolve_input_images(args) + + self.assertEqual([path.name for path in paths], ["slide_1.png", "slide_2.png"]) + + def test_explicit_input_glob_excludes_edited_derivatives(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + Image.new("RGB", (800, 450), "#FFFFFF").save(root / "slide_1.png") + Image.new("RGB", (800, 450), "#000000").save(root / "slide_1_edited.png") + Image.new("RGB", (800, 450), "#F8FAFC").save(root / "slide_2.png") + args = argparse.Namespace( + input_images=[], + input_glob=[str(root / "slide_[0-9]*.png")], + slides=0, + ) + + paths = runner._resolve_input_images(args) + + self.assertEqual([path.name for path in paths], ["slide_1.png", "slide_2.png"]) + + def test_run_mode_accepts_sorted_input_glob_and_writes_report(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + input_dir = root / "inputs" + input_dir.mkdir() + for name, color in (("slide_2.png", "#FFFFFF"), ("slide_1.png", "#F8FAFC")): + image = Image.new("RGB", (800, 450), color) + ImageDraw.Draw(image).rectangle((80, 80, 240, 180), fill="#2563EB") + image.save(input_dir / name) + + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main( + [ + "run", + "--use-fake", + "--input-glob", + str(input_dir / "slide_*.png"), + "--slides", + "2", + "--output-dir", + str(root / "out"), + "--job-id", + "real-test", + ] + ) + + self.assertEqual(exit_code, 0) + line = json.loads(stdout.getvalue().splitlines()[-1]) + report_path = Path(line["report_path"]) + report = json.loads(report_path.read_text(encoding="utf-8")) + self.assertEqual(report["status"], "passed") + self.assertEqual( + [Path(path).name for path in report["input_images"]], + ["slide_1.png", "slide_2.png"], + ) + self.assertTrue(Path(report["output_path"]).exists()) + self.assertEqual(len(report["object_stats"]["slides"]), 2) + self.assertIn("TEXT_BOX", report["object_stats"]["slides"][0]["shape_counts"]) + + def test_run_mode_disables_source_crop_asset_fallback_by_default(self): + from api.routes import export as export_route + + captured = {} + dependency_sentinel = object() + + def fake_pipeline(**kwargs): + from pptx import Presentation + + from src.generative_editable_manifest import DeckManifest, write_manifest + + captured["dependencies"] = kwargs["dependencies"] + output_path = Path(kwargs["output_path"]) + output_path.parent.mkdir(parents=True, exist_ok=True) + Presentation().save(output_path) + job_dir = Path(kwargs["artifact_root"]) / kwargs["job_id"] + job_dir.mkdir(parents=True, exist_ok=True) + (job_dir / "stage-events.jsonl").write_text( + json.dumps({"stage": "compose_deck", "status": "finished"}) + "\n", + encoding="utf-8", + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id=kwargs["job_id"], + slide_order=[], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=[], + validation_status="passed", + ), + ) + return type( + "Result", + (), + { + "status": "passed", + "output_path": str(output_path), + "fallback_used": False, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_generative_editable_pipeline_dependencies", + return_value=export_route._build_fake_generative_editable_pipeline_dependencies(), + ), + patch( + "scripts.run_real_generative_editable_pptx.run_generative_editable_pipeline", + side_effect=fake_pipeline, + ), + patch( + "scripts.run_real_generative_editable_pptx._write_preview_reports", + return_value=[], + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "fallback-test", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertFalse(captured["dependencies"].allow_source_crop_asset_fallback) + + def test_run_mode_passes_runtime_fallback_policy_to_pipeline(self): + from api.routes import export as export_route + + captured = {} + + def fake_pipeline(**kwargs): + from pptx import Presentation + + from src.generative_editable_manifest import DeckManifest, write_manifest + + captured["fallback_policy"] = kwargs.get("fallback_policy") + captured["fallback_output_factory"] = kwargs.get("fallback_output_factory") + output_path = Path(kwargs["output_path"]) + output_path.parent.mkdir(parents=True, exist_ok=True) + Presentation().save(output_path) + job_dir = Path(kwargs["artifact_root"]) / kwargs["job_id"] + job_dir.mkdir(parents=True, exist_ok=True) + (job_dir / "stage-events.jsonl").write_text( + json.dumps({"stage": "compose_deck", "status": "finished"}) + "\n", + encoding="utf-8", + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id=kwargs["job_id"], + slide_order=[], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="raster_pptx", + page_manifest_paths=[], + validation_status="passed", + ), + ) + return type( + "Result", + (), + { + "status": "passed", + "output_path": str(output_path), + "fallback_used": "", + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_generative_editable_pipeline_dependencies", + return_value=export_route._build_fake_generative_editable_pipeline_dependencies(), + ), + patch( + "scripts.run_real_generative_editable_pptx.run_generative_editable_pipeline", + side_effect=fake_pipeline, + ), + patch( + "scripts.run_real_generative_editable_pptx._write_preview_reports", + return_value=[], + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "fallback-policy", + "--fallback-policy", + "raster_pptx", + ] + ) + fallback_path = captured["fallback_output_factory"]() + + self.assertEqual(exit_code, 0) + self.assertEqual(captured["fallback_policy"], "raster_pptx") + self.assertEqual( + fallback_path, + str((root / "out" / "fallback-policy.raster-fallback.pptx").resolve()), + ) + + def test_run_mode_can_route_to_vlm_first_pipeline(self): + import scripts.run_real_generative_editable_pptx as runner + + captured = {} + dependency_sentinel = object() + + def fake_vlm_pipeline(**kwargs): + from pptx import Presentation + + from src.generative_editable_manifest import DeckManifest, write_manifest + + captured.update(kwargs) + output_path = Path(kwargs["output_path"]) + output_path.parent.mkdir(parents=True, exist_ok=True) + Presentation().save(output_path) + job_dir = Path(kwargs["artifact_root"]) / kwargs["job_id"] + job_dir.mkdir(parents=True, exist_ok=True) + (job_dir / "stage-events.jsonl").write_text( + json.dumps({"stage": "compose_deck", "status": "finished"}) + "\n", + encoding="utf-8", + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id=kwargs["job_id"], + slide_order=[], + aspect_ratio="16:9", + provider_roles={"vlm": "VLM", "image_edit": "edit_model"}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=[], + validation_status="passed", + ), + ) + return type( + "Result", + (), + { + "status": "passed", + "output_path": str(output_path), + "fallback_used": "", + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch.object(runner, "_vlm_dependencies", return_value=dependency_sentinel), + patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object(runner, "_write_preview_reports", return_value=[]), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--mode", + "vlm_first", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "vlm-test", + ] + ) + line = json.loads(stdout.getvalue().splitlines()[-1]) + report = json.loads(Path(line["report_path"]).read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 0) + self.assertEqual(line["status"], "passed") + self.assertIs(captured["dependencies"], dependency_sentinel) + self.assertEqual(captured["slides"][0]["slide_id"], "slide-1") + self.assertEqual(captured["aspect_ratio"], "16:9") + self.assertEqual(report["last_stage_event"]["stage"], "compose_deck") + self.assertEqual(report["last_stage_event"]["status"], "finished") + + def test_vlm_first_run_can_override_provider_retry_settings(self): + import scripts.run_real_generative_editable_pptx as runner + + captured = {} + dependency_sentinel = object() + + def fake_vlm_dependencies(**kwargs): + captured.update(kwargs) + return dependency_sentinel + + def fake_vlm_pipeline(**kwargs): + from pptx import Presentation + + from src.generative_editable_manifest import DeckManifest, write_manifest + + output_path = Path(kwargs["output_path"]) + output_path.parent.mkdir(parents=True, exist_ok=True) + Presentation().save(output_path) + job_dir = Path(kwargs["artifact_root"]) / kwargs["job_id"] + job_dir.mkdir(parents=True, exist_ok=True) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id=kwargs["job_id"], + slide_order=[], + aspect_ratio="16:9", + provider_roles={"vlm": "VLM", "image_edit": "edit_model"}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=[], + validation_status="passed", + ), + ) + return type( + "Result", + (), + { + "status": "passed", + "output_path": str(output_path), + "fallback_used": "", + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch.object(runner, "_vlm_dependencies", side_effect=fake_vlm_dependencies), + patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object(runner, "_write_preview_reports", return_value=[]), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--mode", + "vlm_first", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "vlm-retry", + "--provider-max-attempts", + "6", + "--provider-retry-backoff", + "12.5", + "--pipeline-page-timeout", + "240", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(captured["provider_max_attempts"], 6) + self.assertEqual(captured["provider_retry_backoff_seconds"], 12.5) + self.assertEqual(captured["page_timeout_seconds"], 240) + + def test_vlm_first_run_marks_preview_failure_as_failed(self): + import scripts.run_real_generative_editable_pptx as runner + + def fake_vlm_pipeline(**kwargs): + from pptx import Presentation + + from src.generative_editable_manifest import DeckManifest, write_manifest + + output_path = Path(kwargs["output_path"]) + output_path.parent.mkdir(parents=True, exist_ok=True) + Presentation().save(output_path) + job_dir = Path(kwargs["artifact_root"]) / kwargs["job_id"] + job_dir.mkdir(parents=True, exist_ok=True) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id=kwargs["job_id"], + slide_order=[], + aspect_ratio="16:9", + provider_roles={"vlm": "VLM"}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=[], + validation_status="passed", + ), + ) + return type( + "Result", + (), + { + "status": "passed", + "output_path": str(output_path), + "fallback_used": "", + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch.object(runner, "_vlm_dependencies", return_value=object()), + patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object( + runner, + "_write_preview_reports", + return_value=[{"status": "failed", "issues": [{"code": "preview_similarity_failed"}]}], + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--mode", + "vlm_first", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "vlm-preview-fail", + ] + ) + + self.assertEqual(exit_code, 1) + line = json.loads(stdout.getvalue().splitlines()[-1]) + self.assertEqual(line["status"], "failed") + + def test_run_mode_fails_when_output_is_only_full_slide_picture(self): + import shutil + + from api.routes import export as export_route + from pptx import Presentation + + from src.generative_editable_manifest import DeckManifest, PageManifest, TextBoxSpec, write_manifest + + def fake_pipeline(**kwargs): + source_path = Path(kwargs["slides"][0].image_path) + output_path = Path(kwargs["output_path"]) + output_path.parent.mkdir(parents=True, exist_ok=True) + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture( + str(source_path), + 0, + 0, + width=prs.slide_width, + height=prs.slide_height, + ) + prs.save(output_path) + + job_dir = Path(kwargs["artifact_root"]) / kwargs["job_id"] + sources_dir = job_dir / "sources" + pages_dir = job_dir / "pages" + sources_dir.mkdir(parents=True, exist_ok=True) + pages_dir.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source_path, sources_dir / "source.png") + page = PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/source.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + text_boxes=[ + TextBoxSpec( + text="Editable text", + source_pixel_bbox=(80, 40, 360, 90), + source_pixel_polygon=((80, 40), (360, 40), (360, 90), (80, 90)), + ) + ], + validation_status="passed", + ) + write_manifest(pages_dir / "0000-slide-1.json", page) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id=kwargs["job_id"], + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + validation_status="passed", + ), + ) + return type( + "Result", + (), + { + "status": "passed", + "output_path": str(output_path), + "fallback_used": False, + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_generative_editable_pipeline_dependencies", + return_value=export_route._build_fake_generative_editable_pipeline_dependencies(), + ), + patch( + "scripts.run_real_generative_editable_pptx.run_generative_editable_pipeline", + side_effect=fake_pipeline, + ), + patch( + "scripts.run_real_generative_editable_pptx._write_preview_reports", + return_value=[], + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "full-slide-only", + ] + ) + + result = json.loads(stdout.getvalue().splitlines()[-1]) + report = json.loads(Path(result["report_path"]).read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 1) + self.assertEqual(report["status"], "failed") + self.assertIn("no_decomposed_visual_elements", report["reconstruction_issues"][0]["code"]) + self.assertEqual(report["object_stats"]["slides"][0]["full_slide_picture_count"], 1) + + def test_single_image_edit_gate_checks_only_requested_role(self): + config = load_generative_editable_config(use_fake=True) + calls: list[str] = [] + + class RecordingImageEditProvider(FakeImageEditProvider): + def edit(self, request): + calls.append(self.config.role) + return super().edit(request) + + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=RecordingImageEditProvider( + ProviderConfig( + role="clean_base_model", + provider="fake_image_edit", + model="fake-edit", + base_url="", + api_key="", + ) + ), + asset_sheet_image_edit_provider=RecordingImageEditProvider( + ProviderConfig( + role="asset_sheet_model", + provider="fake_image_edit", + model="fake-edit", + base_url="", + api_key="", + ) + ), + repair_image_edit_provider=RecordingImageEditProvider( + ProviderConfig( + role="repair_model", + provider="fake_image_edit", + model="fake-edit", + base_url="", + api_key="", + ) + ), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "image_edit_repair", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(calls, ["repair_model"]) + result = json.loads(stdout.getvalue().splitlines()[-1]) + self.assertEqual(result["status"], "passed") + self.assertEqual(result["gates"][0]["gate"], "image_edit_repair") + + def test_provider_gates_do_not_retry_by_default(self): + config = load_generative_editable_config(use_fake=True) + + class FlakyImageGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__(config.generation_model) + self.calls = 0 + + def generate(self, request): + self.calls += 1 + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="temporary provider failure", + retryable=True, + ) + + provider = FlakyImageGenerationProvider() + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=provider, + provider_max_attempts=3, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "image_generation", + ] + ) + + self.assertEqual(exit_code, 1) + self.assertEqual(provider.calls, 1) + + def test_provider_gates_include_attempt_history_without_retry_override(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "image_generation", + ] + ) + report = json.loads((root / "out" / "provider-gates.json").read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 0) + self.assertEqual(report["gates"][0]["provider_attempts"][0]["attempt"], 1) + self.assertEqual(report["gates"][0]["provider_attempts"][0]["status"], "passed") + + def test_provider_gate_report_includes_upstream_error_code(self): + config = load_generative_editable_config(use_fake=True) + + class QuotaImageGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__(config.generation_model) + + def generate(self, request): + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="quota exhausted", + retryable=False, + status_code=403, + provider_error_code="insufficient_user_quota", + ) + + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=QuotaImageGenerationProvider(), + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "image_generation", + ] + ) + report = json.loads((root / "out" / "provider-gates.json").read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 1) + self.assertEqual(report["gates"][0]["status_code"], 403) + self.assertEqual(report["gates"][0]["provider_error_code"], "insufficient_user_quota") + + def test_provider_gates_use_cli_retry_override(self): + config = load_generative_editable_config(use_fake=True) + + class FlakyImageGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__(config.generation_model) + self.calls = 0 + self.fake = FakeImageGenerationProvider(config.generation_model) + + def generate(self, request): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="temporary provider failure", + retryable=True, + ) + return self.fake.generate(request) + + provider = FlakyImageGenerationProvider() + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=provider, + provider_max_attempts=1, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "image_generation", + "--provider-max-attempts", + "2", + "--provider-retry-backoff", + "0", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(provider.calls, 2) + + def test_provider_gate_report_includes_retry_attempt_history(self): + config = load_generative_editable_config(use_fake=True) + + class FlakyImageGenerationProvider(ImageGenerationProvider): + def __init__(self): + super().__init__(config.generation_model) + self.calls = 0 + self.fake = FakeImageGenerationProvider(config.generation_model) + + def generate(self, request): + self.calls += 1 + if self.calls == 1: + raise ProviderError( + provider_role=self.config.role, + operation=request.prompt_id, + message="temporary api_key=secret failure", + retryable=True, + status_code=500, + provider_error_code="upstream_timeout", + ) + return self.fake.generate(request) + + provider = FlakyImageGenerationProvider() + dependencies = GenerativeEditablePipelineDependencies( + ocr_provider=FakeOCRProvider(config.ocr), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + image_generation_provider=provider, + provider_max_attempts=1, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch( + "scripts.run_real_generative_editable_pptx.export_route" + "._build_fake_generative_editable_pipeline_dependencies", + return_value=dependencies, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "image_generation", + "--provider-max-attempts", + "2", + "--provider-retry-backoff", + "0", + ] + ) + report = json.loads((root / "out" / "provider-gates.json").read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 0) + gate = report["gates"][0] + self.assertEqual( + [(item["attempt"], item["status"]) for item in gate["provider_attempts"]], + [(1, "failed"), (2, "passed")], + ) + self.assertTrue(gate["provider_attempts"][0]["retrying"]) + self.assertEqual(gate["provider_attempts"][0]["status_code"], 500) + self.assertEqual(gate["provider_attempts"][0]["provider_error_code"], "upstream_timeout") + self.assertNotIn("secret", json.dumps(gate["provider_attempts"], ensure_ascii=False)) + + def test_provider_gates_can_run_vlm_analysis_gate(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "vlm_analysis", + ] + ) + + self.assertEqual(exit_code, 0) + result = json.loads(stdout.getvalue().splitlines()[-1]) + self.assertEqual(result["status"], "passed") + self.assertEqual(result["gates"][0]["gate"], "vlm_analysis") + self.assertGreaterEqual(result["gates"][0]["text_region_count"], 1) + + def test_provider_gate_report_includes_vlm_payload_attempts(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + with_vlm_provider_retries, + ) + + config = load_generative_editable_config(use_fake=True) + + class PayloadFallbackVLMProvider(VLMPageAnalysisProvider): + def __init__(self): + self.last_payload_attempts = [] + + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + self.last_payload_attempts = [ + { + "attempt": 1, + "status": "failed", + "error": "VLM.vlm_page_analysis: 503 no healthy upstream", + "retryable": True, + "retrying": True, + }, + {"attempt": 2, "status": "passed"}, + ] + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [{"id": "t1", "text": "标题", "bbox": [10, 10, 100, 30]}], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + dependencies = with_vlm_provider_retries( + VLMEditablePipelineDependencies( + vlm_provider=PayloadFallbackVLMProvider(), + image_edit_provider=FakeImageEditProvider(config.clean_base_model), + provider_max_attempts=1, + ) + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch.object( + runner, + "_gate_dependencies", + return_value={ + "legacy": object(), + "vlm": dependencies, + }, + ), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "vlm_analysis", + ] + ) + report = json.loads((root / "out" / "provider-gates.json").read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 0) + attempts = report["gates"][0]["provider_attempts"] + self.assertEqual(attempts[0]["status"], "passed") + self.assertEqual( + [(item["attempt"], item["status"]) for item in attempts[0]["payload_attempts"]], + [(1, "failed"), (2, "passed")], + ) + + def test_subprocess_json_runner_times_out_and_writes_report(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report_path = root / "report.json" + + result = runner._run_subprocess_json( + [ + sys.executable, + "-c", + "import time; time.sleep(2)", + ], + timeout_seconds=1, + report_path=report_path, + timeout_payload={ + "status": "failed", + "stage": "test", + }, + ) + + report = json.loads(report_path.read_text(encoding="utf-8")) + + self.assertEqual(result["status"], "failed") + self.assertEqual(result["error_type"], "TimeoutExpired") + self.assertEqual(report["stage"], "test") + self.assertEqual(report["error_type"], "TimeoutExpired") + + def test_subprocess_timeout_report_includes_existing_pptx_object_stats(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pptx_path = root / "partial.pptx" + report_path = root / "report.json" + code = ( + "import sys,time;" + "from pptx import Presentation;" + "p=sys.argv[1];" + "prs=Presentation();" + "prs.slides.add_slide(prs.slide_layouts[6]);" + "prs.save(p);" + "time.sleep(2)" + ) + + result = runner._run_subprocess_json( + [sys.executable, "-c", code, str(pptx_path)], + timeout_seconds=1, + report_path=report_path, + timeout_payload={ + "status": "failed", + "output_path": str(pptx_path), + }, + ) + + report = json.loads(report_path.read_text(encoding="utf-8")) + + self.assertEqual(result["error_type"], "TimeoutExpired") + self.assertEqual(report["object_stats"]["slide_count"], 1) + + def test_timeout_augmentation_skips_oversized_pptx(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pptx_path = root / "large.pptx" + pptx_path.write_bytes(b"0" * (runner.MAX_TIMEOUT_AUGMENT_PPTX_BYTES + 1)) + + result = runner._augment_timeout_result_from_artifacts( + { + "status": "failed", + "output_path": str(pptx_path), + } + ) + + self.assertNotIn("object_stats", result) + self.assertEqual(result["object_stats_error"], "pptx exceeds timeout augmentation size cap") + + def test_timeout_augmentation_removes_missing_output_path(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + + result = runner._augment_timeout_result_from_artifacts( + { + "status": "failed", + "output_path": str(root / "missing.pptx"), + "artifact_root": str(root / "artifacts"), + } + ) + + self.assertNotIn("output_path", result) + + def test_timeout_augmentation_includes_last_stage_event(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact_root = root / "artifacts" + artifact_root.mkdir() + (artifact_root / "stage-events.jsonl").write_text( + "\n".join( + [ + json.dumps( + { + "stage": "ocr", + "status": "finished", + "elapsed_ms": 1000, + } + ), + json.dumps( + { + "stage": "text_clean_background.provider_edit", + "status": "started", + "elapsed_ms": 0, + "provider_role": "edit_model", + } + ), + ] + ) + + "\n", + encoding="utf-8", + ) + + result = runner._augment_timeout_result_from_artifacts( + { + "status": "failed", + "artifact_root": str(artifact_root), + } + ) + + self.assertEqual( + result["last_stage_event"]["stage"], + "text_clean_background.provider_edit", + ) + self.assertEqual(result["active_stage"], "text_clean_background.provider_edit") + + def test_timeout_augmentation_salvages_stage_events_before_malformed_line(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact_root = root / "artifacts" + artifact_root.mkdir() + (artifact_root / "stage-events.jsonl").write_text( + json.dumps( + { + "stage": "base_clean_background", + "status": "started", + } + ) + + "\n{bad-json", + encoding="utf-8", + ) + + result = runner._augment_timeout_result_from_artifacts( + { + "status": "failed", + "artifact_root": str(artifact_root), + } + ) + + self.assertEqual(result["active_stage"], "base_clean_background") + self.assertEqual(result["stage_events_error"], "malformed stage-events line skipped") + + def test_failed_pipeline_report_includes_existing_pptx_diagnostics(self): + import scripts.run_real_generative_editable_pptx as runner + from pptx import Presentation + + from src.generative_editable_manifest import ( + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) + from src.generative_editable_pipeline import GenerativeEditableValidationError + from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact_root = root / "artifacts" + job_dir = artifact_root / "job" + (job_dir / "pages").mkdir(parents=True) + ocr_dir = job_dir / "provider_outputs" / "ocr" / "0000-slide-1" + ocr_dir.mkdir(parents=True) + (ocr_dir / "ocr.json").write_text( + '{"items":[{"text":"a"},{"text":"b"}]}', + encoding="utf-8", + ) + output_path = root / "job.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_textbox(100, 100, 1000, 300).text = "核心架构" + prs.save(output_path) + page = PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + text_boxes=[ + TextBoxSpec( + text="核心架构", + source_pixel_bbox=(10, 10, 120, 40), + source_pixel_polygon=((10, 10), (120, 10), (120, 40), (10, 40)), + ) + ], + ) + write_manifest(job_dir / "pages" / "0000-slide-1.json", page) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={ + "ocr": "ocr_model", + "image_edit": "edit_model", + "image_generation": "image_model", + }, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + error = GenerativeEditableValidationError( + validation_report=ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code="preview_similarity_failed", + message="preview differs", + ) + ], + ), + fallback_policy="fail", + ) + + with patch( + "scripts.run_real_generative_editable_pptx._write_preview_reports", + return_value=[ + { + "page": 1, + "status": "failed", + "diff_metrics": { + "mean_abs_delta": 23.9, + "changed_pixel_ratio": 0.16, + }, + } + ], + ) as preview_reports: + report = runner._failed_pipeline_report_from_artifacts( + error=error, + output_dir=root, + output_path=output_path, + artifact_root=artifact_root, + job_id="job", + input_images=[root / "slide_1.png"], + ) + + self.assertEqual(report["status"], "failed") + self.assertEqual(report["object_stats"]["totals"]["TEXT_BOX"], 1) + self.assertEqual(report["validation_issues"], ["preview_similarity_failed"]) + self.assertEqual(report["deck_manifest_path"], str(job_dir / "deck.json")) + self.assertEqual(report["preview_reports"][0]["status"], "failed") + preview_reports.assert_called_once() + + def test_failed_pipeline_report_includes_provider_error_metadata(self): + import scripts.run_real_generative_editable_pptx as runner + + error = ProviderError( + provider_role="VLM", + operation="vlm_page_analysis", + message="quota exhausted", + retryable=False, + status_code=403, + provider_error_code="insufficient_user_quota", + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report = runner._failed_pipeline_report_from_artifacts( + error=error, + output_dir=root, + output_path=None, + artifact_root=root / "artifacts", + job_id="job", + input_images=[root / "slide_1.png"], + ) + + self.assertEqual(report["status_code"], 403) + self.assertEqual(report["provider_error_code"], "insufficient_user_quota") + self.assertFalse(report["retryable"]) + + def test_preview_report_rejects_manifest_stub_renderer(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import DeckManifest, PageManifest, write_manifest + from src.generative_editable_preview_validator import PreviewRenderResult + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact_root = root / "artifacts" + (artifact_root / "pages").mkdir(parents=True) + source_path = root / "slide_1.png" + source_image = Image.new("RGB", (800, 450), "#020817") + source_image.save(source_path) + page = PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + ) + write_manifest(artifact_root / "pages" / "0000-slide-1.json", page) + deck_path = artifact_root / "deck.json" + write_manifest( + deck_path, + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + with patch( + "scripts.run_real_generative_editable_pptx.render_powerpoint_preview_with_metadata", + return_value=PreviewRenderResult( + image=source_image.copy(), + metadata={"renderer": "manifest_stub", "is_powerpoint_render": False}, + ), + ): + reports = runner._write_preview_reports( + source_images=[source_path], + pptx_path=root / "deck.pptx", + deck_manifest_path=deck_path, + artifact_root=artifact_root, + similarity_threshold=0.8, + output_dir=root / "previews", + ) + + self.assertEqual(reports[0]["status"], "failed") + self.assertEqual(reports[0]["renderer"], "manifest_stub") + self.assertEqual(reports[0]["issues"][0]["code"], "preview_renderer_not_powerpoint") + + def test_reconstruction_issues_fail_for_source_preserving_fast_path_degradation(self): + import scripts.run_real_generative_editable_pptx as runner + from pptx import Presentation + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact_root = root / "artifacts" + job_dir = artifact_root / "job" + (job_dir / "pages").mkdir(parents=True) + ocr_dir = job_dir / "provider_outputs" / "ocr" / "0000-slide-1" + ocr_dir.mkdir(parents=True) + (ocr_dir / "ocr.json").write_text( + '{"items":[{"text":"a"},{"text":"b"}]}', + encoding="utf-8", + ) + output_path = root / "job.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture(str(_solid_png(root / "background.png")), 0, 0, width=prs.slide_width, height=prs.slide_height) + slide.shapes.add_picture(str(_solid_png(root / "anchor.png")), 100, 100, width=1000, height=600) + slide.shapes.add_textbox(100, 100, 1000, 300).text = "核心架构" + prs.save(output_path) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + text_boxes=[ + TextBoxSpec( + text="核心架构", + source_pixel_bbox=(10, 10, 120, 40), + source_pixel_polygon=((10, 10), (120, 10), (120, 40), (10, 40)), + ) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="anchor-001", + source_pixel_bbox=(20, 20, 220, 160), + asset_path="assets/anchor-001.png", + z_order=1, + provenance={"asset_strategy": "source_preserving_anchor"}, + ) + ], + provenance={"chosen_background_kind": "source_preserving_text_fast_path"}, + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={ + "ocr": "ocr_model", + "image_edit": "edit_model", + "image_generation": "image_model", + }, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + stats = runner._pptx_object_stats(output_path) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats=stats, + ) + + self.assertEqual(issues[0]["code"], "source_preserving_fast_path_degraded") + self.assertEqual(issues[0]["severity"], "error") + + def test_pptx_object_stats_count_slide_background_picture(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_manifest import DeckManifest, PageManifest, TextBoxSpec, write_manifest + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "backgrounds").mkdir(parents=True) + (job_dir / "pages").mkdir(parents=True) + Image.new("RGB", (800, 450), "#FFFFFF").save(job_dir / "backgrounds" / "base.png") + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/source.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + chosen_background="backgrounds/base.png", + text_boxes=[ + TextBoxSpec( + text="Editable", + source_pixel_bbox=(100, 100, 220, 140), + source_pixel_polygon=((100, 100), (220, 100), (220, 140), (100, 140)), + ) + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + output_path = root / "out.pptx" + compose_deck_from_manifests(job_dir / "deck.json", job_dir, output_path) + + stats = runner._pptx_object_stats(output_path) + + self.assertEqual(stats["slides"][0]["background_picture_count"], 1) + self.assertEqual(stats["totals"]["BACKGROUND_PICTURE"], 1) + self.assertEqual(stats["slides"][0]["full_slide_picture_count"], 0) + + def test_reconstruction_issues_fail_for_background_only_raster_slide(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_manifest import DeckManifest, PageManifest, write_manifest + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "backgrounds").mkdir(parents=True) + (job_dir / "pages").mkdir(parents=True) + Image.new("RGB", (800, 450), "#FFFFFF").save(job_dir / "backgrounds" / "base.png") + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/source.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + chosen_background="backgrounds/base.png", + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + output_path = root / "out.pptx" + compose_deck_from_manifests(job_dir / "deck.json", job_dir, output_path) + stats = runner._pptx_object_stats(output_path) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats=stats, + ) + + self.assertIn("no_decomposed_visual_elements", [issue["code"] for issue in issues]) + issue = next(issue for issue in issues if issue["code"] == "no_decomposed_visual_elements") + self.assertEqual(issue["details"]["background_picture_count"], 1) + self.assertEqual(issue["details"]["full_slide_picture_count"], 0) + + def test_reconstruction_issues_fail_for_low_opacity_source_overlay(self): + import scripts.run_real_generative_editable_pptx as runner + from pptx import Presentation + + from src.generative_editable_manifest import ( + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact_root = root / "artifacts" + job_dir = artifact_root / "job" + (job_dir / "pages").mkdir(parents=True) + output_path = root / "job.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture(str(_solid_png(root / "background.png")), 0, 0, width=prs.slide_width, height=prs.slide_height) + slide.shapes.add_textbox(100, 100, 1000, 300).text = "核心架构" + prs.save(output_path) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + text_boxes=[ + TextBoxSpec( + text="核心架构", + source_pixel_bbox=(10, 10, 120, 40), + source_pixel_polygon=((10, 10), (120, 10), (120, 40), (10, 40)), + opacity=0.1, + ) + ], + provenance={ + "chosen_background_kind": "source_preserving_low_opacity_text_overlay" + }, + provider_output_paths={ + "ocr": "provider_outputs/ocr/0000-slide-1/ocr.json", + }, + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={ + "ocr": "ocr_model", + "image_edit": "edit_model", + "image_generation": "image_model", + }, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + stats = runner._pptx_object_stats(output_path) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats=stats, + ) + + self.assertEqual([issue["code"] for issue in issues], ["source_preserving_low_opacity_overlay_degraded"]) + self.assertEqual(issues[0]["severity"], "error") + + def test_reconstruction_issues_fail_for_oversized_bitmap_asset_coverage(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + PageManifest, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="huge-source-crop", + source_pixel_bbox=(0, 0, 850, 500), + asset_path="assets/huge-source-crop.png", + z_order=1, + provenance={"asset_strategy": "masked_source_element"}, + ) + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"PICTURE": 2}, + "full_slide_picture_count": 1, + "non_full_slide_picture_count": 1, + "text_box_count": 0, + } + ] + }, + ) + + self.assertEqual(issues[0]["code"], "oversized_bitmap_asset_coverage") + self.assertEqual(issues[0]["severity"], "error") + + def test_reconstruction_issues_warn_for_overlapping_duplicate_text_boxes(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + text_boxes=[ + TextBoxSpec( + text="21英寸4K后舱屏", + source_pixel_bbox=(700, 100, 860, 130), + source_pixel_polygon=((700, 100), (860, 100), (860, 130), (700, 130)), + ), + TextBoxSpec( + text="21 英寸 4K 后舱屏", + source_pixel_bbox=(705, 106, 870, 138), + source_pixel_polygon=((705, 106), (870, 106), (870, 138), (705, 138)), + ), + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"TEXT_BOX": 2}, + "full_slide_picture_count": 0, + "non_full_slide_picture_count": 0, + "text_box_count": 2, + } + ] + }, + ) + + self.assertEqual(issues[0]["code"], "overlapping_duplicate_text_boxes") + self.assertEqual(issues[0]["severity"], "warning") + self.assertEqual(issues[0]["details"]["duplicate_text"], "21英寸4K后舱屏") + + def test_reconstruction_issues_do_not_warn_for_overlapping_substring_text_boxes(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + text_boxes=[ + TextBoxSpec( + text="Revenue", + source_pixel_bbox=(100, 100, 170, 130), + source_pixel_polygon=((100, 100), (170, 100), (170, 130), (100, 130)), + ), + TextBoxSpec( + text="Revenue Q1", + source_pixel_bbox=(104, 106, 220, 138), + source_pixel_polygon=((104, 106), (220, 106), (220, 138), (104, 138)), + ), + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"TEXT_BOX": 2}, + "full_slide_picture_count": 0, + "non_full_slide_picture_count": 0, + "text_box_count": 2, + } + ] + }, + ) + + self.assertEqual(issues, []) + + def test_reconstruction_issues_treat_vlm_source_crop_as_source_preserved_asset(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + PageManifest, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="huge-vlm-crop", + source_pixel_bbox=(0, 0, 850, 500), + asset_path="assets/huge-vlm-crop.png", + z_order=1, + provenance={"source_type": "vlm_source_crop"}, + ) + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"PICTURE": 1}, + "full_slide_picture_count": 0, + "non_full_slide_picture_count": 1, + "text_box_count": 0, + } + ] + }, + ) + + self.assertEqual(issues[0]["code"], "oversized_bitmap_asset_coverage") + self.assertEqual(issues[0]["severity"], "error") + + def test_reconstruction_issues_treat_source_preserved_crop_as_source_preserved_asset(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + PageManifest, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="huge-source-preserved-crop", + source_pixel_bbox=(0, 0, 850, 500), + asset_path="assets/huge-source-preserved-crop.png", + z_order=1, + provenance={"asset_strategy": "source_preserved_crop"}, + ) + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"PICTURE": 1}, + "full_slide_picture_count": 0, + "non_full_slide_picture_count": 1, + "text_box_count": 0, + } + ] + }, + ) + + self.assertEqual(issues[0]["code"], "oversized_bitmap_asset_coverage") + self.assertEqual(issues[0]["severity"], "error") + + def test_reconstruction_issues_degrade_for_high_combined_bitmap_coverage(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + text_boxes=[ + TextBoxSpec( + text="标题", + source_pixel_bbox=(40, 20, 160, 60), + source_pixel_polygon=((40, 20), (160, 20), (160, 60), (40, 60)), + ) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="top-band", + source_pixel_bbox=(0, 0, 1000, 180), + asset_path="assets/top-band.png", + z_order=1, + provenance={"asset_strategy": "masked_source_element"}, + ), + BitmapAssetSpec( + asset_id="middle-band", + source_pixel_bbox=(0, 180, 1000, 180 + 170), + asset_path="assets/middle-band.png", + z_order=2, + provenance={"asset_strategy": "masked_source_element"}, + ), + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"PICTURE": 3, "TEXT_BOX": 1}, + "full_slide_picture_count": 1, + "non_full_slide_picture_count": 2, + "text_box_count": 1, + } + ] + }, + ) + + self.assertEqual(issues[0]["code"], "high_bitmap_asset_coverage") + self.assertEqual(issues[0]["severity"], "warning") + + def test_reconstruction_issues_allow_split_row_level_bitmap_assets(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + NativeShapeSpec, + PageManifest, + TextBoxSpec, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide.png", + source_image_size=(1706, 960), + slide_size=(10.0, 5.625), + native_shapes=[ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(100, 180 + index * 30, 1500, 183 + index * 30), + ) + for index in range(6) + ], + text_boxes=[ + TextBoxSpec( + text="底盘域", + source_pixel_bbox=(160, 450, 260, 490), + source_pixel_polygon=((160, 450), (260, 450), (260, 490), (160, 490)), + ) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id=f"row-{index}", + source_pixel_bbox=(59, top, 1672, bottom), + asset_path=f"assets/row-{index}.png", + z_order=index, + provenance={"asset_strategy": "masked_source_element"}, + ) + for index, (top, bottom) in enumerate( + [(168, 405), (405, 642), (642, 880)], + start=1, + ) + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"PICTURE": 3, "TEXT_BOX": 1, "LINE": 6}, + "full_slide_picture_count": 0, + "non_full_slide_picture_count": 3, + "text_box_count": 1, + } + ] + }, + ) + + self.assertEqual(issues, []) + + def test_reconstruction_issues_allow_dense_infographic_alpha_visible_assets(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + NativeShapeSpec, + PageManifest, + TextBoxSpec, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide.png", + source_image_size=(1672, 941), + slide_size=(10.0, 5.625), + native_shapes=[ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(100, 180 + index * 24, 1500, 183 + index * 24), + ) + for index in range(11) + ], + text_boxes=[ + TextBoxSpec( + text=f"文本{index}", + source_pixel_bbox=(80, 120 + index * 30, 220, 145 + index * 30), + source_pixel_polygon=( + (80, 120 + index * 30), + (220, 120 + index * 30), + (220, 145 + index * 30), + (80, 145 + index * 30), + ), + ) + for index in range(15) + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="upper-band", + source_pixel_bbox=(95, 169, 1576, 419), + asset_path="assets/upper-band.png", + z_order=1, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.097449, + }, + ), + BitmapAssetSpec( + asset_id="small-detail", + source_pixel_bbox=(957, 375, 1002, 437), + asset_path="assets/small-detail.png", + z_order=2, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.000983, + }, + ), + BitmapAssetSpec( + asset_id="lower-visual", + source_pixel_bbox=(0, 407, 1658, 909), + asset_path="assets/lower-visual.png", + z_order=3, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.21248, + }, + ), + BitmapAssetSpec( + asset_id="generic", + source_pixel_bbox=(502, 169, 1207, 405), + asset_path="assets/generic.png", + z_order=4, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + "alpha_visible_area_ratio": 0.069225, + }, + ), + ], + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"PICTURE": 4, "TEXT_BOX": 15, "LINE": 11}, + "full_slide_picture_count": 0, + "non_full_slide_picture_count": 4, + "text_box_count": 15, + } + ] + }, + ) + + self.assertEqual(issues, []) + + def test_reconstruction_issues_fail_for_source_raster_guardrail(self): + import scripts.run_real_generative_editable_pptx as runner + + from src.generative_editable_manifest import ( + BitmapAssetSpec, + DeckManifest, + PageManifest, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + job_dir = root / "artifacts" / "job" + (job_dir / "pages").mkdir(parents=True) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="source-raster", + source_pixel_bbox=(0, 0, 1000, 500), + asset_path="assets/source-raster.png", + z_order=1, + provenance={"asset_strategy": "source_raster_guardrail"}, + ) + ], + provenance={ + "chosen_background_kind": "source_raster_guardrail", + "source_raster_guardrail": {"reason": "unreliable_ocr_layout"}, + }, + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats={ + "slides": [ + { + "shape_counts": {"PICTURE": 1}, + "full_slide_picture_count": 1, + "non_full_slide_picture_count": 0, + "text_box_count": 0, + } + ] + }, + ) + + self.assertEqual(issues[0]["code"], "source_raster_guardrail_degraded") + self.assertEqual(issues[0]["severity"], "error") + + def test_error_reconstruction_issues_mark_run_failed(self): + import scripts.run_real_generative_editable_pptx as runner + + helper = getattr(runner, "_status_with_reconstruction_issues", None) + self.assertIsNotNone(helper) + + self.assertEqual( + helper( + "passed", + [ + { + "code": "source_preserving_low_opacity_overlay_degraded", + "severity": "error", + } + ] + ), + "failed", + ) + self.assertEqual( + helper( + "passed", + [ + { + "code": "no_decomposed_visual_elements", + "severity": "error", + } + ] + ), + "failed", + ) + self.assertEqual(helper("failed", []), "failed") + + def test_isolated_status_aggregates_failed_reconstruction_pages(self): + import scripts.run_real_generative_editable_pptx as runner + + helper = getattr(runner, "_aggregate_page_status", None) + self.assertIsNotNone(helper) + + self.assertEqual( + helper( + [ + {"status": "passed"}, + { + "status": "passed", + "reconstruction_issues": [ + { + "code": "source_preserving_low_opacity_overlay_degraded", + "severity": "error", + } + ], + }, + {"status": "passed"}, + ] + ), + "failed", + ) + self.assertEqual( + helper( + [ + {"status": "degraded"}, + {"status": "failed"}, + ] + ), + "failed", + ) + + def test_reconstruction_issues_warn_when_ocr_items_are_all_filtered(self): + import scripts.run_real_generative_editable_pptx as runner + from pptx import Presentation + + from src.generative_editable_manifest import ( + DeckManifest, + PageManifest, + write_manifest, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifact_root = root / "artifacts" + job_dir = artifact_root / "job" + (job_dir / "pages").mkdir(parents=True) + ocr_dir = job_dir / "provider_outputs" / "ocr" / "0000-slide-1" + ocr_dir.mkdir(parents=True) + (ocr_dir / "ocr.json").write_text( + '{"items":[{"text":"a"},{"text":"b"}]}', + encoding="utf-8", + ) + output_path = root / "job.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_picture(str(_solid_png(root / "background.png")), 0, 0, width=prs.slide_width, height=prs.slide_height) + prs.save(output_path) + write_manifest( + job_dir / "pages" / "0000-slide-1.json", + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/slide-1.png", + source_image_size=(800, 450), + slide_size=(10.0, 5.625), + native_shapes=[], + provenance={ + "text_issues": [ + { + "code": "ignored_spurious_ocr", + "severity": "warning", + "ocr_text": "重复幻觉文本", + } + ], + "text_validation_status": "passed", + }, + provider_output_paths={ + "ocr": "provider_outputs/ocr/0000-slide-1/ocr.json", + }, + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="job", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={ + "ocr": "ocr_model", + "image_edit": "edit_model", + "image_generation": "image_model", + }, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + stats = runner._pptx_object_stats(output_path) + + issues = runner._reconstruction_object_issues( + deck_manifest_path=job_dir / "deck.json", + artifact_root=job_dir, + object_stats=stats, + ) + + self.assertIn("no_editable_text_after_ocr_filtering", [issue["code"] for issue in issues]) + warning = next(issue for issue in issues if issue["code"] == "no_editable_text_after_ocr_filtering") + self.assertEqual(warning["severity"], "warning") + self.assertEqual(warning["details"]["filtered_ocr_issue_count"], 1) + self.assertEqual(warning["details"]["ocr_returned_count"], 2) + self.assertEqual(warning["details"]["filtered_reason_counts"], {"ignored_spurious_ocr": 1}) + + def test_warning_summary_counts_pages_and_codes(self): + import scripts.run_real_generative_editable_pptx as runner + + helper = getattr(runner, "_warning_summary", None) + self.assertIsNotNone(helper) + + summary = helper( + [ + { + "page": 1, + "reconstruction_issues": [ + {"code": "source_preserving_low_opacity_overlay_degraded", "severity": "error"} + ], + }, + { + "page": 2, + "reconstruction_issues": [ + {"code": "no_editable_text_after_ocr_filtering", "severity": "warning"}, + {"code": "no_decomposed_visual_elements", "severity": "error"}, + ], + }, + ] + ) + + self.assertEqual(summary["warning_count"], 1) + self.assertEqual(summary["warning_pages"], [2]) + self.assertEqual( + summary["warning_codes"], + { + "no_editable_text_after_ocr_filtering": 1, + }, + ) + + def test_warning_summary_prefers_issue_page_over_outer_report_page(self): + import scripts.run_real_generative_editable_pptx as runner + + helper = getattr(runner, "_warning_summary", None) + self.assertIsNotNone(helper) + + summary = helper( + [ + { + "page": 1, + "reconstruction_issues": [ + { + "page": 5, + "code": "source_preserving_low_opacity_overlay_degraded", + "severity": "error", + } + ], + } + ] + ) + + self.assertEqual(summary["warning_pages"], []) + + def test_main_failure_report_preserves_requested_input_images(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + image_path = root / "slide_1.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(image_path) + with patch.object(runner, "_run_pipeline", side_effect=RuntimeError("boom")): + exit_code = runner.main( + [ + "run", + "--input-images", + str(image_path), + "--output-dir", + str(root / "out"), + "--job-id", + "job", + ] + ) + report = json.loads((root / "out" / "report.json").read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 1) + self.assertEqual(report["input_images"], [str(image_path.resolve())]) + + def test_main_failure_report_uses_generated_job_id_when_job_id_omitted(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + image_path = root / "slide_1.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(image_path) + generated_job_id = "real-editable-20260704000000" + with ( + patch.object(runner, "_generated_job_id", return_value=generated_job_id), + patch.object(runner, "_run_pipeline", side_effect=RuntimeError("boom")), + ): + exit_code = runner.main( + [ + "run", + "--input-images", + str(image_path), + "--output-dir", + str(root / "out"), + ] + ) + report = json.loads((root / "out" / "report.json").read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 1) + self.assertEqual( + report["artifact_root"], + str((root / "out" / "artifacts" / generated_job_id).resolve()), + ) + + def test_subprocess_timeout_kills_process_tree(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + started = root / "grandchild-started.txt" + marker = root / "grandchild-marker.txt" + report_path = root / "report.json" + code = ( + "import subprocess,sys,time;" + "subprocess.Popen([sys.executable,'-c'," + "\"import pathlib,sys,time; pathlib.Path(sys.argv[1]).write_text('started'); time.sleep(2); pathlib.Path(sys.argv[2]).write_text('leaked')\"," + "sys.argv[1],sys.argv[2]]);" + "time.sleep(5)" + ) + + result = runner._run_subprocess_json( + [sys.executable, "-c", code, str(started), str(marker)], + timeout_seconds=1, + report_path=report_path, + timeout_payload={"status": "failed"}, + ) + time.sleep(2.5) + started_exists = started.exists() + marker_exists = marker.exists() + + self.assertEqual(result["error_type"], "TimeoutExpired") + self.assertTrue(started_exists) + self.assertFalse(marker_exists) + + def test_subprocess_timeout_returns_when_detached_descendant_holds_stdout_pipe(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report_path = root / "report.json" + code = ( + "import subprocess,sys,time;" + "subprocess.Popen([sys.executable,'-c','import time; time.sleep(4)']," + "start_new_session=True);" + "time.sleep(5)" + ) + started_at = time.monotonic() + + result = runner._run_subprocess_json( + [sys.executable, "-c", code], + timeout_seconds=1, + report_path=report_path, + timeout_payload={"status": "failed"}, + ) + elapsed = time.monotonic() - started_at + + self.assertEqual(result["error_type"], "TimeoutExpired") + self.assertLess(elapsed, 3.0) + + def test_subprocess_timeout_preserves_existing_child_report(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report_path = root / "report.json" + code = ( + "import json,pathlib,subprocess,sys,time;" + "pathlib.Path(sys.argv[1]).write_text(json.dumps({'status':'passed','child':'done'}));" + "subprocess.Popen([sys.executable,'-c','import time; time.sleep(4)']," + "start_new_session=True);" + "time.sleep(5)" + ) + + result = runner._run_subprocess_json( + [sys.executable, "-c", code, str(report_path)], + timeout_seconds=1, + report_path=report_path, + timeout_payload={"status": "failed", "stage": "test"}, + ) + stored = json.loads(report_path.read_text(encoding="utf-8")) + + self.assertEqual(result["status"], "passed") + self.assertEqual(result["child"], "done") + self.assertEqual(result["subprocess_cleanup"]["error_type"], "TimeoutExpired") + self.assertEqual(stored["status"], "passed") + self.assertEqual(stored["child"], "done") + + def test_isolated_pages_mode_writes_page_reports(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + input_dir = root / "inputs" + input_dir.mkdir() + image_path = input_dir / "slide_1.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(image_path) + + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main( + [ + "run", + "--use-fake", + "--isolate-pages", + "--input-images", + str(image_path), + "--output-dir", + str(root / "out"), + "--job-id", + "isolated", + ] + ) + + result = json.loads(stdout.getvalue().splitlines()[-1]) + report = json.loads(Path(result["report_path"]).read_text(encoding="utf-8")) + page_report_exists = Path(report["page_reports"][0]["report_path"]).exists() + + self.assertEqual(exit_code, 0) + self.assertEqual(report["status"], "passed") + self.assertEqual(len(report["page_reports"]), 1) + self.assertEqual(report["page_reports"][0]["page"], 1) + self.assertTrue(page_report_exists) + + def test_isolated_pages_top_level_report_includes_child_diagnostics(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + image_path = root / "slide_1.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(image_path) + args = argparse.Namespace( + input_images=[str(image_path)], + input_glob=[], + slides=0, + job_id="isolated", + aspect_ratio="16:9", + provider_timeout=5, + fallback_policy="fail", + use_fake=False, + page_wall_timeout=10, + ) + + child_result = { + "status": "failed", + "output_path": str(root / "out" / "page-01" / "isolated-page-01.pptx"), + "artifact_root": str(root / "out" / "page-01" / "artifacts" / "isolated-page-01"), + "object_stats": {"totals": {"PICTURE": 1, "TEXT_BOX": 3}}, + "reconstruction_issues": [{"code": "source_raster_guardrail_degraded"}], + "validation_status": "degraded", + "validation_issues": ["source_raster_guardrail_degraded"], + "active_stage": "base_clean_background", + "last_stage_event": { + "stage": "base_clean_background", + "status": "started", + }, + } + with patch.object(runner, "_run_subprocess_json", return_value=child_result): + result = runner._run_isolated_pages(args, root / "out") + + report = json.loads(Path(result["report_path"]).read_text(encoding="utf-8")) + page_report = report["page_reports"][0] + + self.assertEqual(page_report["object_stats"]["totals"]["TEXT_BOX"], 3) + self.assertEqual(page_report["reconstruction_issues"][0]["code"], "source_raster_guardrail_degraded") + self.assertEqual(page_report["validation_status"], "degraded") + self.assertEqual(page_report["validation_issues"], ["source_raster_guardrail_degraded"]) + self.assertEqual(page_report["active_stage"], "base_clean_background") + self.assertEqual(page_report["last_stage_event"]["status"], "started") + + def test_isolated_pages_forwards_selected_pipeline_mode_to_child(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + image_path = root / "slide_1.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(image_path) + args = argparse.Namespace( + input_images=[str(image_path)], + input_glob=[], + slides=0, + job_id="isolated", + aspect_ratio="16:9", + provider_timeout=5, + fallback_policy="fail", + use_fake=True, + page_wall_timeout=10, + mode="vlm_first", + ) + captured_commands = [] + + def fake_subprocess(command, **kwargs): + captured_commands.append(command) + return {"status": "passed"} + + with patch.object(runner, "_run_subprocess_json", side_effect=fake_subprocess): + runner._run_isolated_pages(args, root / "out") + + self.assertIn("--mode", captured_commands[0]) + self.assertEqual( + captured_commands[0][captured_commands[0].index("--mode") + 1], + "vlm_first", + ) + + def test_isolated_pages_forwards_pipeline_page_timeout_to_child(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + image_path = root / "slide_1.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(image_path) + args = argparse.Namespace( + input_images=[str(image_path)], + input_glob=[], + slides=0, + job_id="isolated", + aspect_ratio="16:9", + provider_timeout=5, + provider_max_attempts=3, + provider_retry_backoff=1.0, + pipeline_page_timeout=240.0, + fallback_policy="fail", + use_fake=True, + page_wall_timeout=10, + mode="vlm_first", + ) + captured_commands = [] + + def fake_subprocess(command, **kwargs): + captured_commands.append(command) + return {"status": "passed"} + + with patch.object(runner, "_run_subprocess_json", side_effect=fake_subprocess): + runner._run_isolated_pages(args, root / "out") + + command = captured_commands[0] + self.assertIn("--pipeline-page-timeout", command) + self.assertEqual(command[command.index("--pipeline-page-timeout") + 1], "240.0") + + +def _solid_png(path: Path) -> Path: + Image.new("RGB", (16, 16), "#2563EB").save(path) + return path + + +if __name__ == "__main__": + unittest.main() diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..1c51e70 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,4205 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + axios: + specifier: ^1.6.2 + version: 1.18.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.1.6 + version: 6.9.1 + '@testing-library/react': + specifier: ^14.1.2 + version: 14.3.1(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^20.10.0 + version: 20.19.43 + '@types/react': + specifier: ^18.2.43 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.2.17 + version: 18.3.7(@types/react@18.3.31) + '@typescript-eslint/eslint-plugin': + specifier: ^6.14.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.14.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.7.0(vite@5.4.21(@types/node@20.19.43)) + autoprefixer: + specifier: ^10.4.16 + version: 10.5.2(postcss@8.5.16) + eslint: + specifier: ^8.55.0 + version: 8.57.1 + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.2(eslint@8.57.1) + eslint-plugin-react-refresh: + specifier: ^0.4.5 + version: 0.4.26(eslint@8.57.1) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 + fast-check: + specifier: ^3.14.0 + version: 3.23.2 + jsdom: + specifier: ^23.0.1 + version: 23.2.0 + postcss: + specifier: ^8.4.32 + version: 8.5.16 + prettier: + specifier: ^3.1.1 + version: 3.9.4 + tailwindcss: + specifier: ^3.3.6 + version: 3.4.19 + typescript: + specifier: ^5.2.2 + version: 5.9.3 + vite: + specifier: ^5.0.8 + version: 5.4.21(@types/node@20.19.43) + vitest: + specifier: ^1.1.0 + version: 1.6.1(@types/node@20.19.43)(jsdom@23.2.0) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@asamuzakjp/dom-selector@2.0.2': + resolution: {integrity: sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@14.3.1': + resolution: {integrity: sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.2': + resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.5.2: + resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.10.42: + resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001800: + resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.387: + resolution: {integrity: sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsdom@23.2.0: + resolution: {integrity: sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@asamuzakjp/dom-selector@2.0.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 2.3.1 + is-potential-custom-element-name: 1.0.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@sinclair/typebox@0.27.10': {} + + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@14.3.1(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 9.3.4 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/semver@7.7.1': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.8.5 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.8.5 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.2': {} + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.43))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-union@2.1.0: {} + + assertion-error@1.1.0: {} + + asynckit@0.4.0: {} + + autoprefixer@10.5.2(postcss@8.5.16): + dependencies: + browserslist: 4.28.4 + caniuse-lite: 1.0.30001800 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.16 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.10.42: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.42 + caniuse-lite: 1.0.30001800 + electron-to-chromium: 1.5.387 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001800: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.1 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + didyoumean@1.2.2: {} + + diff-sequences@29.6.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.387: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.9 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react-refresh@0.4.26(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.2 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.3.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + fake-indexeddb@6.2.5: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + functions-have-names@1.2.3: {} + + gensync@1.0.0-beta.2: {} + + get-func-name@2.0.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@8.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graphemer@1.4.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@5.0.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@3.0.0: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-weakmap@2.0.2: {} + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsdom@23.2.0: + dependencies: + '@asamuzakjp/dom-selector': 2.0.2 + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 7.3.0 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.30: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@4.0.0: {} + + min-indent@1.0.1: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.1.1 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.50: {} + + normalize-path@3.0.0: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.16): + dependencies: + postcss: 8.5.16 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.16): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.16 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.16): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.16 + + postcss-nested@6.2.0(postcss@8.5.16): + dependencies: + postcss: 8.5.16 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.4: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + proxy-from-env@2.1.0: {} + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + querystringify@2.2.0: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + + react-is@18.3.1: {} + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + require-from-string@2.0.2: {} + + requires-port@1.0.0: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + rrweb-cssom@0.6.0: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.16 + postcss-import: 15.1.0(postcss@8.5.16) + postcss-js: 4.1.0(postcss@8.5.16) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.16) + postcss-nested: 6.2.0(postcss@8.5.16) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.1.0: {} + + type-fest@0.20.2: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + universalify@0.2.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + util-deprecate@1.0.2: {} + + vite-node@1.6.1(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.43)(jsdom@23.2.0): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.43) + vite-node: 1.6.1(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + jsdom: 23.2.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..00f6fc4 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/web/src/App.tsx b/web/src/App.tsx index 476d9f7..cb11bf3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -85,7 +85,8 @@ function AppContent() { } = useEditConflict() const { state: exportState, - startExport + startExport, + clearError: clearExportError } = useExport(visibleSlides, state.generationConfig.aspectRatio) const [exportError, setExportError] = useState(null) const { @@ -599,6 +600,7 @@ function AppContent() { onExport={handleExport} isExporting={exportState.isExporting} exportProgress={exportState.progress} + exportFormat={exportState.format} isLoading={isGenerating && slides.length === 0 && state.lastCompletedSlides.length === 0} /> } @@ -649,8 +651,12 @@ function AppContent() { {exportError || exportState.error} + )} diff --git a/web/src/components/RightPanel.tsx b/web/src/components/RightPanel.tsx index 09ae36c..d32c758 100644 --- a/web/src/components/RightPanel.tsx +++ b/web/src/components/RightPanel.tsx @@ -12,6 +12,7 @@ interface RightPanelProps { onExport?: (format: ExportFormat) => void isExporting?: boolean exportProgress?: number + exportFormat?: ExportFormat | null isLoading?: boolean } @@ -26,10 +27,12 @@ function RightPanel({ onExport, isExporting = false, exportProgress = 0, + exportFormat = null, isLoading = false, }: RightPanelProps) { const { t } = useUiPreferences() const canExport = slides.length > 0 + const isGenerativeEditableExport = exportFormat === 'generative_editable_pptx' return (
@@ -72,7 +75,19 @@ function RightPanel({ )}
- {isExporting && exportProgress > 0 && ( + {isExporting && isGenerativeEditableExport && ( +
+
+ + + + + {t('right.exportIndeterminate')} +
+
+ )} + + {isExporting && !isGenerativeEditableExport && exportProgress > 0 && (
{t('right.exportProgress')} diff --git a/web/src/components/__tests__/ApiConfigForm.property.test.tsx b/web/src/components/__tests__/ApiConfigForm.property.test.tsx index 9b9c0b3..19f87c8 100644 --- a/web/src/components/__tests__/ApiConfigForm.property.test.tsx +++ b/web/src/components/__tests__/ApiConfigForm.property.test.tsx @@ -54,20 +54,25 @@ describe('ApiConfigForm Property Tests', () => { json: async () => ({ success: true, profiles: { - prompt_model: { + text_model: { model: 'text-model', - base_url: 'https://text.example/v1', - adapter: 'openai_chat' + base_url: 'https://text.example/v1' }, image_model: { model: 'image-model', - base_url: 'https://image.example/v1', - adapter: 'raw_chat_multimodal' + base_url: 'https://image.example/v1' }, edit_model: { model: 'edit-model', - base_url: 'https://edit.example/v1', - adapter: 'raw_chat_multimodal' + base_url: 'https://edit.example/v1' + }, + VLM: { + model: 'vlm-model', + base_url: 'https://vlm.example/v1' + }, + ocr_model: { + model: 'ocr-model', + base_url: 'https://ocr.example/v1' } } }) @@ -84,6 +89,8 @@ describe('ApiConfigForm Property Tests', () => { await screen.findByText('image-model') expect(screen.getByText('text-model')).toBeInTheDocument() expect(screen.getByText('edit-model')).toBeInTheDocument() + expect(screen.getByText('vlm-model')).toBeInTheDocument() + expect(screen.getByText('ocr-model')).toBeInTheDocument() expect(screen.getByRole('button', { name: /图像生成模型/ })).toHaveAttribute('aria-expanded', 'false') await waitFor(() => { diff --git a/web/src/components/__tests__/AppProjectLifecycle.test.tsx b/web/src/components/__tests__/AppProjectLifecycle.test.tsx index 7778b52..a9a19fc 100644 --- a/web/src/components/__tests__/AppProjectLifecycle.test.tsx +++ b/web/src/components/__tests__/AppProjectLifecycle.test.tsx @@ -90,6 +90,7 @@ const mocks = vi.hoisted(() => { generationIsGenerating: true, rightPanelSnapshots: [] as Array<{ slideIds: string[]; isLoading: boolean; hasEditHandler: boolean }>, exportSlideSnapshots: [] as string[][], + exportHookError: null as string | null, uploadSettlements: [] as Array<{ status: 'resolved' } | { status: 'rejected'; message: string }>, latestUploadSettlement: null as Promise | null, capturedAutoSaveParams: undefined as Record | undefined, @@ -98,6 +99,7 @@ const mocks = vi.hoisted(() => { selectedUploadFile: new File(['# Uploaded Deck'], 'uploaded.md', { type: 'text/markdown' }), workflowPanelMounts: 0, startExport: vi.fn(), + clearExportError: vi.fn(), beginEdit: vi.fn(), submitEdit: vi.fn(), revertToVersion: vi.fn(), @@ -205,11 +207,13 @@ vi.mock('../RightPanel', () => ({ default: ({ slides, isLoading, - onSlideEdit + onSlideEdit, + onExport }: { slides: Slide[] isLoading?: boolean onSlideEdit?: (slideId: string) => void + onExport?: (format: 'generative_editable_pptx') => void }) => { mocks.rightPanelSnapshots.push({ slideIds: slides.map(slide => slide.id), @@ -217,7 +221,16 @@ vi.mock('../RightPanel', () => ({ hasEditHandler: typeof onSlideEdit === 'function' }) - return
{slides.map(slide => slide.id).join(',')}
+ return ( +
+
{slides.map(slide => slide.id).join(',')}
+ {onExport && ( + + )} +
+ ) } })) vi.mock('../ApiConfigForm', () => ({ default: () => null })) @@ -325,8 +338,9 @@ vi.mock('../../hooks/useExport', () => ({ mocks.exportSlideSnapshots.push(slides.map(slide => slide.id)) return { - state: { isExporting: false, progress: 0, error: null }, - startExport: mocks.startExport + state: { isExporting: false, progress: 0, error: mocks.exportHookError, format: null }, + startExport: mocks.startExport, + clearError: mocks.clearExportError } } })) @@ -384,6 +398,7 @@ describe('App project lifecycle safeguards', () => { mocks.generationIsGenerating = true mocks.rightPanelSnapshots.length = 0 mocks.exportSlideSnapshots.length = 0 + mocks.exportHookError = null mocks.uploadSettlements.length = 0 mocks.latestUploadSettlement = null mocks.capturedAutoSaveParams = undefined @@ -664,6 +679,34 @@ describe('App project lifecycle safeguards', () => { expect(mocks.exportSlideSnapshots).toContainEqual(['previous-completed-slide']) }) + it('keeps the current deck visible when generative editable export fails', async () => { + mocks.startExport.mockRejectedValueOnce(new Error('preview validation failed')) + + render() + + await waitFor(() => { + expect(screen.getByTestId('right-panel-slide-ids')).toHaveTextContent('current-slide') + }) + + fireEvent.click(screen.getByRole('button', { name: 'Export generative editable' })) + + await waitFor(() => { + expect(screen.getByText('preview validation failed')).toBeInTheDocument() + }) + expect(screen.getByTestId('right-panel-slide-ids')).toHaveTextContent('current-slide') + }) + + it('can dismiss export errors reported by the export hook', async () => { + mocks.exportHookError = 'provider timed out' + + render() + + expect(screen.getByText('provider timed out')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '关闭导出错误' })) + + expect(mocks.clearExportError).toHaveBeenCalled() + }) + it('blocks project open, new, and duplicate when edit discard confirmation is needed', async () => { mocks.editSession = { slideId: 'current-slide', diff --git a/web/src/components/__tests__/ExportButton.test.tsx b/web/src/components/__tests__/ExportButton.test.tsx new file mode 100644 index 0000000..9e42b2a --- /dev/null +++ b/web/src/components/__tests__/ExportButton.test.tsx @@ -0,0 +1,24 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ExportButton from '../ExportButton' + +describe('ExportButton', () => { + it('shows raster PPTX and high-fidelity editable PPTX as distinct options', () => { + const onExport = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /导出/ })) + + expect(screen.getByText('PPTX 格式')).toBeInTheDocument() + expect(screen.getByText('每页保存为整页图片')).toBeInTheDocument() + expect(screen.getByText('高保真可编辑 PPTX')).toBeInTheDocument() + expect(screen.getByText('重建文本、形状和图片元素')).toBeInTheDocument() + + fireEvent.click(screen.getByText('PPTX 格式')) + expect(onExport).toHaveBeenLastCalledWith('pptx') + + fireEvent.click(screen.getByRole('button', { name: /导出/ })) + fireEvent.click(screen.getByText('高保真可编辑 PPTX')) + expect(onExport).toHaveBeenLastCalledWith('generative_editable_pptx') + }) +}) diff --git a/web/src/components/__tests__/ExportConsistency.property.test.tsx b/web/src/components/__tests__/ExportConsistency.property.test.tsx index 12ecce1..0cf7468 100644 --- a/web/src/components/__tests__/ExportConsistency.property.test.tsx +++ b/web/src/components/__tests__/ExportConsistency.property.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import * as fc from 'fast-check' import { Slide, ExportFormat } from '../../types' -import { canExport } from '../../services/exportService' +import { buildExportRequestBody, canExport } from '../../services/exportService' /** * Feature: webui-frontend, Property 8: Export Format Consistency @@ -36,20 +36,11 @@ const slidesArbitrary = (count: number): fc.Arbitrary => { /** * Generate export format */ -const exportFormatArbitrary: fc.Arbitrary = fc.constantFrom('pdf', 'pptx') - -/** - * Helper function to build export request body - * This mimics the behavior in exportService.ts - */ -function buildExportRequestBody(slides: Slide[], format: ExportFormat) { - return { - slides: slides.map(slide => ({ - image_base64: slide.imageBase64 || extractBase64FromDataUrl(slide.imageUrl) - })), - format - } -} +const allExportFormatArbitrary: fc.Arbitrary = fc.constantFrom( + 'pdf', + 'pptx', + 'generative_editable_pptx' +) /** * Extract base64 from data URL @@ -87,9 +78,9 @@ describe('Export Format Consistency Property Tests', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 20 }).chain(count => slidesArbitrary(count)), - exportFormatArbitrary, + allExportFormatArbitrary, (slides, format) => { - const requestBody = buildExportRequestBody(slides, format) + const requestBody = buildExportRequestBody({ slides, format, aspectRatio: '16:9' }) // Property: Export request should contain same number of slides expect(requestBody.slides.length).toBe(slides.length) @@ -106,11 +97,11 @@ describe('Export Format Consistency Property Tests', () => { fc.assert( fc.property( fc.integer({ min: 2, max: 20 }).chain(count => slidesArbitrary(count)), - exportFormatArbitrary, + allExportFormatArbitrary, (slides, format) => { // Ensure slides are sorted by page number first const sortedSlides = [...slides].sort((a, b) => a.pageNumber - b.pageNumber) - const requestBody = buildExportRequestBody(sortedSlides, format) + const requestBody = buildExportRequestBody({ slides: sortedSlides, format, aspectRatio: '16:9' }) // Property: Export request slides should be in same order as input for (let i = 0; i < sortedSlides.length; i++) { @@ -131,9 +122,9 @@ describe('Export Format Consistency Property Tests', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 10 }).chain(count => slidesArbitrary(count)), - exportFormatArbitrary, + allExportFormatArbitrary, (slides, format) => { - const requestBody = buildExportRequestBody(slides, format) + const requestBody = buildExportRequestBody({ slides, format, aspectRatio: '4:3' }) // Property: Format should match requested format expect(requestBody.format).toBe(format) @@ -202,9 +193,9 @@ describe('Export Format Consistency Property Tests', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 10 }).chain(count => slidesArbitrary(count)), - exportFormatArbitrary, + allExportFormatArbitrary, (slides, format) => { - const requestBody = buildExportRequestBody(slides, format) + const requestBody = buildExportRequestBody({ slides, format, aspectRatio: '16:9' }) // Property: Each slide should have image_base64 from imageBase64 field for (let i = 0; i < slides.length; i++) { @@ -225,7 +216,7 @@ describe('Export Format Consistency Property Tests', () => { fc.assert( fc.property( fc.integer({ min: 2, max: 20 }).chain(count => slidesArbitrary(count)), - exportFormatArbitrary, + allExportFormatArbitrary, (slides, format) => { // Shuffle slides first const shuffled = [...slides].sort(() => Math.random() - 0.5) @@ -236,7 +227,7 @@ describe('Export Format Consistency Property Tests', () => { expect(areSlidesInOrder(sorted)).toBe(true) // Build export request with sorted slides - const requestBody = buildExportRequestBody(sorted, format) + const requestBody = buildExportRequestBody({ slides: sorted, format, aspectRatio: '16:9' }) // Property: Request should have same number of slides expect(requestBody.slides.length).toBe(sorted.length) @@ -246,15 +237,12 @@ describe('Export Format Consistency Property Tests', () => { ) }) - /** - * Export format should only be pdf or pptx - */ it('should only accept valid export formats', () => { - const validFormats: ExportFormat[] = ['pdf', 'pptx'] + const validFormats: ExportFormat[] = ['pdf', 'pptx', 'generative_editable_pptx'] fc.assert( fc.property( - exportFormatArbitrary, + allExportFormatArbitrary, (format) => { // Property: Format should be one of the valid formats expect(validFormats).toContain(format) @@ -263,5 +251,39 @@ describe('Export Format Consistency Property Tests', () => { { numRuns: 50 } ) }) + + it('should include generative editable PPTX ordering, metadata, and fail-fast fallback policy', () => { + fc.assert( + fc.property( + fc.integer({ min: 2, max: 10 }).chain(count => slidesArbitrary(count)), + (slides) => { + const slidesWithMetadata = slides.map((slide, index) => ({ + ...slide, + textMetadata: [{ + text: `Text ${index + 1}`, + role: index === 0 ? 'title' : 'body', + order: index + 1, + style_hint: { font_size: 20 + index } + }] + })) + const requestBody = buildExportRequestBody({ + slides: slidesWithMetadata, + format: 'generative_editable_pptx', + aspectRatio: '4:3' + }) + + expect(requestBody.format).toBe('generative_editable_pptx') + expect(requestBody.aspect_ratio).toBe('4:3') + expect(requestBody.slide_order).toEqual(slidesWithMetadata.map(slide => slide.id)) + expect(requestBody.editable_options).toEqual({ fallback_policy: 'fail' }) + requestBody.slides.forEach((slide, index) => { + expect(slide.slide_id).toBe(slidesWithMetadata[index].id) + expect(slide.text_metadata).toEqual(slidesWithMetadata[index].textMetadata) + }) + } + ), + { numRuns: 100 } + ) + }) }) }) diff --git a/web/src/components/__tests__/RightPanelExport.test.tsx b/web/src/components/__tests__/RightPanelExport.test.tsx new file mode 100644 index 0000000..317a88f --- /dev/null +++ b/web/src/components/__tests__/RightPanelExport.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { Slide } from '../../types' +import RightPanel from '../RightPanel' + +const slide: Slide = { + id: 'slide-1', + pageNumber: 1, + imageUrl: 'data:image/png;base64,one', + imageBase64: 'one', + prompt: 'Prompt' +} + +describe('RightPanel export state', () => { + it('shows indeterminate loading for generative editable PPTX without percentage progress', () => { + vi.stubGlobal('IntersectionObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }) + + render( + + ) + + expect(screen.getByText('正在导出高保真可编辑 PPTX')).toBeInTheDocument() + expect(screen.queryByText('70%')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/hooks/__tests__/useExport.test.tsx b/web/src/hooks/__tests__/useExport.test.tsx new file mode 100644 index 0000000..5cc534d --- /dev/null +++ b/web/src/hooks/__tests__/useExport.test.tsx @@ -0,0 +1,49 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Slide } from '../../types' +import { exportPresentation } from '../../services/exportService' +import { useExport } from '../useExport' + +vi.mock('../../services/exportService', async () => { + const actual = await vi.importActual( + '../../services/exportService' + ) + return { + ...actual, + exportPresentation: vi.fn() + } +}) + +const slide: Slide = { + id: 'slide-1', + pageNumber: 1, + imageUrl: 'data:image/png;base64,one', + imageBase64: 'one', + prompt: 'Prompt' +} + +describe('useExport', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('surfaces generative editable export errors through the real hook error state', async () => { + vi.mocked(exportPresentation).mockImplementation(async (_config, callbacks) => { + callbacks?.onStart?.() + callbacks?.onError?.('provider timed out') + }) + + const { result } = renderHook(() => useExport([slide], '16:9')) + + await act(async () => { + await result.current.startExport('generative_editable_pptx') + }) + + expect(result.current.state).toEqual({ + isExporting: false, + progress: 0, + error: 'provider timed out', + format: null + }) + }) +}) diff --git a/web/src/hooks/useEdit.ts b/web/src/hooks/useEdit.ts index 68de7d5..a25db9c 100644 --- a/web/src/hooks/useEdit.ts +++ b/web/src/hooks/useEdit.ts @@ -86,23 +86,20 @@ export function useEdit() { base_url: editConfig.baseUrl, model: editConfig.model, model_profiles: { - prompt_model: { + text_model: { model: state.fullApiConfig.text.model, base_url: state.fullApiConfig.text.baseUrl, - api_key: state.fullApiConfig.text.apiKey, - adapter: 'openai_chat' + api_key: state.fullApiConfig.text.apiKey }, image_model: { model: state.fullApiConfig.image.model, base_url: state.fullApiConfig.image.baseUrl, - api_key: state.fullApiConfig.image.apiKey, - adapter: 'raw_chat_multimodal' + api_key: state.fullApiConfig.image.apiKey }, edit_model: { model: editConfig.model, base_url: editConfig.baseUrl, - api_key: editConfig.apiKey, - adapter: 'raw_chat_multimodal' + api_key: editConfig.apiKey } } } diff --git a/web/src/hooks/useExport.ts b/web/src/hooks/useExport.ts index 5c41adb..6648e24 100644 --- a/web/src/hooks/useExport.ts +++ b/web/src/hooks/useExport.ts @@ -9,6 +9,7 @@ export interface ExportState { isExporting: boolean progress: number error: string | null + format: ExportFormat | null } /** @@ -34,7 +35,8 @@ export function useExport( const [state, setState] = useState({ isExporting: false, progress: 0, - error: null + error: null, + format: null }) const canExportSlides = canExport(slides) @@ -51,7 +53,8 @@ export function useExport( setState({ isExporting: true, progress: 0, - error: null + error: null, + format }) try { @@ -59,23 +62,30 @@ export function useExport( { slides, format, aspectRatio }, { onStart: () => { - setState(prev => ({ ...prev, progress: 5 })) + setState(prev => ({ + ...prev, + progress: format === 'generative_editable_pptx' ? 0 : 5 + })) }, onProgress: (progress) => { - setState(prev => ({ ...prev, progress })) + if (format !== 'generative_editable_pptx') { + setState(prev => ({ ...prev, progress })) + } }, onComplete: () => { setState({ isExporting: false, progress: 100, - error: null + error: null, + format: null }) }, onError: (error) => { setState({ isExporting: false, progress: 0, - error + error, + format: null }) } } diff --git a/web/src/i18n.ts b/web/src/i18n.ts index 724a08b..d2ecb34 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -78,7 +78,12 @@ export const dictionaries = { 'api.edit.subtitle': '负责单页重绘和版本迭代', 'api.text.title': '文本生成模型', 'api.text.subtitle': '负责理解资料并规划每页内容', + 'api.vlm.title': 'VLM 多模态理解模型(可选)', + 'api.vlm.subtitle': '仅在将整页图片 PPT 转为可编辑 PPT 时用于分析页面元素', + 'api.ocr.title': 'OCR 文字识别模型(可选)', + 'api.ocr.subtitle': '仅在将整页图片 PPT 转为可编辑 PPT 时用于识别页面文字', 'api.unsetModel': '未选择模型', + 'api.optionalUnsetModel': '可选,未配置', 'api.needsCheck': '需检查', 'api.key': 'API Key', 'api.baseUrl': 'Base URL', @@ -95,9 +100,13 @@ export const dictionaries = { 'api.imageLabel': '图像模型', 'api.editLabel': '编辑模型', 'api.textLabel': '文本模型', + 'api.vlmLabel': 'VLM 模型', + 'api.ocrLabel': 'OCR 模型', 'api.imageBasePlaceholder': 'https://api.example.com/v1', 'api.editBasePlaceholder': '默认复用图像生成模型', 'api.textModelPlaceholder': 'DeepSeek-V4-Pro', + 'api.vlmModelPlaceholder': 'gpt-5.5', + 'api.ocrModelPlaceholder': 'PaddleOCR-VL', 'api.showKey': '显示 API Key', 'api.hideKey': '隐藏 API Key', 'api.saveError': '保存配置失败', @@ -172,6 +181,7 @@ export const dictionaries = { 'right.subtitle.loading': '加载中...', 'right.subtitle.pages': '共 {count} 页', 'right.exportProgress': '导出进度', + 'right.exportIndeterminate': '正在导出高保真可编辑 PPTX', 'right.currentPage': '第 {page} 页', 'right.editCurrent': '编辑当前页', 'right.slides': '全部幻灯片', @@ -183,10 +193,13 @@ export const dictionaries = { 'export.button': '导出', 'export.failed': '导出失败', 'export.loading': '导出中...', + 'export.dismissError': '关闭导出错误', 'export.pdfTitle': 'PDF 格式', 'export.pdfSubtitle': '适合打印和分享', 'export.pptxTitle': 'PPTX 格式', - 'export.pptxSubtitle': '可在 PowerPoint 中编辑', + 'export.pptxSubtitle': '每页保存为整页图片', + 'export.generativePptxTitle': '高保真可编辑 PPTX', + 'export.generativePptxSubtitle': '重建文本、形状和图片元素', 'slide.loading': '加载中...', 'slide.imageMissing': '图片未保存', @@ -306,7 +319,12 @@ export const dictionaries = { 'api.edit.subtitle': 'Redraws a single page and tracks versions', 'api.text.title': 'Text generation model', 'api.text.subtitle': 'Understands sources and plans each page', + 'api.vlm.title': 'VLM multimodal understanding model (optional)', + 'api.vlm.subtitle': 'Used only when converting whole-slide image PPTs into editable PPTs', + 'api.ocr.title': 'OCR text recognition model (optional)', + 'api.ocr.subtitle': 'Used only when converting whole-slide image PPTs into editable PPTs', 'api.unsetModel': 'No model selected', + 'api.optionalUnsetModel': 'Optional, not configured', 'api.needsCheck': 'Check required', 'api.key': 'API Key', 'api.baseUrl': 'Base URL', @@ -323,9 +341,13 @@ export const dictionaries = { 'api.imageLabel': 'image model', 'api.editLabel': 'edit model', 'api.textLabel': 'text model', + 'api.vlmLabel': 'VLM model', + 'api.ocrLabel': 'OCR model', 'api.imageBasePlaceholder': 'https://api.example.com/v1', 'api.editBasePlaceholder': 'Reuse image generation model by default', 'api.textModelPlaceholder': 'DeepSeek-V4-Pro', + 'api.vlmModelPlaceholder': 'gpt-5.5', + 'api.ocrModelPlaceholder': 'PaddleOCR-VL', 'api.showKey': 'Show API Key', 'api.hideKey': 'Hide API Key', 'api.saveError': 'Failed to save config', @@ -400,6 +422,7 @@ export const dictionaries = { 'right.subtitle.loading': 'Loading...', 'right.subtitle.pages': '{count} slides', 'right.exportProgress': 'Export progress', + 'right.exportIndeterminate': 'Exporting high-fidelity editable PPTX', 'right.currentPage': 'Slide {page}', 'right.editCurrent': 'Edit current slide', 'right.slides': 'All slides', @@ -411,10 +434,13 @@ export const dictionaries = { 'export.button': 'Export', 'export.failed': 'Export failed', 'export.loading': 'Exporting...', + 'export.dismissError': 'Dismiss export error', 'export.pdfTitle': 'PDF', 'export.pdfSubtitle': 'Best for printing and sharing', 'export.pptxTitle': 'PPTX', - 'export.pptxSubtitle': 'Editable in PowerPoint', + 'export.pptxSubtitle': 'Slides as full-page images', + 'export.generativePptxTitle': 'High-fidelity editable PPTX', + 'export.generativePptxSubtitle': 'Rebuild text, shapes, and image elements', 'slide.loading': 'Loading...', 'slide.imageMissing': 'Image not saved', diff --git a/web/src/services/__tests__/exportService.test.ts b/web/src/services/__tests__/exportService.test.ts new file mode 100644 index 0000000..e840246 --- /dev/null +++ b/web/src/services/__tests__/exportService.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Slide } from '../../types' +import { + buildExportRequestBody, + exportPresentation, + getDefaultExportFilename, + getExportMimeType +} from '../exportService' + +const originalCreateObjectURL = URL.createObjectURL +const originalRevokeObjectURL = URL.revokeObjectURL + +function buildSlide(overrides: Partial = {}): Slide { + return { + id: 'slide-1', + pageNumber: 1, + imageUrl: 'data:image/png;base64,aW1hZ2UtMQ==', + imageBase64: 'image-1', + prompt: 'Prompt one', + ...overrides + } +} + +describe('exportService', () => { + afterEach(() => { + vi.restoreAllMocks() + URL.createObjectURL = originalCreateObjectURL + URL.revokeObjectURL = originalRevokeObjectURL + }) + + it('builds the generative editable PPTX request body with slide ids and order', () => { + const slides = [ + buildSlide({ + id: 'slide-b', + pageNumber: 2, + imageBase64: 'image-b', + textMetadata: [{ text: 'Second', role: 'title', order: 1, style_hint: { font_size: 30 } }] + }), + buildSlide({ + id: 'slide-a', + pageNumber: 1, + imageBase64: undefined, + imageUrl: 'data:image/png;base64,aW1hZ2UtYQ==', + textMetadata: [{ text: 'First', role: 'body', order: 2 }] + }) + ] + + expect(buildExportRequestBody({ + slides, + format: 'generative_editable_pptx', + aspectRatio: '4:3' + })).toEqual({ + slides: [ + { + image_base64: 'image-b', + slide_id: 'slide-b', + text_metadata: [ + { text: 'Second', role: 'title', order: 1, style_hint: { font_size: 30 } } + ] + }, + { + image_base64: 'aW1hZ2UtYQ==', + slide_id: 'slide-a', + text_metadata: [{ text: 'First', role: 'body', order: 2, style_hint: {} }] + } + ], + format: 'generative_editable_pptx', + aspect_ratio: '4:3', + slide_order: ['slide-b', 'slide-a'], + editable_options: { fallback_policy: 'fail' } + }) + }) + + it('keeps raster PPTX requests on the existing compact contract', () => { + expect(buildExportRequestBody({ + slides: [buildSlide()], + format: 'pptx', + aspectRatio: '16:9' + })).toEqual({ + slides: [{ image_base64: 'image-1' }], + format: 'pptx', + aspect_ratio: '16:9' + }) + }) + + it('allows callers to select a generative editable fallback policy', () => { + expect(buildExportRequestBody({ + slides: [buildSlide()], + format: 'generative_editable_pptx', + aspectRatio: '16:9', + fallbackPolicy: 'text_editable_background' + }).editable_options).toEqual({ fallback_policy: 'text_editable_background' }) + }) + + it('uses PPTX filename and MIME handling for generative editable downloads', () => { + expect(getDefaultExportFilename('generative_editable_pptx', new Date('2026-06-30T00:00:00Z'))) + .toBe('presentation_20260630.generative-editable.pptx') + expect(getExportMimeType('generative_editable_pptx')) + .toBe('application/vnd.openxmlformats-officedocument.presentationml.presentation') + }) + + it('downloads backend filename for generative editable PPTX responses', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="deck.generative-editable.pptx"' + }), + blob: async () => new Blob(['pptx'], { + type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + }) + } as Response) + URL.createObjectURL = vi.fn(() => 'blob:deck') + URL.revokeObjectURL = vi.fn() + const click = vi.fn() + vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName) as HTMLElement + if (tagName === 'a') { + Object.defineProperty(element, 'click', { value: click }) + } + return element + }) + + const onComplete = vi.fn() + await exportPresentation({ + slides: [buildSlide({ textMetadata: [{ text: 'Title', role: 'title', order: 1 }] })], + format: 'generative_editable_pptx', + aspectRatio: '16:9' + }, { onComplete }) + + const request = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) + expect(request.format).toBe('generative_editable_pptx') + expect(request.editable_options).toEqual({ fallback_policy: 'fail' }) + expect(onComplete).toHaveBeenCalledWith('deck.generative-editable.pptx') + expect(click).toHaveBeenCalled() + }) + + it('posts selected fallback policy through exportPresentation', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: new Headers(), + blob: async () => new Blob(['pptx']) + } as Response) + URL.createObjectURL = vi.fn(() => 'blob:deck') + URL.revokeObjectURL = vi.fn() + vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName) as HTMLElement + if (tagName === 'a') { + Object.defineProperty(element, 'click', { value: vi.fn() }) + } + return element + }) + + await exportPresentation({ + slides: [buildSlide()], + format: 'generative_editable_pptx', + aspectRatio: '16:9', + fallbackPolicy: 'text_editable_background' + }) + + const request = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) + expect(request.editable_options).toEqual({ fallback_policy: 'text_editable_background' }) + }) + + it('does not emit claimed percentage progress for generative editable exports', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: new Headers(), + blob: async () => new Blob(['pptx']) + } as Response) + URL.createObjectURL = vi.fn(() => 'blob:deck') + URL.revokeObjectURL = vi.fn() + vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName) as HTMLElement + if (tagName === 'a') { + Object.defineProperty(element, 'click', { value: vi.fn() }) + } + return element + }) + const onProgress = vi.fn() + + await exportPresentation({ + slides: [buildSlide()], + format: 'generative_editable_pptx', + aspectRatio: '16:9' + }, { onProgress }) + + expect(onProgress).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/services/__tests__/generateService.test.ts b/web/src/services/__tests__/generateService.test.ts new file mode 100644 index 0000000..27a64d7 --- /dev/null +++ b/web/src/services/__tests__/generateService.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { convertToSlide } from '../generateService' + +describe('generateService', () => { + it('preserves text metadata from generated slide SSE payloads', () => { + expect(convertToSlide({ + id: 'slide-1', + page_number: 1, + image_base64: 'aW1hZ2U=', + prompt: 'Prompt', + text_metadata: [ + { text: 'Quarterly Plan', role: 'title', order: 1, style_hint: { font_size: 30 } }, + { text: 'Revenue up 18%', role: 'body', order: 2, style_hint: {} } + ] + })).toMatchObject({ + id: 'slide-1', + pageNumber: 1, + imageBase64: 'aW1hZ2U=', + imageUrl: 'data:image/png;base64,aW1hZ2U=', + prompt: 'Prompt', + textMetadata: [ + { text: 'Quarterly Plan', role: 'title', order: 1, style_hint: { font_size: 30 } }, + { text: 'Revenue up 18%', role: 'body', order: 2, style_hint: {} } + ] + }) + }) + + it('defaults missing text metadata to an empty list', () => { + expect(convertToSlide({ + id: 'slide-1', + page_number: 1, + image_base64: 'aW1hZ2U=', + prompt: 'Prompt' + }).textMetadata).toEqual([]) + }) +}) diff --git a/web/src/services/__tests__/modelProfileService.test.ts b/web/src/services/__tests__/modelProfileService.test.ts new file mode 100644 index 0000000..041c74d --- /dev/null +++ b/web/src/services/__tests__/modelProfileService.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { buildModelProfiles } from '../modelProfileService' + +describe('modelProfileService', () => { + it('does not expose internal adapters in model profile requests', () => { + const profiles = buildModelProfiles({ + text: { + model: 'text-model', + baseUrl: 'https://text.example/v1', + apiKey: 'text-key', + format: 'openai', + thinking: 'disabled' + }, + image: { + model: 'image-model', + baseUrl: 'https://image.example/v1', + apiKey: 'image-key' + }, + edit: { + model: 'edit-model', + baseUrl: 'https://edit.example/v1', + apiKey: 'edit-key' + }, + vlm: { + model: 'vlm-model', + baseUrl: 'https://vlm.example/v1', + apiKey: 'vlm-key' + }, + ocr: { + model: 'ocr-model', + baseUrl: 'https://ocr.example/v1', + apiKey: 'ocr-key' + } + }) + + expect(profiles).toEqual({ + text_model: { + model: 'text-model', + base_url: 'https://text.example/v1', + api_key: 'text-key', + thinking: 'disabled' + }, + image_model: { + model: 'image-model', + base_url: 'https://image.example/v1', + api_key: 'image-key' + }, + edit_model: { + model: 'edit-model', + base_url: 'https://edit.example/v1', + api_key: 'edit-key' + }, + VLM: { + model: 'vlm-model', + base_url: 'https://vlm.example/v1', + api_key: 'vlm-key' + }, + ocr_model: { + model: 'ocr-model', + base_url: 'https://ocr.example/v1', + api_key: 'ocr-key' + } + }) + }) +}) diff --git a/web/src/services/exportService.ts b/web/src/services/exportService.ts index ef5d350..1dcbf4b 100644 --- a/web/src/services/exportService.ts +++ b/web/src/services/exportService.ts @@ -1,4 +1,4 @@ -import { Slide, ExportFormat, GenerationConfig } from '../types' +import { Slide, ExportFormat, GenerationConfig, EditablePptxFallbackPolicy } from '../types' /** * 导出请求配置 @@ -7,6 +7,7 @@ export interface ExportRequestConfig { slides: Slide[] format: ExportFormat aspectRatio: GenerationConfig['aspectRatio'] + fallbackPolicy?: EditablePptxFallbackPolicy } /** @@ -19,14 +20,34 @@ export interface ExportCallbacks { onError?: (error: string) => void } +type ExportRequestBody = { + slides: Array<{ + image_base64: string + slide_id?: string + text_metadata?: Array<{ + text: string + role: string + order: number + style_hint: Record + }> + }> + format: ExportFormat + aspect_ratio: GenerationConfig['aspectRatio'] + slide_order?: string[] + editable_options?: { + fallback_policy: EditablePptxFallbackPolicy + } +} + /** * 获取文件扩展名对应的 MIME 类型 */ -function getMimeType(format: ExportFormat): string { +export function getExportMimeType(format: ExportFormat): string { switch (format) { case 'pdf': return 'application/pdf' case 'pptx': + case 'generative_editable_pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation' default: return 'application/octet-stream' @@ -36,11 +57,46 @@ function getMimeType(format: ExportFormat): string { /** * 获取默认文件名 */ -function getDefaultFilename(format: ExportFormat): string { - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') +export function getDefaultExportFilename(format: ExportFormat, now = new Date()): string { + const timestamp = now.toISOString().slice(0, 10).replace(/-/g, '') + if (format === 'generative_editable_pptx') { + return `presentation_${timestamp}.generative-editable.pptx` + } return `presentation_${timestamp}.${format}` } +export function buildExportRequestBody(config: ExportRequestConfig): ExportRequestBody { + const { slides, format, aspectRatio, fallbackPolicy = 'fail' } = config + const baseSlides = slides.map(slide => ({ + image_base64: slide.imageBase64 || extractBase64FromDataUrl(slide.imageUrl) + })) + + if (format !== 'generative_editable_pptx') { + return { + slides: baseSlides, + format, + aspect_ratio: aspectRatio + } + } + + return { + slides: slides.map(slide => ({ + image_base64: slide.imageBase64 || extractBase64FromDataUrl(slide.imageUrl), + slide_id: slide.id, + text_metadata: (slide.textMetadata || []).map(item => ({ + text: item.text, + role: item.role, + order: item.order, + style_hint: item.style_hint || {} + })) + })), + format, + aspect_ratio: aspectRatio, + slide_order: slides.map(slide => slide.id), + editable_options: { fallback_policy: fallbackPolicy } + } +} + /** * 导出演示文稿 * @@ -52,7 +108,7 @@ export async function exportPresentation( config: ExportRequestConfig, callbacks?: ExportCallbacks ): Promise { - const { slides, format, aspectRatio } = config + const { slides, format, aspectRatio, fallbackPolicy } = config // 验证输入 if (!slides || slides.length === 0) { @@ -61,19 +117,16 @@ export async function exportPresentation( } callbacks?.onStart?.() - callbacks?.onProgress?.(10) + if (format !== 'generative_editable_pptx') { + callbacks?.onProgress?.(10) + } try { - // 构建请求体 - const requestBody = { - slides: slides.map(slide => ({ - image_base64: slide.imageBase64 || extractBase64FromDataUrl(slide.imageUrl) - })), - format, - aspect_ratio: aspectRatio - } + const requestBody = buildExportRequestBody({ slides, format, aspectRatio, fallbackPolicy }) - callbacks?.onProgress?.(30) + if (format !== 'generative_editable_pptx') { + callbacks?.onProgress?.(30) + } // 发起请求 const response = await fetch('/api/export', { @@ -84,7 +137,9 @@ export async function exportPresentation( body: JSON.stringify(requestBody) }) - callbacks?.onProgress?.(70) + if (format !== 'generative_editable_pptx') { + callbacks?.onProgress?.(70) + } if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: '导出失败' })) @@ -93,7 +148,7 @@ export async function exportPresentation( // 获取文件名 const contentDisposition = response.headers.get('Content-Disposition') - let filename = getDefaultFilename(format) + let filename = getDefaultExportFilename(format) if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/) if (filenameMatch) { @@ -101,13 +156,17 @@ export async function exportPresentation( } } - callbacks?.onProgress?.(90) + if (format !== 'generative_editable_pptx') { + callbacks?.onProgress?.(90) + } // 下载文件 const blob = await response.blob() - downloadBlob(blob, filename, getMimeType(format)) + downloadBlob(blob, filename, getExportMimeType(format)) - callbacks?.onProgress?.(100) + if (format !== 'generative_editable_pptx') { + callbacks?.onProgress?.(100) + } callbacks?.onComplete?.(filename) } catch (error) { const errorMessage = error instanceof Error ? error.message : '导出失败' diff --git a/web/src/services/generateService.ts b/web/src/services/generateService.ts index 7c439f5..0844b48 100644 --- a/web/src/services/generateService.ts +++ b/web/src/services/generateService.ts @@ -5,6 +5,7 @@ import { FullApiConfig, DeckOutline, ConfirmedSlidePrompt, + ExportTextMetadata, } from '../types' import { buildModelProfiles } from './modelProfileService' @@ -23,6 +24,7 @@ export interface SSESlideData { page_number: number image_base64: string prompt: string + text_metadata?: ExportTextMetadata[] } export interface SSEErrorData { @@ -90,12 +92,13 @@ function parseSSELine(line: string): { type: SSEEventType; data: unknown } | nul /** * 将后端返回的 slide 数据转换为前端 Slide 类型 */ -function convertToSlide(data: SSESlideData): Slide { +export function convertToSlide(data: SSESlideData): Slide { return { id: data.id, pageNumber: data.page_number, imageUrl: `data:image/png;base64,${data.image_base64}`, imageBase64: data.image_base64, + textMetadata: data.text_metadata || [], prompt: data.prompt, } } diff --git a/web/src/services/modelProfileService.ts b/web/src/services/modelProfileService.ts index 6f8c50b..ccb296b 100644 --- a/web/src/services/modelProfileService.ts +++ b/web/src/services/modelProfileService.ts @@ -28,25 +28,37 @@ export async function saveBackendModelProfiles( export function buildModelProfiles(config: FullApiConfig): ModelProfilesRequestConfig { const editConfig = config.edit || config.image - return { - prompt_model: { + const profiles: ModelProfilesRequestConfig = { + text_model: { model: config.text.model, base_url: config.text.baseUrl, api_key: config.text.apiKey, - adapter: 'openai_chat', thinking: config.text.thinking || 'disabled', }, image_model: { model: config.image.model, base_url: config.image.baseUrl, api_key: config.image.apiKey, - adapter: 'raw_chat_multimodal', }, edit_model: { model: editConfig.model, base_url: editConfig.baseUrl, api_key: editConfig.apiKey, - adapter: 'raw_chat_multimodal', }, } + if (config.vlm?.model || config.vlm?.baseUrl || config.vlm?.apiKey) { + profiles.VLM = { + model: config.vlm.model, + base_url: config.vlm.baseUrl, + api_key: config.vlm.apiKey, + } + } + if (config.ocr?.model || config.ocr?.baseUrl || config.ocr?.apiKey) { + profiles.ocr_model = { + model: config.ocr.model, + base_url: config.ocr.baseUrl, + api_key: config.ocr.apiKey, + } + } + return profiles } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 460a6f4..d5a31f2 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -24,11 +24,21 @@ export interface Slide { imageBase64?: string imageStorageKey?: string imageAsset?: SlideAssetRef + textMetadata?: ExportTextMetadata[] prompt: string editHistory?: EditHistoryItem[] updatedAt?: number } +export interface ExportTextMetadata { + text: string + role: string + order: number + style_hint?: Record +} + +export type EditablePptxFallbackPolicy = 'fail' | 'text_editable_background' | 'raster_pptx' + /** * 编辑历史记录项 */ @@ -78,6 +88,8 @@ export interface FullApiConfig { image: ImageApiConfig text: TextApiConfig edit?: ImageApiConfig + vlm?: ImageApiConfig + ocr?: ImageApiConfig } /** @@ -267,14 +279,15 @@ export interface ModelProfileRequestConfig { model: string base_url: string api_key: string - adapter: 'openai_chat' | 'raw_chat_multimodal' | string thinking?: 'enabled' | 'disabled' } export interface ModelProfilesRequestConfig { - prompt_model: ModelProfileRequestConfig + text_model: ModelProfileRequestConfig image_model: ModelProfileRequestConfig edit_model?: ModelProfileRequestConfig + VLM?: ModelProfileRequestConfig + ocr_model?: ModelProfileRequestConfig } export interface ModelProfilePublic { @@ -282,7 +295,6 @@ export interface ModelProfilePublic { label: string model: string base_url: string - adapter: string has_api_key: boolean thinking?: 'enabled' | 'disabled' } @@ -290,9 +302,12 @@ export interface ModelProfilePublic { export interface ModelProfilesResponse { success: boolean profiles?: { - prompt_model: ModelProfilePublic + text_model: ModelProfilePublic + prompt_model?: ModelProfilePublic image_model: ModelProfilePublic edit_model: ModelProfilePublic + VLM?: ModelProfilePublic + ocr_model?: ModelProfilePublic } message?: string } @@ -313,4 +328,4 @@ export interface SSEEvent { /** * 导出格式 */ -export type ExportFormat = 'pdf' | 'pptx' +export type ExportFormat = 'pdf' | 'pptx' | 'generative_editable_pptx' diff --git a/web/src/utils/apiConfig.ts b/web/src/utils/apiConfig.ts index a79a780..547c7ce 100644 --- a/web/src/utils/apiConfig.ts +++ b/web/src/utils/apiConfig.ts @@ -20,6 +20,8 @@ export const DEFAULT_FULL_API_CONFIG: FullApiConfig = { image: DEFAULT_IMAGE_CONFIG, text: DEFAULT_TEXT_CONFIG, edit: { ...DEFAULT_IMAGE_CONFIG }, + vlm: { apiKey: '', baseUrl: '', model: '' }, + ocr: { apiKey: '', baseUrl: '', model: '' }, } export function loadFullApiConfig(): FullApiConfig { @@ -48,6 +50,16 @@ export function loadFullApiConfig(): FullApiConfig { baseUrl: parsed.edit?.baseUrl || image.baseUrl, model: parsed.edit?.model || image.model, }, + vlm: { + apiKey: parsed.vlm?.apiKey || '', + baseUrl: parsed.vlm?.baseUrl || '', + model: parsed.vlm?.model || '', + }, + ocr: { + apiKey: parsed.ocr?.apiKey || '', + baseUrl: parsed.ocr?.baseUrl || '', + model: parsed.ocr?.model || '', + }, } } } catch (e) { @@ -70,12 +82,16 @@ export function validateFullApiConfig(config: FullApiConfig): { image?: { apiKey?: string; baseUrl?: string; model?: string } text?: { apiKey?: string; baseUrl?: string; model?: string } edit?: { apiKey?: string; baseUrl?: string; model?: string } + vlm?: { apiKey?: string; baseUrl?: string; model?: string } + ocr?: { apiKey?: string; baseUrl?: string; model?: string } } } { const errors: { image?: { apiKey?: string; baseUrl?: string; model?: string } text?: { apiKey?: string; baseUrl?: string; model?: string } edit?: { apiKey?: string; baseUrl?: string; model?: string } + vlm?: { apiKey?: string; baseUrl?: string; model?: string } + ocr?: { apiKey?: string; baseUrl?: string; model?: string } } = {} const validateUrl = (value: string | undefined) => { @@ -108,6 +124,23 @@ export function validateFullApiConfig(config: FullApiConfig): { if (Object.keys(editErrors).length > 0) errors.edit = editErrors } + const validateOptionalModel = ( + value: ImageApiConfig | undefined, + emptyModelMessage: string + ) => { + if (!value || (!value.apiKey && !value.baseUrl && !value.model)) return undefined + const modelErrors: { apiKey?: string; baseUrl?: string; model?: string } = {} + const urlError = validateUrl(value.baseUrl) + if (urlError) modelErrors.baseUrl = urlError + if (!value.model?.trim()) modelErrors.model = emptyModelMessage + return Object.keys(modelErrors).length > 0 ? modelErrors : undefined + } + + const vlmErrors = validateOptionalModel(config.vlm, 'VLM 模型名称不能为空') + if (vlmErrors) errors.vlm = vlmErrors + const ocrErrors = validateOptionalModel(config.ocr, 'OCR 模型名称不能为空') + if (ocrErrors) errors.ocr = ocrErrors + return { isValid: Object.keys(errors).length === 0, errors } } From b7189309028d435063b9cdcbf446d5492a487787 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 04:29:37 +0800 Subject: [PATCH 03/16] Harden VLM editable PPTX real flow --- scripts/run_real_generative_editable_pptx.py | 105 ++++++++- src/generative_editable_providers.py | 19 +- src/generative_editable_vlm_reconstruction.py | 208 +++++++++++++++++- ...test_generative_editable_live_providers.py | 94 ++++++++ ...t_generative_editable_preview_validator.py | 40 ++++ ..._generative_editable_vlm_reconstruction.py | 150 ++++++++++++- tests/test_real_generative_editable_runner.py | 105 ++++++++- 7 files changed, 710 insertions(+), 11 deletions(-) diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py index aac761d..e6c1f9f 100644 --- a/scripts/run_real_generative_editable_pptx.py +++ b/scripts/run_real_generative_editable_pptx.py @@ -102,6 +102,12 @@ def main(argv: list[str] | None = None) -> int: default=-1.0, help="override provider retry backoff seconds for gates; negative keeps zero backoff", ) + gates_parser.add_argument( + "--gate-wall-timeout", + type=int, + default=0, + help="wall-clock timeout in seconds for each provider gate; 0 runs gates in-process", + ) run_parser = subparsers.add_parser("run", help="run the editable PPTX pipeline") _add_common_args(run_parser) @@ -200,6 +206,10 @@ def _add_common_args(parser: argparse.ArgumentParser) -> None: def _run_gates(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: images = _resolve_input_images(args) + requested = _requested_gates(args.provider_gate) + gate_wall_timeout = int(getattr(args, "gate_wall_timeout", 0) or 0) + if gate_wall_timeout > 0: + return _run_gates_with_wall_timeout(args, output_dir, images, requested, gate_wall_timeout) dependencies = _gate_dependencies( use_fake=args.use_fake, provider_timeout=args.provider_timeout, @@ -207,7 +217,6 @@ def _run_gates(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: provider_retry_backoff_seconds=getattr(args, "provider_retry_backoff", -1.0), ) config = load_generative_editable_config(use_fake=args.use_fake) - requested = _requested_gates(args.provider_gate) gates = [] for gate in requested: gate_dependencies = dependencies["vlm"] if gate == "vlm_analysis" else dependencies["legacy"] @@ -225,6 +234,78 @@ def _run_gates(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: return {"status": status, "report_path": str(report_path), "gates": gates} +def _run_gates_with_wall_timeout( + args: argparse.Namespace, + output_dir: Path, + images: list[Path], + requested: list[str], + gate_wall_timeout: int, +) -> dict[str, Any]: + config = load_generative_editable_config(use_fake=args.use_fake) + gates: list[dict[str, Any]] = [] + for gate in requested: + gate_dir = output_dir / f"gate-{gate}" + gate_dir.mkdir(parents=True, exist_ok=True) + command = [ + sys.executable, + str(Path(__file__).resolve()), + "gates", + "--input-images", + *[str(path) for path in images], + "--output-dir", + str(gate_dir), + "--provider-gate", + gate, + "--aspect-ratio", + args.aspect_ratio, + "--provider-timeout", + str(args.provider_timeout), + "--provider-max-attempts", + str(getattr(args, "provider_max_attempts", 0)), + "--provider-retry-backoff", + str(getattr(args, "provider_retry_backoff", -1.0)), + ] + if args.use_fake: + command.append("--use-fake") + child_result = _run_subprocess_json( + command, + timeout_seconds=gate_wall_timeout, + report_path=gate_dir / "provider-gates.json", + timeout_payload={ + "gate": gate, + "status": "failed", + "error_type": "TimeoutExpired", + "error": f"provider gate exceeded wall-clock timeout_seconds={gate_wall_timeout}", + "timeout_seconds": gate_wall_timeout, + }, + ) + gates.append(_extract_gate_result(gate, child_result)) + status = "passed" if all(item["status"] == "passed" for item in gates) else "failed" + report = { + "status": status, + "input_images": [str(path) for path in images], + "output_dir": str(output_dir), + "config": _redacted_config_summary(config), + "gate_wall_timeout": gate_wall_timeout, + "gates": gates, + } + report_path = output_dir / "provider-gates.json" + _write_json(report_path, report) + return {"status": status, "report_path": str(report_path), "gates": gates} + + +def _extract_gate_result(gate: str, child_result: dict[str, Any]) -> dict[str, Any]: + child_gates = child_result.get("gates") + if isinstance(child_gates, list) and child_gates: + first = child_gates[0] + if isinstance(first, dict): + return first + payload = dict(child_result) + payload.setdefault("gate", gate) + payload.setdefault("status", "failed") + return payload + + def _run_one_gate( gate: str, images: list[Path], @@ -664,6 +745,7 @@ def _run_subprocess_json( "error": "child process did not print a JSON result", "returncode": process.returncode, } + _add_subprocess_output_tails(result, stdout=stdout, stderr=stderr) if process.returncode != 0 and result.get("status") == "passed": result = { **result, @@ -671,6 +753,7 @@ def _run_subprocess_json( "error_type": "SubprocessFailed", "returncode": process.returncode, } + _add_subprocess_output_tails(result, stdout=stdout, stderr=stderr) if not report_path.exists(): _write_json(report_path, result) return result @@ -693,7 +776,10 @@ def _run_subprocess_json( **timeout_payload, "status": "failed", "error_type": "TimeoutExpired", - "error": f"child process exceeded wall-clock timeout_seconds={timeout_seconds}", + "error": timeout_payload.get( + "error", + f"child process exceeded wall-clock timeout_seconds={timeout_seconds}", + ), "timeout_seconds": timeout_seconds, } result = _augment_timeout_result_from_artifacts(result) @@ -701,6 +787,19 @@ def _run_subprocess_json( return result +def _add_subprocess_output_tails( + result: dict[str, Any], + *, + stdout: str, + stderr: str, + max_chars: int = 2000, +) -> None: + if stdout: + result["stdout_tail"] = safe_provider_error_message(stdout[-max_chars:]) + if stderr: + result["stderr_tail"] = safe_provider_error_message(stderr[-max_chars:]) + + def _read_existing_report(report_path: Path) -> dict[str, Any] | None: if not report_path.exists(): return None @@ -1463,7 +1562,7 @@ def _bitmap_asset_coverage_issues( issues.append( { "code": "high_bitmap_asset_coverage", - "severity": "warning", + "severity": "error", "page": page_number, "slide_id": page.slide_id, "message": "Source-preserved bitmap assets cover a large part of the slide", diff --git a/src/generative_editable_providers.py b/src/generative_editable_providers.py index 250c346..665c765 100644 --- a/src/generative_editable_providers.py +++ b/src/generative_editable_providers.py @@ -994,6 +994,11 @@ def _post_openai_chat( def _is_retryable_transport_exception(exc: Exception) -> bool: + if isinstance(exc, requests.HTTPError): + status_code = exc.response.status_code if exc.response is not None else 0 + return status_code == 429 or status_code >= 500 + if isinstance(exc, requests.RequestException): + return True if isinstance(exc, http.client.IncompleteRead): return True text = f"{type(exc).__name__}: {exc}".lower() @@ -1006,6 +1011,10 @@ def _is_retryable_transport_exception(exc: Exception) -> bool: "connection reset", "protocolerror", "remote end closed", + "sslerror", + "unexpected_eof", + "eof occurred", + "max retries exceeded", ) ) @@ -1975,10 +1984,16 @@ def _write_normalized_provider_image( secret_values=[config.api_key, config.base_url], ) from exc except Exception as exc: + status_code = ( + exc.response.status_code + if isinstance(exc, requests.HTTPError) and exc.response is not None + else None + ) raise ProviderError( provider_role=config.role, operation=operation, message=str(exc), - retryable=False, - secret_values=[config.api_key], + retryable=_is_retryable_transport_exception(exc), + secret_values=[config.api_key, config.base_url], + status_code=status_code, ) from exc diff --git a/src/generative_editable_vlm_reconstruction.py b/src/generative_editable_vlm_reconstruction.py index d479721..7243ae9 100644 --- a/src/generative_editable_vlm_reconstruction.py +++ b/src/generative_editable_vlm_reconstruction.py @@ -470,6 +470,7 @@ def _validate_vlm_pipeline_output( ) -> ValidationReport: reports = [ _validate_vlm_minimum_editable_structure(page_manifests), + _validate_vlm_source_preserved_bitmap_coverage(page_manifests), dependencies.structure_validator( deck_manifest_path=deck_manifest_path, artifact_root=artifact_root, @@ -497,6 +498,165 @@ def _validate_vlm_pipeline_output( return _merge_validation_reports(reports, checked_pages=len(page_manifests)) +def _validate_vlm_source_preserved_bitmap_coverage(page_manifests: list[PageManifest]) -> ValidationReport: + issues: list[ValidationIssue] = [] + for page in page_manifests: + issues.extend(_vlm_source_preserved_bitmap_coverage_issues(page)) + return ValidationReport( + status="failed" if any(issue.severity == "error" for issue in issues) else "passed", + checked_pages=len(page_manifests), + issues=issues, + ) + + +def _vlm_source_preserved_bitmap_coverage_issues(page: PageManifest) -> list[ValidationIssue]: + source_width, source_height = page.source_image_size + page_area = max(1, int(source_width) * int(source_height)) + if page_area <= 1: + return [] + source_preserved_assets = [ + asset for asset in page.bitmap_assets if _is_vlm_source_preserved_bitmap_asset(asset) + ] + if not source_preserved_assets: + return [] + largest_asset = max( + source_preserved_assets, + key=lambda asset: _vlm_source_preserved_asset_effective_area_ratio( + asset, + (int(source_width), int(source_height)), + ), + ) + largest_ratio = _vlm_source_preserved_asset_effective_area_ratio( + largest_asset, + (int(source_width), int(source_height)), + ) + largest_bbox_ratio = _bbox_area(largest_asset.source_pixel_bbox) / float(page_area) + combined_bbox_ratio = _combined_bbox_coverage_ratio( + [asset.source_pixel_bbox for asset in source_preserved_assets], + (int(source_width), int(source_height)), + ) + combined_visible_ratio = min( + 1.0, + sum( + _vlm_source_preserved_asset_effective_area_ratio( + asset, + (int(source_width), int(source_height)), + ) + for asset in source_preserved_assets + ), + ) + combined_ratio = min(combined_bbox_ratio, combined_visible_ratio) + structure_count = len(page.text_boxes) + len(page.native_shapes) + details = { + "largest_asset_id": largest_asset.asset_id, + "largest_asset_area_ratio": round(largest_ratio, 4), + "largest_asset_bbox_area_ratio": round(largest_bbox_ratio, 4), + "combined_bitmap_asset_coverage_ratio": round(combined_ratio, 4), + "combined_bitmap_asset_bbox_coverage_ratio": round(combined_bbox_ratio, 4), + "combined_bitmap_asset_visible_area_ratio": round(combined_visible_ratio, 4), + "bitmap_asset_count": len(page.bitmap_assets), + "source_preserved_bitmap_asset_count": len(source_preserved_assets), + "native_shape_count": len(page.native_shapes), + "text_box_count": len(page.text_boxes), + "page_index": page.page_index, + } + if largest_ratio >= 0.80: + return [ + ValidationIssue( + code="oversized_bitmap_asset_coverage", + message="A single source-preserved bitmap asset covers most of the slide", + slide_id=page.slide_id, + severity="error", + details=details, + ) + ] + if combined_ratio >= 0.85 and structure_count < 3: + return [ + ValidationIssue( + code="excessive_bitmap_asset_coverage", + message="Source-preserved bitmap assets cover most of the slide without enough editable structure", + slide_id=page.slide_id, + severity="error", + details=details, + ) + ] + if combined_ratio >= 0.65 and not _has_vlm_split_bitmap_structure( + source_preserved_assets, + largest_ratio=largest_ratio, + structure_count=structure_count, + ): + return [ + ValidationIssue( + code="high_bitmap_asset_coverage", + message="Source-preserved bitmap assets cover a large part of the slide", + slide_id=page.slide_id, + severity="error", + details=details, + ) + ] + return [] + + +def _is_vlm_source_preserved_bitmap_asset(asset: BitmapAssetSpec) -> bool: + return asset.provenance.get("asset_strategy") in { + "masked_source_element", + "source_preserved_crop", + "source_preserving_anchor", + } or asset.provenance.get("source_type") == "vlm_source_crop" + + +def _vlm_source_preserved_asset_effective_area_ratio( + asset: BitmapAssetSpec, + source_size: tuple[int, int], +) -> float: + visible_ratio = asset.provenance.get("alpha_visible_area_ratio") + if ( + asset.provenance.get("background_difference_alpha") is True + and isinstance(visible_ratio, (int, float)) + and 0.0 <= float(visible_ratio) <= 1.0 + ): + return float(visible_ratio) + page_area = max(1, source_size[0] * source_size[1]) + return _bbox_area(asset.source_pixel_bbox) / float(page_area) + + +def _has_vlm_split_bitmap_structure( + source_preserved_assets: list[BitmapAssetSpec], + *, + largest_ratio: float, + structure_count: int, +) -> bool: + split_row_structure = ( + len(source_preserved_assets) >= 3 + and largest_ratio <= 0.35 + and structure_count >= 3 + ) + dense_infographic_structure = ( + len(source_preserved_assets) >= 3 + and largest_ratio <= 0.60 + and structure_count >= 12 + ) + return split_row_structure or dense_infographic_structure + + +def _combined_bbox_coverage_ratio( + bboxes: list[PixelBBox], + source_size: tuple[int, int], +) -> float: + width, height = source_size + if width <= 0 or height <= 0: + return 0.0 + mask = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(mask) + for bbox in bboxes: + left, top, right, bottom = _clamp_bbox(bbox, source_size) + if right > left and bottom > top: + draw.rectangle((left, top, right, bottom), fill=255) + histogram = mask.histogram() + covered = width * height - histogram[0] + return covered / float(width * height) + + def _validate_vlm_minimum_editable_structure(page_manifests: list[PageManifest]) -> ValidationReport: issues: list[ValidationIssue] = [] for page in page_manifests: @@ -1137,7 +1297,20 @@ def _resolve_text_boxes_with_vlm_gate( ) ) if not analysis.text_regions: - return [] + reliable_ocr_boxes = [ + _ocr_text_with_ocr_layout(box) + for box in ocr_text_boxes + if _ocr_box_is_reliable_without_vlm_text_region(box) + ] + if not _ocr_only_text_fallback_is_sane(reliable_ocr_boxes, source_image_size): + return [] + return _dedupe_overlapping_text_boxes( + _avoid_bitmap_text_overlap( + reliable_ocr_boxes, + bitmap_assets, + source_image_size, + ) + ) resolved: list[TextBoxSpec] = [] used_indexes: set[int] = set() @@ -1229,6 +1402,39 @@ def _vlm_fallback_is_already_represented( return False +def _ocr_box_is_reliable_without_vlm_text_region(box: TextBoxSpec) -> bool: + text = _normalize_text_for_match(box.text) + if not text: + return False + confidence = float(box.provenance.get("ocr_confidence", 0.0) or 0.0) + if confidence >= 0.85: + return True + return confidence >= 0.75 and len(text) >= 4 + + +def _ocr_only_text_fallback_is_sane( + boxes: list[TextBoxSpec], + source_image_size: tuple[int, int], +) -> bool: + if not boxes: + return True + if len(boxes) > 40: + return False + normalized_lengths = [len(_normalize_text_for_match(box.text)) for box in boxes] + if len(boxes) > 20 and sum(1 for length in normalized_lengths if length <= 1) / len(boxes) >= 0.65: + return False + page_area = max(1, int(source_image_size[0]) * int(source_image_size[1])) + combined_bbox_ratio = _combined_bbox_coverage_ratio( + [box.source_pixel_bbox for box in boxes], + source_image_size, + ) + summed_text_area_ratio = min( + 1.0, + sum(_bbox_area(box.source_pixel_bbox) for box in boxes) / float(page_area), + ) + return combined_bbox_ratio <= 0.55 and summed_text_area_ratio <= 0.35 + + def _vlm_text_should_stay_baked_into_bitmap( text_box: TextBoxSpec, bitmap_assets: list[BitmapAssetSpec], diff --git a/tests/test_generative_editable_live_providers.py b/tests/test_generative_editable_live_providers.py index bc439e6..c9d31d2 100644 --- a/tests/test_generative_editable_live_providers.py +++ b/tests/test_generative_editable_live_providers.py @@ -583,6 +583,100 @@ def test_openai_chat_image_edit_normalization_errors_are_redacted(self): self.assertNotIn("secret-key", str(ctx.exception)) self.assertIn("[URL_REDACTED]", str(ctx.exception)) + def test_openai_chat_image_edit_marks_download_ssl_eof_as_retryable(self): + payload = {"choices": [{"message": {"content": "https://signed.example/image.png?token=secret-key"}}]} + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (32, 18), "white").save(source) + request = ImageEditRequest( + source_image_path=str(source), + prompt_id="base_clean_background", + prompt="remove text", + output_asset_path="assets/out.png", + asset_root=str(root), + ) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + with patch("src.image_result.requests.get") as get: + get.side_effect = requests.exceptions.SSLError( + "HTTPSConnectionPool(host='signed.example', port=443): " + "Max retries exceeded with url: /image.png " + "(Caused by SSLError(SSLEOFError(8, " + "'[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol')))" + ) + with self.assertRaises(ProviderError) as ctx: + OpenAIChatImageEditProvider( + _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + ).edit(request) + + self.assertTrue(ctx.exception.retryable) + self.assertNotIn("secret-key", str(ctx.exception)) + self.assertIn("[URL_REDACTED]", str(ctx.exception)) + + def test_openai_chat_image_edit_marks_download_http_404_as_non_retryable(self): + payload = {"choices": [{"message": {"content": "https://signed.example/missing.png?token=secret-key"}}]} + response = requests.Response() + response.status_code = 404 + error = requests.HTTPError("404 Client Error", response=response) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (32, 18), "white").save(source) + request = ImageEditRequest( + source_image_path=str(source), + prompt_id="base_clean_background", + prompt="remove text", + output_asset_path="assets/out.png", + asset_root=str(root), + ) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + with patch("src.image_result.requests.get") as get: + get.return_value.raise_for_status.side_effect = error + with self.assertRaises(ProviderError) as ctx: + OpenAIChatImageEditProvider( + _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + ).edit(request) + + self.assertFalse(ctx.exception.retryable) + self.assertEqual(ctx.exception.status_code, 404) + self.assertNotIn("secret-key", str(ctx.exception)) + + def test_openai_chat_image_edit_marks_download_http_503_as_retryable(self): + payload = {"choices": [{"message": {"content": "https://signed.example/temporary.png"}}]} + response = requests.Response() + response.status_code = 503 + error = requests.HTTPError("503 Server Error", response=response) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.png" + Image.new("RGB", (32, 18), "white").save(source) + request = ImageEditRequest( + source_image_path=str(source), + prompt_id="base_clean_background", + prompt="remove text", + output_asset_path="assets/out.png", + asset_root=str(root), + ) + + with patch("src.generative_editable_providers.requests.post") as post: + post.return_value = _FakeResponse(payload) + with patch("src.image_result.requests.get") as get: + get.return_value.raise_for_status.side_effect = error + with self.assertRaises(ProviderError) as ctx: + OpenAIChatImageEditProvider( + _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + ).edit(request) + + self.assertTrue(ctx.exception.retryable) + self.assertEqual(ctx.exception.status_code, 503) + def test_openai_chat_image_generation_provider_writes_normalized_image(self): image_base64 = base64.b64encode(_png_bytes()).decode() payload = {"choices": [{"message": {"content": f"data:image/png;base64,{image_base64}"}}]} diff --git a/tests/test_generative_editable_preview_validator.py b/tests/test_generative_editable_preview_validator.py index 8b1b1c1..1ba9cc3 100644 --- a/tests/test_generative_editable_preview_validator.py +++ b/tests/test_generative_editable_preview_validator.py @@ -295,6 +295,46 @@ def test_structural_validation_rejects_source_crop_bitmap_asset_provenance(self) self.assertEqual(issue.details["target_id"], "asset") self.assertEqual(issue.details["asset_ref"], "assets/0000-slide-a/asset.png") + def test_structural_validation_allows_vlm_bbox_source_preserved_crop_assets(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_preserved_path = root / "assets" / "0000-slide-a" / "asset.source-preserved.png" + source_preserved_path.write_bytes( + (root / "assets" / "0000-slide-a" / "asset.png").read_bytes() + ) + unsafe_page = replace( + page, + bitmap_assets=[ + replace( + page.bitmap_assets[0], + asset_path="assets/0000-slide-a/asset.source-preserved.png", + provenance={ + "asset_strategy": "source_preserved_crop", + "asset_sheet_provider_failed": True, + }, + ) + ], + ) + write_manifest(root / "pages" / "0000-slide-a.json", unsafe_page) + output = root / "source-preserved-provider-failed.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "passed") + self.assertNotIn( + "forbidden_source_crop_bitmap_asset", + [issue.code for issue in report.issues], + ) + def test_structural_validation_reports_unsafe_full_slide_source_with_text(self): from src.generative_editable_composer import compose_deck_from_manifests from src.generative_editable_preview_validator import validate_composed_deck_structure diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index f626bee..d9e9e3b 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -15,6 +15,93 @@ class GenerativeEditableVLMReconstructionTest(unittest.TestCase): + def test_vlm_validation_fails_large_source_preserved_bitmap_coverage_without_runner(self): + from src.generative_editable_manifest import BitmapAssetSpec, PageManifest + from src.generative_editable_vlm_reconstruction import _validate_vlm_source_preserved_bitmap_coverage + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + bitmap_assets=[ + BitmapAssetSpec( + asset_id="huge-source-preserved-crop", + source_pixel_bbox=(0, 0, 850, 500), + asset_path="assets/huge-source-preserved-crop.png", + z_order=1, + provenance={"asset_strategy": "source_preserved_crop"}, + ) + ], + ) + + report = _validate_vlm_source_preserved_bitmap_coverage([page]) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "oversized_bitmap_asset_coverage") + + def test_vlm_validation_allows_split_row_source_preserved_bitmap_structure(self): + from src.generative_editable_manifest import BitmapAssetSpec, NativeShapeSpec, PageManifest, TextBoxSpec + from src.generative_editable_vlm_reconstruction import _validate_vlm_source_preserved_bitmap_coverage + + page = PageManifest( + slide_id="slide-a", + page_index=0, + source_image_path="sources/slide-a.png", + source_image_size=(1000, 500), + slide_size=(10.0, 5.0), + text_boxes=[ + TextBoxSpec( + text="标题", + source_pixel_bbox=(40, 20, 160, 60), + source_pixel_polygon=((40, 20), (160, 20), (160, 60), (40, 60)), + ) + ], + native_shapes=[ + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(20, 80, 980, 82), + line_start=(20, 80), + line_end=(980, 80), + ), + NativeShapeSpec( + shape_type="line", + source_pixel_bbox=(20, 240, 980, 242), + line_start=(20, 240), + line_end=(980, 240), + ), + ], + bitmap_assets=[ + BitmapAssetSpec( + asset_id="row-a", + source_pixel_bbox=(0, 0, 1000, 150), + asset_path="assets/row-a.png", + z_order=1, + provenance={"asset_strategy": "source_preserved_crop", "alpha_visible_area_ratio": 0.12}, + ), + BitmapAssetSpec( + asset_id="row-b", + source_pixel_bbox=(0, 170, 1000, 320), + asset_path="assets/row-b.png", + z_order=2, + provenance={"asset_strategy": "source_preserved_crop", "alpha_visible_area_ratio": 0.12}, + ), + BitmapAssetSpec( + asset_id="row-c", + source_pixel_bbox=(0, 340, 1000, 490), + asset_path="assets/row-c.png", + z_order=3, + provenance={"asset_strategy": "source_preserved_crop", "alpha_visible_area_ratio": 0.12}, + ), + ], + ) + + report = _validate_vlm_source_preserved_bitmap_coverage([page]) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + def test_vlm_pipeline_runs_structure_and_preview_validation_before_returning(self): from src.generative_editable_preview_validator import ValidationReport from src.generative_editable_vlm_reconstruction import ( @@ -1776,7 +1863,7 @@ def test_vlm_fallback_does_not_duplicate_nearby_ocr_text_line(self): self.assertEqual([box.text for box in page.text_boxes], ["Livis版800V主动悬架"]) self.assertEqual(page.text_boxes[0].source_pixel_bbox, (1292, 540, 1502, 563)) - def test_without_vlm_text_regions_ocr_is_not_spread_across_page(self): + def test_without_vlm_text_regions_uses_high_confidence_ocr_boxes(self): from src.generative_editable_manifest import TextBoxSpec from src.generative_editable_vlm_reconstruction import ( build_page_manifest_from_vlm_analysis, @@ -1825,9 +1912,63 @@ def test_without_vlm_text_regions_ocr_is_not_spread_across_page(self): text_boxes=ocr_boxes, ) - self.assertEqual(page.text_boxes, []) + self.assertEqual([box.text for box in page.text_boxes], [f"OCR {index}" for index in range(5)]) + self.assertTrue( + all(box.provenance.get("layout_source") == "ocr" for box in page.text_boxes) + ) self.assertEqual(page.provenance["vlm_counts"]["text_regions"], 0) + def test_without_vlm_text_regions_rejects_dense_short_ocr_noise(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (240, 120), "#001122").save(source) + Image.new("RGB", (240, 120), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 240, "height": 120, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_boxes = [ + TextBoxSpec( + text="x", + source_pixel_bbox=(5 + (index % 20) * 11, 5 + (index // 20) * 10, 12 + (index % 20) * 11, 12 + (index // 20) * 10), + source_pixel_polygon=( + (5 + (index % 20) * 11, 5 + (index // 20) * 10), + (12 + (index % 20) * 11, 5 + (index // 20) * 10), + (12 + (index % 20) * 11, 12 + (index // 20) * 10), + (5 + (index % 20) * 11, 12 + (index // 20) * 10), + ), + provenance={"content_source": "ocr", "ocr_confidence": 0.99}, + ) + for index in range(60) + ] + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=ocr_boxes, + ) + + self.assertEqual(page.text_boxes, []) + def test_openai_chat_vlm_provider_requests_compact_json_from_gateway_friendly_image(self): from src.generative_editable_vlm_reconstruction import OpenAIChatVLMPageAnalysisProvider @@ -2200,7 +2341,7 @@ def edit(self, request): self.assertGreaterEqual(shape_types.count(MSO_SHAPE_TYPE.PICTURE), 1) self.assertIn(MSO_SHAPE_TYPE.LINE, shape_types) - def test_vlm_pipeline_falls_back_to_source_crops_when_asset_sheet_provider_fails(self): + def test_vlm_pipeline_rejects_asset_sheet_provider_failure_source_crops(self): from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE @@ -2862,7 +3003,8 @@ def extract_text(self, image_path): mask = Image.open(job_dir / "assets" / "0000-slide-a" / "vlm-text-bitmap-mask.png") self.assertEqual(len(ocr_provider.calls), 1) - self.assertEqual(page.text_boxes, []) + self.assertEqual([box.text for box in page.text_boxes], ["OCR 正文"]) + self.assertEqual(page.text_boxes[0].provenance["layout_source"], "ocr") self.assertIn("ocr", page.provider_output_paths) self.assertEqual(mask.getpixel((25, 25)), 255) diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index c8313a1..5e27986 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -943,6 +943,65 @@ def generate(self, request): self.assertEqual(gate["provider_attempts"][0]["provider_error_code"], "upstream_timeout") self.assertNotIn("secret", json.dumps(gate["provider_attempts"], ensure_ascii=False)) + def test_provider_gates_can_run_each_gate_with_wall_timeout(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + calls = [] + + def fake_subprocess(command, *, timeout_seconds, report_path, timeout_payload): + calls.append( + { + "command": command, + "timeout_seconds": timeout_seconds, + "report_path": report_path, + "timeout_payload": timeout_payload, + } + ) + return { + **timeout_payload, + "status": "failed", + "error_type": "TimeoutExpired", + "timeout_seconds": timeout_seconds, + } + + stdout = io.StringIO() + with patch.object(runner, "_run_subprocess_json", side_effect=fake_subprocess), redirect_stdout(stdout): + exit_code = main( + [ + "gates", + "--use-fake", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--provider-gate", + "image_generation", + "--provider-timeout", + "5", + "--provider-max-attempts", + "2", + "--provider-retry-backoff", + "0", + "--gate-wall-timeout", + "1", + ] + ) + report = json.loads((root / "out" / "provider-gates.json").read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 1) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["timeout_seconds"], 1) + command = calls[0]["command"] + self.assertIn("--provider-gate", command) + self.assertEqual(command[command.index("--provider-gate") + 1], "image_generation") + self.assertNotIn("--gate-wall-timeout", command) + self.assertEqual(report["gates"][0]["gate"], "image_generation") + self.assertEqual(report["gates"][0]["error_type"], "TimeoutExpired") + def test_provider_gates_can_run_vlm_analysis_gate(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1079,6 +1138,50 @@ def test_subprocess_json_runner_times_out_and_writes_report(self): self.assertEqual(report["stage"], "test") self.assertEqual(report["error_type"], "TimeoutExpired") + def test_subprocess_json_runner_preserves_custom_timeout_error(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report_path = root / "report.json" + + result = runner._run_subprocess_json( + [sys.executable, "-c", "import time; time.sleep(2)"], + timeout_seconds=1, + report_path=report_path, + timeout_payload={ + "status": "failed", + "stage": "provider_gate", + "error": "provider gate exceeded wall-clock timeout_seconds=1", + }, + ) + + self.assertEqual(result["error_type"], "TimeoutExpired") + self.assertEqual(result["error"], "provider gate exceeded wall-clock timeout_seconds=1") + + def test_subprocess_json_runner_includes_redacted_stderr_when_child_prints_no_json(self): + import scripts.run_real_generative_editable_pptx as runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report_path = root / "report.json" + + result = runner._run_subprocess_json( + [ + sys.executable, + "-c", + "import sys; sys.stderr.write('boom api_key=secret-key\\n')", + ], + timeout_seconds=5, + report_path=report_path, + timeout_payload={"status": "failed", "stage": "test"}, + ) + + self.assertEqual(result["error_type"], "SubprocessOutputError") + self.assertIn("stderr_tail", result) + self.assertIn("[REDACTED]", result["stderr_tail"]) + self.assertNotIn("secret-key", result["stderr_tail"]) + def test_subprocess_timeout_report_includes_existing_pptx_object_stats(self): import scripts.run_real_generative_editable_pptx as runner @@ -2063,7 +2166,7 @@ def test_reconstruction_issues_degrade_for_high_combined_bitmap_coverage(self): ) self.assertEqual(issues[0]["code"], "high_bitmap_asset_coverage") - self.assertEqual(issues[0]["severity"], "warning") + self.assertEqual(issues[0]["severity"], "error") def test_reconstruction_issues_allow_split_row_level_bitmap_assets(self): import scripts.run_real_generative_editable_pptx as runner From b122c30541719eb9c90ca4ebddfaeddc01c45e23 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 05:32:47 +0800 Subject: [PATCH 04/16] Require VLM-first editable PPTX export --- api/routes/export.py | 51 +++---- scripts/run_real_generative_editable_pptx.py | 6 +- src/generative_editable_vlm_reconstruction.py | 11 +- ...est_generative_editable_export_contract.py | 61 ++++++++ .../test_generative_editable_export_route.py | 135 ++++++++++++++---- ..._generative_editable_vlm_reconstruction.py | 90 ++++++++++++ tests/test_real_generative_editable_runner.py | 19 ++- 7 files changed, 308 insertions(+), 65 deletions(-) diff --git a/api/routes/export.py b/api/routes/export.py index 33c49ad..46ed586 100644 --- a/api/routes/export.py +++ b/api/routes/export.py @@ -200,6 +200,10 @@ def _export_generative_editable_pptx( slide_order=slide_order, ) ordered_image_paths = [slide.image_path for slide in slide_inputs] + if config.reconstruction_mode != "vlm_first": + raise GenerativeEditableConfigError( + "VLM-first reconstruction is required for generative_editable_pptx export" + ) fallback_factory = None if fallback_policy == "raster_pptx": fallback_factory = lambda: _export_raster_pptx_fallback( @@ -215,36 +219,23 @@ def _export_generative_editable_pptx( ) try: - if config.reconstruction_mode == "vlm_first": - try: - return run_vlm_editable_pptx_pipeline( - slides=slide_inputs, - output_path=output_path, - artifact_root=str(artifact_root), - job_id=job_id, - dependencies=_build_vlm_editable_pipeline_dependencies(config), - aspect_ratio=aspect_ratio, - cleanup_artifacts=fallback_policy == "fail", - ) - except GenerativeEditableValidationError as exc: - return finalize_validated_export( - validation_report=exc.validation_report, - output_path=output_path, - fallback_policy=fallback_policy, - fallback_output_factory=fallback_factory, - ) - return run_generative_editable_pipeline( - slides=slide_inputs, - output_path=output_path, - artifact_root=str(artifact_root), - job_id=job_id, - dependencies=_build_generative_editable_pipeline_dependencies(), - aspect_ratio=aspect_ratio, - fallback_policy=fallback_policy, - fallback_output_factory=fallback_factory, - max_page_concurrency=1, - cleanup_artifacts=True, - ) + try: + return run_vlm_editable_pptx_pipeline( + slides=slide_inputs, + output_path=output_path, + artifact_root=str(artifact_root), + job_id=job_id, + dependencies=_build_vlm_editable_pipeline_dependencies(config), + aspect_ratio=aspect_ratio, + cleanup_artifacts=fallback_policy == "fail", + ) + except GenerativeEditableValidationError as exc: + return finalize_validated_export( + validation_report=exc.validation_report, + output_path=output_path, + fallback_policy=fallback_policy, + fallback_output_factory=fallback_factory, + ) except Exception: Path(output_path).unlink(missing_ok=True) raise diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py index e6c1f9f..d38aa8a 100644 --- a/scripts/run_real_generative_editable_pptx.py +++ b/scripts/run_real_generative_editable_pptx.py @@ -114,7 +114,7 @@ def main(argv: list[str] | None = None) -> int: run_parser.add_argument( "--mode", choices=["legacy", "vlm_first"], - default="legacy", + default="vlm_first", help="pipeline mode; legacy uses OCR/CV, vlm_first uses VLM structured analysis", ) run_parser.add_argument("--job-id", default="") @@ -408,7 +408,7 @@ def _run_one_gate( def _run_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: - if getattr(args, "mode", "legacy") == "vlm_first": + if getattr(args, "mode", "vlm_first") == "vlm_first": return _run_vlm_pipeline(args, output_dir) images = _resolve_input_images(args) job_id = args.job_id or _generated_job_id() @@ -658,7 +658,7 @@ def _run_isolated_pages(args: argparse.Namespace, output_dir: Path) -> dict[str, str(Path(__file__).resolve()), "run", "--mode", - getattr(args, "mode", "legacy"), + getattr(args, "mode", "vlm_first"), "--input-images", str(image), "--output-dir", diff --git a/src/generative_editable_vlm_reconstruction.py b/src/generative_editable_vlm_reconstruction.py index 7243ae9..408445d 100644 --- a/src/generative_editable_vlm_reconstruction.py +++ b/src/generative_editable_vlm_reconstruction.py @@ -1252,13 +1252,16 @@ def build_page_manifest_from_vlm_analysis( for region in analysis.shape_regions for shape in _native_shapes_from_vlm(region, mapper, source_image_size) ] + background_ref = _artifact_ref(background_path, root) return PageManifest( slide_id=slide_id, page_index=page_index, source_image_path=_artifact_ref(source_path, root), source_image_size=source_image_size, slide_size=_slide_size_inches(aspect_ratio), - chosen_background=_artifact_ref(background_path, root), + text_clean_background=background_ref, + base_clean_background=background_ref, + chosen_background=background_ref, text_boxes=resolved_text_boxes, native_shapes=native_shapes, bitmap_assets=resolved_bitmap_assets, @@ -1266,6 +1269,7 @@ def build_page_manifest_from_vlm_analysis( provider_output_paths={}, provenance={ "reconstruction_strategy": "vlm_first", + "chosen_background_kind": "source_preserving_text_clean", "vlm_counts": { "text_regions": len(analysis.text_regions), "bitmap_regions": len(analysis.bitmap_regions), @@ -2732,9 +2736,10 @@ def _estimate_font_size( # VLM bboxes describe the visible glyph envelope, while PPT renderers need # extra line-height headroom. A conservative factor avoids dense pages # drifting from source renders because text clips or expands vertically. - font_size = height_px / source_image_size[1] * slide_height_inches * 72 * 0.65 if role in {"title", "heading"}: - return max(9.0, min(font_size, 30.0)) + font_size = height_px / source_image_size[1] * slide_height_inches * 72 * 0.9 + return max(12.0, min(font_size, 44.0)) + font_size = height_px / source_image_size[1] * slide_height_inches * 72 * 0.65 return max(7.0, min(font_size, 22.0)) diff --git a/tests/test_generative_editable_export_contract.py b/tests/test_generative_editable_export_contract.py index c8f4924..1828b4e 100644 --- a/tests/test_generative_editable_export_contract.py +++ b/tests/test_generative_editable_export_contract.py @@ -260,6 +260,67 @@ def test_temporary_generative_export_does_not_silently_return_raster_output(self self.assertFalse(output_path.exists()) + def test_generative_editable_export_rejects_non_vlm_reconstruction_mode(self): + from src.generative_editable_config import ( + GenerativeEditableConfig, + GenerativeEditableConfigError, + ProviderConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "out.pptx" + Image.new("RGB", (800, 450), "white").save(image_path) + legacy_called = False + + def fake_legacy_pipeline(**kwargs): + nonlocal legacy_called + legacy_called = True + Path(kwargs["output_path"]).write_bytes(b"legacy") + + provider = ProviderConfig( + role="stub", + model="stub-model", + base_url="https://example.test/v1", + api_key="secret", + ) + non_vlm_config = GenerativeEditableConfig( + ocr=provider, + clean_base_model=provider, + asset_sheet_model=provider, + repair_model=provider, + generation_model=provider, + use_aippt_metadata_first=True, + ocr_min_confidence=0.75, + quality=QualityConfig(), + retries=RetryConfig(), + timeouts=TimeoutConfig(), + reconstruction_mode="generative", + ) + + original_loader = export_route.load_generative_editable_config + original_legacy = export_route.run_generative_editable_pipeline + export_route.load_generative_editable_config = lambda: non_vlm_config + export_route.run_generative_editable_pipeline = fake_legacy_pipeline + try: + with self.assertRaisesRegex( + GenerativeEditableConfigError, + "VLM-first reconstruction is required", + ): + export_route._export_generative_editable_pptx( + [str(image_path)], + str(output_path), + ) + finally: + export_route.load_generative_editable_config = original_loader + export_route.run_generative_editable_pipeline = original_legacy + + self.assertFalse(legacy_called) + self.assertFalse(output_path.exists()) + def test_generative_export_accepts_explicit_raster_fallback_policy(self): with tempfile.TemporaryDirectory() as tmp: image_path = Path(tmp) / "slide.png" diff --git a/tests/test_generative_editable_export_route.py b/tests/test_generative_editable_export_route.py index 09f752f..3a3fca9 100644 --- a/tests/test_generative_editable_export_route.py +++ b/tests/test_generative_editable_export_route.py @@ -1,11 +1,12 @@ import asyncio +import base64 import tempfile import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from PIL import Image +from PIL import Image, ImageDraw from fastapi import HTTPException from api.models import ExportRequest, ExportSlide @@ -13,9 +14,25 @@ def _slide_base64() -> str: - from tests.test_generative_editable_export_contract import _slide_base64 as make_slide - - return make_slide() + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + _write_fake_vlm_source(image_path) + return base64.b64encode(image_path.read_bytes()).decode() + + +def _write_fake_vlm_source(path: Path, *, size: tuple[int, int] = (800, 450)) -> None: + image = Image.new("RGB", size, "white") + width, height = size + ImageDraw.Draw(image).rectangle( + ( + round(width * 0.08), + round(height * 0.08), + round(width * 0.48), + round(height * 0.18), + ), + fill="#2563EB", + ) + image.save(path) def _fake_route_dependencies( @@ -36,6 +53,63 @@ def _fake_route_dependencies( return dependencies +def _fake_vlm_route_dependencies( + *, + validation_status="passed", + validation_code="preview_similarity_failed", + provider_exception=None, +): + from src.generative_editable_config import ProviderConfig + from src.generative_editable_preview_validator import ValidationIssue, ValidationReport + from src.generative_editable_providers import FakeImageEditProvider, FakeOCRProvider + from src.generative_editable_vlm_reconstruction import ( + FakeVLMPageAnalysisProvider, + VLMEditablePipelineDependencies, + ) + + def preview_validator(**kwargs): + if validation_status == "failed": + return ValidationReport( + status="failed", + checked_pages=1, + issues=[ + ValidationIssue( + code=validation_code, + message="forced preview failure", + slide_id=kwargs["slide_id"], + ) + ], + ) + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + provider = ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ocr_provider = FakeOCRProvider( + ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="fake-ocr", + base_url="fake", + api_key="fake", + ) + ) + dependencies = VLMEditablePipelineDependencies( + vlm_provider=FakeVLMPageAnalysisProvider(), + image_edit_provider=FakeImageEditProvider(provider), + asset_sheet_image_edit_provider=FakeImageEditProvider(provider), + ocr_provider=ocr_provider, + preview_validator=preview_validator, + ) + if provider_exception is not None: + dependencies = _with_raising_ocr_provider(dependencies, provider_exception) + return dependencies + + def _legacy_generative_config(): from dataclasses import replace @@ -44,6 +118,14 @@ def _legacy_generative_config(): return replace(load_generative_editable_config(use_fake=True), reconstruction_mode="generative") +def _vlm_first_config(): + from dataclasses import replace + + from src.generative_editable_config import load_generative_editable_config + + return replace(load_generative_editable_config(use_fake=True), reconstruction_mode="vlm_first") + + def _with_raising_ocr_provider(dependencies, exception): class RaisingOCRProvider: config = dependencies.ocr_provider.config @@ -93,7 +175,7 @@ class GenerativeEditableExportRouteTest(unittest.TestCase): def setUp(self): self._config_patcher = patch( "api.routes.export.load_generative_editable_config", - return_value=_legacy_generative_config(), + return_value=_vlm_first_config(), ) self._config_patcher.start() @@ -446,7 +528,7 @@ async def fake_to_thread(func, *args, **kwargs): asyncio.to_thread = fake_to_thread try: with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", + "api.routes.export._build_vlm_editable_pipeline_dependencies", side_effect=GenerativeEditableConfigError("Missing provider configuration"), ): with self.assertRaises(HTTPException) as ctx: @@ -492,8 +574,8 @@ async def fake_to_thread(func, *args, **kwargs): try: for raised, expected_status in ((timeout, 504), (provider_error, 502)): with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda raised=raised: _fake_route_dependencies(provider_exception=raised), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config, raised=raised: _fake_vlm_route_dependencies(provider_exception=raised), ): with self.assertRaises(HTTPException) as ctx: asyncio.run( @@ -522,8 +604,8 @@ async def fake_to_thread(func, *args, **kwargs): asyncio.to_thread = fake_to_thread try: with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda: _fake_route_dependencies(validation_status="failed"), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(validation_status="failed"), ): with self.assertRaises(HTTPException) as ctx: asyncio.run( @@ -552,8 +634,11 @@ async def fake_to_thread(func, *args, **kwargs): asyncio.to_thread = fake_to_thread try: with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda: _fake_route_dependencies(repair_limit_failure=True), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies( + validation_status="failed", + validation_code="repair_limit_exceeded", + ), ): with self.assertRaises(HTTPException) as ctx: asyncio.run( @@ -580,8 +665,8 @@ async def fake_to_thread(func, *args, **kwargs): asyncio.to_thread = fake_to_thread try: with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda: _fake_route_dependencies(validation_status="failed"), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(validation_status="failed"), ): response = asyncio.run( export_presentation( @@ -613,8 +698,8 @@ async def fake_to_thread(func, *args, **kwargs): asyncio.to_thread = fake_to_thread try: with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda: _fake_route_dependencies(), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(), ): response = asyncio.run( export_presentation( @@ -654,11 +739,11 @@ def test_generative_editable_route_returns_valid_pptx_with_fake_providers(self): with tempfile.TemporaryDirectory() as tmp: image_path = Path(tmp) / "slide.png" output_path = Path(tmp) / "out.pptx" - Image.new("RGB", (800, 450), "white").save(image_path) + _write_fake_vlm_source(image_path) with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda: _fake_route_dependencies(), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(), ): result = export_route._export_generative_editable_pptx( [str(image_path)], @@ -730,11 +815,11 @@ def fake_raster_export(image_paths, output_path, aspect_ratio="16:9"): with tempfile.TemporaryDirectory() as tmp: image_path = Path(tmp) / "slide.png" output_path = Path(tmp) / "out.pptx" - Image.new("RGB", (800, 450), "white").save(image_path) + _write_fake_vlm_source(image_path, size=(800, 600)) with patch("api.routes.export._export_pptx", fake_raster_export), patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda: _fake_route_dependencies(validation_status="failed"), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(validation_status="failed"), ): result = export_route._export_generative_editable_pptx( [str(image_path)], @@ -759,11 +844,11 @@ def test_text_editable_background_fallback_returns_text_editable_background_deck with tempfile.TemporaryDirectory() as tmp: image_path = Path(tmp) / "slide.png" output_path = Path(tmp) / "out.pptx" - Image.new("RGB", (800, 450), "white").save(image_path) + _write_fake_vlm_source(image_path) with patch( - "api.routes.export._build_generative_editable_pipeline_dependencies", - lambda: _fake_route_dependencies(validation_status="failed"), + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(validation_status="failed"), ): result = export_route._export_generative_editable_pptx( [str(image_path)], diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index d9e9e3b..a9084d4 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -1479,6 +1479,96 @@ def test_vlm_text_font_size_leaves_renderer_headroom_for_approximate_bboxes(self self.assertLessEqual(page.text_boxes[0].font_size or 0, 10.6) self.assertTrue(page.text_boxes[0].style_hints["approximate_layout"]) + def test_vlm_title_font_size_preserves_large_visual_hierarchy(self): + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1672, 941), "#001122").save(source) + Image.new("RGB", (1672, 941), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1672, "height": 941, "unit": "px"}, + "text_regions": [ + { + "id": "hero-title", + "text": "旗舰增程SUV的技术实验", + "bbox": [67, 283, 923, 368], + "role": "title", + "confidence": 0.95, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + ) + + self.assertGreaterEqual(page.text_boxes[0].font_size or 0, 32.0) + self.assertTrue(page.text_boxes[0].style_hints["bold"]) + + def test_vlm_page_manifest_marks_clean_background_as_text_clean(self): + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (800, 450), "#001122").save(source) + Image.new("RGB", (800, 450), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 800, "height": 450, "unit": "px"}, + "text_regions": [ + { + "id": "title", + "text": "Editable Title", + "bbox": [64, 36, 384, 81], + "role": "title", + "confidence": 1.0, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + ) + + self.assertEqual(page.chosen_background, "backgrounds/0000-slide-a/clean.png") + self.assertEqual(page.text_clean_background, page.chosen_background) + self.assertEqual(page.base_clean_background, page.chosen_background) + self.assertEqual(page.provenance["chosen_background_kind"], "source_preserving_text_clean") + def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_mismatched_ocr(self): from src.generative_editable_manifest import TextBoxSpec from src.generative_editable_vlm_reconstruction import ( diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index 5e27986..97b5832 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -169,6 +169,8 @@ def fake_pipeline(**kwargs): exit_code = main( [ "run", + "--mode", + "legacy", "--input-images", str(source), "--output-dir", @@ -249,6 +251,8 @@ def fake_pipeline(**kwargs): exit_code = main( [ "run", + "--mode", + "legacy", "--input-images", str(source), "--output-dir", @@ -268,7 +272,7 @@ def fake_pipeline(**kwargs): str((root / "out" / "fallback-policy.raster-fallback.pptx").resolve()), ) - def test_run_mode_can_route_to_vlm_first_pipeline(self): + def test_run_mode_defaults_to_vlm_first_pipeline(self): import scripts.run_real_generative_editable_pptx as runner captured = {} @@ -320,14 +324,17 @@ def fake_vlm_pipeline(**kwargs): with ( patch.object(runner, "_vlm_dependencies", return_value=dependency_sentinel), patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object( + runner, + "run_generative_editable_pipeline", + side_effect=AssertionError("default run mode must not call legacy pipeline"), + ), patch.object(runner, "_write_preview_reports", return_value=[]), redirect_stdout(stdout), ): exit_code = main( [ "run", - "--mode", - "vlm_first", "--input-images", str(source), "--output-dir", @@ -587,6 +594,8 @@ def fake_pipeline(**kwargs): exit_code = main( [ "run", + "--mode", + "legacy", "--input-images", str(source), "--output-dir", @@ -2786,7 +2795,9 @@ def test_isolated_pages_mode_writes_page_reports(self): input_dir = root / "inputs" input_dir.mkdir() image_path = input_dir / "slide_1.png" - Image.new("RGB", (800, 450), "#FFFFFF").save(image_path) + image = Image.new("RGB", (800, 450), "#FFFFFF") + ImageDraw.Draw(image).rectangle((64, 36, 384, 81), fill="#2563EB") + image.save(image_path) stdout = io.StringIO() with redirect_stdout(stdout): From d4073cfe363a3da16155277394fb0bcb055855b4 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 06:56:54 +0800 Subject: [PATCH 05/16] Harden VLM editable PPTX reconstruction --- scripts/run_real_generative_editable_pptx.py | 29 ++- src/generative_editable_assets.py | 21 ++ src/generative_editable_preview_validator.py | 19 ++ src/generative_editable_vlm_reconstruction.py | 153 +++++++++++-- tests/test_generative_editable_assets.py | 76 +++++++ ...t_generative_editable_preview_validator.py | 47 +++- ..._generative_editable_vlm_reconstruction.py | 214 +++++++++++++++++- tests/test_real_generative_editable_runner.py | 79 +++++++ 8 files changed, 614 insertions(+), 24 deletions(-) diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py index d38aa8a..a736969 100644 --- a/scripts/run_real_generative_editable_pptx.py +++ b/scripts/run_real_generative_editable_pptx.py @@ -201,7 +201,20 @@ def _add_common_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--aspect-ratio", choices=["16:9", "4:3"], default="16:9") parser.add_argument("--output-dir", default="") parser.add_argument("--use-fake", action="store_true") - parser.add_argument("--provider-timeout", type=int, default=60) + parser.add_argument( + "--provider-timeout", + type=int, + default=0, + help="provider call timeout seconds; 0 uses generative_editable_pptx.timeouts.provider_call", + ) + + +def _provider_timeout_for_args(args: argparse.Namespace) -> int: + configured = int(getattr(args, "provider_timeout", 0) or 0) + if configured > 0: + return configured + config = load_generative_editable_config(use_fake=bool(getattr(args, "use_fake", False))) + return config.timeouts.provider_call def _run_gates(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: @@ -210,9 +223,10 @@ def _run_gates(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: gate_wall_timeout = int(getattr(args, "gate_wall_timeout", 0) or 0) if gate_wall_timeout > 0: return _run_gates_with_wall_timeout(args, output_dir, images, requested, gate_wall_timeout) + provider_timeout = _provider_timeout_for_args(args) dependencies = _gate_dependencies( use_fake=args.use_fake, - provider_timeout=args.provider_timeout, + provider_timeout=provider_timeout, provider_max_attempts=getattr(args, "provider_max_attempts", 0), provider_retry_backoff_seconds=getattr(args, "provider_retry_backoff", -1.0), ) @@ -220,7 +234,7 @@ def _run_gates(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: gates = [] for gate in requested: gate_dependencies = dependencies["vlm"] if gate == "vlm_analysis" else dependencies["legacy"] - gates.append(_run_one_gate(gate, images, gate_dependencies, output_dir, args.provider_timeout)) + gates.append(_run_one_gate(gate, images, gate_dependencies, output_dir, provider_timeout)) status = "passed" if all(item["status"] == "passed" for item in gates) else "failed" report = { "status": status, @@ -415,8 +429,9 @@ def _run_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: artifact_root = output_dir / "artifacts" output_path = output_dir / f"{job_id}.pptx" raster_fallback_path = output_dir / f"{job_id}.raster-fallback.pptx" + provider_timeout = _provider_timeout_for_args(args) dependencies = replace( - _with_timeout(_dependencies(use_fake=args.use_fake), args.provider_timeout), + _with_timeout(_dependencies(use_fake=args.use_fake), provider_timeout), allow_source_crop_asset_fallback=False, ) result = run_generative_editable_pipeline( @@ -499,9 +514,10 @@ def _run_vlm_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, A job_id = args.job_id or _generated_job_id() artifact_root = output_dir / "artifacts" output_path = output_dir / f"{job_id}.pptx" + provider_timeout = _provider_timeout_for_args(args) dependencies = _vlm_dependencies( use_fake=args.use_fake, - provider_timeout=args.provider_timeout, + provider_timeout=provider_timeout, provider_max_attempts=getattr(args, "provider_max_attempts", 0), provider_retry_backoff_seconds=getattr(args, "provider_retry_backoff", -1.0), page_timeout_seconds=getattr(args, "pipeline_page_timeout", 0.0), @@ -649,6 +665,7 @@ def _run_isolated_pages(args: argparse.Namespace, output_dir: Path) -> dict[str, images = _resolve_input_images(args) job_id = args.job_id or f"real-editable-{datetime.now().strftime('%Y%m%d%H%M%S')}" page_reports = [] + provider_timeout = _provider_timeout_for_args(args) for index, image in enumerate(images, start=1): page_dir = output_dir / f"page-{index:02d}" page_dir.mkdir(parents=True, exist_ok=True) @@ -668,7 +685,7 @@ def _run_isolated_pages(args: argparse.Namespace, output_dir: Path) -> dict[str, "--aspect-ratio", args.aspect_ratio, "--provider-timeout", - str(args.provider_timeout), + str(provider_timeout), "--provider-max-attempts", str(getattr(args, "provider_max_attempts", 0)), "--provider-retry-backoff", diff --git a/src/generative_editable_assets.py b/src/generative_editable_assets.py index c6aa19e..344ca90 100644 --- a/src/generative_editable_assets.py +++ b/src/generative_editable_assets.py @@ -131,6 +131,7 @@ def slice_asset_sheet_by_components( asset_root: str | Path, padding: int = 12, min_component_area: int = 32, + allow_extra_components: bool = False, ) -> list[BitmapAssetSpec]: if not candidates: return [] @@ -155,6 +156,15 @@ def slice_asset_sheet_by_components( raise ValueError( f"asset sheet contains unexpected extra component(s) for {candidates[0].candidate_id}" ) + ignored_extra_component_count = 0 + if len(boxes) > len(candidates) and allow_extra_components: + original_count = len(boxes) + boxes = [ + box + for box in boxes + if not _is_ignorable_extra_component(box, min_component_area=min_component_area) + ] + ignored_extra_component_count = original_count - len(boxes) if len(boxes) > len(candidates): raise ValueError( f"asset sheet contains unexpected extra component(s), got {len(boxes)} for {len(candidates)} candidate(s)" @@ -179,12 +189,23 @@ def slice_asset_sheet_by_components( "component_bbox": bbox, "sheet_bbox": crop_box, "split_method": "connected_components", + "ignored_extra_component_count": ignored_extra_component_count, }, ) ) return assets +def _is_ignorable_extra_component( + bbox: tuple[int, int, int, int], + *, + min_component_area: int, +) -> bool: + width = max(0, bbox[2] - bbox[0]) + height = max(0, bbox[3] - bbox[1]) + return width * height <= max(min_component_area, min_component_area * 4) + + def validate_sliced_asset( *, asset_path: str | Path, diff --git a/src/generative_editable_preview_validator.py b/src/generative_editable_preview_validator.py index ea55982..bc769df 100644 --- a/src/generative_editable_preview_validator.py +++ b/src/generative_editable_preview_validator.py @@ -615,6 +615,8 @@ def _validate_bitmap_asset_provenance( asset, issues: list[ValidationIssue], ) -> None: + if _is_allowed_complex_source_preserved_asset(asset): + return provenance_text = _compact_text(asset.provenance) asset_path_text = _compact_text(asset.asset_path) if not ( @@ -640,6 +642,23 @@ def _validate_bitmap_asset_provenance( ) +def _is_allowed_complex_source_preserved_asset(asset) -> bool: + provenance = getattr(asset, "provenance", {}) or {} + candidate_provenance = provenance.get("candidate_provenance") + if not isinstance(candidate_provenance, dict): + candidate_provenance = {} + return ( + provenance.get("asset_strategy") == "source_preserved_crop" + and provenance.get("candidate_classification") == "complex_whole_visual" + and provenance.get("alpha_strategy") == "opaque_source_crop" + and provenance.get("asset_sheet_skipped_reason") == "complex_bitmap_region" + and candidate_provenance.get("source") == "vlm_bitmap_region" + and bool(provenance.get("original_source_pixel_bbox")) + and not provenance.get("asset_sheet_provider_failed") + and not provenance.get("asset_sheet_slicing_failed") + ) + + def _compact_text(value: object) -> str: if value is None: return "" diff --git a/src/generative_editable_vlm_reconstruction.py b/src/generative_editable_vlm_reconstruction.py index 408445d..3598f46 100644 --- a/src/generative_editable_vlm_reconstruction.py +++ b/src/generative_editable_vlm_reconstruction.py @@ -76,7 +76,12 @@ MAX_BITMAP_ELEMENT_AREA_RATIO = 0.72 MAX_BITMAP_ELEMENT_WIDTH_RATIO = 0.92 MAX_BITMAP_ELEMENT_HEIGHT_RATIO = 0.72 -ASSET_SHEET_PROVIDER_TIMEOUT_SECONDS = 45 +ASSET_SHEET_PROVIDER_TIMEOUT_SECONDS = 120 +COMPLEX_BITMAP_MASK_KINDS = frozenset({"photo", "product", "component", "complex"}) +CONDITIONAL_COMPLEX_BITMAP_MASK_KINDS = frozenset({"other", "qr"}) +MIN_COMPLEX_BITMAP_MASK_PADDING = 16 +MAX_COMPLEX_BITMAP_MASK_PADDING = 48 +COMPLEX_BITMAP_MASK_PADDING_RATIO = 0.08 @dataclass(frozen=True) @@ -941,16 +946,17 @@ def _build_vlm_page_manifest( analysis_space=analysis.coordinate_space, source_image_size=source_image_size, ) + protected_text_bboxes = [ + text_box.source_pixel_bbox + for text_box in (text_result.text_boxes if text_result else []) + ] page_deadline.check("vlm_build_mask") with _record_vlm_stage(artifacts, "vlm_build_mask", page_index=page_index, slide_id=slide_id): mask, mask_boxes = build_text_bitmap_mask( analysis, mapper, padding=dependencies.mask_padding, - additional_text_bboxes=[ - text_box.source_pixel_bbox - for text_box in (text_result.text_boxes if text_result else []) - ], + additional_text_bboxes=protected_text_bboxes, ) page_deadline.check("vlm_build_mask") mask_path = artifacts.asset_path(slide_id, page_index, "assets", "vlm-text-bitmap-mask.png") @@ -1001,6 +1007,7 @@ def _build_vlm_page_manifest( page_index=page_index, edit_provider=asset_sheet_provider, timeout_seconds=asset_sheet_timeout_seconds, + additional_text_bboxes=protected_text_bboxes, ) page_deadline.check("vlm_asset_sheet") vlm_output_path = artifacts.write_provider_output( @@ -1178,6 +1185,11 @@ def build_text_bitmap_mask( mask = Image.new("L", mapper.source_image_size, 0) draw = ImageDraw.Draw(mask) boxes: list[MaskBox] = [] + protected_bboxes = _protected_mask_bboxes( + analysis, + mapper, + additional_text_bboxes=additional_text_bboxes, + ) for region in analysis.text_regions: bbox = mapper.to_source_bbox(region.bbox, padding=padding) draw.rectangle(bbox, fill=255) @@ -1186,7 +1198,18 @@ def build_text_bitmap_mask( unpadded_bbox = mapper.to_source_bbox(region.bbox, padding=0) if _bitmap_region_should_be_ignored_as_element(unpadded_bbox, mapper.source_image_size): continue - bbox = mapper.to_source_bbox(region.bbox, padding=padding) + region_padding = _mask_padding_for_bitmap_region( + region, + unpadded_bbox, + mapper.source_image_size, + padding, + ) + bbox = _expand_bbox_avoiding_protected( + unpadded_bbox, + padding=region_padding, + source_image_size=mapper.source_image_size, + protected_bboxes=protected_bboxes, + ) draw.rectangle(bbox, fill=255) boxes.append(MaskBox(kind="bitmap", region_id=region.region_id, bbox=bbox)) for index, bbox in enumerate(additional_text_bboxes or [], start=1): @@ -1196,6 +1219,54 @@ def build_text_bitmap_mask( return mask, boxes +def _protected_mask_bboxes( + analysis: VLMPageAnalysis, + mapper: VLMCoordinateMapper, + *, + additional_text_bboxes: list[PixelBBox] | None = None, +) -> list[PixelBBox]: + protected = [ + mapper.to_source_bbox(region.bbox, padding=0) + for region in analysis.text_regions + ] + protected.extend( + mapper.to_source_bbox(region.bbox, padding=0) + for region in analysis.shape_regions + ) + protected.extend(additional_text_bboxes or []) + return protected + + +def _expand_bbox_avoiding_protected( + bbox: PixelBBox, + *, + padding: int, + source_image_size: tuple[int, int], + protected_bboxes: list[PixelBBox], +) -> PixelBBox: + left, top, right, bottom = bbox + expanded_left = left - padding + expanded_top = top - padding + expanded_right = right + padding + expanded_bottom = bottom + padding + for protected in protected_bboxes: + protected_left, protected_top, protected_right, protected_bottom = protected + if _ranges_overlap(top, bottom, protected_top, protected_bottom): + if protected_right <= left: + expanded_left = min(left, max(expanded_left, protected_right + 1)) + elif protected_left >= right: + expanded_right = max(right, min(expanded_right, protected_left - 1)) + if _ranges_overlap(left, right, protected_left, protected_right): + if protected_bottom <= top: + expanded_top = min(top, max(expanded_top, protected_bottom + 1)) + elif protected_top >= bottom: + expanded_bottom = max(bottom, min(expanded_bottom, protected_top - 1)) + return _clamp_bbox( + (expanded_left, expanded_top, expanded_right, expanded_bottom), + source_image_size, + ) + + def build_page_manifest_from_vlm_analysis( *, analysis: VLMPageAnalysis, @@ -1806,11 +1877,13 @@ def _create_vlm_asset_sheet_assets( page_index: int, edit_provider: ImageEditProvider, timeout_seconds: int, + additional_text_bboxes: list[PixelBBox] | None = None, ) -> tuple[list[BitmapAssetSpec], list[AssetSheetSpec], Path | None]: text_bboxes = [ mapper.to_source_bbox(region.bbox, padding=8) for region in analysis.text_regions ] + text_bboxes.extend(_pad_bbox(bbox, 8) for bbox in additional_text_bboxes or []) candidates = _foreground_candidates_from_vlm_bitmap_regions(analysis, mapper) candidates = _refine_candidates_with_clean_background_difference( candidates=candidates, @@ -1883,6 +1956,7 @@ def _create_vlm_asset_sheet_assets( candidates=asset_sheet_candidates, output_dir=artifacts.job_dir / "assets" / f"{page_index:04d}-{_safe_name(slide_id)}", asset_root=artifacts.job_dir, + allow_extra_components=True, ) except ValueError as exc: assets = _source_preserved_bitmap_assets_from_candidates( @@ -2022,14 +2096,32 @@ def _source_preserved_bitmap_assets_from_candidates( _pad_bbox(candidate.source_pixel_bbox, fallback_padding), source_rgb.size, ) - crop = _foreground_rgba_crop( - source_rgb, - background_rgb, - bbox, - transparent_bboxes=transparent_bboxes or [], - ) + use_opaque_crop = candidate.classification == "complex_whole_visual" + if use_opaque_crop: + crop = source_rgb.crop(bbox).convert("RGBA") + _clear_crop_regions(crop, bbox, transparent_bboxes or []) + else: + crop = _foreground_rgba_crop( + source_rgb, + background_rgb, + bbox, + transparent_bboxes=transparent_bboxes or [], + ) visible_pixel_count = _alpha_visible_pixel_count(crop) source_area = max(1, source_rgb.size[0] * source_rgb.size[1]) + alpha_provenance = ( + { + "alpha_strategy": "opaque_source_crop", + "alpha_visible_pixel_count": visible_pixel_count, + "alpha_visible_area_ratio": round(visible_pixel_count / float(source_area), 6), + } + if use_opaque_crop + else { + "background_difference_alpha": True, + "alpha_visible_pixel_count": visible_pixel_count, + "alpha_visible_area_ratio": round(visible_pixel_count / float(source_area), 6), + } + ) output_path = output_dir / f"{_safe_name(candidate.candidate_id)}.source-preserved.png" crop.save(output_path) assets.append( @@ -2044,10 +2136,8 @@ def _source_preserved_bitmap_assets_from_candidates( "candidate_classification": candidate.classification, "candidate_provenance": dict(candidate.provenance), "asset_strategy": "source_preserved_crop", - "background_difference_alpha": True, - "alpha_visible_pixel_count": visible_pixel_count, - "alpha_visible_area_ratio": round(visible_pixel_count / float(source_area), 6), "original_source_pixel_bbox": list(candidate.source_pixel_bbox), + **alpha_provenance, }, provider_failed=asset_sheet_provider_failed, slicing_failed=asset_sheet_slicing_failed, @@ -2661,6 +2751,35 @@ def _bitmap_region_should_be_ignored_as_element(bbox: PixelBBox, size: tuple[int return _is_near_full_slide_region(bbox, size) +def _mask_padding_for_bitmap_region( + region: VLMBitmapRegion, + bbox: PixelBBox, + source_image_size: tuple[int, int], + base_padding: int, +) -> int: + if not _bitmap_region_needs_broad_clean_mask(region, bbox, source_image_size): + return base_padding + width = max(1, bbox[2] - bbox[0]) + height = max(1, bbox[3] - bbox[1]) + adaptive_padding = round(max(width, height) * COMPLEX_BITMAP_MASK_PADDING_RATIO) + broad_padding = max(MIN_COMPLEX_BITMAP_MASK_PADDING, adaptive_padding) + broad_padding = min(MAX_COMPLEX_BITMAP_MASK_PADDING, broad_padding) + return max(base_padding, broad_padding) + + +def _bitmap_region_needs_broad_clean_mask( + region: VLMBitmapRegion, + bbox: PixelBBox, + source_image_size: tuple[int, int], +) -> bool: + kind = region.kind.lower() + if kind in COMPLEX_BITMAP_MASK_KINDS: + return True + if kind not in CONDITIONAL_COMPLEX_BITMAP_MASK_KINDS: + return False + return region.importance.lower() == "major" or _area_ratio(bbox, source_image_size) >= 0.01 + + def _is_near_full_slide_region(bbox: PixelBBox, size: tuple[int, int]) -> bool: width = max(1, size[0]) height = max(1, size[1]) @@ -2700,6 +2819,10 @@ def _bbox_intersection_area(left_bbox: PixelBBox, right_bbox: PixelBBox) -> int: return (right - left) * (bottom - top) +def _ranges_overlap(left_start: int, left_end: int, right_start: int, right_end: int) -> bool: + return min(left_end, right_end) > max(left_start, right_start) + + def _bbox_area(bbox: PixelBBox) -> int: return max(0, bbox[2] - bbox[0]) * max(0, bbox[3] - bbox[1]) diff --git a/tests/test_generative_editable_assets.py b/tests/test_generative_editable_assets.py index 5f7bafc..d8cc8f1 100644 --- a/tests/test_generative_editable_assets.py +++ b/tests/test_generative_editable_assets.py @@ -383,6 +383,82 @@ def test_component_slicing_rejects_extra_components_for_multiple_candidates(self padding=4, ) + def test_component_slicing_can_ignore_extra_components_when_explicitly_allowed(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGBA", (240, 160), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((20, 32, 58, 70), fill=(0, 112, 243, 255)) + draw.rectangle((90, 32, 128, 70), fill=(16, 185, 129, 255)) + draw.rectangle((20, 112, 27, 119), fill=(255, 255, 255, 255)) + image.save(sheet) + + assets = slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=[ + ForegroundCandidate( + candidate_id="fg-left", + source_pixel_bbox=(10, 10, 45, 45), + area=1200, + classification="bitmap_asset_candidate", + ), + ForegroundCandidate( + candidate_id="fg-right", + source_pixel_bbox=(80, 12, 126, 54), + area=1400, + classification="bitmap_asset_candidate", + ), + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=4, + allow_extra_components=True, + ) + + self.assertEqual([asset.asset_id for asset in assets], ["fg-left", "fg-right"]) + self.assertTrue(all(asset.provenance["ignored_extra_component_count"] == 1 for asset in assets)) + + def test_component_slicing_rejects_large_extra_components_even_when_allowed(self): + from src.generative_editable_assets import slice_asset_sheet_by_components + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet = root / "asset_sheets" / "0000-slide-a" / "sheet.png" + sheet.parent.mkdir(parents=True) + image = Image.new("RGBA", (240, 120), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rectangle((20, 32, 58, 70), fill=(255, 255, 255, 255)) + draw.rectangle((90, 32, 128, 70), fill=(0, 112, 243, 255)) + draw.rectangle((168, 28, 220, 78), fill=(16, 185, 129, 255)) + image.save(sheet) + + with self.assertRaisesRegex(ValueError, "unexpected extra component"): + slice_asset_sheet_by_components( + sheet_path=sheet, + candidates=[ + ForegroundCandidate( + candidate_id="fg-left", + source_pixel_bbox=(10, 10, 45, 45), + area=1200, + classification="bitmap_asset_candidate", + ), + ForegroundCandidate( + candidate_id="fg-right", + source_pixel_bbox=(80, 12, 126, 54), + area=1400, + classification="bitmap_asset_candidate", + ), + ], + output_dir=root / "assets" / "0000-slide-a", + asset_root=root, + padding=4, + allow_extra_components=True, + ) + def test_component_slicing_orders_same_row_by_horizontal_position(self): from src.generative_editable_assets import slice_asset_sheet_by_components diff --git a/tests/test_generative_editable_preview_validator.py b/tests/test_generative_editable_preview_validator.py index 1ba9cc3..301eaaf 100644 --- a/tests/test_generative_editable_preview_validator.py +++ b/tests/test_generative_editable_preview_validator.py @@ -314,7 +314,11 @@ def test_structural_validation_allows_vlm_bbox_source_preserved_crop_assets(self asset_path="assets/0000-slide-a/asset.source-preserved.png", provenance={ "asset_strategy": "source_preserved_crop", - "asset_sheet_provider_failed": True, + "alpha_strategy": "opaque_source_crop", + "candidate_classification": "complex_whole_visual", + "asset_sheet_skipped_reason": "complex_bitmap_region", + "candidate_provenance": {"source": "vlm_bitmap_region"}, + "original_source_pixel_bbox": [10, 10, 80, 80], }, ) ], @@ -335,6 +339,47 @@ def test_structural_validation_allows_vlm_bbox_source_preserved_crop_assets(self [issue.code for issue in report.issues], ) + def test_structural_validation_rejects_incomplete_opaque_source_crop_provenance(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_preserved_path = root / "assets" / "0000-slide-a" / "asset.source-preserved.png" + source_preserved_path.write_bytes( + (root / "assets" / "0000-slide-a" / "asset.png").read_bytes() + ) + unsafe_page = replace( + page, + bitmap_assets=[ + replace( + page.bitmap_assets[0], + asset_path="assets/0000-slide-a/asset.source-preserved.png", + provenance={ + "asset_strategy": "source_preserved_crop", + "alpha_strategy": "opaque_source_crop", + "candidate_classification": "complex_whole_visual", + }, + ) + ], + ) + write_manifest(root / "pages" / "0000-slide-a.json", unsafe_page) + output = root / "source-preserved-incomplete.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "failed") + self.assertIn( + "forbidden_source_crop_bitmap_asset", + [issue.code for issue in report.issues], + ) + def test_structural_validation_reports_unsafe_full_slide_source_with_text(self): from src.generative_editable_composer import compose_deck_from_manifests from src.generative_editable_preview_validator import validate_composed_deck_structure diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index a9084d4..c25cdab 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -825,6 +825,65 @@ def test_mask_uses_text_and_bitmap_regions_but_excludes_shape_regions(self): self.assertEqual(mask.getpixel((80, 80)), 0) self.assertEqual([box.kind for box in mask_boxes], ["text", "bitmap"]) + def test_mask_expands_complex_bitmap_regions_more_than_icon_regions(self): + from src.generative_editable_vlm_reconstruction import ( + VLMCoordinateMapper, + build_text_bitmap_mask, + coerce_vlm_analysis, + ) + + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + {"id": "product", "type": "product", "bbox": [80, 20, 120, 45]}, + {"id": "icon", "type": "icon", "bbox": [10, 20, 20, 30]}, + ], + "shape_regions": [], + } + ) + mapper = VLMCoordinateMapper( + analysis_space=analysis.coordinate_space, + source_image_size=(160, 90), + ) + + _mask, mask_boxes = build_text_bitmap_mask(analysis, mapper, padding=4) + + by_id = {box.region_id: box.bbox for box in mask_boxes} + self.assertEqual(by_id["icon"], (6, 16, 24, 34)) + self.assertLessEqual(by_id["product"][0], 64) + self.assertLessEqual(by_id["product"][1], 4) + self.assertGreaterEqual(by_id["product"][2], 136) + self.assertGreaterEqual(by_id["product"][3], 61) + + def test_mask_expansion_does_not_cover_adjacent_text_region(self): + from src.generative_editable_vlm_reconstruction import ( + VLMCoordinateMapper, + build_text_bitmap_mask, + coerce_vlm_analysis, + ) + + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [{"id": "label", "text": "文字", "bbox": [25, 25, 37, 45]}], + "bitmap_regions": [ + {"id": "product", "type": "product", "bbox": [40, 20, 80, 50]}, + ], + "shape_regions": [], + } + ) + mapper = VLMCoordinateMapper( + analysis_space=analysis.coordinate_space, + source_image_size=(160, 90), + ) + + _mask, mask_boxes = build_text_bitmap_mask(analysis, mapper, padding=4) + + by_id = {box.region_id: box.bbox for box in mask_boxes} + self.assertGreaterEqual(by_id["product"][0], 38) + def test_builds_manifest_with_editable_text_split_assets_and_conservative_shapes(self): from src.generative_editable_manifest import PageManifest from src.generative_editable_vlm_reconstruction import ( @@ -940,6 +999,49 @@ def test_selects_source_preserved_crop_when_generated_asset_drifts_from_source(s self.assertEqual(selected[0].source_pixel_bbox, (22, 12, 78, 58)) self.assertEqual(selected_pixel[:3], (34, 170, 255)) + def test_complex_source_preserved_assets_use_opaque_crop_not_background_diff_alpha(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_vlm_reconstruction import ( + _source_preserved_bitmap_assets_from_candidates, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + source_image = Image.new("RGB", (120, 80), "#001122") + draw = ImageDraw.Draw(source_image) + draw.rectangle((30, 18, 105, 55), fill="#0B2A66") + draw.ellipse((36, 46, 52, 62), fill="#000000") + source_image.save(source) + Image.new("RGB", (120, 80), "#001122").save(background) + candidate = ForegroundCandidate( + candidate_id="vehicle", + source_pixel_bbox=(30, 18, 105, 62), + area=3300, + classification="complex_whole_visual", + provenance={"vlm_type": "photo"}, + ) + + assets = _source_preserved_bitmap_assets_from_candidates( + candidates=[candidate], + source_image_path=source, + clean_background_path=background, + artifact_root=root, + slide_id="slide-a", + page_index=0, + reason="complex_bitmap_region", + asset_sheet_skipped_reason="complex_bitmap_region", + ) + crop = Image.open(root / assets[0].asset_path).convert("RGBA") + + self.assertEqual(crop.getpixel((0, 0))[3], 255) + self.assertEqual(crop.getpixel((crop.width - 1, crop.height - 1))[3], 255) + self.assertEqual(assets[0].provenance["alpha_strategy"], "opaque_source_crop") + self.assertNotIn("background_difference_alpha", assets[0].provenance) + def test_foreground_crop_preserves_edge_foreground_close_to_background(self): from src.generative_editable_vlm_reconstruction import _foreground_rgba_crop @@ -2417,7 +2519,7 @@ def edit(self, request): self.assertIn("Preserve every unmasked pixel", image_edit_provider.calls[0].prompt) self.assertIn("asset_sheet", [request.prompt_id for request in image_edit_provider.calls]) asset_sheet_request = next(request for request in image_edit_provider.calls if request.prompt_id == "asset_sheet") - self.assertEqual(asset_sheet_request.timeout_seconds, 45) + self.assertEqual(asset_sheet_request.timeout_seconds, 120) self.assertEqual(mask.getpixel((75, 40)), 255) self.assertIn("vlm", page.provider_output_paths) self.assertIn("asset_sheet", page.provider_output_paths) @@ -2819,7 +2921,8 @@ def edit(self, request): self.assertEqual(len(page.bitmap_assets), 1) self.assertEqual(page.bitmap_assets[0].provenance["asset_strategy"], "source_preserved_crop") self.assertEqual(page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region") - self.assertTrue(page.bitmap_assets[0].provenance["background_difference_alpha"]) + self.assertEqual(page.bitmap_assets[0].provenance["alpha_strategy"], "opaque_source_crop") + self.assertNotIn("background_difference_alpha", page.bitmap_assets[0].provenance) self.assertIn("alpha_visible_area_ratio", page.bitmap_assets[0].provenance) def test_vlm_pipeline_asset_sheet_failure_does_not_duplicate_complex_source_crops(self): @@ -3098,6 +3201,113 @@ def extract_text(self, image_path): self.assertIn("ocr", page.provider_output_paths) self.assertEqual(mask.getpixel((25, 25)), 255) + def test_vlm_pipeline_clears_ocr_only_text_from_complex_source_preserved_crop(self): + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_preview_validator import ValidationReport + from src.generative_editable_providers import OCRProvider + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class ProductOnlyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path, *, timeout_seconds=180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + { + "id": "product", + "type": "product", + "bbox": [20, 20, 110, 70], + "importance": "major", + } + ], + "shape_regions": [], + } + ) + + class OverlappingOCRProvider(OCRProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="ocr_model", + provider="fake_ocr", + model="fake-ocr", + base_url="fake", + api_key="fake", + ) + ) + + def extract_text(self, image_path): + return OCRResult( + source_image_path=image_path, + image_size=(160, 90), + provider_role=self.config.role, + provider_name="fake_ocr", + model="fake-ocr", + items=[ + OCRTextItem( + text="OCR", + bbox=(34, 30, 58, 42), + polygon=((34, 30), (58, 30), (58, 42), (34, 42)), + confidence=0.99, + color_hex="#FFFFFF", + font_size_hint=12, + provenance={"item_id": "ocr-overlap"}, + ) + ], + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 110, 70), fill="#0B2A66") + draw.rectangle((34, 30, 58, 42), fill="#FFFFFF") + image.save(source) + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-ocr-crop", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=ProductOnlyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="fake", + api_key="fake", + ) + ), + ocr_provider=OverlappingOCRProvider(), + structure_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_validator=lambda **kwargs: ValidationReport( + status="passed", checked_pages=1, issues=[] + ), + ), + ) + job_dir = root / "artifacts" / "job-ocr-crop" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + asset = page.bitmap_assets[0] + crop = Image.open(job_dir / asset.asset_path).convert("RGBA") + crop_x = 36 - asset.source_pixel_bbox[0] + crop_y = 32 - asset.source_pixel_bbox[1] + + self.assertEqual(asset.provenance["alpha_strategy"], "opaque_source_crop") + self.assertEqual(crop.getpixel((crop_x, crop_y))[3], 0) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index 97b5832..bcfdaef 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -433,6 +433,85 @@ def fake_vlm_pipeline(**kwargs): self.assertEqual(captured["provider_retry_backoff_seconds"], 12.5) self.assertEqual(captured["page_timeout_seconds"], 240) + def test_vlm_first_run_default_provider_timeout_uses_config(self): + from dataclasses import replace + + import scripts.run_real_generative_editable_pptx as runner + from src.generative_editable_config import TimeoutConfig + + captured = {} + dependency_sentinel = object() + config = replace( + load_generative_editable_config(use_fake=True), + timeouts=TimeoutConfig(provider_call=123, page=456), + ) + + def fake_vlm_dependencies(**kwargs): + captured.update(kwargs) + return dependency_sentinel + + def fake_vlm_pipeline(**kwargs): + from pptx import Presentation + + from src.generative_editable_manifest import DeckManifest, write_manifest + + output_path = Path(kwargs["output_path"]) + output_path.parent.mkdir(parents=True, exist_ok=True) + Presentation().save(output_path) + job_dir = Path(kwargs["artifact_root"]) / kwargs["job_id"] + job_dir.mkdir(parents=True, exist_ok=True) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id=kwargs["job_id"], + slide_order=[], + aspect_ratio="16:9", + provider_roles={"vlm": "VLM", "image_edit": "edit_model"}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=[], + validation_status="passed", + ), + ) + return type( + "Result", + (), + { + "status": "passed", + "output_path": str(output_path), + "fallback_used": "", + }, + )() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch.object(runner, "load_generative_editable_config", return_value=config), + patch.object(runner, "_vlm_dependencies", side_effect=fake_vlm_dependencies), + patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object(runner, "_write_preview_reports", return_value=[]), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--mode", + "vlm_first", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "vlm-config-timeout", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(captured["provider_timeout"], 123) + def test_vlm_first_run_marks_preview_failure_as_failed(self): import scripts.run_real_generative_editable_pptx as runner From 9d1f321151da8b5e861eeb26c647293a93c9d384 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 08:54:11 +0800 Subject: [PATCH 06/16] Improve VLM editable PPTX reconstruction fidelity --- src/generative_editable_vlm_reconstruction.py | 117 ++++++- ..._generative_editable_vlm_reconstruction.py | 297 +++++++++++++++++- 2 files changed, 394 insertions(+), 20 deletions(-) diff --git a/src/generative_editable_vlm_reconstruction.py b/src/generative_editable_vlm_reconstruction.py index 3598f46..61bd41b 100644 --- a/src/generative_editable_vlm_reconstruction.py +++ b/src/generative_editable_vlm_reconstruction.py @@ -18,6 +18,7 @@ from pathlib import Path import re import time +import unicodedata from typing import Any from PIL import Image, ImageChops, ImageDraw, ImageStat @@ -1617,6 +1618,8 @@ def _should_keep_high_confidence_vlm_text_over_ocr( ocr_box: TextBoxSpec, vlm_box: TextBoxSpec, ) -> bool: + if _should_keep_medium_confidence_vlm_text_over_fragmentary_ocr(region, ocr_box, vlm_box): + return True if region.confidence < 0.90: return False if float(ocr_box.provenance.get("ocr_confidence", 0.0) or 0.0) >= 0.90: @@ -1626,6 +1629,29 @@ def _should_keep_high_confidence_vlm_text_over_ocr( return bool(_normalize_text_for_match(vlm_box.text)) +def _should_keep_medium_confidence_vlm_text_over_fragmentary_ocr( + region: VLMTextRegion, + ocr_box: TextBoxSpec, + vlm_box: TextBoxSpec, +) -> bool: + if region.confidence < 0.80: + return False + if float(ocr_box.provenance.get("ocr_confidence", 0.0) or 0.0) >= 0.90: + return False + normalized_vlm = _normalize_text_for_match(vlm_box.text) + normalized_ocr = _normalize_text_for_match(ocr_box.text) + if len(normalized_vlm) < 6 or not normalized_ocr: + return False + if normalized_vlm == normalized_ocr: + return False + coverage = len(normalized_ocr) / max(1, len(normalized_vlm)) + if normalized_ocr in normalized_vlm: + return coverage < 0.92 + if coverage <= 0.55: + return True + return _text_similarity(vlm_box.text, ocr_box.text) < 0.55 + + def _merged_ocr_text_box_for_guard(ocr_boxes: list[TextBoxSpec]) -> TextBoxSpec: if not ocr_boxes: raise ValueError("ocr_boxes are required") @@ -1906,8 +1932,7 @@ def _create_vlm_asset_sheet_assets( artifact_root=artifacts.job_dir, slide_id=slide_id, page_index=page_index, - reason="complex_bitmap_region", - asset_sheet_skipped_reason="complex_bitmap_region", + reason="source_preserved_bitmap_region", transparent_bboxes=text_bboxes, ) if not asset_sheet_candidates: @@ -2033,7 +2058,20 @@ def _create_vlm_asset_sheet_assets( def _candidate_should_use_asset_sheet(candidate: ForegroundCandidate) -> bool: - return candidate.classification == "bitmap_asset_candidate" + return candidate.classification == "bitmap_asset_candidate" and not _candidate_is_vlm_icon(candidate) + + +def _candidate_is_vlm_icon(candidate: ForegroundCandidate) -> bool: + provenance = candidate.provenance or {} + return str(provenance.get("vlm_type") or "").lower() in {"icon", "ui_icon", "small_icon"} + + +def _source_preserved_skip_reason_for_candidate(candidate: ForegroundCandidate) -> str: + if _candidate_is_vlm_icon(candidate): + return "icon_source_preserved" + if candidate.classification == "complex_whole_visual": + return "complex_bitmap_region" + return "" def _with_candidate_z_order( @@ -2092,6 +2130,9 @@ def _source_preserved_bitmap_assets_from_candidates( source_rgb = source_image.convert("RGB") background_rgb = background_image.convert("RGB").resize(source_rgb.size) for index, candidate in enumerate(candidates, start=1): + candidate_skipped_reason = asset_sheet_skipped_reason + if not candidate_skipped_reason and not asset_sheet_provider_failed and not asset_sheet_slicing_failed: + candidate_skipped_reason = _source_preserved_skip_reason_for_candidate(candidate) bbox = _clamp_bbox( _pad_bbox(candidate.source_pixel_bbox, fallback_padding), source_rgb.size, @@ -2141,7 +2182,7 @@ def _source_preserved_bitmap_assets_from_candidates( }, provider_failed=asset_sheet_provider_failed, slicing_failed=asset_sheet_slicing_failed, - skipped_reason=asset_sheet_skipped_reason, + skipped_reason=candidate_skipped_reason, reason=reason, ), ) @@ -2568,7 +2609,12 @@ def _text_box_from_vlm( source_pixel_bbox=bbox, source_pixel_polygon=_bbox_polygon(bbox), font_family="Microsoft YaHei", - font_size=_estimate_font_size(bbox, mapper.source_image_size, role=region.role), + font_size=_estimate_font_size( + bbox, + mapper.source_image_size, + role=region.role, + text=region.text, + ), color_hex=region.color_hex or _infer_text_color(source_rgb, bbox), alignment="left", style_hints={ @@ -2712,7 +2758,7 @@ def _vlm_compact_analysis_prompt(size: tuple[int, int]) -> str: "输出 schema: {" "\"coordinate_space\":{\"width\":%d,\"height\":%d,\"unit\":\"px\"}," "\"text_regions\":[{\"id\":\"t1\",\"text\":\"原文\",\"bbox\":[0,0,1,1],\"role\":\"title|heading|body|label|button\",\"color\":\"#RRGGBB\",\"confidence\":0.9,\"group_id\":\"g1\"}]," - "\"bitmap_regions\":[{\"id\":\"b1\",\"type\":\"photo|icon|qr|product|component|other\",\"bbox\":[0,0,1,1],\"importance\":\"major|minor\",\"group_id\":\"g1\"}]," + "\"bitmap_regions\":[{\"id\":\"b1\",\"type\":\"photo|icon|logo|qr|product|component|other\",\"bbox\":[0,0,1,1],\"importance\":\"major|minor\",\"group_id\":\"g1\"}]," "\"shape_regions\":[{\"id\":\"s1\",\"type\":\"rounded_rect|rect|line|divider|connector|arrow|circle|other\",\"bbox\":[0,0,1,1],\"importance\":\"major|minor\",\"group_id\":\"g1\"}]" "}。\n" "规则:文字逐项列出,中文照抄,不合并跨区域文字;大背景不要放入 bitmap_regions;" @@ -2852,8 +2898,9 @@ def _estimate_font_size( source_image_size: tuple[int, int], *, role: str, + text: str = "", ) -> float: - _left, top, _right, bottom = bbox + left, top, right, bottom = bbox height_px = max(1, bottom - top) slide_height_inches = 5.625 # VLM bboxes describe the visible glyph envelope, while PPT renderers need @@ -2861,9 +2908,59 @@ def _estimate_font_size( # drifting from source renders because text clips or expands vertically. if role in {"title", "heading"}: font_size = height_px / source_image_size[1] * slide_height_inches * 72 * 0.9 - return max(12.0, min(font_size, 44.0)) - font_size = height_px / source_image_size[1] * slide_height_inches * 72 * 0.65 - return max(7.0, min(font_size, 22.0)) + font_size = min(font_size, 44.0) + min_font_size = 12.0 + else: + font_size = height_px / source_image_size[1] * slide_height_inches * 72 * 0.65 + font_size = min(font_size, 22.0) + min_font_size = 7.0 + width_capped = _cap_font_size_by_text_width( + font_size, + bbox_width_px=max(1, right - left), + source_width_px=max(1, source_image_size[0]), + role=role, + text=text, + ) + return max(min_font_size, width_capped) + + +def _cap_font_size_by_text_width( + font_size: float, + *, + bbox_width_px: int, + source_width_px: int, + role: str, + text: str, +) -> float: + line_units = [ + _estimate_text_line_units(line) + for line in str(text).splitlines() + if line.strip() + ] + if not line_units: + return font_size + longest_line_units = max(line_units) + if longest_line_units <= 0: + return font_size + slide_width_points = 10.0 * 72.0 + bbox_width_points = bbox_width_px / source_width_px * slide_width_points + width_factor = 0.9 if role in {"title", "heading"} else 1.05 + width_based_font_size = bbox_width_points / (longest_line_units * width_factor) + return min(font_size, width_based_font_size) + + +def _estimate_text_line_units(line: str) -> float: + units = 0.0 + for char in line: + if char.isspace(): + units += 0.35 + elif char.isascii(): + units += 0.55 if char.isalnum() else 0.35 + elif unicodedata.category(char).startswith("P"): + units += 0.5 + else: + units += 1.0 + return units def _infer_text_color(image: Image.Image, bbox: PixelBBox) -> str: diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index c25cdab..0e2476a 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -1625,6 +1625,94 @@ def test_vlm_title_font_size_preserves_large_visual_hierarchy(self): self.assertGreaterEqual(page.text_boxes[0].font_size or 0, 32.0) self.assertTrue(page.text_boxes[0].style_hints["bold"]) + def test_vlm_heading_font_size_is_capped_by_text_width_density(self): + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1672, 941), "#001122").save(source) + Image.new("RGB", (1672, 941), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1672, "height": 941, "unit": "px"}, + "text_regions": [ + { + "id": "narrow-heading", + "text": "传统电动车\n长途焦虑", + "bbox": [104, 196, 224, 266], + "role": "heading", + "confidence": 0.88, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + ) + + self.assertLessEqual(page.text_boxes[0].font_size or 0, 16.0) + self.assertTrue(page.text_boxes[0].style_hints["bold"]) + + def test_vlm_body_font_size_is_capped_by_text_width_density(self): + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1672, 941), "#001122").save(source) + Image.new("RGB", (1672, 941), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1672, "height": 941, "unit": "px"}, + "text_regions": [ + { + "id": "solution-body", + "text": "四零重力座椅,\n全家舒适出行", + "bbox": [409, 579, 594, 640], + "role": "body", + "confidence": 0.9, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + ) + + self.assertLessEqual(page.text_boxes[0].font_size or 0, 13.0) + self.assertFalse(page.text_boxes[0].style_hints["bold"]) + def test_vlm_page_manifest_marks_clean_background_as_text_clean(self): from src.generative_editable_vlm_reconstruction import ( build_page_manifest_from_vlm_analysis, @@ -1724,6 +1812,112 @@ def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_mismatched_ocr( self.assertEqual([box.text for box in page.text_boxes], ["保持视野清晰"]) self.assertEqual(page.text_boxes[0].provenance["provider_role"], "VLM") + def test_vlm_text_resolution_keeps_medium_confidence_vlm_text_over_short_ocr_fragment(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (160, 90), "#001122").save(source) + Image.new("RGB", (160, 90), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [ + { + "id": "range-anxiety", + "text": "续航短,\n频繁充电", + "bbox": [40, 24, 82, 48], + "role": "body", + "confidence": 0.86, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + fragment_ocr = TextBoxSpec( + text="充电慢,", + source_pixel_bbox=(50, 30, 70, 38), + source_pixel_polygon=((50, 30), (70, 30), (70, 38), (50, 38)), + font_size=8, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[fragment_ocr], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["续航短,\n频繁充电"]) + self.assertEqual(page.text_boxes[0].provenance["provider_role"], "VLM") + + def test_vlm_text_resolution_keeps_medium_confidence_vlm_text_over_substring_ocr_fragment(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (320, 180), "#001122").save(source) + Image.new("RGB", (320, 180), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 320, "height": 180, "unit": "px"}, + "text_regions": [ + { + "id": "range-solution", + "text": "纯电续航里程长", + "bbox": [40, 40, 170, 70], + "role": "body", + "confidence": 0.86, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + substring_ocr = TextBoxSpec( + text="续航里程长", + source_pixel_bbox=(58, 42, 170, 68), + source_pixel_polygon=((58, 42), (170, 42), (170, 68), (58, 68)), + font_size=12, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[substring_ocr], + ) + + self.assertEqual([box.text for box in page.text_boxes], ["纯电续航里程长"]) + self.assertEqual(page.text_boxes[0].provenance["provider_role"], "VLM") + def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_partial_ocr_substring(self): from src.generative_editable_manifest import TextBoxSpec from src.generative_editable_vlm_reconstruction import ( @@ -2430,7 +2624,7 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): } ], "bitmap_regions": [ - {"id": "icon", "type": "icon", "bbox": [120, 20, 150, 50]}, + {"id": "logo", "type": "logo", "bbox": [120, 20, 150, 50]}, ], "shape_regions": [ {"id": "divider", "type": "divider", "bbox": [20, 60, 150, 61]}, @@ -2524,7 +2718,7 @@ def edit(self, request): self.assertIn("vlm", page.provider_output_paths) self.assertIn("asset_sheet", page.provider_output_paths) self.assertEqual([sheet.sheet_id for sheet in page.asset_sheets], ["vlm-asset-sheet-0000"]) - self.assertEqual(page.asset_sheets[0].candidate_ids, ["icon"]) + self.assertEqual(page.asset_sheets[0].candidate_ids, ["logo"]) self.assertEqual(page.bitmap_assets[0].provenance["split_method"], "connected_components") self.assertEqual(page.provenance["reconstruction_strategy"], "vlm_first") self.assertEqual(page.provenance["text_mask_path"], "assets/0000-slide-a/vlm-text-bitmap-mask.png") @@ -2552,7 +2746,7 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, "text_regions": [], "bitmap_regions": [ - {"id": "icon", "type": "icon", "bbox": [40, 20, 90, 60]}, + {"id": "logo", "type": "logo", "bbox": [40, 20, 90, 60]}, ], "shape_regions": [ {"id": "line-a", "type": "line", "bbox": [20, 60, 140, 61]} @@ -2942,7 +3136,7 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): "text_regions": [], "bitmap_regions": [ {"id": "product", "type": "product", "bbox": [20, 20, 70, 60]}, - {"id": "icon", "type": "icon", "bbox": [100, 20, 130, 50]}, + {"id": "logo", "type": "logo", "bbox": [100, 20, 130, 50]}, ], "shape_regions": [], } @@ -3000,7 +3194,7 @@ def edit(self, request): deck = read_deck_manifest(job_dir / "deck.json") page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) - self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["product", "icon"]) + self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["product", "logo"]) self.assertEqual(page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region") self.assertTrue(page.bitmap_assets[1].provenance["asset_sheet_provider_failed"]) @@ -3013,15 +3207,15 @@ def test_vlm_pipeline_falls_back_when_asset_sheet_component_count_is_short(self) run_vlm_editable_pptx_pipeline, ) - class TwoIconVLMProvider(VLMPageAnalysisProvider): + class TwoLogoVLMProvider(VLMPageAnalysisProvider): def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): return coerce_vlm_analysis( { "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, "text_regions": [], "bitmap_regions": [ - {"id": "icon-a", "type": "icon", "bbox": [30, 20, 55, 45]}, - {"id": "icon-b", "type": "icon", "bbox": [95, 20, 125, 50]}, + {"id": "logo-a", "type": "logo", "bbox": [30, 20, 55, 45]}, + {"id": "logo-b", "type": "logo", "bbox": [95, 20, 125, 50]}, ], "shape_regions": [], } @@ -3076,7 +3270,7 @@ def edit(self, request): artifact_root=str(root / "artifacts"), job_id="job-short-sheet", dependencies=VLMEditablePipelineDependencies( - vlm_provider=TwoIconVLMProvider(), + vlm_provider=TwoLogoVLMProvider(), image_edit_provider=FakeImageEditProvider( ProviderConfig( role="edit_model", @@ -3094,9 +3288,92 @@ def edit(self, request): page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) self.assertEqual(page.asset_sheets, []) - self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["icon-a", "icon-b"]) + self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["logo-a", "logo-b"]) self.assertTrue(all(asset.provenance["asset_sheet_slicing_failed"] for asset in page.bitmap_assets)) + def test_vlm_pipeline_skips_asset_sheet_for_icon_bitmap_regions(self): + from src.generative_editable_manifest import read_deck_manifest, read_page_manifest + from src.generative_editable_vlm_reconstruction import ( + VLMEditablePipelineDependencies, + VLMPageAnalysisProvider, + coerce_vlm_analysis, + run_vlm_editable_pptx_pipeline, + ) + + class IconOnlyVLMProvider(VLMPageAnalysisProvider): + def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): + return coerce_vlm_analysis( + { + "coordinate_space": {"width": 160, "height": 90, "unit": "px"}, + "text_regions": [], + "bitmap_regions": [ + {"id": "icon-a", "type": "icon", "bbox": [30, 20, 55, 45]}, + {"id": "icon-b", "type": "icon", "bbox": [95, 20, 125, 50]}, + ], + "shape_regions": [], + } + ) + + class RecordingAssetSheetProvider(ImageEditProvider): + def __init__(self): + super().__init__( + ProviderConfig( + role="asset_sheet_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ) + self.calls = [] + + def edit(self, request): + self.calls.append(request) + raise AssertionError("icon bitmap regions should not request asset sheet") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "input.png" + image = Image.new("RGB", (160, 90), "#001122") + draw = ImageDraw.Draw(image) + draw.rectangle((30, 20, 55, 45), fill="#FFAA00") + draw.rectangle((95, 20, 125, 50), fill="#22AAFF") + image.save(source) + asset_sheet_provider = RecordingAssetSheetProvider() + + run_vlm_editable_pptx_pipeline( + slides=[{"slide_id": "slide-a", "image_path": str(source)}], + output_path=str(root / "out.pptx"), + artifact_root=str(root / "artifacts"), + job_id="job-icon-source-preserved", + dependencies=VLMEditablePipelineDependencies( + vlm_provider=IconOnlyVLMProvider(), + image_edit_provider=FakeImageEditProvider( + ProviderConfig( + role="edit_model", + provider="fake_image_edit", + model="fake-image-edit", + base_url="", + api_key="", + ) + ), + asset_sheet_image_edit_provider=asset_sheet_provider, + ), + ) + job_dir = root / "artifacts" / "job-icon-source-preserved" + deck = read_deck_manifest(job_dir / "deck.json") + page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + + self.assertEqual(asset_sheet_provider.calls, []) + self.assertEqual(page.asset_sheets, []) + self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["icon-a", "icon-b"]) + self.assertTrue( + all( + asset.provenance.get("asset_sheet_skipped_reason") == "icon_source_preserved" + for asset in page.bitmap_assets + ) + ) + def test_vlm_pipeline_uses_ocr_for_mask_without_ungated_editable_text(self): from src.generative_editable_manifest import read_deck_manifest, read_page_manifest from src.generative_editable_preview_validator import ValidationReport From 092c408ef5b28c9b1843bc4f32ed96a15e1a8642 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 12:52:28 +0800 Subject: [PATCH 07/16] Stabilize VLM editable PPTX real reconstruction --- .../design.md | 22 +- .../proposal.md | 3 +- .../generative-editable-pptx-export/spec.md | 12 +- scripts/run_real_generative_editable_pptx.py | 3 +- src/generative_editable_backgrounds.py | 31 +- src/generative_editable_pipeline.py | 3 +- src/generative_editable_preview_validator.py | 168 ++++++++++- src/generative_editable_vlm_reconstruction.py | 85 +++++- tests/test_generative_editable_backgrounds.py | 23 ++ ...t_generative_editable_preview_validator.py | 275 ++++++++++++++++++ ..._generative_editable_vlm_reconstruction.py | 155 +++++++++- 11 files changed, 754 insertions(+), 26 deletions(-) diff --git a/openspec/changes/add-generative-editable-pptx-export/design.md b/openspec/changes/add-generative-editable-pptx-export/design.md index 0ad4e86..984e4ba 100644 --- a/openspec/changes/add-generative-editable-pptx-export/design.md +++ b/openspec/changes/add-generative-editable-pptx-export/design.md @@ -17,6 +17,7 @@ Development must follow test-driven development. Each task starts by adding or u **Goals:** - Add a distinct `generative_editable_pptx` export mode without changing existing PDF, raster `pptx`, or the separate image-layer OpenSpec change. +- Require VLM-first page understanding for the current implementation path; do not silently bypass VLM with OCR-only or local-CV-only reconstruction. - Prefer output quality over model cost by default: validate previews, repair bounded failures, and fail rather than silently returning low-quality editable output. - Use AIPPT text metadata as semantic truth when available and OCR as the source for layout, style, and fallback text. - Create both text-clean and base-clean background assets so the system has a high-fidelity text-editable fallback and a full reconstruction base. @@ -61,6 +62,7 @@ Alternatives considered: `config.example.yaml` documents placeholder provider roles: +- `api.models.VLM` - `api.models.ocr_model` - `api.models.image_model` - `api.models.edit_model` @@ -98,9 +100,9 @@ Alternatives considered: - Generate only a base-clean background. Rejected because a failed asset reconstruction would leave no good fallback. - Generate only a text-clean background. Rejected because full foreground editability requires a clean base underneath rebuilt assets. -### Decision 6: Foreground planning uses generated background differences plus visual analysis +### Decision 6: Foreground planning uses VLM page analysis plus generated background differences -Without an image-layer model, the system detects candidate foreground elements by comparing source images to base-clean backgrounds, excluding accepted text masks, and grouping connected/nearby regions. A visual planner can classify candidates into native-shape candidates, bitmap asset candidates, complex whole visuals, duplicates, rejected text-like regions, or uncertain regions. +Without an image-layer model, the current implementation requires a VLM page analysis to identify text regions, bitmap regions, and simple shape regions. The system combines those regions with generated clean backgrounds, OCR masks, source-pixel geometry, and deterministic filtering to classify candidates into native-shape candidates, bitmap asset candidates, complex whole visuals, duplicates, rejected text-like regions, or uncertain regions. This is not true layer decomposition; it is a reconstruction plan that drives asset generation and validation. @@ -147,6 +149,9 @@ Slide images + optional AIPPT text metadata Create job directory and deck/page manifests | v +VLM page analysis + | + v OCR + text matching | v @@ -187,6 +192,7 @@ Text masks The implementation should define provider interfaces before concrete clients: - `OCRProvider`: returns text content, boxes/polygons, confidence, font-size hints, color, alignment, and style hints. +- `VLMPageAnalysisProvider`: returns structured page regions for text, bitmap assets, and simple shapes in a known pixel coordinate space. - `ImageEditProvider`: edits a source image with a prompt and optional mask to produce text-clean backgrounds, base-clean backgrounds, asset sheets, or repairs. - `ImageGenerationProvider`: generates or regenerates assets when edit-based extraction fails or a visual asset needs clean re-creation. - `VisualPlanner`: uses source images, generated backgrounds, OCR masks, and optional model analysis to produce foreground candidates and classifications. @@ -227,8 +233,10 @@ All implementation tasks follow TDD: ## Live Verification Status - Local verification targets are configured through project `config.yaml`; automated tests continue to use fake providers. -- The initial strict live gate is one-slide first, followed by a small multi-slide deck only after the one-slide run passes. -- The current configured OCR provider did not return schema-valid OCR JSON during strict one-slide smoke verification. -- The current image generation provider returned an upstream `524` during direct provider smoke verification. -- A diagnostic one-slide run with local OCR advanced into asset reconstruction, but the generated asset sheet failed strict asset QA with `edge_touch` and visible source drift. -- The initial preview similarity threshold remains configurable through `generative_editable_pptx.quality.preview_similarity_threshold` and is recorded in the deck manifest for each run. +- The current live path is VLM-first. If the VLM provider is unavailable or repeatedly fails, the generative editable export fails rather than switching to an OCR-only reconstruction path. +- A six-slide replay-assets run completed with live VLM, OCR, image edit, and image generation/edit configuration. The original strict run produced a PPTX and artifacts, but failed the old preview gate on page 5 because the raw changed-pixel ratio slightly exceeded the threshold on a text-dense page. +- The existing six-slide artifact was then revalidated without additional provider calls using the current text-overlap-aware preview gate. This revalidation passed all 6 pages; it is not a separate fresh provider run. +- Revalidated report: `/private/tmp/aippt-vlm-6page-full-editquota-20260708/report-revalidated-strict.json`. +- Revalidated object totals: `TEXT_BOX=120`, `PICTURE=54`, `LINE=30`, `AUTO_SHAPE=18`, `BACKGROUND_PICTURE=6`; each page had `full_slide_picture_count=0`. +- Revalidated preview status: all 6 pages passed with the current text-overlap-aware preview gate and `reconstruction_issues=[]`. +- Known limitation: text-dense pages can exceed the raw changed-pixel ratio because editable PPT text renders differently from baked raster text. The preview validator only grants limited headroom when mean delta remains within threshold and changed pixels substantially overlap text regions. diff --git a/openspec/changes/add-generative-editable-pptx-export/proposal.md b/openspec/changes/add-generative-editable-pptx-export/proposal.md index 113475b..7c05e3f 100644 --- a/openspec/changes/add-generative-editable-pptx-export/proposal.md +++ b/openspec/changes/add-generative-editable-pptx-export/proposal.md @@ -2,12 +2,13 @@ AIPPT currently exports PPTX files as full-slide raster images, which preserves appearance but leaves text and visual elements mostly uneditable. Since image-layer decomposition resources are not currently available, this change proposes a separate effect-first editable export path that relies on OCR, image editing, image generation, manifest composition, and validation. -This change intentionally does not modify the existing `add-editable-pptx-export` OpenSpec change; that change remains available as the future image-layer decomposition approach. +This change intentionally does not modify the existing `add-editable-pptx-export` OpenSpec change; that change remains available as the future image-layer decomposition approach. The current implementation path is VLM-first: a multimodal understanding model supplies page structure, OCR supplies text/layout evidence, and image edit/generation providers reconstruct clean backgrounds and bitmap assets. ## What Changes - Add a new explicit generative editable PPTX export mode beside the existing raster `pptx` export. - Introduce a manifest-driven reconstruction pipeline that uses: + - VLM page analysis as the required page-structure source for text regions, bitmap regions, and simple shape regions. - AIPPT slide/text metadata when available, with OCR as the layout/style and fallback text source. - Image editing to create text-clean and base-clean background assets. - Image editing or image generation to create source-faithful foreground asset sheets and local repairs. diff --git a/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md b/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md index 603f5c4..867ddbc 100644 --- a/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md +++ b/openspec/changes/add-generative-editable-pptx-export/specs/generative-editable-pptx-export/spec.md @@ -9,7 +9,7 @@ The system SHALL provide a distinct generative editable PPTX export mode without #### Scenario: Generative editable PPTX export is requested - **WHEN** a user exports with format `generative_editable_pptx` -- **THEN** the system SHALL run the OCR, image editing, image generation, manifest composition, preview, validation, and repair pipeline +- **THEN** the system SHALL run the VLM-first OCR, image editing, image generation, manifest composition, preview, validation, and repair pipeline #### Scenario: Image-layer editable export is not modified - **WHEN** the separate image-layer editable export OpenSpec change exists in the repository @@ -24,7 +24,7 @@ The system SHALL load generative editable export provider settings from the proj #### Scenario: Example configuration is updated - **WHEN** the repository is inspected after this capability is implemented -- **THEN** `config.example.yaml` SHALL document placeholder OCR, image edit, image generation, reconstruction, quality, timeout, and retry settings for generative editable PPTX export +- **THEN** `config.example.yaml` SHALL document placeholder VLM, OCR, image edit, image generation, reconstruction, quality, timeout, and retry settings for generative editable PPTX export #### Scenario: Local configuration provides real providers - **WHEN** a developer runs live provider verification @@ -38,6 +38,10 @@ The system SHALL load generative editable export provider settings from the proj - **WHEN** a live generative editable export is requested without required local provider settings - **THEN** the system SHALL fail with a clear configuration error that names the missing provider role without exposing secrets +#### Scenario: VLM-first reconstruction is required +- **WHEN** a live generative editable export is requested +- **THEN** the system SHALL require a configured VLM provider for page-structure analysis and SHALL NOT silently downgrade to OCR-only or local-CV-only reconstruction + ### Requirement: Generative editable deck manifest The system SHALL create a deck manifest and one page manifest per slide for every generative editable PPTX export run. @@ -95,8 +99,8 @@ The system SHALL create separate background candidates for text-only cleanup and - **WHEN** a background candidate is generated, preserved, repaired, or rejected - **THEN** the page manifest SHALL record the strategy, provider role, prompts or prompt identifiers, input asset references, output asset reference, and validation result -### Requirement: Foreground planning from generated backgrounds -The system SHALL plan non-text foreground reconstruction using OCR masks, generated backgrounds, image differences, and visual analysis. +### Requirement: Foreground planning from VLM analysis and generated backgrounds +The system SHALL plan non-text foreground reconstruction using VLM page analysis, OCR masks, generated backgrounds, image differences, and deterministic visual filtering. #### Scenario: Foreground candidates are detected - **WHEN** source and base-clean images are available diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py index a736969..4b00ad6 100644 --- a/scripts/run_real_generative_editable_pptx.py +++ b/scripts/run_real_generative_editable_pptx.py @@ -774,7 +774,7 @@ def _run_subprocess_json( if not report_path.exists(): _write_json(report_path, result) return result - except subprocess.TimeoutExpired as exc: + except subprocess.TimeoutExpired: _kill_process_tree(process.pid) _finish_timed_out_process(process) existing_result = _read_existing_report(report_path) @@ -1779,6 +1779,7 @@ def _write_preview_reports( max_mean_abs_delta=gates.max_mean_abs_delta, max_changed_pixel_ratio=gates.max_changed_pixel_ratio, require_powerpoint_render=True, + page_manifest=page, ) reports.append( { diff --git a/src/generative_editable_backgrounds.py b/src/generative_editable_backgrounds.py index ccb7478..b056928 100644 --- a/src/generative_editable_backgrounds.py +++ b/src/generative_editable_backgrounds.py @@ -16,6 +16,7 @@ BackgroundStrategy = Literal["local_fill", "local_inpaint", "image_edit"] MAX_LOCAL_FILL_AREA_RATIO = 0.12 MAX_LOCAL_INPAINT_AREA_RATIO = 0.03 +MAX_SOURCE_PRESERVING_LOCAL_INPAINT_AREA_RATIO = 0.20 MIN_BORDER_SAMPLE_PIXELS = 8 LOCAL_FILL_BORDER_STDDEV_THRESHOLD = 1.5 @@ -192,6 +193,7 @@ def create_source_preserving_text_background( text_bboxes: list[tuple[int, int, int, int]], output_asset_path: str | Path, asset_root: str | Path, + prefer_inpaint: bool = False, ) -> BackgroundResult: source_path = Path(source_image_path) output_path = Path(output_asset_path) @@ -204,19 +206,40 @@ def create_source_preserving_text_background( output_path.parent.mkdir(parents=True, exist_ok=True) with Image.open(source_path) as source: cleaned = source.convert("RGB") - for bbox in text_bboxes: - _fill_bbox_from_border(cleaned, bbox) + if prefer_inpaint and text_bboxes: + mask = Image.new("L", cleaned.size, 0) + draw = ImageDraw.Draw(mask) + for bbox in text_bboxes: + left, top, right, bottom = _clamp_bbox(bbox, cleaned.size) + if right > left and bottom > top: + draw.rectangle((left, top, right, bottom), fill=255) + mask_pixels = _count_mask_pixels(mask) + area_ratio = mask_pixels / float(mask.width * mask.height) + if area_ratio <= MAX_SOURCE_PRESERVING_LOCAL_INPAINT_AREA_RATIO: + cleaned = _local_inpaint(cleaned, mask) + strategy: BackgroundStrategy = "local_inpaint" + decision = "source preserving local inpaint cleanup" + else: + for bbox in text_bboxes: + _fill_bbox_from_border(cleaned, bbox) + strategy = "local_fill" + decision = "source preserving local text cleanup" + else: + for bbox in text_bboxes: + _fill_bbox_from_border(cleaned, bbox) + strategy = "local_fill" + decision = "source preserving local text cleanup" cleaned.save(output_path) return BackgroundResult( output_asset_path=str(output_path), artifact_path=_artifact_ref(output_path, artifact_root), - strategy="local_fill", + strategy=strategy, provider_role="local", prompt_id="source_preserving_text_background", input_asset_refs=[_artifact_ref(source_path, artifact_root)], validation_status="passed", provenance={ - "decision": "source preserving local text cleanup", + "decision": decision, "text_bbox_count": len(text_bboxes), }, ) diff --git a/src/generative_editable_pipeline.py b/src/generative_editable_pipeline.py index 3edaf1b..6ecb1fd 100644 --- a/src/generative_editable_pipeline.py +++ b/src/generative_editable_pipeline.py @@ -669,6 +669,7 @@ def _default_preview_validator(**kwargs) -> ValidationReport: page_index=kwargs["page_index"], max_mean_abs_delta=gates.max_mean_abs_delta, max_changed_pixel_ratio=gates.max_changed_pixel_ratio, + page_manifest=kwargs.get("page_manifest"), ) @@ -1828,6 +1829,7 @@ def _validate_pipeline_output( page_index=page.page_index, preview_similarity_threshold=dependencies.preview_similarity_threshold, require_preview_validation=dependencies.require_preview_validation, + page_manifest=page, ) ) reports.extend(page_reports) @@ -3695,7 +3697,6 @@ def _is_source_diff_native_fill_residual( source_scan_shapes: list[NativeShapeSpec], source_image: Image.Image, ) -> bool: - candidate_id = str(shape.provenance.get("candidate_id", "")) if _is_source_native_shape(shape): return False for container in source_scan_shapes: diff --git a/src/generative_editable_preview_validator.py b/src/generative_editable_preview_validator.py index bc769df..495a6ba 100644 --- a/src/generative_editable_preview_validator.py +++ b/src/generative_editable_preview_validator.py @@ -44,6 +44,13 @@ "cropped source", "source-cropped", } +TEXT_DENSE_CHANGED_RATIO_EXTRA_HEADROOM = 0.03 +TEXT_DENSE_CHANGED_RATIO_RELATIVE_HEADROOM = 0.25 +TEXT_DENSE_MIN_CHANGED_PIXEL_TEXT_OVERLAP_RATIO = 0.65 +TEXT_DENSE_MAX_OUTSIDE_TEXT_CHANGED_PIXEL_RATIO = 0.05 +TEXT_DENSE_TEXT_REGION_PADDING_RATIO = 0.035 +MAX_CLEAN_FALLBACK_ICON_SOURCE_CROP_AREA_RATIO = 0.03 +MAX_CLEAN_FALLBACK_ICON_SOURCE_CROP_DIMENSION_RATIO = 0.22 class PreviewGateThresholds(NamedTuple): @@ -259,6 +266,7 @@ def validate_preview_similarity( max_changed_pixel_ratio: float, require_powerpoint_render: bool = True, changed_pixel_delta_threshold: int = 16, + page_manifest: PageManifest | None = None, ) -> ValidationReport: renderer = str(preview.metadata.get("renderer", "")) if require_powerpoint_render and ( @@ -368,6 +376,19 @@ def validate_preview_similarity( "fallback_candidate": "text_editable_background", } if mean_abs_delta > max_mean_abs_delta or changed_pixel_ratio > max_changed_pixel_ratio: + text_dense_drift = _text_dense_editable_preview_drift_analysis( + page_manifest, + source_rgb=source_rgb, + preview_rgb=preview_rgb, + mean_abs_delta=mean_abs_delta, + changed_pixel_ratio=changed_pixel_ratio, + max_mean_abs_delta=max_mean_abs_delta, + max_changed_pixel_ratio=max_changed_pixel_ratio, + changed_pixel_delta_threshold=changed_pixel_delta_threshold, + ) + details.update(text_dense_drift) + if text_dense_drift.get("text_dense_editable_drift_allowed") is True: + return ValidationReport(status=VALIDATION_PASSED, checked_pages=1, issues=[]) return ValidationReport( status=VALIDATION_FAILED, checked_pages=1, @@ -383,6 +404,116 @@ def validate_preview_similarity( return ValidationReport(status=VALIDATION_PASSED, checked_pages=1, issues=[]) +def _text_dense_editable_preview_drift_analysis( + page_manifest: PageManifest | None, + *, + source_rgb: Image.Image, + preview_rgb: Image.Image, + mean_abs_delta: float, + changed_pixel_ratio: float, + max_mean_abs_delta: float, + max_changed_pixel_ratio: float, + changed_pixel_delta_threshold: int, +) -> dict[str, object]: + rejected = {"text_dense_editable_drift_allowed": False} + if page_manifest is None or mean_abs_delta > max_mean_abs_delta: + return rejected + if changed_pixel_ratio <= max_changed_pixel_ratio: + return {"text_dense_editable_drift_allowed": True} + changed_ratio_headroom = min( + TEXT_DENSE_CHANGED_RATIO_EXTRA_HEADROOM, + max_changed_pixel_ratio * TEXT_DENSE_CHANGED_RATIO_RELATIVE_HEADROOM, + ) + if changed_pixel_ratio > max_changed_pixel_ratio + changed_ratio_headroom: + return {**rejected, "text_dense_changed_ratio_headroom": changed_ratio_headroom} + text_box_count = len(page_manifest.text_boxes) + bitmap_count = len(page_manifest.bitmap_assets) + native_shape_count = len(page_manifest.native_shapes) + editable_object_count = text_box_count + bitmap_count + native_shape_count + if text_box_count < 12 or editable_object_count < 20 or bitmap_count + native_shape_count < 4: + return {**rejected, "text_box_count": text_box_count, "editable_object_count": editable_object_count} + background_ref = str(page_manifest.chosen_background or "") + source_ref = str(page_manifest.source_image_path or "") + if background_ref and source_ref and background_ref == source_ref: + return {**rejected, "text_dense_rejection_reason": "source_background"} + overlap_metrics = _changed_pixel_text_overlap_metrics( + page_manifest, + source_rgb, + preview_rgb, + changed_pixel_delta_threshold=changed_pixel_delta_threshold, + ) + overlap_ratio = float(overlap_metrics["text_changed_pixel_overlap_ratio"]) + outside_text_changed_ratio = float(overlap_metrics["outside_text_changed_pixel_ratio"]) + return { + "text_dense_editable_drift_allowed": overlap_ratio + >= TEXT_DENSE_MIN_CHANGED_PIXEL_TEXT_OVERLAP_RATIO + and outside_text_changed_ratio <= TEXT_DENSE_MAX_OUTSIDE_TEXT_CHANGED_PIXEL_RATIO, + "text_changed_pixel_overlap_ratio": round(overlap_ratio, 4), + "min_text_changed_pixel_overlap_ratio": TEXT_DENSE_MIN_CHANGED_PIXEL_TEXT_OVERLAP_RATIO, + "outside_text_changed_pixel_ratio": round(outside_text_changed_ratio, 4), + "max_outside_text_changed_pixel_ratio": TEXT_DENSE_MAX_OUTSIDE_TEXT_CHANGED_PIXEL_RATIO, + "text_dense_changed_ratio_headroom": changed_ratio_headroom, + } + + +def _changed_pixel_text_overlap_metrics( + page_manifest: PageManifest, + source_rgb: Image.Image, + preview_rgb: Image.Image, + *, + changed_pixel_delta_threshold: int, +) -> dict[str, float]: + changed_mask = _changed_pixel_mask( + source_rgb, + preview_rgb, + changed_pixel_delta_threshold=changed_pixel_delta_threshold, + ) + changed_count = _mask_nonzero_pixel_count(changed_mask) + if changed_count == 0: + return { + "text_changed_pixel_overlap_ratio": 1.0, + "outside_text_changed_pixel_ratio": 0.0, + } + text_mask = Image.new("L", changed_mask.size, 0) + draw = ImageDraw.Draw(text_mask) + source_size = page_manifest.source_image_size + padding = max(8, round(min(changed_mask.size) * TEXT_DENSE_TEXT_REGION_PADDING_RATIO)) + for text_box in page_manifest.text_boxes: + left, top, right, bottom = _scale_bbox(text_box.source_pixel_bbox, source_size, changed_mask.size) + draw.rectangle( + ( + max(0, left - padding), + max(0, top - padding), + min(changed_mask.width, right + padding), + min(changed_mask.height, bottom + padding), + ), + fill=255, + ) + overlap = ImageChops.multiply(changed_mask, text_mask) + overlap_count = _mask_nonzero_pixel_count(overlap) + total_pixels = max(1, changed_mask.width * changed_mask.height) + return { + "text_changed_pixel_overlap_ratio": overlap_count / float(changed_count), + "outside_text_changed_pixel_ratio": (changed_count - overlap_count) / float(total_pixels), + } + + +def _changed_pixel_mask( + source_rgb: Image.Image, + preview_rgb: Image.Image, + *, + changed_pixel_delta_threshold: int, +) -> Image.Image: + return ImageChops.difference(source_rgb, preview_rgb).convert("L").point( + lambda pixel: 255 if pixel > changed_pixel_delta_threshold else 0 + ) + + +def _mask_nonzero_pixel_count(mask: Image.Image) -> int: + histogram = mask.histogram() + return sum(histogram[1:]) + + def quality_threshold_to_preview_gates(similarity_threshold: float) -> PreviewGateThresholds: similarity = min(1.0, max(0.0, float(similarity_threshold))) allowed_difference = 1.0 - similarity @@ -615,7 +746,7 @@ def _validate_bitmap_asset_provenance( asset, issues: list[ValidationIssue], ) -> None: - if _is_allowed_complex_source_preserved_asset(asset): + if _is_allowed_complex_source_preserved_asset(asset) or _is_allowed_clean_fallback_icon_source_preserved_asset(page, asset): return provenance_text = _compact_text(asset.provenance) asset_path_text = _compact_text(asset.asset_path) @@ -659,6 +790,41 @@ def _is_allowed_complex_source_preserved_asset(asset) -> bool: ) +def _is_allowed_clean_fallback_icon_source_preserved_asset(page: PageManifest, asset) -> bool: + provenance = getattr(asset, "provenance", {}) or {} + candidate_provenance = provenance.get("candidate_provenance") + if not isinstance(candidate_provenance, dict): + candidate_provenance = {} + return ( + bool(page.provenance.get("clean_background_provider_failed")) + and provenance.get("asset_strategy") == "source_preserved_crop" + and provenance.get("candidate_classification") == "bitmap_asset_candidate" + and provenance.get("alpha_strategy") == "opaque_source_crop" + and provenance.get("asset_sheet_skipped_reason") == "icon_source_preserved" + and candidate_provenance.get("source") == "vlm_bitmap_region" + and str(candidate_provenance.get("vlm_type") or "").lower() in {"icon", "ui_icon", "small_icon"} + and bool(provenance.get("original_source_pixel_bbox")) + and _is_small_source_crop_asset(page, asset) + and not provenance.get("asset_sheet_provider_failed") + and not provenance.get("asset_sheet_slicing_failed") + ) + + +def _is_small_source_crop_asset(page: PageManifest, asset) -> bool: + left, top, right, bottom = getattr(asset, "source_pixel_bbox", (0, 0, 0, 0)) + width = max(0, right - left) + height = max(0, bottom - top) + source_width, source_height = page.source_image_size + if source_width <= 0 or source_height <= 0: + return False + area_ratio = (width * height) / float(source_width * source_height) + dimension_ratio = max(width / source_width, height / source_height) + return ( + area_ratio <= MAX_CLEAN_FALLBACK_ICON_SOURCE_CROP_AREA_RATIO + and dimension_ratio <= MAX_CLEAN_FALLBACK_ICON_SOURCE_CROP_DIMENSION_RATIO + ) + + def _compact_text(value: object) -> str: if value is None: return "" diff --git a/src/generative_editable_vlm_reconstruction.py b/src/generative_editable_vlm_reconstruction.py index 61bd41b..ce0c56e 100644 --- a/src/generative_editable_vlm_reconstruction.py +++ b/src/generative_editable_vlm_reconstruction.py @@ -77,6 +77,9 @@ MAX_BITMAP_ELEMENT_AREA_RATIO = 0.72 MAX_BITMAP_ELEMENT_WIDTH_RATIO = 0.92 MAX_BITMAP_ELEMENT_HEIGHT_RATIO = 0.72 +CLEAN_FALLBACK_LOCAL_ICON_KINDS = frozenset({"icon", "ui_icon", "small_icon"}) +MAX_CLEAN_FALLBACK_LOCAL_ICON_AREA_RATIO = 0.03 +MAX_CLEAN_FALLBACK_LOCAL_ICON_DIMENSION_RATIO = 0.22 ASSET_SHEET_PROVIDER_TIMEOUT_SECONDS = 120 COMPLEX_BITMAP_MASK_KINDS = frozenset({"photo", "product", "component", "complex"}) CONDITIONAL_COMPLEX_BITMAP_MASK_KINDS = frozenset({"other", "qr"}) @@ -356,6 +359,7 @@ def _default_vlm_preview_validator(**kwargs) -> ValidationReport: page_index=kwargs["page_index"], max_mean_abs_delta=gates.max_mean_abs_delta, max_changed_pixel_ratio=gates.max_changed_pixel_ratio, + page_manifest=kwargs.get("page_manifest"), ) @@ -499,6 +503,7 @@ def _validate_vlm_pipeline_output( pptx_path=pptx_path, preview_similarity_threshold=dependencies.preview_similarity_threshold, require_preview_validation=dependencies.require_preview_validation, + page_manifest=page, ) ) return _merge_validation_reports(reports, checked_pages=len(page_manifests)) @@ -982,7 +987,11 @@ def _build_vlm_page_manifest( asset_root=artifacts.job_dir, edit_provider=dependencies.image_edit_provider, timeout_seconds=clean_timeout, - cleanup_bboxes=[box.bbox for box in mask_boxes], + cleanup_bboxes=_local_clean_background_bboxes( + mask_boxes, + analysis, + mapper, + ), ) page_deadline.check("vlm_clean_background") asset_sheet_provider = dependencies.asset_sheet_image_edit_provider or dependencies.image_edit_provider @@ -1009,6 +1018,7 @@ def _build_vlm_page_manifest( edit_provider=asset_sheet_provider, timeout_seconds=asset_sheet_timeout_seconds, additional_text_bboxes=protected_text_bboxes, + force_opaque_source_crops=bool(clean_background.provenance.get("provider_failed")), ) page_deadline.check("vlm_asset_sheet") vlm_output_path = artifacts.write_provider_output( @@ -1220,6 +1230,47 @@ def build_text_bitmap_mask( return mask, boxes +def _local_clean_background_bboxes( + mask_boxes: list[MaskBox], + analysis: VLMPageAnalysis, + mapper: VLMCoordinateMapper, +) -> list[PixelBBox]: + bitmap_regions = {region.region_id: region for region in analysis.bitmap_regions} + cleanup_bboxes: list[PixelBBox] = [] + for box in mask_boxes: + if box.kind in {"text", "ocr_text"}: + cleanup_bboxes.append(box.bbox) + continue + if box.kind != "bitmap": + continue + region = bitmap_regions.get(box.region_id) + if region is None: + continue + unpadded_bbox = mapper.to_source_bbox(region.bbox, padding=0) + if _is_local_cleanable_fallback_icon(region, unpadded_bbox, mapper.source_image_size): + cleanup_bboxes.append(unpadded_bbox) + return cleanup_bboxes + + +def _is_local_cleanable_fallback_icon( + region: VLMBitmapRegion, + bbox: PixelBBox, + source_image_size: tuple[int, int], +) -> bool: + if region.kind.lower() not in CLEAN_FALLBACK_LOCAL_ICON_KINDS: + return False + width = max(0, bbox[2] - bbox[0]) + height = max(0, bbox[3] - bbox[1]) + source_width = max(1, source_image_size[0]) + source_height = max(1, source_image_size[1]) + area_ratio = (width * height) / float(source_width * source_height) + dimension_ratio = max(width / source_width, height / source_height) + return ( + area_ratio <= MAX_CLEAN_FALLBACK_LOCAL_ICON_AREA_RATIO + and dimension_ratio <= MAX_CLEAN_FALLBACK_LOCAL_ICON_DIMENSION_RATIO + ) + + def _protected_mask_bboxes( analysis: VLMPageAnalysis, mapper: VLMCoordinateMapper, @@ -1704,14 +1755,30 @@ def _is_short_text(text: str) -> bool: def _should_keep_ocr_layout_for_exact_text(ocr_box: TextBoxSpec, vlm_box: TextBoxSpec) -> bool: if not _normalized_texts_are_exact_duplicate(ocr_box.text, vlm_box.text): return False + vertical_center_close = _vertical_center_distance(ocr_box.source_pixel_bbox, vlm_box.source_pixel_bbox) <= max( + ocr_box.source_pixel_bbox[3] - ocr_box.source_pixel_bbox[1], + vlm_box.source_pixel_bbox[3] - vlm_box.source_pixel_bbox[1], + ) + ocr_width = ocr_box.source_pixel_bbox[2] - ocr_box.source_pixel_bbox[0] + vlm_width = vlm_box.source_pixel_bbox[2] - vlm_box.source_pixel_bbox[0] + if ( + vertical_center_close + and ocr_width >= vlm_width * 1.15 + and ocr_box.source_pixel_bbox[0] < vlm_box.source_pixel_bbox[0] + ): + return True + if ( + vertical_center_close + and len(_normalize_text_for_match(ocr_box.text)) >= 8 + and ocr_box.source_pixel_bbox[0] <= vlm_box.source_pixel_bbox[0] - max(24, int(vlm_width * 0.05)) + and ocr_width >= vlm_width * 0.90 + ): + return True overlap = _bbox_intersection_area(ocr_box.source_pixel_bbox, vlm_box.source_pixel_bbox) smaller_area = min(_bbox_area(ocr_box.source_pixel_bbox), _bbox_area(vlm_box.source_pixel_bbox)) if overlap / max(1, smaller_area) >= 0.45: return False - return _vertical_center_distance(ocr_box.source_pixel_bbox, vlm_box.source_pixel_bbox) <= max( - ocr_box.source_pixel_bbox[3] - ocr_box.source_pixel_bbox[1], - vlm_box.source_pixel_bbox[3] - vlm_box.source_pixel_bbox[1], - ) + return vertical_center_close def _vertical_center_distance(left: PixelBBox, right: PixelBBox) -> float: @@ -1904,6 +1971,7 @@ def _create_vlm_asset_sheet_assets( edit_provider: ImageEditProvider, timeout_seconds: int, additional_text_bboxes: list[PixelBBox] | None = None, + force_opaque_source_crops: bool = False, ) -> tuple[list[BitmapAssetSpec], list[AssetSheetSpec], Path | None]: text_bboxes = [ mapper.to_source_bbox(region.bbox, padding=8) @@ -1934,6 +2002,7 @@ def _create_vlm_asset_sheet_assets( page_index=page_index, reason="source_preserved_bitmap_region", transparent_bboxes=text_bboxes, + force_opaque_source_crops=force_opaque_source_crops, ) if not asset_sheet_candidates: return _with_candidate_z_order(source_preserved_assets, candidates), [], None @@ -1959,6 +2028,7 @@ def _create_vlm_asset_sheet_assets( reason=str(exc), asset_sheet_provider_failed=True, transparent_bboxes=text_bboxes, + force_opaque_source_crops=force_opaque_source_crops, ) provider_output_path = artifacts.write_provider_output( slide_id, @@ -1994,6 +2064,7 @@ def _create_vlm_asset_sheet_assets( reason=str(exc), asset_sheet_slicing_failed=True, transparent_bboxes=text_bboxes, + force_opaque_source_crops=force_opaque_source_crops, ) provider_output_path = artifacts.write_provider_output( slide_id, @@ -2119,6 +2190,7 @@ def _source_preserved_bitmap_assets_from_candidates( asset_sheet_skipped_reason: str = "", fallback_padding: int = 8, transparent_bboxes: list[PixelBBox] | None = None, + force_opaque_source_crops: bool = False, ) -> list[BitmapAssetSpec]: if not candidates: return [] @@ -2137,7 +2209,7 @@ def _source_preserved_bitmap_assets_from_candidates( _pad_bbox(candidate.source_pixel_bbox, fallback_padding), source_rgb.size, ) - use_opaque_crop = candidate.classification == "complex_whole_visual" + use_opaque_crop = force_opaque_source_crops or candidate.classification == "complex_whole_visual" if use_opaque_crop: crop = source_rgb.crop(bbox).convert("RGBA") _clear_crop_regions(crop, bbox, transparent_bboxes or []) @@ -2574,6 +2646,7 @@ def _create_local_vlm_clean_background_fallback( text_bboxes=cleanup_bboxes, output_asset_path=output_asset_path, asset_root=asset_root, + prefer_inpaint=True, ) return replace( result, diff --git a/tests/test_generative_editable_backgrounds.py b/tests/test_generative_editable_backgrounds.py index 8acfd84..afe2ae4 100644 --- a/tests/test_generative_editable_backgrounds.py +++ b/tests/test_generative_editable_backgrounds.py @@ -206,6 +206,29 @@ def test_source_preserving_text_background_cleans_text_without_changing_complex_ self.assertEqual(visual_pixel, (125, 135, 150)) self.assertNotEqual(cleaned_text_pixel, (255, 255, 255)) + def test_source_preserving_text_background_does_not_inpaint_large_masks(self): + from src.generative_editable_backgrounds import create_source_preserving_text_background + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "slide.png" + output = root / "backgrounds" / "0000-slide-a" / "source-preserving.png" + source.parent.mkdir(parents=True) + image = Image.new("RGB", (220, 120), "#041225") + ImageDraw.Draw(image).rectangle((20, 20, 190, 95), fill="#FFFFFF") + image.save(source) + + result = create_source_preserving_text_background( + source_image_path=source, + text_bboxes=[(20, 20, 190, 95)], + output_asset_path=output, + asset_root=root, + prefer_inpaint=True, + ) + + self.assertEqual(result.strategy, "local_fill") + self.assertEqual(result.provenance["decision"], "source preserving local text cleanup") + def test_source_raster_background_copies_source_without_cleanup(self): from src.generative_editable_backgrounds import create_source_raster_background diff --git a/tests/test_generative_editable_preview_validator.py b/tests/test_generative_editable_preview_validator.py index 301eaaf..8a6375d 100644 --- a/tests/test_generative_editable_preview_validator.py +++ b/tests/test_generative_editable_preview_validator.py @@ -339,6 +339,103 @@ def test_structural_validation_allows_vlm_bbox_source_preserved_crop_assets(self [issue.code for issue in report.issues], ) + def test_structural_validation_allows_clean_fallback_icon_source_preserved_crop_assets(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_preserved_path = root / "assets" / "0000-slide-a" / "icon.source-preserved.png" + source_preserved_path.write_bytes( + (root / "assets" / "0000-slide-a" / "asset.png").read_bytes() + ) + clean_fallback_page = replace( + page, + provenance={**page.provenance, "clean_background_provider_failed": True}, + bitmap_assets=[ + replace( + page.bitmap_assets[0], + asset_path="assets/0000-slide-a/icon.source-preserved.png", + provenance={ + "asset_strategy": "source_preserved_crop", + "alpha_strategy": "opaque_source_crop", + "candidate_classification": "bitmap_asset_candidate", + "asset_sheet_skipped_reason": "icon_source_preserved", + "candidate_provenance": { + "source": "vlm_bitmap_region", + "vlm_type": "icon", + }, + "original_source_pixel_bbox": [10, 10, 80, 80], + }, + ) + ], + ) + write_manifest(root / "pages" / "0000-slide-a.json", clean_fallback_page) + output = root / "source-preserved-clean-fallback-icon.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "passed") + self.assertNotIn( + "forbidden_source_crop_bitmap_asset", + [issue.code for issue in report.issues], + ) + + def test_structural_validation_rejects_oversized_clean_fallback_icon_source_preserved_crop_assets(self): + from src.generative_editable_composer import compose_deck_from_manifests + from src.generative_editable_preview_validator import validate_composed_deck_structure + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source_preserved_path = root / "assets" / "0000-slide-a" / "icon.source-preserved.png" + source_preserved_path.write_bytes( + (root / "assets" / "0000-slide-a" / "asset.png").read_bytes() + ) + oversized_icon_page = replace( + page, + provenance={**page.provenance, "clean_background_provider_failed": True}, + bitmap_assets=[ + replace( + page.bitmap_assets[0], + asset_path="assets/0000-slide-a/icon.source-preserved.png", + source_pixel_bbox=(20, 20, 720, 380), + provenance={ + "asset_strategy": "source_preserved_crop", + "alpha_strategy": "opaque_source_crop", + "candidate_classification": "bitmap_asset_candidate", + "asset_sheet_skipped_reason": "icon_source_preserved", + "candidate_provenance": { + "source": "vlm_bitmap_region", + "vlm_type": "icon", + }, + "original_source_pixel_bbox": [20, 20, 720, 380], + }, + ) + ], + ) + write_manifest(root / "pages" / "0000-slide-a.json", oversized_icon_page) + output = root / "source-preserved-oversized-clean-fallback-icon.pptx" + compose_deck_from_manifests(root / "deck.json", root, output) + + report = validate_composed_deck_structure( + deck_manifest_path=root / "deck.json", + artifact_root=root, + pptx_path=output, + ) + + self.assertEqual(report.status, "failed") + self.assertIn( + "forbidden_source_crop_bitmap_asset", + [issue.code for issue in report.issues], + ) + def test_structural_validation_rejects_incomplete_opaque_source_crop_provenance(self): from src.generative_editable_composer import compose_deck_from_manifests from src.generative_editable_preview_validator import validate_composed_deck_structure @@ -1201,6 +1298,184 @@ def test_preview_similarity_fails_when_fixture_images_exceed_threshold(self): self.assertGreater(report.issues[0].details["mean_abs_delta"], 1.0) self.assertGreater(report.issues[0].details["changed_pixel_ratio"], 0.01) + def test_preview_similarity_allows_text_dense_editable_render_drift(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source = root / "source.png" + source_image = Image.new("RGB", (100, 100), (128, 128, 128)) + source_image.save(source) + preview = source_image.copy() + draw = ImageDraw.Draw(preview) + draw.rectangle((0, 0, 99, 13), fill=(160, 160, 160)) + dense_page = replace( + page, + source_image_size=(100, 100), + native_shapes=[], + text_boxes=[ + replace( + page.text_boxes[0], + text=f"Line {index}", + source_pixel_bbox=(0, index, 80, index + 1), + source_pixel_polygon=((0, index), (80, index), (80, index + 1), (0, index + 1)), + ) + for index in range(24) + ], + bitmap_assets=[ + replace(page.bitmap_assets[0], asset_id=f"asset-{index}", source_pixel_bbox=(0, 0, 10, 10)) + for index in range(6) + ], + ) + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=preview, + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=20.4, + max_changed_pixel_ratio=0.132, + page_manifest=dense_page, + ) + + self.assertEqual(report.status, "passed") + self.assertEqual(report.issues, []) + + def test_preview_similarity_rejects_text_dense_render_drift_outside_text_regions(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source = root / "source.png" + source_image = Image.new("RGB", (100, 100), (128, 128, 128)) + source_image.save(source) + preview = source_image.copy() + draw = ImageDraw.Draw(preview) + draw.rectangle((0, 80, 99, 93), fill=(160, 160, 160)) + dense_page = replace( + page, + source_image_size=(100, 100), + native_shapes=[], + text_boxes=[ + replace( + page.text_boxes[0], + text=f"Line {index}", + source_pixel_bbox=(0, index, 80, index + 1), + source_pixel_polygon=((0, index), (80, index), (80, index + 1), (0, index + 1)), + ) + for index in range(24) + ], + bitmap_assets=[ + replace(page.bitmap_assets[0], asset_id=f"asset-{index}", source_pixel_bbox=(0, 0, 10, 10)) + for index in range(6) + ], + ) + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=preview, + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=20.4, + max_changed_pixel_ratio=0.132, + page_manifest=dense_page, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "preview_similarity_failed") + + def test_preview_similarity_rejects_text_dense_mixed_drift_with_too_much_non_text_change(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source = root / "source.png" + source_image = Image.new("RGB", (100, 100), (128, 128, 128)) + source_image.save(source) + preview = source_image.copy() + draw = ImageDraw.Draw(preview) + for y in range(0, 11): + for x in range(0, 100): + draw.point((x, y), fill=(160, 160, 160)) + for y in range(80, 85): + for x in range(0, 100): + draw.point((x, y), fill=(160, 160, 160)) + for x in range(0, 10): + draw.point((x, 85), fill=(160, 160, 160)) + dense_page = replace( + page, + source_image_size=(100, 100), + native_shapes=[], + text_boxes=[ + replace( + page.text_boxes[0], + text=f"Line {index}", + source_pixel_bbox=(0, index, 80, index + 1), + source_pixel_polygon=((0, index), (80, index), (80, index + 1), (0, index + 1)), + ) + for index in range(24) + ], + bitmap_assets=[ + replace(page.bitmap_assets[0], asset_id=f"asset-{index}", source_pixel_bbox=(0, 0, 10, 10)) + for index in range(6) + ], + ) + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=preview, + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=20.4, + max_changed_pixel_ratio=0.132, + page_manifest=dense_page, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "preview_similarity_failed") + self.assertGreater(report.issues[0].details["outside_text_changed_pixel_ratio"], 0.05) + + def test_preview_similarity_still_rejects_sparse_render_drift_over_changed_area_threshold(self): + from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _, page = self._write_validation_fixture(root) + source = root / "source.png" + source_image = Image.new("RGB", (100, 100), (128, 128, 128)) + source_image.save(source) + preview = source_image.copy() + draw = ImageDraw.Draw(preview) + draw.rectangle((0, 0, 99, 13), fill=(160, 160, 160)) + + report = validate_preview_similarity( + source_image_path=source, + preview=PreviewRenderResult( + image=preview, + metadata={"renderer": "fake_powerpoint", "is_powerpoint_render": True}, + ), + slide_id="slide-a", + page_index=0, + max_mean_abs_delta=20.4, + max_changed_pixel_ratio=0.132, + page_manifest=page, + ) + + self.assertEqual(report.status, "failed") + self.assertEqual(report.issues[0].code, "preview_similarity_failed") + def test_preview_similarity_rejects_manifest_stub_as_real_powerpoint_quality_gate(self): from src.generative_editable_preview_validator import ( render_manifest_preview_with_metadata, diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index 0e2476a..14b0e5c 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -1042,6 +1042,47 @@ def test_complex_source_preserved_assets_use_opaque_crop_not_background_diff_alp self.assertEqual(assets[0].provenance["alpha_strategy"], "opaque_source_crop") self.assertNotIn("background_difference_alpha", assets[0].provenance) + def test_source_preserved_icons_can_force_opaque_crop_when_background_is_untrusted(self): + from src.generative_editable_foreground_planner import ForegroundCandidate + from src.generative_editable_vlm_reconstruction import ( + _source_preserved_bitmap_assets_from_candidates, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + source_image = Image.new("RGB", (120, 80), "#001122") + ImageDraw.Draw(source_image).rectangle((30, 18, 48, 36), fill="#FFFFFF") + source_image.save(source) + source_image.save(background) + candidate = ForegroundCandidate( + candidate_id="icon", + source_pixel_bbox=(30, 18, 48, 36), + area=324, + classification="bitmap_asset_candidate", + provenance={"vlm_type": "icon"}, + ) + + assets = _source_preserved_bitmap_assets_from_candidates( + candidates=[candidate], + source_image_path=source, + clean_background_path=background, + artifact_root=root, + slide_id="slide-a", + page_index=0, + reason="untrusted_clean_background", + asset_sheet_skipped_reason="icon_source_preserved", + force_opaque_source_crops=True, + ) + crop = Image.open(root / assets[0].asset_path).convert("RGBA") + + self.assertEqual(crop.getpixel((0, 0))[3], 255) + self.assertEqual(assets[0].provenance["alpha_strategy"], "opaque_source_crop") + self.assertNotIn("background_difference_alpha", assets[0].provenance) + def test_foreground_crop_preserves_edge_foreground_close_to_background(self): from src.generative_editable_vlm_reconstruction import _foreground_rgba_crop @@ -2199,6 +2240,112 @@ def test_vlm_text_resolution_uses_ocr_layout_for_short_text(self): self.assertEqual(page.text_boxes[0].source_pixel_bbox, (1292, 575, 1340, 595)) self.assertEqual(page.text_boxes[0].provenance["layout_source"], "ocr") + def test_vlm_text_resolution_uses_wider_ocr_layout_for_exact_long_title(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1672, 941), "#001122").save(source) + Image.new("RGB", (1672, 941), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1672, "height": 941, "unit": "px"}, + "text_regions": [ + { + "id": "title", + "text": "落地建议:用车、补能与维护的最佳实践", + "bbox": [431, 20, 1241, 76], + "role": "title", + "confidence": 0.95, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_title = TextBoxSpec( + text="落地建议:用车、补能与维护的最佳实践", + source_pixel_bbox=(296, 22, 1283, 76), + source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), + font_size=18, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_title], + ) + + self.assertEqual(page.text_boxes[0].source_pixel_bbox, (296, 22, 1283, 76)) + self.assertEqual(page.text_boxes[0].provenance["layout_source"], "ocr") + + def test_vlm_text_resolution_uses_left_shifted_ocr_layout_for_exact_long_title(self): + from src.generative_editable_manifest import TextBoxSpec + from src.generative_editable_vlm_reconstruction import ( + build_page_manifest_from_vlm_analysis, + coerce_vlm_analysis, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sources" / "0000-slide-a" / "source.png" + background = root / "backgrounds" / "0000-slide-a" / "clean.png" + source.parent.mkdir(parents=True) + background.parent.mkdir(parents=True) + Image.new("RGB", (1672, 941), "#001122").save(source) + Image.new("RGB", (1672, 941), "#001122").save(background) + analysis = coerce_vlm_analysis( + { + "coordinate_space": {"width": 1672, "height": 941, "unit": "px"}, + "text_regions": [ + { + "id": "title", + "text": "落地建议:用车、补能与维护的最佳实践", + "bbox": [359, 22, 1315, 76], + "role": "title", + "confidence": 0.95, + } + ], + "bitmap_regions": [], + "shape_regions": [], + } + ) + ocr_title = TextBoxSpec( + text="落地建议:用车、补能与维护的最佳实践", + source_pixel_bbox=(296, 22, 1283, 76), + source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), + font_size=18, + provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + ) + + page = build_page_manifest_from_vlm_analysis( + analysis=analysis, + slide_id="slide-a", + page_index=0, + source_image_path=source, + clean_background_path=background, + artifact_root=root, + aspect_ratio="16:9", + text_boxes=[ocr_title], + ) + + self.assertEqual(page.text_boxes[0].source_pixel_bbox, (296, 22, 1283, 76)) + self.assertEqual(page.text_boxes[0].provenance["layout_source"], "ocr") + def test_vlm_fallback_does_not_duplicate_nearby_ocr_text_line(self): from src.generative_editable_manifest import TextBoxSpec from src.generative_editable_vlm_reconstruction import ( @@ -2841,6 +2988,7 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): ], "bitmap_regions": [ {"id": "product", "type": "product", "bbox": [44, 24, 102, 64]}, + {"id": "small-icon", "type": "icon", "bbox": [112, 30, 124, 42]}, ], "shape_regions": [ {"id": "divider", "type": "line", "bbox": [10, 72, 150, 73]}, @@ -2877,6 +3025,7 @@ def edit(self, request): draw = ImageDraw.Draw(image) draw.text((14, 8), "标题", fill="#FFFFFF") draw.rectangle((44, 24, 102, 64), fill="#22AAFF") + draw.rectangle((112, 30, 124, 42), fill="#FF3344") draw.line((10, 72, 150, 72), fill="#55CCFF", width=1) image.save(source) output = root / "out.pptx" @@ -2901,6 +3050,9 @@ def edit(self, request): job_dir = root / "artifacts" / "job-clean-fallback" deck = read_deck_manifest(job_dir / "deck.json") page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) + clean_background_path = job_dir / page.chosen_background + with Image.open(clean_background_path) as clean_background: + cleaned_icon_pixel = clean_background.convert("RGB").getpixel((116, 34)) prs = Presentation(str(output)) shape_types = [shape.shape_type for shape in prs.slides[0].shapes] @@ -2908,7 +3060,8 @@ def edit(self, request): self.assertEqual(result.fallback_used, "clean_background_local") self.assertEqual(page.provider_output_paths["image_edit"], "provider_outputs/image_edit/0000-slide-a/backgrounds.json") self.assertTrue(page.provenance["clean_background_provider_failed"]) - self.assertEqual(page.provenance["clean_background_strategy"], "local_fill") + self.assertEqual(page.provenance["clean_background_strategy"], "local_inpaint") + self.assertNotEqual(cleaned_icon_pixel, (255, 51, 68)) self.assertGreaterEqual(len(page.text_boxes), 1) self.assertGreaterEqual(len(page.bitmap_assets), 1) self.assertIn(MSO_SHAPE_TYPE.TEXT_BOX, shape_types) From 03bf01aec226f85c60a0453cb8674deb8e7fe4f6 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 14:53:12 +0800 Subject: [PATCH 08/16] Handle editable PPTX fallback failures --- api/routes/export.py | 38 ++++++ scripts/run_real_generative_editable_pptx.py | 112 ++++++++++++++---- .../test_generative_editable_export_route.py | 41 +++++++ tests/test_real_generative_editable_runner.py | 48 ++++++++ 4 files changed, 214 insertions(+), 25 deletions(-) diff --git a/api/routes/export.py b/api/routes/export.py index 46ed586..57da44c 100644 --- a/api/routes/export.py +++ b/api/routes/export.py @@ -50,6 +50,7 @@ ProviderError, ProviderTimeoutError, ) +from src.generative_editable_preview_validator import ValidationIssue, ValidationReport from src.model_profiles import load_default_profiles from ..models import ExportRequest @@ -236,11 +237,48 @@ def _export_generative_editable_pptx( fallback_policy=fallback_policy, fallback_output_factory=fallback_factory, ) + except (ProviderError, ProviderTimeoutError) as exc: + if fallback_policy == "fail": + raise + return finalize_validated_export( + validation_report=_provider_failure_validation_report(exc, len(slide_inputs)), + output_path=output_path, + fallback_policy=fallback_policy, + fallback_output_factory=fallback_factory, + ) except Exception: Path(output_path).unlink(missing_ok=True) raise +def _provider_failure_validation_report( + error: ProviderError, + slide_count: int, +) -> ValidationReport: + details: dict[str, object] = { + "provider_role": error.provider_role, + "operation": error.operation, + "retryable": bool(error.retryable), + } + if error.status_code is not None: + details["status_code"] = error.status_code + if error.provider_error_code: + details["provider_error_code"] = error.provider_error_code + if isinstance(error, ProviderTimeoutError): + details["timeout_seconds"] = error.timeout_seconds + return ValidationReport( + status="failed", + checked_pages=slide_count, + issues=[ + ValidationIssue( + code="provider_timeout" if isinstance(error, ProviderTimeoutError) else "provider_failure", + message=str(error) or error.__class__.__name__, + details=details, + ) + ], + ) + + def _editable_fallback_policy(editable_options: object | None) -> str: if editable_options is None: return "fail" diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py index 4b00ad6..bc7be19 100644 --- a/scripts/run_real_generative_editable_pptx.py +++ b/scripts/run_real_generative_editable_pptx.py @@ -37,6 +37,7 @@ GenerativeEditableFallbackError, GenerativeEditableSlideInput, GenerativeEditableValidationError, + finalize_validated_export, run_generative_editable_pipeline, with_provider_retries, ) @@ -48,6 +49,8 @@ with_vlm_provider_retries, ) from src.generative_editable_preview_validator import ( # noqa: E402 + ValidationIssue, + ValidationReport, quality_threshold_to_preview_gates, render_powerpoint_preview_with_metadata, validate_preview_similarity, @@ -191,7 +194,7 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(result, ensure_ascii=False)) return 1 print(json.dumps(result, ensure_ascii=False)) - return 0 if result.get("status") == "passed" else 1 + return 0 if result.get("status") in {"passed", "fallback_used"} else 1 def _add_common_args(parser: argparse.ArgumentParser) -> None: @@ -514,6 +517,7 @@ def _run_vlm_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, A job_id = args.job_id or _generated_job_id() artifact_root = output_dir / "artifacts" output_path = output_dir / f"{job_id}.pptx" + raster_fallback_path = output_dir / f"{job_id}.raster-fallback.pptx" provider_timeout = _provider_timeout_for_args(args) dependencies = _vlm_dependencies( use_fake=args.use_fake, @@ -522,35 +526,65 @@ def _run_vlm_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, A provider_retry_backoff_seconds=getattr(args, "provider_retry_backoff", -1.0), page_timeout_seconds=getattr(args, "pipeline_page_timeout", 0.0), ) - result = run_vlm_editable_pptx_pipeline( - slides=[ - {"slide_id": f"slide-{index + 1}", "image_path": str(path)} - for index, path in enumerate(images) - ], - output_path=str(output_path), - artifact_root=str(artifact_root), - job_id=job_id, - aspect_ratio=args.aspect_ratio, - dependencies=dependencies, - cleanup_artifacts=False, + fallback_factory = ( + lambda: _export_raster_pptx_fallback( + image_paths=images, + output_path=raster_fallback_path, + aspect_ratio=args.aspect_ratio, + ) + if args.fallback_policy == "raster_pptx" + else None ) + try: + result = run_vlm_editable_pptx_pipeline( + slides=[ + {"slide_id": f"slide-{index + 1}", "image_path": str(path)} + for index, path in enumerate(images) + ], + output_path=str(output_path), + artifact_root=str(artifact_root), + job_id=job_id, + aspect_ratio=args.aspect_ratio, + dependencies=dependencies, + cleanup_artifacts=False, + ) + except GenerativeEditableValidationError as exc: + result = finalize_validated_export( + validation_report=exc.validation_report, + output_path=str(output_path), + fallback_policy=args.fallback_policy, + fallback_output_factory=fallback_factory, + ) + except (ProviderError, ProviderTimeoutError) as exc: + if args.fallback_policy == "fail": + raise + result = finalize_validated_export( + validation_report=_provider_failure_validation_report(exc, len(images)), + output_path=str(output_path), + fallback_policy=args.fallback_policy, + fallback_output_factory=fallback_factory, + ) job_dir = artifact_root / job_id deck_path = job_dir / "deck.json" result_output_path = Path(result.output_path) object_stats = _pptx_object_stats(result_output_path) - reconstruction_issues = _reconstruction_object_issues( - deck_manifest_path=deck_path, - artifact_root=job_dir, - object_stats=object_stats, - ) - preview_reports = _write_preview_reports( - source_images=images, - pptx_path=result_output_path, - deck_manifest_path=deck_path, - artifact_root=job_dir, - similarity_threshold=0.92, - output_dir=output_dir / "previews", - ) + if deck_path.is_file(): + reconstruction_issues = _reconstruction_object_issues( + deck_manifest_path=deck_path, + artifact_root=job_dir, + object_stats=object_stats, + ) + preview_reports = _write_preview_reports( + source_images=images, + pptx_path=result_output_path, + deck_manifest_path=deck_path, + artifact_root=job_dir, + similarity_threshold=getattr(dependencies, "preview_similarity_threshold", 0.92), + output_dir=output_dir / "previews", + ) + else: + reconstruction_issues = [] + preview_reports = [] status = _status_with_preview_reports( _status_with_reconstruction_issues(result.status, reconstruction_issues), preview_reports, @@ -571,6 +605,9 @@ def _run_vlm_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, A **warning_summary, "preview_reports": preview_reports, } + if getattr(result, "validation_report", None) is not None: + report["validation_status"] = result.validation_report.status + report["validation_issues"] = [issue.code for issue in result.validation_report.issues] _augment_result_with_stage_events(report, job_dir) report_path = output_dir / "report.json" _write_json(report_path, report) @@ -583,6 +620,31 @@ def _run_vlm_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, A } +def _provider_failure_validation_report(error: ProviderError, slide_count: int) -> ValidationReport: + details: dict[str, Any] = { + "provider_role": error.provider_role, + "operation": error.operation, + "retryable": bool(error.retryable), + } + if error.status_code is not None: + details["status_code"] = error.status_code + if error.provider_error_code: + details["provider_error_code"] = error.provider_error_code + if isinstance(error, ProviderTimeoutError): + details["timeout_seconds"] = error.timeout_seconds + return ValidationReport( + status="failed", + checked_pages=slide_count, + issues=[ + ValidationIssue( + code="provider_timeout" if isinstance(error, ProviderTimeoutError) else "provider_failure", + message=str(error) or error.__class__.__name__, + details=details, + ) + ], + ) + + def _export_raster_pptx_fallback( *, image_paths: list[Path], diff --git a/tests/test_generative_editable_export_route.py b/tests/test_generative_editable_export_route.py index 3a3fca9..cd288cf 100644 --- a/tests/test_generative_editable_export_route.py +++ b/tests/test_generative_editable_export_route.py @@ -838,6 +838,47 @@ def fake_raster_export(image_paths, output_path, aspect_ratio="16:9"): self.assertEqual(result.status, "fallback_used") self.assertEqual(result.fallback_used, "raster_pptx") + def test_explicit_raster_fallback_handles_vlm_provider_failure(self): + from src.generative_editable_providers import ProviderError + + calls = [] + provider_error = ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="upstream failed", + retryable=False, + ) + + def fake_raster_export(image_paths, output_path, aspect_ratio="16:9"): + calls.append((list(image_paths), output_path, aspect_ratio)) + Path(output_path).write_bytes(b"raster-fallback") + + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "out.pptx" + _write_fake_vlm_source(image_path) + + with patch("api.routes.export._export_pptx", fake_raster_export), patch( + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(provider_exception=provider_error), + ): + result = export_route._export_generative_editable_pptx( + [str(image_path)], + str(output_path), + aspect_ratio="16:9", + editable_options=ExportRequest( + slides=[ExportSlide(image_base64=_slide_base64())], + format="generative_editable_pptx", + editable_options={"fallback_policy": "raster_pptx"}, + ).editable_options, + ) + + self.assertEqual(output_path.read_bytes(), b"raster-fallback") + + self.assertEqual(calls[0][2], "16:9") + self.assertEqual(result.status, "fallback_used") + self.assertEqual(result.fallback_used, "raster_pptx") + def test_text_editable_background_fallback_returns_text_editable_background_deck(self): from pptx import Presentation diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index bcfdaef..85468a0 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -272,6 +272,54 @@ def fake_pipeline(**kwargs): str((root / "out" / "fallback-policy.raster-fallback.pptx").resolve()), ) + def test_vlm_first_run_uses_raster_fallback_on_provider_failure(self): + import scripts.run_real_generative_editable_pptx as runner + + def fake_vlm_pipeline(**kwargs): + raise ProviderError( + provider_role="ocr_model", + operation="extract_text", + message="upstream failed", + retryable=False, + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "slide.png" + Image.new("RGB", (800, 450), "#FFFFFF").save(source) + stdout = io.StringIO() + with ( + patch.object(runner, "_vlm_dependencies", return_value=object()), + patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object(runner, "_write_preview_reports", return_value=[]), + redirect_stdout(stdout), + ): + exit_code = main( + [ + "run", + "--mode", + "vlm_first", + "--input-images", + str(source), + "--output-dir", + str(root / "out"), + "--job-id", + "vlm-provider-fallback", + "--fallback-policy", + "raster_pptx", + ] + ) + line = json.loads(stdout.getvalue().splitlines()[-1]) + report = json.loads(Path(line["report_path"]).read_text(encoding="utf-8")) + output_exists = Path(line["output_path"]).exists() + + self.assertEqual(exit_code, 0) + self.assertEqual(line["status"], "fallback_used") + self.assertEqual(Path(line["output_path"]).name, "vlm-provider-fallback.raster-fallback.pptx") + self.assertTrue(output_exists) + self.assertEqual(report["fallback_used"], "raster_pptx") + self.assertEqual(report["object_stats"]["slides"][0]["full_slide_picture_count"], 1) + def test_run_mode_defaults_to_vlm_first_pipeline(self): import scripts.run_real_generative_editable_pptx as runner From 359978838efe4c8a39aa2927ebd913cdb39e91a0 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 15:22:54 +0800 Subject: [PATCH 09/16] Fix editable PPTX review findings --- api/profile_resolver.py | 3 +- api/routes/export.py | 25 +++++++++- api/routes/generate.py | 19 ++++--- src/generative_editable_backgrounds.py | 11 +++-- tests/test_generate_route_helpers.py | 4 ++ tests/test_generative_editable_backgrounds.py | 40 +++++++++++++++ .../test_generative_editable_export_route.py | 49 +++++++++++++++++++ tests/test_profile_resolver.py | 24 +++++++++ 8 files changed, 163 insertions(+), 12 deletions(-) diff --git a/api/profile_resolver.py b/api/profile_resolver.py index 5a7f40f..8e874ad 100644 --- a/api/profile_resolver.py +++ b/api/profile_resolver.py @@ -20,7 +20,8 @@ def _has_complete_generation_profiles(config: Any) -> bool: or getattr(profiles, "prompt_model", None) or getattr(profiles, "VLM", None) ) - required = (text_profile, profiles.image_model) + image_profile = getattr(profiles, "image_model", None) + required = (text_profile, image_profile) return all( profile and _has_value(profile.model) diff --git a/api/routes/export.py b/api/routes/export.py index 57da44c..46c2619 100644 --- a/api/routes/export.py +++ b/api/routes/export.py @@ -292,9 +292,30 @@ def _export_raster_pptx_fallback(*, image_paths: list, output_path: str, aspect_ return output_path +def _text_editable_background_fallback_error(reason: str) -> GenerativeEditableFallbackError: + return GenerativeEditableFallbackError( + validation_report=ValidationReport( + status="failed", + checked_pages=0, + issues=[ + ValidationIssue( + code="fallback_precondition_failed", + message=reason, + ) + ], + ), + fallback_policy="text_editable_background", + fallback_failure_reason=reason, + ) + + def _export_text_editable_background_fallback(*, artifact_root: Path, job_id: str, output_path: str) -> str: job_dir = artifact_root / job_id deck_path = job_dir / "deck.json" + if not deck_path.is_file(): + raise _text_editable_background_fallback_error( + "text_clean_background fallback failed: deck manifest is missing because the pipeline failed before creating artifacts" + ) deck = read_deck_manifest(deck_path) fallback_dir = job_dir / "pages" / "text-editable-background-fallback" fallback_dir.mkdir(parents=True, exist_ok=True) @@ -303,7 +324,9 @@ def _export_text_editable_background_fallback(*, artifact_root: Path, job_id: st page = read_page_manifest(job_dir / page_ref) background = page.text_clean_background or page.base_clean_background or page.chosen_background if not background: - raise RuntimeError("text_clean_background fallback requires a cleaned background artifact") + raise _text_editable_background_fallback_error( + "text_clean_background fallback requires a cleaned background artifact" + ) fallback_page = replace( page, chosen_background=background, diff --git a/api/routes/generate.py b/api/routes/generate.py index 29412f7..5ad4d82 100644 --- a/api/routes/generate.py +++ b/api/routes/generate.py @@ -87,13 +87,20 @@ def _prompt_data_from_confirmed(request: GenerationRequest, ppt_config: PPTConfi def _slide_text_metadata(slide_prompt) -> list[dict]: + if isinstance(slide_prompt, str): + return [] + metadata = [] - title = str(getattr(slide_prompt, "title", "") or "").strip() - body = str( - getattr(slide_prompt, "display_content", "") - or getattr(slide_prompt, "content_summary", "") - or "" - ).strip() + title = getattr(slide_prompt, "title", "") + if callable(title): + title = "" + title = str(title or "").strip() + body = getattr(slide_prompt, "display_content", "") or getattr( + slide_prompt, "content_summary", "" + ) + if callable(body): + body = "" + body = str(body or "").strip() if title: metadata.append({"text": title, "role": "title", "order": 1, "style_hint": {}}) diff --git a/src/generative_editable_backgrounds.py b/src/generative_editable_backgrounds.py index b056928..280d2b1 100644 --- a/src/generative_editable_backgrounds.py +++ b/src/generative_editable_backgrounds.py @@ -438,12 +438,15 @@ def _normalize_background_to_source_size( output_path = Path(output_asset_path) with Image.open(source_image_path) as source: source_size = source.size + needs_resize = False with Image.open(output_path) as output: - if output.size == source_size: - return {} - normalized = output.convert("RGB").resize(source_size) + if output.size != source_size: + needs_resize = True + normalized = output.convert("RGB").resize(source_size) + if needs_resize: normalized.save(output_path) - return {"normalized_to_source_size": list(source_size)} + return {"normalized_to_source_size": list(source_size)} + return {} def _sample_border_pixels(image: Image.Image, bbox: tuple[int, int, int, int]) -> list[tuple[int, int, int]]: diff --git a/tests/test_generate_route_helpers.py b/tests/test_generate_route_helpers.py index 8441724..0b42494 100644 --- a/tests/test_generate_route_helpers.py +++ b/tests/test_generate_route_helpers.py @@ -52,3 +52,7 @@ def test_slide_text_metadata_avoids_duplicate_body_matching_title(): assert _slide_text_metadata(prompt) == [ {"text": "Only Title", "role": "title", "order": 1, "style_hint": {}} ] + + +def test_slide_text_metadata_ignores_string_prompt_values(): + assert _slide_text_metadata("standalone prompt text") == [] diff --git a/tests/test_generative_editable_backgrounds.py b/tests/test_generative_editable_backgrounds.py index afe2ae4..bc8d324 100644 --- a/tests/test_generative_editable_backgrounds.py +++ b/tests/test_generative_editable_backgrounds.py @@ -2,6 +2,7 @@ import unittest import hashlib from pathlib import Path +from unittest.mock import patch from PIL import Image, ImageChops, ImageDraw @@ -30,6 +31,45 @@ def edit(self, request): class GenerativeEditableBackgroundsTest(unittest.TestCase): + def test_normalize_background_closes_output_before_overwriting_same_path(self): + from src.generative_editable_backgrounds import _normalize_background_to_source_size + + class FakeImage: + def __init__(self, size): + self.size = size + self.closed = False + self.saved = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + self.closed = True + + def convert(self, mode): + return self + + def resize(self, size): + self.size = size + return self + + def save(self, path): + if not output_image.closed: + raise PermissionError("output image handle is still open") + self.saved = True + + source_image = FakeImage((120, 80)) + output_image = FakeImage((240, 120)) + + with patch( + "src.generative_editable_backgrounds.Image.open", + side_effect=[source_image, output_image], + ): + provenance = _normalize_background_to_source_size("output.png", "source.png") + + self.assertEqual(provenance["normalized_to_source_size"], [120, 80]) + self.assertTrue(output_image.saved) + def test_image_edit_background_outputs_are_normalized_to_source_size(self): from src.generative_editable_backgrounds import create_base_clean_background diff --git a/tests/test_generative_editable_export_route.py b/tests/test_generative_editable_export_route.py index cd288cf..62c554c 100644 --- a/tests/test_generative_editable_export_route.py +++ b/tests/test_generative_editable_export_route.py @@ -905,6 +905,55 @@ def test_text_editable_background_fallback_returns_text_editable_background_deck self.assertEqual(result.fallback_used, "text_editable_background") self.assertTrue(any(shape.has_text_frame for shape in presentation.slides[0].shapes)) + def test_text_editable_background_fallback_requires_deck_manifest(self): + from src.generative_editable_pipeline import GenerativeEditableFallbackError + + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(GenerativeEditableFallbackError): + export_route._export_text_editable_background_fallback( + artifact_root=Path(tmp), + job_id="export", + output_path=str(Path(tmp) / "out.pptx"), + ) + + def test_text_editable_background_fallback_reports_missing_background_artifact(self): + from src.generative_editable_manifest import DeckManifest, PageManifest, write_manifest + from src.generative_editable_pipeline import GenerativeEditableFallbackError + + with tempfile.TemporaryDirectory() as tmp: + artifact_root = Path(tmp) + job_dir = artifact_root / "export" + page_path = job_dir / "pages" / "0000-slide-1.json" + write_manifest( + page_path, + PageManifest( + slide_id="slide-1", + page_index=0, + source_image_path="sources/0000-slide-1.png", + source_image_size=(800, 450), + slide_size=(13.333, 7.5), + ), + ) + write_manifest( + job_dir / "deck.json", + DeckManifest( + job_id="export", + slide_order=["slide-1"], + aspect_ratio="16:9", + provider_roles={}, + quality_settings={}, + fallback_policy="fail", + page_manifest_paths=["pages/0000-slide-1.json"], + ), + ) + + with self.assertRaises(GenerativeEditableFallbackError): + export_route._export_text_editable_background_fallback( + artifact_root=artifact_root, + job_id="export", + output_path=str(artifact_root / "out.pptx"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_profile_resolver.py b/tests/test_profile_resolver.py index 815f52b..d6488d7 100644 --- a/tests/test_profile_resolver.py +++ b/tests/test_profile_resolver.py @@ -1,4 +1,5 @@ import unittest +from types import SimpleNamespace from unittest.mock import patch from api.models import ( @@ -127,6 +128,29 @@ def test_model_profile_requests_without_adapter_use_role_defaults(self): self.assertEqual(profiles.image.adapter, "raw_chat_multimodal") self.assertEqual(profiles.edit.adapter, "raw_chat_multimodal") + def test_partial_generation_profile_object_without_image_model_uses_backend_profiles(self): + config = SimpleNamespace( + model_profiles=SimpleNamespace( + text_model=ModelProfileConfig( + model="text", + base_url="https://text.example/v1", + api_key="text-key", + ), + prompt_model=None, + VLM=None, + ), + text=None, + image=None, + ) + + with patch( + "api.profile_resolver.load_default_profiles", return_value=self.default_profiles() + ): + profiles = profiles_from_generation_config(config) + + self.assertEqual(profiles.prompt.model, "default-text") + self.assertEqual(profiles.image.model, "default-image") + def test_empty_edit_config_uses_backend_profiles(self): config = EditConfig(api_key="", base_url="", model="gpt-image-2") From 4d0dda6a8bce826d9c5b93c189645eacd87ae7bd Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 15:47:56 +0800 Subject: [PATCH 10/16] Prune editable PPTX process docs --- ...editable-pptx-reference-full-chain-plan.md | 573 ----------- docs/editable-pptx-reference-pipeline.md | 894 ------------------ docs/generative-editable-live-verification.md | 48 - docs/generative-editable-pptx.md | 131 ++- 4 files changed, 85 insertions(+), 1561 deletions(-) delete mode 100644 docs/editable-pptx-reference-full-chain-plan.md delete mode 100644 docs/editable-pptx-reference-pipeline.md delete mode 100644 docs/generative-editable-live-verification.md diff --git a/docs/editable-pptx-reference-full-chain-plan.md b/docs/editable-pptx-reference-full-chain-plan.md deleted file mode 100644 index 89d0500..0000000 --- a/docs/editable-pptx-reference-full-chain-plan.md +++ /dev/null @@ -1,573 +0,0 @@ -# 图片转可编辑 PPTX 全链路方案 - -本文基于本地代码重新梳理两个参考项目: - -- `/Users/lzj/proj/notebook/slide-alchemy` -- `/Users/lzj/proj/notebook/image-to-editable-ppt-skill` - -目标不是直接把某个 skill 塞进 AIPPT,而是提炼出可在 AIPPT 内部实现、可测试、可验收的图片转可编辑 PPTX 方案。 - -## 1. 结论 - -建议 AIPPT 采用 `image-to-editable-ppt-skill` 的工程骨架和验收契约,吸收 `slide-alchemy` 的轻量分层思路与 asset sheet 生成/切分策略。 - -具体判断: - -- `image-to-editable-ppt-skill` 更适合作为主参考:它有完整 CLI 状态机、页面级 manifest、OCR text hints、asset provenance、PPTX 结构校验、禁止整页截图叠文本等硬规则。 -- `slide-alchemy` 更适合作为 prompt/workflow 参考:它的 clean base、元素分类、PNG asset sheet、composition 顺序很清楚,但整体更依赖 agent 执行纪律,确定性状态机较弱。 -- AIPPT 不应依赖 Codex skill 运行时或 page worker 作为产品能力。应把参考项目里的确定性逻辑改造成 AIPPT 内部模块,把模型调用通过现有 `config.yaml` 的 `prompt_model/image_model/edit_model/ocr_model` 接入。 -- 当前没有 Qwen layered model 时,纯模型路径本质是:OCR + 视觉理解/结构分析 + 图像编辑生成 clean base/asset sheet + 本地切图 + PPTX 原生对象重建。 - -## 2. slide-alchemy 是如何做的 - -代码入口和关键文件: - -- `skill/slide-alchemy/SKILL.md` -- `skill/slide-alchemy/references/workflow.md` -- `skill/slide-alchemy/references/element-classification.md` -- `skill/slide-alchemy/references/icon-slicing-qa.md` -- `skill/slide-alchemy/references/text-extraction.md` -- `skill/slide-alchemy/references/compose-spec.md` -- `skill/slide-alchemy/scripts/compose_component_pptx.py` -- `skill/slide-alchemy/scripts/slice_asset_sheet.py` - -### 2.1 全链路 - -slide-alchemy 的固定顺序是: - -1. 渲染或取得每页源图。 -2. 做 base grouping,判断哪些页共享底图。 -3. 调用图像编辑/生成模型生成 clean base,去掉文字、图标、卡片、中心内容,保留背景主题。 -4. 用视觉模型分析非文本视觉元素,产出 `element_analysis.json`。 -5. 分类元素: - - `simple_geometry_svg_ooxml`:线条、矩形、圆角矩形、圆、星形、卡片底、标题条等结构几何。 - - `icon_png`:语义图标、设备、人物、建筑、badge 等。 - - `complex_png_whole`:复杂组合视觉、强渐变/发光/阴影装饰。 -6. 对 PNG 类元素调用图像编辑/生成模型生成 asset sheet,要求高对比纯色背景和大间距。 -7. 本地脚本对 asset sheet 做 chroma key/裁切,生成透明 PNG 资产和 contact sheet。 -8. 用视觉模型提取文本布局,产出 `texts_layout.json`。 -9. 用 `compose_component_pptx.py` 组合 PPTX,层级顺序固定为: - - clean base PNG - - editable geometry - - PNG assets - - editable text boxes -10. 导出 preview,做视觉 QA。 - -### 2.2 模型调用点 - -slide-alchemy 明确要求图像模型参与这些环节: - -- clean base generation/editing。 -- icon PNG asset sheet generation/editing。 -- complex PNG whole asset generation/editing。 -- 需要重生成的视觉资产。 - -文本提取默认依赖视觉模型。它没有把 OCR 作为强状态机入口,但 AIPPT 可以替换为 OCR 优先、视觉模型补漏。 - -### 2.3 PPTX 是如何组装的 - -`compose_component_pptx.py` 使用 `python-pptx`: - -- `base` 用 `slide.shapes.add_picture(...)` 铺满整页。 -- `geometry` 用 `add_shape(...)` 转为 PPT 原生形状,支持 `rect/round_rect/oval/star/line`。 -- `images` 用 `add_picture(...)` 按 bbox 放置透明 PNG。 -- `texts` 用 `add_textbox(...)` 写入 PPT 原生文本框。 - -因此 slide-alchemy 中的线框、卡片、分割线不是图像模型“生成成 PPT 线条”。实际过程是:模型/agent 识别出几何元素的位置、颜色和类型,确定性 composer 再创建 PPT 原生 shape。 - -## 3. image-to-editable-ppt-skill 是如何做的 - -代码入口和关键文件: - -- `skills/image-to-editable-ppt/SKILL.md` -- `skills/image-to-editable-ppt/references/page-decision-tree.md` -- `skills/image-to-editable-ppt/references/manifest-schema.md` -- `skills/image-to-editable-ppt/references/cli-helper.md` -- `skills/image-to-editable-ppt/cli/editppt/runtime/main.py` -- `skills/image-to-editable-ppt/cli/editppt/runtime/build_pptx_from_manifest.py` -- `skills/image-to-editable-ppt/cli/editppt/runtime/validate_pptx.py` -- `skills/image-to-editable-ppt/cli/editppt/runtime/image_gen.py` -- `skills/image-to-editable-ppt/cli/editppt/runtime/process_asset_sheet.py` -- `skills/image-to-editable-ppt/cli/editppt/runtime/split_alpha_components.py` -- `skills/image-to-editable-ppt/cli/editppt/runtime/paddle_text_hints.py` - -### 3.1 全链路 - -image-to-editable-ppt-skill 的固定流程是: - -1. `editppt prepare ` - - 把图片、PDF、图片型 PPTX 归一化成逐页 `pages/page_NNN/source.png`。 - - 写入 `deck_manifest.json`、`page_jobs.json`、`page_request.json`。 - - 生成 `text_hints.json/text_hints.png`。 -2. OCR/text hints - - 有 PaddleOCR token 时走 PaddleOCR-VL。 - - 没有 token 时退化为本地 ink geometry detector,但这只测位置和大小,不识别内容。 -3. `editppt run next` - - 单页走 local page reconstructor。 - - 多页分发 page worker。 -4. 每页按 `page-decision-tree.md` 重建: - - 先判断背景是否需要 image edit repair。 - - 再做 foreground asset separation。 - - 最后重建文本、形状、公式等 PPT native elements。 -5. 图像模型调用: - - `editppt image edit --image source.png` 做 clean base。 - - `editppt image edit --image source.png` 做 source-faithful asset sheet。 - - `editppt image generate` 只用于不需要严格保留源图对象的新支持图。 -6. asset sheet 处理: - - `process_asset_sheet.py` 先调用 chroma helper 去背景。 - - `split_alpha_components.py` 用 alpha 连通域拆分透明 PNG 资产。 - - 产出 split manifest/contact sheet。 -7. 页面 reconstructor 写 `manifest.json`。 -8. `editppt page build` 或 `run record` 用 manifest 构建 `page.pptx`、preview、validation。 -9. `editppt run finalize` 按页读取已 record 的 manifest,生成最终 `.pptx`。 - -### 3.2 manifest 是核心契约 - -`manifest-schema.md` 要求页面 manifest 至少包含: - -- `slide` -- `content_box` -- `source` -- `text_inventory` -- `visual_inventory` -- `background_strategy` -- `quality_checks` -- `text_boxes` -- `shapes` -- `images` -- `asset_provenance` - -所有对象坐标必须是源图像素坐标: - -- 文本和图片:`box_px: [x, y, width, height]` -- 线条:`points_px: [x1, y1, x2, y2]` - -这点对 AIPPT 很关键:模型只需要输出源图坐标,确定性 builder 负责映射到 PPT canvas/content box。 - -### 3.3 PPTX 是如何组装的 - -`build_pptx_from_manifest.py` 不主要依赖 `python-pptx`,而是直接写 OOXML zip 包: - -- `normalize_manifest()` 把源图像素坐标映射到 slide inch/EMU 坐标。 -- `text_box_xml()` 生成 PPT 原生 text box。 -- `shape_xml()` 生成 PPT 原生 shape,支持 line、rect、roundRect、ellipse、custom polygon 等。 -- `image_xml()` 生成图片对象。 -- `slide_xml()` 按 `z_index` 排序写入对象。 -- `write_deck()` 按页面 manifest 生成最终多页 PPTX。 - -这个 builder 比 slide-alchemy 的 `python-pptx` composer 更适合作为 AIPPT 的长期基础,因为它能控制: - -- roundRect radius。 -- measured text fitting。 -- z-index。 -- notes。 -- 直接结构校验。 -- 不依赖 PowerPoint/WPS 交互。 - -### 3.4 校验机制 - -`validate_pptx.py` 做了几类关键检查: - -- PPTX zip/package 是否有效。 -- manifest 是否缺坐标。 -- text/images/shapes 是否有可定位对象。 -- `asset_provenance.source_type` 是否在允许列表内。 -- 是否存在 full-slide `source.png` + editable text overlay 的假可编辑结果。 -- foreground visual object 是否违规使用 crop/fallback/approximation/emoji/native approximation。 -- `quality_checks` 是否完整。 -- roundRect 是否记录真实 source corner radius。 - -这是 AIPPT 当前必须补齐的部分。否则“能打开 PPTX”会误判为“转换成功”。 - -## 4. 两个方案的关键差异 - -| 维度 | slide-alchemy | image-to-editable-ppt-skill | AIPPT 建议 | -| --- | --- | --- | --- | -| 主体形态 | Codex skill + 线性流程 | Codex skill + editppt CLI 状态机 | AIPPT 内部服务模块 | -| 输入归一化 | 依赖 workflow 约定 | `editppt prepare` 强制生成 run/page 结构 | 实现 AIPPT job/page artifact | -| OCR | 视觉文本提取为主 | PaddleOCR-VL hints 优先 | OCR provider 优先,视觉模型补漏 | -| 图像分离 | clean base + asset sheet | clean base + source-faithful asset sheet | 采用 asset sheet,不允许 source crop fallback | -| PPTX 生成 | `python-pptx` composer | 直接 OOXML builder | 优先参考 OOXML builder | -| 校验 | preview/diff 为主 | manifest/provenance/package 结构 gate | 两者都要:结构 gate + preview/diff | -| 多页并发 | clean base 后可分发 subagent | page worker 状态机 | 后端 worker/async queue,不依赖 Codex subagent | - -## 5. AIPPT 落地全链路 - -### 5.1 输入与任务目录 - -新增或改造 AIPPT 的 editable PPTX job 目录: - -```text -run/ - deck_manifest.json - provider_logs.jsonl - pages/ - page_001/ - source.png - page_request.json - ocr_hints.json - ocr_hints_overlay.png - page_analysis.json - clean_base.png - asset_sheet_001.png - asset_sheet_001_alpha.png - split_assets.json - assets/ - manifest.json - page.pptx - preview.png - validation.json - final/ - editable.pptx - report.json -``` - -输入可以先只支持图片列表和现有图片版 PPTX 渲染后的逐页 PNG。后续再扩 PDF/PPTX 渲染。 - -### 5.2 Provider gate - -全链路前必须先跑 gate: - -1. config check:确认 `prompt_model/image_model/edit_model/ocr_model` 都有 `model/base_url/api_key`。 -2. OCR JSON contract:必须返回可解析结构化 OCR 结果。 -3. image edit clean-base probe。 -4. image edit asset-sheet probe。 -5. image generation probe:只作为可选支持,不作为 foreground separation 主路径。 - -如果 OCR 不是结构化 JSON,直接 fail fast。不能静默 fallback 到假 OCR 或本地临时 OCR。 - -### 5.3 OCR 与文本 hints - -AIPPT 应把 OCR 结果标准化成类似 image-to-editable-ppt-skill 的 hints: - -```json -{ - "backend": "configured-ocr-model", - "source": {"width_px": 1672, "height_px": 941}, - "lines": [ - { - "id": "T01", - "text": "核心架构设计:增程、底盘与智能域", - "box_px": [360, 70, 950, 70], - "font_pt": 32, - "font_pt_if_cjk": 32, - "size_group": "title" - } - ] -} -``` - -文本重建规则: - -- 主标题、正文、数字、标签默认生成 PPT 原生文本框。 -- OCR 漏字、错字必须进入 page failure 或 human review,不允许默默生成错字。 -- 同级文本统一字号,避免同一层级字体忽大忽小。 -- 字体名称不强求完全一致,优先保证文字内容、位置、字号、粗细、颜色接近;可默认 `PingFang SC` 或项目配置字体。 - -### 5.4 页面分析 - -调用 `prompt_model` 或视觉理解模型,输入: - -- `source.png` -- `ocr_hints.json` -- 可选 `ocr_hints_overlay.png` - -输出 `page_analysis.json`: - -- background strategy。 -- visual inventory。 -- foreground asset list。 -- native shape candidates。 -- text mapping:OCR line -> text box。 -- asset sheet prompt plan。 - -重点:模型只负责“识别、分类、规划、给坐标”,不直接生成 PPTX。 - -### 5.5 背景处理 - -根据 analysis: - -- 简单纯色/渐变/规则背景:用 PPT native background 或本地生成背景。 -- 复杂背景但无前景遮挡:可保留局部/全底图作为 clean base,但不能包含后续会重建的文字和前景对象。 -- 有遮挡/文字/图标污染的复杂背景:调用 `edit_model` 对 `source.png` 生成 clean base。 - -clean base prompt 必须约束: - -- 保持原图 composition、perspective、object positions、colors、lighting。 -- 只移除将被重建的文字、图标、标签、前景视觉元素。 -- 禁止新对象、新布局、伪文字、模糊补丁、水印。 - -### 5.6 前景图片资产分离 - -采用 image-to-editable-ppt-skill 的强规则: - -- 所有非文本 foreground visual objects 必须通过 image edit asset-sheet workflow 分离。 -- 不允许直接从 source.png 裁剪后当作资产。 -- 不允许把语义 icon 用 native shape 近似替代。 -- 不允许失败后降级成 warning 并继续交付。 - -流程: - -1. 根据 `visual_inventory` 生成 asset sheet prompt。 -2. 调用 `edit_model`: - -```text -Input: source.png -Task: separate exact existing foreground visual objects into a sparse chroma-key asset sheet. -Preserve shape, stroke, color, proportions, texture, shadow, internal spacing. -No text, no watermarks, no replacements, no simplified icons. -``` - -3. 本地去 chroma/alpha。 -4. 用连通域切分资产。 -5. 生成 contact sheet。 -6. asset count/name/order 与 `visual_inventory` reconciliation。 -7. 资产 provenance 标记为 `asset-sheet-separated`。 - -### 5.7 原生形状重建 - -简单几何转 PPT 原生对象: - -- 线条、虚线、分割线。 -- 矩形、圆角矩形、圆、椭圆。 -- 卡片、容器、表格线、普通箭头、流程框。 - -这些对象不需要图像模型重新生成。模型只输出: - -```json -{ - "type": "roundRect", - "box_px": [120, 200, 460, 160], - "fill": "#102A44", - "stroke": "#1677FF", - "stroke_width": 1.2, - "source_corner_radius_px": 12, - "z_index": 20 -} -``` - -然后由 AIPPT builder 生成 PPT 原生 `AUTO_SHAPE/LINE`。 - -### 5.8 页面 manifest - -AIPPT 的 page manifest 应收敛到 image-to-editable-ppt-skill 风格,但字段可按现有 `generative_editable_manifest.py` 适配: - -```json -{ - "slide": {"width": 13.333, "height": 7.5}, - "source": {"width_px": 1672, "height_px": 941, "path": "source.png"}, - "background_strategy": {}, - "text_inventory": [], - "visual_inventory": [], - "quality_checks": {}, - "images": [], - "shapes": [], - "text_boxes": [], - "asset_provenance": [] -} -``` - -硬性要求: - -- 所有 positioned object 必须有 `box_px` 或 `points_px`。 -- 所有 `images[]` 必须有 `asset_provenance`。 -- forbidden provenance:`source_crop`、`fallback`、`approximation`、`direct source crop`。 -- full-slide `source.png` + editable text overlay 必须 fail。 - -### 5.9 PPTX builder - -建议优先迁移/改造 image-to-editable-ppt-skill 的 OOXML builder 思路,而不是继续扩大 `python-pptx` composer: - -- 直接写 OOXML 能更精确控制 z-index、roundRect radius、text body、notes 和 package relationships。 -- WPS/PowerPoint 都能看到对象结构。 -- 可以通过解析 `ppt/slides/slideN.xml` 直接统计 `p:sp`、`p:pic`、`p:txBody`,判断是否真实可编辑。 - -组装层级: - -1. clean base/background。 -2. native shapes。 -3. separated bitmap assets。 -4. native text boxes。 -5. 必要时覆盖层,例如标注/手绘圈。 - -### 5.10 验收与重试 - -每页必须生成结构化 validation: - -- PPTX zip/package 可打开。 -- slide count 正确。 -- 每页不允许只有 1 张 full-slide picture。 -- 每页 text coverage 达标,OCR 失败必须明确记录。 -- simple geometry 尽量是 native shape。 -- foreground visual image 必须来自 asset-sheet-separated。 -- preview 与 source 无明显错位、漏块、重复文字、脏背景。 -- 所有 provider 错误脱敏记录。 - -重试策略: - -- OCR JSON 不合格:调整 OCR prompt/adapter 后重试,仍失败则 fail fast。 -- clean base 有残字/鬼影:重试 clean base。 -- asset sheet 数量不对/切坏/粘连:重试 asset sheet,换 chroma color 或拆小批次。 -- 文本错字:重新 OCR 或视觉模型校正,不能用猜测覆盖。 -- PPTX 结构异常:先修 builder/manifest validator,不能靠人工打开后判断。 - -## 6. 模型用量预估 - -单页典型调用: - -- OCR:1 次。 -- 页面分析/manifest draft:1 次。 -- clean base edit:0-1 次,复杂页通常 1 次。 -- foreground asset sheet edit:1-N 次,复杂页可能 2-4 次。 -- repair/retry:按 QA 结果触发。 - -所以成本确实会高于图片版 PPT 生成。合理预估: - -- 简单页:约 2-3 次模型调用。 -- 中等页:约 4-6 次模型调用。 -- 复杂汽车架构/信息图页:约 6+ 次模型调用,主要花在 clean base、asset sheet 和重试。 - -优化方向: - -- 多页共享 clean base。 -- 一个 asset sheet 承载多个前景对象。 -- OCR 批量化。 -- 只对复杂背景调用 clean base edit。 -- native geometry 不走图像模型。 - -## 7. AIPPT 开发拆分建议 - -### 阶段 1:结构 gate 先修好 - -先保证不能再出现“整张图拖动但报告 passed”的情况。 - -任务: - -- PPTX object inspector:解析 slide XML,统计 full-slide picture、non-full picture、native shape、text box。 -- manifest provenance validator:禁止 source crop/fallback。 -- preview validation report:报告对象统计和失败原因。 -- 测试:给一个只有整页图的 PPTX,必须失败。 - -### 阶段 2:OCR hints 接入 - -任务: - -- 用当前 `ocr_model` 生成结构化 text hints。 -- 标准化 OCR 输出。 -- 将 text hints 注入 page analysis prompt。 -- 测试:非 JSON OCR 响应必须失败;真实 OCR canary 必须产出 text boxes。 - -### 阶段 3:asset sheet 分离 - -任务: - -- asset sheet prompt builder。 -- image edit provider 调用。 -- chroma/alpha 去背景。 -- connected components 切分。 -- contact sheet。 -- asset provenance。 -- 测试:asset sheet 尺寸和源图 bbox 不一致时,仍按 sheet 连通域切图,不允许拿源图 bbox 去切 asset sheet。 - -### 阶段 4:manifest -> PPTX builder - -任务: - -- 将 native text/shape/image 映射成 PPTX。 -- 优先增强现有 composer,或迁移 OOXML builder。 -- 支持 roundRect radius、z-index、measured text fitting。 -- 测试:解析生成 PPTX,确认 shape/text/picture 数量和 manifest 一致。 - -### 阶段 5:真实 1/2/6 页验收 - -任务: - -- 先 slide 3 canary。 -- 再 2 页混合。 -- 最后 6 页 replay-assets。 -- 每轮输出 report、preview、diff、object stats、provider logs。 -- 根据实测迭代 prompt、切图、builder、validator。 - -## 8. 不能接受的降级 - -这些情况应直接 fail,不应标记 passed: - -- 最终页只有整页图片。 -- 整页 source 图片作为底图,上面叠 OCR 文本。 -- foreground asset 直接从 source.png 裁剪。 -- OCR 明显错字、漏主要标题或正文。 -- asset sheet 失败后回退 source crop。 -- 语义图标被 native shape 粗糙近似。 -- preview 有明显残字、重影、漏块、错位。 - -## 9. 推荐最终方案 - -最终方案: - -1. 主流程采用 image-to-editable-ppt-skill 的状态机思想、manifest schema、PPTX OOXML builder、validator/provenance gate。 -2. clean base、元素分类、asset sheet prompt 采用 slide-alchemy 的轻量工作流和分类规则。 -3. AIPPT 内部实现,不依赖 Codex skill、`editppt` 命令或 page worker;可以借鉴代码结构,但要改造成服务端模块和测试。 -4. OCR 使用 AIPPT 配置中的真实 `ocr_model`,不要保留 `.env` fallback。 -5. 图像资产分离在没有 Qwen layered model 时使用 image edit asset sheet;后续有 layered model 后,可以把 `foreground asset separation` 这一层替换为 layered decomposition,但 manifest、builder、validator 不需要推倒重来。 - -## 10. AIPPT 当前问题根因与修复结论 - -基于真实 `output/replay-assets/slide_1.png` 到 `slide_6.png` 回归,旧问题不是模型完全不可用,而是 AIPPT 自己的重建链路存在三个问题: - -1. OCR 结果被过度过滤。 - - page 5 的 OCR provider 实际返回过 22 项文本。 - - 旧过滤逻辑只保留 4 项,导致背景清掉文本后没有足够 text box 重建。 - - 修复后会保留尺寸和文本量足够可信的 approximate OCR,并记录 warning。 - - focused OCR recovery 还会把已经被大标题覆盖的局部候选恢复成重复文本,例如 `落地建议:`;当前已增加重复片段抑制,并把该 visual candidate 标记为 non-blocking。 - -2. source-preserving fast path 错误地清理所有粗糙 OCR 框。 - - 对 approximate OCR 框直接清底再重绘,会把视觉差异从约 9.9% 拉到 15.3%。 - - 当前修复为保真优先:复杂/粗糙 OCR 页使用源视觉背景,叠加低透明度原生 text box,并明确标记 `source_preserving_low_opacity_text_overlay` warning。 - - 该模式不能标记为普通 `passed`,runner 必须返回 `degraded`,避免把“整页源图 + 低透明文本层”误报为完整元素分离成功。 - - 这是降级策略,不等同于高质量元素分层;它解决“文字 OCR 没做/只有整图”的问题,但复杂视觉仍保留在背景图里。 - -3. runner 报告把 warning 当失败,且没有暴露 OCR 全过滤情况。 - - 修复后只有 `severity=error` 的 reconstruction issue 才使 run failed。 - - 当 OCR 返回内容但全部被过滤为幻觉/噪声时,报告 `no_editable_text_after_ocr_filtering` warning,避免 page 6 这种情况静默通过。 - -## 11. 真实回归结果 - -最近一次 6 页真实回归: - -```bash -python scripts/run_real_generative_editable_pptx.py run \ - --isolate-pages \ - --page-wall-timeout 300 \ - --input-glob 'output/replay-assets/slide_[0-9]*.png' \ - --slides 6 \ - --output-dir /private/tmp/aippt-real-replay6-lowopacity-timeout300-20260704-071603 \ - --job-id replay6-lowopacity-timeout300 \ - --provider-timeout 90 -``` - -按当前状态语义复算,结果应为 `status=degraded`:没有 error 级失败,但 page 2/3/5 使用低透明 OCR overlay,page 6 有 OCR 全过滤 warning。 - -每页摘要: - -| Page | PPTX objects | Preview changed ratio | 说明 | -| --- | --- | ---: | --- | -| 1 | `AUTO_SHAPE=15`, `LINE=11`, `PICTURE=1`, `TEXT_BOX=2` | `0.054813` | 原生形状/线条拆分有效 | -| 2 | `PICTURE=1`, `TEXT_BOX=10` | `0.041215` | 低透明 OCR overlay 降级 | -| 3 | `PICTURE=1`, `TEXT_BOX=11` | `0.047467` | 低透明 OCR overlay 降级 | -| 4 | `PICTURE=3`, `TEXT_BOX=9` | `0.059219` | 多图片对象 + 文本框 | -| 5 | `PICTURE=1`, `TEXT_BOX=22` | `0.079666` | 低透明 OCR overlay 降级,接近阈值 | -| 6 | `AUTO_SHAPE=21`, `LINE=6`, `PICTURE=1`, `TEXT_BOX=0` | `0.049679` | OCR 输出为幻觉/重复文本,报告 warning | - -注意: - -- `--page-wall-timeout 180` 下 page 4 会超时;实际 stage 显示 text clean 和 base clean 两次 image edit 已占约 143s,加上 OCR 约 30s 后剩余时间不足。 -- `--page-wall-timeout 300` 可以通过 6 页全量真实回归。 -- 当前效果已经不再是“每页只有整张图”。但 page 2/3/5 属于明确降级,不是完整视觉元素分离;下一步要继续迁移参考项目里的 asset sheet/page-decision-tree,减少 full-slide background 占比。 - -后续 page 5 局部修复: - -- 去掉 focused OCR 重复恢复后,page 5 为 `TEXT_BOX=11`。 -- 真实 canary:`/private/tmp/aippt-real-page5-dedupe-nonblocking-20260704-075418`。 -- 结果:`status=degraded`,preview changed ratio `0.075914`,无 `visual_text_candidate_missing_ocr_text`。 -- 进一步补齐同轮 focused OCR recovery 的去重:两个重叠 visual candidates 如果恢复出同一文本,只保留第一个,后者标为 non-blocking。 -- 修复非 isolated 多页 runner warning 聚合:`warning_pages` 优先使用 issue 自带页码,避免多页非 isolated 模式全部误报为 page 1。 diff --git a/docs/editable-pptx-reference-pipeline.md b/docs/editable-pptx-reference-pipeline.md deleted file mode 100644 index 3a1ea91..0000000 --- a/docs/editable-pptx-reference-pipeline.md +++ /dev/null @@ -1,894 +0,0 @@ -# slide-alchemy 与 image-to-editable-ppt-skill 转 PPT 全链路溯源 - -项目: - -- `slide-alchemy` -- `image-to-editable-ppt-skill` - ---- - -## 1. slide-alchemy 全链路 - -### 1.1 项目形态 - -`slide-alchemy` 是一个 Codex skill 风格项目,不是一个标准 Python package。它的核心在: - -- workflow 文档约束每一步必须做什么; -- prompt 模板约束模型如何生成 clean base 和 asset sheet; -- Python scripts 负责切图、校验、组合 PPTX。 - -关键文件: - -- `slide-alchemy/skill/slide-alchemy/SKILL.md` -- `slide-alchemy/skill/slide-alchemy/references/workflow.md` -- `slide-alchemy/skill/slide-alchemy/references/element-classification.md` -- `slide-alchemy/skill/slide-alchemy/references/base-prompt-template.md` -- `slide-alchemy/skill/slide-alchemy/references/icon-sheet-prompt-template.md` -- `slide-alchemy/skill/slide-alchemy/references/compose-spec.md` -- `slide-alchemy/skill/slide-alchemy/scripts/slice_asset_sheet.py` -- `slide-alchemy/skill/slide-alchemy/scripts/compose_component_pptx.py` - -### 1.2 总流程 - -`slide-alchemy` 明确要求完整流程,不允许直接把源图塞进 PPT: - -```text -输入 PPT/PDF/截图/图片 - -> 渲染为 source/slide_001.png - -> base grouping - -> 图像模型生成 clean base - -> element_analysis.json - -> 简单几何转 SVG/OOXML 或 PPT shape - -> 图像模型生成 PNG asset sheet - -> 切分 asset sheet 为透明 PNG - -> OCR/视觉模型提取文本布局 - -> compose_spec.json - -> compose_component_pptx.py 组合 PPTX - -> 预览图和视觉 QA -``` - -### 1.3 目录产物 - -它期望的 run 目录大致是: - -```text -run/ - source/ - slide_001.png - base/ - slide_001_base.png - analysis/ - element_analysis.json - texts_layout.json - compose_spec.json - assets/ - svg/ - png/ - contact_sheets/ - out/ - editable.pptx - preview/ -``` - -### 1.4 第一步:source page rendering - -输入可以是图片、PPTX、PDF、扫描页。无论原始格式是什么,先得到每页的源图: - -```text -source/slide_001.png -source/slide_002.png -... -``` - -后续所有坐标都以 source image 的像素坐标为准。 - -### 1.5 第二步:base grouping - -先判断哪些页面共享同一类背景,例如: - -- 封面 base; -- 内容页 base; -- 结束页 base; -- 每页独立 base。 - -这是为了减少 clean base 生成次数,也避免每页背景风格飘。 - -`workflow.md` 要求默认在这里停一次给用户确认,除非用户明确要求 unattended/full automatic。 - -### 1.6 第三步:clean base 生成 - -clean base 是用图像编辑/生成模型生成的,不是本地抹除,也不是源图裁剪。 - -参考模板在: - -`slide-alchemy/skill/slide-alchemy/references/base-prompt-template.md` - -要求模型: - -- 保留背景氛围、边缘装饰、渐变、纹理、光效; -- 移除所有文字、页码、标签、图标、徽章、卡片、框、标题栏、图表、中央内容; -- 自然补全被移除区域; -- 不留下 ghost text、模糊块、水印、伪文字。 - -输出: - -```text -base/slide_001_base.png -``` - -这个 clean base 会作为最终 PPT 的底层全页图片。 - -### 1.7 第四步:元素分析 - -在生成前景资产前,先做 `element_analysis.json`。 - -它包含: - -- `components`:可复用组件定义; -- `instances`:每页每个组件的位置; -- `png_asset_sheet_plan`:哪些组件需要生成 PNG asset sheet。 - -分类规则在: - -`slide-alchemy/skill/slide-alchemy/references/element-classification.md` - -核心分类: - -| 分类 | 含义 | 处理方式 | -|---|---|---| -| `simple_geometry_svg_ooxml` | 线、分割线、圆、圆角矩形、卡片、边框等非语义布局几何 | 转 PPT 原生形状/OOXML | -| `icon_png` | 图标、徽章、设备、小插画、语义 pictogram | 用图像模型生成 PNG asset sheet,再切图 | -| `complex_png_whole` | 复杂插画、发光组合、复杂装饰、拆开会变差的视觉块 | 整体作为 PNG 资产 | - -重点:语义图标即使看起来由简单线条组成,也默认不转 PPT shape,而是走 PNG。因为目标是视觉保真,不是拆到每根线可编辑。 - -### 1.8 第五步:PNG asset sheet 生成 - -需要 PNG 的组件会通过图像模型生成 asset sheet。 - -模板在: - -`slide-alchemy/skill/slide-alchemy/references/icon-sheet-prompt-template.md` - -要求模型: - -- 以源图为视觉参考或编辑目标; -- 重新生成要拆出的 icon/complex visual; -- 用纯色 key-color 背景; -- 每个元素之间留足空白; -- 不包含普通文字、标签、网格线、卡片、框、标题栏; -- 不直接复制裁剪源图像素。 - -典型输出: - -```text -assets/sheets/domain_sheet.png -assets/sheets/hardware_sheet.png -``` - -### 1.9 第六步:asset sheet 切分 - -切图脚本: - -`slide-alchemy/skill/slide-alchemy/scripts/slice_asset_sheet.py` - -它做的事情: - -1. 读取 sheet PNG; -2. 根据 key color 把背景转透明; -3. 根据 JSON crop spec 裁剪每个资产; -4. 默认保留 padding; -5. 输出透明 PNG。 - -脚本核心输入: - -```text -sheet_png -crops_json: [{ "id": "...", "bbox": [x, y, w, h] }] -output_dir ---key-color ---pad -``` - -输出示例: - -```text -assets/png/smart_icon.png -assets/png/display_screen.png -assets/png/speaker_cluster.png -``` - -然后用 contact sheet 和 edge inspection 检查: - -- 是否切掉边缘; -- 是否混入邻近元素; -- 是否有 key color 残留; -- 是否缺失元素; -- 是否把简单线框误切进 PNG。 - -### 1.10 第七步:文本提取 - -文本不放进 PNG,普通可读文本要变成 PPT 原生 text box。 - -`slide-alchemy` 的 text extraction 目标是生成: - -```text -analysis/texts_layout.json -``` - -里面应包含: - -- 文本内容; -- bbox; -- 字号; -- 颜色; -- 粗细; -- 对齐; -- 换行; -- 近似字体。 - -它强调“visual extraction by default”,也就是主要看页面视觉结果,而不是只信 PPT XML 或简单 OCR。 - -### 1.11 第八步:compose spec - -组合规范在: - -`slide-alchemy/skill/slide-alchemy/references/compose-spec.md` - -最小结构: - -```json -{ - "ref_width": 1920, - "ref_height": 1080, - "slide_width_in": 13.333333, - "slide_height_in": 7.5, - "slides": [ - { - "base": "base/slide_001_base.png", - "geometry": [], - "images": [], - "texts": [] - } - ] -} -``` - -其中: - -- `base` 是 clean base; -- `geometry` 是可编辑几何; -- `images` 是切出来的透明 PNG; -- `texts` 是可编辑文本框。 - -### 1.12 第九步:PPTX 组合 - -组合脚本: - -`slide-alchemy/skill/slide-alchemy/scripts/compose_component_pptx.py` - -它使用 `python-pptx`。 - -图层顺序固定: - -```text -1. base PNG -2. geometry -3. images -4. texts -``` - -支持的 geometry: - -- `rect` -- `round_rect` -- `oval` -- `star` -- `line` - -注意:它的 `line` 实现不是 PowerPoint connector,而是用很薄的 rectangle 模拟线。圆角矩形、椭圆、星形等是 PPT native auto shape。 - -### 1.13 第十步:预览与 QA - -最后导出 preview,并用脚本比较: - -- `slide-alchemy/skill/slide-alchemy/scripts/compare_preview.py` -- `slide-alchemy/skill/slide-alchemy/scripts/build_contact_sheet.py` -- `slide-alchemy/skill/slide-alchemy/scripts/inspect_edges.py` - -检查点: - -- clean base 是否过度保留了原文字/元素; -- asset 是否缺失/切边/污染; -- 文本是否溢出; -- 元素是否重复出现; -- 预览是否明显偏移; -- 是否还存在整页截图伪装成可编辑。 - -### 1.14 slide-alchemy 的本质 - -`slide-alchemy` 的本质是: - -```text -图像模型生成干净背景 -+ 图像模型生成前景资产 sheet -+ 程序切成透明 PNG -+ 程序把简单几何转 PPT shape -+ OCR/视觉提取文本后转 PPT text box -+ python-pptx 按 compose spec 叠起来 -``` - -它不是图像分层模型。它的前景元素不是从原图数学分离出来的,而是通过图像编辑/生成模型“重建/再生成”出来,再切图使用。 - ---- - -## 2. image-to-editable-ppt-skill 全链路 - -### 2.1 项目形态 - -`image-to-editable-ppt-skill` 也是 skill,但它比 `slide-alchemy` 多了一个完整 CLI runtime:`editppt`。 - -它不是简单脚本拼 PPT,而是有: - -- run directory; -- page state machine; -- worker prompt; -- page manifest; -- validation; -- finalize; -- PPTX package builder。 - -关键文件: - -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/SKILL.md` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/prompts/page-worker.md` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/cli-helper.md` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/page-decision-tree.md` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/manifest-schema.md` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/prepare_deck_run.py` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/deck_run_state.py` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/image_gen.py` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/process_asset_sheet.py` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/build_pptx_from_manifest.py` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/validate_pptx.py` - -### 2.2 总流程 - -完整流程: - -```text -editppt prepare - -> run dir - -> deck_manifest.json - -> page_jobs.json - -> notes_manifest.json - -> pages/page_001/source.png - -> pages/page_001/page_request.json - -> pages/page_001/text_hints.json - -editppt run next - -> rebuild_page_locally 或 dispatch_pages - -page reconstructor - -> 读 page-decision-tree / manifest-schema / cli-helper - -> 背景识别与 clean base - -> 前景 asset sheet 分离 - -> native text / shapes / formulas - -> manifest.json - -> editppt page build - -> editppt page contact-sheet - -> editppt page validate - -> validation.json - -> page_result.json - -editppt run record - -> 校验 page 输出并记录 - -editppt run finalize - -> 从 page manifest 重建最终 deck - -> deck validation -``` - -### 2.3 prepare 阶段 - -命令: - -```bash -editppt prepare input.png -editppt prepare input.pdf -editppt prepare input1.png input2.png -``` - -实现文件: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/prepare_deck_run.py` - -它会: - -1. 标准化输入; -2. 生成 run 目录; -3. 为每页生成 `source.png`; -4. 计算 slide size; -5. 计算 `content_box`; -6. 写 `page_request.json`; -7. 写 `deck_manifest.json`; -8. 写 `page_jobs.json`; -9. 写 `notes_manifest.json`; -10. 初始化每页 `imagegen-jobs.json`。 - -典型目录: - -```text -run/ - deck_manifest.json - page_jobs.json - run_state.json - notes_manifest.json - input/ - pages/ - page_001/ - source.png - page_request.json - text_hints.json - text_hints.png - imagegen-jobs.json -``` - -`page_request.json` 是页面 worker 的任务边界,包含: - -- page id; -- source image; -- source size; -- slide size; -- content box; -- allowed write scope; -- required outputs; -- image backend contract。 - -### 2.4 page state machine - -实现文件: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/deck_run_state.py` - -核心状态: - -- `pending` -- `dispatched` -- `recorded` -- `accepted` -- `complete` - -`page_jobs.json` 是状态源,不允许手写跳状态。 - -关键原则: - -- `dispatched` 是活跃租约,不能因为慢就 reset; -- `record` 只能记录通过校验的 page; -- `finalize` 只从 recorded page manifests 重建最终 deck; -- failed page 需要明确 reset 后重新跑。 - -### 2.5 page worker 决策流程 - -模板: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/prompts/page-worker.md` - -worker 必须先完整读: - -- `page-decision-tree.md` -- `manifest-schema.md` -- `cli-helper.md` - -然后按固定顺序: - -```text -1. page inventory -2. background recognition and repair -3. foreground asset separation -4. native text / shapes / tables / formulas -5. manifest.json -6. editppt page build -7. editppt page contact-sheet -8. editppt page validate -9. validation.json + page_result.json -``` - -### 2.6 背景处理 - -规则文件: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/page-decision-tree.md` - -背景分三类: - -1. 不需要图像工具的背景 - 例如纯色、简单渐变、普通卡片、线、表格框等,直接用 PPT shape/script 重建。 - -2. 可复用背景区域 - 必须不包含要重建的文字/图标/前景,否则会重复。 - -3. 需要图像工具修复的背景 - 如果复杂背景被文字/图标/前景遮挡,就用: - -```bash -editppt image edit --image pages/page_001/source.png \ - --prompt-file clean-base.prompt.txt \ - --out pages/page_001/assets/clean-base.png -``` - -clean base 要求保留: - -- composition; -- perspective; -- object positions; -- colors; -- lighting; -- textures; -- background identity。 - -移除: - -- readable text; -- labels; -- icons; -- stickers; -- badges; -- hand-drawn marks; -- decorative objects that will be rebuilt。 - -### 2.7 前景资产分离 - -`image-to-editable-ppt-skill` 在这点比 `slide-alchemy` 更强硬: - -所有非文本前景视觉对象都必须走 asset-sheet workflow。 - -包括: - -- foreground photos; -- screenshots; -- icons; -- pictograms; -- logo-like marks; -- badges; -- stickers; -- hand-drawn marks; -- complex arrows; -- devices; -- illustrations。 - -禁止: - -- 用 native shape 近似语义图标; -- 用 emoji 或文本符号替代; -- 直接裁剪 source.png; -- 失败后降级成 warning。 - -命令形态: - -```bash -editppt image edit \ - --image pages/page_001/source.png \ - --prompt-file asset-sheet.prompt.txt \ - --out pages/page_001/assets/asset-sheet.png -``` - -prompt 要求: - -- separate existing objects from source; -- preserve original shapes, strokes, colors, proportions, internal spacing, texture, visual identity; -- flat chroma-key background; -- object count and order match visual inventory; -- no readable text; -- no full cards/panels/charts/page fragments; -- no redraw/beautify/synonym replacement。 - -### 2.8 图像后端实现 - -实现文件: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/image_gen.py` - -它有两条后端路径: - -1. Codex OAuth 优先: - -```text -~/.codex/auth.json -chatgpt.com/backend-api/codex/images/generations -chatgpt.com/backend-api/codex/images/edits -``` - -2. OpenAI-compatible API fallback: - -```text -OPENAI_API_KEY -OPENAI_BASE_URL -/v1/images/generations -/v1/images/edits -``` - -也就是说,`image-to-editable-ppt-skill` 默认不是调用 `chat/completions` 生图,而是走 Codex image endpoint 或 OpenAI Images API endpoint。 - -### 2.9 asset sheet 处理 - -命令: - -```bash -editppt image process-sheet pages/page_001 \ - --job-id icon-sheet \ - --asset-sheet-source assets/asset-sheet.png \ - --assets-dir assets/foreground \ - --asset-names smart_icon,display_screen,speaker_cluster -``` - -实现文件: - -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/process_asset_sheet.py` -- `image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/_page_artifacts.py` - -处理链: - -```text -asset-sheet.png - -> remove_chroma_key.py - -> imagegen_asset_sheet_alpha.png - -> split_alpha_components.py - -> assets/foreground/*.png - -> split_assets.json -``` - -它支持: - -- 自动从边缘采样 key color; -- soft matte; -- despill; -- connected component splitting; -- min area; -- merge gap; -- square asset padding; -- asset names。 - -### 2.10 文本与 text hints - -`editppt prepare` 会生成: - -```text -text_hints.json -text_hints.png -``` - -`page-decision-tree.md` 要求: - -- 普通可读文本默认变成 native PPT text boxes; -- 不能用隐藏文字、透明文字、1pt 字来冒充; -- 不能把主标题、正文、表格、图例、数字等留在图片里; -- text box 使用 source-pixel `box_px`; -- 字号优先从 text hints 取; -- 同层级文本字号保持一致; -- 公式另走 LaTeX rendering,不用普通文本框硬拼。 - -### 2.11 manifest.json - -规则文件: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/references/manifest-schema.md` - -`manifest.json` 是页面构建的唯一权威来源。 - -必须包含: - -```json -{ - "slide": {}, - "content_box": {}, - "source": {}, - "text_inventory": [], - "visual_inventory": [], - "background_strategy": {}, - "quality_checks": {}, - "text_boxes": [], - "shapes": [], - "images": [], - "asset_provenance": [] -} -``` - -坐标规则: - -- `text_boxes[].box_px` -- `images[].box_px` -- 非 line 的 `shapes[].box_px` -- line 的 `shapes[].points_px` - -所有坐标都是 `source.png` 像素坐标,runtime 再映射到 `content_box`。 - -`asset_provenance` 对每个图片资产说明来源: - -- `asset-sheet-separated` -- `imagegen` -- `latex-rendered-formula` -- `user-provided` -- `user-approved-rasterization` - -前景视觉对象不允许出现 crop、fallback、approximation 等来源描述。 - -### 2.12 page build - -命令: - -```bash -editppt page build pages/page_001 -``` - -实现文件: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/build_pptx_from_manifest.py` - -它不是简单调用 `python-pptx`,而是直接写 PPTX zip 包里的 XML。 - -它做的事: - -- 将 `box_px` / `points_px` 映射到 slide inch/EMU; -- 写 `ppt/slides/slide1.xml`; -- 写 relationships; -- 写 media; -- 写 content types; -- 写 theme/layout/master; -- 写 notes; -- 支持 page-level PPTX 和 final deck。 - -支持对象: - -- images; -- text boxes; -- lines; -- rectangles; -- rounded rectangles; -- ellipses; -- polygons; -- SVG/image media; -- z-index layering。 - -图层排序: - -```text -shapes/images/text_boxes 根据 z_index 排序 -``` - -与 `slide-alchemy` 不同,它不是只靠 `python-pptx` API,而是更底层地控制 OOXML。 - -### 2.13 page validate - -命令: - -```bash -editppt page validate pages/page_001 -``` - -校验文件: - -`image-to-editable-ppt-skill/skills/image-to-editable-ppt/cli/editppt/runtime/validate_pptx.py` - -它检查: - -- PPTX zip 是否正常; -- slide 数量; -- media 数量; -- relationship targets; -- required text 是否出现在 PPTX; -- manifest images 是否都进了 media; -- media hash 是否匹配; -- asset provenance 是否完整; -- positioned objects 是否有坐标; -- full-slide `source.png` + editable text 是否违规; -- foreground asset 是否用了 crop/fallback/approximation; -- quality checks 是否都为 true。 - -这是它比 `slide-alchemy` 更工程化的地方。 - -### 2.14 page_result 与 record - -page reconstructor 需要输出: - -```text -manifest.json -imagegen-jobs.json -page.pptx -preview.png -split_assets_contact.png -validation.json -page_result.json -``` - -`validation.json` 必须有顶层: - -```json -{ "passed": true } -``` - -然后 parent 跑: - -```bash -editppt run record --page page_001 --agent-id -``` - -record 会再次校验 page.pptx 和 manifest。失败就不能进入 final assembly。 - -### 2.15 finalize - -命令: - -```bash -editppt run finalize -``` - -finalize 不拿 page.pptx 拼接,而是重新读取每页 `manifest.json`,从 manifest 重新构建最终 deck。 - -最终输出: - -```text -final/_edited.pptx -``` - -final deck validation 会检查: - -- slide count; -- notes; -- media relationships; -- media hashes; -- page validations; -- full-slide source raster + editable text 违规模式。 - -### 2.16 image-to-editable-ppt-skill 的本质 - -它的本质是: - -```text -editppt prepare 建 run/page 状态 -+ page worker 按三阶段决策 -+ 图像后端生成 clean base -+ 图像后端生成 foreground asset sheet -+ runtime 切透明 PNG -+ OCR/text hints 生成 native text boxes -+ native shapes 重建结构几何 -+ manifest 作为唯一构建源 -+ build_pptx_from_manifest 写 OOXML PPTX -+ validate_pptx 严格拒绝伪可编辑结果 -+ finalize 从 manifest 重建最终 deck -``` - -它同样不是图像分层模型。前景资产仍然来自图像编辑/生成模型生成的 asset sheet,然后程序切分。它比 `slide-alchemy` 多的不是“更先进的视觉模型”,而是更完整的状态机、manifest schema、PPTX builder 和 validation contract。 - ---- - -## 3. 两者核心差异 - -| 项目 | 主要特点 | PPTX 构建方式 | 图像模型用途 | 强项 | 弱项 | -|---|---|---|---|---|---| -| `slide-alchemy` | 轻量 workflow + scripts | `python-pptx` 读 compose spec | clean base、asset sheet | 直观、prompt/workflow 清晰、容易理解 | 缺少强状态机和严格 deck validation | -| `image-to-editable-ppt-skill` | `editppt` runtime + state machine | 直接写 PPTX OOXML package | clean base、asset sheet、可选 generation | 状态、manifest、validation、finalize 很完整 | 依赖 skill/CLI 运行形态,默认图像后端优先 Codex OAuth | - -## 4. 两者共同点 - -它们共同的真实路线都是: - -```text -不是直接图像分层 -不是把原图裁剪成元素 -不是 OCRPDF 那种原图背景叠文字 - -而是: -图像编辑/生成模型生成 clean base -+ 图像编辑/生成模型生成前景 asset sheet -+ 程序切出透明 PNG -+ 程序生成 native text / native shapes -+ 按坐标叠成 PPTX -``` - -因此,页面里可编辑的粒度是: - -- 文本:PPT 原生 text box,可改字; -- 简单结构:PPT 原生 shape,可改线条、颜色、尺寸; -- 复杂视觉/图标/产品图:透明 PNG,可移动、缩放、裁剪、隐藏,但不能改内部线条; -- clean base:全页背景图,不可编辑内部元素。 diff --git a/docs/generative-editable-live-verification.md b/docs/generative-editable-live-verification.md deleted file mode 100644 index 23afd75..0000000 --- a/docs/generative-editable-live-verification.md +++ /dev/null @@ -1,48 +0,0 @@ -# Generative Editable PPTX Live Verification - -This file records non-secret live verification results for the generative editable PPTX export path. Do not include API keys, bearer tokens, request headers, or private provider payloads. - -## 2026-07-01 - -Environment: -- Local `config.yaml` contains OCR, image edit, and image generation provider settings. -- `scripts/smoke_generative_editable_pptx.py --config-check-only` reads all required roles and prints redacted diagnostics. -- `soffice`, `pdftoppm`, and local `tesseract` are available. - -Commands: - -```bash -/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --config-check-only -/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --provider-check image_edit --output-dir /private/tmp/aippt-provider-image-edit-live-check -/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --provider-check image_generation --output-dir /private/tmp/aippt-provider-image-generation-live-check -/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --slides one --output-dir /private/tmp/aippt-generative-editable-smoke-one -/Users/lzj/miniconda3/bin/python scripts/smoke_generative_editable_pptx.py --slides multi --output-dir /private/tmp/aippt-generative-editable-smoke-multi -``` - -Results: -- Configuration check passed with redacted provider diagnostics. -- Minimal `image_edit` provider check passed and produced an output image. -- Minimal `image_generation` provider check passed after sending the fixture source slide as a visual reference. -- Strict one-slide smoke passed with `validation_status: "passed"` and wrote `/private/tmp/aippt-generative-editable-smoke-one/smoke-one-16-9.pptx`. -- Strict multi-slide smoke passed with `validation_status: "passed"` and wrote `/private/tmp/aippt-generative-editable-smoke-multi/smoke-multi-16-9.pptx`. -- The local verification used schema-valid local OCR diagnostics plus configured live image edit/generation roles; smoke output redacted provider URLs and API keys. -- During repeated live attempts, the image edit provider intermittently returned upstream `503/524`; retryable 5xx handling and local retry settings were required for a stable pass. -- The final multi-slide PPTX editability spot check found editable `TEXT_BOX` objects on all three pages, editable `AUTO_SHAPE` objects on pages 1 and 2, an editable native `LINE` on page 1, and bitmap `PICTURE` assets for complex visuals/backgrounds. -- Preview validation passed for the final one-slide and multi-slide smoke outputs. -- Repair/source-crop behavior was exercised by the complex visual page: provider-generated complex-visual assets that fail QA can be replaced with exact source crops, preserving output fidelity instead of silently accepting drifted assets. - -Hardening added from the failed live runs: -- Generated slide SSE payloads now carry AIPPT text metadata into the WebUI, so high-fidelity export can use known slide text instead of relying only on OCR content. -- The local `tesseract` OCR provider can be selected explicitly with `provider: "local_tesseract"` for development smoke tests. -- HTTP `429` and `5xx` provider errors are marked retryable, and pipeline provider calls honor `generative_editable_pptx.retries.provider_max_attempts` and `backoff_seconds`. -- Provider diagnostics and smoke config output keep API keys and base URLs redacted. -- Smoke diagnostics support direct OCR, image generation, and image edit provider checks before running the full pipeline. The image generation check now sends the fixture source slide as a visual reference, and the image edit check exercises the clean-base, asset-sheet, and repair edit roles. -- Source-native shape detection now supplements base-clean diff planning, so simple rectangle/rounded-rectangle/line pages can rebuild as native PPT shapes even when the edited base background drifts. -- Source-base diff residual filtering now drops page-edge noise, blank background residuals, and fill-only fragments inside detected source-native shapes before asset-sheet generation. -- Native-only flat-background pages use a deterministic local reconstruction background instead of a hallucinated model-cleaned background. -- Metadata-matching OCR text can use AIPPT text content even when local OCR reports low confidence, while obvious low-confidence pseudo-text from shapes is ignored. -- Complex whole-visual bitmap assets fall back to exact source crops when provider-generated transparent assets fail QA. -- Negative-slope native lines are validated by their endpoint bounding box because `python-pptx` exposes connector direction lossy through `left/top/width/height`. -- Base-clean background generation no longer reuses the text-only edit mask from text cleanup, avoiding contradictory full-background prompts with masked-only edit semantics. -- Low-confidence OCR pseudo-text is retained as a non-blocking warning instead of being silently swallowed; real short low-confidence text remains a blocking validation issue. -- Full-slide source-background safety validation now detects resized near-source backgrounds before allowing editable text over them. diff --git a/docs/generative-editable-pptx.md b/docs/generative-editable-pptx.md index f67b56b..63b9dc4 100644 --- a/docs/generative-editable-pptx.md +++ b/docs/generative-editable-pptx.md @@ -1,14 +1,20 @@ # Generative Editable PPTX Export -Generative editable PPTX export rebuilds a generated slide image into a PowerPoint deck with editable text boxes, conservative native shapes, and positioned bitmap assets. It is separate from the existing raster PPTX export and from the image-layer editable PPTX OpenSpec backup plan. +Generative editable PPTX export converts slide images into PowerPoint decks that preserve visual fidelity while exposing practical editing handles. The exported deck can contain editable text boxes, conservative native PowerPoint shapes, and positioned bitmap assets. -## When To Use It +This mode is intended for workflows where the generated deck needs downstream editing in PowerPoint-compatible tools. It is separate from the standard PPTX export, which places each slide image as a single full-slide picture. -Use **High-fidelity editable PPTX** when a deck needs to be edited in PowerPoint after generation and quality is more important than model cost. The existing **PPTX** option remains a raster export: each generated slide image is placed as a full-slide picture. +## Capabilities -The generative editable path is quality-gated. The default fallback policy is `fail`, so AIPPT returns an error instead of silently giving the user a lower-fidelity PPTX when validation fails. +- Reconstructs each slide from an input image rather than requiring the original source layout. +- Uses OCR and optional AIPPT text metadata to create editable PowerPoint text boxes. +- Uses a VLM to understand page structure and candidate visual regions. +- Keeps simple, high-confidence geometry as native PowerPoint objects where possible. +- Keeps complex visuals as bitmap assets to preserve fidelity. +- Runs structural and preview validation before returning the deck. +- Supports explicit fallback policies instead of silently returning lower-fidelity output. -## Request Contract +## Export Request `POST /api/export` @@ -42,48 +48,56 @@ The generative editable path is quality-gated. The default fallback policy is `f Supported `fallback_policy` values: -- `fail`: default. Return an error if validation fails. -- `text_editable_background`: explicitly allow a lower-fidelity PPTX that keeps editable text boxes over the generated text-clean background when full foreground reconstruction fails after artifacts are available. -- `raster_pptx`: explicitly allow falling back to the existing raster PPTX exporter. +- `fail`: Default. Return an error if editable reconstruction or validation fails. +- `text_editable_background`: Return editable text over a cleaned background when foreground reconstruction fails after required artifacts are available. +- `raster_pptx`: Return the standard raster PPTX as an explicit fallback. -Fallback output is never returned unless the request explicitly permits it. When fallback is used, the response includes `X-Generative-Editable-Fallback-Policy` and `X-Generative-Editable-Fallback-Used` headers. +When a fallback is used, the response includes: -## Configuration +- `X-Generative-Editable-Fallback-Policy` +- `X-Generative-Editable-Fallback-Used` -Provider settings live in `config.yaml`. `config.example.yaml` documents the expected shape with placeholder values. +## Model Configuration + +Model settings live in `config.yaml`. `config.example.yaml` documents the supported structure with placeholder values. + +The editable export path uses these model roles: + +- `text_model`: Text chat-completions model. If omitted, the VLM profile may be used for text-only tasks. +- `image_model`: Image generation model. +- `edit_model`: Image editing model. If omitted, it may inherit from `image_model`. +- `VLM`: Optional multimodal understanding model required for image-to-editable-PPTX reconstruction. +- `ocr_model`: Optional OCR model required for image-to-editable-PPTX reconstruction. + +Example: ```yaml api: models: - prompt_model: - adapter: "openai_chat" + text_model: model: "..." - base_url: "..." + base_url: "https://api.example.com/v1" api_key: "..." image_model: - adapter: "raw_chat_multimodal" model: "..." - base_url: "..." + base_url: "https://api.example.com/v1" api_key: "..." edit_model: - adapter: "raw_chat_multimodal" model: "..." - base_url: "..." + base_url: "https://api.example.com/v1" + api_key: "..." + VLM: + model: "..." + base_url: "https://api.example.com/v1" api_key: "..." ocr_model: - provider: "..." model: "..." - base_url: "..." + base_url: "https://api.example.com/v1" api_key: "..." - # For local development smoke tests: - # provider: "local_tesseract" - # model: "eng" - # base_url: "" - # api_key: "" generative_editable_pptx: reconstruction: - mode: "generative" + mode: "vlm_first" clean_base_model: "edit_model" asset_sheet_model: "edit_model" repair_model: "edit_model" @@ -96,37 +110,62 @@ generative_editable_pptx: max_repair_attempts: 2 preview_similarity_threshold: 0.92 require_preview_validation: true + retries: + provider_max_attempts: 2 + repair_max_attempts: 2 + backoff_seconds: 1.0 + timeouts: + provider_call: 180 + page: 600 ``` -Do not commit real provider credentials. Before live verification, populate local `config.yaml` with OCR, image edit, and image generation provider settings, then run a one-slide smoke test before multi-slide verification. +Do not commit real provider credentials. -The current live adapter is an OpenAI-compatible chat-completions multimodal adapter (`openai_chat` or `raw_chat_multimodal`). It sends slide images as `image_url` message parts and expects the provider response to contain an image URL, data URL, or base64 image payload. It is not the official OpenAI Images API edit endpoint. If a provider requires multipart image-edit requests, add a provider-specific adapter behind the existing provider interfaces instead of changing the export contract; unsupported adapter names fail during dependency construction instead of silently using the wrong protocol. +## Provider Protocol -## Pipeline +Current providers use OpenAI-compatible `/chat/completions` APIs: -1. Create a job artifact directory and write source images. -2. Run OCR. When `use_aippt_metadata_first` is enabled, AIPPT text metadata is used as semantic text when available; OCR supplies layout, style, color, and fallback text. When disabled, OCR text is used directly. -3. Build text masks from accepted OCR/text boxes. -4. Create `text_clean_background` and `base_clean_background` assets. -5. Plan foreground candidates from source/base-clean differences. +- Text and VLM providers return message text. +- OCR providers must return structured OCR JSON. +- Image generation and image editing providers return an image URL, data URL, or base64 image payload. + +Provider-specific adapters should be added behind the existing provider interfaces when a model requires a different protocol. + +## Reconstruction Pipeline + +1. Create an isolated export job directory and store source slide images. +2. Run VLM page analysis to identify layout, visual regions, and reconstruction candidates. +3. Run OCR and merge OCR output with optional AIPPT text metadata. +4. Build text masks and generate cleaned background assets. +5. Plan foreground candidates from VLM regions and source/background differences. 6. Convert high-confidence simple geometry to native PowerPoint shapes. -7. Generate bitmap assets for complex foreground regions through the configured asset-sheet provider and run configured bounded repair for failed assets. Source crops are only available when the pipeline dependency explicitly enables that internal diagnostic fallback; request fallback policies do not silently enable source crops. -8. Compose PPTX using cleaned background, native shapes, bitmap assets, and editable text boxes. -9. Validate structure and preview similarity. Repair bounded issues when possible. -10. Return the validated deck, or an explicit fallback/error. +7. Build bitmap assets for complex regions and run bounded repair when validation detects asset issues. +8. Compose the PPTX from background, native shapes, bitmap assets, and editable text boxes. +9. Validate the generated deck structure and preview similarity. +10. Return the validated editable PPTX, an explicit fallback, or a structured error. -## Quality And Cost Notes +## Quality And Cost -This path uses OCR plus image editing/image generation providers. It can require multiple provider calls per page: cleanup, asset sheet generation, repairs, and validation retries. That is expected; the design optimizes output quality first and leaves cost optimization to caching, component reuse, bounded concurrency, and explicit fallback policies. +This export mode prioritizes editability and visual fidelity over model cost. A single slide can require multiple provider calls: VLM analysis, OCR, background cleanup, asset generation, repair, and validation-driven retries. + +Recommended production controls: + +- Use bounded concurrency. +- Keep provider call timeouts explicit. +- Enable retries only for retryable provider failures. +- Cache source and intermediate artifacts where appropriate. +- Use fallback policies intentionally per user workflow. ## Limitations -- Bitmap assets are source-guided through provider calls and local slicing, but generated or repaired assets may not be pixel-identical to the original slide image. -- Native shape conversion is conservative; uncertain regions stay as bitmap assets instead of inaccurate PowerPoint shapes. -- Text editability depends on OCR quality and available AIPPT text metadata. -- Current strict live smoke is not passing with the local provider set recorded on 2026-07-01 because OCR and image-edit upstream calls fail provider validation; see `docs/generative-editable-live-verification.md`. -- Real provider smoke tests require local `config.yaml` settings and are not part of default CI. +- Complex visuals are usually represented as bitmap assets, not fully editable native PowerPoint objects. +- Generated or repaired bitmap assets may not be pixel-identical to the source image. +- Native shape conversion is conservative; uncertain geometry remains bitmap-based to avoid inaccurate editable objects. +- Text editability depends on OCR accuracy, available source text metadata, and font availability in the presentation editor. +- Real provider verification requires local model credentials and is not part of default CI. ## Relation To Image-Layer Export -The image-layer editable PPTX plan is a separate backup path for future model resources. Image-layer decomposition can produce movable/scalable/croppable visual layers, but it does not automatically produce native PowerPoint text boxes, editable line styles, or editable rounded rectangles. The generative editable export path composes a PPTX from OCR, native-shape fitting, provider-generated assets, and validation. +Image-layer decomposition is a separate future path. It can produce movable, scalable, croppable visual layers, but it does not automatically provide editable PowerPoint text boxes, line styles, rounded rectangles, or shape semantics. + +Generative editable PPTX export focuses on reconstructing a practical PowerPoint document from OCR, VLM layout analysis, image editing/generation, native-shape fitting, deterministic composition, and validation. From baacf6622e54ee10e60c2d634149a253b4888d20 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 16:24:26 +0800 Subject: [PATCH 11/16] Fix backend CI lint and formatting --- api/models.py | 4 +- api/routes/export.py | 53 +- src/generative_editable_assets.py | 31 +- src/generative_editable_backgrounds.py | 19 +- src/generative_editable_composer.py | 16 +- src/generative_editable_config.py | 4 +- src/generative_editable_foreground_planner.py | 4 +- src/generative_editable_manifest.py | 44 +- src/generative_editable_page_analysis.py | 8 +- src/generative_editable_pipeline.py | 406 +++++++++------ src/generative_editable_preview_validator.py | 185 ++++--- src/generative_editable_providers.py | 76 +-- src/generative_editable_shape_fitter.py | 13 +- src/generative_editable_text.py | 21 +- src/generative_editable_text_candidates.py | 5 +- src/generative_editable_vlm_reconstruction.py | 259 ++++++---- src/model_profiles.py | 8 +- tests/generative_editable_fixtures.py | 24 +- tests/test_generative_editable_assets.py | 14 +- tests/test_generative_editable_composer.py | 16 +- tests/test_generative_editable_config.py | 16 +- ...est_generative_editable_export_contract.py | 58 +-- .../test_generative_editable_export_route.py | 55 +- tests/test_generative_editable_fixtures.py | 5 +- ..._generative_editable_foreground_planner.py | 4 +- .../test_generative_editable_job_artifacts.py | 8 +- ...test_generative_editable_live_providers.py | 83 ++- tests/test_generative_editable_manifest.py | 9 +- .../test_generative_editable_page_analysis.py | 7 +- tests/test_generative_editable_pipeline.py | 474 +++++++++++++----- ...t_generative_editable_preview_validator.py | 152 ++++-- tests/test_generative_editable_providers.py | 26 +- .../test_generative_editable_shape_fitter.py | 1 + .../test_generative_editable_smoke_script.py | 4 +- tests/test_generative_editable_text.py | 11 +- ...est_generative_editable_text_candidates.py | 47 +- ..._generative_editable_vlm_reconstruction.py | 173 +++++-- tests/test_real_generative_editable_runner.py | 97 +++- 38 files changed, 1664 insertions(+), 776 deletions(-) diff --git a/api/models.py b/api/models.py index fb2b9c2..a49ef4e 100644 --- a/api/models.py +++ b/api/models.py @@ -323,9 +323,7 @@ class ExportRequest(BaseModel): """导出请求""" slides: List[ExportSlide] - format: Literal["pdf", "pptx", "generative_editable_pptx"] = Field( - ..., description="导出格式" - ) + format: Literal["pdf", "pptx", "generative_editable_pptx"] = Field(..., description="导出格式") aspect_ratio: Literal["16:9", "4:3"] = Field("16:9", description="导出画幅比例") slide_order: Optional[List[str]] = None editable_options: ExportEditableOptions = Field(default_factory=ExportEditableOptions) diff --git a/api/routes/export.py b/api/routes/export.py index 46c2619..3f71306 100644 --- a/api/routes/export.py +++ b/api/routes/export.py @@ -32,7 +32,6 @@ GenerativeEditableSlideInput, GenerativeEditableValidationError, finalize_validated_export, - run_generative_editable_pipeline, ) from src.generative_editable_vlm_reconstruction import ( OpenAIChatVLMPageAnalysisProvider, @@ -207,17 +206,21 @@ def _export_generative_editable_pptx( ) fallback_factory = None if fallback_policy == "raster_pptx": - fallback_factory = lambda: _export_raster_pptx_fallback( - image_paths=ordered_image_paths, - output_path=output_path, - aspect_ratio=aspect_ratio, - ) + + def fallback_factory(): + return _export_raster_pptx_fallback( + image_paths=ordered_image_paths, + output_path=output_path, + aspect_ratio=aspect_ratio, + ) elif fallback_policy == "text_editable_background": - fallback_factory = lambda: _export_text_editable_background_fallback( - artifact_root=artifact_root, - job_id=job_id, - output_path=output_path, - ) + + def fallback_factory(): + return _export_text_editable_background_fallback( + artifact_root=artifact_root, + job_id=job_id, + output_path=output_path, + ) try: try: @@ -271,7 +274,9 @@ def _provider_failure_validation_report( checked_pages=slide_count, issues=[ ValidationIssue( - code="provider_timeout" if isinstance(error, ProviderTimeoutError) else "provider_failure", + code="provider_timeout" + if isinstance(error, ProviderTimeoutError) + else "provider_failure", message=str(error) or error.__class__.__name__, details=details, ) @@ -309,7 +314,9 @@ def _text_editable_background_fallback_error(reason: str) -> GenerativeEditableF ) -def _export_text_editable_background_fallback(*, artifact_root: Path, job_id: str, output_path: str) -> str: +def _export_text_editable_background_fallback( + *, artifact_root: Path, job_id: str, output_path: str +) -> str: job_dir = artifact_root / job_id deck_path = job_dir / "deck.json" if not deck_path.is_file(): @@ -322,7 +329,9 @@ def _export_text_editable_background_fallback(*, artifact_root: Path, job_id: st page_manifest_paths = [] for page_ref in deck.page_manifest_paths: page = read_page_manifest(job_dir / page_ref) - background = page.text_clean_background or page.base_clean_background or page.chosen_background + background = ( + page.text_clean_background or page.base_clean_background or page.chosen_background + ) if not background: raise _text_editable_background_fallback_error( "text_clean_background fallback requires a cleaned background artifact" @@ -338,7 +347,9 @@ def _export_text_editable_background_fallback(*, artifact_root: Path, job_id: st ) fallback_page_path = fallback_dir / Path(page_ref).name write_manifest(fallback_page_path, fallback_page) - page_manifest_paths.append(f"pages/text-editable-background-fallback/{fallback_page_path.name}") + page_manifest_paths.append( + f"pages/text-editable-background-fallback/{fallback_page_path.name}" + ) fallback_deck = replace( deck, page_manifest_paths=page_manifest_paths, @@ -368,7 +379,9 @@ def _export_http_exception(error: Exception) -> HTTPException: def _build_generative_editable_pipeline_dependencies() -> GenerativeEditablePipelineDependencies: config = load_generative_editable_config() return GenerativeEditablePipelineDependencies( - ocr_provider=_ocr_provider_for_config(config.ocr, timeout_seconds=config.timeouts.provider_call), + ocr_provider=_ocr_provider_for_config( + config.ocr, timeout_seconds=config.timeouts.provider_call + ), image_edit_provider=_image_edit_provider_for_config(config.clean_base_model), asset_sheet_image_edit_provider=_image_edit_provider_for_config(config.asset_sheet_model), repair_image_edit_provider=_image_edit_provider_for_config(config.repair_model), @@ -389,7 +402,9 @@ def _build_vlm_editable_pipeline_dependencies( ) -> VLMEditablePipelineDependencies: profiles = load_default_profiles() if not profiles or not profiles.vlm: - raise GenerativeEditableConfigError("Missing VLM model profile for vlm_first reconstruction") + raise GenerativeEditableConfigError( + "Missing VLM model profile for vlm_first reconstruction" + ) vlm_config = ProviderConfig( role="VLM", model=profiles.vlm.model, @@ -401,7 +416,9 @@ def _build_vlm_editable_pipeline_dependencies( vlm_provider=OpenAIChatVLMPageAnalysisProvider(vlm_config), image_edit_provider=_image_edit_provider_for_config(config.clean_base_model), asset_sheet_image_edit_provider=_image_edit_provider_for_config(config.asset_sheet_model), - ocr_provider=_ocr_provider_for_config(config.ocr, timeout_seconds=config.timeouts.provider_call), + ocr_provider=_ocr_provider_for_config( + config.ocr, timeout_seconds=config.timeouts.provider_call + ), provider_timeout_seconds=config.timeouts.provider_call, page_timeout_seconds=config.timeouts.page, provider_max_attempts=config.retries.provider_max_attempts, diff --git a/src/generative_editable_assets.py b/src/generative_editable_assets.py index 344ca90..ec3456b 100644 --- a/src/generative_editable_assets.py +++ b/src/generative_editable_assets.py @@ -145,7 +145,7 @@ def slice_asset_sheet_by_components( min_area=min_component_area, merge_gap=24, merge_union_growth=2.6, - ) + ) if len(boxes) < len(candidates): raise ValueError( f"asset sheet contains {len(boxes)} component(s), expected at least {len(candidates)}" @@ -372,7 +372,11 @@ def _remove_chroma_key(crop: Image.Image, chroma_key: tuple[int, int, int]) -> I for y in range(output.height): for x in range(output.width): red, green, blue, alpha = pixels[x, y] - if abs(red - chroma_key[0]) <= 8 and abs(green - chroma_key[1]) <= 8 and abs(blue - chroma_key[2]) <= 8: + if ( + abs(red - chroma_key[0]) <= 8 + and abs(green - chroma_key[1]) <= 8 + and abs(blue - chroma_key[2]) <= 8 + ): pixels[x, y] = (red, green, blue, 0) else: pixels[x, y] = (red, green, blue, alpha) @@ -463,9 +467,8 @@ def _remove_light_neutral_border_background(image: Image.Image) -> Image.Image: for x in range(output.width): red, green, blue, alpha = pixels[x, y] rgb = (red, green, blue) - if ( - min_luma <= _luma(rgb) <= max_luma - and _is_light_neutral(rgb, min_luma=min_luma, max_channel_delta=30) + if min_luma <= _luma(rgb) <= max_luma and _is_light_neutral( + rgb, min_luma=min_luma, max_channel_delta=30 ): pixels[x, y] = (red, green, blue, 0) else: @@ -518,13 +521,7 @@ def _is_light_neutral( def _is_magenta_chroma(pixel: tuple[int, int, int]) -> bool: red, green, blue = pixel - return ( - red >= 170 - and blue >= 160 - and green <= 130 - and red - green >= 70 - and blue - green >= 60 - ) + return red >= 170 and blue >= 160 and green <= 130 and red - green >= 70 and blue - green >= 60 def _luma(pixel: tuple[int, int, int]) -> int: @@ -540,11 +537,7 @@ def _component_boxes_from_alpha( merge_union_growth: float, ) -> list[tuple[int, int, int, int]]: components = _connected_components(_opaque_pixels(image)) - boxes = [ - _box_from_points(component) - for component in components - if len(component) >= min_area - ] + boxes = [_box_from_points(component) for component in components if len(component) >= min_area] return _merge_nearby_boxes(boxes, gap=merge_gap, max_union_growth=merge_union_growth) @@ -654,7 +647,9 @@ def _reading_order_boxes( for row_index, row_center in enumerate(row_centers): if abs(center_y - row_center) <= row_threshold: rows[row_index].append(box) - row_centers[row_index] = sum(_box_center_y(item) for item in rows[row_index]) / len(rows[row_index]) + row_centers[row_index] = sum(_box_center_y(item) for item in rows[row_index]) / len( + rows[row_index] + ) break else: rows.append([box]) diff --git a/src/generative_editable_backgrounds.py b/src/generative_editable_backgrounds.py index 280d2b1..0bd4802 100644 --- a/src/generative_editable_backgrounds.py +++ b/src/generative_editable_backgrounds.py @@ -46,7 +46,9 @@ def local_cleanup_text_mask( source_path = Path(source_image_path) mask_path = Path(text_mask_path) output_path = Path(output_asset_path) - artifact_root = Path(asset_root).resolve() if asset_root is not None else _infer_artifact_root(output_path) + artifact_root = ( + Path(asset_root).resolve() if asset_root is not None else _infer_artifact_root(output_path) + ) _validate_background_paths( source_image_path=source_path, output_asset_path=output_path, @@ -348,9 +350,13 @@ def create_base_clean_background( "and visual objects unless they are inside those removal regions. " ) if removal_bboxes: - removal_instruction += "Removal boxes: " + ", ".join(str(bbox) for bbox in removal_bboxes) + ". " + removal_instruction += ( + "Removal boxes: " + ", ".join(str(bbox) for bbox in removal_bboxes) + ". " + ) else: - removal_instruction = "Omit movable foreground graphics and baked text from this background layer." + removal_instruction = ( + "Omit movable foreground graphics and baked text from this background layer." + ) if mask_path is not None or removal_bboxes: removal_instruction = " " + removal_instruction @@ -359,8 +365,7 @@ def create_base_clean_background( prompt_id="base_clean_background", prompt=( "Return the slide background layer only. Preserve theme background, edge " - "decoration, ambient texture, and layout background. " - + removal_instruction + "decoration, ambient texture, and layout background. " + removal_instruction ), output_asset_path=str(output_asset_path), asset_root=str(asset_root), @@ -449,7 +454,9 @@ def _normalize_background_to_source_size( return {} -def _sample_border_pixels(image: Image.Image, bbox: tuple[int, int, int, int]) -> list[tuple[int, int, int]]: +def _sample_border_pixels( + image: Image.Image, bbox: tuple[int, int, int, int] +) -> list[tuple[int, int, int]]: left, top, right, bottom = bbox expanded = ( max(0, left - 2), diff --git a/src/generative_editable_composer.py b/src/generative_editable_composer.py index db68e12..0a6a4a5 100644 --- a/src/generative_editable_composer.py +++ b/src/generative_editable_composer.py @@ -17,7 +17,6 @@ from pptx.util import Inches, Pt from .generative_editable_manifest import ( - DeckManifest, NativeShapeSpec, PageManifest, TextBoxSpec, @@ -93,7 +92,9 @@ def compose_deck_from_manifests( _normalize_pptx_zip(output_path) -def _compose_page(slide, prs: Presentation, page: PageManifest, aspect_ratio: str, root: Path) -> None: +def _compose_page( + slide, prs: Presentation, page: PageManifest, aspect_ratio: str, root: Path +) -> None: if page.chosen_background: _set_slide_picture_background(slide, _artifact_path(root, page.chosen_background)) for shape in page.native_shapes: @@ -289,7 +290,9 @@ def _validate_page_geometry(page: PageManifest, aspect_ratio: str) -> None: expected_ratio = expected_width / expected_height slide_ratio = actual_width / actual_height source_ratio = page.source_image_size[0] / page.source_image_size[1] - if not _ratio_close(slide_ratio, expected_ratio) or not _ratio_close(source_ratio, expected_ratio): + if not _ratio_close(slide_ratio, expected_ratio) or not _ratio_close( + source_ratio, expected_ratio + ): raise CompositionGeometryError( f"page {page.slide_id} geometry does not match deck aspect ratio {aspect_ratio}" ) @@ -350,9 +353,10 @@ def _normalize_pptx_zip(path: str | Path) -> None: with tempfile.NamedTemporaryFile(delete=False, suffix=".pptx") as tmp: tmp_path = Path(tmp.name) try: - with zipfile.ZipFile(pptx_path, "r") as source, zipfile.ZipFile( - tmp_path, "w", compression=zipfile.ZIP_DEFLATED - ) as target: + with ( + zipfile.ZipFile(pptx_path, "r") as source, + zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as target, + ): for name in sorted(source.namelist()): info = zipfile.ZipInfo(name, fixed_date) original = source.getinfo(name) diff --git a/src/generative_editable_config.py b/src/generative_editable_config.py index 6bb762a..f995c8b 100644 --- a/src/generative_editable_config.py +++ b/src/generative_editable_config.py @@ -100,7 +100,9 @@ def load_generative_editable_config(use_fake: bool = False) -> GenerativeEditabl clean_base_model=_provider_config(clean_role, _provider_source(clean_role, models)), asset_sheet_model=_provider_config(asset_role, _provider_source(asset_role, models)), repair_model=_provider_config(repair_role, _provider_source(repair_role, models)), - generation_model=_provider_config(generation_role, _provider_source(generation_role, models)), + generation_model=_provider_config( + generation_role, _provider_source(generation_role, models) + ), use_aippt_metadata_first=_as_bool(ocr_settings.get("use_aippt_metadata_first", True)), ocr_min_confidence=float(ocr_settings.get("min_confidence", 0.75)), quality=QualityConfig( diff --git a/src/generative_editable_foreground_planner.py b/src/generative_editable_foreground_planner.py index 36d9f1e..b9a0cba 100644 --- a/src/generative_editable_foreground_planner.py +++ b/src/generative_editable_foreground_planner.py @@ -198,7 +198,9 @@ def _connected_components(pixels: set[tuple[int, int]]) -> list[list[tuple[int, stack.append(neighbor) component.append(neighbor) components.append(component) - components.sort(key=lambda item: (min(point[1] for point in item), min(point[0] for point in item))) + components.sort( + key=lambda item: (min(point[1] for point in item), min(point[0] for point in item)) + ) return components diff --git a/src/generative_editable_manifest.py b/src/generative_editable_manifest.py index 964530a..2c82607 100644 --- a/src/generative_editable_manifest.py +++ b/src/generative_editable_manifest.py @@ -142,7 +142,9 @@ def _validate_bbox_in_bounds(name: str, bbox: PixelBBox, image_size: tuple[int, raise ValueError(f"{name} must be inside source_image_size") -def _validate_polygon_in_bounds(name: str, polygon: tuple[Point, ...], image_size: tuple[int, int]) -> None: +def _validate_polygon_in_bounds( + name: str, polygon: tuple[Point, ...], image_size: tuple[int, int] +) -> None: _validate_polygon(name, polygon) width, height = image_size for x, y in polygon: @@ -206,7 +208,9 @@ def __post_init__(self) -> None: _validate_point("line_end", self.line_end) if self.stroke_width is not None and self.stroke_width <= 0: raise ValueError("stroke_width must be positive") - if self.radius is not None and (not isinstance(self.radius, int | float) or self.radius < 0): + if self.radius is not None and ( + not isinstance(self.radius, int | float) or self.radius < 0 + ): raise ValueError("radius must be a non-negative number") @@ -297,7 +301,9 @@ def __post_init__(self) -> None: raise ValueError("source_image_size must be positive") if self.slide_size[0] <= 0 or self.slide_size[1] <= 0: raise ValueError("slide_size must be positive") - _validate_relative_artifact_path("source_image_path", self.source_image_path, SOURCE_PREFIXES) + _validate_relative_artifact_path( + "source_image_path", self.source_image_path, SOURCE_PREFIXES + ) _validate_relative_artifact_path( "text_clean_background", self.text_clean_background, BACKGROUND_PREFIXES ) @@ -308,7 +314,9 @@ def __post_init__(self) -> None: "chosen_background", self.chosen_background, BACKGROUND_PREFIXES ) for value in self.provider_output_paths.values(): - _validate_relative_artifact_path("provider_output_paths", value, PROVIDER_OUTPUT_PREFIXES) + _validate_relative_artifact_path( + "provider_output_paths", value, PROVIDER_OUTPUT_PREFIXES + ) expected_slide_dir = f"{self.page_index:04d}-{_safe_name(self.slide_id)}" for stage, value in self.provider_output_paths.items(): if stage not in PROVIDER_OUTPUT_STAGES: @@ -317,12 +325,16 @@ def __post_init__(self) -> None: if len(parts) < 4 or parts[1] != stage or parts[2] != expected_slide_dir: raise ValueError("provider_output_paths must match stage and slide/page") for text_box in self.text_boxes: - _validate_bbox_in_bounds("source_pixel_bbox", text_box.source_pixel_bbox, self.source_image_size) + _validate_bbox_in_bounds( + "source_pixel_bbox", text_box.source_pixel_bbox, self.source_image_size + ) _validate_polygon_in_bounds( "source_pixel_polygon", text_box.source_pixel_polygon, self.source_image_size ) for shape in self.native_shapes: - _validate_bbox_in_bounds("source_pixel_bbox", shape.source_pixel_bbox, self.source_image_size) + _validate_bbox_in_bounds( + "source_pixel_bbox", shape.source_pixel_bbox, self.source_image_size + ) if shape.line_start is not None: _validate_point_in_bounds("line_start", shape.line_start, self.source_image_size) if shape.line_end is not None: @@ -332,7 +344,9 @@ def __post_init__(self) -> None: "source_pixel_bbox", candidate.source_pixel_bbox, self.source_image_size ) for asset in self.bitmap_assets: - _validate_bbox_in_bounds("source_pixel_bbox", asset.source_pixel_bbox, self.source_image_size) + _validate_bbox_in_bounds( + "source_pixel_bbox", asset.source_pixel_bbox, self.source_image_size + ) @dataclass(frozen=True) @@ -354,7 +368,9 @@ def __post_init__(self) -> None: _validate_status("validation_status", self.validation_status, VALIDATION_STATUSES) if len(self.slide_order) != len(self.page_manifest_paths): raise ValueError("page_manifest_paths must match slide_order length") - for page_index, (slide_id, path) in enumerate(zip(self.slide_order, self.page_manifest_paths)): + for page_index, (slide_id, path) in enumerate( + zip(self.slide_order, self.page_manifest_paths) + ): _validate_relative_artifact_path("page_manifest_paths", path, PAGE_PREFIXES) expected = f"{page_index:04d}-{_safe_name(slide_id)}.json" if Path(path).name != expected: @@ -392,9 +408,7 @@ def page_manifest_from_dict(data: dict[str, Any]) -> PageManifest: payload = dict(data) payload["source_image_size"] = tuple(payload["source_image_size"]) payload["slide_size"] = tuple(payload["slide_size"]) - payload["text_boxes"] = [ - _text_box_from_dict(item) for item in payload.get("text_boxes", []) - ] + payload["text_boxes"] = [_text_box_from_dict(item) for item in payload.get("text_boxes", [])] payload["native_shapes"] = [ _native_shape_from_dict(item) for item in payload.get("native_shapes", []) ] @@ -404,9 +418,7 @@ def page_manifest_from_dict(data: dict[str, Any]) -> PageManifest: payload["bitmap_assets"] = [ _bitmap_asset_from_dict(item) for item in payload.get("bitmap_assets", []) ] - payload["asset_sheets"] = [ - AssetSheetSpec(**item) for item in payload.get("asset_sheets", []) - ] + payload["asset_sheets"] = [AssetSheetSpec(**item) for item in payload.get("asset_sheets", [])] payload["repair_attempts"] = [ RepairAttempt(**item) for item in payload.get("repair_attempts", []) ] @@ -446,9 +458,7 @@ def _polygon_from_json(value: list[list[int]] | tuple[Point, ...]) -> tuple[Poin return tuple(tuple(point) for point in value) -def sanitize_persisted_payload( - value: Any, *, _key: str = "", _redact_strings: bool = False -) -> Any: +def sanitize_persisted_payload(value: Any, *, _key: str = "", _redact_strings: bool = False) -> Any: if isinstance(value, dict): safe: dict[str, Any] = {} redact_nested = _redact_strings or _should_redact_string_value(_key) diff --git a/src/generative_editable_page_analysis.py b/src/generative_editable_page_analysis.py index 7cd4bf2..9b4120a 100644 --- a/src/generative_editable_page_analysis.py +++ b/src/generative_editable_page_analysis.py @@ -163,7 +163,9 @@ def _visual_candidate_can_anchor_text( def _is_short_section_label_text(text: str) -> bool: stripped = str(text).strip() - meaningful = "".join(char for char in stripped if char.isalnum() or "\u3400" <= char <= "\u9fff") + meaningful = "".join( + char for char in stripped if char.isalnum() or "\u3400" <= char <= "\u9fff" + ) cjk_count = sum(1 for char in meaningful if "\u3400" <= char <= "\u9fff") return 2 <= cjk_count <= 5 and meaningful.endswith(("域", "区", "层", "类", "项", "栏")) @@ -175,9 +177,7 @@ def _is_large_top_text_box( ) -> bool: left, top, right, bottom = bbox return ( - top <= image_height * 0.12 - and (right - left) >= image_width * 0.25 - and (bottom - top) >= 20 + top <= image_height * 0.12 and (right - left) >= image_width * 0.25 and (bottom - top) >= 20 ) diff --git a/src/generative_editable_pipeline.py b/src/generative_editable_pipeline.py index 6ecb1fd..9d7db19 100644 --- a/src/generative_editable_pipeline.py +++ b/src/generative_editable_pipeline.py @@ -91,7 +91,8 @@ def _build_default_assets(**kwargs) -> "AssetBuildResult": candidates = [ candidate for candidate in kwargs["candidates"] - if candidate.classification in {"bitmap_asset_candidate", "complex_whole_visual", "uncertain"} + if candidate.classification + in {"bitmap_asset_candidate", "complex_whole_visual", "uncertain"} ] if not candidates: return AssetBuildResult(bitmap_assets=[]) @@ -100,8 +101,12 @@ def _build_default_assets(**kwargs) -> "AssetBuildResult": page_index = int(kwargs["page_index"]) slide_id = str(kwargs["slide_id"]) output_dir = Path(kwargs["output_dir"]) - asset_sheet_provider = kwargs.get("asset_sheet_image_edit_provider", kwargs["image_edit_provider"]) - asset_reference_image_path = kwargs.get("asset_reference_image_path") or kwargs["source_image_path"] + asset_sheet_provider = kwargs.get( + "asset_sheet_image_edit_provider", kwargs["image_edit_provider"] + ) + asset_reference_image_path = ( + kwargs.get("asset_reference_image_path") or kwargs["source_image_path"] + ) background_reference_image_path = kwargs.get("background_reference_image_path") allow_source_crop_fallback = bool(kwargs.get("allow_source_crop_fallback", False)) preserved_candidates = [ @@ -135,7 +140,9 @@ def _build_default_assets(**kwargs) -> "AssetBuildResult": asset_sheets: list[AssetSheetSpec] = [] sheet_results: list[Any] = [] for batch_index, batch in enumerate(batches, start=1): - sheet_filename = "asset-sheet.png" if len(batches) == 1 else f"asset-sheet-{batch_index:03d}.png" + sheet_filename = ( + "asset-sheet.png" if len(batches) == 1 else f"asset-sheet-{batch_index:03d}.png" + ) sheet_path = ( asset_root / "asset_sheets" @@ -175,11 +182,11 @@ def _build_default_assets(**kwargs) -> "AssetBuildResult": ) try: sliced_assets = slice_asset_sheet_by_components( - sheet_path=sheet_result.output_asset_path, - candidates=batch, - output_dir=output_dir, - asset_root=asset_root, - ) + sheet_path=sheet_result.output_asset_path, + candidates=batch, + output_dir=output_dir, + asset_root=asset_root, + ) bitmap_assets.extend( replace(asset, z_order=len(preserved_assets) + asset.z_order) for asset in sliced_assets @@ -334,9 +341,7 @@ def _build_masked_source_element_assets( crop = source_rgba.crop(bbox) preserve_whole_visual = _should_preserve_whole_visual_source_crop(candidate) background_crop = ( - None - if preserve_whole_visual or background is None - else background.crop(bbox) + None if preserve_whole_visual or background is None else background.crop(bbox) ) crop_mask = None if preserve_whole_visual or mask is None else mask.crop(bbox) if background_crop is not None or crop_mask is not None: @@ -344,19 +349,27 @@ def _build_masked_source_element_assets( smooth_difference_suppressed = False pre_suppression_crop = crop.copy() pre_suppression_visible_pixel_count = _alpha_visible_pixel_count(pre_suppression_crop) - if background_crop is not None and _should_suppress_smooth_background_difference(candidate): + if background_crop is not None and _should_suppress_smooth_background_difference( + candidate + ): smooth_difference_suppressed = _suppress_smooth_background_difference_alpha(crop) - if smooth_difference_suppressed and _smooth_suppression_collapsed_to_fragment( - before_visible_pixel_count=pre_suppression_visible_pixel_count, - after_visible_pixel_count=_alpha_visible_pixel_count(crop), - ) or ( + if ( smooth_difference_suppressed - and _smooth_suppression_lost_domain_row_span(candidate, crop) + and _smooth_suppression_collapsed_to_fragment( + before_visible_pixel_count=pre_suppression_visible_pixel_count, + after_visible_pixel_count=_alpha_visible_pixel_count(crop), + ) + or ( + smooth_difference_suppressed + and _smooth_suppression_lost_domain_row_span(candidate, crop) + ) ): crop = pre_suppression_crop smooth_difference_suppressed = False edge_alpha_feathered = False - if background_crop is not None and _should_feather_source_preserved_asset_edges(crop, source_size): + if background_crop is not None and _should_feather_source_preserved_asset_edges( + crop, source_size + ): edge_alpha_feathered = _feather_outer_alpha_edges(crop) crop, bbox, trimmed_transparent_bounds = _trim_crop_to_visible_alpha(crop, bbox) visible_pixel_count = _alpha_visible_pixel_count(crop) @@ -379,7 +392,9 @@ def _build_masked_source_element_assets( "edge_alpha_feathered": edge_alpha_feathered, "trimmed_transparent_bounds": trimmed_transparent_bounds, "alpha_visible_pixel_count": visible_pixel_count, - "alpha_visible_area_ratio": round(visible_pixel_count / float(source_area), 6), + "alpha_visible_area_ratio": round( + visible_pixel_count / float(source_area), 6 + ), "candidate_provenance": _as_dict(candidate.provenance), }, ) @@ -398,12 +413,9 @@ def _should_preserve_whole_visual_source_crop(candidate: ForegroundCandidate) -> }: return False original_detection = provenance.get("original_detection") - if ( - provenance.get("detection") == "source_base_difference" - or ( - isinstance(original_detection, dict) - and original_detection.get("detection") == "source_base_difference" - ) + if provenance.get("detection") == "source_base_difference" or ( + isinstance(original_detection, dict) + and original_detection.get("detection") == "source_base_difference" ): return False return ( @@ -461,7 +473,10 @@ def _apply_source_element_alpha( continue if background_pixels is None: continue - if _color_distance((red, green, blue), background_pixels[x, y]) <= background_delta_threshold: + if ( + _color_distance((red, green, blue), background_pixels[x, y]) + <= background_delta_threshold + ): crop_pixels[x, y] = (red, green, blue, 0) elif alpha != 255: crop_pixels[x, y] = (red, green, blue, 255) @@ -529,7 +544,9 @@ def _feather_outer_alpha_edges( distance_to_edge = min(x, y, alpha.width - 1 - x, alpha.height - 1 - y) if distance_to_edge >= edge_width: continue - edge_factor = min_edge_alpha + int((255 - min_edge_alpha) * (distance_to_edge / float(edge_width))) + edge_factor = min_edge_alpha + int( + (255 - min_edge_alpha) * (distance_to_edge / float(edge_width)) + ) new_alpha = max(1, min(current, (current * edge_factor) // 255)) if new_alpha != current: alpha_pixels[x, y] = new_alpha @@ -566,16 +583,13 @@ def _suppress_smooth_background_difference_alpha( abs(red - right[0]) + abs(green - right[1]) + abs(blue - right[2]), abs(red - down[0]) + abs(green - down[1]) + abs(blue - down[2]), ) - if luminance >= bright_threshold or (gradient >= edge_threshold and luminance >= bright_threshold - 16): + if luminance >= bright_threshold or ( + gradient >= edge_threshold and luminance >= bright_threshold - 16 + ): salient_pixels[x, y] = 255 dilated = salient.filter(ImageFilter.MaxFilter(15)) dilated_pixels = dilated.load() - edge_points = { - (x, y) - for y in range(height) - for x in range(width) - if dilated_pixels[x, y] > 0 - } + edge_points = {(x, y) for y in range(height) for x in range(width) if dilated_pixels[x, y] > 0} components = _source_connected_components(edge_points) if not components: return False @@ -597,8 +611,12 @@ def _suppress_smooth_background_difference_alpha( bbox_area = max(1, (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])) if bbox_area / float(crop_area) >= max_component_area_ratio: continue - touches_crop_edge = bbox[0] <= 1 or bbox[1] <= 1 or bbox[2] >= width - 1 or bbox[3] >= height - 1 - spans_crop_edge = (bbox[2] - bbox[0]) >= width * 0.72 or (bbox[3] - bbox[1]) >= height * 0.72 + touches_crop_edge = ( + bbox[0] <= 1 or bbox[1] <= 1 or bbox[2] >= width - 1 or bbox[3] >= height - 1 + ) + spans_crop_edge = (bbox[2] - bbox[0]) >= width * 0.72 or ( + bbox[3] - bbox[1] + ) >= height * 0.72 if touches_crop_edge and spans_crop_edge: continue keep_draw.rectangle(bbox, fill=255) @@ -625,7 +643,9 @@ def _load_optional_background_reference( return Image.open(path).convert("RGB").resize(source_size) -def _load_optional_text_mask(text_mask_path: str | Path | None, source_size: tuple[int, int]) -> Image.Image | None: +def _load_optional_text_mask( + text_mask_path: str | Path | None, source_size: tuple[int, int] +) -> Image.Image | None: if text_mask_path is None: return None path = Path(text_mask_path) @@ -661,7 +681,9 @@ def _candidate_batches( def _default_preview_validator(**kwargs) -> ValidationReport: if kwargs.get("require_preview_validation") is False: return ValidationReport(status="passed", checked_pages=1, issues=[]) - gates = quality_threshold_to_preview_gates(float(kwargs.get("preview_similarity_threshold", 0.92))) + gates = quality_threshold_to_preview_gates( + float(kwargs.get("preview_similarity_threshold", 0.92)) + ) return validate_preview_similarity( source_image_path=kwargs["source_image_path"], preview=kwargs["preview"], @@ -771,7 +793,9 @@ def apply_asset_repair_results( repair_attempts: list[RepairAttempt], ) -> PageManifest: existing_ids = {asset.asset_id for asset in page_manifest.bitmap_assets} - unknown_ids = [asset.asset_id for asset in repaired_assets if asset.asset_id not in existing_ids] + unknown_ids = [ + asset.asset_id for asset in repaired_assets if asset.asset_id not in existing_ids + ] if unknown_ids: raise ValueError(f"unknown repaired asset ids: {', '.join(sorted(unknown_ids))}") repaired_by_id = {asset.asset_id: asset for asset in repaired_assets} @@ -878,7 +902,9 @@ def retry_image_edit(provider: ImageEditProvider | None) -> ImageEditProvider | dependencies, ocr_provider=ocr_provider, image_edit_provider=retry_image_edit(dependencies.image_edit_provider), - asset_sheet_image_edit_provider=retry_image_edit(dependencies.asset_sheet_image_edit_provider), + asset_sheet_image_edit_provider=retry_image_edit( + dependencies.asset_sheet_image_edit_provider + ), repair_image_edit_provider=retry_image_edit(dependencies.repair_image_edit_provider), image_generation_provider=image_generation_provider, ) @@ -905,7 +931,8 @@ def run_generative_editable_pipeline( retry_dependencies = with_provider_retries(dependencies) effective_dependencies = replace( retry_dependencies, - allow_metadata_ocr_fallback=retry_dependencies.allow_metadata_ocr_fallback and policy != "fail", + allow_metadata_ocr_fallback=retry_dependencies.allow_metadata_ocr_fallback + and policy != "fail", allow_source_crop_asset_fallback=retry_dependencies.allow_source_crop_asset_fallback, ) try: @@ -998,7 +1025,9 @@ def _run_generative_editable_pipeline_impl( page_index=page_index, slide_id=page_manifest.slide_id, ): - page_manifest_path = artifacts.page_manifest_path(page_manifest.slide_id, page_index) + page_manifest_path = artifacts.page_manifest_path( + page_manifest.slide_id, page_index + ) write_manifest(page_manifest_path, page_manifest) page_manifest_refs.append(_job_relative(page_manifest_path, artifacts.job_dir)) @@ -1190,7 +1219,7 @@ def _build_one_slide_manifest( ), ignored_noise_candidates=ignored_visual_text_candidates, min_confidence=dependencies.ocr_min_confidence, - ) + ) if recovered_text_boxes: text_boxes = _remove_approximate_text_boxes_replaced_by_focused_recovery( text_boxes, @@ -1339,9 +1368,7 @@ def _build_one_slide_manifest( source_image_path=str(source_path), text_boxes=source_raster_anchor_text_boxes, output_dir=( - artifacts.job_dir - / "assets" - / f"{page_index:04d}-{_safe_name(slide.slide_id)}" + artifacts.job_dir / "assets" / f"{page_index:04d}-{_safe_name(slide.slide_id)}" ), asset_root=artifacts.job_dir, start_z_order=1, @@ -1411,9 +1438,7 @@ def _build_one_slide_manifest( ) provenance = dict(page.provenance) backgrounds = dict(provenance.get("backgrounds", {})) - backgrounds["source_raster"] = _background_result_manifest_record( - source_raster_background - ) + backgrounds["source_raster"] = _background_result_manifest_record(source_raster_background) provenance["backgrounds"] = backgrounds provenance["chosen_background_kind"] = "source_raster_guardrail" return replace(page, provenance=provenance) @@ -1515,9 +1540,7 @@ def _build_one_slide_manifest( background_reference_image_path=base_clean.output_asset_path, candidates=bitmap_candidates, output_dir=str( - artifacts.job_dir - / "assets" - / f"{page_index:04d}-{_safe_name(slide.slide_id)}" + artifacts.job_dir / "assets" / f"{page_index:04d}-{_safe_name(slide.slide_id)}" ), asset_root=str(artifacts.job_dir), image_generation_provider=dependencies.image_generation_provider, @@ -1596,9 +1619,7 @@ def _build_one_slide_manifest( source_image_path=str(source_path), text_boxes=text_boxes, output_dir=( - artifacts.job_dir - / "assets" - / f"{page_index:04d}-{_safe_name(slide.slide_id)}" + artifacts.job_dir / "assets" / f"{page_index:04d}-{_safe_name(slide.slide_id)}" ), asset_root=artifacts.job_dir, start_z_order=1, @@ -1834,9 +1855,7 @@ def _validate_pipeline_output( ) reports.extend(page_reports) page_failed = any(report.status != "passed" or report.issues for report in page_reports) - page_statuses[page.slide_id] = ( - "failed" if structure_failed or page_failed else "passed" - ) + page_statuses[page.slide_id] = "failed" if structure_failed or page_failed else "passed" return _merge_validation_reports(reports, checked_pages=len(page_manifests)), page_statuses @@ -1934,7 +1953,8 @@ def _repair_or_replace_provider_asset_mismatches( repairable_failed_assets = [ asset for asset in failed_assets - if max_repair_attempts > 0 and _should_repair_asset(failure_reasons.get(asset.asset_id, [])) + if max_repair_attempts > 0 + and _should_repair_asset(failure_reasons.get(asset.asset_id, [])) ] try: if repairable_failed_assets: @@ -1973,10 +1993,7 @@ def _repair_or_replace_provider_asset_mismatches( raise ProviderError( provider_role=repair_provider.config.role, operation="asset_quality", - message=( - f"asset quality failed for {asset.asset_id}: " - + ",".join(qa_reasons) - ), + message=(f"asset quality failed for {asset.asset_id}: " + ",".join(qa_reasons)), retryable=False, ) replaced.append( @@ -2065,7 +2082,9 @@ def _plan_reconstruction_targets( source_size = source.size source_rgb = source.convert("RGB").copy() source_background = _estimate_background_color(source_rgb) - for candidate in _reclassify_uncertain_candidates(candidates, source_image_path=source_image_path): + for candidate in _reclassify_uncertain_candidates( + candidates, source_image_path=source_image_path + ): fit = fit_native_shape_with_fallback( candidate, source_image_path=source_image_path, @@ -2083,7 +2102,11 @@ def _plan_reconstruction_targets( if fit.bitmap_candidate is not None: bitmap_candidates.append(fit.bitmap_candidate) continue - if candidate.classification in {"bitmap_asset_candidate", "complex_whole_visual", "uncertain"}: + if candidate.classification in { + "bitmap_asset_candidate", + "complex_whole_visual", + "uncertain", + }: bitmap_candidates.append(candidate) native_shapes = _drop_source_diff_native_fill_residuals( _native_shapes_with_source_scan_priority(native_shapes), @@ -2144,7 +2167,9 @@ def _promote_large_card_containers_to_native_shapes( ) ] selected: list[ForegroundCandidate] = [] - for candidate in sorted(eligible, key=lambda item: _bbox_area_pixels(item.source_pixel_bbox), reverse=True): + for candidate in sorted( + eligible, key=lambda item: _bbox_area_pixels(item.source_pixel_bbox), reverse=True + ): if any( _bbox_contains_ratio(parent.source_pixel_bbox, candidate.source_pixel_bbox) >= 0.85 for parent in selected @@ -2163,7 +2188,14 @@ def _promote_large_card_containers_to_native_shapes( line_color="#1D4ED8", stroke_width=max(1.0, min(source_size) * 0.002), opacity=0.78, - radius=max(8.0, min(candidate.source_pixel_bbox[2] - candidate.source_pixel_bbox[0], candidate.source_pixel_bbox[3] - candidate.source_pixel_bbox[1]) * 0.04), + radius=max( + 8.0, + min( + candidate.source_pixel_bbox[2] - candidate.source_pixel_bbox[0], + candidate.source_pixel_bbox[3] - candidate.source_pixel_bbox[1], + ) + * 0.04, + ), confidence=max(candidate.confidence, 0.72), provenance={ **_as_dict(candidate.provenance), @@ -2176,7 +2208,9 @@ def _promote_large_card_containers_to_native_shapes( promoted_ids.add(candidate.candidate_id) if not promoted: return [], candidates - return promoted, [candidate for candidate in candidates if candidate.candidate_id not in promoted_ids] + return promoted, [ + candidate for candidate in candidates if candidate.candidate_id not in promoted_ids + ] def _is_large_card_container_candidate( @@ -2188,7 +2222,11 @@ def _is_large_card_container_candidate( native_shapes: list[NativeShapeSpec], min_child_count: int = 2, ) -> bool: - if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + if candidate.classification not in { + "complex_whole_visual", + "bitmap_asset_candidate", + "uncertain", + }: return False if not _is_source_base_difference_candidate(candidate): return False @@ -2201,7 +2239,9 @@ def _is_large_card_container_candidate( return False if not (0.25 <= width_ratio <= 0.78 and 0.35 <= height_ratio <= 0.78): return False - if bbox[0] <= round(source_size[0] * 0.015) or bbox[2] >= source_size[0] - round(source_size[0] * 0.015): + if bbox[0] <= round(source_size[0] * 0.015) or bbox[2] >= source_size[0] - round( + source_size[0] * 0.015 + ): return False if not _large_card_container_has_outline_signal(source_image, bbox): return False @@ -2213,13 +2253,15 @@ def _is_large_card_container_candidate( if other.candidate_id != candidate.candidate_id and other.classification in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"} and _bbox_contains_ratio(candidate.source_pixel_bbox, other.source_pixel_bbox) >= 0.85 - and _bbox_area_pixels(other.source_pixel_bbox) <= _bbox_area_pixels(candidate.source_pixel_bbox) * 0.45 + and _bbox_area_pixels(other.source_pixel_bbox) + <= _bbox_area_pixels(candidate.source_pixel_bbox) * 0.45 ] contained_native_shapes = [ shape for shape in native_shapes if _bbox_contains_ratio(candidate.source_pixel_bbox, shape.source_pixel_bbox) >= 0.85 - and _bbox_area_pixels(shape.source_pixel_bbox) <= _bbox_area_pixels(candidate.source_pixel_bbox) * 0.45 + and _bbox_area_pixels(shape.source_pixel_bbox) + <= _bbox_area_pixels(candidate.source_pixel_bbox) * 0.45 ] return len(contained_children) + len(contained_native_shapes) >= min_child_count @@ -2241,7 +2283,9 @@ def _large_card_container_has_outline_signal( vertical_bottom = min(image.height, bottom + search) edge_found = [ _scan_horizontal_card_outline(image, horizontal_left, horizontal_right, top, search, strip), - _scan_horizontal_card_outline(image, horizontal_left, horizontal_right, bottom, search, strip), + _scan_horizontal_card_outline( + image, horizontal_left, horizontal_right, bottom, search, strip + ), _scan_vertical_card_outline(image, vertical_top, vertical_bottom, left, search, strip), _scan_vertical_card_outline(image, vertical_top, vertical_bottom, right, search, strip), ] @@ -2292,7 +2336,9 @@ def _bbox_has_card_outline_signal( (left, top, left + strip, bottom), (right - strip, top, right, bottom), ] - return sum(_edge_region_has_coherent_card_outline(image, region) for region in edge_regions) >= 2 + return ( + sum(_edge_region_has_coherent_card_outline(image, region) for region in edge_regions) >= 2 + ) def _edge_region_has_coherent_card_outline( @@ -2452,7 +2498,11 @@ def _should_split_edge_spanning_bitmap_candidate( candidate: ForegroundCandidate, source_size: tuple[int, int], ) -> bool: - if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + if candidate.classification not in { + "complex_whole_visual", + "bitmap_asset_candidate", + "uncertain", + }: return False if not _is_source_base_difference_candidate(candidate): return False @@ -2462,14 +2512,23 @@ def _should_split_edge_spanning_bitmap_candidate( area_ratio = _bbox_area_pixels(bbox) / float(max(1, source_size[0] * source_size[1])) edge_margin = max(4, round(min(source_size) * 0.01)) spans_horizontal_edges = bbox[0] <= edge_margin and bbox[2] >= source_size[0] - edge_margin - return spans_horizontal_edges and width_ratio >= 0.90 and height_ratio >= 0.35 and area_ratio >= 0.18 + return ( + spans_horizontal_edges + and width_ratio >= 0.90 + and height_ratio >= 0.35 + and area_ratio >= 0.18 + ) def _should_split_large_layered_bitmap_candidate( candidate: ForegroundCandidate, source_size: tuple[int, int], ) -> bool: - if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + if candidate.classification not in { + "complex_whole_visual", + "bitmap_asset_candidate", + "uncertain", + }: return False if not _is_source_base_difference_candidate(candidate): return False @@ -2493,7 +2552,9 @@ def _drop_nested_bitmap_fragments( ) -> list[ForegroundCandidate]: kept: list[ForegroundCandidate] = [] for candidate in candidates: - if any(_bitmap_candidate_contains_fragment(container, candidate) for container in candidates): + if any( + _bitmap_candidate_contains_fragment(container, candidate) for container in candidates + ): continue kept.append(candidate) return kept @@ -2525,7 +2586,11 @@ def _bitmap_candidate_contains_fragment( return False if container.classification not in {"complex_whole_visual", "bitmap_asset_candidate"}: return False - if fragment.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + if fragment.classification not in { + "complex_whole_visual", + "bitmap_asset_candidate", + "uncertain", + }: return False container_area = _bbox_area_pixels(container.source_pixel_bbox) fragment_area = _bbox_area_pixels(fragment.source_pixel_bbox) @@ -2584,13 +2649,20 @@ def _bitmap_fragments_should_merge( if _box_distance_pixels(left.source_pixel_bbox, right.source_pixel_bbox) > max_gap: return False union = _union_bbox(left.source_pixel_bbox, right.source_pixel_bbox) - if _bbox_area_pixels(union) / float(max(1, source_size[0] * source_size[1])) > max_merged_page_area_ratio: + if ( + _bbox_area_pixels(union) / float(max(1, source_size[0] * source_size[1])) + > max_merged_page_area_ratio + ): return False - separate_area = _bbox_area_pixels(left.source_pixel_bbox) + _bbox_area_pixels(right.source_pixel_bbox) + separate_area = _bbox_area_pixels(left.source_pixel_bbox) + _bbox_area_pixels( + right.source_pixel_bbox + ) return _bbox_area_pixels(union) / float(max(1, separate_area)) <= max_union_growth -def _merged_bitmap_candidate(left: ForegroundCandidate, right: ForegroundCandidate) -> ForegroundCandidate: +def _merged_bitmap_candidate( + left: ForegroundCandidate, right: ForegroundCandidate +) -> ForegroundCandidate: merged_ids = [ *_as_dict(left.provenance).get("merged_candidate_ids", [left.candidate_id]), *_as_dict(right.provenance).get("merged_candidate_ids", [right.candidate_id]), @@ -2773,7 +2845,10 @@ def _source_foreground_pixels( if text_mask is not None and text_mask.getpixel((x, y)) > 0: continue red, green, blue = image.getpixel((x, y)) - if max(abs(red - background[0]), abs(green - background[1]), abs(blue - background[2])) > threshold: + if ( + max(abs(red - background[0]), abs(green - background[1]), abs(blue - background[2])) + > threshold + ): pixels.add((x, y)) return pixels @@ -2802,7 +2877,9 @@ def _source_connected_components(pixels: set[tuple[int, int]]) -> list[list[tupl stack.append(neighbor) component.append(neighbor) components.append(component) - components.sort(key=lambda item: (min(point[1] for point in item), min(point[0] for point in item))) + components.sort( + key=lambda item: (min(point[1] for point in item), min(point[0] for point in item)) + ) return components @@ -2964,7 +3041,8 @@ def _drop_text_boxes_covered_by_complex_bitmap_assets( ( asset.source_pixel_bbox for asset in complex_assets - if _bbox_contains_ratio(asset.source_pixel_bbox, text_box.source_pixel_bbox) >= min_cover_ratio + if _bbox_contains_ratio(asset.source_pixel_bbox, text_box.source_pixel_bbox) + >= min_cover_ratio and ( _text_box_has_approximate_ocr_layout(text_box) or _is_opaque_complex_bitmap_asset(asset) @@ -3016,9 +3094,7 @@ def _drop_native_shapes_covered_by_complex_bitmap_assets( min_cover_ratio: float = 0.85, ) -> list[NativeShapeSpec]: complex_asset_bboxes = [ - asset.source_pixel_bbox - for asset in bitmap_assets - if _is_opaque_complex_bitmap_asset(asset) + asset.source_pixel_bbox for asset in bitmap_assets if _is_opaque_complex_bitmap_asset(asset) ] if not complex_asset_bboxes: return native_shapes @@ -3215,7 +3291,10 @@ def _augment_bitmap_candidates_with_generic_visual_anchors( *[ bbox for bbox in row_bboxes - if not any(_bbox_iou(bbox, generic) >= 0.55 for generic in [*cover_lower_bboxes, *generic_bboxes]) + if not any( + _bbox_iou(bbox, generic) >= 0.55 + for generic in [*cover_lower_bboxes, *generic_bboxes] + ) ], ] if not anchor_bboxes: @@ -3224,7 +3303,10 @@ def _augment_bitmap_candidates_with_generic_visual_anchors( for index, bbox in enumerate(anchor_bboxes, start=1): if any(_bbox_iou(bbox, candidate.source_pixel_bbox) >= 0.55 for candidate in augmented): continue - if any(_bbox_contains_ratio(candidate.source_pixel_bbox, bbox) >= 0.85 for candidate in augmented): + if any( + _bbox_contains_ratio(candidate.source_pixel_bbox, bbox) >= 0.85 + for candidate in augmented + ): continue augmented.append( ForegroundCandidate( @@ -3480,7 +3562,10 @@ def _create_local_reconstruction_background( ) footer_top = _footer_top(source_rgb.size) if footer_top < source_rgb.height: - background.paste(source_rgb.crop((0, footer_top, source_rgb.width, source_rgb.height)), (0, footer_top)) + background.paste( + source_rgb.crop((0, footer_top, source_rgb.width, source_rgb.height)), + (0, footer_top), + ) background.save(output_path) return BackgroundResult( output_asset_path=str(output_path), @@ -3541,7 +3626,9 @@ def _sample_flat_background_pixels( pixels = image.load() for y in range(inset, max(inset, footer_top - inset), step_y): for x in range(inset, max(inset, image.width - inset), step_x): - if any(_point_inside_padded_bbox((x, y), bbox, padding=4) for bbox in foreground_bboxes): + if any( + _point_inside_padded_bbox((x, y), bbox, padding=4) for bbox in foreground_bboxes + ): continue samples.append(pixels[x, y]) return samples @@ -3597,16 +3684,12 @@ def _should_drop_bitmap_candidate( native_shapes=native_shapes, ): return True - if ( - native_shapes - and _bbox_area_pixels(bbox) / float(source_size[0] * source_size[1]) >= 0.80 - ): + if native_shapes and _bbox_area_pixels(bbox) / float(source_size[0] * source_size[1]) >= 0.80: return True if _bbox_overlaps_any_shape(bbox, native_shapes): return True - if ( - _is_source_base_difference_candidate(candidate) - and not _source_region_has_foreground_signal(source_image, bbox, source_background) + if _is_source_base_difference_candidate(candidate) and not _source_region_has_foreground_signal( + source_image, bbox, source_background ): return True if _tiny_diff_fragment(candidate): @@ -3635,7 +3718,11 @@ def _is_structural_container_bitmap_candidate( ) -> bool: if not native_shapes: return False - if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + if candidate.classification not in { + "complex_whole_visual", + "bitmap_asset_candidate", + "uncertain", + }: return False if not _is_source_base_difference_candidate(candidate): return False @@ -3663,12 +3750,12 @@ def _is_structural_container_bitmap_candidate( return child_area_in_page / float(candidate_area) >= min_child_coverage_ratio -def _native_shapes_with_source_scan_priority(shapes: list[NativeShapeSpec]) -> list[NativeShapeSpec]: +def _native_shapes_with_source_scan_priority( + shapes: list[NativeShapeSpec], +) -> list[NativeShapeSpec]: return sorted( shapes, - key=lambda shape: ( - 0 if _is_source_native_shape(shape) else 1, - ), + key=lambda shape: (0 if _is_source_native_shape(shape) else 1,), ) @@ -3677,11 +3764,7 @@ def _drop_source_diff_native_fill_residuals( *, source_image: Image.Image, ) -> list[NativeShapeSpec]: - source_scan_shapes = [ - shape - for shape in shapes - if _is_source_native_shape(shape) - ] + source_scan_shapes = [shape for shape in shapes if _is_source_native_shape(shape)] if not source_scan_shapes: return shapes filtered: list[NativeShapeSpec] = [] @@ -3704,9 +3787,11 @@ def _is_source_diff_native_fill_residual( continue if not _bbox_contains(container.source_pixel_bbox, shape.source_pixel_bbox): continue - if _bbox_area_pixels(shape.source_pixel_bbox) / float( - max(1, _bbox_area_pixels(container.source_pixel_bbox)) - ) > 0.10: + if ( + _bbox_area_pixels(shape.source_pixel_bbox) + / float(max(1, _bbox_area_pixels(container.source_pixel_bbox))) + > 0.10 + ): continue fill_rgb = _hex_color_to_rgb(container.fill_color) if fill_rgb is None: @@ -3759,7 +3844,11 @@ def _tiny_diff_fragment(candidate: ForegroundCandidate) -> bool: bbox = candidate.source_pixel_bbox width = max(0, bbox[2] - bbox[0]) height = max(0, bbox[3] - bbox[1]) - if candidate.classification not in {"uncertain", "complex_whole_visual", "bitmap_asset_candidate"}: + if candidate.classification not in { + "uncertain", + "complex_whole_visual", + "bitmap_asset_candidate", + }: return False return _bbox_area_pixels(bbox) <= 500 or max(width, height) <= 24 @@ -3767,7 +3856,11 @@ def _tiny_diff_fragment(candidate: ForegroundCandidate) -> bool: def _line_like_bitmap_residual(candidate: ForegroundCandidate) -> bool: if not _is_source_base_difference_candidate(candidate): return False - if candidate.classification not in {"complex_whole_visual", "bitmap_asset_candidate", "uncertain"}: + if candidate.classification not in { + "complex_whole_visual", + "bitmap_asset_candidate", + "uncertain", + }: return False bbox = candidate.source_pixel_bbox width = max(1, bbox[2] - bbox[0]) @@ -4003,7 +4096,9 @@ def _source_component_is_box_outline( ) -def _source_line_endpoints(component: list[tuple[int, int]]) -> tuple[tuple[int, int], tuple[int, int]]: +def _source_line_endpoints( + component: list[tuple[int, int]], +) -> tuple[tuple[int, int], tuple[int, int]]: points = sorted(component) first = min(points, key=lambda point: point[0] + point[1]) second = max(points, key=lambda point: point[0] + point[1]) @@ -4063,7 +4158,11 @@ def _repair_validation_report(page: PageManifest) -> ValidationReport: continue if attempt.target_id in fallback_asset_ids: continue - code = "repair_limit_exceeded" if attempt.reason.startswith("repair_limit_exceeded") else "repair_failed" + code = ( + "repair_limit_exceeded" + if attempt.reason.startswith("repair_limit_exceeded") + else "repair_failed" + ) issues.append( ValidationIssue( code=code, @@ -4141,11 +4240,15 @@ def _bitmap_coverage_validation_report(page: PageManifest) -> ValidationReport: page_area = max(1, page.source_image_size[0] * page.source_image_size[1]) largest_asset = max( source_preserved_assets, - key=lambda asset: _source_preserved_asset_effective_area_ratio(asset, page.source_image_size), + key=lambda asset: _source_preserved_asset_effective_area_ratio( + asset, page.source_image_size + ), ) largest_bbox = _clamp_bbox_to_size(largest_asset.source_pixel_bbox, page.source_image_size) largest_bbox_ratio = _bbox_area_pixels(largest_bbox) / float(page_area) - largest_ratio = _source_preserved_asset_effective_area_ratio(largest_asset, page.source_image_size) + largest_ratio = _source_preserved_asset_effective_area_ratio( + largest_asset, page.source_image_size + ) combined_bbox_ratio = _bbox_coverage_ratio( [asset.source_pixel_bbox for asset in source_preserved_assets], page.source_image_size, @@ -4235,14 +4338,10 @@ def _has_split_row_level_bitmap_structure( structure_count: int, ) -> bool: split_row_structure = ( - len(source_preserved_assets) >= 3 - and largest_ratio <= 0.35 - and structure_count >= 3 + len(source_preserved_assets) >= 3 and largest_ratio <= 0.35 and structure_count >= 3 ) dense_infographic_structure = ( - len(source_preserved_assets) >= 3 - and largest_ratio <= 0.60 - and structure_count >= 12 + len(source_preserved_assets) >= 3 and largest_ratio <= 0.60 and structure_count >= 12 ) return split_row_structure or dense_infographic_structure @@ -4259,7 +4358,10 @@ def _visual_text_coverage_issues( for candidate_bbox in visual_text_candidates: if candidate_bbox in non_blocking: continue - if any(_text_box_covers_visual_candidate(text_bbox, candidate_bbox) for text_bbox in covered_boxes): + if any( + _text_box_covers_visual_candidate(text_bbox, candidate_bbox) + for text_bbox in covered_boxes + ): continue issues.append( { @@ -4359,14 +4461,14 @@ def _has_summary_like_visual_text_candidate_pattern( for bbox in visual_text_candidates ) has_bottom_helper_or_qr_line = any( - bbox[0] >= width * 0.72 - and bbox[1] >= height * 0.72 - for bbox in visual_text_candidates + bbox[0] >= width * 0.72 and bbox[1] >= height * 0.72 for bbox in visual_text_candidates ) return has_upper_summary_line and has_body_line and has_bottom_helper_or_qr_line -def _summary_slide_recovery_bboxes(source_image_size: tuple[int, int]) -> list[tuple[int, int, int, int]]: +def _summary_slide_recovery_bboxes( + source_image_size: tuple[int, int], +) -> list[tuple[int, int, int, int]]: width, height = source_image_size return [ (round(width * 0.28), round(height * 0.22), round(width * 0.72), round(height * 0.29)), @@ -4464,7 +4566,8 @@ def _is_right_side_spec_stack_text_box( same_column = [ other for other in stack_boxes - if abs(((other.source_pixel_bbox[0] + other.source_pixel_bbox[2]) / 2.0) - center_x) <= width * 0.095 + if abs(((other.source_pixel_bbox[0] + other.source_pixel_bbox[2]) / 2.0) - center_x) + <= width * 0.095 ] if text_box not in same_column: return False @@ -4477,8 +4580,13 @@ def _is_right_side_spec_stack_text_box( if len(same_column) >= 4: return True ordered = sorted(same_column, key=lambda item: item.source_pixel_bbox[1]) - neighbor_centers = [(item.source_pixel_bbox[1] + item.source_pixel_bbox[3]) / 2.0 for item in ordered] - return len(neighbor_centers) >= 3 and max(neighbor_centers) - min(neighbor_centers) <= height * 0.22 + neighbor_centers = [ + (item.source_pixel_bbox[1] + item.source_pixel_bbox[3]) / 2.0 for item in ordered + ] + return ( + len(neighbor_centers) >= 3 + and max(neighbor_centers) - min(neighbor_centers) <= height * 0.22 + ) def _right_side_spec_stack_has_visual_column_anchors( @@ -4583,7 +4691,9 @@ def _visual_candidate_can_anchor_text( def _is_short_section_label_text(text: str) -> bool: stripped = str(text).strip() - meaningful = "".join(char for char in stripped if char.isalnum() or "\u3400" <= char <= "\u9fff") + meaningful = "".join( + char for char in stripped if char.isalnum() or "\u3400" <= char <= "\u9fff" + ) cjk_count = sum(1 for char in meaningful if "\u3400" <= char <= "\u9fff") return 2 <= cjk_count <= 5 and meaningful.endswith(("域", "区", "层", "类", "项", "栏")) @@ -4595,9 +4705,7 @@ def _is_large_top_text_box( ) -> bool: left, top, right, bottom = bbox return ( - top <= image_height * 0.12 - and (right - left) >= image_width * 0.25 - and (bottom - top) >= 20 + top <= image_height * 0.12 and (right - left) >= image_width * 0.25 and (bottom - top) >= 20 ) @@ -4732,7 +4840,9 @@ def _is_replaced_by_focused_recovery( or recovered_text in original_text ): continue - if _text_box_covers_visual_candidate(text_box.source_pixel_bbox, recovered.source_pixel_bbox): + if _text_box_covers_visual_candidate( + text_box.source_pixel_bbox, recovered.source_pixel_bbox + ): return True if _approximate_text_box_is_near_focused_recovery( text_box.source_pixel_bbox, @@ -4795,13 +4905,19 @@ def _is_recovered_ocr_noise_text(text: str) -> bool: stripped = text.strip() if not stripped: return True - meaningful = [char for char in stripped if not char.isspace() and char not in ".,;:!?,。;:!?、·-—_()()[]【】{}<>《》/\\|"] + meaningful = [ + char + for char in stripped + if not char.isspace() and char not in ".,;:!?,。;:!?、·-—_()()[]【】{}<>《》/\\|" + ] if not meaningful: return True unique_meaningful = set(meaningful) digit_count = sum(1 for char in meaningful if char.isdigit()) cjk_count = sum(1 for char in meaningful if "\u3400" <= char <= "\u9fff") - alpha_count = sum(1 for char in meaningful if char.isalpha() and not ("\u3400" <= char <= "\u9fff")) + alpha_count = sum( + 1 for char in meaningful if char.isalpha() and not ("\u3400" <= char <= "\u9fff") + ) if cjk_count >= 3 and len(unique_meaningful) <= 2: return True if "输入文本" in stripped: @@ -4833,7 +4949,11 @@ def _recovered_ocr_duplicates_existing_text( existing_text = _normalize_text_for_duplicate_check(getattr(text_box, "text", "")) if not existing_text: continue - if normalized != existing_text and normalized not in existing_text and existing_text not in normalized: + if ( + normalized != existing_text + and normalized not in existing_text + and existing_text not in normalized + ): continue existing_bbox = text_box.source_pixel_bbox if ( @@ -4846,7 +4966,9 @@ def _recovered_ocr_duplicates_existing_text( def _normalize_text_for_duplicate_check(text: str) -> str: - return "".join(char for char in str(text).lower() if char.isalnum() or "\u3400" <= char <= "\u9fff") + return "".join( + char for char in str(text).lower() if char.isalnum() or "\u3400" <= char <= "\u9fff" + ) def _recovered_text_box_from_candidate( diff --git a/src/generative_editable_preview_validator.py b/src/generative_editable_preview_validator.py index 495a6ba..13ae0b3 100644 --- a/src/generative_editable_preview_validator.py +++ b/src/generative_editable_preview_validator.py @@ -141,9 +141,8 @@ def validate_composed_deck_structure( ) expected_width, expected_height = slide_dimensions(deck.aspect_ratio) - if ( - presentation is not None - and (presentation.slide_width != expected_width or presentation.slide_height != expected_height) + if presentation is not None and ( + presentation.slide_width != expected_width or presentation.slide_height != expected_height ): issues.append( ValidationIssue( @@ -191,7 +190,9 @@ def validate_composed_deck_structure( _validate_page_assets(page, root, issues) _validate_slide_background_identity(page, root, pptx_path, page_index, issues) source_background_used = _uses_full_slide_source_background(page, root) - editable_object_count = len(page.text_boxes) + len(page.native_shapes) + len(page.bitmap_assets) + editable_object_count = ( + len(page.text_boxes) + len(page.native_shapes) + len(page.bitmap_assets) + ) if source_background_used and editable_object_count == 0: issues.append( ValidationIssue( @@ -206,20 +207,17 @@ def validate_composed_deck_structure( }, ) ) - if ( - _has_non_empty_text(page) - and ( - source_background_used - or ( - presentation is not None - and page_index < len(presentation.slides) - and _slide_contains_full_slide_source_picture( - page, - presentation.slides[page_index], - root, - presentation.slide_width, - presentation.slide_height, - ) + if _has_non_empty_text(page) and ( + source_background_used + or ( + presentation is not None + and page_index < len(presentation.slides) + and _slide_contains_full_slide_source_picture( + page, + presentation.slides[page_index], + root, + presentation.slide_width, + presentation.slide_height, ) ) ): @@ -431,7 +429,11 @@ def _text_dense_editable_preview_drift_analysis( native_shape_count = len(page_manifest.native_shapes) editable_object_count = text_box_count + bitmap_count + native_shape_count if text_box_count < 12 or editable_object_count < 20 or bitmap_count + native_shape_count < 4: - return {**rejected, "text_box_count": text_box_count, "editable_object_count": editable_object_count} + return { + **rejected, + "text_box_count": text_box_count, + "editable_object_count": editable_object_count, + } background_ref = str(page_manifest.chosen_background or "") source_ref = str(page_manifest.source_image_path or "") if background_ref and source_ref and background_ref == source_ref: @@ -479,7 +481,9 @@ def _changed_pixel_text_overlap_metrics( source_size = page_manifest.source_image_size padding = max(8, round(min(changed_mask.size) * TEXT_DENSE_TEXT_REGION_PADDING_RATIO)) for text_box in page_manifest.text_boxes: - left, top, right, bottom = _scale_bbox(text_box.source_pixel_bbox, source_size, changed_mask.size) + left, top, right, bottom = _scale_bbox( + text_box.source_pixel_bbox, source_size, changed_mask.size + ) draw.rectangle( ( max(0, left - padding), @@ -504,8 +508,10 @@ def _changed_pixel_mask( *, changed_pixel_delta_threshold: int, ) -> Image.Image: - return ImageChops.difference(source_rgb, preview_rgb).convert("L").point( - lambda pixel: 255 if pixel > changed_pixel_delta_threshold else 0 + return ( + ImageChops.difference(source_rgb, preview_rgb) + .convert("L") + .point(lambda pixel: 255 if pixel > changed_pixel_delta_threshold else 0) ) @@ -746,7 +752,9 @@ def _validate_bitmap_asset_provenance( asset, issues: list[ValidationIssue], ) -> None: - if _is_allowed_complex_source_preserved_asset(asset) or _is_allowed_clean_fallback_icon_source_preserved_asset(page, asset): + if _is_allowed_complex_source_preserved_asset( + asset + ) or _is_allowed_clean_fallback_icon_source_preserved_asset(page, asset): return provenance_text = _compact_text(asset.provenance) asset_path_text = _compact_text(asset.asset_path) @@ -802,7 +810,8 @@ def _is_allowed_clean_fallback_icon_source_preserved_asset(page: PageManifest, a and provenance.get("alpha_strategy") == "opaque_source_crop" and provenance.get("asset_sheet_skipped_reason") == "icon_source_preserved" and candidate_provenance.get("source") == "vlm_bitmap_region" - and str(candidate_provenance.get("vlm_type") or "").lower() in {"icon", "ui_icon", "small_icon"} + and str(candidate_provenance.get("vlm_type") or "").lower() + in {"icon", "ui_icon", "small_icon"} and bool(provenance.get("original_source_pixel_bbox")) and _is_small_source_crop_asset(page, asset) and not provenance.get("asset_sheet_provider_failed") @@ -906,7 +915,9 @@ def _uses_full_slide_source_background(page: PageManifest, root: Path) -> bool: return False if bg.size != src.size: bg = bg.resize(src.size) - if _chosen_background_is_text_clean(page) and _text_mask_region_changed(page, root, src, bg): + if _chosen_background_is_text_clean(page) and _text_mask_region_changed( + page, root, src, bg + ): return False mean_delta, changed_ratio = _image_delta_metrics( src, @@ -922,7 +933,10 @@ def _uses_full_slide_source_background(page: PageManifest, root: Path) -> bool: def _chosen_background_is_text_clean(page: PageManifest) -> bool: - if str(page.provenance.get("chosen_background_kind", "")).lower() == "source_preserving_text_clean": + if ( + str(page.provenance.get("chosen_background_kind", "")).lower() + == "source_preserving_text_clean" + ): return True if page.chosen_background and page.chosen_background == page.text_clean_background: return True @@ -933,7 +947,10 @@ def _chosen_background_is_text_clean(page: PageManifest) -> bool: record = backgrounds.get(key) if not isinstance(record, dict): continue - if record.get("output_asset_ref") and record.get("output_asset_ref") != page.chosen_background: + if ( + record.get("output_asset_ref") + and record.get("output_asset_ref") != page.chosen_background + ): continue if record.get("provider_role") == "local" and record.get("prompt_id") in { "local_text_cleanup", @@ -1023,7 +1040,9 @@ def _slide_contains_full_slide_source_picture( return False -def _validate_required_text(page: PageManifest, slide, issues: list[ValidationIssue], aspect_ratio: str) -> None: +def _validate_required_text( + page: PageManifest, slide, issues: list[ValidationIssue], aspect_ratio: str +) -> None: actual_counts: dict[str, int] = {} for shape in slide.shapes: if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX: @@ -1039,7 +1058,8 @@ def _validate_required_text(page: PageManifest, slide, issues: list[ValidationIs matching_shapes = [ shape for shape in slide.shapes - if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX and _normalize_text(shape.text) == normalized + if shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX + and _normalize_text(shape.text) == normalized ] if len(matching_shapes) < seen_expected[normalized]: issues.append( @@ -1059,7 +1079,12 @@ def _validate_required_text(page: PageManifest, slide, issues: list[ValidationIs continue matched_shape = matching_shapes[seen_expected[normalized] - 1] expected_rect = _slide_rect_for_bbox(page, text_box.source_pixel_bbox, aspect_ratio) - actual_rect = (matched_shape.left, matched_shape.top, matched_shape.width, matched_shape.height) + actual_rect = ( + matched_shape.left, + matched_shape.top, + matched_shape.width, + matched_shape.height, + ) if not _text_rect_close(actual_rect, expected_rect, text_box): issues.append( ValidationIssue( @@ -1121,20 +1146,35 @@ def _validate_object_identity( slide_width: int, slide_height: int, ) -> None: - relevant_shapes = [shape for shape in slide.shapes if _shape_role(shape) in {"picture", "native_shape", "text"}] + relevant_shapes = [ + shape for shape in slide.shapes if _shape_role(shape) in {"picture", "native_shape", "text"} + ] expected_index = 0 for shape_index, shape_spec in enumerate(page.native_shapes): actual_shape = relevant_shapes[expected_index] if shape_spec.shape_type == "line": - if actual_shape.shape_type != MSO_SHAPE_TYPE.LINE or not _line_close(page, shape_spec, actual_shape, aspect_ratio): - _append_identity_issue(page, issues, "native_shape", str(shape_index), expected_index) + if actual_shape.shape_type != MSO_SHAPE_TYPE.LINE or not _line_close( + page, shape_spec, actual_shape, aspect_ratio + ): + _append_identity_issue( + page, issues, "native_shape", str(shape_index), expected_index + ) elif actual_shape.shape_type != MSO_SHAPE_TYPE.AUTO_SHAPE: _append_identity_issue(page, issues, "native_shape", str(shape_index), expected_index) else: expected_rect = _slide_rect_for_bbox(page, shape_spec.source_pixel_bbox, aspect_ratio) - actual_rect = (actual_shape.left, actual_shape.top, actual_shape.width, actual_shape.height) - if not _native_auto_shape_type_matches(shape_spec, actual_shape) or not _rect_close(actual_rect, expected_rect): - _append_identity_issue(page, issues, "native_shape", str(shape_index), expected_index) + actual_rect = ( + actual_shape.left, + actual_shape.top, + actual_shape.width, + actual_shape.height, + ) + if not _native_auto_shape_type_matches(shape_spec, actual_shape) or not _rect_close( + actual_rect, expected_rect + ): + _append_identity_issue( + page, issues, "native_shape", str(shape_index), expected_index + ) expected_index += 1 for asset in sorted(page.bitmap_assets, key=lambda item: item.z_order): _validate_picture_identity( @@ -1255,7 +1295,15 @@ def _validate_picture_identity( expected_sha = _image_sha1(expected_path) if expected_path is not None else "" actual_sha = getattr(root_shape.image, "sha1", "") if expected_sha and actual_sha != expected_sha: - _append_identity_issue(page, issues, target_kind, target_id, index, expected_sha=expected_sha, actual_sha=actual_sha) + _append_identity_issue( + page, + issues, + target_kind, + target_id, + index, + expected_sha=expected_sha, + actual_sha=actual_sha, + ) return if expected_rect is None and expected_bbox is not None: expected_rect = _slide_rect_for_bbox(page, expected_bbox, aspect_ratio) @@ -1329,10 +1377,14 @@ def _aspect_ratio_from_page(page: PageManifest) -> str: def _rect_close(actual: tuple[int, int, int, int], expected: tuple[int, int, int, int]) -> bool: - return all(abs(actual[index] - expected[index]) <= SLIDE_POSITION_TOLERANCE_EMU for index in range(4)) + return all( + abs(actual[index] - expected[index]) <= SLIDE_POSITION_TOLERANCE_EMU for index in range(4) + ) -def _text_rect_close(actual: tuple[int, int, int, int], expected: tuple[int, int, int, int], text_box) -> bool: +def _text_rect_close( + actual: tuple[int, int, int, int], expected: tuple[int, int, int, int], text_box +) -> bool: if not getattr(text_box, "style_hints", {}).get("approximate_layout"): return _rect_close(actual, expected) left_close = abs(actual[0] - expected[0]) <= SLIDE_POSITION_TOLERANCE_EMU @@ -1351,31 +1403,34 @@ def _native_auto_shape_type_matches(shape_spec, actual_shape) -> bool: return expected is not None and getattr(actual_shape, "auto_shape_type", None) == expected -def _line_close(page: PageManifest, shape_spec, actual_shape, aspect_ratio: str | None = None) -> bool: - start = shape_spec.line_start or (shape_spec.source_pixel_bbox[0], shape_spec.source_pixel_bbox[1]) +def _line_close( + page: PageManifest, shape_spec, actual_shape, aspect_ratio: str | None = None +) -> bool: + start = shape_spec.line_start or ( + shape_spec.source_pixel_bbox[0], + shape_spec.source_pixel_bbox[1], + ) end = shape_spec.line_end or (shape_spec.source_pixel_bbox[2], shape_spec.source_pixel_bbox[3]) expected_start = _slide_point_for_source_pixel(page, start, aspect_ratio) expected_end = _slide_point_for_source_pixel(page, end, aspect_ratio) actual_start = (actual_shape.left, actual_shape.top) actual_end = (actual_shape.left + actual_shape.width, actual_shape.top + actual_shape.height) return ( - _point_close(actual_start, expected_start) - and _point_close(actual_end, expected_end) - ) or ( - _point_close(actual_start, expected_end) - and _point_close(actual_end, expected_start) - ) or _rect_close( - (actual_shape.left, actual_shape.top, actual_shape.width, actual_shape.height), - _slide_rect_for_bbox( - page, - ( - min(start[0], end[0]), - min(start[1], end[1]), - max(start[0], end[0]), - max(start[1], end[1]), + (_point_close(actual_start, expected_start) and _point_close(actual_end, expected_end)) + or (_point_close(actual_start, expected_end) and _point_close(actual_end, expected_start)) + or _rect_close( + (actual_shape.left, actual_shape.top, actual_shape.width, actual_shape.height), + _slide_rect_for_bbox( + page, + ( + min(start[0], end[0]), + min(start[1], end[1]), + max(start[0], end[0]), + max(start[1], end[1]), + ), + aspect_ratio, ), - aspect_ratio, - ), + ) ) @@ -1384,15 +1439,21 @@ def _slide_point_for_source_pixel( point: tuple[int, int], aspect_ratio: str | None = None, ) -> tuple[int, int]: - left, top, _, _ = _slide_rect_for_bbox(page, (point[0], point[1], point[0] + 1, point[1] + 1), aspect_ratio) + left, top, _, _ = _slide_rect_for_bbox( + page, (point[0], point[1], point[0] + 1, point[1] + 1), aspect_ratio + ) return left, top def _point_close(actual: tuple[int, int], expected: tuple[int, int]) -> bool: - return all(abs(actual[index] - expected[index]) <= SLIDE_POSITION_TOLERANCE_EMU for index in range(2)) + return all( + abs(actual[index] - expected[index]) <= SLIDE_POSITION_TOLERANCE_EMU for index in range(2) + ) -def _same_aspect_ratio(first: tuple[int, int], second: tuple[int, int], tolerance: float = 0.01) -> bool: +def _same_aspect_ratio( + first: tuple[int, int], second: tuple[int, int], tolerance: float = 0.01 +) -> bool: return abs((first[0] / first[1]) - (second[0] / second[1])) <= tolerance @@ -1417,7 +1478,9 @@ def _image_delta_metrics( lambda value: 255 if value > changed_pixel_delta_threshold else 0, mode="L", ) - changed_pixel_ratio = sum(ImageStat.Stat(grayscale).sum) / (255.0 * source.width * source.height) + changed_pixel_ratio = sum(ImageStat.Stat(grayscale).sum) / ( + 255.0 * source.width * source.height + ) return mean_abs_delta, changed_pixel_ratio diff --git a/src/generative_editable_providers.py b/src/generative_editable_providers.py index 665c765..8ca945c 100644 --- a/src/generative_editable_providers.py +++ b/src/generative_editable_providers.py @@ -440,7 +440,9 @@ def extract_text(self, image_path: str) -> OCRResult: with Image.open(path) as image: width, height = image.size image_base64 = base64.b64encode(path.read_bytes()).decode() - prompt = _focused_crop_ocr_prompt() if _is_focused_ocr_crop_path(path) else _ocr_json_prompt() + prompt = ( + _focused_crop_ocr_prompt() if _is_focused_ocr_crop_path(path) else _ocr_json_prompt() + ) if _is_paddleocr_vl_model(self.config.model): payload = _openai_chat_payload( model=self.config.model, @@ -673,7 +675,10 @@ def _fake_background_fill_color(image: Image.Image, mask: Image.Image) -> tuple[ candidates.append(value if isinstance(value, tuple) else (value,)) if candidates: channels = len(candidates[0]) - return tuple(round(sum(pixel[index] for pixel in candidates) / len(candidates)) for index in range(channels)) + return tuple( + round(sum(pixel[index] for pixel in candidates) / len(candidates)) + for index in range(channels) + ) value = image.resize((1, 1)).getpixel((0, 0)) return value if isinstance(value, tuple) else (value,) @@ -1094,10 +1099,7 @@ def _extract_ocr_text_items( image_size: tuple[int, int], ) -> list[OCRTextItem]: try: - return [ - _ocr_text_item_from_payload(item, config) - for item in _extract_ocr_items(payload) - ] + return [_ocr_text_item_from_payload(item, config) for item in _extract_ocr_items(payload)] except ProviderError: scalar_text = _extract_safe_json_scalar_ocr_text(payload) if scalar_text is None: @@ -1133,10 +1135,10 @@ def _ocr_json_prompt() -> str: return ( "You are an OCR engine for presentation slides. Identify every visible text " "span in the attached image. Output JSON only, with no markdown and no prose. " - "Schema: {\"items\":[{\"text\":\"...\",\"bbox\":[left,top,right,bottom]," - "\"confidence\":0.0,\"font_size\":32,\"color\":\"#RRGGBB\"," - "\"alignment\":\"left\"}]}. Coordinates must be source-image pixels. " - "If there is no visible text, output {\"items\":[]}.\n" + 'Schema: {"items":[{"text":"...","bbox":[left,top,right,bottom],' + '"confidence":0.0,"font_size":32,"color":"#RRGGBB",' + '"alignment":"left"}]}. Coordinates must be source-image pixels. ' + 'If there is no visible text, output {"items":[]}.\n' "你是演示文稿 OCR 引擎。只输出 JSON,不要解释、不要 Markdown。" ) @@ -1205,7 +1207,9 @@ def _approximate_line_ocr_items( style_hints={ "source": "paddleocr_vl_plain_text", "approximate_layout": True, - "layout_source": "image_projection" if estimate is not None else "uniform_fallback", + "layout_source": "image_projection" + if estimate is not None + else "uniform_fallback", }, color_hex=color_hex, provenance={ @@ -1214,7 +1218,9 @@ def _approximate_line_ocr_items( "model": config.model, "item_id": f"paddleocr-vl-line-{index}", "approximate_layout": True, - "layout_source": "image_projection" if estimate is not None else "uniform_fallback", + "layout_source": "image_projection" + if estimate is not None + else "uniform_fallback", }, ) ) @@ -1316,7 +1322,7 @@ def _estimate_text_line_layouts_from_components( red = array[:, :, 0].astype("int16") green = array[:, :, 1].astype("int16") blue = array[:, :, 2].astype("int16") - luma = (0.299 * red + 0.587 * green + 0.114 * blue) + luma = 0.299 * red + 0.587 * green + 0.114 * blue saturation = np.maximum.reduce([red, green, blue]) - np.minimum.reduce([red, green, blue]) masks = [ (((luma >= 145) & (saturation <= 80)) | (luma >= 215)).astype("uint8"), @@ -1415,7 +1421,9 @@ def _build_detected_text_lines( _DetectedTextLine( bbox=expanded, color_hex=_dominant_text_color(image, expanded), - font_size=_font_size_points_from_pixel_height(expanded[3] - expanded[1], height), + font_size=_font_size_points_from_pixel_height( + expanded[3] - expanded[1], height + ), component_count=len(line_components), ) ) @@ -1485,11 +1493,7 @@ def _match_detected_lines_to_text( matched.append(None) continue remaining.remove(best) - remaining = [ - line - for line in remaining - if not _detected_line_is_fragment_of(line, best) - ] + remaining = [line for line in remaining if not _detected_line_is_fragment_of(line, best)] matched.append((best.bbox, best.color_hex, best.font_size)) return matched @@ -1502,18 +1506,12 @@ def _preferred_detected_lines_for_text( stripped = text.strip() if _looks_like_domain_label(stripped): domain_labels = [ - line - for line in viable - if _is_left_domain_label_candidate(line, image_width) + line for line in viable if _is_left_domain_label_candidate(line, image_width) ] return domain_labels or viable if not _looks_like_parameter_line(stripped): return viable - right_column = [ - line - for line in viable - if _is_right_column_text_candidate(line, image_width) - ] + right_column = [line for line in viable if _is_right_column_text_candidate(line, image_width)] return right_column or viable @@ -1586,7 +1584,9 @@ def _text_line_match_score( expected_width = max(10.0, units * box_height * 0.9) ratio = box_width / expected_width width_score = abs(ratio - 1.0) if ratio < 2 else min(3.0, ratio / 2.0) - single_component_penalty = 0.15 if line.component_count == 1 and box_width > image_width * 0.2 else 0.0 + single_component_penalty = ( + 0.15 if line.component_count == 1 and box_width > image_width * 0.2 else 0.0 + ) if _looks_like_domain_label(text.strip()): order_penalty = 0.0 elif _looks_like_parameter_line(text.strip()): @@ -1735,7 +1735,17 @@ def _parse_tesseract_tsv( return [] header = lines[0].split("\t") index = {name: position for position, name in enumerate(header)} - required = {"block_num", "par_num", "line_num", "left", "top", "width", "height", "conf", "text"} + required = { + "block_num", + "par_num", + "line_num", + "left", + "top", + "width", + "height", + "conf", + "text", + } if not required.issubset(index): raise ProviderError( provider_role=config.role, @@ -1780,7 +1790,9 @@ def _parse_tesseract_tsv( y2 = min(max_height, max(word["bbox"][3] for word in words)) if x2 <= x1 or y2 <= y1: continue - confidence = max(0.0, min(1.0, sum(word["confidence"] for word in words) / len(words) / 100)) + confidence = max( + 0.0, min(1.0, sum(word["confidence"] for word in words) / len(words) / 100) + ) items.append( OCRTextItem( text=" ".join(word["text"] for word in words), @@ -1901,7 +1913,9 @@ def _ocr_text_item_from_payload(item: dict[str, Any], config: ProviderConfig) -> confidence=float(item.get("confidence", 1.0)), font_family_hint=str(item.get("font_family", "")), font_size_hint=_optional_float(item.get("font_size", item.get("font_size_hint"))), - style_hints=dict(item.get("style_hints", {})) if isinstance(item.get("style_hints"), dict) else {}, + style_hints=dict(item.get("style_hints", {})) + if isinstance(item.get("style_hints"), dict) + else {}, color_hex=str(item.get("color", item.get("color_hex", "#000000"))), alignment=alignment, # type: ignore[arg-type] provenance={ diff --git a/src/generative_editable_shape_fitter.py b/src/generative_editable_shape_fitter.py index cb14117..f927fbe 100644 --- a/src/generative_editable_shape_fitter.py +++ b/src/generative_editable_shape_fitter.py @@ -51,7 +51,9 @@ def fit_native_shape( or _line_start(candidate.source_pixel_bbox), line_end=_line_endpoint(provenance.get("line_end")) or _line_end(candidate.source_pixel_bbox), - stroke_width=float(provenance.get("stroke_width") or _line_stroke_width(candidate.source_pixel_bbox)), + stroke_width=float( + provenance.get("stroke_width") or _line_stroke_width(candidate.source_pixel_bbox) + ), opacity=1.0, confidence=candidate.confidence, provenance={ @@ -91,7 +93,10 @@ def fit_native_shape_with_fallback( if shape is not None: return ShapeFitResult(native_shape=shape) provenance = _provenance_dict(candidate.provenance) - if candidate.classification == "native_shape_candidate" and candidate.confidence < min_confidence: + if ( + candidate.classification == "native_shape_candidate" + and candidate.confidence < min_confidence + ): reason = "below_native_shape_confidence_threshold" elif candidate.classification == "native_shape_candidate": reason = "unsupported_or_disabled_native_shape" @@ -130,7 +135,9 @@ def _line_endpoint(value: object) -> tuple[int, int] | None: return None -def _validate_candidate_bounds(bbox: tuple[int, int, int, int], image_size: tuple[int, int]) -> None: +def _validate_candidate_bounds( + bbox: tuple[int, int, int, int], image_size: tuple[int, int] +) -> None: left, top, right, bottom = bbox width, height = image_size if min(bbox) < 0 or right <= left or bottom <= top or right > width or bottom > height: diff --git a/src/generative_editable_text.py b/src/generative_editable_text.py index 1b695e6..64b7a0a 100644 --- a/src/generative_editable_text.py +++ b/src/generative_editable_text.py @@ -140,18 +140,16 @@ def extract_text_with_validation( ) -def _text_box_from_metadata_and_ocr( - metadata: dict[str, Any], ocr_item: OCRTextItem -) -> TextBoxSpec: +def _text_box_from_metadata_and_ocr(metadata: dict[str, Any], ocr_item: OCRTextItem) -> TextBoxSpec: style_hint = metadata.get("style_hint") or {} font_family = style_hint.get("font_family") or _font_family_for_ocr_item(ocr_item) provenance = { "content_source": "aippt_metadata", "layout_source": "ocr", "ocr_confidence": ocr_item.confidence, - "ocr_provenance": sanitize_persisted_payload( - {"payload": dict(ocr_item.provenance)} - ).get("payload", {}), + "ocr_provenance": sanitize_persisted_payload({"payload": dict(ocr_item.provenance)}).get( + "payload", {} + ), "metadata_role": metadata.get("role", ""), "metadata_order": metadata.get("order"), } @@ -303,11 +301,7 @@ def _is_likely_spurious_ocr_item( image_area = max(1, image_size[0] * image_size[1]) if alnum_count <= 1 and bbox_area <= 500: return True - return ( - alnum_count <= 3 - and bbox_area / float(image_area) >= 0.15 - and not text.isalnum() - ) + return alnum_count <= 3 and bbox_area / float(image_area) >= 0.15 and not text.isalnum() def _should_ignore_spurious_ocr_item( @@ -348,7 +342,10 @@ def _should_ignore_duplicate_approximate_ocr_item( key = _normalize_match_text(ocr_item.text) if len(key) < 4: return False - return any(_approximate_bboxes_overlap(ocr_item.bbox, kept_bbox) for kept_bbox in kept_bboxes.get(key, [])) + return any( + _approximate_bboxes_overlap(ocr_item.bbox, kept_bbox) + for kept_bbox in kept_bboxes.get(key, []) + ) def _record_kept_approximate_ocr_item( diff --git a/src/generative_editable_text_candidates.py b/src/generative_editable_text_candidates.py index a295227..14cf38c 100644 --- a/src/generative_editable_text_candidates.py +++ b/src/generative_editable_text_candidates.py @@ -57,9 +57,8 @@ def _detect_left_blue_label_bboxes(image_path: str | Path) -> list[BBox]: red = rgb[:, :, 0].astype(np.int16) green = rgb[:, :, 1].astype(np.int16) blue = rgb[:, :, 2].astype(np.int16) - mask = ( - ((blue >= 120) & (green >= 70) & ((blue - red) >= 25)) - | ((green >= 120) & (blue >= 120) & (red <= 140)) + mask = ((blue >= 120) & (green >= 70) & ((blue - red) >= 25)) | ( + (green >= 120) & (blue >= 120) & (red <= 140) ) x_limit = max(1, int(width * 0.35)) left_mask = mask[:, :x_limit] diff --git a/src/generative_editable_vlm_reconstruction.py b/src/generative_editable_vlm_reconstruction.py index ce0c56e..1c9de76 100644 --- a/src/generative_editable_vlm_reconstruction.py +++ b/src/generative_editable_vlm_reconstruction.py @@ -25,7 +25,10 @@ from .generative_editable_config import ProviderConfig from .generative_editable_assets import build_asset_sheet_request, slice_asset_sheet_by_components -from .generative_editable_backgrounds import BackgroundResult, create_source_preserving_text_background +from .generative_editable_backgrounds import ( + BackgroundResult, + create_source_preserving_text_background, +) from .generative_editable_composer import compose_deck_from_manifests from .generative_editable_foreground_planner import ForegroundCandidate from .generative_editable_job_artifacts import GenerativeEditableJobArtifacts @@ -247,7 +250,9 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180) -> VLMPag return analysis except ProviderError as exc: compatibility_error = _is_vlm_payload_shape_compatibility_error(exc) - retrying = bool(attempt_index < len(payloads) and (exc.retryable or compatibility_error)) + retrying = bool( + attempt_index < len(payloads) and (exc.retryable or compatibility_error) + ) self.last_payload_attempts.append( _provider_attempt_error_payload( exc, @@ -293,8 +298,8 @@ def _analysis_from_response(self, response: dict[str, Any]) -> VLMPageAnalysis: operation="vlm_page_analysis", message="VLM provider response JSON must be an object", retryable=False, - secret_values=[self.config.api_key, self.config.base_url], - ) + secret_values=[self.config.api_key, self.config.base_url], + ) return coerce_vlm_analysis(parsed) @@ -351,7 +356,9 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180) -> VLMPag def _default_vlm_preview_validator(**kwargs) -> ValidationReport: if kwargs.get("require_preview_validation") is False: return ValidationReport(status="passed", checked_pages=1, issues=[]) - gates = quality_threshold_to_preview_gates(float(kwargs.get("preview_similarity_threshold", 0.92))) + gates = quality_threshold_to_preview_gates( + float(kwargs.get("preview_similarity_threshold", 0.92)) + ) return validate_preview_similarity( source_image_path=kwargs["source_image_path"], preview=kwargs["preview"], @@ -485,7 +492,7 @@ def _validate_vlm_pipeline_output( deck_manifest_path=deck_manifest_path, artifact_root=artifact_root, pptx_path=pptx_path, - ) + ), ] for page in page_manifests: preview = _render_vlm_preview( @@ -509,7 +516,9 @@ def _validate_vlm_pipeline_output( return _merge_validation_reports(reports, checked_pages=len(page_manifests)) -def _validate_vlm_source_preserved_bitmap_coverage(page_manifests: list[PageManifest]) -> ValidationReport: +def _validate_vlm_source_preserved_bitmap_coverage( + page_manifests: list[PageManifest], +) -> ValidationReport: issues: list[ValidationIssue] = [] for page in page_manifests: issues.extend(_vlm_source_preserved_bitmap_coverage_issues(page)) @@ -609,11 +618,15 @@ def _vlm_source_preserved_bitmap_coverage_issues(page: PageManifest) -> list[Val def _is_vlm_source_preserved_bitmap_asset(asset: BitmapAssetSpec) -> bool: - return asset.provenance.get("asset_strategy") in { - "masked_source_element", - "source_preserved_crop", - "source_preserving_anchor", - } or asset.provenance.get("source_type") == "vlm_source_crop" + return ( + asset.provenance.get("asset_strategy") + in { + "masked_source_element", + "source_preserved_crop", + "source_preserving_anchor", + } + or asset.provenance.get("source_type") == "vlm_source_crop" + ) def _vlm_source_preserved_asset_effective_area_ratio( @@ -638,14 +651,10 @@ def _has_vlm_split_bitmap_structure( structure_count: int, ) -> bool: split_row_structure = ( - len(source_preserved_assets) >= 3 - and largest_ratio <= 0.35 - and structure_count >= 3 + len(source_preserved_assets) >= 3 and largest_ratio <= 0.35 and structure_count >= 3 ) dense_infographic_structure = ( - len(source_preserved_assets) >= 3 - and largest_ratio <= 0.60 - and structure_count >= 12 + len(source_preserved_assets) >= 3 and largest_ratio <= 0.60 and structure_count >= 12 ) return split_row_structure or dense_infographic_structure @@ -668,7 +677,9 @@ def _combined_bbox_coverage_ratio( return covered / float(width * height) -def _validate_vlm_minimum_editable_structure(page_manifests: list[PageManifest]) -> ValidationReport: +def _validate_vlm_minimum_editable_structure( + page_manifests: list[PageManifest], +) -> ValidationReport: issues: list[ValidationIssue] = [] for page in page_manifests: editable_count = len(page.text_boxes) + len(page.native_shapes) + len(page.bitmap_assets) @@ -697,8 +708,7 @@ def _validate_vlm_minimum_editable_structure(page_manifests: list[PageManifest]) def _clean_background_local_fallback_used(page_manifests: list[PageManifest]) -> bool: return any( - bool(page.provenance.get("clean_background_provider_failed")) - for page in page_manifests + bool(page.provenance.get("clean_background_provider_failed")) for page in page_manifests ) @@ -727,7 +737,9 @@ def check(self, stage: str) -> None: if self.timeout_seconds <= 0: return if time.monotonic() - self.started_at >= self.timeout_seconds: - raise self.timeout_error(f"VLM-first page exceeded {self.timeout_seconds}s during {stage}") + raise self.timeout_error( + f"VLM-first page exceeded {self.timeout_seconds}s during {stage}" + ) def provider_timeout(self, configured_timeout_seconds: int | float, stage: str) -> float: self.check(stage) @@ -735,7 +747,9 @@ def provider_timeout(self, configured_timeout_seconds: int | float, stage: str) return configured_timeout_seconds remaining = self.timeout_seconds - (time.monotonic() - self.started_at) if remaining <= 0: - raise self.timeout_error(f"VLM-first page exceeded {self.timeout_seconds}s before {stage}") + raise self.timeout_error( + f"VLM-first page exceeded {self.timeout_seconds}s before {stage}" + ) return max(0.001, min(float(configured_timeout_seconds), remaining)) def timeout_error(self, message: str) -> ProviderTimeoutError: @@ -783,7 +797,9 @@ def _raise_page_timeout_if_page_limited_provider_timeout( ) from provider_error -def _render_vlm_preview(preview_renderer, page: PageManifest, artifact_root: Path, *, pptx_path: str): +def _render_vlm_preview( + preview_renderer, page: PageManifest, artifact_root: Path, *, pptx_path: str +): if "pptx_path" in inspect.signature(preview_renderer).parameters: return preview_renderer(page, artifact_root, pptx_path=pptx_path) return preview_renderer(page, artifact_root) @@ -810,13 +826,19 @@ def _record_vlm_validation_status( page_manifest_refs: list[str], validation_report: ValidationReport, ) -> None: - status = "failed" if validation_report.status != "passed" or validation_report.issues else "passed" + status = ( + "failed" if validation_report.status != "passed" or validation_report.issues else "passed" + ) issue_slide_ids = {issue.slide_id for issue in validation_report.issues if issue.slide_id} rewritten_refs: list[str] = [] for page_ref in page_manifest_refs: page_path = artifacts.job_dir / page_ref page = read_page_manifest(page_path) - page_status = "failed" if status == "failed" and (not issue_slide_ids or page.slide_id in issue_slide_ids) else "passed" + page_status = ( + "failed" + if status == "failed" and (not issue_slide_ids or page.slide_id in issue_slide_ids) + else "passed" + ) write_manifest(page_path, replace(page, validation_status=page_status)) rewritten_refs.append(page_ref) write_manifest( @@ -865,7 +887,9 @@ def retry_image_edit(provider: ImageEditProvider | None) -> ImageEditProvider | dependencies, vlm_provider=vlm_provider, image_edit_provider=retry_image_edit(dependencies.image_edit_provider), - asset_sheet_image_edit_provider=retry_image_edit(dependencies.asset_sheet_image_edit_provider), + asset_sheet_image_edit_provider=retry_image_edit( + dependencies.asset_sheet_image_edit_provider + ), ocr_provider=ocr_provider, ) @@ -953,8 +977,7 @@ def _build_vlm_page_manifest( source_image_size=source_image_size, ) protected_text_bboxes = [ - text_box.source_pixel_bbox - for text_box in (text_result.text_boxes if text_result else []) + text_box.source_pixel_bbox for text_box in (text_result.text_boxes if text_result else []) ] page_deadline.check("vlm_build_mask") with _record_vlm_stage(artifacts, "vlm_build_mask", page_index=page_index, slide_id=slide_id): @@ -983,7 +1006,9 @@ def _build_vlm_page_manifest( clean_background = _create_vlm_clean_background( source_image_path=source_path, mask_path=mask_path, - output_asset_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + output_asset_path=artifacts.asset_path( + slide_id, page_index, "backgrounds", "vlm-clean.png" + ), asset_root=artifacts.job_dir, edit_provider=dependencies.image_edit_provider, timeout_seconds=clean_timeout, @@ -994,7 +1019,9 @@ def _build_vlm_page_manifest( ), ) page_deadline.check("vlm_clean_background") - asset_sheet_provider = dependencies.asset_sheet_image_edit_provider or dependencies.image_edit_provider + asset_sheet_provider = ( + dependencies.asset_sheet_image_edit_provider or dependencies.image_edit_provider + ) asset_sheet_timeout_seconds = page_deadline.provider_timeout( _asset_sheet_timeout_seconds(dependencies.provider_timeout_seconds), "vlm_asset_sheet", @@ -1054,7 +1081,9 @@ def _build_vlm_page_manifest( "issues": text_result.issues if text_result else [], }, ) - with _record_vlm_stage(artifacts, "vlm_build_manifest", page_index=page_index, slide_id=slide_id): + with _record_vlm_stage( + artifacts, "vlm_build_manifest", page_index=page_index, slide_id=slide_id + ): page = build_page_manifest_from_vlm_analysis( analysis=analysis, slide_id=slide_id, @@ -1072,7 +1101,9 @@ def _build_vlm_page_manifest( provenance={ **page.provenance, "clean_background_strategy": clean_background.strategy, - "clean_background_provider_failed": bool(clean_background.provenance.get("provider_failed")), + "clean_background_provider_failed": bool( + clean_background.provenance.get("provider_failed") + ), "text_mask_path": _job_relative(mask_path, artifacts.job_dir), }, ) @@ -1081,7 +1112,9 @@ def _build_vlm_page_manifest( "image_edit": _job_relative(image_edit_output_path, artifacts.job_dir), } if asset_sheet_output_path is not None: - provider_output_paths["asset_sheet"] = _job_relative(asset_sheet_output_path, artifacts.job_dir) + provider_output_paths["asset_sheet"] = _job_relative( + asset_sheet_output_path, artifacts.job_dir + ) if ocr_output_path is not None: provider_output_paths["ocr"] = _job_relative(ocr_output_path, artifacts.job_dir) return replace( @@ -1277,13 +1310,9 @@ def _protected_mask_bboxes( *, additional_text_bboxes: list[PixelBBox] | None = None, ) -> list[PixelBBox]: - protected = [ - mapper.to_source_bbox(region.bbox, padding=0) - for region in analysis.text_regions - ] + protected = [mapper.to_source_bbox(region.bbox, padding=0) for region in analysis.text_regions] protected.extend( - mapper.to_source_bbox(region.bbox, padding=0) - for region in analysis.shape_regions + mapper.to_source_bbox(region.bbox, padding=0) for region in analysis.shape_regions ) protected.extend(additional_text_bboxes or []) return protected @@ -1359,8 +1388,7 @@ def build_page_manifest_from_vlm_analysis( ) ) vlm_text_boxes = [ - _text_box_from_vlm(region, mapper, source_rgb) - for region in analysis.text_regions + _text_box_from_vlm(region, mapper, source_rgb) for region in analysis.text_regions ] resolved_text_boxes = _resolve_text_boxes_with_vlm_gate( analysis=analysis, @@ -1448,11 +1476,7 @@ def _resolve_text_boxes_with_vlm_gate( for index, box in enumerate(ocr_text_boxes) if index not in used_indexes ] - matches = [ - (index, box, score) - for index, box, score in candidates - if score >= 0.35 - ] + matches = [(index, box, score) for index, box, score in candidates if score >= 0.35] if not matches: if region_index < len(vlm_text_boxes): vlm_box = vlm_text_boxes[region_index] @@ -1521,7 +1545,9 @@ def _vlm_fallback_is_already_represented( if _normalize_text_for_match(existing.text) != normalized: continue overlap = _bbox_intersection_area(vlm_box.source_pixel_bbox, existing.source_pixel_bbox) - smaller_area = min(_bbox_area(vlm_box.source_pixel_bbox), _bbox_area(existing.source_pixel_bbox)) + smaller_area = min( + _bbox_area(vlm_box.source_pixel_bbox), _bbox_area(existing.source_pixel_bbox) + ) if overlap / max(1, smaller_area) >= 0.45: return True if _text_boxes_are_nearby_ocr_vlm_duplicates(existing, vlm_box): @@ -1548,7 +1574,10 @@ def _ocr_only_text_fallback_is_sane( if len(boxes) > 40: return False normalized_lengths = [len(_normalize_text_for_match(box.text)) for box in boxes] - if len(boxes) > 20 and sum(1 for length in normalized_lengths if length <= 1) / len(boxes) >= 0.65: + if ( + len(boxes) > 20 + and sum(1 for length in normalized_lengths if length <= 1) / len(boxes) >= 0.65 + ): return False page_area = max(1, int(source_image_size[0]) * int(source_image_size[1])) combined_bbox_ratio = _combined_bbox_coverage_ratio( @@ -1592,7 +1621,9 @@ def _bitmap_asset_is_major_visual(asset: BitmapAssetSpec) -> bool: def _text_box_is_inside_or_near_bitmap(text_bbox: PixelBBox, asset_bbox: PixelBBox) -> bool: text_center_y = (text_bbox[1] + text_bbox[3]) / 2.0 asset_height = max(1, asset_bbox[3] - asset_bbox[1]) - if not (asset_bbox[1] - asset_height * 0.10 <= text_center_y <= asset_bbox[3] + asset_height * 0.10): + if not ( + asset_bbox[1] - asset_height * 0.10 <= text_center_y <= asset_bbox[3] + asset_height * 0.10 + ): return False horizontal_overlap = min(text_bbox[2], asset_bbox[2]) - max(text_bbox[0], asset_bbox[0]) if horizontal_overlap > 0: @@ -1706,15 +1737,14 @@ def _should_keep_medium_confidence_vlm_text_over_fragmentary_ocr( def _merged_ocr_text_box_for_guard(ocr_boxes: list[TextBoxSpec]) -> TextBoxSpec: if not ocr_boxes: raise ValueError("ocr_boxes are required") - ordered = sorted(ocr_boxes, key=lambda box: (box.source_pixel_bbox[1], box.source_pixel_bbox[0])) + ordered = sorted( + ocr_boxes, key=lambda box: (box.source_pixel_bbox[1], box.source_pixel_bbox[0]) + ) left = min(box.source_pixel_bbox[0] for box in ordered) top = min(box.source_pixel_bbox[1] for box in ordered) right = max(box.source_pixel_bbox[2] for box in ordered) bottom = max(box.source_pixel_bbox[3] for box in ordered) - confidences = [ - float(box.provenance.get("ocr_confidence", 0.0) or 0.0) - for box in ordered - ] + confidences = [float(box.provenance.get("ocr_confidence", 0.0) or 0.0) for box in ordered] provenance = dict(ordered[0].provenance) provenance["ocr_confidence"] = max(confidences) if confidences else 0.0 provenance["merged_ocr_fragment_count"] = len(ordered) @@ -1755,7 +1785,9 @@ def _is_short_text(text: str) -> bool: def _should_keep_ocr_layout_for_exact_text(ocr_box: TextBoxSpec, vlm_box: TextBoxSpec) -> bool: if not _normalized_texts_are_exact_duplicate(ocr_box.text, vlm_box.text): return False - vertical_center_close = _vertical_center_distance(ocr_box.source_pixel_bbox, vlm_box.source_pixel_bbox) <= max( + vertical_center_close = _vertical_center_distance( + ocr_box.source_pixel_bbox, vlm_box.source_pixel_bbox + ) <= max( ocr_box.source_pixel_bbox[3] - ocr_box.source_pixel_bbox[1], vlm_box.source_pixel_bbox[3] - vlm_box.source_pixel_bbox[1], ) @@ -1770,7 +1802,8 @@ def _should_keep_ocr_layout_for_exact_text(ocr_box: TextBoxSpec, vlm_box: TextBo if ( vertical_center_close and len(_normalize_text_for_match(ocr_box.text)) >= 8 - and ocr_box.source_pixel_bbox[0] <= vlm_box.source_pixel_bbox[0] - max(24, int(vlm_width * 0.05)) + and ocr_box.source_pixel_bbox[0] + <= vlm_box.source_pixel_bbox[0] - max(24, int(vlm_width * 0.05)) and ocr_width >= vlm_width * 0.90 ): return True @@ -1862,10 +1895,7 @@ def _text_boxes_are_overlapping_duplicates(left: TextBoxSpec, right: TextBoxSpec return False smaller_area = min(_bbox_area(left.source_pixel_bbox), _bbox_area(right.source_pixel_bbox)) larger_area = max(_bbox_area(left.source_pixel_bbox), _bbox_area(right.source_pixel_bbox)) - return ( - overlap / max(1, smaller_area) >= 0.70 - and overlap / max(1, larger_area) >= 0.45 - ) + return overlap / max(1, smaller_area) >= 0.70 and overlap / max(1, larger_area) >= 0.45 def _normalized_texts_are_exact_duplicate(left: str, right: str) -> bool: @@ -1933,7 +1963,9 @@ def _foreground_rgba_crop( return source_crop -def _clear_crop_regions(crop: Image.Image, crop_bbox: PixelBBox, transparent_bboxes: list[PixelBBox]) -> None: +def _clear_crop_regions( + crop: Image.Image, crop_bbox: PixelBBox, transparent_bboxes: list[PixelBBox] +) -> None: if not transparent_bboxes: return pixels = crop.load() @@ -1951,7 +1983,9 @@ def _clear_crop_regions(crop: Image.Image, crop_bbox: PixelBBox, transparent_bbo pixels[x, y] = (pixel[0], pixel[1], pixel[2], 0) -def _pixel_matches_background(source_pixel: tuple[int, ...], background_pixel: tuple[int, ...], tolerance: int) -> bool: +def _pixel_matches_background( + source_pixel: tuple[int, ...], background_pixel: tuple[int, ...], tolerance: int +) -> bool: delta = ( abs(source_pixel[0] - background_pixel[0]) + abs(source_pixel[1] - background_pixel[1]) @@ -1974,15 +2008,16 @@ def _create_vlm_asset_sheet_assets( force_opaque_source_crops: bool = False, ) -> tuple[list[BitmapAssetSpec], list[AssetSheetSpec], Path | None]: text_bboxes = [ - mapper.to_source_bbox(region.bbox, padding=8) - for region in analysis.text_regions + mapper.to_source_bbox(region.bbox, padding=8) for region in analysis.text_regions ] text_bboxes.extend(_pad_bbox(bbox, 8) for bbox in additional_text_bboxes or []) candidates = _foreground_candidates_from_vlm_bitmap_regions(analysis, mapper) candidates = _refine_candidates_with_clean_background_difference( candidates=candidates, source_image_path=source_image_path, - clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + clean_background_path=artifacts.asset_path( + slide_id, page_index, "backgrounds", "vlm-clean.png" + ), ) candidates = _bridge_narrow_same_group_complex_candidates(candidates) if not candidates: @@ -1996,7 +2031,9 @@ def _create_vlm_asset_sheet_assets( source_preserved_assets = _source_preserved_bitmap_assets_from_candidates( candidates=source_preserved_candidates, source_image_path=source_image_path, - clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + clean_background_path=artifacts.asset_path( + slide_id, page_index, "backgrounds", "vlm-clean.png" + ), artifact_root=artifacts.job_dir, slide_id=slide_id, page_index=page_index, @@ -2021,7 +2058,9 @@ def _create_vlm_asset_sheet_assets( assets = _source_preserved_bitmap_assets_from_candidates( candidates=asset_sheet_candidates, source_image_path=source_image_path, - clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + clean_background_path=artifacts.asset_path( + slide_id, page_index, "backgrounds", "vlm-clean.png" + ), artifact_root=artifacts.job_dir, slide_id=slide_id, page_index=page_index, @@ -2057,7 +2096,9 @@ def _create_vlm_asset_sheet_assets( assets = _source_preserved_bitmap_assets_from_candidates( candidates=asset_sheet_candidates, source_image_path=source_image_path, - clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + clean_background_path=artifacts.asset_path( + slide_id, page_index, "backgrounds", "vlm-clean.png" + ), artifact_root=artifacts.job_dir, slide_id=slide_id, page_index=page_index, @@ -2076,7 +2117,12 @@ def _create_vlm_asset_sheet_assets( "error_type": type(exc).__name__, "error": str(exc), "sheet_path": _artifact_ref(Path(result.output_asset_path), artifacts.job_dir), - "bitmap_assets": [asdict(asset) for asset in _with_candidate_z_order(source_preserved_assets + assets, candidates)], + "bitmap_assets": [ + asdict(asset) + for asset in _with_candidate_z_order( + source_preserved_assets + assets, candidates + ) + ], "candidate_ids": [candidate.candidate_id for candidate in asset_sheet_candidates], "provider_role": result.provider_role, "provider_name": result.provider_name, @@ -2089,7 +2135,9 @@ def _create_vlm_asset_sheet_assets( assets = _select_source_faithful_bitmap_assets( generated_assets=assets, source_image_path=source_image_path, - clean_background_path=artifacts.asset_path(slide_id, page_index, "backgrounds", "vlm-clean.png"), + clean_background_path=artifacts.asset_path( + slide_id, page_index, "backgrounds", "vlm-clean.png" + ), artifact_root=artifacts.job_dir, slide_id=slide_id, page_index=page_index, @@ -2129,7 +2177,9 @@ def _create_vlm_asset_sheet_assets( def _candidate_should_use_asset_sheet(candidate: ForegroundCandidate) -> bool: - return candidate.classification == "bitmap_asset_candidate" and not _candidate_is_vlm_icon(candidate) + return candidate.classification == "bitmap_asset_candidate" and not _candidate_is_vlm_icon( + candidate + ) def _candidate_is_vlm_icon(candidate: ForegroundCandidate) -> bool: @@ -2198,18 +2248,27 @@ def _source_preserved_bitmap_assets_from_candidates( output_dir = root / "assets" / f"{page_index:04d}-{_safe_name(slide_id)}" output_dir.mkdir(parents=True, exist_ok=True) assets: list[BitmapAssetSpec] = [] - with Image.open(source_image_path) as source_image, Image.open(clean_background_path) as background_image: + with ( + Image.open(source_image_path) as source_image, + Image.open(clean_background_path) as background_image, + ): source_rgb = source_image.convert("RGB") background_rgb = background_image.convert("RGB").resize(source_rgb.size) for index, candidate in enumerate(candidates, start=1): candidate_skipped_reason = asset_sheet_skipped_reason - if not candidate_skipped_reason and not asset_sheet_provider_failed and not asset_sheet_slicing_failed: + if ( + not candidate_skipped_reason + and not asset_sheet_provider_failed + and not asset_sheet_slicing_failed + ): candidate_skipped_reason = _source_preserved_skip_reason_for_candidate(candidate) bbox = _clamp_bbox( _pad_bbox(candidate.source_pixel_bbox, fallback_padding), source_rgb.size, ) - use_opaque_crop = force_opaque_source_crops or candidate.classification == "complex_whole_visual" + use_opaque_crop = ( + force_opaque_source_crops or candidate.classification == "complex_whole_visual" + ) if use_opaque_crop: crop = source_rgb.crop(bbox).convert("RGBA") _clear_crop_regions(crop, bbox, transparent_bboxes or []) @@ -2299,7 +2358,9 @@ def _select_source_faithful_bitmap_assets( asset.source_pixel_bbox, transparent_bboxes=transparent_bboxes or [], ) - source_score = _asset_reconstruction_delta(source_crop, source_rgb, background_rgb, asset.source_pixel_bbox) + source_score = _asset_reconstruction_delta( + source_crop, source_rgb, background_rgb, asset.source_pixel_bbox + ) with Image.open(generated_path) as generated_image: generated_rgba = generated_image.convert("RGBA") generated_score = _asset_reconstruction_delta( @@ -2415,7 +2476,10 @@ def _refine_candidates_with_clean_background_difference( if not candidates: return [] refined: list[ForegroundCandidate] = [] - with Image.open(source_image_path) as source_image, Image.open(clean_background_path) as background_image: + with ( + Image.open(source_image_path) as source_image, + Image.open(clean_background_path) as background_image, + ): source_rgb = source_image.convert("RGB") background_rgb = background_image.convert("RGB").resize(source_rgb.size) image_size = source_rgb.size @@ -2491,7 +2555,9 @@ def _bridge_narrow_same_group_complex_candidates( continue if gap > neighbor_width * max_gap_ratio: continue - bridge_overlap = max(16, min(48, round(max(current_height, neighbor_bbox[3] - neighbor_bbox[1]) * 0.15))) + bridge_overlap = max( + 16, min(48, round(max(current_height, neighbor_bbox[3] - neighbor_bbox[1]) * 0.15)) + ) bridged_left = max(neighbor_bbox[2] - bridge_overlap, 0) if bridged_left >= current_bbox[0]: continue @@ -2730,7 +2796,11 @@ def _native_shapes_from_vlm( line_end=end, stroke_width=1.0, opacity=0.75, - provenance={"provider_role": "VLM", "region_id": region.region_id, "vlm_type": kind}, + provenance={ + "provider_role": "VLM", + "region_id": region.region_id, + "vlm_type": kind, + }, ) ] if kind in {"rounded_rect", "rounded_rectangle", "rect", "rectangle"}: @@ -2745,7 +2815,11 @@ def _native_shapes_from_vlm( stroke_width=1.0, opacity=0.95, radius=0.12, - provenance={"provider_role": "VLM", "region_id": region.region_id, "vlm_type": kind}, + provenance={ + "provider_role": "VLM", + "region_id": region.region_id, + "vlm_type": kind, + }, ) ] return [] @@ -2829,10 +2903,10 @@ def _vlm_compact_analysis_prompt(size: tuple[int, int]) -> str: "[left,top,right,bottom],不要归一化。\n" "目标是给后续 PPTX 重建提供元素清单,而不是描述图片。\n" "输出 schema: {" - "\"coordinate_space\":{\"width\":%d,\"height\":%d,\"unit\":\"px\"}," - "\"text_regions\":[{\"id\":\"t1\",\"text\":\"原文\",\"bbox\":[0,0,1,1],\"role\":\"title|heading|body|label|button\",\"color\":\"#RRGGBB\",\"confidence\":0.9,\"group_id\":\"g1\"}]," - "\"bitmap_regions\":[{\"id\":\"b1\",\"type\":\"photo|icon|logo|qr|product|component|other\",\"bbox\":[0,0,1,1],\"importance\":\"major|minor\",\"group_id\":\"g1\"}]," - "\"shape_regions\":[{\"id\":\"s1\",\"type\":\"rounded_rect|rect|line|divider|connector|arrow|circle|other\",\"bbox\":[0,0,1,1],\"importance\":\"major|minor\",\"group_id\":\"g1\"}]" + '"coordinate_space":{"width":%d,"height":%d,"unit":"px"},' + '"text_regions":[{"id":"t1","text":"原文","bbox":[0,0,1,1],"role":"title|heading|body|label|button","color":"#RRGGBB","confidence":0.9,"group_id":"g1"}],' + '"bitmap_regions":[{"id":"b1","type":"photo|icon|logo|qr|product|component|other","bbox":[0,0,1,1],"importance":"major|minor","group_id":"g1"}],' + '"shape_regions":[{"id":"s1","type":"rounded_rect|rect|line|divider|connector|arrow|circle|other","bbox":[0,0,1,1],"importance":"major|minor","group_id":"g1"}]' "}。\n" "规则:文字逐项列出,中文照抄,不合并跨区域文字;大背景不要放入 bitmap_regions;" "面板、分割线、按钮边框放 shape_regions;忽略纯装饰噪声。" @@ -2920,7 +2994,12 @@ def _ocr_match_score(region: VLMTextRegion, region_bbox: PixelBBox, box: TextBox confidence = float(box.provenance.get("ocr_confidence", 0.0) or 0.0) if text_score < 0.2 and region.text: return 0.0 - return max(overlap, 0.35 if _bbox_center_inside(box.source_pixel_bbox, region_bbox) else 0.0) * 0.65 + text_score * 0.25 + confidence * 0.1 + return ( + max(overlap, 0.35 if _bbox_center_inside(box.source_pixel_bbox, region_bbox) else 0.0) + * 0.65 + + text_score * 0.25 + + confidence * 0.1 + ) def _bbox_overlap_ratio(outer: PixelBBox, inner: PixelBBox) -> float: @@ -3006,9 +3085,7 @@ def _cap_font_size_by_text_width( text: str, ) -> float: line_units = [ - _estimate_text_line_units(line) - for line in str(text).splitlines() - if line.strip() + _estimate_text_line_units(line) for line in str(text).splitlines() if line.strip() ] if not line_units: return font_size @@ -3048,11 +3125,7 @@ def _infer_text_color(image: Image.Image, bbox: PixelBBox) -> str: ] if not candidates: return "#FFFFFF" - blues = [ - pixel - for pixel in candidates - if pixel[2] > pixel[0] + 25 and pixel[1] > pixel[0] - 20 - ] + blues = [pixel for pixel in candidates if pixel[2] > pixel[0] + 25 and pixel[1] > pixel[0] - 20] values = blues if len(blues) >= max(3, len(candidates) * 0.2) else candidates rgb = tuple(sorted(pixel[index] for pixel in values)[len(values) // 2] for index in range(3)) return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" diff --git a/src/model_profiles.py b/src/model_profiles.py index e732760..a5216cc 100644 --- a/src/model_profiles.py +++ b/src/model_profiles.py @@ -76,7 +76,9 @@ def to_public_dict(self) -> Dict[str, Any]: def resolve_model_profiles(data: Dict[str, Any]) -> ModelProfileSet: vlm_source = data.get("VLM") or data.get("vlm_model") - prompt_source = data.get("text_model") or data.get("prompt_model") or data.get("text") or vlm_source or {} + prompt_source = ( + data.get("text_model") or data.get("prompt_model") or data.get("text") or vlm_source or {} + ) prompt = _profile_from_dict("prompt", prompt_source) image = _profile_from_dict("image", data.get("image_model") or data.get("image") or {}) @@ -218,7 +220,9 @@ def _profile_from_dict(role: str, data: Dict[str, Any]) -> ModelProfile: ) -def _optional_profile_from_dict(role: str, data: Optional[Dict[str, Any]]) -> Optional[ModelProfile]: +def _optional_profile_from_dict( + role: str, data: Optional[Dict[str, Any]] +) -> Optional[ModelProfile]: if not data: return None return _profile_from_dict(role, data) diff --git a/tests/generative_editable_fixtures.py b/tests/generative_editable_fixtures.py index 719c1b8..0f086b6 100644 --- a/tests/generative_editable_fixtures.py +++ b/tests/generative_editable_fixtures.py @@ -48,16 +48,22 @@ def write_deterministic_fixture_deck(root: Path, *, aspect_ratio: str = "16:9") _write_complex_visual_slide(source_root, image_size), _write_text_clean_fallback_slide(source_root, image_size), ] - return FixtureDeck(root=source_root.parent, aspect_ratio=aspect_ratio, image_size=image_size, slides=slides) + return FixtureDeck( + root=source_root.parent, aspect_ratio=aspect_ratio, image_size=image_size, slides=slides + ) def _write_text_and_shapes_slide(root: Path, size: tuple[int, int]) -> FixtureSlide: image = _base_slide(size) draw = ImageDraw.Draw(image) - draw.rounded_rectangle((48, 46, 752, 118), radius=18, fill="#E0F2FE", outline="#0284C7", width=3) + draw.rounded_rectangle( + (48, 46, 752, 118), radius=18, fill="#E0F2FE", outline="#0284C7", width=3 + ) draw.text((82, 66), "Quarterly Plan", fill="#0F172A") draw.rectangle((82, 172, 278, 300), fill="#DCFCE7", outline="#16A34A", width=3) - draw.rounded_rectangle((328, 172, 524, 300), radius=22, fill="#FEF3C7", outline="#D97706", width=3) + draw.rounded_rectangle( + (328, 172, 524, 300), radius=22, fill="#FEF3C7", outline="#D97706", width=3 + ) draw.line((574, 292, 718, 188), fill="#7C3AED", width=6) path = root / "01-text-shapes.png" image.save(path) @@ -81,7 +87,9 @@ def _write_repeated_assets_slide(root: Path, size: tuple[int, int]) -> FixtureSl draw = ImageDraw.Draw(image) draw.text((72, 54), "Repeated Components", fill="#0F172A") for index, x in enumerate((110, 310, 510)): - draw.rounded_rectangle((x, 160, x + 110, 270), radius=18, fill="#CCFBF1", outline="#0F766E", width=3) + draw.rounded_rectangle( + (x, 160, x + 110, 270), radius=18, fill="#CCFBF1", outline="#0F766E", width=3 + ) draw.ellipse((x + 26, 186, x + 84, 244), fill="#14B8A6") draw.text((x + 36, 292), f"A{index + 1}", fill="#115E59") path = root / "02-repeated-assets.png" @@ -102,7 +110,9 @@ def _write_complex_visual_slide(root: Path, size: tuple[int, int]) -> FixtureSli draw.line(points, fill="#2563EB", width=5) for x, y in points: draw.ellipse((x - 9, y - 9, x + 9, y + 9), fill="#DB2777") - draw.polygon([(90, 360), (250, 250), (430, 310), (700, 170), (700, 382), (90, 382)], fill="#DBEAFE") + draw.polygon( + [(90, 360), (250, 250), (430, 310), (700, 170), (700, 382), (90, 382)], fill="#DBEAFE" + ) draw.line(points, fill="#2563EB", width=5) path = root / "03-complex-visual.png" image.save(path) @@ -121,7 +131,9 @@ def _write_text_clean_fallback_slide(root: Path, size: tuple[int, int]) -> Fixtu draw.text((86, 64), "Fallback Text Layer", fill="#0F172A") for row, text in enumerate(("Editable title", "OCR layout hint", "Preserve background visual")): y = 178 + row * 48 - draw.rounded_rectangle((94, y - 12, 706, y + 26), radius=10, fill="#FFFFFF", outline="#CBD5E1") + draw.rounded_rectangle( + (94, y - 12, 706, y + 26), radius=10, fill="#FFFFFF", outline="#CBD5E1" + ) draw.text((122, y), text, fill="#334155") path = root / "04-text-clean-fallback.png" image.save(path) diff --git a/tests/test_generative_editable_assets.py b/tests/test_generative_editable_assets.py index d8cc8f1..7a60bb1 100644 --- a/tests/test_generative_editable_assets.py +++ b/tests/test_generative_editable_assets.py @@ -9,7 +9,6 @@ from src.generative_editable_manifest import BitmapAssetSpec, RepairAttempt from src.generative_editable_providers import ( FakeImageEditProvider, - FakeImageGenerationProvider, ImageEditProvider, ) @@ -95,11 +94,16 @@ def test_builds_asset_sheet_edit_request_from_bitmap_candidates(self): self.assertEqual(request.prompt_id, "asset_sheet") self.assertIn("fg-001", request.prompt) self.assertIn("pure #FF00FF", request.prompt) - self.assertIn("Do not use checkerboard, gray, white, shadows, gradients, or scene floors", request.prompt) + self.assertIn( + "Do not use checkerboard, gray, white, shadows, gradients, or scene floors", + request.prompt, + ) self.assertIn("exactly one separated object per candidate id", request.prompt) self.assertIn("Do not merge, omit, rename, replace, or add objects", request.prompt) self.assertIn("Do not include readable text", request.prompt) - self.assertIn("Do not include full cards, panels, charts, or source-slide fragments", request.prompt) + self.assertIn( + "Do not include full cards, panels, charts, or source-slide fragments", request.prompt + ) self.assertIn("Keep text boxes out of the asset sheet", request.prompt) self.assertIn("fg-001 bbox=(10, 12, 50, 42)", request.prompt) self.assertIn("classification=bitmap_asset_candidate", request.prompt) @@ -420,7 +424,9 @@ def test_component_slicing_can_ignore_extra_components_when_explicitly_allowed(s ) self.assertEqual([asset.asset_id for asset in assets], ["fg-left", "fg-right"]) - self.assertTrue(all(asset.provenance["ignored_extra_component_count"] == 1 for asset in assets)) + self.assertTrue( + all(asset.provenance["ignored_extra_component_count"] == 1 for asset in assets) + ) def test_component_slicing_rejects_large_extra_components_even_when_allowed(self): from src.generative_editable_assets import slice_asset_sheet_by_components diff --git a/tests/test_generative_editable_composer.py b/tests/test_generative_editable_composer.py index b331767..f711862 100644 --- a/tests/test_generative_editable_composer.py +++ b/tests/test_generative_editable_composer.py @@ -160,7 +160,9 @@ def test_composes_approximate_layout_text_as_visible_editable_text(self): prs = Presentation(str(output)) slide = prs.slides[0] shape_types = [shape.shape_type for shape in slide.shapes] - texts = [shape.text for shape in slide.shapes if shape.has_text_frame and shape.text.strip()] + texts = [ + shape.text for shape in slide.shapes if shape.has_text_frame and shape.text.strip() + ] slide_xml = self._slide_xml(output, 1) self.assertEqual( @@ -327,7 +329,9 @@ def test_text_boxes_and_simple_shapes_are_native_powerpoint_objects(self): prs = Presentation(str(output)) slide = prs.slides[0] texts = [shape.text for shape in slide.shapes if shape.has_text_frame] - auto_shapes = [shape for shape in slide.shapes if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE] + auto_shapes = [ + shape for shape in slide.shapes if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE + ] self.assertIn("Quarterly Plan", texts) self.assertEqual(len(auto_shapes), 1) @@ -338,7 +342,9 @@ def test_composes_multi_page_deck_order_dimensions_media_and_rebuild_determinist with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - self._write_fixture_assets(root, slide_ids=("slide-b", "slide-a"), image_size=(800, 600)) + self._write_fixture_assets( + root, slide_ids=("slide-b", "slide-a"), image_size=(800, 600) + ) page_b = self._page_manifest( root, slide_id="slide-b", @@ -530,9 +536,7 @@ def _write_fixture_assets(self, root: Path, slide_ids=("slide-a",), image_size=( suffix = f"{index:04d}-{slide_id}" (root / "backgrounds" / suffix).mkdir(parents=True, exist_ok=True) (root / "assets" / suffix).mkdir(parents=True, exist_ok=True) - Image.new("RGB", image_size, "#F8FAFC").save( - root / "backgrounds" / suffix / "base.png" - ) + Image.new("RGB", image_size, "#F8FAFC").save(root / "backgrounds" / suffix / "base.png") asset = Image.new("RGBA", (120, 80), (0, 0, 0, 0)) for x in range(20, 100): for y in range(18, 62): diff --git a/tests/test_generative_editable_config.py b/tests/test_generative_editable_config.py index 8e09afe..2d0ad93 100644 --- a/tests/test_generative_editable_config.py +++ b/tests/test_generative_editable_config.py @@ -81,9 +81,17 @@ def test_loads_provider_settings_from_project_config(self): "repair_model": "edit_model", "generation_model": "image_model", }, - "ocr": {"model": "ocr_model", "use_aippt_metadata_first": True, "min_confidence": 0.81}, + "ocr": { + "model": "ocr_model", + "use_aippt_metadata_first": True, + "min_confidence": 0.81, + }, "quality": {"max_repair_attempts": 3, "preview_similarity_threshold": 0.9}, - "retries": {"provider_max_attempts": 4, "repair_max_attempts": 2, "backoff_seconds": 0.5}, + "retries": { + "provider_max_attempts": 4, + "repair_max_attempts": 2, + "backoff_seconds": 0.5, + }, "timeouts": {"provider_call": 120, "page": 500}, }, } @@ -262,7 +270,9 @@ def test_null_provider_role_names_raise_redacted_config_error(self): ): load_generative_editable_config() - self.assertIn("generative editable provider role must be a non-empty string", str(ctx.exception)) + self.assertIn( + "generative editable provider role must be a non-empty string", str(ctx.exception) + ) def test_fake_provider_config_does_not_read_live_credentials(self): with patch("src.generative_editable_config.get_config") as get_config: diff --git a/tests/test_generative_editable_export_contract.py b/tests/test_generative_editable_export_contract.py index 1828b4e..e1140b8 100644 --- a/tests/test_generative_editable_export_contract.py +++ b/tests/test_generative_editable_export_contract.py @@ -196,11 +196,11 @@ def fake_export_pptx(image_paths, output_path, aspect_ratio="16:9"): original_vlm_dependencies = export_route._build_vlm_editable_pipeline_dependencies asyncio.to_thread = fake_to_thread export_route._export_pptx = fake_export_pptx - export_route._build_generative_editable_pipeline_dependencies = ( - lambda: _fake_route_dependencies(validation_status="failed") + export_route._build_generative_editable_pipeline_dependencies = lambda: ( + _fake_route_dependencies(validation_status="failed") ) - export_route._build_vlm_editable_pipeline_dependencies = ( - lambda config: _fake_vlm_route_dependencies(validation_status="failed") + export_route._build_vlm_editable_pipeline_dependencies = lambda config: ( + _fake_vlm_route_dependencies(validation_status="failed") ) try: request = ExportRequest( @@ -216,9 +216,7 @@ def fake_export_pptx(image_paths, output_path, aspect_ratio="16:9"): self.assertEqual( response.headers["X-Generative-Editable-Fallback-Policy"], "raster_pptx" ) - self.assertEqual( - response.headers["X-Generative-Editable-Fallback-Used"], "raster_pptx" - ) + self.assertEqual(response.headers["X-Generative-Editable-Fallback-Used"], "raster_pptx") self.assertEqual( [getattr(call[0], "__name__", "") for call in thread_calls], ["_export_generative_editable_pptx"], @@ -238,11 +236,11 @@ def test_temporary_generative_export_does_not_silently_return_raster_output(self original_dependencies = export_route._build_generative_editable_pipeline_dependencies original_vlm_dependencies = export_route._build_vlm_editable_pipeline_dependencies - export_route._build_generative_editable_pipeline_dependencies = ( - lambda: _fake_route_dependencies(validation_status="failed") + export_route._build_generative_editable_pipeline_dependencies = lambda: ( + _fake_route_dependencies(validation_status="failed") ) - export_route._build_vlm_editable_pipeline_dependencies = ( - lambda config: _fake_vlm_route_dependencies(validation_status="failed") + export_route._build_vlm_editable_pipeline_dependencies = lambda config: ( + _fake_vlm_route_dependencies(validation_status="failed") ) try: with self.assertRaisesRegex(RuntimeError, "validation failed"): @@ -255,7 +253,9 @@ def test_temporary_generative_export_does_not_silently_return_raster_output(self ).editable_options, ) finally: - export_route._build_generative_editable_pipeline_dependencies = original_dependencies + export_route._build_generative_editable_pipeline_dependencies = ( + original_dependencies + ) export_route._build_vlm_editable_pipeline_dependencies = original_vlm_dependencies self.assertFalse(output_path.exists()) @@ -274,12 +274,6 @@ def test_generative_editable_export_rejects_non_vlm_reconstruction_mode(self): image_path = Path(tmp) / "slide.png" output_path = Path(tmp) / "out.pptx" Image.new("RGB", (800, 450), "white").save(image_path) - legacy_called = False - - def fake_legacy_pipeline(**kwargs): - nonlocal legacy_called - legacy_called = True - Path(kwargs["output_path"]).write_bytes(b"legacy") provider = ProviderConfig( role="stub", @@ -302,9 +296,7 @@ def fake_legacy_pipeline(**kwargs): ) original_loader = export_route.load_generative_editable_config - original_legacy = export_route.run_generative_editable_pipeline export_route.load_generative_editable_config = lambda: non_vlm_config - export_route.run_generative_editable_pipeline = fake_legacy_pipeline try: with self.assertRaisesRegex( GenerativeEditableConfigError, @@ -316,9 +308,7 @@ def fake_legacy_pipeline(**kwargs): ) finally: export_route.load_generative_editable_config = original_loader - export_route.run_generative_editable_pipeline = original_legacy - self.assertFalse(legacy_called) self.assertFalse(output_path.exists()) def test_generative_export_accepts_explicit_raster_fallback_policy(self): @@ -333,11 +323,11 @@ def test_generative_export_accepts_explicit_raster_fallback_policy(self): export_route._export_pptx = lambda image_paths, output_path, aspect_ratio="16:9": Path( output_path ).write_bytes(b"raster-fallback") - export_route._build_generative_editable_pipeline_dependencies = ( - lambda: _fake_route_dependencies(validation_status="failed") + export_route._build_generative_editable_pipeline_dependencies = lambda: ( + _fake_route_dependencies(validation_status="failed") ) - export_route._build_vlm_editable_pipeline_dependencies = ( - lambda config: _fake_vlm_route_dependencies(validation_status="failed") + export_route._build_vlm_editable_pipeline_dependencies = lambda config: ( + _fake_vlm_route_dependencies(validation_status="failed") ) try: export_route._export_generative_editable_pptx( @@ -349,7 +339,9 @@ def test_generative_export_accepts_explicit_raster_fallback_policy(self): ) finally: export_route._export_pptx = original_export_pptx - export_route._build_generative_editable_pipeline_dependencies = original_dependencies + export_route._build_generative_editable_pipeline_dependencies = ( + original_dependencies + ) export_route._build_vlm_editable_pipeline_dependencies = original_vlm_dependencies self.assertEqual(output_path.read_bytes(), b"raster-fallback") @@ -372,11 +364,11 @@ def fake_export_pptx(image_paths, output_path, aspect_ratio="16:9"): original_dependencies = export_route._build_generative_editable_pipeline_dependencies original_vlm_dependencies = export_route._build_vlm_editable_pipeline_dependencies export_route._export_pptx = fake_export_pptx - export_route._build_generative_editable_pipeline_dependencies = ( - lambda: _fake_route_dependencies(validation_status="failed") + export_route._build_generative_editable_pipeline_dependencies = lambda: ( + _fake_route_dependencies(validation_status="failed") ) - export_route._build_vlm_editable_pipeline_dependencies = ( - lambda config: _fake_vlm_route_dependencies(validation_status="failed") + export_route._build_vlm_editable_pipeline_dependencies = lambda config: ( + _fake_vlm_route_dependencies(validation_status="failed") ) try: export_route._export_generative_editable_pptx( @@ -391,7 +383,9 @@ def fake_export_pptx(image_paths, output_path, aspect_ratio="16:9"): ) finally: export_route._export_pptx = original_export_pptx - export_route._build_generative_editable_pipeline_dependencies = original_dependencies + export_route._build_generative_editable_pipeline_dependencies = ( + original_dependencies + ) export_route._build_vlm_editable_pipeline_dependencies = original_vlm_dependencies self.assertEqual(captured["image_paths"], [str(second), str(first)]) diff --git a/tests/test_generative_editable_export_route.py b/tests/test_generative_editable_export_route.py index 62c554c..ad550c2 100644 --- a/tests/test_generative_editable_export_route.py +++ b/tests/test_generative_editable_export_route.py @@ -215,7 +215,9 @@ def test_export_uses_vlm_first_pipeline_when_configured(self): base_url="fake", api_key="fake", ), - generation_model=ProviderConfig(role="image_model", model="image", base_url="fake", api_key="fake"), + generation_model=ProviderConfig( + role="image_model", model="image", base_url="fake", api_key="fake" + ), use_aippt_metadata_first=False, ocr_min_confidence=0.75, quality=QualityConfig(), @@ -247,7 +249,10 @@ def fake_vlm_pipeline(**kwargs): output = root / "out.pptx" with ( patch("api.routes.export.load_generative_editable_config", return_value=config), - patch("api.routes.export.run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch( + "api.routes.export.run_vlm_editable_pptx_pipeline", + side_effect=fake_vlm_pipeline, + ), patch( "api.routes.export._build_vlm_editable_pipeline_dependencies", return_value=dependency_sentinel, @@ -423,7 +428,9 @@ def test_vlm_dependency_builder_wires_vlm_edit_and_ocr_providers(self): ), use_aippt_metadata_first=False, ocr_min_confidence=0.83, - quality=QualityConfig(preview_similarity_threshold=0.86, require_preview_validation=True), + quality=QualityConfig( + preview_similarity_threshold=0.86, require_preview_validation=True + ), retries=RetryConfig(provider_max_attempts=4, backoff_seconds=0.5), timeouts=TimeoutConfig(provider_call=77, page=500), ) @@ -434,12 +441,16 @@ def test_vlm_dependency_builder_wires_vlm_edit_and_ocr_providers(self): adapter="openai_chat", ) - with patch("api.routes.export.load_default_profiles", return_value=SimpleNamespace(vlm=vlm_profile)): + with patch( + "api.routes.export.load_default_profiles", return_value=SimpleNamespace(vlm=vlm_profile) + ): dependencies = export_route._build_vlm_editable_pipeline_dependencies(config) self.assertEqual(dependencies.vlm_provider.config.model, "vlm") self.assertEqual(dependencies.image_edit_provider.config.role, "edit_model") - self.assertEqual(dependencies.asset_sheet_image_edit_provider.config.role, "asset_sheet_edit_model") + self.assertEqual( + dependencies.asset_sheet_image_edit_provider.config.role, "asset_sheet_edit_model" + ) self.assertEqual(dependencies.ocr_provider.config.role, "ocr_model") self.assertEqual(dependencies.ocr_min_confidence, 0.83) self.assertEqual(dependencies.provider_timeout_seconds, 77) @@ -505,7 +516,9 @@ def test_live_dependency_builder_wires_distinct_configured_providers(self): dependencies = export_route._build_generative_editable_pipeline_dependencies() self.assertEqual(dependencies.image_edit_provider.config.role, "clean_edit_model") - self.assertEqual(dependencies.asset_sheet_image_edit_provider.config.role, "asset_sheet_edit_model") + self.assertEqual( + dependencies.asset_sheet_image_edit_provider.config.role, "asset_sheet_edit_model" + ) self.assertEqual(dependencies.repair_image_edit_provider.config.role, "repair_edit_model") self.assertEqual(dependencies.preview_similarity_threshold, 0.87) self.assertEqual(dependencies.provider_timeout_seconds, 77) @@ -575,7 +588,9 @@ async def fake_to_thread(func, *args, **kwargs): for raised, expected_status in ((timeout, 504), (provider_error, 502)): with patch( "api.routes.export._build_vlm_editable_pipeline_dependencies", - lambda config, raised=raised: _fake_vlm_route_dependencies(provider_exception=raised), + lambda config, raised=raised: _fake_vlm_route_dependencies( + provider_exception=raised + ), ): with self.assertRaises(HTTPException) as ctx: asyncio.run( @@ -679,9 +694,7 @@ async def fake_to_thread(func, *args, **kwargs): ) self.assertEqual(response.headers["X-Generative-Editable-Status"], "fallback_used") - self.assertEqual( - response.headers["X-Generative-Editable-Fallback-Used"], "raster_pptx" - ) + self.assertEqual(response.headers["X-Generative-Editable-Fallback-Used"], "raster_pptx") asyncio.run(response.background()) finally: asyncio.to_thread = original_to_thread @@ -725,9 +738,7 @@ async def fake_to_thread(func, *args, **kwargs): presentation = Presentation(str(response.path)) self.assertEqual(len(presentation.slides), 1) self.assertEqual(response.headers["X-Generative-Editable-Status"], "passed") - self.assertEqual( - response.headers["X-Generative-Editable-Fallback-Policy"], "fail" - ) + self.assertEqual(response.headers["X-Generative-Editable-Fallback-Policy"], "fail") asyncio.run(response.background()) self.assertFalse(Path(response.path).exists()) finally: @@ -817,9 +828,12 @@ def fake_raster_export(image_paths, output_path, aspect_ratio="16:9"): output_path = Path(tmp) / "out.pptx" _write_fake_vlm_source(image_path, size=(800, 600)) - with patch("api.routes.export._export_pptx", fake_raster_export), patch( - "api.routes.export._build_vlm_editable_pipeline_dependencies", - lambda config: _fake_vlm_route_dependencies(validation_status="failed"), + with ( + patch("api.routes.export._export_pptx", fake_raster_export), + patch( + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(validation_status="failed"), + ), ): result = export_route._export_generative_editable_pptx( [str(image_path)], @@ -858,9 +872,12 @@ def fake_raster_export(image_paths, output_path, aspect_ratio="16:9"): output_path = Path(tmp) / "out.pptx" _write_fake_vlm_source(image_path) - with patch("api.routes.export._export_pptx", fake_raster_export), patch( - "api.routes.export._build_vlm_editable_pipeline_dependencies", - lambda config: _fake_vlm_route_dependencies(provider_exception=provider_error), + with ( + patch("api.routes.export._export_pptx", fake_raster_export), + patch( + "api.routes.export._build_vlm_editable_pipeline_dependencies", + lambda config: _fake_vlm_route_dependencies(provider_exception=provider_error), + ), ): result = export_route._export_generative_editable_pptx( [str(image_path)], diff --git a/tests/test_generative_editable_fixtures.py b/tests/test_generative_editable_fixtures.py index f780fee..d35367e 100644 --- a/tests/test_generative_editable_fixtures.py +++ b/tests/test_generative_editable_fixtures.py @@ -6,7 +6,10 @@ import api.routes.export as export_route from src.generative_editable_manifest import read_deck_manifest -from src.generative_editable_pipeline import GenerativeEditableSlideInput, run_generative_editable_pipeline +from src.generative_editable_pipeline import ( + GenerativeEditableSlideInput, + run_generative_editable_pipeline, +) from tests.generative_editable_fixtures import write_deterministic_fixture_deck diff --git a/tests/test_generative_editable_foreground_planner.py b/tests/test_generative_editable_foreground_planner.py index 4aa8727..3164627 100644 --- a/tests/test_generative_editable_foreground_planner.py +++ b/tests/test_generative_editable_foreground_planner.py @@ -158,7 +158,9 @@ def test_non_dict_candidate_provenance_is_ignored_for_manifest_and_reuse(self): component_key="same", provenance={"shape_hint": "rectangle"}, ) - duplicate = replace(first, candidate_id="duplicate", source_pixel_bbox=(30, 10, 40, 20), provenance=1) + duplicate = replace( + first, candidate_id="duplicate", source_pixel_bbox=(30, 10, 40, 20), provenance=1 + ) reused = record_component_reuse([first, duplicate]) specs = foreground_candidates_to_manifest_specs(reused) diff --git a/tests/test_generative_editable_job_artifacts.py b/tests/test_generative_editable_job_artifacts.py index fa4956f..38a7cb7 100644 --- a/tests/test_generative_editable_job_artifacts.py +++ b/tests/test_generative_editable_job_artifacts.py @@ -155,7 +155,9 @@ def test_provider_outputs_are_persisted_for_rebuild_without_provider_calls(self) loaded_page = read_page_manifest(artifacts.page_manifest_path("slide-a", 0)) ocr_output = artifacts.read_provider_output("slide-a", 0, "ocr", "result.json") - edit_output = artifacts.read_provider_output("slide-a", 0, "image_edit", "base-clean.json") + edit_output = artifacts.read_provider_output( + "slide-a", 0, "image_edit", "base-clean.json" + ) generation_output = artifacts.read_provider_output( "slide-a", 0, "image_generation", "asset-sheet.json" ) @@ -249,7 +251,9 @@ def append_events(worker: int) -> None: } ) - threads = [threading.Thread(target=append_events, args=(worker,)) for worker in range(4)] + threads = [ + threading.Thread(target=append_events, args=(worker,)) for worker in range(4) + ] for thread in threads: thread.start() for thread in threads: diff --git a/tests/test_generative_editable_live_providers.py b/tests/test_generative_editable_live_providers.py index c9d31d2..bee34d4 100644 --- a/tests/test_generative_editable_live_providers.py +++ b/tests/test_generative_editable_live_providers.py @@ -1,6 +1,5 @@ import base64 import http.client -import json import tempfile import time import unittest @@ -51,7 +50,12 @@ def _provider(role="ocr_model", model="vision-model", provider="openai_vision"): class LiveProviderAdapterTest(unittest.TestCase): def test_route_dependency_builder_uses_live_provider_adapters_for_non_fake_config(self): - from src.generative_editable_config import GenerativeEditableConfig, QualityConfig, RetryConfig, TimeoutConfig + from src.generative_editable_config import ( + GenerativeEditableConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) config = GenerativeEditableConfig( ocr=_provider(provider="paddle_ocr_vl"), @@ -77,7 +81,12 @@ def test_route_dependency_builder_uses_live_provider_adapters_for_non_fake_confi ) def test_route_dependency_builder_passes_configured_timeout_to_ocr_provider(self): - from src.generative_editable_config import GenerativeEditableConfig, QualityConfig, RetryConfig, TimeoutConfig + from src.generative_editable_config import ( + GenerativeEditableConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) config = GenerativeEditableConfig( ocr=_provider(provider="paddle_ocr_vl"), @@ -98,7 +107,12 @@ def test_route_dependency_builder_passes_configured_timeout_to_ocr_provider(self self.assertEqual(dependencies.ocr_provider.timeout_seconds, 77) def test_route_dependency_builder_uses_local_tesseract_ocr_provider_when_configured(self): - from src.generative_editable_config import GenerativeEditableConfig, QualityConfig, RetryConfig, TimeoutConfig + from src.generative_editable_config import ( + GenerativeEditableConfig, + QualityConfig, + RetryConfig, + TimeoutConfig, + ) config = GenerativeEditableConfig( ocr=ProviderConfig( @@ -138,7 +152,10 @@ def test_local_tesseract_ocr_provider_parses_tsv_lines(self): image_path = Path(tmp) / "slide.png" Image.new("RGB", (800, 450), "white").save(image_path) with ( - patch("src.generative_editable_providers.shutil.which", return_value="/usr/bin/tesseract"), + patch( + "src.generative_editable_providers.shutil.which", + return_value="/usr/bin/tesseract", + ), patch("src.generative_editable_providers.subprocess.run") as run, ): run.return_value.stdout = tsv @@ -250,7 +267,7 @@ def test_openai_chat_ocr_provider_extracts_json_from_surrounding_model_text(self "message": { "content": ( "Here is the OCR result:\n" - "{\"items\":[{\"text\":\"Title\",\"bbox\":[1,2,30,40],\"confidence\":0.9}]}" + '{"items":[{"text":"Title","bbox":[1,2,30,40],"confidence":0.9}]}' "\nDone." ) } @@ -302,11 +319,11 @@ def test_paddleocr_vl_provider_requests_json_coordinates(self): { "message": { "content": ( - "{\"items\":[" - "{\"text\":\"理想汽车技术实验室\",\"bbox\":[118,86,372,124]," - "\"confidence\":0.93,\"font_size\":22,\"color\":\"#F8FAFC\"}," - "{\"text\":\"理想L9: 旗舰增程SUV的技术实验\",\"bbox\":[146,178,544,214]," - "\"confidence\":0.91,\"font_size\":21,\"color\":\"#38BDF8\"}" + '{"items":[' + '{"text":"理想汽车技术实验室","bbox":[118,86,372,124],' + '"confidence":0.93,"font_size":22,"color":"#F8FAFC"},' + '{"text":"理想L9: 旗舰增程SUV的技术实验","bbox":[146,178,544,214],' + '"confidence":0.91,"font_size":21,"color":"#38BDF8"}' "]}" ) } @@ -341,15 +358,7 @@ def test_paddleocr_vl_provider_requests_json_coordinates(self): self.assertEqual(result.items[0].color_hex, "#F8FAFC") def test_paddleocr_vl_plain_text_response_uses_deduped_local_layout(self): - payload = { - "choices": [ - { - "message": { - "content": "Title\n\n29in 6K" - } - } - ] - } + payload = {"choices": [{"message": {"content": "Title\n\n29in 6K"}}]} provider = _provider(model="PaddlePaddle/PaddleOCR-VL-1.5") with tempfile.TemporaryDirectory() as tmp: @@ -559,7 +568,11 @@ def test_openai_chat_image_edit_provider_sends_mask_when_available(self): ) def test_openai_chat_image_edit_normalization_errors_are_redacted(self): - payload = {"choices": [{"message": {"content": "https://signed.example/image.png?token=secret-key"}}]} + payload = { + "choices": [ + {"message": {"content": "https://signed.example/image.png?token=secret-key"}} + ] + } with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -577,14 +590,20 @@ def test_openai_chat_image_edit_normalization_errors_are_redacted(self): post.return_value = _FakeResponse(payload) with self.assertRaises(ProviderError) as ctx: OpenAIChatImageEditProvider( - _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + _provider( + role="edit_model", model="edit-model", provider="openai_image_edit" + ) ).edit(request) self.assertNotIn("secret-key", str(ctx.exception)) self.assertIn("[URL_REDACTED]", str(ctx.exception)) def test_openai_chat_image_edit_marks_download_ssl_eof_as_retryable(self): - payload = {"choices": [{"message": {"content": "https://signed.example/image.png?token=secret-key"}}]} + payload = { + "choices": [ + {"message": {"content": "https://signed.example/image.png?token=secret-key"}} + ] + } with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -609,7 +628,9 @@ def test_openai_chat_image_edit_marks_download_ssl_eof_as_retryable(self): ) with self.assertRaises(ProviderError) as ctx: OpenAIChatImageEditProvider( - _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + _provider( + role="edit_model", model="edit-model", provider="openai_image_edit" + ) ).edit(request) self.assertTrue(ctx.exception.retryable) @@ -617,7 +638,11 @@ def test_openai_chat_image_edit_marks_download_ssl_eof_as_retryable(self): self.assertIn("[URL_REDACTED]", str(ctx.exception)) def test_openai_chat_image_edit_marks_download_http_404_as_non_retryable(self): - payload = {"choices": [{"message": {"content": "https://signed.example/missing.png?token=secret-key"}}]} + payload = { + "choices": [ + {"message": {"content": "https://signed.example/missing.png?token=secret-key"}} + ] + } response = requests.Response() response.status_code = 404 error = requests.HTTPError("404 Client Error", response=response) @@ -640,7 +665,9 @@ def test_openai_chat_image_edit_marks_download_http_404_as_non_retryable(self): get.return_value.raise_for_status.side_effect = error with self.assertRaises(ProviderError) as ctx: OpenAIChatImageEditProvider( - _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + _provider( + role="edit_model", model="edit-model", provider="openai_image_edit" + ) ).edit(request) self.assertFalse(ctx.exception.retryable) @@ -671,7 +698,9 @@ def test_openai_chat_image_edit_marks_download_http_503_as_retryable(self): get.return_value.raise_for_status.side_effect = error with self.assertRaises(ProviderError) as ctx: OpenAIChatImageEditProvider( - _provider(role="edit_model", model="edit-model", provider="openai_image_edit") + _provider( + role="edit_model", model="edit-model", provider="openai_image_edit" + ) ).edit(request) self.assertTrue(ctx.exception.retryable) diff --git a/tests/test_generative_editable_manifest.py b/tests/test_generative_editable_manifest.py index 0257973..7d3ce22 100644 --- a/tests/test_generative_editable_manifest.py +++ b/tests/test_generative_editable_manifest.py @@ -288,7 +288,10 @@ def test_page_manifest_round_trips_through_json_without_losing_structure(self): self.assertEqual(loaded.repair_attempts[0].provenance["prompt_id"], "repair_asset") self.assertEqual(loaded.text_clean_background, "backgrounds/slide-a.text-clean.png") self.assertEqual(loaded.base_clean_background, "backgrounds/slide-a.base-clean.png") - self.assertEqual(loaded.provider_output_paths["repair"], "provider_outputs/repair/0000-slide-a/repair.json") + self.assertEqual( + loaded.provider_output_paths["repair"], + "provider_outputs/repair/0000-slide-a/repair.json", + ) self.assertEqual(loaded.provenance["input_hash"], "abc123") self.assertEqual(loaded.validation_status, "passed") @@ -313,7 +316,9 @@ def test_deck_manifest_round_trips_through_json_without_losing_provider_metadata self.assertEqual(loaded.provider_roles["generation"], "image_model") self.assertEqual(loaded.page_manifest_paths, ["pages/0000-slide-a.json"]) self.assertEqual(loaded.validation_status, "failed") - self.assertEqual(loaded.provenance["validation_error"], "preview similarity below threshold") + self.assertEqual( + loaded.provenance["validation_error"], "preview similarity below threshold" + ) with self.assertRaisesRegex(ValueError, "validation_status"): PageManifest( diff --git a/tests/test_generative_editable_page_analysis.py b/tests/test_generative_editable_page_analysis.py index cd7e8fe..aff0e97 100644 --- a/tests/test_generative_editable_page_analysis.py +++ b/tests/test_generative_editable_page_analysis.py @@ -6,7 +6,12 @@ def _box(text: str, bbox: tuple[int, int, int, int], *, approximate: bool = True return TextBoxSpec( text=text, source_pixel_bbox=bbox, - source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + source_pixel_polygon=( + (bbox[0], bbox[1]), + (bbox[2], bbox[1]), + (bbox[2], bbox[3]), + (bbox[0], bbox[3]), + ), provenance=provenance, ) diff --git a/tests/test_generative_editable_pipeline.py b/tests/test_generative_editable_pipeline.py index 4456154..4823f38 100644 --- a/tests/test_generative_editable_pipeline.py +++ b/tests/test_generative_editable_pipeline.py @@ -1,7 +1,6 @@ import json import tempfile import threading -import time import unittest from pathlib import Path @@ -44,7 +43,9 @@ def test_visual_text_coverage_reports_unmatched_left_label_candidate(self): ], ) - self.assertEqual([issue["code"] for issue in issues], ["visual_text_candidate_missing_ocr_text"]) + self.assertEqual( + [issue["code"] for issue in issues], ["visual_text_candidate_missing_ocr_text"] + ) self.assertEqual(issues[0]["source_pixel_bbox"], (286, 497, 391, 532)) def test_unanchored_approximate_text_boxes_are_filtered_when_local_candidates_exist(self): @@ -172,7 +173,12 @@ def test_right_side_spec_stack_approximate_ocr_is_dropped_without_visual_candida TextBoxSpec( text=text, source_pixel_bbox=bbox, - source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + source_pixel_polygon=( + (bbox[0], bbox[1]), + (bbox[2], bbox[1]), + (bbox[2], bbox[3]), + (bbox[0], bbox[3]), + ), provenance={"ocr_provenance": {"approximate_layout": True}}, ) for text, bbox in [ @@ -207,7 +213,12 @@ def test_right_side_spec_stack_approximate_ocr_requires_visual_column_anchors(se TextBoxSpec( text=text, source_pixel_bbox=bbox, - source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + source_pixel_polygon=( + (bbox[0], bbox[1]), + (bbox[2], bbox[1]), + (bbox[2], bbox[3]), + (bbox[0], bbox[3]), + ), provenance={"ocr_provenance": {"approximate_layout": True}}, ) for text, bbox in [ @@ -312,7 +323,12 @@ def test_right_side_spec_stack_approximate_ocr_survives_row_band_boundaries(self TextBoxSpec( text=text, source_pixel_bbox=bbox, - source_pixel_polygon=((bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])), + source_pixel_polygon=( + (bbox[0], bbox[1]), + (bbox[2], bbox[1]), + (bbox[2], bbox[3]), + (bbox[0], bbox[3]), + ), provenance={"ocr_provenance": {"approximate_layout": True}}, ) for text, bbox in [ @@ -653,7 +669,8 @@ def test_recovery_rejects_repetitive_cjk_hallucination(self): self.assertIsNotNone(helper) item = OCRTextItem( - text="2. 根据用户需求,根据需求,根据用户需求,根据需求,根据用户需求,根据用户需求," * 4, + text="2. 根据用户需求,根据需求,根据用户需求,根据需求,根据用户需求,根据用户需求," + * 4, bbox=(0, 0, 120, 62), polygon=((0, 0), (120, 0), (120, 62), (0, 62)), confidence=0.78, @@ -898,7 +915,9 @@ def test_focused_recovery_keeps_quality_candidates_covered_only_by_approximate_o def test_focused_recovery_removes_overlapping_wrong_approximate_text_boxes(self): import src.generative_editable_pipeline as pipeline - helper = getattr(pipeline, "_remove_approximate_text_boxes_replaced_by_focused_recovery", None) + helper = getattr( + pipeline, "_remove_approximate_text_boxes_replaced_by_focused_recovery", None + ) self.assertIsNotNone(helper) original = [ @@ -931,7 +950,9 @@ def test_focused_recovery_removes_overlapping_wrong_approximate_text_boxes(self) def test_focused_recovery_replaces_offset_approximate_text_in_same_visual_region(self): import src.generative_editable_pipeline as pipeline - helper = getattr(pipeline, "_remove_approximate_text_boxes_replaced_by_focused_recovery", None) + helper = getattr( + pipeline, "_remove_approximate_text_boxes_replaced_by_focused_recovery", None + ) self.assertIsNotNone(helper) original = [ @@ -963,7 +984,6 @@ def test_focused_recovery_replaces_offset_approximate_text_in_same_visual_region def test_recovery_marks_noise_candidates_as_non_blocking(self): import src.generative_editable_pipeline as pipeline - from src.generative_editable_providers import OCRTextItem helper = getattr(pipeline, "_visual_text_coverage_issues", None) self.assertIsNotNone(helper) @@ -1236,7 +1256,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) self.assertEqual(page.chosen_background, "backgrounds/0000-slide-a/base-clean.png") - self.assertNotEqual(page.provenance.get("chosen_background_kind"), "source_preserving_text_clean") + self.assertNotEqual( + page.provenance.get("chosen_background_kind"), "source_preserving_text_clean" + ) self.assertNotIn("source_preserving", page.provenance.get("backgrounds", {})) def test_pipeline_uses_source_raster_guardrail_when_approximate_ocr_layout_is_unreliable(self): @@ -1275,7 +1297,7 @@ def extract_text(self, image_path: str) -> OCRResult: polygon=((20, 92), (380, 92), (380, 142), (20, 142)), confidence=0.9, provenance={"layout_source": "uniform_fallback"}, - ) + ), ] return OCRResult( source_image_path=image_path, @@ -1464,7 +1486,7 @@ def extract_text(self, image_path: str) -> OCRResult: polygon=((20, 92), (380, 92), (380, 142), (20, 142)), confidence=0.9, provenance={"layout_source": "uniform_fallback"}, - ) + ), ] return OCRResult( source_image_path=image_path, @@ -1531,7 +1553,6 @@ def test_pipeline_runs_reconstruction_for_dense_approximate_ocr_layout(self): from PIL import Image, ImageDraw from src.generative_editable_config import load_generative_editable_config - from src.generative_editable_manifest import read_deck_manifest, read_page_manifest from src.generative_editable_pipeline import ( AssetBuildResult, GenerativeEditablePipelineDependencies, @@ -1609,14 +1630,18 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): with self.assertRaises(ProviderError) as caught: run_generative_editable_pipeline( - slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], + slides=[ + GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source)) + ], output_path=str(output_path), artifact_root=str(artifact_root), job_id="job-1", dependencies=GenerativeEditablePipelineDependencies( ocr_provider=DenseApproximateOCRProvider(config.ocr), image_edit_provider=FailingImageEditProvider(config.clean_base_model), - image_generation_provider=FakeImageGenerationProvider(config.generation_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), foreground_planner=lambda **kwargs: [], asset_builder=lambda **kwargs: AssetBuildResult(bitmap_assets=[]), visual_text_candidate_detector=lambda **kwargs: [ @@ -1637,7 +1662,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): ), ) - self.assertIn(caught.exception.operation, {"text_clean_background", "base_clean_background"}) + self.assertIn( + caught.exception.operation, {"text_clean_background", "base_clean_background"} + ) def test_text_validation_marks_source_raster_guardrail_as_degraded(self): import src.generative_editable_pipeline as pipeline @@ -1757,7 +1784,9 @@ def test_bitmap_coverage_validation_allows_split_row_level_complex_assets(self): self.assertEqual(report.status, "passed") self.assertEqual(report.issues, []) - def test_bitmap_coverage_validation_allows_infographic_assets_with_dense_editable_structure(self): + def test_bitmap_coverage_validation_allows_infographic_assets_with_dense_editable_structure( + self, + ): import src.generative_editable_pipeline as pipeline helper = getattr(pipeline, "_bitmap_coverage_validation_report", None) @@ -2003,7 +2032,7 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): "asset_strategy": "masked_source_element", "background_difference_alpha": True, }, - ) + ), ] ), visual_text_candidate_detector=lambda **kwargs: [], @@ -2019,8 +2048,12 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): deck = read_deck_manifest(artifact_root / "job-1" / "deck.json") page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) - self.assertNotEqual(page.provenance.get("chosen_background_kind"), "source_preserving_text_clean") - self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["oversized-source", "small-icon"]) + self.assertNotEqual( + page.provenance.get("chosen_background_kind"), "source_preserving_text_clean" + ) + self.assertEqual( + [asset.asset_id for asset in page.bitmap_assets], ["oversized-source", "small-icon"] + ) def test_diff_alpha_source_preserved_asset_is_not_treated_as_oversized_source_crop(self): import src.generative_editable_pipeline as pipeline @@ -2250,7 +2283,9 @@ def test_native_shapes_are_not_dropped_by_transparent_complex_bitmap_assets(self self.assertEqual(filtered, [panel]) - def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cover_children(self): + def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cover_children( + self, + ): from PIL import Image, ImageDraw from src.generative_editable_foreground_planner import ForegroundCandidate @@ -2260,8 +2295,12 @@ def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cov source = Path(tmp) / "source.png" image = Image.new("RGB", (800, 450), "#050B16") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((40, 80, 760, 190), radius=18, fill="#0B2140", outline="#1D4ED8", width=3) - draw.rounded_rectangle((40, 220, 760, 330), radius=18, fill="#0B2140", outline="#1D4ED8", width=3) + draw.rounded_rectangle( + (40, 80, 760, 190), radius=18, fill="#0B2140", outline="#1D4ED8", width=3 + ) + draw.rounded_rectangle( + (40, 220, 760, 330), radius=18, fill="#0B2140", outline="#1D4ED8", width=3 + ) draw.line((120, 140, 700, 140), fill="#38BDF8", width=3) draw.line((120, 280, 700, 280), fill="#38BDF8", width=3) image.save(source) @@ -2274,7 +2313,10 @@ def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cov area=740 * 270, classification="complex_whole_visual", confidence=0.86, - provenance={"detection": "source_base_difference", "reason": "large_or_visually_complex"}, + provenance={ + "detection": "source_base_difference", + "reason": "large_or_visually_complex", + }, ), ForegroundCandidate( candidate_id="panel-1", @@ -2282,7 +2324,10 @@ def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cov area=720 * 110, classification="native_shape_candidate", confidence=0.94, - provenance={"detection": "source_native_shape_scan", "shape_hint": "rounded_rectangle"}, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "rounded_rectangle", + }, ), ForegroundCandidate( candidate_id="panel-2", @@ -2290,7 +2335,10 @@ def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cov area=720 * 110, classification="native_shape_candidate", confidence=0.94, - provenance={"detection": "source_native_shape_scan", "shape_hint": "rounded_rectangle"}, + provenance={ + "detection": "source_native_shape_scan", + "shape_hint": "rounded_rectangle", + }, ), ForegroundCandidate( candidate_id="line-1", @@ -2325,7 +2373,9 @@ def test_structural_container_bitmap_candidate_is_dropped_when_native_shapes_cov ) self.assertGreaterEqual(len(native_shapes), 4) - self.assertNotIn("large-container", [candidate.candidate_id for candidate in bitmap_candidates]) + self.assertNotIn( + "large-container", [candidate.candidate_id for candidate in bitmap_candidates] + ) def test_structural_container_drop_accepts_many_native_children_with_quarter_coverage(self): import src.generative_editable_pipeline as pipeline @@ -2599,7 +2649,10 @@ def test_generic_visual_anchors_are_added_for_cover_title_slides_with_lower_visu ) self.assertTrue( - any(candidate.provenance["detection"] == "source_generic_visual_anchor" for candidate in augmented) + any( + candidate.provenance["detection"] == "source_generic_visual_anchor" + for candidate in augmented + ) ) def test_generic_visual_anchors_are_added_for_cover_title_slides_with_wide_lower_photo(self): @@ -2638,7 +2691,10 @@ def test_generic_visual_anchors_are_added_for_cover_title_slides_with_wide_lower ) self.assertTrue( - any(candidate.provenance["detection"] == "source_generic_visual_anchor" for candidate in augmented) + any( + candidate.provenance["detection"] == "source_generic_visual_anchor" + for candidate in augmented + ) ) def test_cover_title_slide_adds_wide_lower_source_visual_anchor(self): @@ -2735,7 +2791,10 @@ def test_cover_title_slide_does_not_add_lower_anchor_for_top_ota_marker_only(sel ) self.assertFalse( - any(candidate.provenance["detection"] == "cover_title_lower_visual_anchor" for candidate in augmented) + any( + candidate.provenance["detection"] == "cover_title_lower_visual_anchor" + for candidate in augmented + ) ) def test_generic_visual_anchor_source_asset_uses_background_alpha(self): @@ -2849,7 +2908,9 @@ def test_source_base_difference_large_complex_visual_uses_alpha_trim(self): Image.new("RGB", (200, 120), "#061020").save(background) source_image = Image.new("RGB", (200, 120), "#061020") draw = ImageDraw.Draw(source_image) - draw.rounded_rectangle((40, 30, 160, 90), radius=10, fill="#0B2140", outline="#1D4ED8", width=2) + draw.rounded_rectangle( + (40, 30, 160, 90), radius=10, fill="#0B2140", outline="#1D4ED8", width=2 + ) draw.rectangle((86, 50, 116, 70), fill="#DCEAFE") source_image.save(source) @@ -2901,7 +2962,9 @@ def test_edge_spanning_diff_candidate_is_split_into_complex_visual_bands(self): [candidate.candidate_id for candidate in expanded], ["fg-wide-band-1", "fg-wide-band-2", "fg-wide-band-3"], ) - self.assertTrue(all(candidate.classification == "complex_whole_visual" for candidate in expanded)) + self.assertTrue( + all(candidate.classification == "complex_whole_visual" for candidate in expanded) + ) self.assertEqual(expanded[0].provenance["detection"], "split_edge_spanning_difference") self.assertEqual(expanded[0].provenance["parent_candidate_id"], "fg-wide") @@ -2930,7 +2993,10 @@ def test_large_layered_diff_candidate_is_split_into_row_level_bitmap_bands(self) ["fg-layered-band-1", "fg-layered-band-2", "fg-layered-band-3"], ) self.assertTrue( - all(candidate.provenance["detection"] == "split_large_layered_difference" for candidate in expanded) + all( + candidate.provenance["detection"] == "split_large_layered_difference" + for candidate in expanded + ) ) self.assertLess( max(candidate.area for candidate in expanded) / float(1706 * 960), @@ -2972,9 +3038,15 @@ def test_reconstruction_targets_promote_edge_spanning_bands_to_native_shapes(sel source = Path(tmp) / "source.png" image = Image.new("RGB", (1600, 940), "#020817") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((0, 150, 1600, 405), radius=22, fill="#0B2140", outline="#1D4ED8", width=3) - draw.rounded_rectangle((0, 405, 1600, 660), radius=22, fill="#0B2140", outline="#1D4ED8", width=3) - draw.rounded_rectangle((0, 660, 1600, 920), radius=22, fill="#0B2140", outline="#1D4ED8", width=3) + draw.rounded_rectangle( + (0, 150, 1600, 405), radius=22, fill="#0B2140", outline="#1D4ED8", width=3 + ) + draw.rounded_rectangle( + (0, 405, 1600, 660), radius=22, fill="#0B2140", outline="#1D4ED8", width=3 + ) + draw.rounded_rectangle( + (0, 660, 1600, 920), radius=22, fill="#0B2140", outline="#1D4ED8", width=3 + ) image.save(source) native_shapes, bitmap_candidates = _plan_reconstruction_targets( @@ -2999,7 +3071,10 @@ def test_reconstruction_targets_promote_edge_spanning_bands_to_native_shapes(sel self.assertTrue(all(shape.shape_type == "rounded_rectangle" for shape in promoted)) self.assertTrue(all(shape.opacity == 0.15 for shape in promoted)) self.assertFalse( - any(candidate.candidate_id.startswith("fg-wide-band-") for candidate in bitmap_candidates) + any( + candidate.candidate_id.startswith("fg-wide-band-") + for candidate in bitmap_candidates + ) ) def test_reconstruction_targets_promote_large_card_containers_to_native_shapes(self): @@ -3082,7 +3157,10 @@ def test_reconstruction_targets_promote_large_card_containers_to_native_shapes(s ["left-card", "right-card"], ) self.assertFalse( - any(candidate.candidate_id in {"left-card", "right-card"} for candidate in bitmap_candidates) + any( + candidate.candidate_id in {"left-card", "right-card"} + for candidate in bitmap_candidates + ) ) self.assertTrue( any(candidate.candidate_id == "left-icon" for candidate in bitmap_candidates) @@ -3136,9 +3214,14 @@ def test_reconstruction_targets_do_not_promote_unframed_screenshot_as_card_conta ) self.assertFalse( - any(shape.provenance.get("parent_candidate_id") == "screenshot" for shape in native_shapes) + any( + shape.provenance.get("parent_candidate_id") == "screenshot" + for shape in native_shapes + ) + ) + self.assertTrue( + any(candidate.candidate_id == "screenshot" for candidate in bitmap_candidates) ) - self.assertTrue(any(candidate.candidate_id == "screenshot" for candidate in bitmap_candidates)) def test_reconstruction_targets_promote_large_card_container_with_native_children(self): from PIL import Image, ImageDraw @@ -3150,7 +3233,9 @@ def test_reconstruction_targets_promote_large_card_container_with_native_childre source = Path(tmp) / "source.png" image = Image.new("RGB", (1000, 600), "#020817") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((40, 90, 450, 480), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + draw.rounded_rectangle( + (40, 90, 450, 480), radius=18, fill="#071B36", outline="#1D4ED8", width=3 + ) draw.line((80, 210, 410, 210), fill="#94A3B8", width=3) draw.line((80, 330, 410, 330), fill="#94A3B8", width=3) image.save(source) @@ -3197,11 +3282,18 @@ def test_reconstruction_targets_promote_large_card_container_with_native_childre ) self.assertTrue( - any(shape.provenance.get("parent_candidate_id") == "left-card" for shape in native_shapes) + any( + shape.provenance.get("parent_candidate_id") == "left-card" + for shape in native_shapes + ) + ) + self.assertFalse( + any(candidate.candidate_id == "left-card" for candidate in bitmap_candidates) ) - self.assertFalse(any(candidate.candidate_id == "left-card" for candidate in bitmap_candidates)) - def test_reconstruction_targets_promote_card_container_when_candidate_is_trimmed_inside_border(self): + def test_reconstruction_targets_promote_card_container_when_candidate_is_trimmed_inside_border( + self, + ): from PIL import Image, ImageDraw from src.generative_editable_foreground_planner import ForegroundCandidate @@ -3211,7 +3303,9 @@ def test_reconstruction_targets_promote_card_container_when_candidate_is_trimmed source = Path(tmp) / "source.png" image = Image.new("RGB", (1000, 600), "#020817") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((40, 90, 450, 480), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + draw.rounded_rectangle( + (40, 90, 450, 480), radius=18, fill="#071B36", outline="#1D4ED8", width=3 + ) draw.line((80, 210, 410, 210), fill="#94A3B8", width=3) draw.line((80, 330, 410, 330), fill="#94A3B8", width=3) image.save(source) @@ -3258,9 +3352,14 @@ def test_reconstruction_targets_promote_card_container_when_candidate_is_trimmed ) self.assertTrue( - any(shape.provenance.get("parent_candidate_id") == "trimmed-card" for shape in native_shapes) + any( + shape.provenance.get("parent_candidate_id") == "trimmed-card" + for shape in native_shapes + ) + ) + self.assertFalse( + any(candidate.candidate_id == "trimmed-card" for candidate in bitmap_candidates) ) - self.assertFalse(any(candidate.candidate_id == "trimmed-card" for candidate in bitmap_candidates)) def test_reconstruction_targets_do_not_promote_framed_high_texture_screenshot(self): from PIL import Image, ImageDraw @@ -3272,7 +3371,9 @@ def test_reconstruction_targets_do_not_promote_framed_high_texture_screenshot(se source = Path(tmp) / "source.png" image = Image.new("RGB", (1000, 600), "#020817") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((120, 100, 700, 450), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + draw.rounded_rectangle( + (120, 100, 700, 450), radius=18, fill="#071B36", outline="#1D4ED8", width=3 + ) for y in range(120, 430): for x in range(145, 675): red = (x * 7 + y * 3) % 255 @@ -3311,9 +3412,14 @@ def test_reconstruction_targets_do_not_promote_framed_high_texture_screenshot(se ) self.assertFalse( - any(shape.provenance.get("parent_candidate_id") == "framed-screenshot" for shape in native_shapes) + any( + shape.provenance.get("parent_candidate_id") == "framed-screenshot" + for shape in native_shapes + ) + ) + self.assertTrue( + any(candidate.candidate_id == "framed-screenshot" for candidate in bitmap_candidates) ) - self.assertTrue(any(candidate.candidate_id == "framed-screenshot" for candidate in bitmap_candidates)) def test_reconstruction_targets_do_not_promote_nested_screenshot_inside_card_container(self): from PIL import Image, ImageDraw @@ -3325,7 +3431,9 @@ def test_reconstruction_targets_do_not_promote_nested_screenshot_inside_card_con source = Path(tmp) / "source.png" image = Image.new("RGB", (1000, 600), "#020817") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((100, 90, 850, 450), radius=18, fill="#071B36", outline="#1D4ED8", width=3) + draw.rounded_rectangle( + (100, 90, 850, 450), radius=18, fill="#071B36", outline="#1D4ED8", width=3 + ) for y in range(140, 390): for x in range(260, 760): red = (x * 7 + y * 3) % 255 @@ -3371,12 +3479,20 @@ def test_reconstruction_targets_do_not_promote_nested_screenshot_inside_card_con ) self.assertTrue( - any(shape.provenance.get("parent_candidate_id") == "outer-card" for shape in native_shapes) + any( + shape.provenance.get("parent_candidate_id") == "outer-card" + for shape in native_shapes + ) ) self.assertFalse( - any(shape.provenance.get("parent_candidate_id") == "inner-screenshot" for shape in native_shapes) + any( + shape.provenance.get("parent_candidate_id") == "inner-screenshot" + for shape in native_shapes + ) + ) + self.assertTrue( + any(candidate.candidate_id == "inner-screenshot" for candidate in bitmap_candidates) ) - self.assertTrue(any(candidate.candidate_id == "inner-screenshot" for candidate in bitmap_candidates)) def test_domain_row_visual_anchor_bboxes_use_space_between_label_and_specs(self): import src.generative_editable_pipeline as pipeline @@ -3726,7 +3842,12 @@ def test_source_preserving_background_is_not_chosen_for_very_native_heavy_comple native_shapes = [ NativeShapeSpec( shape_type="rounded_rectangle", - source_pixel_bbox=(10 + (i % 10) * 32, 45 + (i // 10) * 42, 34 + (i % 10) * 32, 69 + (i // 10) * 42), + source_pixel_bbox=( + 10 + (i % 10) * 32, + 45 + (i // 10) * 42, + 34 + (i % 10) * 32, + 69 + (i // 10) * 42, + ), fill_color="#123456", ) for i in range(20) @@ -3757,7 +3878,9 @@ def test_source_preserving_background_is_not_chosen_for_very_native_heavy_comple self.assertFalse(should_choose) - def test_source_preserving_background_is_not_chosen_for_many_native_shapes_with_few_bitmap_assets(self): + def test_source_preserving_background_is_not_chosen_for_many_native_shapes_with_few_bitmap_assets( + self, + ): from PIL import Image import src.generative_editable_pipeline as pipeline @@ -3822,7 +3945,9 @@ def test_source_preserving_background_is_not_chosen_for_many_native_shapes_with_ self.assertFalse(should_choose) - def test_source_raster_guardrail_does_not_trigger_for_many_tall_short_warning_only_text_boxes(self): + def test_source_raster_guardrail_does_not_trigger_for_many_tall_short_warning_only_text_boxes( + self, + ): import src.generative_editable_pipeline as pipeline helper = getattr(pipeline, "_should_use_source_raster_guardrail", None) @@ -3898,8 +4023,16 @@ def test_source_raster_guardrail_does_not_trigger_for_fragmented_top_warning_onl ), ] text_issues = [ - {"code": "ignored_spurious_ocr", "severity": "warning", "ocr_text": "基于增程系统消除长距离出行焦虑"}, - {"code": "ignored_spurious_ocr", "severity": "warning", "ocr_text": "从用户安全与长期用车角度建立系统性落地指南"}, + { + "code": "ignored_spurious_ocr", + "severity": "warning", + "ocr_text": "基于增程系统消除长距离出行焦虑", + }, + { + "code": "ignored_spurious_ocr", + "severity": "warning", + "ocr_text": "从用户安全与长期用车角度建立系统性落地指南", + }, ] should_guard = helper( @@ -4508,7 +4641,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): self.assertEqual(result.status, "passed") self.assertEqual([shape.shape_type for shape in page.native_shapes], ["rectangle"]) self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["asset-001"]) - self.assertTrue(Path(artifact_root / "job-1" / page.bitmap_assets[0].asset_path).exists()) + self.assertTrue( + Path(artifact_root / "job-1" / page.bitmap_assets[0].asset_path).exists() + ) self.assertEqual([sheet.sheet_id for sheet in page.asset_sheets], ["asset-sheet-0000"]) self.assertEqual(page.asset_sheets[0].candidate_ids, ["asset-001"]) self.assertIn("asset_sheet", edit_prompt_ids) @@ -4636,7 +4771,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): asset_builder_calls[0]["asset_reference_image_path"], asset_builder_calls[0]["source_image_path"], ) - self.assertTrue(asset_builder_calls[0]["asset_reference_image_path"].endswith("text-clean.png")) + self.assertTrue( + asset_builder_calls[0]["asset_reference_image_path"].endswith("text-clean.png") + ) self.assertEqual(page.bitmap_assets, []) self.assertEqual(page.native_shapes, []) self.assertTrue(page.text_boxes[0].style_hints["approximate_layout"]) @@ -4839,7 +4976,9 @@ def test_reconstruction_targets_drop_line_like_bitmap_residuals(self): source_image_path=str(source), ) - self.assertEqual([candidate.candidate_id for candidate in bitmap_candidates], ["real-object"]) + self.assertEqual( + [candidate.candidate_id for candidate in bitmap_candidates], ["real-object"] + ) def test_reconstruction_targets_merge_nearby_bitmap_fragments(self): from PIL import Image, ImageDraw @@ -4885,7 +5024,9 @@ def test_reconstruction_targets_merge_nearby_bitmap_fragments(self): self.assertEqual(len(bitmap_candidates), 2) self.assertEqual(bitmap_candidates[0].source_pixel_bbox, (60, 80, 189, 141)) - self.assertEqual(bitmap_candidates[0].provenance["merged_candidate_ids"], ["near-left", "near-right"]) + self.assertEqual( + bitmap_candidates[0].provenance["merged_candidate_ids"], ["near-left", "near-right"] + ) self.assertEqual(bitmap_candidates[1].candidate_id, "far") def test_masked_source_element_assets_preserve_visuals_and_remove_text_pixels(self): @@ -5094,7 +5235,9 @@ def test_visual_anchor_source_asset_does_not_collapse_to_tiny_saliency_fragment( self.assertGreater(assets[0].source_pixel_bbox[2] - assets[0].source_pixel_bbox[0], 160) self.assertFalse(assets[0].provenance["smooth_background_difference_suppressed"]) - def test_domain_row_visual_anchor_does_not_collapse_to_one_object_when_multiple_visuals_exist(self): + def test_domain_row_visual_anchor_does_not_collapse_to_one_object_when_multiple_visuals_exist( + self, + ): from PIL import Image, ImageDraw from src.generative_editable_foreground_planner import ForegroundCandidate @@ -5114,7 +5257,9 @@ def test_domain_row_visual_anchor_does_not_collapse_to_one_object_when_multiple_ source_image = Image.new("RGB", (360, 160), "#061020") source_draw = ImageDraw.Draw(source_image) source_draw.rounded_rectangle((20, 30, 340, 130), radius=10, fill="#12304F") - source_draw.rounded_rectangle((74, 58, 166, 106), radius=6, fill="#07111E", outline="#1F2937", width=3) + source_draw.rounded_rectangle( + (74, 58, 166, 106), radius=6, fill="#07111E", outline="#1F2937", width=3 + ) source_draw.ellipse((236, 54, 304, 116), fill="#1F2937", outline="#CBD5E1", width=4) source_draw.ellipse((258, 76, 282, 100), fill="#94A3B8") source_image.save(source) @@ -5178,7 +5323,9 @@ def test_large_source_preserved_asset_feathers_outer_alpha_edges(self): with Image.open(root / assets[0].asset_path).convert("RGBA") as asset_image: edge_alpha = asset_image.getpixel((0, asset_image.height // 2))[3] - center_alpha = asset_image.getpixel((asset_image.width // 2, asset_image.height // 2))[3] + center_alpha = asset_image.getpixel( + (asset_image.width // 2, asset_image.height // 2) + )[3] self.assertLess(edge_alpha, center_alpha) self.assertEqual(center_alpha, 255) @@ -5194,7 +5341,9 @@ def test_reconstruction_targets_drop_bitmap_candidates_covered_by_source_native_ source = Path(tmp) / "source.png" image = Image.new("RGB", (240, 140), "#F8FAFC") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((30, 20, 130, 70), radius=12, fill="#FEF3C7", outline="#D97706", width=3) + draw.rounded_rectangle( + (30, 20, 130, 70), radius=12, fill="#FEF3C7", outline="#D97706", width=3 + ) draw.line((150, 110, 220, 80), fill="#7C3AED", width=5) image.save(source) @@ -5234,10 +5383,14 @@ def test_reconstruction_targets_drop_bitmap_candidates_covered_by_source_native_ source_image_path=str(source), ) - self.assertEqual([shape.shape_type for shape in native_shapes], ["rounded_rectangle", "line"]) + self.assertEqual( + [shape.shape_type for shape in native_shapes], ["rounded_rectangle", "line"] + ) self.assertEqual(bitmap_candidates, []) - def test_reconstruction_targets_ignore_full_page_diff_and_tiny_fragments_inside_native_shapes(self): + def test_reconstruction_targets_ignore_full_page_diff_and_tiny_fragments_inside_native_shapes( + self, + ): from PIL import Image, ImageDraw from src.generative_editable_foreground_planner import ForegroundCandidate @@ -5247,7 +5400,9 @@ def test_reconstruction_targets_ignore_full_page_diff_and_tiny_fragments_inside_ source = Path(tmp) / "source.png" image = Image.new("RGB", (800, 450), "#F8FAFC") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((48, 46, 752, 118), radius=18, fill="#E0F2FE", outline="#0284C7", width=3) + draw.rounded_rectangle( + (48, 46, 752, 118), radius=18, fill="#E0F2FE", outline="#0284C7", width=3 + ) draw.text((82, 66), "Quarterly Plan", fill="#0F172A") image.save(source) @@ -5363,8 +5518,12 @@ def test_detects_source_native_shape_candidates_when_clean_background_is_unusabl image = Image.new("RGB", (220, 140), "#F8FAFC") draw = ImageDraw.Draw(image) draw.rectangle((30, 40, 120, 90), fill="#2563EB") - draw.rounded_rectangle((30, 12, 120, 30), radius=8, fill="#E0F2FE", outline="#0284C7", width=2) - draw.rounded_rectangle((132, 40, 200, 90), radius=12, fill="#FEF3C7", outline="#D97706", width=2) + draw.rounded_rectangle( + (30, 12, 120, 30), radius=8, fill="#E0F2FE", outline="#0284C7", width=2 + ) + draw.rounded_rectangle( + (132, 40, 200, 90), radius=12, fill="#FEF3C7", outline="#D97706", width=2 + ) draw.line((145, 118, 205, 104), fill="#7C3AED", width=4) image.save(source) Image.new("L", (220, 140), 0).save(mask) @@ -5390,8 +5549,15 @@ def test_reconstruction_targets_drop_edge_residuals_without_native_shapes(self): image = Image.new("RGB", (800, 450), "#F8FAFC") draw = ImageDraw.Draw(image) draw.text((72, 46), "Complex Visual", fill="#0F172A") - draw.polygon([(90, 360), (250, 250), (430, 310), (700, 170), (700, 383), (90, 383)], fill="#DBEAFE") - draw.line([(100, 330), (190, 260), (290, 286), (398, 198), (520, 224), (680, 146)], fill="#2563EB", width=4) + draw.polygon( + [(90, 360), (250, 250), (430, 310), (700, 170), (700, 383), (90, 383)], + fill="#DBEAFE", + ) + draw.line( + [(100, 330), (190, 260), (290, 286), (398, 198), (520, 224), (680, 146)], + fill="#2563EB", + width=4, + ) image.save(source) native_shapes, bitmap_candidates = _plan_reconstruction_targets( @@ -5526,7 +5692,9 @@ def test_text_exclusion_mask_does_not_add_unmatched_visual_highlights(self): self.assertEqual(mask.getpixel((32, 28)), 255) self.assertEqual(mask.getpixel((300, 145)), 0) - def test_pipeline_adds_source_native_shapes_even_when_base_clean_provider_returns_candidates(self): + def test_pipeline_adds_source_native_shapes_even_when_base_clean_provider_returns_candidates( + self, + ): from PIL import Image, ImageDraw from src.generative_editable_config import load_generative_editable_config @@ -5597,7 +5765,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): artifact_root = Path(tmp) / "jobs" image = Image.new("RGB", (240, 140), "#F8FAFC") draw = ImageDraw.Draw(image) - draw.rounded_rectangle((30, 20, 130, 70), radius=12, fill="#FEF3C7", outline="#D97706", width=3) + draw.rounded_rectangle( + (30, 20, 130, 70), radius=12, fill="#FEF3C7", outline="#D97706", width=3 + ) draw.line((150, 110, 220, 80), fill="#7C3AED", width=5) image.save(source) @@ -5625,7 +5795,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): page = read_page_manifest(artifact_root / "job-1" / deck.page_manifest_paths[0]) self.assertEqual(result.status, "passed") - self.assertEqual([shape.shape_type for shape in page.native_shapes], ["rounded_rectangle", "line"]) + self.assertEqual( + [shape.shape_type for shape in page.native_shapes], ["rounded_rectangle", "line"] + ) self.assertEqual(page.bitmap_assets, []) self.assertEqual( page.chosen_background, @@ -5840,7 +6012,9 @@ def edit(self, request): asset_sizes.append(asset_image.size) asset_pixels.append(asset_image.convert("RGBA").getbbox()) - self.assertEqual([asset.asset_id for asset in result.bitmap_assets], ["blue-block", "green-dot"]) + self.assertEqual( + [asset.asset_id for asset in result.bitmap_assets], ["blue-block", "green-dot"] + ) self.assertEqual(len(result.asset_sheets), 1) self.assertTrue(all(size[0] < 80 and size[1] < 80 for size in asset_sizes)) self.assertTrue(all(bbox is not None for bbox in asset_pixels)) @@ -6096,7 +6270,9 @@ def generate(self, request): self.assertEqual(result.asset_sheets, []) self.assertEqual(result.bitmap_assets[0].asset_id, "asset-001") - self.assertEqual(result.bitmap_assets[0].provenance["fallback"], "source_crop_after_asset_sheet_failure") + self.assertEqual( + result.bitmap_assets[0].provenance["fallback"], "source_crop_after_asset_sheet_failure" + ) self.assertEqual(result.provider_output_payload["asset_sheet_fallback"], "source_crops") self.assertEqual(asset_size, (41, 31)) @@ -6205,7 +6381,9 @@ def edit(self, request): source = root / "sources" / "slide.png" source.parent.mkdir(parents=True) image = Image.new("RGB", (800, 450), "white") - ImageDraw.Draw(image).rectangle((80, 172, 279, 301), fill="#DCFCE7", outline="#16A34A", width=3) + ImageDraw.Draw(image).rectangle( + (80, 172, 279, 301), fill="#DCFCE7", outline="#16A34A", width=3 + ) image.save(source) with self.assertRaises(ProviderError): @@ -6259,8 +6437,15 @@ def edit(self, request): source.parent.mkdir(parents=True) image = Image.new("RGB", (800, 450), "#F8FAFC") draw = ImageDraw.Draw(image) - draw.polygon([(90, 360), (250, 250), (430, 310), (700, 170), (700, 383), (90, 383)], fill="#DBEAFE") - draw.line([(100, 330), (190, 260), (290, 286), (398, 198), (520, 224), (680, 146)], fill="#2563EB", width=4) + draw.polygon( + [(90, 360), (250, 250), (430, 310), (700, 170), (700, 383), (90, 383)], + fill="#DBEAFE", + ) + draw.line( + [(100, 330), (190, 260), (290, 286), (398, 198), (520, 224), (680, 146)], + fill="#2563EB", + width=4, + ) image.save(source) background = root / "background.png" Image.new("RGB", (800, 450), "#F8FAFC").save(background) @@ -6294,13 +6479,17 @@ def edit(self, request): self.assertEqual(provider.calls, 0) self.assertEqual([asset.asset_id for asset in result.bitmap_assets], ["fg-258"]) - self.assertEqual(result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element") + self.assertEqual( + result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element" + ) self.assertFalse(result.bitmap_assets[0].provenance["text_mask_applied"]) self.assertFalse(result.bitmap_assets[0].provenance["background_difference_alpha"]) with Image.open(root / result.bitmap_assets[0].asset_path) as asset_image: alpha = asset_image.convert("RGBA").getchannel("A") self.assertEqual(alpha.getextrema(), (255, 255)) - self.assertEqual(result.provider_output_payload["asset_strategy"], "masked_source_elements") + self.assertEqual( + result.provider_output_payload["asset_strategy"], "masked_source_elements" + ) def test_complex_whole_visual_skips_multi_component_asset_sheet_slicing(self): from PIL import Image, ImageDraw @@ -6376,7 +6565,9 @@ def edit(self, request): self.assertEqual([asset.asset_id for asset in result.bitmap_assets], ["fg-complex"]) self.assertEqual(provider.calls, 0) - self.assertEqual(result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element") + self.assertEqual( + result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element" + ) def test_complex_singleton_preservation_keeps_candidate_crop_without_provider_input(self): from PIL import Image, ImageDraw @@ -6456,7 +6647,9 @@ def edit(self, request): self.assertEqual(provider.source_image_paths, []) self.assertEqual(crop_size, (200, 64)) self.assertIn("assets", crop_path.parts) - self.assertEqual(result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element") + self.assertEqual( + result.bitmap_assets[0].provenance["asset_strategy"], "masked_source_element" + ) def test_orchestration_text_validation_failure_blocks_success(self): from PIL import Image @@ -6718,9 +6911,7 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): dependencies=GenerativeEditablePipelineDependencies( ocr_provider=RecoveringOCRProvider(config.ocr), image_edit_provider=FakeImageEditProvider(config.clean_base_model), - image_generation_provider=FakeImageGenerationProvider( - config.generation_model - ), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), visual_text_candidate_detector=lambda **kwargs: [ (120, 90, 220, 124), (120, 200, 220, 234), @@ -6741,7 +6932,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): ) self.assertEqual(page_manifest.validation_status, "passed") - self.assertEqual([box.text for box in page_manifest.text_boxes], ["智能域", "动力域", "底盘域"]) + self.assertEqual( + [box.text for box in page_manifest.text_boxes], ["智能域", "动力域", "底盘域"] + ) self.assertEqual(page_manifest.text_boxes[-1].source_pixel_bbox, (120, 200, 220, 234)) self.assertEqual(page_manifest.provenance["text_issues"], []) @@ -6897,7 +7090,9 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): edit_provider = RecordingEditProvider() run_generative_editable_pipeline( - slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source_path))], + slides=[ + GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source_path)) + ], output_path=str(output_path), artifact_root=str(artifact_root), job_id="job-1", @@ -6958,15 +7153,13 @@ def failing_composer(deck_manifest_path, artifact_root, output_path): artifact_root=str(artifact_root), job_id="job-1", fallback_policy="raster_pptx", - fallback_output_factory=lambda: fallback_path.write_bytes(b"raster") and str( - fallback_path + fallback_output_factory=lambda: ( + fallback_path.write_bytes(b"raster") and str(fallback_path) ), dependencies=GenerativeEditablePipelineDependencies( ocr_provider=FakeOCRProvider(config.ocr), image_edit_provider=FakeImageEditProvider(config.clean_base_model), - image_generation_provider=FakeImageGenerationProvider( - config.generation_model - ), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), composer=failing_composer, ), ) @@ -7035,8 +7228,8 @@ def extract_text(self, image_path): artifact_root=str(artifact_root), job_id="job-1", fallback_policy="raster_pptx", - fallback_output_factory=lambda: fallback_path.write_bytes(b"raster") and str( - fallback_path + fallback_output_factory=lambda: ( + fallback_path.write_bytes(b"raster") and str(fallback_path) ), dependencies=GenerativeEditablePipelineDependencies( ocr_provider=FailingOCRProvider(), @@ -7101,8 +7294,8 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): artifact_root=str(artifact_root), job_id="job-asset-fallback", fallback_policy="raster_pptx", - fallback_output_factory=lambda: fallback_path.write_bytes(b"raster") and str( - fallback_path + fallback_output_factory=lambda: ( + fallback_path.write_bytes(b"raster") and str(fallback_path) ), dependencies=GenerativeEditablePipelineDependencies( ocr_provider=FakeOCRProvider(config.ocr), @@ -7191,7 +7384,9 @@ def extract_text(self, image_path): dependencies=GenerativeEditablePipelineDependencies( ocr_provider=FailingOCRProvider(), image_edit_provider=FakeImageEditProvider(config.clean_base_model), - image_generation_provider=FakeImageGenerationProvider(config.generation_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -7283,7 +7478,9 @@ def extract_text(self, image_path): self.assertEqual(result.status, "passed") self.assertEqual(page.text_boxes[0].text, "Metadata Title") - self.assertEqual(page.text_boxes[0].provenance["ocr_provenance"]["fallback"], "ocr_provider_failed") + self.assertEqual( + page.text_boxes[0].provenance["ocr_provenance"]["fallback"], "ocr_provider_failed" + ) def test_pipeline_uses_configured_ocr_min_confidence(self): from PIL import Image @@ -7354,7 +7551,9 @@ def extract_text(self, image_path): dependencies=GenerativeEditablePipelineDependencies( ocr_provider=LowConfidenceOCRProvider(), image_edit_provider=FakeImageEditProvider(config.clean_base_model), - image_generation_provider=FakeImageGenerationProvider(config.generation_model), + image_generation_provider=FakeImageGenerationProvider( + config.generation_model + ), structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -7396,42 +7595,47 @@ def test_multi_slide_orchestration_preserves_order_and_aggregates_validation_and max_active_pages = 0 processed_slide_ids = [] lock = threading.Lock() + page_barrier = threading.Barrier(2, timeout=5) def fake_asset_builder(**kwargs): nonlocal active_pages, max_active_pages with lock: active_pages += 1 max_active_pages = max(max_active_pages, active_pages) - processed_slide_ids.append(kwargs["slide_id"]) - time.sleep(0.05) - asset = BitmapAssetSpec( - asset_id=f"{kwargs['slide_id']}-asset", - source_pixel_bbox=(500, 120, 620, 220), - asset_path=f"assets/{kwargs['page_index']:04d}-{kwargs['slide_id']}/fg-001.png", - z_order=1, - ) - Path(kwargs["asset_root"], asset.asset_path).parent.mkdir(parents=True, exist_ok=True) - Image.new("RGBA", (120, 100), (0, 0, 0, 0)).save( - Path(kwargs["asset_root"], asset.asset_path) - ) - repair_attempts = [] - if kwargs["slide_id"] == "slide-b": - repair_attempts.append( - RepairAttempt( - target_id=asset.asset_id, - attempt_index=1, - reason="edge_touch", - provider_role="edit_model", - status="passed", + try: + page_barrier.wait() + processed_slide_ids.append(kwargs["slide_id"]) + asset = BitmapAssetSpec( + asset_id=f"{kwargs['slide_id']}-asset", + source_pixel_bbox=(500, 120, 620, 220), + asset_path=f"assets/{kwargs['page_index']:04d}-{kwargs['slide_id']}/fg-001.png", + z_order=1, + ) + Path(kwargs["asset_root"], asset.asset_path).parent.mkdir( + parents=True, exist_ok=True + ) + Image.new("RGBA", (120, 100), (0, 0, 0, 0)).save( + Path(kwargs["asset_root"], asset.asset_path) + ) + repair_attempts = [] + if kwargs["slide_id"] == "slide-b": + repair_attempts.append( + RepairAttempt( + target_id=asset.asset_id, + attempt_index=1, + reason="edge_touch", + provider_role="edit_model", + status="passed", + ) ) + return AssetBuildResult( + bitmap_assets=[asset], + repair_attempts=repair_attempts, + provider_output_payload={"candidate_ids": ["should-not-override"]}, ) - with lock: - active_pages -= 1 - return AssetBuildResult( - bitmap_assets=[asset], - repair_attempts=repair_attempts, - provider_output_payload={"candidate_ids": ["should-not-override"]}, - ) + finally: + with lock: + active_pages -= 1 def fake_composer(deck_manifest_path, artifact_root, output_path): deck = read_deck_manifest(deck_manifest_path) @@ -7513,9 +7717,7 @@ def fake_preview_validator(**kwargs): "provider_outputs/image_generation/0000-slide-b/assets.json", ) image_generation_payload = ( - artifact_root - / "job-1" - / page_b.provider_output_paths["image_generation"] + artifact_root / "job-1" / page_b.provider_output_paths["image_generation"] ).read_text(encoding="utf-8") self.assertIn("slide-b-asset", image_generation_payload) self.assertNotIn("should-not-override", image_generation_payload) @@ -7686,9 +7888,7 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): dependencies=GenerativeEditablePipelineDependencies( ocr_provider=FakeOCRProvider(config.ocr), image_edit_provider=FakeImageEditProvider(config.clean_base_model), - image_generation_provider=FakeImageGenerationProvider( - config.generation_model - ), + image_generation_provider=FakeImageGenerationProvider(config.generation_model), composer=fake_composer, structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] diff --git a/tests/test_generative_editable_preview_validator.py b/tests/test_generative_editable_preview_validator.py index 8a6375d..30e25ae 100644 --- a/tests/test_generative_editable_preview_validator.py +++ b/tests/test_generative_editable_preview_validator.py @@ -56,7 +56,7 @@ def test_renders_deterministic_manifest_preview_stub_from_fake_assets(self): line_start=(280, 82), line_end=(380, 82), stroke_width=4, - ) + ), ], bitmap_assets=[ BitmapAssetSpec( @@ -77,7 +77,9 @@ def test_renders_deterministic_manifest_preview_stub_from_fake_assets(self): preview_one = render_manifest_preview(page, root, output_size=(800, 450)) preview_two = render_manifest_preview(page, root, output_size=(800, 450)) - preview_result = render_manifest_preview_with_metadata(page, root, output_size=(800, 450)) + preview_result = render_manifest_preview_with_metadata( + page, root, output_size=(800, 450) + ) self.assertEqual(preview_one.size, (800, 450)) self.assertEqual(preview_one.tobytes(), preview_two.tobytes()) @@ -138,7 +140,8 @@ def test_structural_validation_reports_wrong_slide_background_identity(self): self.assertEqual(report.status, "failed") background_issues = [ - issue for issue in report.issues + issue + for issue in report.issues if issue.code == "object_identity_mismatch" and issue.details["target_kind"] == "background" ] @@ -250,7 +253,9 @@ def test_structural_validation_reports_missing_assets_and_required_text(self): failed_payload = report.to_dict() self.assertEqual(failed_payload["status"], "failed") self.assertEqual(failed_payload["checked_pages"], 1) - text_issue = next(issue for issue in failed_payload["issues"] if issue["code"] == "missing_required_text") + text_issue = next( + issue for issue in failed_payload["issues"] if issue["code"] == "missing_required_text" + ) self.assertEqual(text_issue["severity"], "error") self.assertEqual(text_issue["slide_id"], "slide-a") self.assertEqual(text_issue["details"]["target_kind"], "text") @@ -287,9 +292,7 @@ def test_structural_validation_rejects_source_crop_bitmap_asset_provenance(self) self.assertEqual(report.status, "failed") issue = next( - issue - for issue in report.issues - if issue.code == "forbidden_source_crop_bitmap_asset" + issue for issue in report.issues if issue.code == "forbidden_source_crop_bitmap_asset" ) self.assertEqual(issue.details["target_kind"], "bitmap_asset") self.assertEqual(issue.details["target_id"], "asset") @@ -387,7 +390,9 @@ def test_structural_validation_allows_clean_fallback_icon_source_preserved_crop_ [issue.code for issue in report.issues], ) - def test_structural_validation_rejects_oversized_clean_fallback_icon_source_preserved_crop_assets(self): + def test_structural_validation_rejects_oversized_clean_fallback_icon_source_preserved_crop_assets( + self, + ): from src.generative_editable_composer import compose_deck_from_manifests from src.generative_editable_preview_validator import validate_composed_deck_structure @@ -538,7 +543,9 @@ def test_structural_validation_rejects_source_background_without_editable_struct ) self.assertEqual(report.status, "failed") - issue = next(issue for issue in report.issues if issue.code == "full_slide_source_background_only") + issue = next( + issue for issue in report.issues if issue.code == "full_slide_source_background_only" + ) self.assertEqual(issue.details["target_kind"], "background") self.assertEqual(issue.details["editable_object_count"], 0) @@ -631,7 +638,9 @@ def test_structural_validation_detects_actual_full_slide_source_picture_with_tex prs.slide_width = Inches(10) prs.slide_height = Inches(5.625) slide = prs.slides.add_slide(prs.slide_layouts[6]) - slide.shapes.add_picture(str(source), 0, 0, width=prs.slide_width, height=prs.slide_height) + slide.shapes.add_picture( + str(source), 0, 0, width=prs.slide_width, height=prs.slide_height + ) text = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(0.5)) text.text_frame.text = "Preview" output = root / "unsafe.pptx" @@ -746,7 +755,9 @@ def test_structural_validation_allows_text_clean_local_background_with_editable_ pptx_path=output, ) - self.assertNotIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + self.assertNotIn( + "unsafe_full_slide_source_with_text", [issue.code for issue in report.issues] + ) def test_structural_validation_reports_object_order_failures(self): from src.generative_editable_preview_validator import validate_composed_deck_structure @@ -817,7 +828,9 @@ def test_structural_validation_reports_wrong_bitmap_asset_identity_at_expected_p _set_slide_picture_background(slide, root / "backgrounds" / "0000-slide-a" / "base.png") slide.shapes.add_shape(1, Inches(1), Inches(1), Inches(2), Inches(1)) slide.shapes.add_connector(1, Inches(3.5), Inches(1.0125), Inches(4.75), Inches(1.0125)) - slide.shapes.add_picture(str(wrong_asset), Inches(3.75), Inches(1.125), width=Inches(1), height=Inches(0.75)) + slide.shapes.add_picture( + str(wrong_asset), Inches(3.75), Inches(1.125), width=Inches(1), height=Inches(0.75) + ) text = slide.shapes.add_textbox(Inches(1.25), Inches(2.625), Inches(2), Inches(0.5)) text.text_frame.text = "Preview" output = root / "wrong-identity.pptx" @@ -911,9 +924,13 @@ def test_structural_validation_allows_sparse_text_clean_region(self): pptx_path=output, ) - self.assertNotIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + self.assertNotIn( + "unsafe_full_slide_source_with_text", [issue.code for issue in report.issues] + ) - def test_structural_validation_allows_source_preserving_background_when_text_region_changed(self): + def test_structural_validation_allows_source_preserving_background_when_text_region_changed( + self, + ): from src.generative_editable_composer import compose_deck_from_manifests from src.generative_editable_preview_validator import validate_composed_deck_structure @@ -966,7 +983,9 @@ def test_structural_validation_allows_source_preserving_background_when_text_reg pptx_path=output, ) - self.assertNotIn("unsafe_full_slide_source_with_text", [issue.code for issue in report.issues]) + self.assertNotIn( + "unsafe_full_slide_source_with_text", [issue.code for issue in report.issues] + ) def test_structural_validation_reports_wrong_native_shape_type_at_expected_position(self): from src.generative_editable_composer import _set_slide_picture_background @@ -1224,7 +1243,9 @@ def test_structural_validation_reports_missing_source_asset(self): ) self.assertIn("missing_asset", [issue.code for issue in report.issues]) - source_issue = next(issue for issue in report.issues if issue.details.get("target_kind") == "source") + source_issue = next( + issue for issue in report.issues if issue.details.get("target_kind") == "source" + ) self.assertFalse(source_issue.details["repairable"]) def test_structural_validation_reports_empty_source_asset_path(self): @@ -1234,7 +1255,9 @@ def test_structural_validation_reports_empty_source_asset_path(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) _, page = self._write_validation_fixture(root) - write_manifest(root / "pages" / "0000-slide-a.json", replace(page, source_image_path="")) + write_manifest( + root / "pages" / "0000-slide-a.json", replace(page, source_image_path="") + ) output = root / "out.pptx" compose_deck_from_manifests(root / "deck.json", root, output) @@ -1245,11 +1268,16 @@ def test_structural_validation_reports_empty_source_asset_path(self): ) self.assertIn("invalid_asset_path", [issue.code for issue in report.issues]) - source_issue = next(issue for issue in report.issues if issue.details.get("target_kind") == "source") + source_issue = next( + issue for issue in report.issues if issue.details.get("target_kind") == "source" + ) self.assertEqual(source_issue.details["asset_ref"], "") def test_preview_similarity_passes_for_deterministic_matching_fixture_images(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1273,7 +1301,10 @@ def test_preview_similarity_passes_for_deterministic_matching_fixture_images(sel self.assertEqual(report.issues, []) def test_preview_similarity_fails_when_fixture_images_exceed_threshold(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1299,7 +1330,10 @@ def test_preview_similarity_fails_when_fixture_images_exceed_threshold(self): self.assertGreater(report.issues[0].details["changed_pixel_ratio"], 0.01) def test_preview_similarity_allows_text_dense_editable_render_drift(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1319,12 +1353,21 @@ def test_preview_similarity_allows_text_dense_editable_render_drift(self): page.text_boxes[0], text=f"Line {index}", source_pixel_bbox=(0, index, 80, index + 1), - source_pixel_polygon=((0, index), (80, index), (80, index + 1), (0, index + 1)), + source_pixel_polygon=( + (0, index), + (80, index), + (80, index + 1), + (0, index + 1), + ), ) for index in range(24) ], bitmap_assets=[ - replace(page.bitmap_assets[0], asset_id=f"asset-{index}", source_pixel_bbox=(0, 0, 10, 10)) + replace( + page.bitmap_assets[0], + asset_id=f"asset-{index}", + source_pixel_bbox=(0, 0, 10, 10), + ) for index in range(6) ], ) @@ -1346,7 +1389,10 @@ def test_preview_similarity_allows_text_dense_editable_render_drift(self): self.assertEqual(report.issues, []) def test_preview_similarity_rejects_text_dense_render_drift_outside_text_regions(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1366,12 +1412,21 @@ def test_preview_similarity_rejects_text_dense_render_drift_outside_text_regions page.text_boxes[0], text=f"Line {index}", source_pixel_bbox=(0, index, 80, index + 1), - source_pixel_polygon=((0, index), (80, index), (80, index + 1), (0, index + 1)), + source_pixel_polygon=( + (0, index), + (80, index), + (80, index + 1), + (0, index + 1), + ), ) for index in range(24) ], bitmap_assets=[ - replace(page.bitmap_assets[0], asset_id=f"asset-{index}", source_pixel_bbox=(0, 0, 10, 10)) + replace( + page.bitmap_assets[0], + asset_id=f"asset-{index}", + source_pixel_bbox=(0, 0, 10, 10), + ) for index in range(6) ], ) @@ -1393,7 +1448,10 @@ def test_preview_similarity_rejects_text_dense_render_drift_outside_text_regions self.assertEqual(report.issues[0].code, "preview_similarity_failed") def test_preview_similarity_rejects_text_dense_mixed_drift_with_too_much_non_text_change(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1420,12 +1478,21 @@ def test_preview_similarity_rejects_text_dense_mixed_drift_with_too_much_non_tex page.text_boxes[0], text=f"Line {index}", source_pixel_bbox=(0, index, 80, index + 1), - source_pixel_polygon=((0, index), (80, index), (80, index + 1), (0, index + 1)), + source_pixel_polygon=( + (0, index), + (80, index), + (80, index + 1), + (0, index + 1), + ), ) for index in range(24) ], bitmap_assets=[ - replace(page.bitmap_assets[0], asset_id=f"asset-{index}", source_pixel_bbox=(0, 0, 10, 10)) + replace( + page.bitmap_assets[0], + asset_id=f"asset-{index}", + source_pixel_bbox=(0, 0, 10, 10), + ) for index in range(6) ], ) @@ -1448,7 +1515,10 @@ def test_preview_similarity_rejects_text_dense_mixed_drift_with_too_much_non_tex self.assertGreater(report.issues[0].details["outside_text_changed_pixel_ratio"], 0.05) def test_preview_similarity_still_rejects_sparse_render_drift_over_changed_area_threshold(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1568,7 +1638,10 @@ def test_powerpoint_preview_renderer_falls_back_when_renderer_times_out(self): self.assertIn("timeout", run.call_args.kwargs) def test_preview_similarity_rejects_manifest_stub_even_if_metadata_claims_powerpoint(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1591,7 +1664,10 @@ def test_preview_similarity_rejects_manifest_stub_even_if_metadata_claims_powerp self.assertEqual(report.issues[0].code, "preview_renderer_not_powerpoint") def test_preview_similarity_reports_wrong_aspect_preview_without_resizing(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1614,7 +1690,10 @@ def test_preview_similarity_reports_wrong_aspect_preview_without_resizing(self): self.assertEqual(report.issues[0].code, "preview_dimensions_mismatch") def test_preview_similarity_returns_report_for_invalid_preview_image(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) class BrokenPreview: size = (120, 80) @@ -1643,7 +1722,10 @@ def convert(self, mode): self.assertEqual(report.issues[0].code, "invalid_preview_image") def test_preview_similarity_caps_source_and_preview_pixel_counts_before_comparison(self): - from src.generative_editable_preview_validator import PreviewRenderResult, validate_preview_similarity + from src.generative_editable_preview_validator import ( + PreviewRenderResult, + validate_preview_similarity, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1728,7 +1810,7 @@ def _write_validation_fixture(self, root: Path): line_start=(280, 81), line_end=(380, 81), stroke_width=3, - ) + ), ], bitmap_assets=[ BitmapAssetSpec( diff --git a/tests/test_generative_editable_providers.py b/tests/test_generative_editable_providers.py index 2bbf98f..2fcd9c7 100644 --- a/tests/test_generative_editable_providers.py +++ b/tests/test_generative_editable_providers.py @@ -137,9 +137,15 @@ def test_openai_chat_ocr_uses_crop_prompt_for_focused_recovery_images(self): def fake_post(config, payload, operation, timeout_seconds): payloads.append(payload) - return {"choices": [{"message": {"content": "全周期安全管理与OTA进化能力,让车辆越用越新。"}}]} - - with patch("src.generative_editable_providers._post_openai_chat", side_effect=fake_post): + return { + "choices": [ + {"message": {"content": "全周期安全管理与OTA进化能力,让车辆越用越新。"}} + ] + } + + with patch( + "src.generative_editable_providers._post_openai_chat", side_effect=fake_post + ): result = provider.extract_text(str(image_path)) prompt = payloads[0]["messages"][0]["content"][0]["text"] @@ -192,7 +198,7 @@ def test_ocr_parser_rejects_json_scalar_refusal(self): with self.assertRaisesRegex(ProviderError, "object or array"): _extract_ocr_text_items( - {"choices": [{"message": {"content": "\"I cannot extract text from this image.\""}}]}, + {"choices": [{"message": {"content": '"I cannot extract text from this image."'}}]}, config, (800, 450), ) @@ -633,7 +639,9 @@ def fake_post(config, payload, operation, timeout_seconds): image_base64 = base64.b64encode(output.read_bytes()).decode() return {"data": [{"url": f"data:image/png;base64,{image_base64}"}]} - with patch("src.generative_editable_providers._post_openai_chat", side_effect=fake_post): + with patch( + "src.generative_editable_providers._post_openai_chat", side_effect=fake_post + ): provider.generate( ImageGenerationRequest( prompt_id="asset_sheet", @@ -676,7 +684,9 @@ def json(self): ) payload = {"model": "gpt-5.5", "messages": [{"role": "user", "content": "OK"}]} - with patch("src.generative_editable_providers.requests.post", return_value=FakeResponse()) as post: + with patch( + "src.generative_editable_providers.requests.post", return_value=FakeResponse() + ) as post: result = _post_openai_chat( config, payload, @@ -735,7 +745,9 @@ def test_provider_error_code_is_constrained_before_reporting(self): provider_error_code="insufficient_user_quota https://secret.example/path api_key=secret", ) - self.assertEqual(error.provider_error_code, "insufficient_user_quota_URL_REDACTED_api_key_REDACTED") + self.assertEqual( + error.provider_error_code, "insufficient_user_quota_URL_REDACTED_api_key_REDACTED" + ) self.assertNotIn("://", error.provider_error_code) self.assertNotIn("=", error.provider_error_code) self.assertNotIn("secret", error.provider_error_code) diff --git a/tests/test_generative_editable_shape_fitter.py b/tests/test_generative_editable_shape_fitter.py index b60e58e..278f05e 100644 --- a/tests/test_generative_editable_shape_fitter.py +++ b/tests/test_generative_editable_shape_fitter.py @@ -4,6 +4,7 @@ from PIL import Image, ImageDraw + class GenerativeEditableShapeFitterTest(unittest.TestCase): def test_fits_high_confidence_rectangle_rounded_rectangle_ellipse_and_line(self): from src.generative_editable_foreground_planner import ForegroundCandidate diff --git a/tests/test_generative_editable_smoke_script.py b/tests/test_generative_editable_smoke_script.py index d648d8c..0ad6372 100644 --- a/tests/test_generative_editable_smoke_script.py +++ b/tests/test_generative_editable_smoke_script.py @@ -152,7 +152,9 @@ def edit(self, request): [item["role"] for item in lines[1]["checked_providers"]], ["clean_base_model", "asset_sheet_model", "repair_model"], ) - self.assertEqual(checked_roles, ["clean_base_model", "asset_sheet_model", "repair_model"]) + self.assertEqual( + checked_roles, ["clean_base_model", "asset_sheet_model", "repair_model"] + ) for item in lines[1]["checked_providers"]: self.assertTrue(Path(item["output_path"]).exists()) diff --git a/tests/test_generative_editable_text.py b/tests/test_generative_editable_text.py index 63860f3..ec08d23 100644 --- a/tests/test_generative_editable_text.py +++ b/tests/test_generative_editable_text.py @@ -114,7 +114,9 @@ def test_metadata_order_uses_one_based_ocr_index_and_falls_back_for_unmatched_oc self.assertEqual([box.text for box in boxes], ["Metadata For Second", "OCR One"]) self.assertEqual(boxes[0].source_pixel_bbox, (10, 60, 130, 90)) - self.assertEqual([box.text for box in result.text_boxes], ["Metadata For Second", "OCR One"]) + self.assertEqual( + [box.text for box in result.text_boxes], ["Metadata For Second", "OCR One"] + ) self.assertNotIn("secret", repr(result.text_boxes[0].provenance)) self.assertNotIn("style-secret", repr(result.text_boxes[0].style_hints)) @@ -310,7 +312,7 @@ def test_text_validation_accepts_low_confidence_when_metadata_text_matches_ocr(s bbox=(146, 294, 156, 302), polygon=((146, 294), (156, 294), (156, 302), (146, 302)), confidence=0.62, - ) + ), ], ) metadata = [{"text": "Quarterly Plan", "role": "title", "order": 1}] @@ -608,7 +610,10 @@ def test_text_validation_dedupes_near_overlapping_approximate_text(self): result = extract_text_with_validation([], ocr, min_confidence=0.75) self.assertEqual([box.text for box in result.text_boxes], ["优先平板拖运"]) - self.assertEqual([issue["code"] for issue in result.issues], ["ignored_spurious_ocr", "ignored_spurious_ocr"]) + self.assertEqual( + [issue["code"] for issue in result.issues], + ["ignored_spurious_ocr", "ignored_spurious_ocr"], + ) self.assertEqual(result.validation_status, "passed") def test_text_validation_ignores_long_ocr_text_inside_tiny_bbox(self): diff --git a/tests/test_generative_editable_text_candidates.py b/tests/test_generative_editable_text_candidates.py index 5d02460..8cca3ca 100644 --- a/tests/test_generative_editable_text_candidates.py +++ b/tests/test_generative_editable_text_candidates.py @@ -27,10 +27,7 @@ def test_detects_large_cover_title_lines_on_dark_slide(self): self.assertTrue( any( - bbox[0] <= 70 - and 150 <= bbox[1] <= 190 - and bbox[2] >= 620 - and 230 <= bbox[3] <= 280 + bbox[0] <= 70 and 150 <= bbox[1] <= 190 and bbox[2] >= 620 and 230 <= bbox[3] <= 280 for bbox in candidates ), candidates, @@ -73,8 +70,12 @@ def test_detects_card_heading_labels_on_dashboard_slide(self): image = Image.new("RGB", (1000, 560), "#030914") draw = ImageDraw.Draw(image) font = ImageFont.truetype(str(font_path), 26) - draw.rounded_rectangle((28, 70, 390, 420), radius=16, fill="#0B2340", outline="#1D4ED8", width=2) - draw.rounded_rectangle((410, 70, 960, 420), radius=16, fill="#0B2340", outline="#1D4ED8", width=2) + draw.rounded_rectangle( + (28, 70, 390, 420), radius=16, fill="#0B2340", outline="#1D4ED8", width=2 + ) + draw.rounded_rectangle( + (410, 70, 960, 420), radius=16, fill="#0B2340", outline="#1D4ED8", width=2 + ) draw.ellipse((48, 88, 76, 116), fill="#60A5FA") draw.ellipse((430, 88, 458, 116), fill="#60A5FA") draw.text((86, 88), "日常操作", font=font, fill="#F8FAFC") @@ -84,11 +85,17 @@ def test_detects_card_heading_labels_on_dashboard_slide(self): candidates = detect_text_candidate_bboxes(str(image_path)) self.assertTrue( - any(75 <= bbox[0] <= 95 and 80 <= bbox[1] <= 100 and 170 <= bbox[2] <= 230 for bbox in candidates), + any( + 75 <= bbox[0] <= 95 and 80 <= bbox[1] <= 100 and 170 <= bbox[2] <= 230 + for bbox in candidates + ), candidates, ) self.assertTrue( - any(415 <= bbox[0] <= 475 and 75 <= bbox[1] <= 105 and 580 <= bbox[2] <= 650 for bbox in candidates), + any( + 415 <= bbox[0] <= 475 and 75 <= bbox[1] <= 105 and 580 <= bbox[2] <= 650 + for bbox in candidates + ), candidates, ) @@ -100,7 +107,10 @@ def test_detects_replay_dashboard_card_headings(self): candidates = detect_text_candidate_bboxes(str(image_path)) self.assertTrue( - any(140 <= bbox[0] <= 170 and 120 <= bbox[1] <= 145 and 320 <= bbox[2] <= 360 for bbox in candidates), + any( + 140 <= bbox[0] <= 170 and 120 <= bbox[1] <= 145 and 320 <= bbox[2] <= 360 + for bbox in candidates + ), candidates, ) @@ -112,18 +122,29 @@ def test_splits_replay_dashboard_wide_row_bands_into_flowchart_text_groups(self) candidates = detect_text_candidate_bboxes(str(image_path)) self.assertTrue( - any(1160 <= bbox[0] <= 1210 and 370 <= bbox[1] <= 395 and 1520 <= bbox[2] <= 1560 for bbox in candidates), + any( + 1160 <= bbox[0] <= 1210 and 370 <= bbox[1] <= 395 and 1520 <= bbox[2] <= 1560 + for bbox in candidates + ), candidates, ) self.assertTrue( - any(1020 <= bbox[0] <= 1060 and 800 <= bbox[1] <= 825 and 1200 <= bbox[2] <= 1230 for bbox in candidates), + any( + 1020 <= bbox[0] <= 1060 and 800 <= bbox[1] <= 825 and 1200 <= bbox[2] <= 1230 + for bbox in candidates + ), candidates, ) self.assertFalse( - any(bbox[0] <= 120 and bbox[2] >= 1000 and 120 <= bbox[1] <= 220 for bbox in candidates), + any( + bbox[0] <= 120 and bbox[2] >= 1000 and 120 <= bbox[1] <= 220 for bbox in candidates + ), candidates, ) self.assertTrue( - any(780 <= bbox[0] <= 820 and 120 <= bbox[1] <= 145 and 1010 <= bbox[2] <= 1060 for bbox in candidates), + any( + 780 <= bbox[0] <= 820 and 120 <= bbox[1] <= 145 and 1010 <= bbox[2] <= 1060 + for bbox in candidates + ), candidates, ) diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index 14b0e5c..eb6e44e 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -17,7 +17,9 @@ class GenerativeEditableVLMReconstructionTest(unittest.TestCase): def test_vlm_validation_fails_large_source_preserved_bitmap_coverage_without_runner(self): from src.generative_editable_manifest import BitmapAssetSpec, PageManifest - from src.generative_editable_vlm_reconstruction import _validate_vlm_source_preserved_bitmap_coverage + from src.generative_editable_vlm_reconstruction import ( + _validate_vlm_source_preserved_bitmap_coverage, + ) page = PageManifest( slide_id="slide-a", @@ -42,8 +44,15 @@ def test_vlm_validation_fails_large_source_preserved_bitmap_coverage_without_run self.assertEqual(report.issues[0].code, "oversized_bitmap_asset_coverage") def test_vlm_validation_allows_split_row_source_preserved_bitmap_structure(self): - from src.generative_editable_manifest import BitmapAssetSpec, NativeShapeSpec, PageManifest, TextBoxSpec - from src.generative_editable_vlm_reconstruction import _validate_vlm_source_preserved_bitmap_coverage + from src.generative_editable_manifest import ( + BitmapAssetSpec, + NativeShapeSpec, + PageManifest, + TextBoxSpec, + ) + from src.generative_editable_vlm_reconstruction import ( + _validate_vlm_source_preserved_bitmap_coverage, + ) page = PageManifest( slide_id="slide-a", @@ -78,21 +87,30 @@ def test_vlm_validation_allows_split_row_source_preserved_bitmap_structure(self) source_pixel_bbox=(0, 0, 1000, 150), asset_path="assets/row-a.png", z_order=1, - provenance={"asset_strategy": "source_preserved_crop", "alpha_visible_area_ratio": 0.12}, + provenance={ + "asset_strategy": "source_preserved_crop", + "alpha_visible_area_ratio": 0.12, + }, ), BitmapAssetSpec( asset_id="row-b", source_pixel_bbox=(0, 170, 1000, 320), asset_path="assets/row-b.png", z_order=2, - provenance={"asset_strategy": "source_preserved_crop", "alpha_visible_area_ratio": 0.12}, + provenance={ + "asset_strategy": "source_preserved_crop", + "alpha_visible_area_ratio": 0.12, + }, ), BitmapAssetSpec( asset_id="row-c", source_pixel_bbox=(0, 340, 1000, 490), asset_path="assets/row-c.png", z_order=3, - provenance={"asset_strategy": "source_preserved_crop", "alpha_visible_area_ratio": 0.12}, + provenance={ + "asset_strategy": "source_preserved_crop", + "alpha_visible_area_ratio": 0.12, + }, ), ], ) @@ -177,7 +195,9 @@ def preview_validator(**kwargs): ) self.assertEqual(result.validation_report.status, "passed") - self.assertEqual([call[0] for call in calls], ["structure", "preview_renderer", "preview_validator"]) + self.assertEqual( + [call[0] for call in calls], ["structure", "preview_renderer", "preview_validator"] + ) self.assertEqual(calls[2][2], str(output)) self.assertEqual(calls[2][3], 0.87) self.assertIs(calls[2][4], True) @@ -1115,7 +1135,9 @@ def test_foreground_crop_can_mask_overlapping_text_regions(self): def test_refines_vlm_bitmap_candidate_bbox_with_clean_background_difference(self): from src.generative_editable_foreground_planner import ForegroundCandidate - from src.generative_editable_vlm_reconstruction import _refine_candidates_with_clean_background_difference + from src.generative_editable_vlm_reconstruction import ( + _refine_candidates_with_clean_background_difference, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1312,7 +1334,9 @@ def test_vlm_text_region_can_keep_multiple_ocr_lines_without_stray_text(self): ) self.assertEqual([box.text for box in page.text_boxes], ["第一行", "第二行"]) - self.assertEqual([box.provenance["layout_source"] for box in page.text_boxes], ["ocr", "ocr"]) + self.assertEqual( + [box.provenance["layout_source"] for box in page.text_boxes], ["ocr", "ocr"] + ) def test_vlm_text_resolution_drops_overlapping_duplicate_text(self): from src.generative_editable_manifest import TextBoxSpec @@ -1397,7 +1421,11 @@ def test_vlm_text_resolution_drops_nearby_ocr_vlm_duplicate_text(self): source_pixel_bbox=(85, 70, 298, 94), source_pixel_polygon=((85, 70), (298, 70), (298, 94), (85, 94)), font_size=18, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -1411,7 +1439,9 @@ def test_vlm_text_resolution_drops_nearby_ocr_vlm_duplicate_text(self): text_boxes=[ocr_title], ) - self.assertEqual([box.text for box in page.text_boxes], ["理想L9:", "旗舰增程SUV的技术实验"]) + self.assertEqual( + [box.text for box in page.text_boxes], ["理想L9:", "旗舰增程SUV的技术实验"] + ) self.assertEqual(page.text_boxes[0].provenance["content_source"], "ocr") def test_vlm_text_resolution_drops_nearby_duplicate_after_ocr_match(self): @@ -1434,7 +1464,11 @@ def test_vlm_text_resolution_drops_nearby_duplicate_after_ocr_match(self): "coordinate_space": {"width": 1280, "height": 720, "unit": "px"}, "text_regions": [ {"id": "title-ocr-layout", "text": "理想L9:", "bbox": [85, 70, 296, 94]}, - {"id": "title-vlm-duplicate", "text": "理想L9:", "bbox": [67, 168, 433, 250]}, + { + "id": "title-vlm-duplicate", + "text": "理想L9:", + "bbox": [67, 168, 433, 250], + }, { "id": "subtitle", "text": "旗舰增程SUV的技术实验", @@ -1450,7 +1484,11 @@ def test_vlm_text_resolution_drops_nearby_duplicate_after_ocr_match(self): source_pixel_bbox=(85, 70, 296, 94), source_pixel_polygon=((85, 70), (296, 70), (296, 94), (85, 94)), font_size=18, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -1464,7 +1502,9 @@ def test_vlm_text_resolution_drops_nearby_duplicate_after_ocr_match(self): text_boxes=[ocr_title], ) - self.assertEqual([box.text for box in page.text_boxes], ["理想L9:", "旗舰增程SUV的技术实验"]) + self.assertEqual( + [box.text for box in page.text_boxes], ["理想L9:", "旗舰增程SUV的技术实验"] + ) self.assertEqual(page.text_boxes[0].provenance["content_source"], "ocr") def test_vlm_text_resolution_keeps_real_adjacent_duplicate_text(self): @@ -1836,7 +1876,11 @@ def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_mismatched_ocr( source_pixel_bbox=(1280, 797, 1593, 867), source_pixel_polygon=((1280, 797), (1593, 797), (1593, 867), (1280, 867)), font_size=18, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -1889,7 +1933,11 @@ def test_vlm_text_resolution_keeps_medium_confidence_vlm_text_over_short_ocr_fra source_pixel_bbox=(50, 30, 70, 38), source_pixel_polygon=((50, 30), (70, 30), (70, 38), (50, 38)), font_size=8, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -1942,7 +1990,11 @@ def test_vlm_text_resolution_keeps_medium_confidence_vlm_text_over_substring_ocr source_pixel_bbox=(58, 42, 170, 68), source_pixel_polygon=((58, 42), (170, 42), (170, 68), (58, 68)), font_size=12, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -1995,7 +2047,11 @@ def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_partial_ocr_sub source_pixel_bbox=(100, 380, 380, 410), source_pixel_polygon=((100, 380), (380, 380), (380, 410), (100, 410)), font_size=18, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -2012,7 +2068,9 @@ def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_partial_ocr_sub self.assertEqual([box.text for box in page.text_boxes], ["连接充电枪不可启动车辆"]) self.assertEqual(page.text_boxes[0].provenance["provider_role"], "VLM") - def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_multiple_bad_ocr_fragments(self): + def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_multiple_bad_ocr_fragments( + self, + ): from src.generative_editable_manifest import TextBoxSpec from src.generative_editable_vlm_reconstruction import ( build_page_manifest_from_vlm_analysis, @@ -2049,14 +2107,22 @@ def test_vlm_text_resolution_keeps_high_confidence_vlm_text_over_multiple_bad_oc source_pixel_bbox=(100, 380, 300, 397), source_pixel_polygon=((100, 380), (300, 380), (300, 397), (100, 397)), font_size=12, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ), TextBoxSpec( text="车", source_pixel_bbox=(306, 380, 420, 397), source_pixel_polygon=((306, 380), (420, 380), (420, 397), (306, 397)), font_size=12, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.76}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.76, + }, ), ] @@ -2276,7 +2342,11 @@ def test_vlm_text_resolution_uses_wider_ocr_layout_for_exact_long_title(self): source_pixel_bbox=(296, 22, 1283, 76), source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), font_size=18, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -2329,7 +2399,11 @@ def test_vlm_text_resolution_uses_left_shifted_ocr_layout_for_exact_long_title(s source_pixel_bbox=(296, 22, 1283, 76), source_pixel_polygon=((296, 22), (1283, 22), (1283, 76), (296, 76)), font_size=18, - provenance={"content_source": "ocr", "layout_source": "ocr", "ocr_confidence": 0.78}, + provenance={ + "content_source": "ocr", + "layout_source": "ocr", + "ocr_confidence": 0.78, + }, ) page = build_page_manifest_from_vlm_analysis( @@ -2445,7 +2519,9 @@ def test_without_vlm_text_regions_uses_high_confidence_ocr_boxes(self): text_boxes=ocr_boxes, ) - self.assertEqual([box.text for box in page.text_boxes], [f"OCR {index}" for index in range(5)]) + self.assertEqual( + [box.text for box in page.text_boxes], [f"OCR {index}" for index in range(5)] + ) self.assertTrue( all(box.provenance.get("layout_source") == "ocr" for box in page.text_boxes) ) @@ -2477,7 +2553,12 @@ def test_without_vlm_text_regions_rejects_dense_short_ocr_noise(self): ocr_boxes = [ TextBoxSpec( text="x", - source_pixel_bbox=(5 + (index % 20) * 11, 5 + (index // 20) * 10, 12 + (index % 20) * 11, 12 + (index // 20) * 10), + source_pixel_bbox=( + 5 + (index % 20) * 11, + 5 + (index // 20) * 10, + 12 + (index % 20) * 11, + 12 + (index // 20) * 10, + ), source_pixel_polygon=( (5 + (index % 20) * 11, 5 + (index // 20) * 10), (12 + (index % 20) * 11, 5 + (index // 20) * 10), @@ -2851,7 +2932,14 @@ def edit(self, request): page = read_page_manifest(root / "artifacts" / "job-001" / deck.page_manifest_paths[0]) prs = Presentation(str(output)) shape_types = [shape.shape_type for shape in prs.slides[0].shapes] - mask = Image.open(root / "artifacts" / "job-001" / "assets" / "0000-slide-a" / "vlm-text-bitmap-mask.png") + mask = Image.open( + root + / "artifacts" + / "job-001" + / "assets" + / "0000-slide-a" + / "vlm-text-bitmap-mask.png" + ) self.assertEqual(result.status, "passed") self.assertEqual(len(vlm_provider.calls), 1) @@ -2859,7 +2947,9 @@ def edit(self, request): self.assertIn("no readable residual glyphs", image_edit_provider.calls[0].prompt) self.assertIn("Preserve every unmasked pixel", image_edit_provider.calls[0].prompt) self.assertIn("asset_sheet", [request.prompt_id for request in image_edit_provider.calls]) - asset_sheet_request = next(request for request in image_edit_provider.calls if request.prompt_id == "asset_sheet") + asset_sheet_request = next( + request for request in image_edit_provider.calls if request.prompt_id == "asset_sheet" + ) self.assertEqual(asset_sheet_request.timeout_seconds, 120) self.assertEqual(mask.getpixel((75, 40)), 255) self.assertIn("vlm", page.provider_output_paths) @@ -2868,7 +2958,9 @@ def edit(self, request): self.assertEqual(page.asset_sheets[0].candidate_ids, ["logo"]) self.assertEqual(page.bitmap_assets[0].provenance["split_method"], "connected_components") self.assertEqual(page.provenance["reconstruction_strategy"], "vlm_first") - self.assertEqual(page.provenance["text_mask_path"], "assets/0000-slide-a/vlm-text-bitmap-mask.png") + self.assertEqual( + page.provenance["text_mask_path"], "assets/0000-slide-a/vlm-text-bitmap-mask.png" + ) self.assertEqual([box.text for box in page.text_boxes], ["标题"]) self.assertGreaterEqual(shape_types.count(MSO_SHAPE_TYPE.TEXT_BOX), 1) self.assertGreaterEqual(shape_types.count(MSO_SHAPE_TYPE.PICTURE), 1) @@ -2956,7 +3048,9 @@ def edit(self, request): self.assertEqual(page.asset_sheets, []) self.assertEqual(len(page.bitmap_assets), 1) - self.assertEqual(page.bitmap_assets[0].provenance["asset_strategy"], "source_preserved_crop") + self.assertEqual( + page.bitmap_assets[0].provenance["asset_strategy"], "source_preserved_crop" + ) self.assertTrue(page.bitmap_assets[0].provenance["asset_sheet_provider_failed"]) self.assertIn(MSO_SHAPE_TYPE.PICTURE, shape_types) @@ -3058,7 +3152,10 @@ def edit(self, request): self.assertEqual(result.status, "passed") self.assertEqual(result.fallback_used, "clean_background_local") - self.assertEqual(page.provider_output_paths["image_edit"], "provider_outputs/image_edit/0000-slide-a/backgrounds.json") + self.assertEqual( + page.provider_output_paths["image_edit"], + "provider_outputs/image_edit/0000-slide-a/backgrounds.json", + ) self.assertTrue(page.provenance["clean_background_provider_failed"]) self.assertEqual(page.provenance["clean_background_strategy"], "local_inpaint") self.assertNotEqual(cleaned_icon_pixel, (255, 51, 68)) @@ -3266,8 +3363,12 @@ def edit(self, request): self.assertEqual(asset_sheet_provider.calls, []) self.assertEqual(page.asset_sheets, []) self.assertEqual(len(page.bitmap_assets), 1) - self.assertEqual(page.bitmap_assets[0].provenance["asset_strategy"], "source_preserved_crop") - self.assertEqual(page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region") + self.assertEqual( + page.bitmap_assets[0].provenance["asset_strategy"], "source_preserved_crop" + ) + self.assertEqual( + page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region" + ) self.assertEqual(page.bitmap_assets[0].provenance["alpha_strategy"], "opaque_source_crop") self.assertNotIn("background_difference_alpha", page.bitmap_assets[0].provenance) self.assertIn("alpha_visible_area_ratio", page.bitmap_assets[0].provenance) @@ -3348,7 +3449,9 @@ def edit(self, request): page = read_page_manifest(job_dir / deck.page_manifest_paths[0]) self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["product", "logo"]) - self.assertEqual(page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region") + self.assertEqual( + page.bitmap_assets[0].provenance["asset_sheet_skipped_reason"], "complex_bitmap_region" + ) self.assertTrue(page.bitmap_assets[1].provenance["asset_sheet_provider_failed"]) def test_vlm_pipeline_falls_back_when_asset_sheet_component_count_is_short(self): @@ -3442,7 +3545,9 @@ def edit(self, request): self.assertEqual(page.asset_sheets, []) self.assertEqual([asset.asset_id for asset in page.bitmap_assets], ["logo-a", "logo-b"]) - self.assertTrue(all(asset.provenance["asset_sheet_slicing_failed"] for asset in page.bitmap_assets)) + self.assertTrue( + all(asset.provenance["asset_sheet_slicing_failed"] for asset in page.bitmap_assets) + ) def test_vlm_pipeline_skips_asset_sheet_for_icon_bitmap_regions(self): from src.generative_editable_manifest import read_deck_manifest, read_page_manifest diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index 85468a0..2a156c7 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -105,7 +105,6 @@ def test_run_mode_disables_source_crop_asset_fallback_by_default(self): from api.routes import export as export_route captured = {} - dependency_sentinel = object() def fake_pipeline(**kwargs): from pptx import Presentation @@ -290,7 +289,9 @@ def fake_vlm_pipeline(**kwargs): stdout = io.StringIO() with ( patch.object(runner, "_vlm_dependencies", return_value=object()), - patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object( + runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline + ), patch.object(runner, "_write_preview_reports", return_value=[]), redirect_stdout(stdout), ): @@ -315,7 +316,9 @@ def fake_vlm_pipeline(**kwargs): self.assertEqual(exit_code, 0) self.assertEqual(line["status"], "fallback_used") - self.assertEqual(Path(line["output_path"]).name, "vlm-provider-fallback.raster-fallback.pptx") + self.assertEqual( + Path(line["output_path"]).name, "vlm-provider-fallback.raster-fallback.pptx" + ) self.assertTrue(output_exists) self.assertEqual(report["fallback_used"], "raster_pptx") self.assertEqual(report["object_stats"]["slides"][0]["full_slide_picture_count"], 1) @@ -371,7 +374,9 @@ def fake_vlm_pipeline(**kwargs): stdout = io.StringIO() with ( patch.object(runner, "_vlm_dependencies", return_value=dependency_sentinel), - patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object( + runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline + ), patch.object( runner, "run_generative_editable_pipeline", @@ -452,7 +457,9 @@ def fake_vlm_pipeline(**kwargs): stdout = io.StringIO() with ( patch.object(runner, "_vlm_dependencies", side_effect=fake_vlm_dependencies), - patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object( + runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline + ), patch.object(runner, "_write_preview_reports", return_value=[]), redirect_stdout(stdout), ): @@ -539,7 +546,9 @@ def fake_vlm_pipeline(**kwargs): with ( patch.object(runner, "load_generative_editable_config", return_value=config), patch.object(runner, "_vlm_dependencies", side_effect=fake_vlm_dependencies), - patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object( + runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline + ), patch.object(runner, "_write_preview_reports", return_value=[]), redirect_stdout(stdout), ): @@ -603,11 +612,15 @@ def fake_vlm_pipeline(**kwargs): stdout = io.StringIO() with ( patch.object(runner, "_vlm_dependencies", return_value=object()), - patch.object(runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline), + patch.object( + runner, "run_vlm_editable_pptx_pipeline", side_effect=fake_vlm_pipeline + ), patch.object( runner, "_write_preview_reports", - return_value=[{"status": "failed", "issues": [{"code": "preview_similarity_failed"}]}], + return_value=[ + {"status": "failed", "issues": [{"code": "preview_similarity_failed"}]} + ], ), redirect_stdout(stdout), ): @@ -635,7 +648,12 @@ def test_run_mode_fails_when_output_is_only_full_slide_picture(self): from api.routes import export as export_route from pptx import Presentation - from src.generative_editable_manifest import DeckManifest, PageManifest, TextBoxSpec, write_manifest + from src.generative_editable_manifest import ( + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) def fake_pipeline(**kwargs): source_path = Path(kwargs["slides"][0].image_path) @@ -1105,7 +1123,10 @@ def fake_subprocess(command, *, timeout_seconds, report_path, timeout_payload): } stdout = io.StringIO() - with patch.object(runner, "_run_subprocess_json", side_effect=fake_subprocess), redirect_stdout(stdout): + with ( + patch.object(runner, "_run_subprocess_json", side_effect=fake_subprocess), + redirect_stdout(stdout), + ): exit_code = main( [ "gates", @@ -1667,8 +1688,16 @@ def test_reconstruction_issues_fail_for_source_preserving_fast_path_degradation( output_path = root / "job.pptx" prs = Presentation() slide = prs.slides.add_slide(prs.slide_layouts[6]) - slide.shapes.add_picture(str(_solid_png(root / "background.png")), 0, 0, width=prs.slide_width, height=prs.slide_height) - slide.shapes.add_picture(str(_solid_png(root / "anchor.png")), 100, 100, width=1000, height=600) + slide.shapes.add_picture( + str(_solid_png(root / "background.png")), + 0, + 0, + width=prs.slide_width, + height=prs.slide_height, + ) + slide.shapes.add_picture( + str(_solid_png(root / "anchor.png")), 100, 100, width=1000, height=600 + ) slide.shapes.add_textbox(100, 100, 1000, 300).text = "核心架构" prs.save(output_path) write_manifest( @@ -1729,7 +1758,12 @@ def test_pptx_object_stats_count_slide_background_picture(self): import scripts.run_real_generative_editable_pptx as runner from src.generative_editable_composer import compose_deck_from_manifests - from src.generative_editable_manifest import DeckManifest, PageManifest, TextBoxSpec, write_manifest + from src.generative_editable_manifest import ( + DeckManifest, + PageManifest, + TextBoxSpec, + write_manifest, + ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -1845,7 +1879,13 @@ def test_reconstruction_issues_fail_for_low_opacity_source_overlay(self): output_path = root / "job.pptx" prs = Presentation() slide = prs.slides.add_slide(prs.slide_layouts[6]) - slide.shapes.add_picture(str(_solid_png(root / "background.png")), 0, 0, width=prs.slide_width, height=prs.slide_height) + slide.shapes.add_picture( + str(_solid_png(root / "background.png")), + 0, + 0, + width=prs.slide_width, + height=prs.slide_height, + ) slide.shapes.add_textbox(100, 100, 1000, 300).text = "核心架构" prs.save(output_path) write_manifest( @@ -1896,7 +1936,9 @@ def test_reconstruction_issues_fail_for_low_opacity_source_overlay(self): object_stats=stats, ) - self.assertEqual([issue["code"] for issue in issues], ["source_preserving_low_opacity_overlay_degraded"]) + self.assertEqual( + [issue["code"] for issue in issues], ["source_preserving_low_opacity_overlay_degraded"] + ) self.assertEqual(issues[0]["severity"], "error") def test_reconstruction_issues_fail_for_oversized_bitmap_asset_coverage(self): @@ -2591,7 +2633,7 @@ def test_error_reconstruction_issues_mark_run_failed(self): "code": "source_preserving_low_opacity_overlay_degraded", "severity": "error", } - ] + ], ), "failed", ) @@ -2603,7 +2645,7 @@ def test_error_reconstruction_issues_mark_run_failed(self): "code": "no_decomposed_visual_elements", "severity": "error", } - ] + ], ), "failed", ) @@ -2667,7 +2709,13 @@ def test_reconstruction_issues_warn_when_ocr_items_are_all_filtered(self): output_path = root / "job.pptx" prs = Presentation() slide = prs.slides.add_slide(prs.slide_layouts[6]) - slide.shapes.add_picture(str(_solid_png(root / "background.png")), 0, 0, width=prs.slide_width, height=prs.slide_height) + slide.shapes.add_picture( + str(_solid_png(root / "background.png")), + 0, + 0, + width=prs.slide_width, + height=prs.slide_height, + ) prs.save(output_path) write_manifest( job_dir / "pages" / "0000-slide-1.json", @@ -2718,7 +2766,9 @@ def test_reconstruction_issues_warn_when_ocr_items_are_all_filtered(self): ) self.assertIn("no_editable_text_after_ocr_filtering", [issue["code"] for issue in issues]) - warning = next(issue for issue in issues if issue["code"] == "no_editable_text_after_ocr_filtering") + warning = next( + issue for issue in issues if issue["code"] == "no_editable_text_after_ocr_filtering" + ) self.assertEqual(warning["severity"], "warning") self.assertEqual(warning["details"]["filtered_ocr_issue_count"], 1) self.assertEqual(warning["details"]["ocr_returned_count"], 2) @@ -2735,7 +2785,10 @@ def test_warning_summary_counts_pages_and_codes(self): { "page": 1, "reconstruction_issues": [ - {"code": "source_preserving_low_opacity_overlay_degraded", "severity": "error"} + { + "code": "source_preserving_low_opacity_overlay_degraded", + "severity": "error", + } ], }, { @@ -2992,7 +3045,9 @@ def test_isolated_pages_top_level_report_includes_child_diagnostics(self): page_report = report["page_reports"][0] self.assertEqual(page_report["object_stats"]["totals"]["TEXT_BOX"], 3) - self.assertEqual(page_report["reconstruction_issues"][0]["code"], "source_raster_guardrail_degraded") + self.assertEqual( + page_report["reconstruction_issues"][0]["code"], "source_raster_guardrail_degraded" + ) self.assertEqual(page_report["validation_status"], "degraded") self.assertEqual(page_report["validation_issues"], ["source_raster_guardrail_degraded"]) self.assertEqual(page_report["active_stage"], "base_clean_background") From 3fbbac9145e1a1b5f771550e41602a72e995a707 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 16:44:25 +0800 Subject: [PATCH 12/16] Stabilize editable PPTX CI tests --- ...est_generative_editable_export_contract.py | 15 +++ tests/test_generative_editable_pipeline.py | 118 ++++++++++-------- ..._generative_editable_vlm_reconstruction.py | 27 ++-- tests/test_real_generative_editable_runner.py | 13 ++ 4 files changed, 113 insertions(+), 60 deletions(-) diff --git a/tests/test_generative_editable_export_contract.py b/tests/test_generative_editable_export_contract.py index e1140b8..7edc044 100644 --- a/tests/test_generative_editable_export_contract.py +++ b/tests/test_generative_editable_export_contract.py @@ -64,7 +64,22 @@ def preview_validator(**kwargs): ) +def _fake_vlm_config(): + from dataclasses import replace + + from src.generative_editable_config import load_generative_editable_config + + return replace(load_generative_editable_config(use_fake=True), reconstruction_mode="vlm_first") + + class GenerativeEditableExportContractTest(unittest.TestCase): + def setUp(self): + self._original_config_loader = export_route.load_generative_editable_config + export_route.load_generative_editable_config = _fake_vlm_config + + def tearDown(self): + export_route.load_generative_editable_config = self._original_config_loader + def test_accepts_generative_editable_pptx_format(self): request = ExportRequest( slides=[ExportSlide(image_base64=_slide_base64())], diff --git a/tests/test_generative_editable_pipeline.py b/tests/test_generative_editable_pipeline.py index 4823f38..7713a74 100644 --- a/tests/test_generative_editable_pipeline.py +++ b/tests/test_generative_editable_pipeline.py @@ -14,6 +14,16 @@ from src.generative_editable_preview_validator import ValidationIssue, ValidationReport +def _write_test_asset( + asset_root: str | Path, relative_path: str, size: tuple[int, int] = (16, 16) +) -> None: + from PIL import Image + + path = Path(asset_root) / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", size, (37, 99, 235, 255)).save(path) + + class GenerativeEditablePipelineTest(unittest.TestCase): def test_visual_text_coverage_reports_unmatched_left_label_candidate(self): import src.generative_editable_pipeline as pipeline @@ -1113,6 +1123,16 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): draw.text((24, 20), "Title", fill="#FFFFFF") image.save(source) + def fake_asset_builder(**kwargs): + asset = BitmapAssetSpec( + asset_id="photo", + source_pixel_bbox=(120, 70, 280, 150), + asset_path="assets/0000-slide-a/photo.png", + z_order=1, + ) + _write_test_asset(kwargs["asset_root"], asset.asset_path) + return AssetBuildResult(bitmap_assets=[asset]) + run_generative_editable_pipeline( slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], output_path=str(output_path), @@ -1123,16 +1143,7 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): image_edit_provider=DivergentBackgroundEditProvider(config.clean_base_model), image_generation_provider=FakeImageGenerationProvider(config.generation_model), foreground_planner=lambda **kwargs: [], - asset_builder=lambda **kwargs: AssetBuildResult( - bitmap_assets=[ - BitmapAssetSpec( - asset_id="photo", - source_pixel_bbox=(120, 70, 280, 150), - asset_path="assets/0000-slide-a/photo.png", - z_order=1, - ) - ] - ), + asset_builder=fake_asset_builder, visual_text_candidate_detector=lambda **kwargs: [], composer=fake_composer, structure_validator=lambda **kwargs: ValidationReport( @@ -1222,6 +1233,16 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): draw.rounded_rectangle((120, 90, 280, 170), radius=12, fill="#586272") image.save(source) + def fake_asset_builder(**kwargs): + asset = BitmapAssetSpec( + asset_id="complex-visual", + source_pixel_bbox=(120, 90, 280, 170), + asset_path="assets/0000-slide-a/complex-visual.png", + z_order=1, + ) + _write_test_asset(kwargs["asset_root"], asset.asset_path) + return AssetBuildResult(bitmap_assets=[asset]) + run_generative_editable_pipeline( slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], output_path=str(output_path), @@ -1232,16 +1253,7 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): image_edit_provider=BadGeneratedBackgroundEditProvider(config.clean_base_model), image_generation_provider=FakeImageGenerationProvider(config.generation_model), foreground_planner=lambda **kwargs: [], - asset_builder=lambda **kwargs: AssetBuildResult( - bitmap_assets=[ - BitmapAssetSpec( - asset_id="complex-visual", - source_pixel_bbox=(120, 90, 280, 170), - asset_path="assets/0000-slide-a/complex-visual.png", - z_order=1, - ) - ] - ), + asset_builder=fake_asset_builder, visual_text_candidate_detector=lambda **kwargs: [], composer=fake_composer, structure_validator=lambda **kwargs: ValidationReport( @@ -1994,6 +2006,40 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): draw.rounded_rectangle((10, 84, 390, 232), radius=12, fill="#94A3B8") image.save(source) + def fake_asset_builder(**kwargs): + assets = [ + BitmapAssetSpec( + asset_id="oversized-source", + source_pixel_bbox=(0, 80, 400, 138), + asset_path="assets/0000-slide-a/oversized-source.png", + z_order=1, + provenance={"asset_strategy": "masked_source_element"}, + ), + BitmapAssetSpec( + asset_id="wide-transparent-source", + source_pixel_bbox=(0, 80, 400, 240), + asset_path="assets/0000-slide-a/wide-transparent-source.png", + z_order=2, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ), + BitmapAssetSpec( + asset_id="small-icon", + source_pixel_bbox=(24, 88, 70, 134), + asset_path="assets/0000-slide-a/small-icon.png", + z_order=3, + provenance={ + "asset_strategy": "masked_source_element", + "background_difference_alpha": True, + }, + ), + ] + for asset in assets: + _write_test_asset(kwargs["asset_root"], asset.asset_path) + return AssetBuildResult(bitmap_assets=assets) + run_generative_editable_pipeline( slides=[GenerativeEditableSlideInput(slide_id="slide-a", image_path=str(source))], output_path=str(output_path), @@ -2004,37 +2050,7 @@ def fake_composer(deck_manifest_path, artifact_root, output_path): image_edit_provider=BadGeneratedBackgroundEditProvider(config.clean_base_model), image_generation_provider=FakeImageGenerationProvider(config.generation_model), foreground_planner=lambda **kwargs: [], - asset_builder=lambda **kwargs: AssetBuildResult( - bitmap_assets=[ - BitmapAssetSpec( - asset_id="oversized-source", - source_pixel_bbox=(0, 80, 400, 138), - asset_path="assets/0000-slide-a/oversized-source.png", - z_order=1, - provenance={"asset_strategy": "masked_source_element"}, - ), - BitmapAssetSpec( - asset_id="wide-transparent-source", - source_pixel_bbox=(0, 80, 400, 240), - asset_path="assets/0000-slide-a/wide-transparent-source.png", - z_order=2, - provenance={ - "asset_strategy": "masked_source_element", - "background_difference_alpha": True, - }, - ), - BitmapAssetSpec( - asset_id="small-icon", - source_pixel_bbox=(24, 88, 70, 134), - asset_path="assets/0000-slide-a/small-icon.png", - z_order=3, - provenance={ - "asset_strategy": "masked_source_element", - "background_difference_alpha": True, - }, - ), - ] - ), + asset_builder=fake_asset_builder, visual_text_candidate_detector=lambda **kwargs: [], composer=fake_composer, structure_validator=lambda **kwargs: ValidationReport( diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index eb6e44e..09bafda 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -14,6 +14,15 @@ from src.generative_editable_providers import ProviderError +def _fake_powerpoint_preview(page, artifact_root, *, pptx_path): + from src.generative_editable_preview_validator import PreviewRenderResult + + return PreviewRenderResult( + image=Image.new("RGB", page.source_image_size, "white"), + metadata={"renderer": "test_powerpoint", "is_powerpoint_render": True}, + ) + + class GenerativeEditableVLMReconstructionTest(unittest.TestCase): def test_vlm_validation_fails_large_source_preserved_bitmap_coverage_without_runner(self): from src.generative_editable_manifest import BitmapAssetSpec, PageManifest @@ -255,7 +264,7 @@ def preview_validator(**kwargs): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=preview_validator, ), ) @@ -324,7 +333,7 @@ def failing_preview_validator(**kwargs): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=failing_preview_validator, ), ) @@ -379,7 +388,7 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -725,7 +734,7 @@ def run_pipeline(): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -2922,7 +2931,7 @@ def edit(self, request): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -3135,7 +3144,7 @@ def edit(self, request): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -3241,7 +3250,7 @@ def edit(self, request): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -3719,7 +3728,7 @@ def extract_text(self, image_path): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), @@ -3826,7 +3835,7 @@ def extract_text(self, image_path): structure_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), - preview_renderer=lambda page, artifact_root, *, pptx_path: object(), + preview_renderer=_fake_powerpoint_preview, preview_validator=lambda **kwargs: ValidationReport( status="passed", checked_pages=1, issues=[] ), diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index 2a156c7..e1e8928 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -24,6 +24,19 @@ class RealGenerativeEditableRunnerTest(unittest.TestCase): + def setUp(self): + import scripts.run_real_generative_editable_pptx as runner + + self._config_patcher = patch.object( + runner, + "load_generative_editable_config", + side_effect=lambda use_fake=False: load_generative_editable_config(use_fake=True), + ) + self._config_patcher.start() + + def tearDown(self): + self._config_patcher.stop() + def test_default_replay_inputs_exclude_edited_derivatives(self): import scripts.run_real_generative_editable_pptx as runner From 3ede8dd52d6c62cc97139d953bbbbbbffa775ea5 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 16:59:53 +0800 Subject: [PATCH 13/16] Stabilize VLM fake-mode CI checks --- README.md | 8 ++++- ..._generative_editable_vlm_reconstruction.py | 22 +++++++++++++ tests/test_real_generative_editable_runner.py | 33 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index da16311..75fb596 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ npm run test npm run build ``` -GitHub Actions runs the same default checks on `main` and `dev` pull requests and pushes. Real model API calls are intentionally not part of the default CI because they require private keys and can be flaky. +GitHub Actions runs the same default checks on `main` and `dev` pull requests and pushes. Real model API calls and desktop PowerPoint/WPS rendering are intentionally not part of the default CI because they require private keys or host applications that are not available in GitHub runners. CI still validates package-level PPTX structure, object manifests, renderer contracts, and fake-provider reconstruction paths. ## 📋 TODO @@ -200,6 +200,12 @@ GitHub Actions runs the same default checks on `main` and `dev` pull requests an - [ ] Support region selection for partial slide editing - [ ] Add more provider profile templates +## 🙏 Acknowledgements + +The editable PPTX reconstruction design references ideas from: +- [LRriver/slide-alchemy](https://github.com/LRriver/slide-alchemy) +- [ningzimu/image-to-editable-ppt-skill](https://github.com/ningzimu/image-to-editable-ppt-skill) + ## 📄 License Apache License 2.0 diff --git a/tests/test_generative_editable_vlm_reconstruction.py b/tests/test_generative_editable_vlm_reconstruction.py index 09bafda..671f8d1 100644 --- a/tests/test_generative_editable_vlm_reconstruction.py +++ b/tests/test_generative_editable_vlm_reconstruction.py @@ -23,6 +23,12 @@ def _fake_powerpoint_preview(page, artifact_root, *, pptx_path): ) +def _pass_validation_report(**kwargs): + from src.generative_editable_preview_validator import ValidationReport + + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + class GenerativeEditableVLMReconstructionTest(unittest.TestCase): def test_vlm_validation_fails_large_source_preserved_bitmap_coverage_without_runner(self): from src.generative_editable_manifest import BitmapAssetSpec, PageManifest @@ -497,6 +503,8 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): ), provider_max_attempts=2, provider_retry_backoff_seconds=0, + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) @@ -548,6 +556,8 @@ def analyze_page(self, image_path: str, *, timeout_seconds: int = 180): api_key="fake", ) ), + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) events = GenerativeEditableJobArtifacts( @@ -626,6 +636,8 @@ def edit(self, request): image_edit_provider=image_edit_provider, provider_max_attempts=2, provider_retry_backoff_seconds=0, + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) @@ -3047,6 +3059,8 @@ def edit(self, request): ) ), asset_sheet_image_edit_provider=FailingAssetSheetProvider(), + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) job_dir = root / "artifacts" / "job-asset-fallback" @@ -3363,6 +3377,8 @@ def edit(self, request): ) ), asset_sheet_image_edit_provider=asset_sheet_provider, + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) job_dir = root / "artifacts" / "job-complex-source" @@ -3451,6 +3467,8 @@ def edit(self, request): ) ), asset_sheet_image_edit_provider=FailingAssetSheetProvider(), + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) job_dir = root / "artifacts" / "job-mixed-fallback" @@ -3546,6 +3564,8 @@ def edit(self, request): ) ), asset_sheet_image_edit_provider=ShortAssetSheetProvider(), + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) job_dir = root / "artifacts" / "job-short-sheet" @@ -3625,6 +3645,8 @@ def edit(self, request): ) ), asset_sheet_image_edit_provider=asset_sheet_provider, + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, ), ) job_dir = root / "artifacts" / "job-icon-source-preserved" diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index e1e8928..2db8c84 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -6,6 +6,7 @@ import time import unittest from contextlib import redirect_stdout +from dataclasses import replace from pathlib import Path from unittest.mock import patch @@ -23,18 +24,50 @@ ) +def _fake_powerpoint_preview(page, artifact_root, *, pptx_path): + from src.generative_editable_preview_validator import PreviewRenderResult + + return PreviewRenderResult( + image=Image.new("RGB", page.source_image_size, "white"), + metadata={"renderer": "test_powerpoint", "is_powerpoint_render": True}, + ) + + +def _pass_validation_report(**kwargs): + from src.generative_editable_preview_validator import ValidationReport + + return ValidationReport(status="passed", checked_pages=1, issues=[]) + + class RealGenerativeEditableRunnerTest(unittest.TestCase): def setUp(self): import scripts.run_real_generative_editable_pptx as runner + original_vlm_dependencies = runner._vlm_dependencies + + def fake_vlm_dependencies(**kwargs): + dependencies = original_vlm_dependencies(**kwargs) + return replace( + dependencies, + preview_renderer=_fake_powerpoint_preview, + preview_validator=_pass_validation_report, + ) + self._config_patcher = patch.object( runner, "load_generative_editable_config", side_effect=lambda use_fake=False: load_generative_editable_config(use_fake=True), ) self._config_patcher.start() + self._vlm_dependencies_patcher = patch.object( + runner, + "_vlm_dependencies", + side_effect=fake_vlm_dependencies, + ) + self._vlm_dependencies_patcher.start() def tearDown(self): + self._vlm_dependencies_patcher.stop() self._config_patcher.stop() def test_default_replay_inputs_exclude_edited_derivatives(self): From 78ee027c44d18af7826c91f6ac2f57fa073b21c5 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 17:17:54 +0800 Subject: [PATCH 14/16] Skip desktop preview in fake editable PPTX runs --- scripts/run_real_generative_editable_pptx.py | 19 +++++++++++-------- tests/test_real_generative_editable_runner.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py index bc7be19..4f76459 100644 --- a/scripts/run_real_generative_editable_pptx.py +++ b/scripts/run_real_generative_editable_pptx.py @@ -574,14 +574,17 @@ def _run_vlm_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, A artifact_root=job_dir, object_stats=object_stats, ) - preview_reports = _write_preview_reports( - source_images=images, - pptx_path=result_output_path, - deck_manifest_path=deck_path, - artifact_root=job_dir, - similarity_threshold=getattr(dependencies, "preview_similarity_threshold", 0.92), - output_dir=output_dir / "previews", - ) + if args.use_fake: + preview_reports = [] + else: + preview_reports = _write_preview_reports( + source_images=images, + pptx_path=result_output_path, + deck_manifest_path=deck_path, + artifact_root=job_dir, + similarity_threshold=getattr(dependencies, "preview_similarity_threshold", 0.92), + output_dir=output_dir / "previews", + ) else: reconstruction_issues = [] preview_reports = [] diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index 2db8c84..fbd0386 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -108,6 +108,8 @@ def test_explicit_input_glob_excludes_edited_derivatives(self): self.assertEqual([path.name for path in paths], ["slide_1.png", "slide_2.png"]) def test_run_mode_accepts_sorted_input_glob_and_writes_report(self): + import scripts.run_real_generative_editable_pptx as runner + with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) input_dir = root / "inputs" @@ -118,7 +120,16 @@ def test_run_mode_accepts_sorted_input_glob_and_writes_report(self): image.save(input_dir / name) stdout = io.StringIO() - with redirect_stdout(stdout): + with ( + patch.object( + runner, + "_write_preview_reports", + side_effect=AssertionError( + "fake mode must not require desktop preview rendering" + ), + ), + redirect_stdout(stdout), + ): exit_code = main( [ "run", From da907c7fde11483a6cee69da2dd5bf267c8a2ff9 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 17:29:38 +0800 Subject: [PATCH 15/16] Skip legacy fake preview rendering --- scripts/run_real_generative_editable_pptx.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/run_real_generative_editable_pptx.py b/scripts/run_real_generative_editable_pptx.py index 4f76459..21f4e13 100644 --- a/scripts/run_real_generative_editable_pptx.py +++ b/scripts/run_real_generative_editable_pptx.py @@ -473,14 +473,17 @@ def _run_pipeline(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]: artifact_root=job_dir, object_stats=object_stats, ) - preview_reports = _write_preview_reports( - source_images=images, - pptx_path=result_output_path, - deck_manifest_path=deck_path, - artifact_root=job_dir, - similarity_threshold=dependencies.preview_similarity_threshold, - output_dir=output_dir / "previews", - ) + if args.use_fake: + preview_reports = [] + else: + preview_reports = _write_preview_reports( + source_images=images, + pptx_path=result_output_path, + deck_manifest_path=deck_path, + artifact_root=job_dir, + similarity_threshold=dependencies.preview_similarity_threshold, + output_dir=output_dir / "previews", + ) status = _status_with_preview_reports( _status_with_reconstruction_issues(result.status, reconstruction_issues), preview_reports, From 20bdff1774e654bec7e89a39a7bf4b5880b68d8b Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 17:44:47 +0800 Subject: [PATCH 16/16] Keep isolated page CI test unit-scoped --- tests/test_real_generative_editable_runner.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_real_generative_editable_runner.py b/tests/test_real_generative_editable_runner.py index fbd0386..59bae5e 100644 --- a/tests/test_real_generative_editable_runner.py +++ b/tests/test_real_generative_editable_runner.py @@ -3027,6 +3027,8 @@ def test_subprocess_timeout_preserves_existing_child_report(self): self.assertEqual(stored["child"], "done") def test_isolated_pages_mode_writes_page_reports(self): + import scripts.run_real_generative_editable_pptx as runner + with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) input_dir = root / "inputs" @@ -3035,9 +3037,30 @@ def test_isolated_pages_mode_writes_page_reports(self): image = Image.new("RGB", (800, 450), "#FFFFFF") ImageDraw.Draw(image).rectangle((64, 36, 384, 81), fill="#2563EB") image.save(image_path) + captured_commands = [] + + def fake_subprocess(command, *, timeout_seconds, report_path, timeout_payload): + captured_commands.append(command) + page_result = { + "status": "passed", + "output_path": str(root / "out" / "page-01" / "isolated-page-01.pptx"), + "artifact_root": str( + root / "out" / "page-01" / "artifacts" / "isolated-page-01" + ), + "object_stats": { + "slides": [{"index": 1, "shape_counts": {"TEXT_BOX": 1, "PICTURE": 1}}], + "totals": {"TEXT_BOX": 1, "PICTURE": 1}, + }, + } + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(page_result), encoding="utf-8") + return page_result stdout = io.StringIO() - with redirect_stdout(stdout): + with ( + patch.object(runner, "_run_subprocess_json", side_effect=fake_subprocess), + redirect_stdout(stdout), + ): exit_code = main( [ "run", @@ -3055,12 +3078,15 @@ def test_isolated_pages_mode_writes_page_reports(self): result = json.loads(stdout.getvalue().splitlines()[-1]) report = json.loads(Path(result["report_path"]).read_text(encoding="utf-8")) page_report_exists = Path(report["page_reports"][0]["report_path"]).exists() + command = captured_commands[0] self.assertEqual(exit_code, 0) self.assertEqual(report["status"], "passed") self.assertEqual(len(report["page_reports"]), 1) self.assertEqual(report["page_reports"][0]["page"], 1) + self.assertEqual(report["page_reports"][0]["object_stats"]["totals"]["TEXT_BOX"], 1) self.assertTrue(page_report_exists) + self.assertIn("--use-fake", command) def test_isolated_pages_top_level_report_includes_child_diagnostics(self): import scripts.run_real_generative_editable_pptx as runner