diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e0d1a93cd2c81c59a81b40fb1217d0e99a6b5904..116019ee190b6e294059cf14c3b15135bab57864 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -14,17 +14,22 @@ Added
 
 * Notification drawer in top nav bar
 * GraphQL queries and mutations for core data management
+* [Dev] Provide plain PDF template without header/footer for special layouts.
 * Show also group ownerships on person detail page
 
 Changed
 ~~~~~~~
 
 * Rewrite of frontend using Vuetify
+* [Dev] Provide function to generate PDF files from fully-rendered templates.
 * OIDC scope "profile" now exposes the avatar instead of the official photo
 
 Fixed
 ~~~~~
 
+* The logo in the PDF files was displayed at the wrong position.
+* Sometimes the PDF files were not generated correctly
+  and images were displayed only partially.
 * Error message in permission form was misleading.
 
 Removed
diff --git a/Dockerfile b/Dockerfile
index df38046b1aff30d14a480603c80d5d2987cf4cdd..914ee330f0d7e6646ba62c4d630747501b72d4f4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,6 +30,7 @@ RUN apt-get -y update && \
     eatmydata apt-get install -y --no-install-recommends \
         build-essential \
         chromium \
+        chromium-driver \
         curl \
 	dumb-init \
 	gettext \
diff --git a/aleksis/core/static/print-simple.css b/aleksis/core/static/print-simple.css
new file mode 100644
index 0000000000000000000000000000000000000000..f0e6536b4d835b67ab0d519de13c561953be4ea5
--- /dev/null
+++ b/aleksis/core/static/print-simple.css
@@ -0,0 +1,21 @@
+@page {
+    padding: 0;
+    margin: 0;
+}
+
+table.small-print, td.small-print, th.small-print {
+    font-size: 10pt;
+}
+
+tr {
+    border-bottom: 1px solid rgba(0, 0, 0, 0.3);
+}
+
+td, th {
+    padding: 1px;
+}
+
+td.rotate, th.rotate {
+    text-align: center;
+    transform: rotate(-90deg);
+}
diff --git a/aleksis/core/static/print.css b/aleksis/core/static/print.css
index cda82eacab1d51a39f7f455170eb6ff12121dc30..dad3abb5967e84b014769240ce000d33ced9f014 100644
--- a/aleksis/core/static/print.css
+++ b/aleksis/core/static/print.css
@@ -67,9 +67,10 @@ header .row, header .col {
 }
 
 #print-logo {
-    padding: 2mm;
     height: 22mm;
     width: auto;
+    margin-block: 0;
+    padding: 2mm 2mm 2mm 0;
 }
 
 .page-break {
diff --git a/aleksis/core/templates/core/base_simple_print.html b/aleksis/core/templates/core/base_simple_print.html
new file mode 100644
index 0000000000000000000000000000000000000000..6e66e28983679acebdd2df0993ae7b5fdf64e621
--- /dev/null
+++ b/aleksis/core/templates/core/base_simple_print.html
@@ -0,0 +1,52 @@
+{% load static i18n any_js sass_tags %}
+{% get_current_language as LANGUAGE_CODE %}
+
+<!DOCTYPE html>
+<html lang="{{ LANGUAGE_CODE }}">
+<head>
+  {% include "core/partials/meta.html" %}
+
+  <title>
+    {% block no_browser_title %}
+      {% block browser_title %}{% endblock %} —
+    {% endblock %}
+    {{ SITE_PREFERENCES.general__title }}
+  </title>
+
+  {% include_css "material-design-icons" %}
+  {% include_css "Roboto100" %}
+  {% include_css "Roboto300" %}
+  {% include_css "Roboto400" %}
+  {% include_css "Roboto500" %}
+  {% include_css "Roboto700" %}
+  {% include_css "Roboto900" %}
+  {% include_css "paper-css" %}
+  <link rel="stylesheet" href="{% sass_src 'public/style.scss' %}"/>
+  <link rel="stylesheet" href="{% static "print-simple.css" %}"/>
+
+  {% block size %}
+    <style>
+      @page {
+        size: {{ width }}mm {{ height }}mm;
+      }
+
+      @media print {
+        html, body {
+          width: {{ width }}mm;
+        }
+      }
+
+      .sheet {
+        width: {{ width }}mm;
+        height: {{ height|add:-1 }}.83mm;
+      } 
+    </style>
+  {% endblock %}
+
+  {% block extra_head %}{% endblock %}
+</head>
+
+<body>
+{% block content %}{% endblock %}
+</body>
+</html>
diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py
index a261017b3b2bb22decea1cc517b414858b14a68c..8721d8c3167ba8a01fd250a0967f56af495bdf78 100644
--- a/aleksis/core/util/pdf.py
+++ b/aleksis/core/util/pdf.py
@@ -1,8 +1,9 @@
+import base64
 import os
 import subprocess  # noqa
 from datetime import timedelta
 from tempfile import TemporaryDirectory
-from typing import Optional, Tuple, Union
+from typing import Callable, Optional, Tuple, Union
 from urllib.parse import urljoin
 
 from django.conf import settings
@@ -19,6 +20,7 @@ from django.utils.translation import gettext as _
 
 from celery.result import AsyncResult
 from celery_progress.backend import ProgressRecorder
+from selenium import webdriver
 
 from aleksis.core.celery import app
 from aleksis.core.models import PDFFile
@@ -26,6 +28,29 @@ from aleksis.core.util.celery_progress import recorded_task, render_progress_pag
 from aleksis.core.util.core_helpers import process_custom_context_processors
 
 
+def _generate_pdf_with_chromium(temp_dir, pdf_path, html_url, lang):
+    """Generate a PDF file from a HTML file."""
+    chrome_options = webdriver.ChromeOptions()
+    chrome_options.add_argument("--kiosk-printing")
+    chrome_options.add_argument("--headless")
+    chrome_options.add_argument("--no-sandbox")
+    chrome_options.add_argument("--disable-gpu")
+    chrome_options.add_argument("--disable-dev-shm-usage")
+    chrome_options.add_argument("--disable-setuid-sandbox")
+    chrome_options.add_argument("--dbus-stub")
+    chrome_options.add_argument("--temp-profile")
+    chrome_options.add_argument(f"--lang={lang}")
+
+    driver = webdriver.Chrome(options=chrome_options)
+    driver.get(html_url)
+    pdf = driver.execute_cdp_cmd(
+        "Page.printToPDF", {"printBackground": True, "preferCSSPageSize": True}
+    )
+    driver.close()
+    with open(pdf_path, "wb") as f:
+        f.write(base64.b64decode(pdf["data"]))
+
+
 @recorded_task
 def generate_pdf(
     file_pk: int, html_url: str, recorder: ProgressRecorder, lang: Optional[str] = None
@@ -40,26 +65,7 @@ def generate_pdf(
         pdf_path = os.path.join(temp_dir, "print.pdf")
         lang = lang or get_language()
 
-        # Run PDF generation using a headless Chromium
-        cmd = [
-            "chromium",
-            "--headless",
-            "--no-sandbox",
-            "--run-all-compositor-stages-before-draw",
-            "--temp-profile",
-            "--disable-dev-shm-usage",
-            "--disable-gpu",
-            "--disable-setuid-sandbox",
-            "--dbus-stub",
-            f"--home-dir={temp_dir}",
-            f"--lang={lang}",
-            f"--print-to-pdf={pdf_path}",
-            html_url,
-        ]
-        res = subprocess.run(cmd)  # noqa
-
-        # Let the task fail on a non-success return code
-        res.check_returncode()
+        _generate_pdf_with_chromium(temp_dir, pdf_path, html_url, lang)
 
         # Upload PDF file to media storage
         with open(pdf_path, "rb") as f:
@@ -69,10 +75,8 @@ def generate_pdf(
     recorder.set_progress(1, 1)
 
 
-def generate_pdf_from_template(
-    template_name: str, context: Optional[dict] = None, request: Optional[HttpRequest] = None
-) -> Tuple[PDFFile, AsyncResult]:
-    """Start a PDF generation task and return the matching file object and Celery result."""
+def process_context_for_pdf(context: Optional[dict] = None, request: Optional[HttpRequest] = None):
+    context = context or {}
     if not request:
         processed_context = process_custom_context_processors(
             settings.NON_REQUEST_CONTEXT_PROCESSORS
@@ -80,11 +84,14 @@ def generate_pdf_from_template(
         processed_context.update(context)
     else:
         processed_context = context
-    html_template = render_to_string(template_name, processed_context, request)
+    return processed_context
 
-    file_object = PDFFile.objects.create(
-        html_file=ContentFile(html_template.encode(), name="source.html")
-    )
+
+def generate_pdf_from_html(
+    html: str, request: Optional[HttpRequest] = None
+) -> Tuple[PDFFile, AsyncResult]:
+    """Start a PDF generation task and return the matching file object and Celery result."""
+    file_object = PDFFile.objects.create(html_file=ContentFile(html.encode(), name="source.html"))
 
     # As this method may be run in background and there is no request available,
     # we have to use a predefined URL from settings then
@@ -98,6 +105,23 @@ def generate_pdf_from_template(
     return file_object, result
 
 
+def generate_pdf_from_template(
+    template_name: str,
+    context: Optional[dict] = None,
+    request: Optional[HttpRequest] = None,
+    render_method: Optional[Callable] = None,
+) -> Tuple[PDFFile, AsyncResult]:
+    """Start a PDF generation task and return the matching file object and Celery result."""
+    processed_context = process_context_for_pdf(context, request)
+
+    if render_method:
+        html_template = render_method(processed_context, request)
+    else:
+        html_template = render_to_string(template_name, processed_context, request)
+
+    return generate_pdf_from_html(html_template, request)
+
+
 def render_pdf(
     request: Union[HttpRequest, None], template_name: str, context: dict = None
 ) -> HttpResponse:
diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst
index 3f350d5f9199ac265b6be906ad284a20705a5bf9..ee46193d93282c9e44016b3b0ebf8e1f1b9c4b36 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -53,6 +53,7 @@ Install some packages from the Debian package system.
                yarnpkg \
                python3-virtualenv \
                chromium \
+               chromium-driver \
                redis-server \
                postgresql \
                locales-all \
diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst
index 75cf677a7f43703dc5e6a389e97be28fcf813a02..1b82cf5e35c78585ee01b101120ceaff8fa19305 100644
--- a/docs/dev/01_setup.rst
+++ b/docs/dev/01_setup.rst
@@ -47,7 +47,7 @@ Install native dependencies
 
 Some system libraries are required to install AlekSIS. On Debian, for example, this would be done with::
 
-  sudo apt install build-essential libpq-dev libpq5 libssl-dev python3-dev python3-pip python3-venv yarnpkg gettext chromium
+  sudo apt install build-essential libpq-dev libpq5 libssl-dev python3-dev python3-pip python3-venv yarnpkg gettext chromium chromium-driver
 
 Get Poetry
 ----------
diff --git a/pyproject.toml b/pyproject.toml
index 8768704eba7cab5cc3b1c57eae6fcb97d900560c..b5349b77fb39aa4332e18660b86a63de540ade9e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -130,6 +130,7 @@ django-iconify = "^0.3"
 customidenticon = "^0.1.5"
 graphene-django = "^3.0.0"
 django-webpack-loader = "^1.6.0"
+selenium = "^4.4.3"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]