Images: a PDF figure finds poppler, the lookup admits image files only, a missing image is an error
Assembled from development commit 8ed24ef03b36. Since ee8bcf5: a PDF file used
as an image finds poppler's pdfinfo and pdftoppm on the PATH instead of
/usr/bin, and says which package to install when they are missing (the
Linux, macOS and Windows pages name it); the image lookup considers
image files only, so a source or log file beside a figure is never taken
for it; and an @image whose file cannot be found is an error naming the
basename and the search path, instead of a silent red placeholder. Two
test suites that had relied on that placeholder now supply a real image;
a new suite covers the PDF figure.
This commit is contained in:
1 parent
ee8bcf5300
commit
1b00738654
7 files changed
+77
-41
No files matched your search
@@ -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
|
Report problems (or send patches) to the author; accepted changes are
|
||||||
applied to the development tree and appear in a following snapshot.
|
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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ sudo apt-get install python3-openimageio
|
|||||||
python3 -c "import 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
|
(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
|
`python3-pip` is installed, and since 24.04 it refuses to install into the
|
||||||
system Python without `--break-system-packages`; the package avoids both.)
|
system Python without `--break-system-packages`; the package avoids both.)
|
||||||
|
|||||||
@@ -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
|
managed". (Homebrew alternative: `brew install openimageio`, heavier — it pulls
|
||||||
ffmpeg/openexr/etc.)
|
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
|
## 3. Clone and configure the environment
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ python -m pip install OpenImageIO
|
|||||||
python -c "import 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
|
Note that the interpreter is called `python` on Windows. There is no
|
||||||
`python3`; the `python3` that Windows itself offers is a placeholder that
|
`python3`; the `python3` that Windows itself offers is a placeholder that
|
||||||
opens the Microsoft Store, and Klammertext does not use it.
|
opens the Microsoft Store, and Klammertext does not use it.
|
||||||
|
|||||||
+2
-12
@@ -29,21 +29,16 @@ class Image(klammer_base.Klammer_base):
|
|||||||
self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False,
|
self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False,
|
||||||
quality=self.Image_quality)
|
quality=self.Image_quality)
|
||||||
self.basename = klammer_base.unescape_ktesc(self.basename)
|
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)
|
self.source, self.pwidth, self.pheight = self.cache.get(self.K_target, self.basename)
|
||||||
if self.file_error:
|
|
||||||
self.file_error_message = f'\nERROR: Image "{self.basename}" not found'
|
|
||||||
self.as_string = as_string
|
self.as_string = as_string
|
||||||
if width:
|
if width:
|
||||||
self.width = width
|
self.width = width
|
||||||
self.rel_image = None
|
self.rel_image = None
|
||||||
self.rel_fraction = 1.0
|
self.rel_fraction = 1.0
|
||||||
if self.rel:
|
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
|
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):
|
def html(self):
|
||||||
img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}"
|
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:
|
if self.border:
|
||||||
result.cls("image_border")
|
result.cls("image_border")
|
||||||
|
|
||||||
if self.file_error:
|
|
||||||
self.caption += self.file_error_message
|
|
||||||
|
|
||||||
result = html_util.add_caption(
|
result = html_util.add_caption(
|
||||||
result, "Figure", self.number, self.caption, self.caption_style,
|
result, "Figure", self.number, self.caption, self.caption_style,
|
||||||
self.hpos, self.caption_side, False, self.caption_size,
|
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}}}'
|
#result = f'\\includegraphics[width={width}]{{{source}}}'
|
||||||
if self.number or self.caption:
|
if self.number or self.caption:
|
||||||
result = f'\\includegraphics[width=\\textwidth]{{{source}}}'
|
result = f'\\includegraphics[width=\\textwidth]{{{source}}}'
|
||||||
if self.file_error:
|
|
||||||
self.caption += self.file_error_message
|
|
||||||
result = latex_util.add_caption(
|
result = latex_util.add_caption(
|
||||||
result, "Figure", self.number, self.caption, width,
|
result, "Figure", self.number, self.caption, width,
|
||||||
self.hpos, self.caption_side, offset=self.offset)
|
self.hpos, self.caption_side, offset=self.offset)
|
||||||
|
|||||||
+23
-19
@@ -45,6 +45,14 @@ def image_dimensions(filename):
|
|||||||
# html takes them).
|
# html takes them).
|
||||||
photograph_formats = {"jpg", "jpeg", "png", "tif", "tiff"}
|
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):
|
def is_photograph(filename):
|
||||||
"""True if the file is a raster with no alpha channel: a photograph
|
"""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,
|
(or a grayscale one) that JPEG stores well. A png with transparency,
|
||||||
@@ -63,7 +71,6 @@ def is_photograph(filename):
|
|||||||
class Image_cache:
|
class Image_cache:
|
||||||
max_width = 1600 # Should be a state variable
|
max_width = 1600 # Should be a state variable
|
||||||
cache_basedir = "_klammertext_imgsrc" # 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
|
verbose_label = " [klammertext]" # Should parameterize verbosity
|
||||||
def __init__(self, cache_dir, search_path, verbose=False, quality=100):
|
def __init__(self, cache_dir, search_path, verbose=False, quality=100):
|
||||||
#self.cache_dir = kutil.cache_directory(
|
#self.cache_dir = kutil.cache_directory(
|
||||||
@@ -89,17 +96,10 @@ class Image_cache:
|
|||||||
"pdf" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"],
|
"pdf" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"],
|
||||||
"tex" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"],
|
"tex" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"],
|
||||||
"latex" : ["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):
|
if not os.path.exists(self.dir):
|
||||||
os.makedirs(self.dir)
|
os.makedirs(self.dir)
|
||||||
self.search = self.search + [f"{os.path.dirname(cache_dir)}/imgsrc"]
|
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):
|
def construct_cache_filename(self, target, filename):
|
||||||
basename, ext = os.path.splitext(filename)
|
basename, ext = os.path.splitext(filename)
|
||||||
required = self.required_format[target]
|
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."""
|
"""Whether this source is written to the cache as a quality-N JPEG."""
|
||||||
return self.quality < 100 \
|
return self.quality < 100 \
|
||||||
and target in self.required_format \
|
and target in self.required_format \
|
||||||
and source_filename != self.error_image_filename \
|
|
||||||
and is_photograph(source_filename)
|
and is_photograph(source_filename)
|
||||||
|
|
||||||
def find_file(self, target, basename):
|
def find_file(self, target, basename):
|
||||||
@@ -128,6 +127,8 @@ class Image_cache:
|
|||||||
matches = []
|
matches = []
|
||||||
for path in self.search:
|
for path in self.search:
|
||||||
candidates += glob.glob(f"{path}/{basename}.*")
|
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)
|
candidates = sorted(candidates, key=lambda p: 0 if p.endswith(".png") else 1)
|
||||||
if False:
|
if False:
|
||||||
print("Candidates:")
|
print("Candidates:")
|
||||||
@@ -142,10 +143,14 @@ class Image_cache:
|
|||||||
if not matches:
|
if not matches:
|
||||||
result = candidates[0]
|
result = candidates[0]
|
||||||
else:
|
else:
|
||||||
kutil.msg(kutil.red_text(f'No image with basename "{basename}" found in search path:'))
|
# An error, as any other missing input is: a document with an
|
||||||
kutil.msg(kutil.red_text(" | " + " | ".join(self.search) + " | "))
|
# image that is not there does not render. (Until 2026-09-25
|
||||||
self.make_error_marker()
|
# this substituted a red marker image and printed a message --
|
||||||
matches = [self.error_image_filename]
|
# 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]
|
result = matches[0] if matches else candidates[0]
|
||||||
"""
|
"""
|
||||||
@@ -238,10 +243,10 @@ class Image_cache:
|
|||||||
else:
|
else:
|
||||||
raise Exception(buf.geterror())
|
raise Exception(buf.geterror())
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(f'Cannot convert "{source_filename}" to '
|
# Raised, not printed and exited: the print would go to the
|
||||||
f'"{os.path.splitext(cached_filename)[-1][1:]}":')
|
# captured stdout and say nothing; the error report says it.
|
||||||
print(" ", err)
|
raise Exception(f'Cannot convert "{source_filename}" to '
|
||||||
sys.exit(1)
|
f'"{os.path.splitext(cached_filename)[-1][1:]}": {err}')
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
self.describe_caching(source_filename, cached_filename, copied,
|
self.describe_caching(source_filename, cached_filename, copied,
|
||||||
@@ -265,8 +270,7 @@ class Image_cache:
|
|||||||
base = os.path.basename(cached_filename)
|
base = os.path.basename(cached_filename)
|
||||||
#print(f"{Image_cache.verbose_label} From cache: {base} [{width}x{height}]")
|
#print(f"{Image_cache.verbose_label} From cache: {base} [{width}x{height}]")
|
||||||
kutil.msg("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
|
||||||
return cached_filename, width, height, cached_filename == Image_cache.error_marker_filename
|
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
shutil.rmtree(self.dir)
|
shutil.rmtree(self.dir)
|
||||||
|
|||||||
+32
-9
@@ -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):
|
def dimensions_from_prolog(filename):
|
||||||
mediabox_pat = re.compile(r"/MediaBox\s+\[(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\]", re.S)
|
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):
|
def dimensions_from_pdfinfo(filename):
|
||||||
sp = subprocess.Popen(
|
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, err = sp.communicate()
|
||||||
out = out.decode("ascii")
|
out = out.decode("ascii", errors="replace")
|
||||||
width, height = None, None
|
width, height = None, None
|
||||||
if out:
|
if out:
|
||||||
pat = re.compile(r"Page size:\s+([\d.]+) x ([\d.]+) pts", re.S)
|
pat = re.compile(r"Page size:\s+([\d.]+) x ([\d.]+) pts", re.S)
|
||||||
@@ -52,13 +69,18 @@ def dimensions_from_pdfinfo(filename):
|
|||||||
|
|
||||||
|
|
||||||
def dimensions(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
|
width, height = None, None
|
||||||
try:
|
try:
|
||||||
width, height = dimensions_from_prolog(filename)
|
width, height = dimensions_from_prolog(filename)
|
||||||
except:
|
except Exception:
|
||||||
if width is None:
|
pass
|
||||||
|
if width is None or height is None:
|
||||||
width, height = dimensions_from_pdfinfo(filename)
|
width, height = dimensions_from_pdfinfo(filename)
|
||||||
if width is None:
|
if width is None or height is None:
|
||||||
raise Exception(f'Could not determine the width and height of "{filename}"')
|
raise Exception(f'Could not determine the width and height of "{filename}"')
|
||||||
return width, height
|
return width, height
|
||||||
|
|
||||||
@@ -71,11 +93,12 @@ def convert(pdf_filename, image_filename):
|
|||||||
else:
|
else:
|
||||||
raise Exception(f'PDF file "{pdf_filename}" can only be converted to PNG or JPEG')
|
raise Exception(f'PDF file "{pdf_filename}" can only be converted to PNG or JPEG')
|
||||||
arg2 = "-singlefile"
|
arg2 = "-singlefile"
|
||||||
command = "/usr/bin/pdftoppm"
|
command = _tool("pdftoppm")
|
||||||
basename = os.path.splitext(image_filename)[0]
|
basename = os.path.splitext(image_filename)[0]
|
||||||
sp = subprocess.Popen(
|
sp = subprocess.Popen(
|
||||||
[command, arg1, arg2, pdf_filename, basename],
|
[command, arg1, arg2, pdf_filename, basename],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
out, err = sp.communicate()
|
out, err = sp.communicate()
|
||||||
if err:
|
if sp.returncode != 0 or not os.path.exists(image_filename):
|
||||||
raise Exception(f'Error creating image file from "{pdf_filename}": {err}')
|
raise Exception(f'Error creating image file from "{pdf_filename}": '
|
||||||
|
f'{err.decode("utf-8", errors="replace").strip()}')
|
||||||
Reference in new issue
Block a user