diff --git a/backend/Dockerfile b/backend/Dockerfile
index 7e4fd942..509dfde3 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -39,16 +39,6 @@ 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 e7a8ee69..6b755093 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, export
+from routers import auth
from scaffold import load_scaffold_data
from starlette.requests import ClientDisconnect
from strawberry import Schema
@@ -61,7 +61,6 @@ 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 eb47c308..d135fb3c 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,8 +9,6 @@ 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
deleted file mode 100644
index 8331cce6..00000000
--- a/backend/routers/export.py
+++ /dev/null
@@ -1,188 +0,0 @@
-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
deleted file mode 100644
index d3729e1f..00000000
--- a/backend/tests/unit/test_export_pdf.py
+++ /dev/null
@@ -1,60 +0,0 @@
-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/proxy/README.md b/proxy/README.md
index 0c29515a..921db2f7 100644
--- a/proxy/README.md
+++ b/proxy/README.md
@@ -34,7 +34,6 @@ docker run --rm \
- `/` - Frontend application
- `/graphql` - Backend GraphQL API
-- `/export/*` - Backend export endpoints (e.g. table PDF export)
- `/keycloak` - Keycloak authentication service
## Production
diff --git a/proxy/nginx.conf b/proxy/nginx.conf
index 090b4ad3..e6b776fb 100644
--- a/proxy/nginx.conf
+++ b/proxy/nginx.conf
@@ -25,7 +25,7 @@ http {
listen 80;
server_name localhost;
- location ~ ^/(graphql|callback|export/.+)$ {
+ location ~ ^/(graphql|callback)$ {
proxy_pass http://backend_upstream;
proxy_set_header Host $host;
diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx
index 00bf1650..eeb1f3f8 100644
--- a/web/components/tables/PatientList.tsx
+++ b/web/components/tables/PatientList.tsx
@@ -20,7 +20,6 @@ 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'
@@ -1119,7 +1118,6 @@ 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 b2772a06..fc7da8fa 100644
--- a/web/components/tables/TaskList.tsx
+++ b/web/components/tables/TaskList.tsx
@@ -30,7 +30,6 @@ 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'
@@ -1029,7 +1028,6 @@ export const TaskList = forwardRef(({ tasks: initial
>
-
string,
'priorityLabel': string,
'priorityNone': string,
@@ -560,7 +559,6 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -979,7 +977,6 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -1397,7 +1394,6 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -1815,7 +1811,6 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normal`,
@@ -2233,7 +2228,6 @@ export const tasksTranslation: Translation {
return TranslationGen.resolveSelect(priority, {
'P1': `Normaal`,
@@ -2654,7 +2648,6 @@ 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 9a6ca5be..bc05d439 100644
--- a/web/locales/de-DE.arb
+++ b/web/locales/de-DE.arb
@@ -121,7 +121,6 @@
"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 21456f43..9c905eac 100644
--- a/web/locales/en-US.arb
+++ b/web/locales/en-US.arb
@@ -121,7 +121,6 @@
"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 7fb69c38..0deb97d7 100644
--- a/web/locales/es-ES.arb
+++ b/web/locales/es-ES.arb
@@ -81,7 +81,6 @@
"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 d2065b73..6c853cb4 100644
--- a/web/locales/fr-FR.arb
+++ b/web/locales/fr-FR.arb
@@ -81,7 +81,6 @@
"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 53fdb09e..cf7c2f4a 100644
--- a/web/locales/nl-NL.arb
+++ b/web/locales/nl-NL.arb
@@ -81,7 +81,6 @@
"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 f338b735..f37f2ef2 100644
--- a/web/locales/pt-BR.arb
+++ b/web/locales/pt-BR.arb
@@ -81,7 +81,6 @@
"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/utils/tableExport.ts b/web/utils/tableExport.ts
deleted file mode 100644
index c4b61162..00000000
--- a/web/utils/tableExport.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-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)
-}