diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py
index 31560882195ddd2f5bc8d4ddabb0970a47c1d983..4bef817612874468653420f04b0ccd7653253816 100644
--- a/mo/jobs/__init__.py
+++ b/mo/jobs/__init__.py
@@ -189,4 +189,5 @@ def job_handler(type: db.JobType):
 
 
 # Moduly implementující jednotlivé typy jobů
+import mo.jobs.protocols
 import mo.jobs.submit
diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd652cb89612fd46ea87fd572c7d96b7f8010da6
--- /dev/null
+++ b/mo/jobs/protocols.py
@@ -0,0 +1,120 @@
+# Implementace jobů na sazbu protokolů
+
+import os
+import re
+import shutil
+from sqlalchemy.orm import joinedload
+import subprocess
+import tempfile
+from typing import Optional
+
+import mo
+import mo.db as db
+from mo.jobs import TheJob, job_handler
+from mo.util import logger, data_dir, part_path
+import mo.util_format
+
+
+def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for_user: db.User):
+    place = site or contest.place
+
+    the_job = TheJob()
+    job = the_job.create(db.JobType.create_protocols, for_user)
+    job.description = f'Formuláře protokolů {contest.round.round_code_short()} {place.name}'
+    job.in_json = {
+        'contest_id': contest.contest_id,
+        'site_id': site.place_id if site else None,
+    }
+    the_job.submit()
+
+
+def tex_escape(s: str) -> str:
+    # Primitivní escapování do TeXu. Nesnaží se ani tak o věrnou intepretaci všech znaků,
+    # jako o zabránění pádu TeXu kvůli divným znakům.
+    s = re.sub(r'[\\{}#$%^]', '?', s)
+    s = re.sub(r'([&_])', r'\\\1', s)
+    return s
+
+
+@job_handler(db.JobType.create_protocols)
+def handle_create_protocols(the_job: TheJob):
+    """Vygeneruje formuláře protokolů.
+
+    Vstupní JSON:
+        { 'contest_id': ID contestu,
+          'site_id': ID soutěžního místa nebo none,
+        }
+
+    Výstupní JSON:
+        null
+    """
+
+    job = the_job.job
+    assert job.in_json is not None
+    contest_id = job.in_json['contest_id']  # type: ignore
+    site_id = job.in_json['site_id']  # type: ignore
+
+    sess = db.get_session()
+    contest = sess.query(db.Contest).options(joinedload(db.Contest.round)).get(contest_id)
+    assert contest is not None
+
+    user_subq = sess.query(db.Participation.user_id).filter_by(contest=contest)
+    if site_id is not None:
+        user_subq = user_subq.filter_by(place_id=site_id)
+    user_subq = (user_subq
+                 .filter(db.Participation.state.in_((db.PartState.invited, db.PartState.registered, db.PartState.present)))
+                 .subquery())
+
+    pants = (sess.query(db.Participant)
+             .options(joinedload(db.Participant.user), joinedload(db.Participant.school_place))
+             .filter(db.Participant.user_id.in_(user_subq))
+             .all())
+    pants.sort(key=lambda p: p.user.sort_key())
+
+    tasks = sess.query(db.Task).filter_by(round=contest.round).order_by(db.Task.code).all()
+
+    temp_dir = tempfile.mkdtemp(prefix='proto-', dir=data_dir('tmp'))
+    logger.debug('Job: Vytvářím protokoly v %s', temp_dir)
+
+    tex_src = os.path.join(temp_dir, 'proto.tex')
+    npages = 0
+    with open(tex_src, 'w') as f:
+        f.write('\\input protokol.tex\n\n')
+        kolo = f'{contest.round.name} {contest.round.year}. ročníku MO kategorie {contest.round.category}'
+        f.write('\\def\\kolo{' + kolo + '}\n\n')
+
+        for p in pants:
+            for t in tasks:
+                args = [
+                    ':'.join(['MO', contest.round.round_code_short(), t.code, str(p.user_id)]),
+                    p.user.full_name(),
+                    p.grade,
+                    p.school_place.name,
+                    t.code,
+                ]
+                f.write('\\proto' + "".join(['{' + tex_escape(x) + '}' for x in args]) + '\n')
+                npages += 1
+
+        f.write('\n\\bye\n')
+
+    if npages == 0:
+        job.result = 'Prázdný výstup'
+        return
+
+    env = dict(os.environ)
+    env['TEXINPUTS'] = part_path('tex') + '//:'
+
+    subprocess.run(
+        ['luatex', '--interaction=errorstopmode', 'proto.tex'],
+        check=True,
+        cwd=temp_dir,
+        env=env,
+        stdin=subprocess.DEVNULL,
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+    )
+
+    job.out_file = the_job.attach_file(os.path.join(temp_dir, 'proto.pdf'), '.pdf')
+    job.result = 'Celkem ' + mo.util_format.inflect_number(npages, 'strana', 'strany', 'stran')
+
+    shutil.rmtree(temp_dir)