allow specifying conversion resolution, drop explicit dpi

This commit is contained in:
2026-03-27 13:51:56 -07:00
parent 5f68aa5567
commit 5923bff155
14 changed files with 398 additions and 94 deletions
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
import math
from pathlib import Path
import subprocess
from typing import Iterable
@@ -27,6 +28,12 @@ class SlideDeckResult:
source_filename: str
slides: list[SlideArtifact]
width: int
height: int
inferred_dpi: int
pptx_to_pdf_timeout_s: int
pdf_to_images_timeout_s: int
pdf_to_images_page_timeout_s: int
ProgressCallback = Callable[[str, int, int], None]
@@ -36,6 +43,21 @@ PHASE_EXTRACTING_NOTES = "extracting_notes"
PHASE_PPTX_TO_PDF = "pptx_to_pdf"
PHASE_PDF_TO_IMAGES = "pdf_to_images"
RESOLUTION_SD = "sd"
RESOLUTION_HD = "hd"
RESOLUTION_FHD = "fhd"
RESOLUTION_QHD = "qhd"
RESOLUTION_UHD = "uhd"
_SHORT_EDGE_PIXELS_BY_RESOLUTION = {
RESOLUTION_SD: 480,
RESOLUTION_HD: 720,
RESOLUTION_FHD: 1080,
RESOLUTION_QHD: 1440,
RESOLUTION_UHD: 2160,
}
_EMU_PER_INCH = 914400
logger = logging.getLogger("uvicorn.error")
@@ -108,6 +130,8 @@ def render_pdf_to_images(
out_dir: Path,
*,
dpi: int = 72,
target_width: int | None = None,
target_height: int | None = None,
image_format: str = "png",
timeout_s: int = 120,
total_pages: int | None = None,
@@ -132,14 +156,24 @@ def render_pdf_to_images(
"""
if not pdf_path.exists():
raise FileNotFoundError(f"source PDF does not exist: {pdf_path}")
if (target_width is None) != (target_height is None):
raise ValueError("target_width and target_height must be provided together")
if target_width is not None and target_width <= 0:
raise ValueError("target_width must be greater than zero")
if target_height is not None and target_height <= 0:
raise ValueError("target_height must be greater than zero")
out_dir.mkdir(parents=True, exist_ok=True)
scale_args: list[str] = []
if target_width is not None and target_height is not None:
scale_args = ["-scale-to-x", str(target_width), "-scale-to-y", str(target_height)]
if total_pages is None:
prefix_path = out_dir / "slide"
command = [
"pdftoppm",
"-r",
str(dpi),
*scale_args,
f"-{image_format}",
str(pdf_path.resolve()),
str(prefix_path),
@@ -156,7 +190,7 @@ def render_pdf_to_images(
message = (
"Poppler rasterization timed out after "
f"{timeout_s} seconds while rendering {pdf_path.name}; "
"increase conversion PDF render timeout cap or lower image DPI"
"increase conversion PDF render timeout cap or lower output resolution"
)
logger.error(message, exc_info=True)
raise ConversionTimeoutError(message) from exc
@@ -175,6 +209,7 @@ def render_pdf_to_images(
"pdftoppm",
"-r",
str(dpi),
*scale_args,
f"-{image_format}",
"-f",
str(page_index),
@@ -202,7 +237,7 @@ def render_pdf_to_images(
message = (
"Poppler rasterization timed out while rendering page "
f"{page_index}/{total_pages} of {pdf_path.name}; "
f"{timeout_context}. Increase timeout settings or lower image DPI."
f"{timeout_context}. Increase timeout settings or lower output resolution."
)
logger.error(message, exc_info=True)
raise ConversionTimeoutError(message) from exc
@@ -254,7 +289,7 @@ def convert_pptx_to_slidedeck(
pptx_path: Path,
work_dir: Path,
*,
dpi: int = 72,
resolution: str = RESOLUTION_FHD,
image_format: str = "png",
pptx_to_pdf_timeout_s: int = 180,
pdf_to_images_timeout_s: int = 1800,
@@ -273,7 +308,7 @@ def convert_pptx_to_slidedeck(
Args:
pptx_path: Source `.pptx` path.
work_dir: Scratch directory for generated outputs.
dpi: Rasterization DPI for output slide images.
resolution: Output resolution preset (`sd`, `hd`, `fhd`, `qhd`, `uhd`).
image_format: Output image format accepted by `pdftoppm`.
pptx_to_pdf_timeout_s: Timeout in seconds for the LibreOffice subprocess.
pdf_to_images_timeout_s: Timeout in seconds for the Poppler subprocess.
@@ -292,6 +327,18 @@ def convert_pptx_to_slidedeck(
_emit_progress(progress_callback, PHASE_EXTRACTING_NOTES, 0, 1)
notes = extract_slide_notes(pptx_path)
_emit_progress(progress_callback, PHASE_EXTRACTING_NOTES, 1, 1)
slide_width, slide_height = _read_slide_size_emu(pptx_path)
output_width, output_height = _infer_output_dimensions_from_slide_size(
slide_width=slide_width,
slide_height=slide_height,
resolution=resolution,
)
inferred_dpi = infer_minimum_raster_dpi(
slide_width_emu=slide_width,
slide_height_emu=slide_height,
output_width_px=output_width,
output_height_px=output_height,
)
slide_count = len(notes)
pptx_to_pdf_timeout = _compute_adaptive_timeout(
slide_count=slide_count,
@@ -317,12 +364,16 @@ def convert_pptx_to_slidedeck(
base_timeout_s=pdf_to_images_base_timeout_s,
)
logger.info(
"Conversion plan source=%s slides=%d dpi=%d image_format=%s "
"Conversion plan source=%s slides=%d inferred_dpi=%d image_format=%s "
"resolution=%s output_size=%dx%d "
"computed_timeouts_s[pptx_to_pdf_total=%d,pdf_to_images_total=%d,pdf_to_images_per_page=%d]",
pptx_path.name,
slide_count,
dpi,
inferred_dpi,
image_format,
resolution,
output_width,
output_height,
pptx_to_pdf_timeout,
pdf_to_images_timeout,
pdf_to_images_page_timeout,
@@ -330,7 +381,9 @@ def convert_pptx_to_slidedeck(
image_paths = render_pdf_to_images(
pdf_path,
image_dir,
dpi=dpi,
dpi=inferred_dpi,
target_width=output_width,
target_height=output_height,
image_format=image_format,
timeout_s=pdf_to_images_page_timeout,
total_pages=slide_count,
@@ -353,7 +406,82 @@ def convert_pptx_to_slidedeck(
SlideArtifact(index=index, image_path=image_path, notes_plain=note)
for index, (image_path, note) in enumerate(zip(image_paths, notes), start=1)
]
return SlideDeckResult(source_filename=pptx_path.name, slides=slides)
return SlideDeckResult(
source_filename=pptx_path.name,
slides=slides,
width=output_width,
height=output_height,
inferred_dpi=inferred_dpi,
pptx_to_pdf_timeout_s=pptx_to_pdf_timeout,
pdf_to_images_timeout_s=pdf_to_images_timeout,
pdf_to_images_page_timeout_s=pdf_to_images_page_timeout,
)
def infer_output_dimensions_for_resolution(
pptx_path: Path,
*,
resolution: str,
) -> tuple[int, int]:
"""Infer output image dimensions from source slide aspect ratio and preset."""
slide_width, slide_height = _read_slide_size_emu(pptx_path)
return _infer_output_dimensions_from_slide_size(
slide_width=slide_width,
slide_height=slide_height,
resolution=resolution,
)
def infer_minimum_raster_dpi(
*,
slide_width_emu: int,
slide_height_emu: int,
output_width_px: int,
output_height_px: int,
) -> int:
"""Compute the minimum DPI needed to reach target output dimensions."""
if slide_width_emu <= 0 or slide_height_emu <= 0:
raise ValueError("source slide dimensions must be greater than zero")
if output_width_px <= 0 or output_height_px <= 0:
raise ValueError("output dimensions must be greater than zero")
dpi_for_width = (output_width_px * _EMU_PER_INCH) / slide_width_emu
dpi_for_height = (output_height_px * _EMU_PER_INCH) / slide_height_emu
return max(1, math.ceil(max(dpi_for_width, dpi_for_height)))
def _read_slide_size_emu(pptx_path: Path) -> tuple[int, int]:
"""Read presentation slide size in English Metric Units (EMU)."""
if not pptx_path.exists():
raise FileNotFoundError(f"source PPTX does not exist: {pptx_path}")
presentation = Presentation(str(pptx_path.resolve()))
# Canonical python-pptx API on `Presentation` object.
slide_width = presentation.slide_width
slide_height = presentation.slide_height
if slide_width is None or slide_height is None:
raise ValueError("source presentation did not define slide dimensions")
slide_width = int(slide_width)
slide_height = int(slide_height)
if slide_width <= 0 or slide_height <= 0:
raise ValueError("source slide dimensions must be greater than zero")
return slide_width, slide_height
def _infer_output_dimensions_from_slide_size(
*,
slide_width: int,
slide_height: int,
resolution: str,
) -> tuple[int, int]:
"""Infer output dimensions from slide size and short-edge preset."""
normalized = resolution.strip().lower()
short_edge_pixels = _SHORT_EDGE_PIXELS_BY_RESOLUTION.get(normalized)
if short_edge_pixels is None:
raise ValueError(f"unsupported resolution preset: {resolution}")
if slide_width >= slide_height:
long_edge = max(1, round(short_edge_pixels * (slide_width / slide_height)))
return long_edge, short_edge_pixels
long_edge = max(1, round(short_edge_pixels * (slide_height / slide_width)))
return short_edge_pixels, long_edge
def _compute_adaptive_timeout(
@@ -16,7 +16,6 @@ class ServerConfig:
s3_secure: bool
s3_public_endpoint: str
s3_session_ttl_seconds: int
conversion_image_dpi: int
conversion_pptx_to_pdf_timeout_seconds: int
conversion_pdf_to_images_timeout_seconds: int
conversion_pptx_to_pdf_base_timeout_seconds: int
@@ -35,7 +34,6 @@ def load_server_config() -> ServerConfig:
s3_secure=os.getenv("S3_USE_SSL", "false").lower() == "true",
s3_public_endpoint=os.getenv("S3_PUBLIC_ENDPOINT", "localhost:8333"),
s3_session_ttl_seconds=int(os.getenv("S3_SESSION_TTL_SECONDS", "3600")),
conversion_image_dpi=int(os.getenv("CONVERSION_IMAGE_DPI", "72")),
conversion_pptx_to_pdf_timeout_seconds=int(
os.getenv("CONVERSION_PPTX_TO_PDF_TIMEOUT_SECONDS", "180")
),
@@ -19,6 +19,7 @@ class ConversionSession:
conversion_id: str
source_filename: str
resolution: conversion_pb2.ConversionResolution
bucket_name: str
upload_object_key: str
status: conversion_pb2.ConversionStatus
@@ -22,6 +22,11 @@ from officeconvert.conversion import (
PHASE_EXTRACTING_NOTES,
PHASE_PDF_TO_IMAGES,
PHASE_PPTX_TO_PDF,
RESOLUTION_FHD,
RESOLUTION_HD,
RESOLUTION_QHD,
RESOLUTION_SD,
RESOLUTION_UHD,
)
from officeconvertapi.v1 import conversion_connect, conversion_pb2
@@ -31,6 +36,14 @@ from officeconvert_server.storage import S3Store
logger = logging.getLogger("uvicorn.error")
_RESOLUTION_PRESET_BY_PROTO = {
conversion_pb2.CONVERSION_RESOLUTION_SD: RESOLUTION_SD,
conversion_pb2.CONVERSION_RESOLUTION_HD: RESOLUTION_HD,
conversion_pb2.CONVERSION_RESOLUTION_FHD: RESOLUTION_FHD,
conversion_pb2.CONVERSION_RESOLUTION_QHD: RESOLUTION_QHD,
conversion_pb2.CONVERSION_RESOLUTION_UHD: RESOLUTION_UHD,
}
class ConversionServiceImpl(conversion_connect.ConversionService):
"""Implements the conversion API with in-memory state and S3 orchestration."""
@@ -54,6 +67,11 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
raise ConnectError(Code.INVALID_ARGUMENT, "source_filename is required")
if not source_filename.lower().endswith(".pptx"):
raise ConnectError(Code.INVALID_ARGUMENT, "only .pptx input is supported")
resolution = request.resolution
if resolution == conversion_pb2.CONVERSION_RESOLUTION_UNSPECIFIED:
resolution = conversion_pb2.CONVERSION_RESOLUTION_FHD
if resolution not in _RESOLUTION_PRESET_BY_PROTO:
raise ConnectError(Code.INVALID_ARGUMENT, "resolution is invalid")
conversion_id = str(uuid.uuid4())
bucket_name = f"oc-{conversion_id}"
@@ -70,6 +88,7 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
session = ConversionSession(
conversion_id=conversion_id,
source_filename=source_filename,
resolution=resolution,
bucket_name=bucket_name,
upload_object_key=upload_key,
status=conversion_pb2.CONVERSION_STATUS_PENDING,
@@ -186,11 +205,11 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
"""Execute conversion flow and persist terminal state in memory."""
started_at = time.monotonic()
logger.info(
"Starting conversion conversion_id=%s source_filename=%s dpi=%d "
"Starting conversion conversion_id=%s source_filename=%s resolution=%s "
"timeout_caps_s[pptx_to_pdf_total=%d,pdf_to_images_total=%d]",
session.conversion_id,
session.source_filename,
self._config.conversion_image_dpi,
conversion_pb2.ConversionResolution.Name(session.resolution),
self._config.conversion_pptx_to_pdf_timeout_seconds,
self._config.conversion_pdf_to_images_timeout_seconds,
)
@@ -210,7 +229,7 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
convert_pptx_to_slidedeck,
source_path,
work_dir,
dpi=self._config.conversion_image_dpi,
resolution=_RESOLUTION_PRESET_BY_PROTO[session.resolution],
pptx_to_pdf_timeout_s=self._config.conversion_pptx_to_pdf_timeout_seconds,
pdf_to_images_timeout_s=self._config.conversion_pdf_to_images_timeout_seconds,
pptx_to_pdf_base_timeout_s=self._config.conversion_pptx_to_pdf_base_timeout_seconds,
@@ -224,6 +243,20 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
max_progress=max_value,
),
)
logger.info(
"Resolved conversion plan conversion_id=%s source_filename=%s "
"resolution=%s inferred_dpi=%d output_size=%dx%d "
"computed_timeouts_s[pptx_to_pdf_total=%d,pdf_to_images_total=%d,pdf_to_images_per_page=%d]",
session.conversion_id,
session.source_filename,
conversion_pb2.ConversionResolution.Name(session.resolution),
result.inferred_dpi,
result.width,
result.height,
result.pptx_to_pdf_timeout_s,
result.pdf_to_images_timeout_s,
result.pdf_to_images_page_timeout_s,
)
self._set_session_progress(
session,
phase=conversion_pb2.CONVERSION_PHASE_UPLOADING_RESULTS,
@@ -235,6 +268,8 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
session,
result.slides,
result.source_filename,
result.width,
result.height,
lambda current, max_value: self._set_session_progress(
session,
phase=conversion_pb2.CONVERSION_PHASE_UPLOADING_RESULTS,
@@ -300,6 +335,8 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
session: ConversionSession,
slides: list[SlideArtifact],
source_filename: str,
width: int,
height: int,
progress_callback: Callable[[int, int], None] | None = None,
) -> conversion_pb2.SlideDeck:
"""Upload generated slide images and construct API response payload."""
@@ -328,6 +365,8 @@ class ConversionServiceImpl(conversion_connect.ConversionService):
source_filename=source_filename,
slides=response_slides,
created_at=_to_timestamp(utc_now()),
width=width,
height=height,
)
async def _delayed_cleanup(self, session: ConversionSession) -> None: