feat: extract pdf from mp/pdf — initial libreshop/pdf
Some checks failed
Build and publish / build (push) Failing after 17s

Source moved verbatim from mp/pdf/ on 2026-04-29; mp was the first
concrete adapter consuming the libreshop toolkit. Builds and publishes
git.librete.ch/libreshop/pdf on every main / v* push via the standard
.gitea/workflows/build.yml shared across libreshop components.
This commit is contained in:
Michael Czechowski
2026-04-29 17:48:35 +02:00
commit c40bf8f8a5
15 changed files with 798 additions and 0 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.dockerignore
*Dockerfile
*.log
README.md

View File

@@ -0,0 +1,55 @@
name: Build and publish
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Required secrets:
# REGISTRY git.librete.ch
# REGISTRY_USER libretech-bot
# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team)
# Required variable:
# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs)
#
# Image: git.librete.ch/libreshop/pdf
# main pushes → :main + :sha-<short>
# tag pushes → :<tag> + :latest
jobs:
build:
runs-on: ubuntu-latest
container:
image: git.librete.ch/libretech/runner-image:v1
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Login (only when publishing)
if: ${{ vars.PUBLISH_ENABLED == 'true' }}
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY }}/libreshop/pdf
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
data/output/*.pdf
.venv
# libreshop additions
.env
.env.local
.env.*.local
node_modules/
.nuxt/
.output/
.cache/
.parcel-cache/
dist/
build/
coverage/
logs/
tmp/

8
CHANGELOG.md Normal file
View File

@@ -0,0 +1,8 @@
# Changelog
All notable changes to libreshop/pdf are documented here.
## Unreleased
- Extracted from `mp/pdf/` (2026-04-29). The component history before
the extraction lives in the `muellerprints` repository.

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM rstropek/pandoc-latex:3.1
RUN apk add --no-cache --update \
make \
python3 py-pip \
fontconfig ttf-freefont font-noto
WORKDIR /app
RUN tlmgr update --self
RUN tlmgr install ragged2e xltxtra realscripts wallpaper eso-pic \
titlesec arydshln spreadtab enumitem xstring
COPY ./requirements.txt /app/requirements.txt
RUN pip install -r requirements.txt
COPY ./data /app/data
COPY ./src /app/src
COPY ./makefile /app/makefile
COPY ./docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
ENTRYPOINT ["/app/docker-entrypoint.sh"]
EXPOSE 1111

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# libreshop/pdf
Pandoc + LaTeX renderer for invoices and shop documents.
Part of the [libreshop](https://git.librete.ch/libreshop) toolkit. Image
published at `git.librete.ch/libreshop/pdf` on every push to `main`
and on `v*` tags.
## Source
This repo was extracted from `mp/pdf/` on 2026-04-29; mp was the
first concrete adapter consuming the toolkit. mp's `compose.yml` now
pulls `git.librete.ch/libreshop/pdf:<pin>` instead of building locally.
## Build locally
```
docker build -t libreshop/pdf:dev .
```
## Adapter contract
See `docker-entrypoint.sh` and `Dockerfile` for the runtime surface.
Adapters configure the component via env vars and bind-mounted volumes;
do not patch the running container or rely on internal paths.

8
data/details.md Normal file
View File

@@ -0,0 +1,8 @@
---
---
**Gewählte Zahlungsart:** PayPal
Die Rechnung wurde per **PayPal** bereits beglichen.
Vielen Dank für Ihren Einkauf

0
data/output/.gitkeep Normal file
View File

BIN
data/templates/RE.pdf Normal file

Binary file not shown.

View File

@@ -0,0 +1,145 @@
\documentclass[
fontsize=10pt,
parskip=full,
paper=A4,
fromalign=off,
fromphone=false,
fromfax=false,
fromemail=false,
fromurl=false,
foldmarks=true,
version=last,
refline=wide
]{scrlttr2}
% Layout
\usepackage{geometry}
\geometry{a4paper, left=20mm, right=0mm, top=20mm, bottom=17mm}
% Typography
\usepackage[utf8]{inputenc}
\usepackage{fontspec}
\usepackage{eurosym}
\usepackage[hidelinks]{hyperref}
\defaultfontfeatures{Mapping=tex-text}
\setsansfont[Scale=0.9]{Noto Sans Regular}
\setmainfont[SmallCapsFeatures={LetterSpace=5,Letters=SmallCaps}]{Noto Sans Regular}
% Language
\usepackage[ngerman]{babel}
% Table Customization
\usepackage{spreadtab}
\usepackage{arydshln}
\usepackage{hhline}
\usepackage{enumitem}
\renewcommand{\arraystretch}{1.5} % Apply vertical padding to table cells
% \usepackage[table]{xcolor}
% \definecolor{gr}{rgb}{0.95,0.95,1}
% Letterhead
$if(letterhead)$
\usepackage{wallpaper}
\ULCornerWallPaper{1}{$letterhead$}
$endif$
% \setplength{refwidth}{10cm}
% \setplength{refhpos}{0pt}
\setplength{locwidth}{6cm}
\setplength{locvpos}{6cm}
% \showfields{test}
\begin{document}
\date{}
\setkomavar{subject}[left]{\LARGE \textit{\textcolor{red}{$subject$}}}
% Additional Information
\setkomavar{location}{\raggedright
$if(nr.invoice)$
\small\textit{\textbf{RECHNUNG Nr.} $nr.invoice$}\\
\scriptsize\textit{(Bitte bei Bezahlungen stets angeben)}
$endif$
$if(nr.shipping)$
\small \textit{\textbf{LIEFERSCHEIN Nr.} $nr.shipping$}\\
$endif$
$if(nr.customer)$
\small \textit{Kundennummer: $nr.customer$}\\
$endif$
$if(nr.order)$
\small \textit{Bestellnummer: $nr.order$}\\
$endif$
$if(date)$
\small \textit{Datum: $date$}
$endif$
}
% Letter
\begin{letter}{
\scriptsize{\textcolor{red}{Rechnungsadresse:}} \\
$if(to.name)$
$to.name$\\
$endif$
$if(to.address)$
$for(to.address)$
$to.address$\\
$endfor$
$endif$
}
\opening{}
% Table
% \footnotesize
\newcounter{pos}
\setcounter{pos}{0}
\STautoround*{2}
\STsetdecimalsep{,}
\begin{spreadtab}{{tabular}[t t t t t t t]{lp{7.6cm}rrrr}}
\hdashline[1pt/1pt]
% \rowcolor{red}
% \rowcolor{gray!25}
% \rowcolor{gr}
@ \textbf{Pos.} & @ \textbf{Artikel} & @ \textbf{Art-Nr.} & @ \textbf{Menge} & @ \textbf{Einzelpreis} & @ \textbf{Gesamt} \\
\hline
$for(service)$
@ \refstepcounter{pos} \thepos &
@ $service.description$ &
@ $service.nr$ &
@ $service.count$ &
:={$service.price.per_unit$} $currency$ &
:={$service.price.total$} $currency$ \\
\hline
$endfor$
@ \noalign{\vskip 1.2cm} & @ & @ & @ & @ & @\\
$if(subtotal)$
@ & @ & @ \multicolumn{3}{l}{\textbf{Zwischensumme:}} & :={$subtotal$} $currency$ \\
$endif$
$if(shipping)$
@ & @ & @ \multicolumn{3}{l}{\textbf{Versandkosten:}} & :={$shipping$} $currency$ \\
$endif$
$if(VAT)$
@ & @ & @ \multicolumn{3}{l}{\textbf{USt. $VAT.rate$\%}} & :={$VAT.amount$} $currency$ \\
\noalign{\vskip 2mm} \hhline{~~----}
$endif$
\noalign{\vskip 2mm} & @ & @ \multicolumn{3}{l}{\textbf{Gesamtbetrag:}} & \textbf{:={$total$} $currency$} \\
\noalign{\vskip 2mm}\hhline{~~----}
\end{spreadtab}
\vspace{15mm}
% \useplength{toaddrhpos}
% \useplength{refvpos}
% Body
$body$
% Closing
\end{letter}
\end{document}

View File

@@ -0,0 +1,145 @@
\documentclass[
fontsize=10pt,
parskip=full,
paper=A4,
fromalign=off,
fromphone=false,
fromfax=false,
fromemail=false,
fromurl=false,
foldmarks=true,
version=last,
refline=wide
]{scrlttr2}
% Layout
\usepackage{geometry}
\geometry{a4paper, left=20mm, right=0mm, top=20mm, bottom=17mm}
% Typography
\usepackage[utf8]{inputenc}
\usepackage{fontspec}
\usepackage{eurosym}
\usepackage[hidelinks]{hyperref}
\defaultfontfeatures{Mapping=tex-text}
\setsansfont[Scale=0.9]{Noto Sans Regular}
\setmainfont[SmallCapsFeatures={LetterSpace=5,Letters=SmallCaps}]{Noto Sans Regular}
% Language
\usepackage[ngerman]{babel}
% Table Customization
\usepackage{spreadtab}
\usepackage{arydshln}
\usepackage{hhline}
\usepackage{enumitem}
\renewcommand{\arraystretch}{1.5} % Apply vertical padding to table cells
% \usepackage[table]{xcolor}
% \definecolor{gr}{rgb}{0.95,0.95,1}
% Letterhead
$if(letterhead)$
\usepackage{wallpaper}
\ULCornerWallPaper{1}{$letterhead$}
$endif$
% \setplength{refwidth}{10cm}
% \setplength{refhpos}{0pt}
\setplength{locwidth}{6cm}
\setplength{locvpos}{6cm}
% \showfields{test}
\begin{document}
\date{}
\setkomavar{subject}[left]{\LARGE \textit{\textcolor{red}{$subject$}}}
% Additional Information
\setkomavar{location}{\raggedright
$if(nr.invoice)$
\small\textit{\textbf{RECHNUNG Nr.} $nr.invoice$}\\
\scriptsize\textit{(Bitte bei Bezahlungen stets angeben)}
$endif$
$if(nr.shipping)$
\small \textit{\textbf{LIEFERSCHEIN Nr.} $nr.shipping$}\\
$endif$
$if(nr.customer)$
\small \textit{Kundennummer: $nr.customer$}\\
$endif$
$if(nr.order)$
\small \textit{Bestellnummer: $nr.order$}\\
$endif$
$if(date)$
\small \textit{Datum: $date$}
$endif$
}
% Letter
\begin{letter}{
\scriptsize{\textcolor{red}{Lieferadresse:}} \\
$if(to.name)$
$to.name$\\
$endif$
$if(to.address)$
$for(to.address)$
$to.address$\\
$endfor$
$endif$
}
\opening{}
% Table
% \footnotesize
\newcounter{pos}
\setcounter{pos}{0}
\STautoround*{2}
\STsetdecimalsep{,}
\begin{spreadtab}{{tabular}[t t t t t t t]{lp{7.6cm}rrrr}}
\hdashline[1pt/1pt]
% \rowcolor{red}
% \rowcolor{gray!25}
% \rowcolor{gr}
@ \textbf{Pos.} & @ \textbf{Artikel} & @ \textbf{Art-Nr.} & @ \textbf{Menge} & @ \textbf{Einzelpreis} & @ \textbf{Gesamt} \\
\hline
$for(service)$
@ \refstepcounter{pos} \thepos &
@ $service.description$ &
@ $service.nr$ &
@ $service.count$ &
:={$service.price.per_unit$} $currency$ &
:={$service.price.total$} $currency$ \\
\hline
$endfor$
@ \noalign{\vskip 1.2cm} & @ & @ & @ & @ & @\\
$if(subtotal)$
@ & @ & @ \multicolumn{3}{l}{\textbf{Zwischensumme:}} & :={$subtotal$} $currency$ \\
$endif$
$if(shipping)$
@ & @ & @ \multicolumn{3}{l}{\textbf{Versandkosten:}} & :={$shipping$} $currency$ \\
$endif$
$if(VAT)$
@ & @ & @ \multicolumn{3}{l}{\textbf{USt. $VAT.rate$\%}} & :={$VAT.amount$} $currency$ \\
\noalign{\vskip 2mm} \hhline{~~----}
$endif$
\noalign{\vskip 2mm} & @ & @ \multicolumn{3}{l}{\textbf{Gesamtbetrag:}} & \textbf{:={$total$} $currency$} \\
\noalign{\vskip 2mm}\hhline{~~----}
\end{spreadtab}
\vspace{15mm}
% \useplength{toaddrhpos}
% \useplength{refvpos}
% Body
$body$
% Closing
\end{letter}
\end{document}

7
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
if [ "$ENVIRONMENT" = "production" ]; then
exec gunicorn -w 4 -b 0.0.0.0:1111 src.app:app
else
exec python3 src/app.py
fi

15
makefile Normal file
View File

@@ -0,0 +1,15 @@
TEX = pandoc
FLAGS = --pdf-engine=xelatex
# Use the values of src, template, and output passed as arguments
$(output) : $(src)
$(TEX) $(src) -o $@ --template=$(template) $(FLAGS)
.PHONY: clean
clean :
rm -f /app/data/output/*.pdf
.PHONY: cleanall
cleanall : clean
rm -rf /app/data/output/*

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
blinker==1.6.2
click==8.1.7
Flask==2.3.3
flask-prometheus-metrics==1.0.0
gunicorn==21.2.0
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
prometheus-client==0.19.0
prometheus-flask-exporter==0.23.0
PyYAML==6.0.1
Werkzeug==2.3.7

331
src/app.py Normal file
View File

@@ -0,0 +1,331 @@
import html
import time
from enum import Enum
from flask import Flask, request, send_file, jsonify
from prometheus_flask_exporter import PrometheusMetrics
import subprocess
import tempfile
import os
import re
import json
import yaml
from typing import List, NamedTuple
import logging
class FilterRemoveDate(logging.Filter):
# '192.168.0.102 - - [30/Jun/2024 01:14:03] "%s" %s %s' -> '192.168.0.102 - "%s" %s %s'
pattern: re.Pattern = re.compile(r' - \[.+?]')
def filter(self, record: logging.LogRecord) -> bool:
record.msg = self.pattern.sub('', record.msg)
return True
class FilterReplaceWerkzeug(logging.Filter):
# 'werkzeug:' -> 'app:flask:'
pattern: re.Pattern = re.compile(r'werkzeug:')
def filter(self, record: logging.LogRecord) -> bool:
record.msg = self.pattern.sub('app:flask:', record.msg)
return True
class FilterReplaceLowercaseI(logging.Filter):
# 'I:' -> 'i:'
pattern: re.Pattern = re.compile(r'I:')
def filter(self, record: logging.LogRecord) -> bool:
record.msg = self.pattern.sub('i:', record.msg)
return True
# Setup logger
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s.%(msecs)03dZ %(name)s:%(levelname).1s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('app')
logger_werkzeug = logging.getLogger('werkzeug')
logger_werkzeug.addFilter(FilterRemoveDate())
logger_werkzeug.addFilter(FilterReplaceWerkzeug())
logger_werkzeug.addFilter(FilterReplaceLowercaseI())
app = Flask(__name__, static_folder=None)
metrics = PrometheusMetrics(app, defaults_prefix="pdf")
# Define a counter for successful pdf generation
pdf_generation_counter = metrics.counter(
'pdf_generation_total',
'Total number of pdfs generated',
labels={'endpoint': lambda: request.endpoint}
)
class Template(Enum):
INVOICE = "/app/data/templates/invoice-scrlttr2.tex"
SHIPPING = "/app/data/templates/shipping-note-scrlttr2.tex"
ORDER_CONFIRMATION = "/app/data/templates/order-confirmation.tex"
LETTERHEAD = "/app/data/templates/RE.pdf"
DETAILS = ""
template_to_label = {
Template.INVOICE: "invoice",
Template.SHIPPING: "shipping",
Template.ORDER_CONFIRMATION: "order_confirmation"
}
class SenderAddress(NamedTuple):
companyname: str
name: str
street: str
city: str
email: str
url: str
class RecipientAddress(NamedTuple):
name: str
address: List[str]
class Services(NamedTuple):
description: str
price: float
details: List[str]
class Details(NamedTuple):
subject: str
date: str
me: SenderAddress
to: RecipientAddress
invoice_nr: str
author: str
city: str
VAT: int
service: List[Services]
closingnote: str
def normalize(string: str):
return (
string
.lower()
.replace('ö', 'oe')
.replace('ä', 'ae')
.replace('ß', 'ss')
.replace('ü', 'ue')
.replace(',', '')
.replace('.', '')
.replace(' ', '')
)
def generate_pdf(template_path: Template, details_json: Details):
try:
logger = logging.getLogger('app:generate-pdf')
logger.info("Generating pdf")
logger.debug(details_json)
details_dict = json.loads(json.dumps(details_json))
details_yaml = yaml.dump(details_dict)
date = time.strftime("%Y%m%d")
label = template_to_label[template_path]
logger.debug(details_yaml)
with tempfile.TemporaryDirectory() as temp_dir:
# Write the details JSON to a YAML file
details_yaml_path = os.path.join(temp_dir, 'details.md')
with open(details_yaml_path, 'w') as yaml_file:
yaml_file.write("---\n")
yaml_file.write("letterhead: /app/data/templates/RE.pdf\n")
yaml_file.write(details_yaml)
yaml_file.write("...\n")
yaml_file.write(details_dict["body"])
normalized_recipient = normalize(details_dict["to"]["name"] or details_dict["to"]["address"][0])
output_base_path = os.path.join("/", "app", "data", "output", f'{date}-{label}-{normalized_recipient}')
logger.debug(normalized_recipient)
counter = 0
if os.path.exists(output_base_path + '.pdf'):
counter = 1
while os.path.exists(f'{output_base_path}-{counter}.pdf'):
counter += 1
output_path = f'{output_base_path}-{counter}.pdf'
else:
output_path = output_base_path + '.pdf'
logger.debug(f"Starting Pandoc")
logger.debug(f"Source YAML: {details_yaml_path}")
logger.debug(f"Template: {template_path.value}")
logger.debug(f"Output pdf: {output_path}")
result = subprocess.run([
'make',
'-e',
'-B',
f'src={details_yaml_path}',
f'template={template_path.value}',
f'output={output_path}'
])
if result.returncode != 0:
logger.error(f"Pdf generation failed: {result.stderr}")
return None
logger.info("Pdf generated successfully")
return output_path
except Exception as e:
logger.error(f"Error generating pdf: {str(e)}")
return None
@app.route('/v1')
@metrics.do_not_track()
def info():
today = time.strftime("%Y-%m-%d")
url_map = app.url_map
return f"<h1>pdf v1.0.0 - {today}</h1><pre>" + html.escape(str(url_map), False) + "</pre>"
@app.route('/v1/invoice', methods=['POST'])
@pdf_generation_counter
def generate_invoice():
logger = logging.getLogger('app:generate-invoice')
try:
details_json = request.json
logger.info("Generating invoice")
logger.debug(details_json)
template = Template.INVOICE
pdf_content = generate_pdf(template, details_json)
if pdf_content:
logger.info(f"Sending invoice pdf: {pdf_content}")
return send_file(pdf_content, mimetype='application/pdf')
else:
logger.error("Sending pdf failed")
return jsonify({"status": "failed"}), 500
except Exception as e:
logger.error(f"Error in generate_invoice: {str(e)}")
return jsonify({"status": "failed"}), 500
@app.route('/v1/shipping', methods=['POST'])
@pdf_generation_counter
def generate_shipping():
logger = logging.getLogger('app:generate-shipping-note')
try:
details_json = request.json
logger.info("Generating shipping note")
logger.debug(details_json)
template = Template.SHIPPING
pdf_content = generate_pdf(template, details_json)
if pdf_content:
logger.info(f"Sending shipping pdf: {pdf_content}")
return send_file(pdf_content, mimetype='application/pdf')
else:
logger.error("Sending pdf failed, generated pdf is empty")
return jsonify({"status": "failed"}), 500
except Exception as e:
logger.error(f"Error in generate_shipping: {str(e)}")
return jsonify({"status": "failed"}), 500
@app.route('/v1/order-confirmation', methods=['POST'])
@pdf_generation_counter
def generate_order_confirmation():
logger = logging.getLogger('app:generate-order-confirmation')
try:
details_json = request.json
logger.info("Generating order confirmation")
logger.debug(details_json)
template = Template.ORDER_CONFIRMATION
pdf_content = generate_pdf(template, details_json)
if pdf_content:
logger.info(f"Sending order confirmation pdf: {pdf_content}")
return send_file(pdf_content, mimetype='application/pdf')
else:
logger.error("pdf generation failed")
return "pdf generation failed", 500
except Exception as e:
logger.error(f"Error in generate_order_confirmation: {str(e)}")
return str(e), 400
@app.route('/v1/delete/pdf', methods=['DELETE'])
def delete_pdf():
logger = logging.getLogger('app:delete-pdf')
try:
subprocess.run([
'make',
'-e',
'clean'
])
logger.info("Cleaned up pdf files in /app/data/output directory.")
return jsonify({"status": "success"}), 204
except Exception as e:
logger.error(f"Failed to clean pdf files: {str(e)}")
return jsonify({"status": "failed"}), 500
@app.route('/v1/delete/all', methods=['DELETE'])
def delete_all():
logger = logging.getLogger('app:delete-all-pdf')
try:
subprocess.run([
'make',
'-e',
'cleanall'
])
logger.info("Cleaned up ALL files in /app/data/output directory.")
return jsonify({"status": "success"}), 204
except Exception as e:
logger.error(f"Failed to clean all files: {str(e)}")
return jsonify({"status": "failed"}), 500
@app.route('/health', methods=['GET'])
def health_check():
logger = logging.getLogger('app:health-check')
health_status = {'status': 'ok'}
# Check write access to output directory
output_dir = "/app/data/output"
try:
test_file = os.path.join(output_dir, "test_write.txt")
with open(test_file, "w") as f:
f.write("test")
os.remove(test_file)
health_status['output_dir_writable'] = True
except Exception as e:
logger.error(f"Output directory not writable: {str(e)}")
health_status['output_dir_writable'] = False
health_status['status'] = 'error'
# Check if Pandoc is responding
try:
result = subprocess.run(['pandoc', '--version'], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
health_status['pandoc_responding'] = True
else:
health_status['pandoc_responding'] = False
health_status['status'] = 'error'
except subprocess.TimeoutExpired:
logger.error("Pandoc check timed out")
health_status['pandoc_responding'] = False
health_status['status'] = 'error'
except Exception as e:
logger.error(f"Error checking Pandoc: {str(e)}")
health_status['pandoc_responding'] = False
health_status['status'] = 'error'
logger.debug(f"Health check performed: {health_status}")
return jsonify(health_status)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=1111, debug=False)