diff --git a/.gitignore b/.gitignore
index 0db9e6a46b9fa4611784c4cce0d509c708f3a1d4..8015dc08236bd6b1e9c0b5261cd9ed9a862e9904 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,7 @@ tags
 
 # Database
 soitool/database
+soitool/testDatabase*
 
 __pycache__/
 
diff --git a/soitool/accept_reject_dialog.py b/soitool/accept_reject_dialog.py
new file mode 100644
index 0000000000000000000000000000000000000000..0e67f84ad72a0d62c963c635c342d92677652c14
--- /dev/null
+++ b/soitool/accept_reject_dialog.py
@@ -0,0 +1,42 @@
+"""Provides a superclass to use for all dialogs needing accept and reject."""
+
+from PySide2.QtWidgets import (
+    QHBoxLayout,
+    QVBoxLayout,
+    QPushButton,
+    QDialog,
+)
+
+
+class AcceptRejectDialog(QDialog):
+    """Dialog with accept and reject buttons.
+
+    The class is not meant to be used directly. It is meant to be the
+    superclass of all dialogs that include "accept" and "reject" -like buttons.
+
+    Button texts have meaningful defaults, but can be overriden by subclass.
+
+    Subclasses should include custom content by adding it to the
+    'layout_content' layout.
+    """
+
+    def __init__(self):
+        super().__init__()
+
+        self.button_ok = QPushButton("Ok")
+        self.button_cancel = QPushButton("Avbryt")
+
+        self.button_ok.clicked.connect(self.accept)
+        self.button_cancel.clicked.connect(self.reject)
+
+        self.layout_content = QVBoxLayout()
+        self.layout_accept_reject_buttons = QHBoxLayout()
+        self.layout_accept_reject_buttons.addWidget(self.button_ok)
+        self.layout_accept_reject_buttons.addWidget(self.button_cancel)
+        self.layout_content_and_buttons = QVBoxLayout()
+        self.layout_content_and_buttons.addLayout(self.layout_content)
+        self.layout_content_and_buttons.addLayout(
+            self.layout_accept_reject_buttons
+        )
+
+        self.setLayout(self.layout_content_and_buttons)
diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py
index 8a6c9a85e417cb5b00e799a00efb341e2390328f..5bde3a8ba14af685065fa8200d96e2148e36651a 100644
--- a/soitool/inline_editable_soi_view.py
+++ b/soitool/inline_editable_soi_view.py
@@ -9,8 +9,15 @@ from PySide2.QtWidgets import (
     QGraphicsScene,
     QGraphicsView,
     QGraphicsRectItem,
+    QGraphicsProxyWidget,
+)
+from PySide2.QtGui import (
+    QFont,
+    QPixmap,
+    QBrush,
+    QPalette,
+    QPainter,
 )
-from PySide2.QtGui import QFont, QPixmap, QBrush, QPalette, QPainter
 from PySide2.QtPrintSupport import QPrinter
 from soitool.soi import ModuleLargerThanBinError
 from soitool.dialog_wrappers import exec_warning_dialog
@@ -22,45 +29,153 @@ from soitool.serialize_export_import_soi import generate_soi_filename
 ATTACHMENT_NUMBERING_SCHEME = list(string.ascii_uppercase)
 
 
-class InlineEditableSOIView(QScrollArea):
-    """Widget som kan byttes ut med view, edit etc."""
+class ProxyLabelWithCustomQPrintText(QGraphicsProxyWidget):
+    """QGraphicsItem that prints a custom text when printed onto QPrint.
 
-    def is_widget_in_scene(self, widget):
-        """Indicate wether given widget already has a proxy in the scene."""
-        for proxy in self.proxies:
-            if proxy.widget() == widget:
-                return True
-        return False
+    Useful to have a piece of text be painted differently in a QGraphicsScene
+    depending on whether it's drawn to QPrint or not.
 
-    def ensure_proxies(self):
-        """Make sure all modules of the SOI have a proxy inside the scene."""
-        for module in self.soi.modules + self.soi.attachments:
-            if not self.is_widget_in_scene(module["widget"]):
-                proxy = self.scene.addWidget(module["widget"])
-                self.proxies.add(proxy)
+    Note that this class doesn't have to use a QLabel. It was done to KISS.
 
-    def mousePressEvent(self, _):
-        """Reorganize modules when pressed.
+    ## How it works
 
-        This is a temporary way to activate reorganization of widgets. Note
-        that will not be triggered by clicks on modules in the scene.
+    When the QGrahpicsScene wants to render it's items it first uses the
+    "bounding rects" of it's items to figure out which of them needs to be
+    redrawn. It then redraws items using their `paint` functions. By overriding
+    `paint` we can control how our item is drawn. One of the parameters to the
+    `paint` function is the QPainter that is being used, which we can use to
+    print custom text onto QPrinter.
 
