Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@ 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
with:
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:
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
1 change: 1 addition & 0 deletions packages/django/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
69 changes: 69 additions & 0 deletions packages/django/basicbar-pdf/README.md
Original file line number Diff line number Diff line change
@@ -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
```
14 changes: 14 additions & 0 deletions packages/django/basicbar-pdf/basicbar_pdf/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
209 changes: 209 additions & 0 deletions packages/django/basicbar-pdf/basicbar_pdf/document.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions packages/django/basicbar-pdf/basicbar_pdf/fonts.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading