diff --git a/bin/test-pdf-score b/bin/test-pdf-score
new file mode 100755
index 0000000000000000000000000000000000000000..6181a062599b1b18c2fead236a8604652769c106
--- /dev/null
+++ b/bin/test-pdf-score
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import argparse
+
+import mo.db as db
+import mo.jobs.score
+from mo.score import Score
+from mo.util import init_standalone
+import os
+import shutil
+from sqlalchemy.orm import joinedload
+
+parser = argparse.ArgumentParser(description='Testuje generování výsledkových listin v PDF')
+parser.add_argument('--contest', type=int, metavar='ID', help='soutěž')
+parser.add_argument('--year', type=int, metavar='ID', help='ročník')
+
+args = parser.parse_args()
+
+init_standalone()
+sess = db.get_session()
+
+ctq = sess.query(db.Contest).join(db.Contest.round)
+if args.contest is not None:
+    ctq = ctq.filter(db.Contest.contest_id == args.contest)
+else:
+    ctq = ctq.filter(db.Round.round_type != db.RoundType.other)
+if args.year is not None:
+    ctq = ctq.filter(db.Round.year == args.year)
+
+contests = ctq.options(joinedload(db.Contest.round)).all()
+
+for ct in contests:
+    r = ct.round
+    print(f'#{ct.contest_id}: {r.round_code_short()} ({ct.place.get_code()})')
+
+    score = Score(r.master, ct)
+    results = score.get_sorted_results()
+
+    for severity, msg in score.get_messages():
+        if severity != 'info':
+            print(f'\t{severity}: {msg}')
+
+    temp_dir = f'tmp/score/{r.round_code_short()}/{ct.place.get_code()}'
+    shutil.rmtree(temp_dir, ignore_errors=True)
+    os.makedirs(temp_dir, exist_ok=True)
+
+    pdf = mo.jobs.score.make_score_pdf(temp_dir, score, results)
+
+    final = f'{temp_dir}.pdf'
+    try:
+        os.unlink(final)
+    except FileNotFoundError:
+        pass
+    os.link(pdf, final)
+
+    print(f'\t=> {final}')
diff --git a/db/db.ddl b/db/db.ddl
index be72a0c8a6b2cb3f16c4ecacb34d1905b52645cd..2a4bbf191cfaa25763b9866d6ea5440048c9ba68 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -164,6 +164,7 @@ CREATE TABLE contests (
 	place_id		int		NOT NULL REFERENCES places(place_id),
 	state			round_state	NOT NULL DEFAULT 'preparing',	-- používá se, pokud round.state='delegate', jinak kopíruje round.state
 	scoretable_id		int		DEFAULT NULL,			-- odkaz na snapshot představující oficiální výsledkovou listinu soutěže
+	tex_hacks		text		NOT NULL DEFAULT '',		-- speciální nastavení pro sazbu výsledkovky
 	UNIQUE (round_id, place_id)
 );
 
diff --git a/db/upgrade-20220728.sql b/db/upgrade-20220728.sql
new file mode 100644
index 0000000000000000000000000000000000000000..f0514049869632556d0c893ed868b518832c4743
--- /dev/null
+++ b/db/upgrade-20220728.sql
@@ -0,0 +1,4 @@
+SET ROLE 'mo_osmo';
+
+ALTER TABLE contests ADD COLUMN
+	tex_hacks		text		NOT NULL DEFAULT '';
diff --git a/dev-requirements.txt b/dev-requirements.txt
index c13db9a2fb4443e96789d5b7528ae07b7c1d26b6..821ea8324ecb0f07cbaf60ba19705100200aeccd 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -13,4 +13,7 @@ python-lsp-server
 rope
 sqlacodegen
 sqlalchemy-stubs
+types-python-dateutil
+types-requests
+whatthepatch
 yapf
diff --git a/mo/db.py b/mo/db.py
index 3efc429f8ca1b30fc4c6e94668c8dc88ce14c839..f88b6b617f5f9c1e7edc955869d5618cc47fc46a 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -217,6 +217,9 @@ class RoundType(MOEnum):
     def friendly_name(self) -> str:
         return round_type_names[self]
 