-        If reorganization cannot occur because a module is too large the user
-        will be informed. It's worth noting that with the current
-        implementation this is the only way the user will be informed of this,
-        so reorganization that is not triggered here will not give feedback to
-        the user.
+    Parameters
+    ----------
+    default_text : str
+        Text to be drawn for all QPainters except QPrint.
+    printing_text : str
+        Text to be drawn if the QPainter is QPrint.
+    """
+
+    def __init__(self, default_text, printing_text):
+        super(ProxyLabelWithCustomQPrintText, self).__init__()
+
+        self.default_text = default_text
+        self.printing_text = printing_text
+
+        # self.boundingRect is updated at the end of this function, so this
+        # default value is in practice never used.
+        self.bounding_rect = QRectF(0, 0, 0, 0)
+
+        self.label = QLabel(self.default_text)
+        self.setWidget(self.label)
+
+        self.update_bounding_rect()
+
+    def update_bounding_rect(self):
+        """Update bounding rect property."""
+        self.bounding_rect = self.determine_bounding_rect()
+
+    def determine_bounding_rect(self):
+        """Calculate bounding rect that encapsulates both alternative strings.
+
+        From the docs: "Although the item's shape can be arbitrary, the
+        bounding rect is always rectangular, and it is unaffected by the
+        items' transformation." Link:
+        https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
+        Because of this we're returning a rectangle (starting at 0,0) that
+        encapsulates the self.widget(), seen from this items local coordinate
+        system. This class purposefully lets self.widget() have different
+        content depending on the QPainter, so we give a bounding rect that
+        encapsulates the largest content.
+
+        Returns
+        -------
+        QRectF
+            Bounding rect that enpasulates both alternative contents of
+            self.widget().
         """
-        try:
-            self.soi.reorganize()
-        except ModuleLargerThanBinError:
-            exec_warning_dialog(
-                text="Minst én av modulene er for store "
-                "for å passe på én side!",
-                informative_text="Programmet kan desverre ikke fikse dette "
-                "for deg. Se igjennom modulene og sjekk at "
-                "alle moduler er mindre enn én enkelt side.",
-            )
+        bounding_rect_default_text = self.label.fontMetrics().boundingRect(
+            self.default_text
+        )
+        bounding_rect_printing_text = self.label.fontMetrics().boundingRect(
+            self.printing_text
+        )
+        largest_width = max(
+            bounding_rect_default_text.width(),
+            bounding_rect_printing_text.width(),
+        )
+        largest_height = max(
+            bounding_rect_default_text.height(),
+            bounding_rect_printing_text.height(),
+        )
+        return QRectF(0, 0, largest_width, largest_height)
+
+    def paint(self, painter, option, widget):
+        """Overridden to paint text depending on `painter` parameter.
+
+        Source: https://doc.qt.io/qt-5/qgraphicsitem.html#paint
+
+        Parameter
+        ---------
+        painter : QPainter
+            QPainter that is painting the item. Used to determine which text
+            to draw.
+        option : QStyleOptionGraphicsItem
+            Passed on to superclass implementation. See source.
+        widget : QWidget
+            Passed on to superclass implementation. See source.
+        """
+        if isinstance(painter.device(), QPrinter):
+            self.label.setText(self.printing_text)
+        else:
+            self.label.setText(self.default_text)
+
+        # From Qt docs: "Prepares the item for a geometry change. Call this
+        # function before changing the bounding rect of an item to keep
+        # QGraphicsScene's index up to date."
+        # https://doc.qt.io/qt-5/qgraphicsitem.html#prepareGeometryChange
+        self.prepareGeometryChange()
+
+        # QLabel doesn't adjust it's size automatically, so do it here
+        # https://stackoverflow.com/a/47037607/3545896
+        self.label.adjustSize()
+
+        # Let super handle the actual painting
+        super(ProxyLabelWithCustomQPrintText, self).paint(
+            painter, option, widget
+        )
+
+    def boundingRect(self):
+        """Give QRectF that bounds this item. Overridden.
+
+        Overridden to provide custom bounding rect. Custom bounding rect is
+        needed because this item has two different contents depending on where
+        it is drawn, which Qt does not respect (or understand) out of the box.
+        Overrides this: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
+
+        This function is called by Qt to figure out how much space it needs.
+
+        See the docstring of `determine_bounding_rect` for how the bounding
+        rect is calculated.
+
+        If either of self.default_text or self.printing_text changes, or if
+        font / style is updated on self.label, self.bounding_rect needs to be
+        updated using `update_bounding_rect`.
+
+        Returns
+        -------
+        QRectF
+            Bounding rect.
+        """
+        return self.bounding_rect
+
+
+class InlineEditableSOIView(QScrollArea):
+    """Widget that allows for "inline" editing of an SOI. Also prints to PDF.
+
+    Parameters
+    ----------
+    soi : soitool.soi.SOI
+        SOI to edit.
+    """
 
     def __init__(self, soi):
         super().__init__()
@@ -74,6 +189,16 @@ class InlineEditableSOIView(QScrollArea):
         self.number_of_non_attachment_pages = 1
         self.proxies = set()
 
+        # NOTE: These variables are only included to support PDF output of the
+        # widget. When rendering self.scene onto a QPrinter this widget will
+        # use these variables to indicate that "copy number self.copy_current
+        # is being printed, out of a total of self.copy_total copies". By
+        # looping self.copy_total times, updating self.copy_current each time
+        # and rendering onto a QPrinter it is possible to print multiple
+        # copies of the SOI, each marked with a copy number.
+        self.copy_current = 1
+        self.copy_total = 3
+
         # Necessary to make the scroll area fill the space it's given
         self.setWidgetResizable(True)
 
@@ -94,7 +219,44 @@ class InlineEditableSOIView(QScrollArea):
 
         # self.launch_auto_zoom()
 
-    def produce_pdf(self, filename=None):
+    def is_widget_in_scene(self, widget):
+        """Indicate wether given widget already has a proxy in the scene."""
+        for proxy in self.proxies:
+            if proxy.widget() == widget:
+                return True
+        return False
+
+    def ensure_proxies(self):
+        """Make sure all modules of the SOI have a proxy inside the scene."""
+        for module in self.soi.modules + self.soi.attachments:
+            if not self.is_widget_in_scene(module["widget"]):
+                proxy = self.scene.addWidget(module["widget"])
+                self.proxies.add(proxy)
+
+    def mousePressEvent(self, _):
+        """Reorganize modules when pressed.
+
+        This is a temporary way to activate reorganization of widgets. Note
+        that will not be triggered by clicks on modules in the scene.
+
+        If reorganization cannot occur because a module is too large the user
+        will be informed. It's worth noting that with the current
+        implementation this is the only way the user will be informed of this,
+        so reorganization that is not triggered here will not give feedback to
+        the user.
+        """
+        try:
+            self.soi.reorganize()
+        except ModuleLargerThanBinError:
+            exec_warning_dialog(
+                text="Minst én av modulene er for store "
+                "for å passe på én side!",
+                informative_text="Programmet kan desverre ikke fikse dette "
+                "for deg. Se igjennom modulene og sjekk at "
+                "alle moduler er mindre enn én enkelt side.",
+            )
+
+    def produce_pdf(self, number_of_copies, resolution, filename=None):
         """Produce PDF using QGraphicsScene.
 
         Renders the QGraphicsScene-representation of the SOI as a PDF. This
