diff --git a/README.md b/README.md index 223709a..18e8f87 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `c3c40f6c8fca`. +This snapshot was assembled from development commit `8ed24ef03b36`. ## License diff --git a/doc/install/linux_source_install.md b/doc/install/linux_source_install.md index ce4014c..a6b9bc0 100644 --- a/doc/install/linux_source_install.md +++ b/doc/install/linux_source_install.md @@ -32,6 +32,14 @@ sudo apt-get install python3-openimageio python3 -c "import OpenImageIO" ``` +To use a PDF file as an image (`@image` of a `.pdf`), poppler's `pdfinfo` +and `pdftoppm` must be on the `PATH`; without them such a document stops +with a message naming the package: + +```bash +sudo apt-get install poppler-utils +``` + (A `pip` install works too, but a fresh Ubuntu has no `pip` until `python3-pip` is installed, and since 24.04 it refuses to install into the system Python without `--break-system-packages`; the package avoids both.) diff --git a/doc/install/macos_source_install.md b/doc/install/macos_source_install.md index dfcec31..2b800be 100644 --- a/doc/install/macos_source_install.md +++ b/doc/install/macos_source_install.md @@ -58,6 +58,11 @@ With pip, the self-contained PyPI wheel goes into that Python's site-packages; managed". (Homebrew alternative: `brew install openimageio`, heavier — it pulls ffmpeg/openexr/etc.) +To use a PDF file as an image (`@image` of a `.pdf`), poppler's `pdfinfo` and +`pdftoppm` must be on the `PATH`; without them such a document stops with a +message naming the package: `brew install poppler` (MacPorts: `sudo port +install poppler`). + ## 3. Clone and configure the environment ```sh diff --git a/doc/install/windows_source_install.md b/doc/install/windows_source_install.md index 7d19029..ea61715 100644 --- a/doc/install/windows_source_install.md +++ b/doc/install/windows_source_install.md @@ -43,6 +43,12 @@ python -m pip install OpenImageIO python -c "import OpenImageIO" ``` +To use a PDF file as an image (`@image` of a `.pdf`), Klammertext runs +poppler's `pdfinfo` and `pdftoppm`; without them such a document stops +with a message naming the package. MSYS2 (installed below) provides them: +in the UCRT64 shell `pacman -S mingw-w64-ucrt-x86_64-poppler`, and for use +from PowerShell put `C:\msys64\ucrt64\bin` on your `Path` as well. + Note that the interpreter is called `python` on Windows. There is no `python3`; the `python3` that Windows itself offers is a placeholder that opens the Microsoft Store, and Klammertext does not use it. diff --git a/sks/image/image.py b/sks/image/image.py index ad86374..243029b 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -29,21 +29,16 @@ class Image(klammer_base.Klammer_base): self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False, quality=self.Image_quality) self.basename = klammer_base.unescape_ktesc(self.basename) - self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename) - if self.file_error: - self.file_error_message = f'\nERROR: Image "{self.basename}" not found' + self.source, self.pwidth, self.pheight = self.cache.get(self.K_target, self.basename) self.as_string = as_string if width: self.width = width self.rel_image = None self.rel_fraction = 1.0 if self.rel: - self.rel_image, self.rel_pwidth, self.rel_pheight, self.file_error = self.cache.get(self.K_target, self.rel) + self.rel_image, self.rel_pwidth, self.rel_pheight = self.cache.get(self.K_target, self.rel) self.rel_fraction = self.pwidth / self.rel_pwidth - if self.file_error: - self.file_error_message = f'\nERROR: Image "{self.rel}" (the :rel image) not found' - def html(self): img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}" @@ -76,9 +71,6 @@ class Image(klammer_base.Klammer_base): if self.border: result.cls("image_border") - if self.file_error: - self.caption += self.file_error_message - result = html_util.add_caption( result, "Figure", self.number, self.caption, self.caption_style, self.hpos, self.caption_side, False, self.caption_size, @@ -101,8 +93,6 @@ class Image(klammer_base.Klammer_base): #result = f'\\includegraphics[width={width}]{{{source}}}' if self.number or self.caption: result = f'\\includegraphics[width=\\textwidth]{{{source}}}' - if self.file_error: - self.caption += self.file_error_message result = latex_util.add_caption( result, "Figure", self.number, self.caption, width, self.hpos, self.caption_side, offset=self.offset) diff --git a/sks/image/image_cache.py b/sks/image/image_cache.py index 891a1fe..8a81e84 100644 --- a/sks/image/image_cache.py +++ b/sks/image/image_cache.py @@ -45,6 +45,14 @@ def image_dimensions(filename): # html takes them). photograph_formats = {"jpg", "jpeg", "png", "tif", "tiff"} +# Every extension an @image source may have: the targets' preferred formats +# plus what a conversion accepts (OpenImageIO for the rasters, pdftoppm for +# a pdf). find_file() admits no other file -- the glob "basename.*" also +# returns a basename.kt, .aux or .log sitting beside a figure, and the old +# fallback took the first of those blindly, so a document whose PDF figure +# shared its stem with its source got "Cannot read image" (2026-09-25). +image_formats = {"jpg", "jpeg", "png", "gif", "webp", "tif", "tiff", "bmp", "pdf", "eps"} + def is_photograph(filename): """True if the file is a raster with no alpha channel: a photograph (or a grayscale one) that JPEG stores well. A png with transparency, @@ -63,7 +71,6 @@ def is_photograph(filename): class Image_cache: max_width = 1600 # Should be a state variable cache_basedir = "_klammertext_imgsrc" # Should be a state variable - error_marker_filename = "_error_marker.png" verbose_label = " [klammertext]" # Should parameterize verbosity def __init__(self, cache_dir, search_path, verbose=False, quality=100): #self.cache_dir = kutil.cache_directory( @@ -89,17 +96,10 @@ class Image_cache: "pdf" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"], "tex" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"], "latex" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"] } - self.error_image_filename = f"{self.dir}/{Image_cache.error_marker_filename}" if not os.path.exists(self.dir): os.makedirs(self.dir) self.search = self.search + [f"{os.path.dirname(cache_dir)}/imgsrc"] - def make_error_marker(self): - if not os.path.exists(self.error_image_filename): - err_buf = oiio.ImageBuf(oiio.ImageSpec(300, 100, 3, oiio.UINT8)) - oiio.ImageBufAlgo.fill(err_buf, (1.0, 0.0, 0.0)) - err_buf.write(self.error_image_filename) - def construct_cache_filename(self, target, filename): basename, ext = os.path.splitext(filename) required = self.required_format[target] @@ -119,7 +119,6 @@ class Image_cache: """Whether this source is written to the cache as a quality-N JPEG.""" return self.quality < 100 \ and target in self.required_format \ - and source_filename != self.error_image_filename \ and is_photograph(source_filename) def find_file(self, target, basename): @@ -128,6 +127,8 @@ class Image_cache: matches = [] for path in self.search: candidates += glob.glob(f"{path}/{basename}.*") + candidates = [c for c in candidates + if os.path.splitext(c)[1][1:].lower() in image_formats] candidates = sorted(candidates, key=lambda p: 0 if p.endswith(".png") else 1) if False: print("Candidates:") @@ -142,10 +143,14 @@ class Image_cache: if not matches: result = candidates[0] else: - kutil.msg(kutil.red_text(f'No image with basename "{basename}" found in search path:')) - kutil.msg(kutil.red_text(" | " + " | ".join(self.search) + " | ")) - self.make_error_marker() - matches = [self.error_image_filename] + # An error, as any other missing input is: a document with an + # image that is not there does not render. (Until 2026-09-25 + # this substituted a red marker image and printed a message -- + # to a stdout the embedded interpreter captures, so the marker + # appeared and nothing was said.) + raise Exception( + f'No image with basename "{basename}" found in the image search path: ' + + " | ".join(self.search)) result = matches[0] if matches else candidates[0] """ @@ -238,10 +243,10 @@ class Image_cache: else: raise Exception(buf.geterror()) except Exception as err: - print(f'Cannot convert "{source_filename}" to ' - f'"{os.path.splitext(cached_filename)[-1][1:]}":') - print(" ", err) - sys.exit(1) + # Raised, not printed and exited: the print would go to the + # captured stdout and say nothing; the error report says it. + raise Exception(f'Cannot convert "{source_filename}" to ' + f'"{os.path.splitext(cached_filename)[-1][1:]}": {err}') if self.verbose: self.describe_caching(source_filename, cached_filename, copied, @@ -265,8 +270,7 @@ class Image_cache: base = os.path.basename(cached_filename) #print(f"{Image_cache.verbose_label} From cache: {base} [{width}x{height}]") kutil.msg("From cache: {base} [{width}x{height}]") - # print("image_cache:", cached_filename, Image_cache.error_marker_filename) - return cached_filename, width, height, cached_filename == Image_cache.error_marker_filename + return cached_filename, width, height def clear(self): shutil.rmtree(self.dir) diff --git a/sks/image/pdf_image.py b/sks/image/pdf_image.py index 2c68236..da34dca 100644 --- a/sks/image/pdf_image.py +++ b/sks/image/pdf_image.py @@ -1,4 +1,21 @@ -import re, subprocess, os +import re, subprocess, os, shutil + +# The two poppler tools a PDF image needs, found on PATH -- never by an +# absolute path: /usr/bin/pdfinfo exists on a Debian-family Linux and +# nowhere else (Homebrew puts poppler under /opt/homebrew/bin, MSYS2 under +# C:/msys64/ucrt64/bin), and a user on those systems saw "No such file" +# with no hint of what to install. Mirrors latex_util.cpp's report of a +# missing xelatex. +def _tool(name): + path = shutil.which(name) + if path is None: + raise Exception( + f'The "{name}" command required to use a PDF file as an image is not ' + 'installed. It is part of poppler: on Ubuntu "sudo apt-get install ' + 'poppler-utils", with Homebrew "brew install poppler", on Windows ' + 'MSYS2\'s "pacman -S mingw-w64-ucrt-x86_64-poppler" and ' + 'C:\\msys64\\ucrt64\\bin on the Path') + return path def dimensions_from_prolog(filename): mediabox_pat = re.compile(r"/MediaBox\s+\[(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\]", re.S) @@ -30,9 +47,9 @@ def dimensions_from_prolog(filename): def dimensions_from_pdfinfo(filename): sp = subprocess.Popen( - ["/usr/bin/pdfinfo", filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + [_tool("pdfinfo"), filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = sp.communicate() - out = out.decode("ascii") + out = out.decode("ascii", errors="replace") width, height = None, None if out: pat = re.compile(r"Page size:\s+([\d.]+) x ([\d.]+) pts", re.S) @@ -52,14 +69,19 @@ def dimensions_from_pdfinfo(filename): def dimensions(filename): + # The prolog scan answers for a PDF whose MediaBox is in its first + # lines; a compressed or binary prolog either raises (a non-ASCII byte) + # or answers None, and EITHER is pdfinfo's turn -- the None case once + # fell straight through to the error below without asking it. width, height = None, None try: width, height = dimensions_from_prolog(filename) - except: - if width is None: - width, height = dimensions_from_pdfinfo(filename) - if width is None: - raise Exception(f'Could not determine the width and height of "{filename}"') + except Exception: + pass + if width is None or height is None: + width, height = dimensions_from_pdfinfo(filename) + if width is None or height is None: + raise Exception(f'Could not determine the width and height of "{filename}"') return width, height @@ -71,11 +93,12 @@ def convert(pdf_filename, image_filename): else: raise Exception(f'PDF file "{pdf_filename}" can only be converted to PNG or JPEG') arg2 = "-singlefile" - command = "/usr/bin/pdftoppm" + command = _tool("pdftoppm") basename = os.path.splitext(image_filename)[0] sp = subprocess.Popen( [command, arg1, arg2, pdf_filename, basename], stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = sp.communicate() - if err: - raise Exception(f'Error creating image file from "{pdf_filename}": {err}') + if sp.returncode != 0 or not os.path.exists(image_filename): + raise Exception(f'Error creating image file from "{pdf_filename}": ' + f'{err.decode("utf-8", errors="replace").strip()}')