diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index c482cb7afdf7f10518dd5219f4ecaee45e05041d..c4cd28bada4a3f42255602cf0a6c2960523a6a68 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -1,4 +1,5 @@
-from flask import render_template, g
+from flask import render_template, request, g
+from flask.helpers import url_for
 from sqlalchemy import and_
 from sqlalchemy.orm import joinedload
 from typing import List, Tuple, Optional, Dict
@@ -7,6 +8,62 @@ import werkzeug.exceptions
 import mo.db as db
 from mo.rights import Right
 from mo.web import app
+from mo.web.table import Cell, CellLink, Column, Row, Table, cell_user_link
+
+
+class OrderCell(Cell):
+    place: int
+    span: int
+    continuation: bool
+
+    def __init__(self, place: int, span: int = 1, continuation: bool = False):
+        self.place = place
+        self.span = span
+        self.continuation = continuation
+
+    def __str__(self) -> str:
+        if self.span == 1:
+            return f"{self.place}."
+        else:
+            return f"{self.place}.–{self.place + self.span - 1}."
+
+    def to_html(self) -> str:
+        if self.continuation:
+            return ""  # covered by rowspan cell above this one
+        elif self.span == 1:
+            return f"<td>{self.__str__()}"
+        else:
+            return f"<td rowspan='{self.span}'>{self.__str__()}"
+
+
+class SolPointsCell(Cell):
+    url: str
+    exists: bool
+    points: Optional[int]
+
+    def __init__(self, url: str):
+        self.url = url
+        self.exists = False
+        self.points = None
+
+    def __str__(self) -> str:
+        if not self.exists:
+            return '–'
+        elif self.points is None:
+            return '?'
+        return str(self.points)
+
+    def set_points(self, points: Optional[int]):
+        self.exists = True
+        self.points = points
+
+    def to_html(self) -> str:
+        if not self.exists:
+            return '<td>–'
+        a = f'<td><a href="{self.url}" title="Detail řešení">'
+        if self.points is None:
+            return a + '<span class="unknown">?</span></a>'
+        return a + str(self.points) + '</a>'
 
 
 @app.route('/org/contest/r/<int:round_id>/score')
@@ -16,6 +73,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
         raise werkzeug.exceptions.BadRequest()
     if round_id is not None and contest_id is not None:
         raise werkzeug.exceptions.BadRequest()
+    format = request.args.get('format', "")
 
     sess = db.get_session()
     user_id_subq = sess.query(db.Participation.user_id).join(
@@ -35,7 +93,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
         if not contest:
             raise werkzeug.exceptions.NotFound()
         round = contest.round
-        rr.get_for_contest(contest)
+        rr = g.gatekeeper.rights_for_contest(contest)
         contest_subq = [contest_id]
         user_id_subq = user_id_subq.filter(db.Participation.contest == contest)
 
@@ -69,65 +127,100 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
         ).all()
     )
 
-    class ResultOrder:
-        span: int
-        place: int
-
-        def format(self):
-            if self.span == 1:
-                return f"{self.place}."
-            else:
-                return f"{self.place}.–{self.place + self.span - 1}."
-
-    class Result:
-        def __init__(self, user: db.User, contest: db.Contest, school: db.School):
-            self.user = user
-            self.contest = contest
-            self.school = school
-            self.order = ResultOrder()
-            self.points: Dict[int, int] = {}
-            self.total_points = 0
-
-    results_map: Dict[int, Result] = {}
+    # Construct columns
+    columns = [
+        Column(key='order', name='poradi', title='Pořadí'),
+        Column(key='participant', name='ucastnik', title='Účastník'),
+    ]
+    if not contest_id:
+        columns.append(Column(key='contest', name='oblast', title='Soutěžní oblast'))
+    columns.append(Column(key='school', name='skola', title='Škola'))
+    columns.append(Column(key='grade', name='rocnik', title='Ročník'))
+    for task in tasks:
+        title = task.code
+        if contest_id:
+            title = '<a href="{}">{}</a>'.format(
+                url_for('org_contest_task_submits', contest_id=contest_id, task_id=task.task_id),
+                task.code
+            )
+            if rr.have_right(Right.edit_points) and round.state == db.RoundState.grading:
+                title += ' (<a href="{}" title="Editovat body">✎</a>)'.format(
+                    url_for('org_contest_task_points', contest_id=contest_id, task_id=task.task_id),
+                )
+        columns.append(Column(key=f'task_{task.task_id}', name=task.code, title=title))
+    columns.append(Column(key='total_points', name='celkove_body', title='Celkové body'))
+
+    # Construct rows
+    rows_map: Dict[int, Row] = {}
     for user, pion, pant in data:
-        results_map[user.user_id] = Result(user, pion.contest, pant.school_place)
-
+        school = pant.school_place
+        row = Row(keys={
+            'user':         user,
+            'participant':  cell_user_link(user, user.full_name()),
+            'contest':      CellLink(pion.contest.place.name, url_for('org_contest', id=pion.contest_id)),
+            'school':       CellLink(school.name, url_for('org_place', id=school.place_id)),
+            'grade':        pant.grade,
+            'total_points': 0,
+        })
+        for task in tasks:
+            row.keys[f'task_{task.task_id}'] = SolPointsCell(
+                url_for('org_submit_list', contest_id=pion.contest_id, user_id=user.user_id, task_id=task.task_id)
+            )
+        rows_map[user.user_id] = row
     for sol in sols:
-        results_map[sol.user_id].points[sol.task_id] = sol.points
+        rows_map[sol.user_id].keys[f'task_{sol.task_id}'].set_points(sol.points)
         if sol.points:
-            results_map[sol.user_id].total_points += sol.points
+            rows_map[sol.user_id].keys['total_points'] += sol.points
 
-    results = list(results_map.values())
+    rows = list(rows_map.values())
 
     # FIXME: Pokud to chceme přetavit do finální výsledkovky, tak musíme
     # předefinovat následující funkci (a udělat to konfigurovatelné, protože
     # různé olympiády i různé ročníky to mohou mít různě?)
     def get_result_cmp(result):
-        return -result.total_points
+        return -result.keys['total_points']
 
     def get_result_full_cmp(result):
-        return (get_result_cmp(result), result.user.last_name, result.user.first_name)
+        return (get_result_cmp(result), result.keys['user'].last_name, result.keys['user'].first_name)
 
-    results.sort(key=get_result_full_cmp)
-    last = None
-    for result in results:
+    rows.sort(key=get_result_full_cmp)
+
+    # Spočítáme pořadí - v prvním kroku prolinkujeme opakující se OrderCell na první,
+    # ve druhém kroku je pak správně rozkopírujeme s nastaveným continuation na True
+    last: Row = None
+    for row in rows:
         if last is None:
-            result.order.place = 1
-            result.order.span = 1
-            last = result
-        elif get_result_cmp(last) == get_result_cmp(result):
-            result.order.place = last.order.place
-            result.order.span = 0
-            last.order.span += 1
+            row.keys['order'] = OrderCell(1)
+            last = row
+        elif get_result_cmp(last) == get_result_cmp(row):
+            row.keys['order'] = last.keys['order']
+            last.keys['order'].span += 1
+        else:
+            row.keys['order'] = OrderCell(last.keys['order'].place + last.keys['order'].span)
+            last = row
+    lastOrder: OrderCell = None
+    for row in rows:
+        if row.keys['order'] == lastOrder:
+            row.keys['order'] = OrderCell(lastOrder.place, lastOrder.span, True)
         else:
-            result.order.place = last.order.place + last.order.span
-            result.order.span = 1
-            last = result
-
-    return render_template(
-        'org_score.html',
-        contest=contest, round=round, tasks=tasks,
-        results=results,
-        can_edit_points=rr.have_right(Right.edit_points) and round.state == db.RoundState.grading,
-        db=db,  # kvůli hodnotám enumů
+            lastOrder = row.keys['order']
+
+    filename = f"vysledky_{round.year}-{round.category}-{round.level}"
+    if contest_id:
+        filename += f"_oblast_{contest.place.code or contest.place.place_id}"
+    table = Table(
+        table_class="data full center",
+        columns=columns,
+        rows=rows,
+        filename=filename,
     )
+
+    if format == "":
+        return render_template(
+            'org_score.html',
+            contest=contest, round=round, tasks=tasks,
+            table=table,
+            db=db,  # kvůli hodnotám enumů
+        )
+    else:
+        return table.send_as(format)
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index 7a22596cb6f705c35140fe5573575d490c1572d8..db1f4a3d0927abf4b3214a8020d4db545fc5d8f2 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -17,42 +17,6 @@
 <p>Pořadí je neoficiální (seřazené podle součtu bodů s dělenými místy), testovací
 uživatelé jsou skryti. Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 
-<table class="data full center">
-	<thead>
-		<tr>
-			<th>Pořadí
-			<th>Účastník
-			{% if not contest %}<th>Soutěžní oblast{% endif %}
-			<th>Škola
-			{% for task in tasks %}<th>
-				{% if contest %}
-					<a href="{{ url_for('org_contest_task_submits', contest_id=contest.contest_id, task_id=task.task_id) }}">{{ task.code }}</a>
-					{% if can_edit_points %}
-					(<a title="Editovat body" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}">✎</a>)
-					{% endif %}
-				{% else %}{{ task.code }}{% endif %}
-			{% endfor %}
-			<th>Celkové body
-		</tr>
-	</thead>
-	{% for result in results %}
-	<tr>
-		{% if result.order.span > 0 %}<td{% if result.order.span > 1 %} rowspan={{ result.order.span }}{% endif %}>
-			{{ result.order.format() }}
-		{% endif %}
-		<th>{{ result.user|user_link }}
-		{% if not contest %}<td><a href="{{ url_for('org_contest', id=result.contest.contest_id) }}">{{ result.contest.place.name }}</a>{% endif %}
-		<td><a href="{{ url_for('org_place', id=result.school.place_id) }}">{{ result.school.name }}</a>
-		{% for task in tasks %}
-			<td>{% if task.task_id in result.points %}
-				<a title="Detail řešení" href="{{ url_for('org_submit_list', contest_id=result.contest.contest_id, user_id=result.user.user_id, task_id=task.task_id) }}">
-					{% if result.points[task.task_id] is not none %}{{ result.points[task.task_id] }}{% else %}<span class="unknown">?</span>{% endif %}
-				</a>
-			{% else %}–{% endif %}
-		{% endfor %}
-		<th>{{ result.total_points }}</th>
-	</tr>
-	{% endfor %}
-</table>
+{{ table.to_html() }}
 
 {% endblock %}