@@ -107,6 +269,11 @@ class InlineEditableSOIView(QScrollArea):
 
         Parameters
         ----------
+        number_of_copies : int
+            Total number of copies to produce.
+        resolution : int
+            Resolution of PDF. Passed to
+            https://doc.qt.io/qt-5/qprinter.html#setResolution
         filename : str
             Name of file to store PDF in. If file exists it will be
             overwritten. Note that the filename should contain the extension
@@ -123,6 +290,7 @@ class InlineEditableSOIView(QScrollArea):
             filename = generate_soi_filename(self.soi) + ".pdf"
 
         printer = QPrinter(QPrinter.HighResolution)
+        printer.setResolution(resolution)
         printer.setOutputFormat(QPrinter.PdfFormat)
         printer.setOutputFileName(filename)
         printer.setPageSize(QPrinter.A4)
@@ -141,19 +309,32 @@ class InlineEditableSOIView(QScrollArea):
                     "filename '{}'".format(filename)
                 )
 
-            # Render each page to own PDF page
-            for i in range(self.number_of_pages_total):
+            # Update total number of copies from parameter
+            self.copy_total = number_of_copies
 
-                x = 0
-                y = self.soi.HEIGHT * i + self.soi.PADDING * i
+            for i in range(self.copy_total):
 
-                self.scene.render(
-                    painter,
-                    source=QRectF(x, y, self.soi.WIDTH, self.soi.HEIGHT),
-                )
+                # Update copy number and redraw pages so that it is reflected
+                # in the scene
+                self.copy_current = i + 1
+                self.draw_pages()
+
+                # Render each page to own PDF page
+                for j in range(self.number_of_pages_total):
+
+                    x = 0
+                    y = self.soi.HEIGHT * j + self.soi.PADDING * j
 
-                # If there are more pages, newPage
-                if i + 1 < self.number_of_pages_total:
+                    self.scene.render(
+                        painter,
+                        source=QRectF(x, y, self.soi.WIDTH, self.soi.HEIGHT),
+                    )
+
+                    # If there are more pages, newPage
+                    if j + 1 < self.number_of_pages_total:
+                        printer.newPage()
+                # If there are more copies, newPage
+                if i + 1 < self.copy_total:
                     printer.newPage()
         finally:
             painter.end()
@@ -279,19 +460,33 @@ class InlineEditableSOIView(QScrollArea):
         proxy.setRotation(-90)
 
         # Copy number
