feat: add translated PDF export with layout preservation

Adds the ability to download translated documents as PDF files while
preserving the original document layout. Key changes:

- Add apply_translations() function to merge translation JSON with UnifiedDocument
- Add generate_translated_pdf() method to PDFGeneratorService
- Add POST /api/v2/translate/{task_id}/pdf endpoint
- Add downloadTranslatedPdf() method and PDF button in frontend
- Add comprehensive unit tests (52 tests: merge, PDF generation, API endpoints)
- Archive add-translated-pdf-export proposal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-02 12:33:31 +08:00
parent 8d9b69ba93
commit a07aad96b3
15 changed files with 2663 additions and 2 deletions

View File

@@ -501,3 +501,139 @@ async def delete_translation(
logger.info(f"Deleted translation {lang} for task {task_id}")
return None
@router.post("/{task_id}/pdf")
async def download_translated_pdf(
task_id: str,
lang: str = Query(..., description="Target language code"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Download a translated PDF with layout preservation.
- **task_id**: Task UUID
- **lang**: Target language code (e.g., 'en', 'ja')
Returns PDF file with translated content preserving original layout.
"""
from app.services.pdf_generator_service import pdf_generator_service
from app.services.translation_service import list_available_translations
import tempfile
# Verify task ownership
task = task_service.get_task_by_id(
db=db,
task_id=task_id,
user_id=current_user.id
)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
if not task.result_json_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OCR result not found"
)
result_json_path = Path(task.result_json_path)
if not result_json_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Result file not found"
)
# Find translation file
result_dir = result_json_path.parent
base_name = result_json_path.stem.replace('_result', '').replace('edit_', '')
translation_file = result_dir / f"{base_name}_translated_{lang}.json"
# Also try with edit_ prefix removed differently
if not translation_file.exists():
translation_file = result_dir / f"edit_translated_{lang}.json"
if not translation_file.exists():
# List available translations for error message
available = list_available_translations(result_dir)
if available:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Translation for language '{lang}' not found. Available translations: {', '.join(available)}"
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No translations found for this task. Please translate the document first."
)
# Check translation status in translation JSON
try:
with open(translation_file, 'r', encoding='utf-8') as f:
translation_data = json.load(f)
if not translation_data.get('translations'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Translation file is empty or incomplete"
)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid translation file format"
)
# Generate translated PDF to temp file
output_filename = f"{task_id}_translated_{lang}.pdf"
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file:
output_path = Path(tmp_file.name)
try:
# Get source file path for images if available
source_file_path = None
if task.file_path and Path(task.file_path).exists():
source_file_path = Path(task.file_path)
success = pdf_generator_service.generate_translated_pdf(
result_json_path=result_json_path,
translation_json_path=translation_file,
output_path=output_path,
source_file_path=source_file_path
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate translated PDF"
)
logger.info(f"Generated translated PDF for task {task_id}, lang={lang}")
return FileResponse(
path=str(output_path),
filename=output_filename,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{output_filename}"'
}
)
except HTTPException:
# Clean up temp file on HTTP errors
if output_path.exists():
output_path.unlink()
raise
except Exception as e:
# Clean up temp file on unexpected errors
if output_path.exists():
output_path.unlink()
logger.exception(f"Failed to generate translated PDF for task {task_id}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to generate translated PDF: {str(e)}"
)

View File

@@ -3601,6 +3601,100 @@ class PDFGeneratorService:
except Exception as e:
logger.error(f"Failed to draw image element {element.element_id}: {e}")
def generate_translated_pdf(
self,
result_json_path: Path,
translation_json_path: Path,
output_path: Path,
source_file_path: Optional[Path] = None
) -> bool:
"""
Generate layout-preserving PDF with translated content.
This method loads the original result JSON and translation JSON,
merges them to replace original content with translations, and
generates a PDF with the translated content at original positions.
Args:
result_json_path: Path to original result JSON file (UnifiedDocument format)
translation_json_path: Path to translation JSON file
output_path: Path to save generated translated PDF
source_file_path: Optional path to original source file
Returns:
True if successful, False otherwise
"""
import tempfile
try:
# Import apply_translations from translation service
from app.services.translation_service import apply_translations
# Load original result JSON
logger.info(f"Loading result JSON: {result_json_path}")
with open(result_json_path, 'r', encoding='utf-8') as f:
result_json = json.load(f)
# Load translation JSON
logger.info(f"Loading translation JSON: {translation_json_path}")
with open(translation_json_path, 'r', encoding='utf-8') as f:
translation_json = json.load(f)
# Extract translations dict from translation JSON
translations = translation_json.get('translations', {})
if not translations:
logger.warning("No translations found in translation JSON")
# Still generate PDF with original content as fallback
return self.generate_layout_pdf(
json_path=result_json_path,
output_path=output_path,
source_file_path=source_file_path
)
# Apply translations to result JSON
translated_doc = apply_translations(result_json, translations)
target_lang = translation_json.get('target_lang', 'unknown')
logger.info(
f"Generating translated PDF: {len(translations)} translations applied, "
f"target_lang={target_lang}"
)
# Write translated JSON to a temporary file and use existing generate_layout_pdf
with tempfile.NamedTemporaryFile(
mode='w',
suffix='_translated.json',
delete=False,
encoding='utf-8'
) as tmp_file:
json.dump(translated_doc, tmp_file, ensure_ascii=False, indent=2)
tmp_path = Path(tmp_file.name)
try:
# Use existing PDF generation with translated content
success = self.generate_layout_pdf(
json_path=tmp_path,
output_path=output_path,
source_file_path=source_file_path
)
return success
finally:
# Clean up temporary file
if tmp_path.exists():
tmp_path.unlink()
except FileNotFoundError as e:
logger.error(f"File not found: {e}")
return False
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON: {e}")
return False
except Exception as e:
logger.error(f"Failed to generate translated PDF: {e}")
import traceback
traceback.print_exc()
return False
# Singleton instance
pdf_generator_service = PDFGeneratorService()

View File

@@ -35,6 +35,166 @@ TABLE_TYPE = 'table'
SKIP_TYPES = {'page_number', 'image', 'chart', 'logo', 'reference'}
def apply_translations(
result_json: Dict,
translations: Dict[str, Any]
) -> Dict:
"""
Apply translations to a result JSON document, creating a translated copy.
This function merges translation data with the original document structure,
replacing original content with translated content while preserving all
other properties (bounding boxes, styles, etc.).
Args:
result_json: Original UnifiedDocument JSON data
translations: Translation dict mapping element_id to translated content.
For text elements: element_id -> translated_string
For tables: element_id -> {"cells": [{"row": int, "col": int, "content": str}]}
Returns:
A deep copy of result_json with translations applied
"""
import copy
translated_doc = copy.deepcopy(result_json)
applied_count = 0
for page in translated_doc.get('pages', []):
for elem in page.get('elements', []):
elem_id = elem.get('element_id', '')
elem_type = elem.get('type', '')
if elem_id not in translations:
continue
translation = translations[elem_id]
# Handle text elements (string translation)
if isinstance(translation, str):
if elem_type in TRANSLATABLE_TEXT_TYPES:
elem['content'] = translation
applied_count += 1
else:
logger.warning(
f"Translation for {elem_id} is string but element type is {elem_type}"
)
# Handle table elements (cells translation)
elif isinstance(translation, dict) and 'cells' in translation:
if elem_type == TABLE_TYPE and isinstance(elem.get('content'), dict):
_apply_table_translation(elem, translation)
applied_count += 1
else:
logger.warning(
f"Translation for {elem_id} is table but element type is {elem_type}"
)
logger.info(f"Applied {applied_count} translations to document")
return translated_doc
def _apply_table_translation(
table_elem: Dict,
translation: Dict[str, Any]
) -> None:
"""
Apply translation to a table element's cells.
Args:
table_elem: Table element dict with content.cells
translation: Translation dict with 'cells' list
"""
content = table_elem.get('content', {})
original_cells = content.get('cells', [])
if not original_cells:
return
# Build lookup for translated cells by (row, col)
translated_cells = {}
for cell in translation.get('cells', []):
row = cell.get('row', 0)
col = cell.get('col', 0)
translated_cells[(row, col)] = cell.get('content', '')
# Apply translations to matching cells
for cell in original_cells:
row = cell.get('row', 0)
col = cell.get('col', 0)
key = (row, col)
if key in translated_cells:
cell['content'] = translated_cells[key]
def load_translation_json(translation_path: Path) -> Optional[Dict]:
"""
Load translation JSON file.
Args:
translation_path: Path to translation JSON file
Returns:
Translation JSON dict or None if file doesn't exist
"""
if not translation_path.exists():
return None
try:
with open(translation_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load translation JSON: {e}")
return None
def find_translation_file(
result_dir: Path,
target_lang: str
) -> Optional[Path]:
"""
Find translation file for a given language in result directory.
Args:
result_dir: Directory containing result files
target_lang: Target language code (e.g., 'en', 'zh-TW')
Returns:
Path to translation file or None if not found
"""
# Look for *_translated_{lang}.json pattern
pattern = f"*_translated_{target_lang}.json"
matches = list(result_dir.glob(pattern))
if matches:
return matches[0]
return None
def list_available_translations(result_dir: Path) -> List[str]:
"""
List all available translation languages for a result directory.
Args:
result_dir: Directory containing result files
Returns:
List of language codes with available translations
"""
languages = []
pattern = "*_translated_*.json"
for path in result_dir.glob(pattern):
# Extract language from filename: xxx_translated_{lang}.json
stem = path.stem
if '_translated_' in stem:
lang = stem.split('_translated_')[-1]
if lang:
languages.append(lang)
return languages
@dataclass
class TranslationBatch:
"""A batch of items to translate together"""