Source code for zooui.converters.pdfconverter

## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.

"""PDF rasterizer based upon either Xpdf or Poppler."""

import os
import shutil
import subprocess
import tempfile
from typing import Any

from .converter import Converter


[docs] class _DummyFont: """Sentinel used when PIL is not available for page numbering.""" pass
[docs] class PDFConverter(Converter): """ Constructor : PDFConverter(infile, outdir, page_numbering) Parameters : infile : str outdir : str page_numbering : bool (default=True) PDFConverter(infile, outdir, page_numbering) --> None PDFConverter objects are used for rasterizing PDFs to per-page PPM files. Each page is rasterized as a separate PPM file in the output directory: ``outdir/page_0000.ppm``, ``outdir/page_0001.ppm``, etc. If ``page_numbering`` is True (default), a page number label (``"N / total"``) is drawn onto each PPM image in the bottom-right corner before copying to the output directory. The output format will always be PPM irrespective of the file extension of the output file. If another output format is required then :class:`PDFConverter` should be used in conjunction with :class:`VipsConverter`. """ def __init__(self, infile: str, outdir: str, page_numbering: bool = True) -> None: """ Constructor : PDFConverter(infile, outdir, page_numbering) Parameters : infile : str outdir : str page_numbering : bool (default=True) PDFConverter(infile, outdir, page_numbering) --> None Create a new PDFConverter for rasterizing PDF files. The infile parameter is the path to the source PDF file. The outdir parameter is the directory where per-page PPM files will be written. The default resolution is 300 DPI. If page_numbering is True, each page PPM will have a ``"N / total"`` label drawn in the bottom-right corner. """ Converter.__init__(self, infile, outdir) self.resolution = 300 self.page_count = 0 self.page_paths: list[str] = [] self.page_numbering = page_numbering
[docs] @staticmethod def _load_page_number_font(font_size: int) -> Any: """Load a font for drawing page numbers, falling back gracefully. Tries common system TrueType fonts first, then falls back to PIL's built-in default bitmap font. """ try: from PIL import ImageFont except ImportError: return _DummyFont() font_paths = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/HelveticaNeue.ttc", "C:\\Windows\\Fonts\\arial.ttf", ] for path in font_paths: if os.path.isfile(path): try: return ImageFont.truetype(path, size=font_size) except OSError: continue return ImageFont.load_default()
[docs] def _draw_page_numbers(self, tmpdir: str, page_files: dict[int, str]) -> None: """Draw ``"N / total"`` labels onto each page PPM in-place. Parameters: tmpdir: Directory containing raw pdftoppm output. page_files: Mapping of 1-based page number → filename in tmpdir. """ try: from PIL import Image, ImageDraw except ImportError: self._logger.warning("PIL not available — skipping page numbering") return num_pages = len(page_files) self._logger.info("drawing page numbers on %d pages", num_pages) for i in range(num_pages): src = os.path.join(tmpdir, page_files[i + 1]) try: img = Image.open(src) if img.mode != "RGB": img = img.convert("RGB") w, h = img.size font_size = int(min(w, h) * 0.03) font_size = max(24, min(font_size, 200)) font = self._load_page_number_font(font_size) label = f"{i + 1} / {num_pages}" draw = ImageDraw.Draw(img) bbox = draw.textbbox((0, 0), label, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] margin = max(1, font_size // 4) box_x1 = w - text_w - margin * 2 box_y1 = h - text_h - margin * 2 box_x2 = w box_y2 = h draw.rectangle( [box_x1, box_y1, box_x2, box_y2], fill=(32, 32, 32), ) draw.text( (box_x1 + margin, box_y1 + margin), label, fill=(255, 255, 255), font=font, ) img.save(src) except Exception as e: self._logger.warning("failed to number page %d: %s", i + 1, e)
[docs] def run(self) -> None: """ Method : PDFConverter.run() Parameters : None PDFConverter.run() --> None Run the PDF conversion using pdftoppm. Creates a temporary directory, calls pdftoppm to rasterize the PDF into individual PPM pages, then copies each page to the output directory with predictable filenames. If page_numbering is True, a ``"N / total"`` label is drawn onto each page PPM before copying. If any errors are encountered then :attr:`self.error` will be set to a string describing the error. """ tmpdir = tempfile.mkdtemp() self._logger.info("calling pdftoppm") process = subprocess.Popen( ["pdftoppm", "-r", str(self.resolution), self._infile, os.path.join(tmpdir, "page")], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) stdout = process.communicate()[0] if process.returncode == 0: try: self._progress = 0.5 page_files: dict[int, str] = {} for filename in os.listdir(tmpdir): if not filename.startswith("page-"): continue try: page_num = int(filename[5:-4]) except ValueError: continue page_files[page_num] = filename num_pages = len(page_files) if self.page_numbering and num_pages > 0: self._progress = 0.55 self._draw_page_numbers(tmpdir, page_files) self._logger.info("organizing per-page PPMs") self._progress = 0.7 outdir = self._outfile os.makedirs(outdir, exist_ok=True) self.page_paths = [] for i in range(num_pages): src = os.path.join(tmpdir, page_files[i + 1]) dst = os.path.join(outdir, f"page_{i:04d}.ppm") shutil.copy2(src, dst) self.page_paths.append(dst) self.page_count = num_pages self._logger.info("converted %d pages", num_pages) except Exception as e: self.error = "Error organizing per-page PPMs\n" + str(e) self._logger.error(self.error) else: self.error = f"conversion failed with return code {process.returncode}:\n{stdout!r}" self._logger.error(self.error) shutil.rmtree(tmpdir, ignore_errors=True) self._progress = 1.0
def __str__(self) -> str: """ Method : PDFConverter.__str__() Parameters : None PDFConverter.__str__() --> str Return a human-readable string representation of the PDFConverter. """ return f"PDFConverter({self._infile}, {self._outfile})" def __repr__(self) -> str: """ Method : PDFConverter.__repr__() Parameters : None PDFConverter.__repr__() --> str Return a formal string representation of the PDFConverter. """ return f"PDFConverter({self._infile!r}, {self._outfile!r})"