+    def friendly_name_genitive(self) -> str:
+        return round_type_names_genitive[self]
+
     def letter(self) -> Optional[str]:
         """Písmena se používají při exportu výsledkovky na web MO"""
         return round_type_letters[self]
@@ -231,6 +234,15 @@ round_type_names = {
     RoundType.other: 'jiné',
 }
 
+round_type_names_genitive = {
+    RoundType.domaci: 'domácího kola',
+    RoundType.skolni: 'školního kola',
+    RoundType.okresni: 'okresního kola',
+    RoundType.krajske: 'krajského kola',
+    RoundType.ustredni: 'ústředního kola',
+    RoundType.other: 'jiného kola',
+}
+
 round_type_letters = {
     RoundType.domaci: 'D',
     RoundType.skolni: 'S',
@@ -439,6 +451,7 @@ class Contest(Base):
     place_id = Column(Integer, ForeignKey('places.place_id'), nullable=False)
     state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state"))
     scoretable_id = Column(Integer, ForeignKey('score_tables.scoretable_id'), nullable=True)
+    tex_hacks = Column(Text, nullable=False, server_default=text("''::text"))
 
     master = relationship('Contest', primaryjoin='Contest.master_contest_id == Contest.contest_id', remote_side='Contest.contest_id', post_update=True)
     place = relationship('Place')
diff --git a/mo/jobs/protocols.py b/mo/jobs/protocols.py
index 63c58628f328b3b73ed4955c55604f6ceccae7a2..729b0e52ef711f8c30275435e1cfbe91c78f9e9f 100644
--- a/mo/jobs/protocols.py
+++ b/mo/jobs/protocols.py
@@ -18,7 +18,7 @@ import mo
 import mo.config as config
 import mo.db as db
 from mo.jobs import TheJob, job_handler
-from mo.util import logger, part_path
+from mo.util import logger, part_path, tex_arg
 import mo.util_format
 
 
@@ -54,14 +54,6 @@ def schedule_create_protocols(contest: db.Contest, site: Optional[db.Place], for
     the_job.submit()
 
 
-def tex_arg(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 + '}'
-
-
 def _get_user_id_query(contest: db.Contest, site_id: Optional[int]) -> Query:
     q = db.get_session().query(db.Participation.user_id).filter(
         db.Participation.contest == contest,
diff --git a/mo/jobs/score.py b/mo/jobs/score.py
index 32f44a04a9c7eea6017d41f85ed01aa5d6ed9214..452e6662721570cd88d3272242e0249d284522ae 100644
--- a/mo/jobs/score.py
+++ b/mo/jobs/score.py
@@ -3,20 +3,22 @@
 import decimal
 import json
 import os
-import subprocess
+import re
 import requests
-from typing import Any, Dict, Iterable, Optional
+import subprocess
+from typing import Any, Dict, Iterable, Optional, List
 
 import mo
 import mo.config as config
 import mo.db as db
 from mo.jobs import TheJob, job_handler
-from mo.score import Score
+from mo.score import Score, ScoreResult
+from mo.util import tex_arg, assert_not_none
 from mo.util_format import format_decimal
 
 
 #
-# Job snapshot_score: Vytvoří kopii aktuální výsledkovky (TODO: a pdf k ní)
+# Job snapshot_score: Vytvoří kopii aktuální výsledkovky
 #
 # Vstupní JSON:
 #        { 'contest_id': int,
@@ -45,17 +47,95 @@ class OrderKeyEncoder(json.JSONEncoder):
         return super().encode(o)
 
 
-def make_snapshot_score_pdf(the_job: TheJob, score: db.ScoreTable) -> str:
-    job = the_job.job
-    contest = score.contest
-    round = contest.round
+def order_to_tex(result: ScoreResult) -> str:
+    if not result.show_order:
+        return ""
+    order = result.order
+    if order.continuation:
+        return ""
+    elif order.span > 1:
+        return f'{order.place}.--{order.place + order.span -1}.'
+    else:
+        return f'{order.place}.'
 
-    temp_dir = job.dir_path()
 
-    tex_src = os.path.join(temp_dir, 'score.tex')
+def parse_tex_hacks(tex_hacks: str) -> Dict[str, str]:
+    hacks = {}
+    fields = re.split(r'(\w+)={([^}]*)}', tex_hacks)
+    for i, f in enumerate(fields):
+        if i % 3 == 0:
+            if f.strip() != "":
+                raise RuntimeError('Chyba při parsování tex_hacks')
+        elif i % 3 == 1:
+            hacks[f] = fields[i + 1]
+    return hacks
+
+
+def format_hacks(tex_hacks: str) -> str:
+    # Nemůžeme uživatelům dovolit předávat TeXu libovolné příkazy,
+    # protože bychom jim například zpřístupnili celý filesystem.
+    lines = []
+    for k, v in parse_tex_hacks(tex_hacks).items():
+        lines.append('\\hack' + k + tex_arg(v) + '\n')
+    return "".join(lines)
+
+
+def make_score_pdf(temp_dir: str, score: Score, results: List[ScoreResult]) -> str:
+    # Pozor, toto se volá i z pomocných skriptů
 
+    contest = assert_not_none(score.contest)
+    round = contest.round
+    tasks = score.get_tasks()
+
+    tex_src = os.path.join(temp_dir, 'score.tex')
     with open(tex_src, 'w') as f:
-        f.write('Zde bude výsledkovka\\bye\n')
+        f.write('\\input vysledky.tex\n\n')
+
+        hlavicka = f'Výsledková listina {round.round_type.friendly_name_genitive()} {round.year}. ročníku MO kategorie {round.category}'
+        f.write('\\def\\hlavicka' + tex_arg(hlavicka) + '\n')
+        if round.level > 0 and contest.place.name:
+            f.write('\\def\\misto' + tex_arg(contest.place.name) + '\n')
+        f.write(format_hacks(contest.tex_hacks))
+        f.write('\n')
+
+        last_type = ""
+
+        f.write('\\scoretable{\n')
+        for result in score.get_sorted_results():
+            if result.winner:
+                type = 'winner'
+            elif result.successful:
+                type = 'successful'
+            else:
+                type = 'normal'
+
+            if type != last_type:
+                f.write('\\section' + tex_arg(type) + '\n')
+                last_type = type
+
+            cols = [
+                order_to_tex(result),
+                result.user.full_name(),
+                result.pant.grade,
+                result.pant.school_place.name,
+            ]
+            f.write('\\row' + "".join(map(tex_arg, cols)))
+
+            f.write('{')
+            sols = result.get_sols_map()
+            for task in tasks:
+                sol = sols.get(task.task_id)
+                if sol is not None and sol.points is not None:
+                    pts = assert_not_none(format_decimal(sol.points))
+                else:
+                    pts = '\\n'
+                f.write('\\p{' + pts + '}')
+            f.write('}')
+
+            f.write(tex_arg(format_decimal(result.get_total_points())) + '\n')
+
+        f.write('}\n')
+        f.write('\n\\bye\n')
 
     env = dict(os.environ)
     env['TEXINPUTS'] = mo.util.part_path('tex') + '//:'
@@ -69,10 +149,17 @@ def make_snapshot_score_pdf(the_job: TheJob, score: db.ScoreTable) -> str:
         stdout=subprocess.DEVNULL,
         stderr=subprocess.DEVNULL,
     )
-    dir = str(contest.contest_id)
+
+    return os.path.join(temp_dir, 'score.pdf')
+
+
+def make_snapshot_score_pdf(the_job: TheJob, contest: db.Contest, score: Score, results: List[ScoreResult]) -> str:
+    temp_dir = the_job.job.dir_path()
+    temp_pdf = make_score_pdf(temp_dir, score, results)
+    dir = contest.round.round_code_short()
     full_dir = os.path.join(mo.util.data_dir('score'), dir)
     os.makedirs(full_dir, exist_ok=True)
-    full_name = mo.util.link_to_dir(os.path.join(temp_dir, 'score.pdf'), full_dir, suffix='.pdf')
+    full_name = mo.util.link_to_dir(temp_pdf, full_dir, suffix='.pdf')
     return os.path.join(dir, os.path.basename(full_name))
 
 
@@ -96,9 +183,9 @@ def handle_snapshot_score(the_job: TheJob):
     snapshot_rows = []
     for result in results:
         order: Optional[dict] = None
-        if result.successful or not score.want_successful:
+        if result.show_order:
             order = result.order.__dict__
-        snapshot_row = {
+        snapshot_row: Dict[str, Any] = {
             'order': order,
             'winner': result.winner,
             'successful': result.successful,
@@ -125,7 +212,7 @@ def handle_snapshot_score(the_job: TheJob):
 
     }
 
-    pdf_file = make_snapshot_score_pdf(the_job, score)
+    pdf_file = make_snapshot_score_pdf(the_job, contest, score, results)
 
     score_table = db.ScoreTable(
         contest_id=ct_id,
@@ -175,6 +262,7 @@ def api_params(contest: db.Contest) -> Dict[str, Any]:
         'region': contest.place.nuts,
     }
 
+
 def schedule_export_score_to_mo_web(score_table: db.ScoreTable) -> None:
     the_job = TheJob()
     job = the_job.create(db.JobType.export_score_to_mo_web, db.get_system_user())
diff --git a/mo/rights.py b/mo/rights.py
index 6735c2c2b1cbc8ae59cbb30ba853ae0656756edc..542f63da92aaf66cea5694e967d709f7f128f222 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -32,6 +32,7 @@ class Right(Enum):
     edit_school_users = auto()      # Editovat uživatele ze své školy (jen garant_skola)
     add_orgs = auto()
     edit_orgs = auto()
+    edit_tex_hacks = auto()         # Nastavovat hacky pro sazbu výsledkovek TeXem
 
 
 @dataclass
@@ -67,6 +68,7 @@ roles: List[Role] = [
             Right.edit_all_users,
             Right.add_orgs,
             Right.edit_orgs,
+            Right.edit_tex_hacks,
         },
     ),
     Role(
diff --git a/mo/score.py b/mo/score.py
index 04440a2bdae59da18dfef66610287fef3ede128e..24a211a3ef07a7055bf08e30c4c9a78a27877b2d 100644
--- a/mo/score.py
+++ b/mo/score.py
@@ -21,6 +21,10 @@ class ScoreOrder:
         self.span = span
         self.continuation = continuation
 
+    def __repr__(self) -> str:
+        cont = '/cont' if self.continuation else ""
+        return f'ScoreOrder({self.place}+{self.span}{cont})'
+
 
 class ScoreResult:
     user: db.User
@@ -29,6 +33,7 @@ class ScoreResult:
     order: ScoreOrder
     successful: bool
     winner: bool
+    show_order: bool
 
     # Řešení jednotlivých kol (pro některá řazení je potřeba znát i výsledky
     # z předcházejících kol). První index je krok (0 = toto kolo, 1 = předcházející, ...)
@@ -49,6 +54,7 @@ class ScoreResult:
         self.order = ScoreResult._null_score_order
         self.winner = False
         self.successful = False
+        self.show_order = True
         self._order_key = []
 
     def get_sols(self) -> List[db.Solution]:
@@ -64,6 +70,10 @@ class ScoreResult:
                 sum += sol.points
         return sum
 
+    def __repr__(self) -> str:
+        hide = "" if self.show_order else '(hidden)'
+        return f'ScoreResult(user=#{self.user.user_id} order={self.order}{hide} winner={self.winner} succ={self.successful})'
+
 
 class ScoreTask:
     task: db.Task
@@ -232,6 +242,8 @@ class Score:
             total_points = result.get_total_points()
             result.winner = self.want_winners and total_points >= self.round.score_winner_limit
             result.successful = self.want_successful and total_points >= self.round.score_successful_limit
+            if self.want_successful and not result.successful:
+                result.show_order = False
 
     def _load_prev_round(self, step: int) -> bool:
         """Načtení úloh a řešení předchozího kola, pokud takové existuje."""
@@ -375,11 +387,12 @@ class Score:
                 points_from_max = list(sorted(sol_points.values()))
                 points_from_difficult = [sol_points[task_id] for task_id in tasks_by_difficulty]
 
-                if result.successful or not self.want_successful:
+                if result.show_order:
                     # Primárně podle počtu získaných bodů, sekundárně podle bodů od maxima, terciárně podle bodů od nejobtížnější
                     result._order_key.append((total_points, points_from_max, points_from_difficult))
                 else:
-                    # Neúspěšné řešitele třídíme podle počtu získaných bodů, sekundárně podle jména, jednoznačně podle user_id
+                    # Neúspěšné řešitele bez uvedeného pořadí třídíme podle počtu získaných bodů, sekundárně podle jména,
+                    # jednoznačně podle user_id
                     result._order_key.append((total_points, result.user.name_sort_key(), result.user.user_id))
 
             # Otestujeme, jestli teď existují sdílená místa
diff --git a/mo/tex/mo-lib.tex b/mo/tex/mo-lib.tex
new file mode 100644
index 0000000000000000000000000000000000000000..57a1bcf293541eca3f2d51e97d3e3b14184d8e5c
--- /dev/null
+++ b/mo/tex/mo-lib.tex
@@ -0,0 +1,37 @@
+% Obecná makra pro sazbu věcí okolo MO
+
+\newbox\ellipsisbox
+\setbox\ellipsisbox=\hbox{\bf~\dots~~}
+
+\directlua{
+	function cut_box(box_nr, max_w)
+		local box = tex.box[box_nr]
+		% nodetree.analyze(box)
+		local n
+		local total_w = 0
+		local last_visible
+		for n in node.traverse(box.head) do
+			local w, h, d = node.dimensions(n, n.next)
+			total_w = total_w + w
+			if total_w > max_w then
+				local new = node.copy_list(box.head, last_visible.next)
+				tex.box[box_nr] = node.hpack(new)
+				% nodetree.analyze(tex.box[box_nr])
+				return
+			end
+			if n.id == 0 or n.id == 2 or n.id == 29 then	% hlist, rule, glyph
+				last_visible = n
+			end
+		end
+	end
+}
+
+\def\limitedbox#1#2{%
+	\setbox0=\hbox{#2}%
+	\ifdim \wd0 > #1\relax
+		\dimen0=\dimexpr #1 - \wd\ellipsisbox\relax
+		\directlua{cut_box(0, tex.dimen[0])}%
+		\setbox0=\hbox{\box0\copy\ellipsisbox}%
+	\fi
+	\box0
+}
diff --git a/mo/tex/protokol.tex b/mo/tex/protokol.tex
index 0d494403ef04e7dbb60415efaad04dbfca8097f3..1cd939351ca2f0d801777e6d3138ccca9abf1546 100644
--- a/mo/tex/protokol.tex
+++ b/mo/tex/protokol.tex
@@ -1,6 +1,7 @@
 \input ltluatex.tex
 \input luatex85.sty
 \input ucwmac2.tex
+\input mo-lib.tex
 
 \setmargins{15mm}
 \setuppage
@@ -23,42 +24,6 @@
 \def\kolo{TODO}
 \def\kat{TODO}
 
-\newbox\ellipsisbox
-\setbox\ellipsisbox=\hbox{\bf~\dots~~}
-
-\directlua{
-	function cut_box(box_nr, max_w)
-		local box = tex.box[box_nr]
-		% nodetree.analyze(box)
-		local n
-		local total_w = 0
-		local last_visible
-		for n in node.traverse(box.head) do
-			local w, h, d = node.dimensions(n, n.next)
-			total_w = total_w + w
-			if total_w > max_w then
-				local new = node.copy_list(box.head, last_visible.next)
-				tex.box[box_nr] = node.hpack(new)
-				% nodetree.analyze(tex.box[box_nr])
-				return
-			end
-			if n.id == 0 or n.id == 2 or n.id == 29 then	% hlist, rule, glyph
-				last_visible = n
-			end
-		end
-	end
-}
-
-\def\limitedbox#1#2{%
-	\setbox0=\hbox{#2}%
-	\ifdim \wd0 > #1\relax
-		\dimen0=\dimexpr #1 - \wd\ellipsisbox\relax
-		\directlua{cut_box(0, tex.dimen[0])}%
-		\setbox0=\hbox{\box0\copy\ellipsisbox}%
-	\fi
-	\box0
-}
-
 \def\field#1#2{\hbox to #1{\limitedbox{#1}{#2}\hss}}
 \def\fillin#1{\smash{\lower 2pt\hbox to #1{\hrulefill}}}
 
diff --git a/mo/tex/vysledky.tex b/mo/tex/vysledky.tex
new file mode 100644
index 0000000000000000000000000000000000000000..b5a7bd0159c7b8b111f7e0c5a98f966c9d7c7ed2
--- /dev/null
+++ b/mo/tex/vysledky.tex
@@ -0,0 +1,182 @@
+\input ltluatex.tex
+\input luatex85.sty
+\input ucwmac2.tex
+\input mo-lib.tex
+
+\setmargins{15mm}
+\setuppage
+\nopagenumbers
+\raggedbottom
+
+\ucwmodule{luaofs}
+\settextsize{12}
+
+\ofsdeclarefamily [Pagella] {%
+   \loadtextfam qplr;%
+                qplb;%
+                qpli;%
+                qplbi;;%
+}
+
+\def\MSfeat#1{:mode=node;script=latn;+tlig}
+\registertfm qplr      -      file:texgyrepagella-regular.otf\MSfeat{}
+\registertfm qplb      -      file:texgyrepagella-bold.otf\MSfeat{}
+\registertfm qpli      -      file:texgyrepagella-italic.otf\MSfeat{}
+\registertfm qplbi     -      file:texgyrepagella-bolditalic.otf\MSfeat{}
+
+\setfonts[Pagella/12]
+
+\uselanguage{czech}
+\frenchspacing
+
+% Základní údaje o soutěži
+\def\hlavicka{TODO}
+\def\misto{}
+\def\podhlavicka{}
+
+% Řádky tabulky
+\newtoks\rows
+
+\def\scoretable#1{
+	\heading
+	\rows={#1}
+	\findpointswidth
+	\tryone
+}
+
+\def\tryone{
+	% První pokus: vše na plnou šířku, široké mezery mezi sloupci
+	\colskip=20pt
+	\pointswidth=\dimexpr\rawpointswidth + 6pt\relax
+	\trytable
+	\ifdim\wd0 < \hsize
+		\finaltable
+	\else
+		\trytwo
+	\fi
+}
+
+\def\trytwo{
+	% Druhý pokus: vše na plnou šířku, ale zúžíme mezery mezi sloupci
+	\colskip=10pt
+	\pointswidth=\dimexpr\rawpointswidth + 4pt\relax
+	\trytable
+	\ifdim\wd0 < \hsize
+		\finaltable
+	\else
+		\trythree
+	\fi
+}
+
+\def\trythree{
+	% Třetí pokus: zkracujeme názvy škol
+	% Nejdřív si změříme, jak bude tabulka široká bez nich
+	{
+		\let\row=\rownoschools
+		\trytable
+		\global\schoolwd=\dimexpr\hsize - \wd0\relax
+	}
+	\let\row=\rowshortschools
+	\ifdim\schoolwd < 30pt
+		% Lepší je nechat tabulku trochu přetéci než zkrátit školy absurdně
+		\schoolwd=30pt
+	\fi
+	\finaltable
+}
+
+%%% Hlavička %%%
+
+\def\heading{
+	\centerline{\bf\hlavicka}
+	\ifx\misto\empty\else
+		\medskip
+		\centerline{\bf\misto}
+	\fi
+	\ifx\podhlavicka\empty\else
+		\medskip
+		\centerline{\bf\podhlavicka}
+	\fi
+	\bigskip
+	\hrule
+	\bigskip
+}
+
+%%% Oddíly výsledkovky %%%
+
+\def\section#1{%
+	\noalign{\goodbreak\bigskip}
+	\multispan6\hfil\bf \csname sec#1\endcsname\hfil\cr
+	\noalign{\nobreak\medskip}
+}
+
+\def\secwinner{Vítězové}
+\def\secsuccessful{Úspěšní řešitelé}
+\def\secnormal{Ostatní účastníci}
+
+%%% Hlavní tabulka %%%
+
+% Mezera mezi sloupci
+\newdimen\colskip
+\def\cs{\hskip\colskip}
+
+% Pružná mezera na levém a pravém okraji tabulky
+% (uplatní se pouze, pokud sázíme finální tabulku roztaženou na \hsize)
+\newskip\borderskip
+\borderskip=0pt plus 1fil minus 1fil
+
+\def\maintable#1{%
+	{\tabskip=\borderskip
+	\halign#1{%
+		\tabskip=0pt
+		##\hfil\cs&% pořadí
+		\it ##\hfil\cs&% jméno
+		\hfil##\hfil\cs&% ročník
+		##\hfil\cs&% škola
+		##\p{}&% body za úlohy (a pak mezera na šířku počtu bodů)
+		\hfil##% celkové body
+		\tabskip=\borderskip\cr
+		\the\rows
+	}}
+}
+
+% Volá se \row{1.}{Pokusný Králík}{3/4}{Gymnázium, Praha 8, U Libeňského zámku 1}{\p{10}\p{8}\p{\n}}{5}
+%              #1  #2              #3   #4                                        #5                 #6
+\def\row#1#2#3#4#5#6{#1&#2&#3&#4&#5&#6\cr}
+
+\def\rownoschools#1#2#3#4#5#6{#1&#2&#3&&#5&#6\cr}
+
+\newdimen\schoolwd
+\def\rowshortschools#1#2#3#4#5#6{#1&#2&#3&\limitedbox{\schoolwd}{#4}&#5&#6\cr}
+
+\def\p#1{\hbox to \pointswidth{\hfil #1}}
+\def\n{--}
+
+\def\trytable{
+	\setbox0=\vbox{\maintable{}}
+}
+
+\def\finaltable{
+	\maintable{to \hsize}
+}
+
+%%% Stanovení maximální šířky počtu bodů %%%
+
+\newdimen\rawpointswidth
+\newdimen\pointswidth
+
+\def\findpointswidth{{
+	\let\section=\ignoreone
+	\let\row=\fpwrow
+	\let\p=\fpwp
+	\setbox0=\vbox{\halign{\hfil ##\hfil\cr\the\rows}}
+	\global\rawpointswidth=\wd0
+}}
+
+\def\ignoreone#1{}
+
+\def\fpwrow#1#2#3#4#5#6{#5}
+\def\fpwp#1{\hfil #1\cr}
+
+%%% Hacky %%%
+
+\def\hackpodhlavicka#1{\def\podhlavicka{#1}}
diff --git a/mo/util.py b/mo/util.py
index d15232e3145c49ee8917b608c0902e0faa4d3ba6..ede00bc0375a1ee9d7da29ec5a6f2956528d8ce3 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -202,3 +202,16 @@ T = TypeVar('T')
 def assert_not_none(x: Optional[T]) -> T:
     assert x is not None
     return x
+
+
+def tex_arg(s: Any) -> 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.
+    if s is None:
+        s = ""
+    elif type(s) != str:
+        s = str(s)
+    s = re.sub(r'[\\{}#$%^~]', '?', s)
+    s = re.sub(r'([&_])', r'\\\1', s)
+    s = re.sub(r' - ', r' -- ', s)
+    return '{' + s + '}'
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index d312008099b29f1d879732987284c384d0839d2a..96ad0090e8ddaae0f343d2bee9236f295943f714 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1557,6 +1557,8 @@ class ContestEditForm(FlaskForm):
     state = wtforms.SelectField("Stav soutěže",
                                 choices=[ch for ch in db.RoundState.choices() if ch[0] != 'delegate'],
                                 coerce=db.RoundState.coerce)
+    tex_hacks = mo_fields.String("Nastavení sazby",
+                                 description="Speciální nastavení sazby výsledkové listiny (klíč={hodnota}).")
     submit = wtforms.SubmitField('Uložit')
     force_submit = wtforms.SubmitField('Uložit s chybami')
 
@@ -1575,6 +1577,9 @@ def org_contest_edit(ct_id: int):
         form.state.render_kw = {'disabled': ""}
         form.state.description = 'Nastavení kola neumožňuje měnit stav soutěže.'
 
+    if not ctx.rights.have_right(Right.edit_tex_hacks):
+        del form.tex_hacks
+
     do_submit = False
     errors = []
     offer_force_submit = False
diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index 4341ae6c78c015c3bf148401eda87db67b13ccd5..6ffaa654667b06fc9f78e1ff18c478cf5a638d14 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -183,7 +183,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_
         local_pion_ct_id = subcontest_id_map[(round.round_id, pion.contest_id)]
 
         order_cell: Union[Cell, str]
-        if result.successful or not score.want_successful:
+        if result.show_order:
             order_cell = OrderCell(result.order.place, result.order.span, result.order.continuation)
         else:
             order_cell = ""