-        copy_number = QLabel("1 av N")
-        copy_number.setStyleSheet("background-color: rgba(0,0,0,0%)")
-        copy_number.setFont(QFont("Times New Roman", 40))
+
+        # Store for usage when placing copy number title and page number
+        copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100
+
+        # NOTE: See __init__ for explanation on self.copy_current and
+        # self.copy_total
+        proxy = ProxyLabelWithCustomQPrintText(
+            "1 av N", f"{self.copy_current} av {self.copy_total}"
+        )
+        proxy.label.setStyleSheet("background-color: rgba(0,0,0,0%)")
+        proxy.label.setFont(QFont("Times New Roman", 40))
+
+        # Need to call this when label of proxy changes. See function docstring
+        proxy.update_bounding_rect()
+
         # Source: https://stackoverflow.com/a/8638114/3545896
         # CAUTION: does not work if font is set through stylesheet
         label_width = (
-            copy_number.fontMetrics().boundingRect(copy_number.text()).width()
+            proxy.label.fontMetrics().boundingRect(proxy.label.text()).width()
         )
-        # Store for usage when placing copy number title and page number
-        copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100
-        copy_number.move(x + 15, copy_number_y_pos + label_width / 2)
-        proxy = self.scene.addWidget(copy_number)
+
+        # NOTE that this position is only correct for the default text. During
+        # PDF printing the ProxyLabelWithCustomQPrintText class will paint
+        # itself with a different text, and thus not be perfectly centered.
+        proxy.setPos(x + 15, copy_number_y_pos + label_width / 2)
         proxy.setRotation(-90)
+        self.scene.addItem(proxy)
 
         # Copy number title
         label_copy_number_header = QLabel("Eksemplar")
diff --git a/soitool/main_window.py b/soitool/main_window.py
index 23d78eb6e067e9c03e8c461f5b92642d5b2504a3..ddaf71c3771215eb7eba64bd9b41efa47d8ca2b4 100644
--- a/soitool/main_window.py
+++ b/soitool/main_window.py
@@ -11,6 +11,7 @@ from PySide2.QtWidgets import (
     QApplication,
     QAction,
     QFileDialog,
+    QDialog,
 )
 from PySide2.QtGui import QIcon
 from PySide2.QtCore import QTimer
@@ -23,6 +24,7 @@ from soitool.soi_db_widget import SOIDbWidget
 from soitool.soi_model_view import SOITableModel
 from soitool.database import Database, DBPATH
 from soitool.help_actions import ShortcutsHelpDialog, BasicUsageHelpDialog
+from soitool.pdf_export_options_dialog import PdfExportOptionsDialog
 from soitool.serialize_export_import_soi import (
     export_soi,
     import_soi,
@@ -270,7 +272,7 @@ class MainWindow(QMainWindow):
             elif medium == ExportMedium.UNCOMPRESSED:
                 export_soi(tab_widget.soi, False)
             elif medium == ExportMedium.PDF:
-                tab_widget.view.produce_pdf()
+                self.prompt_user_and_produce_pdf()
             else:
                 raise ValueError(f"Unknown medium for export '{medium}'")
         else:
@@ -280,6 +282,21 @@ class MainWindow(QMainWindow):
                 "For å eksportere en SOI må riktig tab velges.",
             )
 
+    def prompt_user_and_produce_pdf(self):
+        """Prompt user for PDF options and produce PDF."""
+        dialog_pdf_options = PdfExportOptionsDialog()
+        dialog_code = dialog_pdf_options.exec()
+        if dialog_code == QDialog.DialogCode.Accepted:
+            chosen_number_of_copies = (
+                dialog_pdf_options.spinbox_number_of_copies.value()
+            )
+            chosen_resolution = dialog_pdf_options.spinbox_resolution.value()
+
+            tab_widget = self.tabs.currentWidget()
+            tab_widget.view.produce_pdf(
+                chosen_number_of_copies, chosen_resolution
+            )
+
     def import_soi(self):
         """Import serialized SOI.
 
diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py
index c7e30afea04aa54c398d06e38191767e58ea860b..b28ce56b8a52199cafd27cce3fbd79c7447f6366 100644
--- a/soitool/new_module_dialog.py
+++ b/soitool/new_module_dialog.py
@@ -3,8 +3,6 @@
 from PySide2.QtWidgets import (
     QHBoxLayout,
     QVBoxLayout,
-    QPushButton,
-    QDialog,
     QListWidget,
     QLineEdit,
     QListWidgetItem,
@@ -18,6 +16,7 @@ from soitool.modules.module_authentication_board import (
 )
 from soitool.modules.module_subtractorcodes import SubtractorcodesModule
 from soitool.modules.module_freetext import FreeTextModule
+from soitool.accept_reject_dialog import AcceptRejectDialog
 
 
 # Constant holding all modules the user can choose from. This is intended as a
@@ -45,7 +44,7 @@ class NewModuleDialogListItem(QListWidgetItem):
         self.widget_implementation = widget
 
 
-class NewModuleDialog(QDialog):
+class NewModuleDialog(AcceptRejectDialog):
     """Dialog to let user select module to be added to the SOI.
 
     When the dialog is closed the user's choice is fetched by directly
