diff --git a/backend/Dockerfile b/backend/Dockerfile
index 509dfde3..7e4fd942 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -39,6 +39,16 @@ ENV PYTHONUNBUFFERED=1
ENV PORT=80
ENV HOST="0.0.0.0"
+# Native libraries required by WeasyPrint for PDF rendering (Pango/Cairo/fonts).
+# Versions are intentionally unpinned: they must match the base image's Alpine
+# branch, which is resolved at build time. Pin them to match the convention of
+# the builder stage above once building against the pinned base image digest.
+# hadolint ignore=DL3018
+RUN apk add --no-cache \
+ font-dejavu \
+ fontconfig \
+ pango
+
COPY --from=builder /build/venv /usr/local/
COPY . /app
diff --git a/backend/main.py b/backend/main.py
index 6b755093..e7a8ee69 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -10,7 +10,7 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
-from routers import auth
+from routers import auth, export
from scaffold import load_scaffold_data
from starlette.requests import ClientDisconnect
from strawberry import Schema
@@ -61,6 +61,7 @@ async def client_disconnect_handler(request: Request, exc: ClientDisconnect):
)
app.include_router(auth.router)
+app.include_router(export.router)
app.include_router(graphql_app, prefix="/graphql")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index d135fb3c..eb47c308 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,6 +9,8 @@ sqlalchemy==2.0.45
strawberry-graphql[fastapi]==0.287.3
uvicorn[standard]==0.38.0
influxdb_client==1.49.0
+jinja2==3.1.4
+weasyprint==63.1
pytest==8.3.4
pytest-asyncio==0.24.0
pytest-cov==5.0.0
diff --git a/backend/routers/export.py b/backend/routers/export.py
new file mode 100644
index 00000000..8331cce6
--- /dev/null
+++ b/backend/routers/export.py
@@ -0,0 +1,188 @@
+from datetime import datetime, timezone
+
+from api.context import Context, get_context
+from database.session import get_db_session
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import Response
+from jinja2 import Environment, select_autoescape
+from pydantic import BaseModel, Field
+
+router = APIRouter()
+
+_MAX_ROWS = 5000
+_MAX_COLUMNS = 40
+
+_jinja_env = Environment(autoescape=select_autoescape(["html", "xml"]))
+
+_TABLE_TEMPLATE = _jinja_env.from_string(
+ """
+
+
+
+
+
+
+
+ {% if rows %}
+
+
+ {% for column in columns %}| {{ column }} | {% endfor %}
+
+
+ {% for row in rows %}
+ {% for cell in row %}| {{ cell }} | {% endfor %}
+ {% endfor %}
+
+
+ {% else %}
+ {{ empty_label }}
+ {% endif %}
+
+"""
+)
+
+
+class TableExportRequest(BaseModel):
+ title: str = Field(..., max_length=200)
+ subtitle: str | None = Field(default=None, max_length=400)
+ columns: list[str]
+ rows: list[list[str]]
+ orientation: str = "landscape"
+ meta: dict[str, str] = Field(default_factory=dict)
+ rows_label: str = "Rows"
+ empty_label: str = "No entries"
+ generated_label: str = "Generated"
+ page_label: str = "Page"
+
+
+def _normalize(request: TableExportRequest) -> TableExportRequest:
+ if not request.columns:
+ raise HTTPException(status_code=422, detail="At least one column is required")
+ if len(request.columns) > _MAX_COLUMNS:
+ raise HTTPException(status_code=422, detail="Too many columns")
+ if len(request.rows) > _MAX_ROWS:
+ raise HTTPException(status_code=422, detail="Too many rows to export")
+ orientation = request.orientation if request.orientation in ("portrait", "landscape") else "landscape"
+ width = len(request.columns)
+ rows: list[list[str]] = []
+ for row in request.rows:
+ cells = ["" if cell is None else str(cell) for cell in row[:width]]
+ cells += [""] * (width - len(cells))
+ rows.append(cells)
+ return request.model_copy(update={"orientation": orientation, "rows": rows})
+
+
+def render_table_html(request: TableExportRequest) -> str:
+ normalized = _normalize(request)
+ return _TABLE_TEMPLATE.render(
+ title=normalized.title,
+ subtitle=normalized.subtitle,
+ columns=normalized.columns,
+ rows=normalized.rows,
+ orientation=normalized.orientation,
+ meta=normalized.meta,
+ rows_label=normalized.rows_label,
+ empty_label=normalized.empty_label,
+ generated_label=normalized.generated_label,
+ page_label=normalized.page_label,
+ generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
+ )
+
+
+def render_table_pdf(request: TableExportRequest) -> bytes:
+ html = render_table_html(request)
+ try:
+ from weasyprint import HTML
+ except (ImportError, OSError) as exc: # missing python pkg or native libs
+ raise HTTPException(
+ status_code=503,
+ detail="PDF rendering is not available on this server",
+ ) from exc
+ return HTML(string=html).write_pdf()
+
+
+async def _require_context(
+ request: Request,
+ session=Depends(get_db_session),
+) -> Context:
+ return await get_context(request, session)
+
+
+@router.post("/export/table.pdf")
+async def export_table_pdf(
+ body: TableExportRequest,
+ context: Context = Depends(_require_context),
+) -> Response:
+ if context.user is None:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ pdf = render_table_pdf(body)
+ filename = _safe_filename(body.title)
+ return Response(
+ content=pdf,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{filename}.pdf"'},
+ )
+
+
+def _safe_filename(title: str) -> str:
+ cleaned = "".join(c if c.isalnum() or c in (" ", "-", "_") else "" for c in title).strip()
+ cleaned = cleaned.replace(" ", "-").lower()
+ return cleaned or "table-export"
diff --git a/backend/tests/unit/test_export_pdf.py b/backend/tests/unit/test_export_pdf.py
new file mode 100644
index 00000000..d3729e1f
--- /dev/null
+++ b/backend/tests/unit/test_export_pdf.py
@@ -0,0 +1,60 @@
+import pytest
+from fastapi import HTTPException
+from routers.export import (
+ TableExportRequest,
+ _normalize,
+ _safe_filename,
+ render_table_html,
+)
+
+
+def _request(**overrides) -> TableExportRequest:
+ base = {
+ "title": "My Tasks",
+ "columns": ["Title", "Patient"],
+ "rows": [["Take blood", "John Doe"], ["Round", "Jane Roe"]],
+ }
+ base.update(overrides)
+ return TableExportRequest(**base)
+
+
+def test_render_table_html_contains_headers_and_rows():
+ html = render_table_html(_request())
+ assert "My Tasks
" in html
+ assert "Title | " in html
+ assert "Take blood" in html
+ assert "John Doe" in html
+ assert "size: A4 landscape" in html
+
+
+def test_render_table_html_escapes_cell_content():
+ html = render_table_html(_request(rows=[["", "x"]]))
+ assert "" not in html
+ assert "<script>" in html
+
+
+def test_render_table_html_empty_rows_shows_placeholder():
+ html = render_table_html(_request(rows=[], empty_label="Nothing here"))
+ assert "Nothing here" in html
+ assert "" not in html
+
+
+def test_normalize_pads_and_truncates_rows_to_column_count():
+ normalized = _normalize(_request(rows=[["only-one"], ["a", "b", "c-extra"]]))
+ assert normalized.rows == [["only-one", ""], ["a", "b"]]
+
+
+def test_normalize_rejects_empty_columns():
+ with pytest.raises(HTTPException) as exc:
+ _normalize(_request(columns=[]))
+ assert exc.value.status_code == 422
+
+
+def test_normalize_defaults_invalid_orientation_to_landscape():
+ assert _normalize(_request(orientation="sideways")).orientation == "landscape"
+ assert _normalize(_request(orientation="portrait")).orientation == "portrait"
+
+
+def test_safe_filename():
+ assert _safe_filename("My Tasks 2026") == "my-tasks-2026"
+ assert _safe_filename("///") == "table-export"
diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx
index eeb1f3f8..00bf1650 100644
--- a/web/components/tables/PatientList.tsx
+++ b/web/components/tables/PatientList.tsx
@@ -20,6 +20,7 @@ import { getPropertyColumnIds, useColumnVisibilityWithPropertyDefaults } from '@
import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi'
import { LIST_PAGE_SIZE } from '@/utils/listPaging'
import { useAccumulatedPagination } from '@/hooks/useAccumulatedPagination'
+import { TableExportButton } from '@/components/tables/TableExportButton'
import { RowRefreshingGate } from '@/components/tables/RowRefreshingGate'
import { InfiniteScrollSentinel } from '@/components/common/InfiniteScrollSentinel'
import { DateDisplay } from '@/components/Date/DateDisplay'
@@ -1118,6 +1119,7 @@ export const PatientList = forwardRef(({ initi
>
+
{!derivedVirtualMode && (
,
+ /** Column ids to omit from the export (e.g. selection/action columns). */
+ excludeColumnIds?: string[],
+}
+
+/**
+ * Renders a print button that exports the table's currently visible columns and
+ * loaded rows to a server-generated PDF. Must be rendered inside a TableProvider.
+ */
+export function TableExportButton({ title, subtitle, meta, excludeColumnIds }: TableExportButtonProps) {
+ const translation = useTasksTranslation()
+ const { table } = useTableStateContext()
+ const [isExporting, setIsExporting] = useState(false)
+
+ const handleExport = useCallback(async () => {
+ const skip = new Set(excludeColumnIds ?? [])
+ const exportColumns = table.getVisibleLeafColumns().filter((column) => {
+ const header = column.columnDef.header
+ return typeof header === 'string' && header.length > 0 && !skip.has(column.id)
+ })
+ if (exportColumns.length === 0) return
+ const columns = exportColumns.map((column) => column.columnDef.header as string)
+ const rows = table.getRowModel().rows.map(
+ (row) => exportColumns.map((column) => cellToText(row.getValue(column.id)))
+ )
+ setIsExporting(true)
+ try {
+ await downloadTablePdf({ title, subtitle, columns, rows, meta })
+ } finally {
+ setIsExporting(false)
+ }
+ }, [table, title, subtitle, meta, excludeColumnIds])
+
+ return (
+ void handleExport()}
+ >
+ {isExporting ? : }
+
+ )
+}
diff --git a/web/components/tables/TaskList.tsx b/web/components/tables/TaskList.tsx
index fc7da8fa..b2772a06 100644
--- a/web/components/tables/TaskList.tsx
+++ b/web/components/tables/TaskList.tsx
@@ -30,6 +30,7 @@ import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems, ty
import { LIST_PAGE_SIZE } from '@/utils/listPaging'
import { TaskCardView } from '@/components/tasks/TaskCardView'
import { RefreshingTaskIdsContext, TaskRowRefreshingGate } from '@/components/tables/TaskRowRefreshingGate'
+import { TableExportButton } from '@/components/tables/TableExportButton'
import { InfiniteScrollSentinel } from '@/components/common/InfiniteScrollSentinel'
import { ExpandableTextBlock } from '@/components/common/ExpandableTextBlock'
import { InTableTextEditPopUp } from '@/components/tables/in-table-edit/InTableTextEditPopUp'
@@ -1028,6 +1029,7 @@ export const TaskList = forwardRef(({ tasks: initial
>
+
string,
'priorityLabel': string,
'priorityNone': string,
@@ -556,6 +557,7 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -972,6 +974,7 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -1387,6 +1390,7 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -1802,6 +1806,7 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -2217,6 +2222,7 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normaal`,
@@ -2635,6 +2641,7 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
diff --git a/web/locales/de-DE.arb b/web/locales/de-DE.arb
index fb01756e..9825b68c 100644
--- a/web/locales/de-DE.arb
+++ b/web/locales/de-DE.arb
@@ -121,6 +121,7 @@
"listViewCard": "Kartenansicht",
"listViewTable": "Tabellenansicht",
"loadMore": "Mehr laden",
+ "print": "Drucken",
"more": "Mehr",
"location": "Ort",
"locationBed": "Bett",
diff --git a/web/locales/en-US.arb b/web/locales/en-US.arb
index ebf5705b..13f83c47 100644
--- a/web/locales/en-US.arb
+++ b/web/locales/en-US.arb
@@ -121,6 +121,7 @@
"listViewCard": "Card view",
"listViewTable": "Table view",
"loadMore": "Load more",
+ "print": "Print",
"more": "More",
"location": "Location",
"locationBed": "Bed",
diff --git a/web/locales/es-ES.arb b/web/locales/es-ES.arb
index 43e23f57..8c8cb3e6 100644
--- a/web/locales/es-ES.arb
+++ b/web/locales/es-ES.arb
@@ -81,6 +81,7 @@
"listViewCard": "Vista de tarjetas",
"listViewTable": "Vista de tabla",
"loadMore": "Cargar más",
+ "print": "Imprimir",
"more": "Más",
"location": "Ubicación",
"locationBed": "Cama",
diff --git a/web/locales/fr-FR.arb b/web/locales/fr-FR.arb
index fb546a47..73a87fd6 100644
--- a/web/locales/fr-FR.arb
+++ b/web/locales/fr-FR.arb
@@ -81,6 +81,7 @@
"listViewCard": "Vue cartes",
"listViewTable": "Vue tableau",
"loadMore": "Charger plus",
+ "print": "Imprimer",
"more": "Plus",
"location": "Emplacement",
"locationBed": "Lit",
diff --git a/web/locales/nl-NL.arb b/web/locales/nl-NL.arb
index 70b3cd5a..3afa945a 100644
--- a/web/locales/nl-NL.arb
+++ b/web/locales/nl-NL.arb
@@ -81,6 +81,7 @@
"listViewCard": "Kaartweergave",
"listViewTable": "Tabelweergave",
"loadMore": "Meer laden",
+ "print": "Afdrukken",
"more": "Meer",
"location": "Locatie",
"locationBed": "Bed",
diff --git a/web/locales/pt-BR.arb b/web/locales/pt-BR.arb
index 09d91dcc..dca4ab5d 100644
--- a/web/locales/pt-BR.arb
+++ b/web/locales/pt-BR.arb
@@ -81,6 +81,7 @@
"listViewCard": "Visualização em cartões",
"listViewTable": "Visualização em tabela",
"loadMore": "Carregar mais",
+ "print": "Imprimir",
"more": "Mais",
"location": "Localização",
"locationBed": "Leito",
diff --git a/web/next-env.d.ts b/web/next-env.d.ts
index 4bc78492..4503dcfd 100644
--- a/web/next-env.d.ts
+++ b/web/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./build/dev/types/routes.d.ts";
+import "./build/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
diff --git a/web/utils/tableExport.ts b/web/utils/tableExport.ts
new file mode 100644
index 00000000..c4b61162
--- /dev/null
+++ b/web/utils/tableExport.ts
@@ -0,0 +1,65 @@
+import { getConfig } from '@/utils/config'
+import { getUser } from '@/api/auth/authService'
+
+export type TableExportPayload = {
+ title: string,
+ subtitle?: string,
+ columns: string[],
+ rows: string[][],
+ orientation?: 'portrait' | 'landscape',
+ meta?: Record,
+ rowsLabel?: string,
+ emptyLabel?: string,
+ generatedLabel?: string,
+ pageLabel?: string,
+}
+
+function exportEndpoint(): string {
+ const { graphqlEndpoint } = getConfig()
+ return graphqlEndpoint.replace(/\/graphql\/?$/, '') + '/export/table.pdf'
+}
+
+export function cellToText(value: unknown): string {
+ if (value == null) return ''
+ if (value instanceof Date) return value.toLocaleString()
+ if (Array.isArray(value)) return value.map(cellToText).filter(Boolean).join(', ')
+ if (typeof value === 'object') return ''
+ return String(value)
+}
+
+export async function downloadTablePdf(payload: TableExportPayload): Promise {
+ const user = await getUser()
+ const token = user?.access_token
+ const response = await fetch(exportEndpoint(), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({
+ title: payload.title,
+ subtitle: payload.subtitle,
+ columns: payload.columns,
+ rows: payload.rows,
+ orientation: payload.orientation ?? 'landscape',
+ meta: payload.meta ?? {},
+ ...(payload.rowsLabel ? { rows_label: payload.rowsLabel } : {}),
+ ...(payload.emptyLabel ? { empty_label: payload.emptyLabel } : {}),
+ ...(payload.generatedLabel ? { generated_label: payload.generatedLabel } : {}),
+ ...(payload.pageLabel ? { page_label: payload.pageLabel } : {}),
+ }),
+ })
+ if (!response.ok) {
+ throw new Error(`PDF export failed with status ${response.status}`)
+ }
+ const blob = await response.blob()
+ const url = URL.createObjectURL(blob)
+ const opened = window.open(url, '_blank')
+ if (!opened) {
+ const link = document.createElement('a')
+ link.href = url
+ link.download = `${payload.title || 'table-export'}.pdf`
+ link.click()
+ }
+ window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
+}