diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c59e90b..bcc5afe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,7 +27,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- package: [integrations, auth, lti]
+ package: [integrations, auth, lti, pdf]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -35,7 +35,8 @@ jobs:
python-version: "3.12"
cache: pip
cache-dependency-path: packages/django/basicbar-${{ matrix.package }}/pyproject.toml
- - run: pip install --quiet ./packages/django/basicbar-${{ matrix.package }}
+ # `[test]` pulls test-only dependencies where a package declares them (pdf: pypdf).
+ - run: pip install --quiet "./packages/django/basicbar-${{ matrix.package }}[test]"
- run: cd packages/django/basicbar-${{ matrix.package }} && python runtests.py
ui-build:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2a981b..73bf681 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,24 @@ Changelog ist die Upgrade-Anleitung für die Tools.
## [Unreleased]
+### basicbar-pdf (→ wird `pdf/v0.1.0`)
+
+**Neues Paket: gemeinsamer PDF-Rahmen** für herunterladbare Dokumente,
+herausgelöst aus abstimmbar (`common/pdf.py`, Sitzungsprotokoll) und um das
+erweitert, was kalkulierbar für seine Berichte braucht: A4 quer
+(`landscape=True`), Ränder (`margin_mm`), Autor, Tabellen mit
+`col_widths="auto"`, rechtsbündigen Zahlenspalten (`align_right_from`) und
+fetten Summenzeilen (`strong_rows`). Mitgeliefert wird Plus Jakarta Sans
+(OFL). Abhängigkeit: `reportlab>=4.4,<6` (getestet mit 4.4.4 und 5.1.0),
+kein Django.
+
+Migration (abstimmbar): `common/pdf.py` und `common/fonts/` löschen,
+`from basicbar_pdf import PdfDocument`, dazu `page_label=gettext_lazy("Page
+%(page)s of %(pages)s")` und `image_placeholder=gettext_lazy("[Image]")`
+übergeben (die msgids stehen schon im Katalog). Migration (kalkulierbar):
+`report_render.render_pdf` auf `PdfDocument(landscape=True, margin_mm=15)`
+umstellen; die Schrift wechselt dabei von Helvetica zu Plus Jakarta Sans.
+
### basicbar-integrations (→ wird `integrations/v0.4.0`)
**Neues Modul `metrics`: token-geschützter Prometheus-Endpunkt.**
diff --git a/packages/django/README.md b/packages/django/README.md
index e25e292..3068530 100644
--- a/packages/django/README.md
+++ b/packages/django/README.md
@@ -8,5 +8,6 @@ Wiederverwendbare Django-Apps, als pip-Pakete direkt aus Git installierbar
| `basicbar-integrations` | LibreTranslate-Client, LiteLLM-Client, Translate-Endpunkt, Capabilities-Endpoint | 2 |
| `basicbar-auth` | OIDC-Backend, Discovery, Silent Login, Back-Channel-Logout, `AbstractBasicUser`, optionale Limits/Retention ([ADR-0003](../../docs/ADR/0003-user-model-bleibt-im-tool.md)) | 4 |
| `basicbar-lti` | LTI-1.3-App (aus abstimmbar), per Env aktivierbar | 5 |
+| `basicbar-pdf` | PDF-Rahmen (reportlab, mitgelieferte Schrift, Fußzeile, Editor-HTML); ohne Django-Abhängigkeit | – |
Keine Tool-Fachlogik, keine konkreten User-Models.
diff --git a/packages/django/basicbar-pdf/README.md b/packages/django/basicbar-pdf/README.md
new file mode 100644
index 0000000..2f24028
--- /dev/null
+++ b/packages/django/basicbar-pdf/README.md
@@ -0,0 +1,69 @@
+# basicbar-pdf
+
+Gemeinsamer PDF-Rahmen der „-bar“-Tools für herunterladbare Dokumente
+(Protokolle, Berichte): [reportlab](https://www.reportlab.com/) mit der
+mitgelieferten Schrift **Plus Jakarta Sans** (SIL OFL 1.1, `basicbar_pdf/fonts/OFL.txt`),
+A4 hoch oder quer, Fußzeile „Seite x von y“ auf jeder Seite.
+
+Kein Django nötig: die einzigen sichtbaren Texte des Rahmens (Seitenangabe,
+Bild-Platzhalter) sind Parameter; das Tool übergibt seine Übersetzungen
+(`gettext_lazy` funktioniert, sie werden erst beim Rendern aufgelöst).
+
+## Einbinden
+
+```text
+# requirements.txt
+basicbar-pdf @ https://github.com/virtUOS/basicbar/archive/refs/tags/pdf/v0.1.0.tar.gz#subdirectory=packages/django/basicbar-pdf
+```
+
+```python
+from django.utils.translation import gettext as _, gettext_lazy
+from basicbar_pdf import PdfDocument
+
+doc = PdfDocument(
+ _("Protocol"),
+ footer_left="Fachbereichsrat – 12. Sitzung",
+ page_label=gettext_lazy("Page %(page)s of %(pages)s"),
+ image_placeholder=gettext_lazy("[Image]"),
+)
+doc.heading(_("Attendance"), 2)
+doc.paragraph("…", style="muted") # normal | small | bold | muted
+doc.rich(question_html) # Editor-HTML (TipTap-Teilmenge)
+doc.table(rows, col_widths="auto", align_right_from=1, strong_rows=[len(rows) - 1])
+doc.box(lambda d: d.paragraph("Siegel …"))
+pdf_bytes = doc.render()
+```
+
+Die msgids `"Page %(page)s of %(pages)s"` und `"[Image]"` gehören in den
+Übersetzungskatalog des Tools (Deutsch: „Seite %(page)s von %(pages)s“, „[Bild]“).
+
+## API
+
+`PdfDocument(title, *, footer_left="", author="", landscape=False, margin_mm=20,
+page_label="Page %(page)s of %(pages)s", image_placeholder="[Image]", compress=True)`
+
+| Methode | Zweck |
+|---|---|
+| `heading(text, level=1)` | Überschrift h1–h3 |
+| `paragraph(text, *, style="normal")` | Absatz (Text wird escaped, `\n` = Zeilenumbruch) |
+| `rich(html)` | Editor-HTML: Absätze, fett/kursiv/unterstrichen/durchgestrichen, Listen, h1–h3, Zitate, Links (URL in Klammern), Tabellen/`pre` vereinfacht, Bilder als Platzhalter; unbekannte Tags fallen weg, ihr Text bleibt |
+| `bullets(items)` | Aufzählung |
+| `table(rows, *, header=True, col_widths=None, align_right_from=None, strong_rows=())` | Gitter-Tabelle, Kopfzeile wiederholt sich auf jeder Seite; `col_widths="auto"` misst die Spalten (kein Wort wird getrennt), `align_right_from` setzt Zahlenspalten rechtsbündig, `strong_rows` fettet Zeilen (Kopfzeile = 0) |
+| `auto_widths(rows, *, header=True)` | die Spaltenbreiten von `col_widths="auto"` |
+| `usable_width` | Breite zwischen den Rändern in Punkt |
+| `rule()`, `spacer(mm=3)` | Trennlinie, Abstand |
+| `keep_together(builder)` | Block bleibt auf einer Seite, wenn er passt |
+| `box(builder)` | hellgrau gerahmter Block; höher als eine Seite → ungerahmt |
+| `add(*flowables)` | eigene reportlab-Flowables |
+| `render() -> bytes` | das PDF |
+
+Außerdem: `safe_text(text)` (Zeichen, die die Schrift nicht hat — Griechisch,
+Kyrillisch, CJK, Emoji — werden zu „?“), `html_to_flowables(html, styles)`,
+`make_styles()`, `FONT`, `FONT_BOLD`.
+
+## Tests
+
+```bash
+pip install "./packages/django/basicbar-pdf[test]"
+cd packages/django/basicbar-pdf && python runtests.py
+```
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/__init__.py b/packages/django/basicbar-pdf/basicbar_pdf/__init__.py
new file mode 100644
index 0000000..b6ac7f8
--- /dev/null
+++ b/packages/django/basicbar-pdf/basicbar_pdf/__init__.py
@@ -0,0 +1,14 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 Universität Osnabrück (virtUOS)
+
+"""PDF frame for downloadable documents of the -bar tools (reportlab, bundled Plus Jakarta Sans).
+
+No Django dependency: user-visible labels (page footer, image placeholder) are parameters, so a
+tool passes its own (lazy) gettext strings.
+"""
+
+from .document import PdfDocument
+from .fonts import FONT, FONT_BOLD, safe_text
+from .html import html_to_flowables, make_styles
+
+__all__ = ["FONT", "FONT_BOLD", "PdfDocument", "html_to_flowables", "make_styles", "safe_text"]
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/document.py b/packages/django/basicbar-pdf/basicbar_pdf/document.py
new file mode 100644
index 0000000..b81b5c7
--- /dev/null
+++ b/packages/django/basicbar-pdf/basicbar_pdf/document.py
@@ -0,0 +1,209 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 Universität Osnabrück (virtUOS)
+
+"""``PdfDocument``: collects flowables and renders an A4 document with a numbered footer."""
+
+from collections.abc import Callable, Iterable, Sequence
+from io import BytesIO
+
+from reportlab.lib import colors
+from reportlab.lib.enums import TA_RIGHT
+from reportlab.lib.pagesizes import A4, landscape as _landscape
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import mm as MM
+from reportlab.pdfbase.pdfmetrics import stringWidth
+from reportlab.pdfgen import canvas
+from reportlab.platypus import HRFlowable, KeepTogether, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
+
+from .fonts import FONT, FONT_BOLD, safe_text
+from .html import _markup, html_to_flowables, make_styles
+
+PAGE_LABEL = "Page %(page)s of %(pages)s"
+GRID = colors.HexColor("#c8c8c8")
+
+
+def _numbered_canvas(footer_left: str, page_label: str, margin: float):
+ left = safe_text(footer_left)
+
+ class NumberedCanvas(canvas.Canvas):
+ """Two passes: the page count is known only after the last page."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._pages = []
+
+ def showPage(self):
+ self._pages.append(dict(self.__dict__))
+ self._startPage()
+
+ def save(self):
+ count = len(self._pages)
+ for state in self._pages:
+ self.__dict__.update(state)
+ width, _height = self._pagesize
+ self.setFont(FONT, 8)
+ self.setFillColor(colors.HexColor("#555555"))
+ self.drawString(margin, 10 * MM, left)
+ self.drawRightString(width - margin, 10 * MM,
+ safe_text(page_label % {"page": self._pageNumber, "pages": count}))
+ super().showPage()
+ super().save()
+
+ return NumberedCanvas
+
+
+class PdfDocument:
+ """Collects flowables and renders an A4 document (portrait or landscape) with a footer:
+ ``footer_left`` on the left, "Page x of y" on the right.
+
+ ``page_label`` and ``image_placeholder`` are the only user-visible strings of the frame; pass
+ the tool's translations (``gettext_lazy`` works — they are resolved when used). All text is
+ escaped; characters the bundled font lacks become "?" (:func:`safe_text`).
+ """
+
+ def __init__(self, title: str, *, footer_left: str = "", author: str = "", landscape: bool = False,
+ margin_mm: float = 20, page_label=PAGE_LABEL, image_placeholder="[Image]",
+ compress: bool = True):
+ self.title = title
+ self.author = author
+ self.footer_left = footer_left
+ self.pagesize = _landscape(A4) if landscape else A4
+ self.margin = margin_mm * MM
+ self.bottom = max(self.margin - 2 * MM, 15 * MM) # room for the footer at 10 mm
+ self.page_label = page_label
+ self.image_placeholder = image_placeholder
+ self.compress = compress
+ self.styles = make_styles()
+ self._story: list = []
+
+ @property
+ def usable_width(self) -> float:
+ """Width between the side margins in points (for ``col_widths``)."""
+ return self.pagesize[0] - 2 * self.margin
+
+ def _add(self, *flowables):
+ self._story.extend(flowables)
+
+ def add(self, *flowables):
+ """Escape hatch: append raw reportlab flowables."""
+ self._add(*flowables)
+
+ def heading(self, text: str, level: int = 1):
+ self._add(Paragraph(_markup(text), self.styles[f"h{min(max(level, 1), 3)}"]))
+
+ def paragraph(self, text: str, *, style: str = "normal"):
+ self._add(Paragraph(_markup(text), self.styles[style]))
+
+ def rich(self, html: str):
+ """Editor HTML (paragraphs, inline formatting, lists, headings, quotes, links)."""
+ self._add(*html_to_flowables(html, self.styles, image_placeholder=self.image_placeholder))
+
+ def bullets(self, items: list[str]):
+ style = ParagraphStyle("bl", parent=self.styles["normal"], leftIndent=16, bulletIndent=4)
+ for item in items:
+ self._add(Paragraph(_markup(item), style, bulletText="•"))
+
+ def auto_widths(self, rows: Sequence[Sequence[str]], *, header: bool = True) -> list[float]:
+ """Column widths for ``rows``: each column at least as wide as its longest header word and
+ its longest value (no value is broken mid-word); the remaining width is shared evenly, or
+ everything shrinks proportionally when the page is too narrow."""
+ font_size = self.styles["cell"].fontSize
+ need = []
+ for col in range(max((len(row) for row in rows), default=0)):
+ widths = [0.0]
+ for i, row in enumerate(rows):
+ if col >= len(row):
+ continue
+ cell = safe_text(row[col])
+ if header and i == 0:
+ widths += [stringWidth(word, FONT_BOLD, font_size) for word in cell.split()]
+ else:
+ widths += [stringWidth(line, FONT_BOLD, font_size) for line in cell.split("\n")]
+ need.append(max(widths) + 8) # cell padding left + right
+ if not need:
+ return []
+ usable = self.usable_width
+ extra = usable - sum(need)
+ if extra >= 0:
+ return [w + extra / len(need) for w in need]
+ return [w * usable / sum(need) for w in need]
+
+ def table(self, rows: list[list[str]], *, header: bool = True,
+ col_widths: list[float] | str | None = None, align_right_from: int | None = None,
+ strong_rows: Iterable[int] = ()):
+ """A grid table; the header row repeats on every page.
+
+ ``col_widths``: points per column, ``"auto"`` (:meth:`auto_widths`) or None (reportlab).
+ ``align_right_from``: columns from this index on are right-aligned (numbers).
+ ``strong_rows``: row indexes (counting the header as 0) set in bold, e.g. totals.
+ """
+ if not rows:
+ return
+ if col_widths == "auto":
+ col_widths = self.auto_widths(rows, header=header)
+ strong = set(strong_rows)
+ styles = {}
+
+ def cell_style(i: int, j: int) -> ParagraphStyle:
+ bold = (header and i == 0) or i in strong
+ right = align_right_from is not None and j >= align_right_from
+ key = (bold, right)
+ if key not in styles:
+ base = self.styles["cell_head" if bold else "cell"]
+ styles[key] = ParagraphStyle(f"cell{int(bold)}{int(right)}", parent=base,
+ alignment=TA_RIGHT) if right else base
+ return styles[key]
+
+ data = [[Paragraph(_markup(cell), cell_style(i, j)) for j, cell in enumerate(row)]
+ for i, row in enumerate(rows)]
+ table = Table(data, colWidths=col_widths, repeatRows=1 if header else 0)
+ style = [("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ("GRID", (0, 0), (-1, -1), 0.25, GRID),
+ ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3)]
+ if header:
+ style.append(("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eeeeee")))
+ table.setStyle(TableStyle(style))
+ self._add(table, Spacer(1, 2 * MM))
+
+ def rule(self):
+ """Thin horizontal separator line."""
+ self._add(HRFlowable(width="100%", thickness=0.5, color=GRID, spaceBefore=2 * MM, spaceAfter=2 * MM))
+
+ def spacer(self, mm: float = 3):
+ self._add(Spacer(1, mm * MM))
+
+ def _collect(self, builder: Callable[["PdfDocument"], None]) -> list:
+ outer, self._story = self._story, []
+ try:
+ builder(self)
+ return self._story
+ finally:
+ self._story = outer
+
+ def keep_together(self, builder: Callable[["PdfDocument"], None]):
+ """Everything ``builder`` adds stays on one page if it fits (e.g. a heading with its table)."""
+ self._add(KeepTogether(self._collect(builder)))
+
+ def box(self, builder: Callable[["PdfDocument"], None]):
+ """Light grey framed block. Rendered as a one-cell table, so it cannot split across pages;
+ a block taller than a page is added unframed instead."""
+ inner = self._collect(builder)
+ width, height = self.pagesize
+ box = Table([[inner]], colWidths=[self.usable_width])
+ if box.wrap(self.usable_width, height)[1] > height - self.margin - self.bottom - 4 * MM:
+ self._add(*inner, Spacer(1, 2 * MM))
+ return
+ box.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f3f3f3")),
+ ("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#aaaaaa")),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8), ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
+ self._add(box, Spacer(1, 2 * MM))
+
+ def render(self) -> bytes:
+ buffer = BytesIO()
+ doc = SimpleDocTemplate(buffer, pagesize=self.pagesize, leftMargin=self.margin, rightMargin=self.margin,
+ topMargin=self.margin, bottomMargin=self.bottom, title=safe_text(self.title),
+ author=safe_text(self.author), pageCompression=1 if self.compress else 0)
+ doc.build(list(self._story),
+ canvasmaker=_numbered_canvas(self.footer_left, str(self.page_label), self.margin))
+ return buffer.getvalue()
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/fonts.py b/packages/django/basicbar-pdf/basicbar_pdf/fonts.py
new file mode 100644
index 0000000..6233048
--- /dev/null
+++ b/packages/django/basicbar-pdf/basicbar_pdf/fonts.py
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 Universität Osnabrück (virtUOS)
+
+"""Bundled Plus Jakarta Sans (SIL OFL 1.1, see fonts/OFL.txt), registered once on import."""
+
+from pathlib import Path
+
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+
+FONT = "PlusJakartaSans"
+FONT_BOLD = FONT + "-Bold"
+FONT_DIR = Path(__file__).resolve().parent / "fonts"
+_FONT_FILES = {FONT: "Regular", FONT_BOLD: "Bold", FONT + "-Italic": "Italic", FONT + "-BoldItalic": "BoldItalic"}
+_covered: set[int] = set()
+
+
+def _register_fonts() -> None:
+ """Register the bundled TTFs once (idempotent) and remember which code points the font has."""
+ if _covered:
+ return
+ for name, style in _FONT_FILES.items():
+ pdfmetrics.registerFont(TTFont(name, FONT_DIR / f"PlusJakartaSans-{style}.ttf"))
+ pdfmetrics.registerFontFamily(FONT, normal=FONT, bold=FONT_BOLD, italic=FONT + "-Italic",
+ boldItalic=FONT + "-BoldItalic")
+ _covered.update(pdfmetrics.getFont(FONT).face.charToGlyph)
+
+
+_register_fonts()
+
+
+def safe_text(text) -> str:
+ """Characters the bundled font lacks (Greek, Cyrillic, CJK, emoji, ...) become "?"."""
+ return "".join(c if c.isspace() or ord(c) in _covered else "?" for c in str(text))
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/fonts/OFL.txt b/packages/django/basicbar-pdf/basicbar_pdf/fonts/OFL.txt
new file mode 100644
index 0000000..ea69b0c
--- /dev/null
+++ b/packages/django/basicbar-pdf/basicbar_pdf/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Bold.ttf b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Bold.ttf
new file mode 100644
index 0000000..2d49642
Binary files /dev/null and b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Bold.ttf differ
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-BoldItalic.ttf b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-BoldItalic.ttf
new file mode 100644
index 0000000..90a6dc4
Binary files /dev/null and b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-BoldItalic.ttf differ
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Italic.ttf b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Italic.ttf
new file mode 100644
index 0000000..622fb73
Binary files /dev/null and b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Italic.ttf differ
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Regular.ttf b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Regular.ttf
new file mode 100644
index 0000000..cb87445
Binary files /dev/null and b/packages/django/basicbar-pdf/basicbar_pdf/fonts/PlusJakartaSans-Regular.ttf differ
diff --git a/packages/django/basicbar-pdf/basicbar_pdf/html.py b/packages/django/basicbar-pdf/basicbar_pdf/html.py
new file mode 100644
index 0000000..068501a
--- /dev/null
+++ b/packages/django/basicbar-pdf/basicbar_pdf/html.py
@@ -0,0 +1,206 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 Universität Osnabrück (virtUOS)
+
+"""Editor HTML (TipTap subset) to reportlab flowables, and the shared paragraph styles."""
+
+import re
+from html.parser import HTMLParser
+from xml.sax.saxutils import escape
+
+from reportlab.lib import colors
+from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
+from reportlab.platypus import Paragraph
+
+from .fonts import FONT, FONT_BOLD, safe_text
+
+INLINE_TAGS = {"strong": "b", "b": "b", "em": "i", "i": "i", "u": "u", "s": "strike", "del": "strike",
+ "strike": "strike"}
+HEADINGS = {"h1": "h1", "h2": "h2", "h3": "h3"}
+SKIP_CONTENT = {"script", "style"}
+TABLE_TAGS = {"table", "thead", "tbody", "tfoot", "tr"}
+
+
+def _markup(text: str) -> str:
+ return escape(safe_text(text)).replace("\n", "
")
+
+
+def make_styles() -> dict:
+ """Paragraph styles in the bundled font: normal, small, bold, muted, h1-h3, quote, cell, cell_head."""
+ base = getSampleStyleSheet()["Normal"]
+ normal = ParagraphStyle("n", parent=base, fontName=FONT, fontSize=10, leading=13.5, spaceAfter=3)
+ styles = {
+ "normal": normal,
+ "small": ParagraphStyle("s", parent=normal, fontSize=8.5, leading=11),
+ "bold": ParagraphStyle("b", parent=normal, fontName=FONT_BOLD),
+ "muted": ParagraphStyle("m", parent=normal, fontSize=8.5, leading=11, textColor=colors.HexColor("#666666")),
+ "h1": ParagraphStyle("h1", parent=normal, fontName=FONT_BOLD, fontSize=18, leading=22,
+ spaceBefore=4, spaceAfter=6),
+ "h2": ParagraphStyle("h2", parent=normal, fontName=FONT_BOLD, fontSize=15, leading=18,
+ spaceBefore=14, spaceAfter=5),
+ "h3": ParagraphStyle("h3", parent=normal, fontName=FONT_BOLD, fontSize=12, leading=15,
+ spaceBefore=8, spaceAfter=2),
+ "quote": ParagraphStyle("q", parent=normal, leftIndent=10, textColor=colors.HexColor("#444444")),
+ "cell": ParagraphStyle("c", parent=normal, fontSize=9, leading=11.5, spaceAfter=0),
+ "cell_head": ParagraphStyle("ch", parent=normal, fontName=FONT_BOLD, fontSize=9, leading=11.5,
+ spaceAfter=0),
+ }
+ return styles
+
+
+def _has(styles, key) -> bool:
+ try:
+ styles[key]
+ return True
+ except KeyError:
+ return False
+
+
+class _HtmlToFlowables(HTMLParser):
+ """Editor HTML (p, br, inline formatting, lists, headings, blockquote) to Paragraphs. Unknown
+ tags are dropped, their text kept; the markup passed to reportlab is always balanced."""
+
+ def __init__(self, styles, image_placeholder):
+ super().__init__(convert_charrefs=True)
+ self.image_placeholder = image_placeholder
+ own = make_styles()
+ # accept any mapping/stylesheet; keys it lacks fall back to the built-in styles
+ self.styles = {key: (styles[key] if _has(styles, key) else own[key]) for key in own}
+ self.out = []
+ self.buf = ""
+ self.open = [] # inline tags currently open (reportlab names)
+ self.block = "normal" # style key of the paragraph being built
+ self.lists = [] # stack of [kind, counter]
+ self.bullet = None
+ self.hrefs = []
+ self.skip = 0
+ self.has_text = False
+ self.cells = 0 # cells already seen in the current table row
+ self.pre = 0
+
+ # -- paragraph handling
+ def _flush(self):
+ if self.has_text:
+ closing = "".join(f"{t}>" for t in reversed(self.open))
+ self.out.append(self._paragraph(self.buf + closing))
+ # the bullet belongs to the first paragraph of the item only (TipTap:
..
Eins fett
Link 😀 αβ
') + + def _texts(self, data): + from io import BytesIO + + from pypdf import PdfReader + return [page.extract_text() for page in PdfReader(BytesIO(data)).pages] + + def test_safe_text_replaces_unencodable_characters(self): + from basicbar_pdf import safe_text + self.assertEqual(safe_text("a😀"), "a?") + self.assertEqual(safe_text("Grüße „x“ – ok"), "Grüße „x“ – ok") + self.assertEqual(safe_text("αβ\n x"), "??\n x") + + def test_extended_latin_renders_with_bundled_font(self): + from basicbar_pdf import PdfDocument + doc = PdfDocument("t", footer_left="Dvořák") + doc.paragraph("Şahin Łukasz Dvořák → € ğ ı ß") + doc.rich("Şahin Łukasz Greek αβ emoji 😀
") + data = doc.render() + text = "\n".join(self._texts(data)) + for needle in ("Şahin Łukasz Dvořák → € ğ ı ß", "Greek ?? emoji ?"): + self.assertIn(needle, text) + self.assertIn(b"PlusJakartaSans", data) + + def test_render_document_with_all_flowables(self): + from basicbar_pdf import PdfDocument + doc = PdfDocument("Titel", footer_left="Links", page_label="Seite %(page)s von %(pages)s") + doc.heading("Überschrift") + doc.heading("Unter", level=2) + for style in ("normal", "small", "bold", "muted"): + doc.paragraph(f"Absatz {style} &", style=style) + doc.rich(self.RICH) + doc.bullets(["Punkt 1", "Punkt 2"]) + doc.table([["Kopf", "Wert"], ["a", "1"]], col_widths=[40, 40]) + doc.spacer() + doc.keep_together(lambda d: (d.paragraph("zusammen"), d.paragraph("bleibt"))) + doc.box(lambda d: d.paragraph("Siegelblock")) + for i in range(200): + doc.paragraph(f"Zeile {i}") + data = doc.render() + self.assertTrue(data.startswith(b"%PDF")) + pages = self._texts(data) + self.assertGreater(len(pages), 1) + text = "\n".join(pages) + for needle in ("fett", "A", "B", "X", "Link", "https://x.org", "Siegelblock", "Punkt 2", "Links"): + self.assertIn(needle, text) + self.assertIn("Seite 1 von", pages[0]) + self.assertIn(f"Seite {len(pages)} von {len(pages)}", pages[-1]) + + def test_default_english_footer(self): + from basicbar_pdf import PdfDocument + doc = PdfDocument("T") + doc.paragraph("x") + self.assertIn("Page 1 of 1", self._texts(doc.render())[0]) + + def test_html_to_flowables_survives_messy_html(self): + from reportlab.lib.styles import getSampleStyleSheet + + from basicbar_pdf import html_to_flowables + styles = getSampleStyleSheet() + messy = ('Zitat
abc u s d
1 < 2 & 3
Alpha
Xaver
Yvonne
") + text = "\n".join(self._texts(doc.render())) + # the bullet glyph is extracted as U+2022 or a control char depending on the font encoding + self.assertRegex(text, "[\u2022\x7f]\\s*Alpha") + self.assertRegex(text, r"1\.\s*Xaver") + self.assertRegex(text, r"2\.\s*Yvonne") + + +class PdfFrameRobustnessTests(unittest.TestCase): + """Inputs that used to hang or crash the render, and rich() fallbacks.""" + + _texts = PdfFrameTests._texts + + def _render_guarded(self, doc, seconds=20): + import signal + + def boom(*_): + raise AssertionError("PDF render hangs") + old = signal.signal(signal.SIGALRM, boom) + signal.alarm(seconds) + try: + return doc.render() + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, old) + + def test_heading_taller_than_a_page_does_not_hang(self): + from basicbar_pdf import PdfDocument + doc = PdfDocument("t") + doc.heading("big " * 700, 2) + doc.paragraph("danach") + text = "\n".join(self._texts(self._render_guarded(doc))) + self.assertIn("danach", text) + + def test_rich_heading_taller_than_a_page_does_not_hang(self): + from basicbar_pdf import PdfDocument + doc = PdfDocument("t") + doc.rich("
danach
") + text = "\n".join(self._texts(self._render_guarded(doc))) + self.assertIn("danach", text) + + def test_multiline_headings_do_not_hang(self): + from basicbar_pdf import PdfDocument + for build in (lambda d: d.heading("x\n" * 40, 1), + lambda d: d.heading("\n" * 299, 1), + lambda d: d.rich("vor
nach
| A1 | B1 |
| A2 | B2 |
zeile1\nzeile2") + doc = PdfDocument("t", image_placeholder="[Bild]") + doc.rich(html) + text = "\n".join(self._texts(doc.render())) + self.assertIn("[Bild]", text) + self.assertIn("A1 | B1", text) + self.assertIn("A2 | B2", text) + self.assertRegex(text, r"zeile1\s*\nzeile2") + doc2 = PdfDocument("t") + doc2.rich('
')
+ self.assertIn("[Image]", "\n".join(self._texts(doc2.render())))
+
+ def test_box_taller_than_a_page_falls_back(self):
+ from basicbar_pdf import PdfDocument
+ doc = PdfDocument("t")
+ doc.box(lambda d: [d.paragraph(f"Boxzeile {i}") for i in range(80)])
+ text = "\n".join(self._texts(self._render_guarded(doc)))
+ self.assertIn("Boxzeile 79", text)
+
+
+class LazyLabel:
+ """Stands in for a gettext_lazy proxy: only str() resolves it."""
+
+ def __init__(self, text):
+ self.text = text
+
+ def __str__(self):
+ return self.text
+
+
+class PdfLayoutOptionTests(unittest.TestCase):
+ """Options added for tools beyond abstimmbar (kalkulierbar's landscape number tables)."""
+
+ _texts = PdfFrameTests._texts
+
+ def test_landscape_page_size_and_author(self):
+ from io import BytesIO
+
+ from pypdf import PdfReader
+
+ from basicbar_pdf import PdfDocument
+ doc = PdfDocument("t", landscape=True, margin_mm=15, author="virtUOS")
+ doc.paragraph("quer")
+ reader = PdfReader(BytesIO(doc.render()))
+ box = reader.pages[0].mediabox
+ self.assertGreater(float(box.width), float(box.height))
+ self.assertEqual(reader.metadata.author, "virtUOS")
+ self.assertAlmostEqual(doc.usable_width, float(box.width) - 2 * 15 * 72 / 25.4, places=3)
+
+ def test_lazy_labels_resolve_at_use(self):
+ from basicbar_pdf import PdfDocument
+ page, image = LazyLabel("Seite %(page)s/%(pages)s"), LazyLabel("[Bild]")
+ doc = PdfDocument("t", page_label=page, image_placeholder=image)
+ page.text, image.text = "S. %(page)s von %(pages)s", "[Abbildung]"
+ doc.rich('