@@ -66,16 +65,6 @@ class NewModuleDialog(QDialog):
         if module_choices is None:
             module_choices = MODULE_CHOICES
 
-        self.button_add = QPushButton("Legg til")
-        self.button_cancel = QPushButton("Avbryt")
-
-        self.button_add.clicked.connect(self.accept)
-        self.button_cancel.clicked.connect(self.reject)
-
-        self.layout_buttons = QHBoxLayout()
-        self.layout_buttons.addWidget(self.button_add)
-        self.layout_buttons.addWidget(self.button_cancel)
-
         self.list_module_choices = QListWidget()
         self.list_module_choices.setViewMode(QListWidget.IconMode)
         self.list_module_choices.setIconSize(QSize(100, 100))
@@ -87,7 +76,11 @@ class NewModuleDialog(QDialog):
             )
 
         # Set the first element as the default choice, whatever it is
-        self.list_module_choices.item(0).setSelected(True)
+        # Only relevant if there is at least one item
+        if self.list_module_choices.count() > 0:
+            self.list_module_choices.setCurrentItem(
+                self.list_module_choices.item(0)
+            )
 
         self.line_edit_name = QLineEdit()
         self.line_edit_name.setPlaceholderText(
@@ -112,6 +105,5 @@ class NewModuleDialog(QDialog):
         self.layout_wrapper.addWidget(self.list_module_choices)
         self.layout_wrapper.addLayout(self.layout_name)
         self.layout_wrapper.addLayout(self.layout_attachment)
-        self.layout_wrapper.addLayout(self.layout_buttons)
 
-        self.setLayout(self.layout_wrapper)
+        self.layout_content.addLayout(self.layout_wrapper)
diff --git a/soitool/pdf_export_options_dialog.py b/soitool/pdf_export_options_dialog.py
new file mode 100644
index 0000000000000000000000000000000000000000..7142b4f1caf5a097c528b7bd20a9bbdf0510a41c
--- /dev/null
+++ b/soitool/pdf_export_options_dialog.py
@@ -0,0 +1,60 @@
+"""Provides a dialog where the user can select options for export to PDF."""
+
+from PySide2.QtWidgets import (
+    QVBoxLayout,
+    QLabel,
+    QSpinBox,
+    QFormLayout,
+)
+from soitool.accept_reject_dialog import AcceptRejectDialog
+
+
+class PdfExportOptionsDialog(AcceptRejectDialog):
+    """Dialog with options for PDF export.
+
+    When the dialog is closed, the users choices can be fetched by directly
+    accessing the child widgets.
+    """
+
+    def __init__(self):
+        super().__init__()
+        self.setWindowTitle("PDF-eksport")
+
+        self.label_number_of_copies = QLabel("Antall eksemplarer")
+
+        self.spinbox_number_of_copies = QSpinBox()
+        self.spinbox_number_of_copies.setMinimum(1)
+        self.spinbox_number_of_copies.setValue(2)
+
+        self.label_resolution = QLabel("Oppløsning")
+
+        self.spinbox_resolution = QSpinBox()
+        self.spinbox_resolution.setMinimum(50)
+        self.spinbox_resolution.setMaximum(1200)
+        self.spinbox_resolution.setSingleStep(50)
+        self.spinbox_resolution.setSuffix(" dpi")
+        self.spinbox_resolution.setValue(200)
+
+        self.layout_settings = QFormLayout()
+
+        self.layout_settings.addRow(
+            self.label_number_of_copies, self.spinbox_number_of_copies,
+        )
+
+        self.layout_settings.addRow(
+            self.label_resolution, self.spinbox_resolution,
+        )
+
+        self.label_resolution_explanation = QLabel(
+            "Oppløsning på 200 dpi vil gi lesbar PDF med akseptabel "
+            "størrelse. Merk at større oppløsning kan føre til veldig store "
+            "filer, som er vanskelig å håndtere, mens mindre oppløsning kan "
+            "resultere i uleselige PDFer."
+        )
+        self.label_resolution_explanation.setWordWrap(True)
+
+        self.layout_wrapper = QVBoxLayout()
+        self.layout_wrapper.addLayout(self.layout_settings)
+        self.layout_wrapper.addWidget(self.label_resolution_explanation)
+
+        self.layout_content.addLayout(self.layout_wrapper)
diff --git a/soitool/setup_settings.py b/soitool/setup_settings.py
index bcc912b5048085e94d62913651082d5650e15ec1..2bfe2248a23bdefc365ec2dc17d53a5a4cad7e84 100644
--- a/soitool/setup_settings.py
+++ b/soitool/setup_settings.py
@@ -5,14 +5,11 @@ This dialog is called when a button in soi_workspace_widget is pressed
 """
 
 from PySide2.QtWidgets import (
-    QDialog,
     QVBoxLayout,
-    QHBoxLayout,
     QFormLayout,
     QLabel,
     QLineEdit,
     QRadioButton,
-    QPushButton,
     QComboBox,
     QGroupBox,
 )
@@ -21,9 +18,10 @@ from soitool.soi import (
     STRING_TO_ALGORITHM_RECTPACK_PACK,
     STRING_TO_ALGORITHM_INITIAL_SORT,
 )
+from soitool.accept_reject_dialog import AcceptRejectDialog
 
 
-class Setup(QDialog):  # pylint: disable = R0902
+class Setup(AcceptRejectDialog):  # pylint: disable = R0902
     """Contains the settings for the SOI.
 
     This class is used to change the settings for the SOI.
@@ -47,7 +45,6 @@ class Setup(QDialog):  # pylint: disable = R0902
 
         # Layouts
         self.layout_setup = QVBoxLayout()
-        self.layout_buttons = QHBoxLayout()
         self.layout_header = QFormLayout()
         self.layout_orientation_button = QVBoxLayout()
         self.layout_alg_button_bin = QVBoxLayout()
@@ -153,24 +150,17 @@ class Setup(QDialog):  # pylint: disable = R0902
         self.layout_setup.addWidget(self.group_algorithm_pack)
         self.layout_setup.addWidget(self.group_algorithm_sort)
 
-        # Save and cancel
-        self.button_save = QPushButton("Lagre")
-        self.button_cancel = QPushButton("Avbryt")
-        self.layout_buttons.addWidget(self.button_save)
-        self.layout_buttons.addWidget(self.button_cancel)
-        self.layout_setup.addLayout(self.layout_buttons)
+        # Customize ok button from superclass to something more fitting
+        self.button_ok.setText("Lagre")
 
-        self.setLayout(self.layout_setup)
+        self.layout_content.addLayout(self.layout_setup)
 
-        self.button_cancel.clicked.connect(self.cancel)  # esc-key (default)
-        self.button_save.clicked.connect(self.save)  # enter-key (default)
+    def accept(self):
+        """Save and update the SOI with the given changes.
 
-    def cancel(self):
-        """Close the dialog without saving."""
-        self.reject()
-
-    def save(self):
-        """Save and update the SOI with the given changes."""
+        Overriden to implement custom behavior on accept, executes superclass'
+        implementation as final step.
+        """
         # Dict will contain all changes to make
         property_changes = {}
         property_changes["title"] = self.edit_title.text()
@@ -213,7 +203,7 @@ class Setup(QDialog):  # pylint: disable = R0902
         # Pass changes as unpacked variable list
         self.soi.update_properties(**property_changes)
 
-        self.accept()
+        super().accept()
 
     def get_from_soi(self):
         """Get data from the SOI and update the dialog with the data."""
diff --git a/test/test_accept_reject_dialog.py b/test/test_accept_reject_dialog.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0ab9966ec8a1698b7f27c9db2900ee88cded988
--- /dev/null
+++ b/test/test_accept_reject_dialog.py
@@ -0,0 +1,79 @@
+"""Test dialog in accept_reject_dialog.py.
+
+Also provides a function 'dialog_code_test_helper' that can be used by tests of
+subclasses of AcceptRejectDialog to ensure that dialog codes work as expected.
+"""
+
+import unittest
+from PySide2 import QtGui
+from PySide2.QtWidgets import QApplication, QDialog
+from PySide2.QtCore import QTimer, Qt
+from PySide2.QtTest import QTest
+from soitool.accept_reject_dialog import AcceptRejectDialog
+
+
+if isinstance(QtGui.qApp, type(None)):
+    app = QApplication([])
+else:
+    app = QtGui.qApp
+
+
+def dialog_code_test_helper(testcase, dialog_class):
+    """Test that a given dialog class returns dialog codes as expected.
+
+    Parameters
+    ----------
+    testcase : unittest.TestCase
+        Used to report status of test.
+    dialog_class : AcceptRejectDialog or derivative
+        The dialog class must be a derivative of AcceptRejectDialog, or
+        AcceptRejectDialog itself. This is because it's properties are used for
+        testing. Also accepts a callable that returns an instance of a
+        derivative of AcceptRejectDialog, or AcceptRejectDialog itself. This is
+        useful when the subclass takes custom init parameters.
+    """
+    # Prepare all nested functions
+    def press_enter_on_active_dialog():
+        active_widget = app.activeModalWidget()
+        QTest.keyClick(active_widget, Qt.Key_Enter)
+
+    def press_escape_on_active_dialog():
+        active_widget = app.activeModalWidget()
+        QTest.keyClick(active_widget, Qt.Key_Escape)
+
+    def press_add_button_on_active_dialog():
+        active_widget = app.activeModalWidget()
+        QTest.mouseClick(active_widget.button_ok, Qt.LeftButton)
+
+    def press_cancel_button_on_active_dialog():
+        active_widget = app.activeModalWidget()
+        QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton)
+
+    # Perform tests
+    dialog = dialog_class()
+    QTimer.singleShot(0, press_enter_on_active_dialog)
+    dialogcode = dialog.exec_()
+    testcase.assertEqual(dialogcode, QDialog.DialogCode.Accepted)
+
+    dialog = dialog_class()
+    QTimer.singleShot(0, press_escape_on_active_dialog)
+    dialogcode = dialog.exec_()
+    testcase.assertEqual(dialogcode, QDialog.DialogCode.Rejected)
+
+    dialog = dialog_class()
+    QTimer.singleShot(0, press_add_button_on_active_dialog)
+    dialogcode = dialog.exec_()
+    testcase.assertEqual(dialogcode, QDialog.DialogCode.Accepted)
+
+    dialog = dialog_class()
+    QTimer.singleShot(0, press_cancel_button_on_active_dialog)
+    dialogcode = dialog.exec_()
+    testcase.assertEqual(dialogcode, QDialog.DialogCode.Rejected)
+
+
+class TestAcceptRejectDialog(unittest.TestCase):
+    """Testcase for AcceptRejectDialog class."""
+
+    def test_dialog_codes(self):
+        """Test dialog codes using helper function."""
+        dialog_code_test_helper(self, AcceptRejectDialog)
diff --git a/test/test_new_module_dialog.py b/test/test_new_module_dialog.py
index abc0b1cbedb3f3234a3e7b7b4539b3701a1d026d..759292483fd40e0b3b9fc4093357e82b1c5bbb8c 100644
--- a/test/test_new_module_dialog.py
+++ b/test/test_new_module_dialog.py
@@ -3,12 +3,18 @@
 import unittest
 from PySide2 import QtGui
 from PySide2.QtGui import QIcon
-from PySide2.QtWidgets import QApplication, QDialog
+from PySide2.QtWidgets import QApplication
 from PySide2.QtCore import QTimer, Qt
 from PySide2.QtTest import QTest
 from soitool.new_module_dialog import NewModuleDialog
 from soitool.modules.module_base import ModuleBase
 
+# The error being ignored here is pylint telling us that 'test' is a standard
+# module, so the import should be placed further up. In our case we have an
+# actual custom module called 'test', so pylint is confused.
+# pylint: disable=C0411
+from test.test_accept_reject_dialog import dialog_code_test_helper
+
 
 class SimpleTestModule(ModuleBase):
     """Simple module used to test NewModuleDialog only."""
@@ -64,43 +70,8 @@ class TestNewModuleDialog(unittest.TestCase):
 
     def test_dialog_code(self):
         """Test that the dialog returns dialog codes as expected."""
-        # prepare all nested functions
-        def press_enter_on_active_dialog():
-            active_widget = app.activeModalWidget()
-            QTest.keyClick(active_widget, Qt.Key_Enter)
-
-        def press_escape_on_active_dialog():
-            active_widget = app.activeModalWidget()
-            QTest.keyClick(active_widget, Qt.Key_Escape)
-
-        def press_add_button_on_active_dialog():
-            active_widget = app.activeModalWidget()
-            QTest.mouseClick(active_widget.button_add, Qt.LeftButton)
-
-        def press_cancel_button_on_active_dialog():
-            active_widget = app.activeModalWidget()
-            QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton)
-
-        # perform tests
-        dialog = NewModuleDialog()
-        QTimer.singleShot(0, press_enter_on_active_dialog)
-        dialogcode = dialog.exec_()
-        self.assertEqual(dialogcode, QDialog.DialogCode.Accepted)
-
-        dialog = NewModuleDialog()
-        QTimer.singleShot(0, press_escape_on_active_dialog)
-        dialogcode = dialog.exec_()
-        self.assertEqual(dialogcode, QDialog.DialogCode.Rejected)
-
-        dialog = NewModuleDialog()
-        QTimer.singleShot(0, press_add_button_on_active_dialog)
-        dialogcode = dialog.exec_()
-        self.assertEqual(dialogcode, QDialog.DialogCode.Accepted)
-
-        dialog = NewModuleDialog()
-        QTimer.singleShot(0, press_cancel_button_on_active_dialog)
-        dialogcode = dialog.exec_()
-        self.assertEqual(dialogcode, QDialog.DialogCode.Rejected)
+        # Makes use of helper function from other test case
+        dialog_code_test_helper(self, NewModuleDialog)
 
     def test_full_selection_scenario(self):
         """Test full usage of the module."""
@@ -140,7 +111,7 @@ class TestNewModuleDialog(unittest.TestCase):
 
             QTest.keyClicks(active_widget.line_edit_name, name_to_input)
             QTest.mouseClick(active_widget.checkbox_attachment, Qt.LeftButton)
-            QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton)
+            QTest.mouseClick(active_widget.button_ok, Qt.LeftButton)
 
         dialog = NewModuleDialog(module_choices=module_choices)
         QTimer.singleShot(0, perform_selection_and_add)
diff --git a/test/test_pdf_export_options_dialog.py b/test/test_pdf_export_options_dialog.py
new file mode 100644
index 0000000000000000000000000000000000000000..f024551691e74fd52345c5078b3448a338e281bf
--- /dev/null
+++ b/test/test_pdf_export_options_dialog.py
@@ -0,0 +1,96 @@
+"""Test dialog in pdf_export_options_dialog.py."""
+
+import unittest
+from PySide2 import QtGui
+from PySide2.QtWidgets import QApplication
+from PySide2.QtCore import QTimer, Qt
+from PySide2.QtTest import QTest
+from soitool.pdf_export_options_dialog import PdfExportOptionsDialog
+
+# The error being ignored here is pylint telling us that 'test' is a standard
+# module, so the import should be placed further up. In our case we have an
+# actual custom module called 'test', so pylint is confused.
+# pylint: disable=C0411
+from test.test_accept_reject_dialog import dialog_code_test_helper
+
+
+if isinstance(QtGui.qApp, type(None)):
+    app = QApplication([])
+else:
+    app = QtGui.qApp
+
+
+class TestPdfExportOptionsDialog(unittest.TestCase):
+    """Testcase for PdfExportOptionsDialog class."""
+
+    def test_starts_up(self):
+        """Test that the dialog can start.
+
+        Also tests whether dialog can be closed with the enter key.
+        """
+        dialog = PdfExportOptionsDialog()
+
+        def assert_window_title_of_active_dialog():
+            active_widget = app.activeModalWidget()
+            self.assertEqual("PDF-eksport", active_widget.windowTitle())
+            QTest.keyClick(active_widget, Qt.Key_Enter)
+
+        QTimer.singleShot(0, assert_window_title_of_active_dialog)
+        dialog.exec_()
+
+    def test_dialog_codes(self):
+        """Test dialog codes using helper function."""
+        dialog_code_test_helper(self, PdfExportOptionsDialog)
+
+    def test_full_usage_scenario(self):
+        """Perform selection of desired settings an accept.
+
+        This function is heavily inspired by soitool.test_new_module_dialog
+        .TestNewModuleDialog.test_full_usage_scenario
+        """
+        test_copies = 10
+        test_resolution = 300
+
+        def perform_selection_and_ok():
+            """Perform selection.
+
+            Select 'test_copies' copies and 'test_resolution' dpi resolution,
+            then click add.
+            """
+            active_widget = app.activeModalWidget()
+
+            # Double click selects text, so that we can replace it entirely
+            QTest.mouseDClick(
+                active_widget.spinbox_number_of_copies.lineEdit(),
+                Qt.LeftButton,
+            )
+            QTest.keyClicks(
+                active_widget.spinbox_number_of_copies, str(test_copies)
+            )
+
+            # Triple click selects text, so that we can replace it entirely
+            # Triple click is needed because the spinbox includes a suffix
+            QTest.mouseDClick(
+                active_widget.spinbox_resolution.lineEdit(), Qt.LeftButton
+            )
+            QTest.mouseClick(
+                active_widget.spinbox_resolution.lineEdit(), Qt.LeftButton
+            )
+            QTest.keyClicks(
+                active_widget.spinbox_resolution, str(test_resolution)
+            )
+
+            QTest.mouseClick(active_widget.button_ok, Qt.LeftButton)
+
+        dialog = PdfExportOptionsDialog()
+        QTimer.singleShot(0, perform_selection_and_ok)
+        dialog.exec()
+
+        # Assert dialog was successfully filled
+
+        self.assertEqual(
+            test_copies, dialog.spinbox_number_of_copies.value(),
+        )
+        self.assertEqual(
+            test_resolution, dialog.spinbox_resolution.value(),
+        )
diff --git a/test/test_setup_settings.py b/test/test_setup_settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c5ef1f49ffd3aa52fbcc3caa494c94d6ed5dc17
--- /dev/null
+++ b/test/test_setup_settings.py
@@ -0,0 +1,29 @@
+"""Test dialog in setup_settings.py."""
+
+import unittest
+from PySide2 import QtGui
+from PySide2.QtWidgets import QApplication
+from soitool.setup_settings import Setup
+from soitool.soi import SOI
+
+# The error being ignored here is pylint telling us that 'test' is a standard
+# module, so the import should be placed further up. In our case we have an
+# actual custom module called 'test', so pylint is confused.
+# pylint: disable=C0411
+from test.test_accept_reject_dialog import dialog_code_test_helper
+
+
+if isinstance(QtGui.qApp, type(None)):
+    app = QApplication([])
+else:
+    app = QtGui.qApp
+
+
+class TestSetup(unittest.TestCase):
+    """TestCase for Setup dialog class."""
+
+    def test_dialog_code(self):
+        """Test that the dialog returns dialog codes as expected."""
+        # Makes use of helper function from other test case
+        # The lambda is used to inject a required init parameter
+        dialog_code_test_helper(self, lambda: Setup(SOI()))