diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index c5bd4ec6c..93d0881d4 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -39,7 +39,6 @@ ARG RUNTIME_PACKAGES="\
ghostscript \
gnupg \
icc-profiles-free \
- imagemagick \
# PostgreSQL
postgresql-client \
# MySQL / MariaDB
@@ -93,21 +92,6 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
-# setup docker-specific things
-# These change sometimes, but rarely
-WORKDIR /usr/src/paperless/src/docker/
-
-COPY [ \
- "docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
- "./" \
-]
-
-RUN set -eux \
- && echo "Configuring ImageMagick" \
- && mkdir -p /etc/ImageMagick-6 \
- && mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
-
-
# Packages needed only for building a few quick Python
# dependencies
ARG BUILD_PACKAGES="\
diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml
index 331168ba2..f24308639 100644
--- a/.github/workflows/ci-backend.yml
+++ b/.github/workflows/ci-backend.yml
@@ -111,10 +111,7 @@ jobs:
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
- packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils qpdf
- - name: Configure ImageMagick
- run: |
- sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml
+ packages: unpaper tesseract-ocr ghostscript poppler-utils qpdf
- name: Install Python dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
diff --git a/Dockerfile b/Dockerfile
index 50a15aa81..83108bf79 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -129,7 +129,6 @@ ARG RUNTIME_PACKAGES="\
ghostscript \
gnupg \
icc-profiles-free \
- imagemagick \
# PostgreSQL
postgresql-client \
# MySQL / MariaDB
@@ -167,8 +166,6 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
- && echo "Configuring imagemagick" \
- && cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
diff --git a/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml b/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml
deleted file mode 100644
index 7943ca56e..000000000
--- a/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml
+++ /dev/null
@@ -1,101 +0,0 @@
-
-
-
-
-
-]>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/configuration.md b/docs/configuration.md
index 80aecaa60..2e82b4edf 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1873,7 +1873,10 @@ the literal path for that program.
#### [`PAPERLESS_CONVERT_BINARY=`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
-: Defaults to "convert".
+!!! warning
+
+ Deprecated and has no effect, since Paperless-ngx no longer uses
+ ImageMagick. It will be removed in a future release.
#### [`PAPERLESS_GS_BINARY=`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
diff --git a/docs/setup.md b/docs/setup.md
index 514cf424c..d3905f50d 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -177,7 +177,6 @@ to a positive number to enable polling and disable native filesystem notificatio
- `pkg-config` for mysqlclient (python dependency)
- `fonts-liberation` for generating thumbnails for plain text
files
- - `imagemagick` >= 6 for image alpha handling
- `gnupg` for decrypting GPG-encrypted email
- `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection
@@ -187,7 +186,7 @@ to a positive number to enable polling and disable native filesystem notificatio
Use this list for your preferred package management:
```
- python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
+ python3 python3-pip python3-dev fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
```
These dependencies are required for OCRmyPDF, which is used for text
@@ -416,13 +415,6 @@ to a positive number to enable polling and disable native filesystem notificatio
You may need to change the path in the files. Example:
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
-12. Harden ImageMagick by disabling formats that Paperless-ngx does not use.
- PDF processing is not needed and should stay disabled.
-
- Configure the active ImageMagick policy file (commonly
- `/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
- adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
-
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
software has been patented until around 2017 and binary packages are not available for most distributions.
diff --git a/paperless.conf.example b/paperless.conf.example
index 740bc9429..216fc7b16 100644
--- a/paperless.conf.example
+++ b/paperless.conf.example
@@ -88,5 +88,4 @@ PAPERLESS_SECRET_KEY=change-me
# Binaries
-#PAPERLESS_CONVERT_BINARY=/usr/bin/convert
#PAPERLESS_GS_BINARY=/usr/bin/gs
diff --git a/src/documents/converters.py b/src/documents/converters.py
index fcd0a8857..76f518d7e 100644
--- a/src/documents/converters.py
+++ b/src/documents/converters.py
@@ -1,12 +1,11 @@
from pathlib import Path
import img2pdf
-from django.conf import settings
from PIL import Image
from documents.utils import copy_basic_file_stats
from documents.utils import maybe_override_pixel_limit
-from documents.utils import run_subprocess
+from documents.utils import remove_alpha_channel
def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
@@ -27,15 +26,7 @@ def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
# Note the save into the temp folder, so as not to trigger a new
# consume
scratch_image = target_directory / tiff_path.name
- run_subprocess(
- [
- settings.CONVERT_BINARY,
- "-alpha",
- "off",
- tiff_path,
- scratch_image,
- ],
- )
+ remove_alpha_channel(tiff_path, scratch_image)
else:
# Not modifying the original, safe to use in place
scratch_image = tiff_path
diff --git a/src/documents/tests/test_utils.py b/src/documents/tests/test_utils.py
index af45880ee..d34a8f8a7 100644
--- a/src/documents/tests/test_utils.py
+++ b/src/documents/tests/test_utils.py
@@ -1,6 +1,11 @@
+from pathlib import Path
+
+import pytest
import pytest_mock
+from PIL import Image
from documents.utils import QuerySetStream
+from documents.utils import remove_alpha_channel
class TestQuerySetStream:
@@ -33,3 +38,99 @@ class TestQuerySetStream:
# the thing being verified here.
mock_queryset.count.assert_called_with()
mock_queryset.iterator.assert_called_once_with(chunk_size=1000)
+
+
+class TestRemoveAlphaChannel:
+ @pytest.mark.parametrize(
+ ("image_format", "mode", "expected_mode", "color", "expected_pixel"),
+ [
+ pytest.param(
+ "PNG",
+ "RGBA",
+ "RGB",
+ (10, 20, 30, 0),
+ (10, 20, 30),
+ id="png-rgba",
+ ),
+ pytest.param(
+ "TIFF",
+ "RGBA",
+ "RGB",
+ (10, 20, 30, 128),
+ (10, 20, 30),
+ id="tiff-rgba",
+ ),
+ pytest.param("PNG", "LA", "L", (40, 0), 40, id="png-la"),
+ ],
+ )
+ def test_drops_alpha_and_keeps_format(
+ self,
+ tmp_path: Path,
+ image_format: str,
+ mode: str,
+ expected_mode: str,
+ color: tuple[int, ...],
+ expected_pixel: int | tuple[int, ...],
+ ) -> None:
+ """
+ GIVEN:
+ - An image with an alpha channel
+ WHEN:
+ - The alpha channel is removed to a destination without an extension
+ THEN:
+ - The destination has the matching opaque mode, the same size and
+ format, and the color values are kept rather than composited
+ """
+ source = tmp_path / "source"
+ Image.new(mode, (8, 6), color).save(source, format=image_format)
+ dest = tmp_path / "no-alpha"
+
+ result = remove_alpha_channel(source, dest)
+
+ assert result == dest
+ with Image.open(dest) as out:
+ assert out.mode == expected_mode
+ assert out.size == (8, 6)
+ assert out.format == image_format
+ assert out.getpixel((0, 0)) == expected_pixel
+
+ def test_keeps_every_frame_of_a_multi_page_tiff(self, tmp_path: Path) -> None:
+ """
+ GIVEN:
+ - A multi-page TIFF where every page has an alpha channel
+ WHEN:
+ - The alpha channel is removed
+ THEN:
+ - All pages are kept and none has an alpha channel
+ """
+ source = tmp_path / "multi.tiff"
+ first = Image.new("RGBA", (8, 6), (255, 0, 0, 50))
+ rest = [Image.new("RGBA", (8, 6), (0, 255, 0, 50)) for _ in range(2)]
+ first.save(source, save_all=True, append_images=rest)
+ dest = tmp_path / "multi-no-alpha.tiff"
+
+ remove_alpha_channel(source, dest)
+
+ with Image.open(dest) as out:
+ assert out.n_frames == 3
+ for index in range(out.n_frames):
+ out.seek(index)
+ assert out.mode == "RGB"
+
+ def test_keeps_dpi(self, tmp_path: Path) -> None:
+ """
+ GIVEN:
+ - An alpha image with a DPI set
+ WHEN:
+ - The alpha channel is removed
+ THEN:
+ - The DPI is unchanged, since OCR scaling depends on it
+ """
+ source = tmp_path / "source.png"
+ Image.new("RGBA", (8, 6)).save(source, dpi=(300, 300))
+ dest = tmp_path / "dest.png"
+
+ remove_alpha_channel(source, dest)
+
+ with Image.open(dest) as out:
+ assert round(out.info["dpi"][0]) == 300
diff --git a/src/documents/utils.py b/src/documents/utils.py
index d6e505aa4..e415aa72f 100644
--- a/src/documents/utils.py
+++ b/src/documents/utils.py
@@ -9,11 +9,13 @@ from pathlib import Path
from subprocess import CompletedProcess
from subprocess import run
from typing import TYPE_CHECKING
+from typing import Any
from typing import Generic
from typing import TypeVar
from django.conf import settings
from PIL import Image
+from PIL import ImageSequence
if TYPE_CHECKING:
from django.db.models import Model
@@ -128,6 +130,30 @@ def maybe_override_pixel_limit() -> None:
Image.MAX_IMAGE_PIXELS = pixel_count
+def remove_alpha_channel(source: Path, dest: Path) -> Path:
+ """
+ Writes a copy of the image with its alpha channel dropped, keeping the
+ format, every frame, the DPI and any ICC profile.
+
+ The channel is dropped, not composited, so transparent pixels keep their
+ underlying color. Returns dest.
+ """
+ with Image.open(source) as im:
+ image_format = im.format
+ opaque_mode = "L" if im.mode == "LA" else "RGB"
+ frames = [frame.convert(opaque_mode) for frame in ImageSequence.Iterator(im)]
+ save_kwargs: dict[str, Any] = {}
+ if "dpi" in im.info:
+ save_kwargs["dpi"] = im.info["dpi"]
+ if "icc_profile" in im.info:
+ save_kwargs["icc_profile"] = im.info["icc_profile"]
+ if len(frames) > 1:
+ save_kwargs["save_all"] = True
+ save_kwargs["append_images"] = frames[1:]
+ frames[0].save(dest, format=image_format, **save_kwargs)
+ return dest
+
+
def run_subprocess(
arguments: list[str],
env: dict[str, str] | None = None,
diff --git a/src/paperless/checks.py b/src/paperless/checks.py
index 16bbff55e..c96bd517f 100644
--- a/src/paperless/checks.py
+++ b/src/paperless/checks.py
@@ -82,7 +82,7 @@ def binaries_check(app_configs: Any, **kwargs: Any) -> list[Error]:
error = "Paperless can't find {}. Without it, consumption is impossible."
hint = "Either it's not in your ${PATH} or it's not installed."
- binaries = (settings.CONVERT_BINARY, "tesseract", "gs")
+ binaries = ("tesseract", "gs")
check_messages = []
for binary in binaries:
diff --git a/src/paperless/parsers/tesseract.py b/src/paperless/parsers/tesseract.py
index 1091dffda..5a3b77f88 100644
--- a/src/paperless/parsers/tesseract.py
+++ b/src/paperless/parsers/tesseract.py
@@ -19,7 +19,7 @@ from documents.parsers import ParseError
from documents.parsers import make_thumbnail_from_pdf
from documents.utils import copy_file_with_basic_stats
from documents.utils import maybe_override_pixel_limit
-from documents.utils import run_subprocess
+from documents.utils import remove_alpha_channel
from paperless.config import OcrConfig
from paperless.models import CleanChoices
from paperless.models import ModeChoices
@@ -197,18 +197,7 @@ class RasterisedDocumentParser:
return im.mode in ("RGBA", "LA")
def remove_alpha(self, image_path: Path) -> Path:
- no_alpha_image = Path(self.tempdir) / "image-no-alpha"
- run_subprocess(
- [
- settings.CONVERT_BINARY,
- "-alpha",
- "off",
- str(image_path),
- str(no_alpha_image),
- ],
- logger=self.log,
- )
- return no_alpha_image
+ return remove_alpha_channel(image_path, Path(self.tempdir) / "image-no-alpha")
def get_dpi(self, image: Path) -> int | None:
try:
diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py
index 7a5ce30b9..5979547f9 100644
--- a/src/paperless/settings/__init__.py
+++ b/src/paperless/settings/__init__.py
@@ -984,9 +984,6 @@ MAX_IMAGE_PIXELS: Final[int | None] = get_int_from_env(
# GNUPG needs a home directory for some reason
GNUPG_HOME = os.getenv("HOME", "/tmp")
-# Convert is part of the ImageMagick package
-CONVERT_BINARY = os.getenv("PAPERLESS_CONVERT_BINARY", "convert")
-
GS_BINARY = os.getenv("PAPERLESS_GS_BINARY", "gs")
# Fallback layout for .eml consumption
diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py
index 4d8a5bfa6..aa09f0413 100644
--- a/src/paperless/tests/test_checks.py
+++ b/src/paperless/tests/test_checks.py
@@ -25,9 +25,17 @@ class TestChecks:
def test_binaries(self) -> None:
assert binaries_check(None) == []
- def test_binaries_fail(self, settings: Settings) -> None:
- settings.CONVERT_BINARY = "uuuhh"
- assert len(binaries_check(None)) == 1
+ def test_binaries_fail(self, mocker: MockerFixture) -> None:
+ """
+ GIVEN:
+ - Neither tesseract nor ghostscript can be found
+ WHEN:
+ - The binaries check runs
+ THEN:
+ - One warning is raised per missing binary
+ """
+ mocker.patch("paperless.checks.shutil.which", return_value=None)
+ assert len(binaries_check(None)) == 2
@pytest.mark.usefixtures("paperless_dirs")
def test_paths_check(self) -> None: