MTGProxyPrinter

Check-in [87ccc737d1]
Login

Check-in [87ccc737d1]

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:Implemented drawing 90° sharp card corners. Also fixed 1 pixel card image overlap.
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 87ccc737d116d5b84a5eaf5345c8e586614182aa3779f650d65b3f3c69f41dd1
User & Date: thomas 2022-07-25 14:39:31
Context
2022-07-26
17:40
Fixed TypeError exception when accepting to open the application download website in the update available notification message. check-in: dcd9adc3c4 user: thomas tags: trunk
2022-07-25
14:39
Implemented drawing 90° sharp card corners. Also fixed 1 pixel card image overlap. check-in: 87ccc737d1 user: thomas tags: trunk
14:37
Added changelog entry. Closed-Leaf check-in: 60e7187be4 user: thomas tags: draw_sharp_corners
2022-07-12
11:08
Removed unnecessary @profile decorator in the CardDatabase class. check-in: 6d37b16c2b user: thomas tags: trunk
Changes
Hide Diffs Unified Diffs Ignore Whitespace Patch

Changes to doc/changelog.md.

1
2











3
4
5
6
7
8
9
# Changelog












# Version 0.18.0 (2022-07-09)  <a name="v0_18_0"></a>

## New features

- Proper, full support for oversized cards, like Archenemy schemes or Planechase plane cards. Regular cards and larger
  cards are always kept on separate pages to ensure that drawn cut marker lines (if enabled) are always 100% accurate.
  - Note: Some cards, like the Legacy Championship winner rewards, are tagged as being oversized, but are then served


>
>
>
>
>
>
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Changelog

# Next version (in development)

## New features

- Implemented optional drawing of 90° card corners. This can be enabled for all new documents
  globally in the application settings or individually in the document settings.

## Fixed issues

- Fixed card images overlapping by one pixel when image spacing is set to zero.

# Version 0.18.0 (2022-07-09)  <a name="v0_18_0"></a>

## New features

- Proper, full support for oversized cards, like Archenemy schemes or Planechase plane cards. Regular cards and larger
  cards are always kept on separate pages to ensure that drawn cut marker lines (if enabled) are always 100% accurate.
  - Note: Some cards, like the Legacy Championship winner rewards, are tagged as being oversized, but are then served

Changes to mtg_proxy_printer/model/carddb.py.

13
14
15
16
17
18
19

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import atexit
import configparser
import dataclasses
import datetime

import itertools
import functools
import pathlib
import textwrap
import typing

from PyQt5.QtGui import QPixmap
from PyQt5.QtCore import Qt
import delegateto

import mtg_proxy_printer.app_dirs
from mtg_proxy_printer.model.carddb_migrations import migrate_card_database
from mtg_proxy_printer.natsort import natural_sorted
import mtg_proxy_printer.sqlite_helpers
import mtg_proxy_printer.meta_data







>






|
|







13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import atexit
import configparser
import dataclasses
import datetime
import enum
import itertools
import functools
import pathlib
import textwrap
import typing

from PyQt5.QtGui import QPixmap, QColor
from PyQt5.QtCore import Qt, QPoint, QRect, QSize
import delegateto

import mtg_proxy_printer.app_dirs
from mtg_proxy_printer.model.carddb_migrations import migrate_card_database
from mtg_proxy_printer.natsort import natural_sorted
import mtg_proxy_printer.sqlite_helpers
import mtg_proxy_printer.meta_data
65
66
67
68
69
70
71

72
73
74
75
76
77
78
# once per month or so.
MINIMUM_REFRESH_DELAY = datetime.timedelta(days=14)

__all__ = [
    "CardIdentificationData",
    "MTGSet",
    "Card",

    "CardDatabase",
    "cached_dedent",
]


@dataclasses.dataclass
class CardIdentificationData:







>







66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# once per month or so.
MINIMUM_REFRESH_DELAY = datetime.timedelta(days=14)

__all__ = [
    "CardIdentificationData",
    "MTGSet",
    "Card",
    "CardCorner",
    "CardDatabase",
    "cached_dedent",
]


@dataclasses.dataclass
class CardIdentificationData:
97
98
99
100
101
102
103















104
105
106
107
108
109
110
111
112
113
114
115
116
117
118




119
120
121
122
123
124
125
126














127
128
129
130
131
132
133
        elif role == Qt.DisplayRole:
            return f"{self.name} ({self.code.upper()})"
        elif role == Qt.ToolTipRole:
            return self.name
        else:
            return None

















@dataclasses.dataclass(unsafe_hash=True)
class Card:
    name: str = dataclasses.field(compare=True)
    set: MTGSet = dataclasses.field(compare=True)
    collector_number: str = dataclasses.field(compare=True)
    language: str = dataclasses.field(compare=True)
    scryfall_id: str = dataclasses.field(compare=True)
    is_front: bool = dataclasses.field(compare=True)
    oracle_id: str = dataclasses.field(compare=True)
    image_uri: str = dataclasses.field(compare=False)
    highres_image: bool = dataclasses.field(compare=False)
    is_oversized: bool = dataclasses.field(compare=False)
    face_number: int = dataclasses.field(compare=False)
    image_file: typing.Optional[QPixmap] = dataclasses.field(default=None, compare=False)





    def requested_page_type(self) -> PageType:
        if self.image_file is None:
            return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR
        size = self.image_file.size()
        if (size.width(), size.height()) == (1040, 1490):
            return PageType.OVERSIZED
        return PageType.REGULAR
















OptionalCard = typing.Optional[Card]
CardList = typing.List[Card]


@functools.lru_cache(None)







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>















>
>
>
>








>
>
>
>
>
>
>
>
>
>
>
>
>
>







99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
        elif role == Qt.DisplayRole:
            return f"{self.name} ({self.code.upper()})"
        elif role == Qt.ToolTipRole:
            return self.name
        else:
            return None


@enum.unique
class CardCorner(enum.Enum):
    """
    The four corners of a card. Values are relative image positions in X and Y.
    These are fractions so that they work properly for both regular and oversized cards

    Values are tuned to return the top-left corner of a 10x10 area
    centered around (20,20) away from the respective corner.
    """
    TOP_LEFT = (15/745, 15/1040)
    TOP_RIGHT = (1-25/745, 15/1040)
    BOTTOM_LEFT = (15/745, 1-25/1040)
    BOTTOM_RIGHT = (1-25/745, 1-25/1040)


@dataclasses.dataclass(unsafe_hash=True)
class Card:
    name: str = dataclasses.field(compare=True)
    set: MTGSet = dataclasses.field(compare=True)
    collector_number: str = dataclasses.field(compare=True)
    language: str = dataclasses.field(compare=True)
    scryfall_id: str = dataclasses.field(compare=True)
    is_front: bool = dataclasses.field(compare=True)
    oracle_id: str = dataclasses.field(compare=True)
    image_uri: str = dataclasses.field(compare=False)
    highres_image: bool = dataclasses.field(compare=False)
    is_oversized: bool = dataclasses.field(compare=False)
    face_number: int = dataclasses.field(compare=False)
    image_file: typing.Optional[QPixmap] = dataclasses.field(default=None, compare=False)

    def set_image_file(self, image: QPixmap):
        self.image_file = image
        self.corner_color.cache_clear()

    def requested_page_type(self) -> PageType:
        if self.image_file is None:
            return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR
        size = self.image_file.size()
        if (size.width(), size.height()) == (1040, 1490):
            return PageType.OVERSIZED
        return PageType.REGULAR

    @functools.lru_cache(maxsize=len(CardCorner))
    def corner_color(self, corner: CardCorner) -> QColor:
        """Returns the color of the card at the given corner. """
        if self.image_file is None:
            return QColor.fromRgb(255, 255, 255, 0)  # fully transparent white
        sample_area = self.image_file.copy(QRect(
            QPoint(
                round(self.image_file.width() * corner.value[0]),
                round(self.image_file.height() * corner.value[1])),
            QSize(10, 10)
        ))
        average_color = sample_area.scaled(1, 1, transformMode=Qt.SmoothTransformation).toImage().pixelColor(0, 0)
        return average_color


OptionalCard = typing.Optional[Card]
CardList = typing.List[Card]


@functools.lru_cache(None)

Added mtg_proxy_printer/model/document-v5.sql.



















































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
-- Copyright (C) 2020-2022 Thomas Hess <thomas.hess@udo.edu>

-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.

-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.

-- You should have received a copy of the GNU General Public License
-- along with this program. If not, see <http://www.gnu.org/licenses/>.

PRAGMA user_version = 0000005;
PRAGMA application_id = 41325044; -- ASCII-encoded "MTGP"
PRAGMA page_size =  512;
VACUUM;  -- Required to apply setting PRAGMA page_size

CREATE TABLE Card (
  page INTEGER NOT NULL CHECK (page > 0),
  slot INTEGER NOT NULL CHECK (slot > 0),
  is_front INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT 1,
  scryfall_id TEXT NOT NULL,
  PRIMARY KEY(page, slot)
) WITHOUT ROWID;

CREATE TABLE DocumentSettings (
  rowid INTEGER NOT NULL PRIMARY KEY CHECK (rowid == 1),
  page_height INTEGER NOT NULL CHECK (page_height > 0),
  page_width INTEGER NOT NULL CHECK (page_width > 0),
  margin_top INTEGER NOT NULL CHECK (margin_top >= 0),
  margin_bottom INTEGER NOT NULL CHECK (margin_bottom >= 0),
  margin_left INTEGER NOT NULL CHECK (margin_left >= 0),
  margin_right INTEGER NOT NULL CHECK (margin_right >= 0),
  image_spacing_horizontal INTEGER NOT NULL CHECK (image_spacing_horizontal >= 0),
  image_spacing_vertical INTEGER NOT NULL CHECK (image_spacing_vertical >= 0),
  draw_cut_markers INTEGER NOT NULL CHECK (draw_cut_markers in (TRUE, FALSE)),
  draw_sharp_corners INTEGER NOT NULL CHECK (draw_sharp_corners in (TRUE, FALSE))
);

Changes to mtg_proxy_printer/model/document.py.

452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
        )
        flattened_data: DocumentSaveFormat = (
            (page, slot, scryfall_id, is_front)
            for (page, (slot, (scryfall_id, is_front)))
            in itertools.chain.from_iterable(cards)
        )
        with mtg_proxy_printer.sqlite_helpers.open_database(
                self.save_file_path, "document-v4", self.loader.MIN_SUPPORTED_SQLITE_VERSION) as db:
            db.execute("BEGIN TRANSACTION")
            _migrate_database(db)
            db.execute("DELETE FROM Card")
            db.executemany(
                "INSERT INTO Card (page, slot, scryfall_id, is_front) VALUES (?, ?, ?, ?)",
                flattened_data
            )
            logger.debug(f"Written {db.execute('SELECT count() FROM Card').fetchone()[0]} cards.")
            db.execute(
                textwrap.dedent("""\
                    INSERT OR REPLACE INTO DocumentSettings (rowid, page_height, page_width,
                          margin_top, margin_bottom, margin_left, margin_right,
                          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers)
                      VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """),
                dataclasses.astuple(self.page_layout))
            logger.debug("Written document settings")
            db.commit()
            db.execute("VACUUM")
        logger.debug("Database saved and closed.")








|












|
|







452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
        )
        flattened_data: DocumentSaveFormat = (
            (page, slot, scryfall_id, is_front)
            for (page, (slot, (scryfall_id, is_front)))
            in itertools.chain.from_iterable(cards)
        )
        with mtg_proxy_printer.sqlite_helpers.open_database(
                self.save_file_path, "document-v5", self.loader.MIN_SUPPORTED_SQLITE_VERSION) as db:
            db.execute("BEGIN TRANSACTION")
            _migrate_database(db)
            db.execute("DELETE FROM Card")
            db.executemany(
                "INSERT INTO Card (page, slot, scryfall_id, is_front) VALUES (?, ?, ?, ?)",
                flattened_data
            )
            logger.debug(f"Written {db.execute('SELECT count() FROM Card').fetchone()[0]} cards.")
            db.execute(
                textwrap.dedent("""\
                    INSERT OR REPLACE INTO DocumentSettings (rowid, page_height, page_width,
                          margin_top, margin_bottom, margin_left, margin_right,
                          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners)
                      VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """),
                dataclasses.astuple(self.page_layout))
            logger.debug("Written document settings")
            db.commit()
            db.execute("VACUUM")
        logger.debug("Database saved and closed.")

743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783






















        self.page_index_cache.clear()
        self.page_index_cache.update(
            (id(page), index) for index, page in enumerate(self.pages)
        )


def _migrate_database(db):
    if db.execute("PRAGMA user_version").fetchone()[0] == 2:
        for statement in [
            "ALTER TABLE Card RENAME TO Card_old",
            textwrap.dedent("""\
            CREATE TABLE Card (
              page INTEGER NOT NULL CHECK (page > 0),
              slot INTEGER NOT NULL CHECK (slot > 0),
              is_front INTEGER NOT NULL CHECK (is_front IN (0, 1)) DEFAULT 1,
              scryfall_id TEXT NOT NULL,
              PRIMARY KEY(page, slot)
            ) WITHOUT ROWID"""),
            textwrap.dedent("""\
            INSERT INTO Card (page, slot, scryfall_id, is_front)
                SELECT page, slot, scryfall_id, 1 AS is_front
                FROM Card_old"""),
            "DROP TABLE Card_old",
            "PRAGMA user_version = 3",
        ]:
            db.execute(f"{statement};\n")
    if db.execute("PRAGMA user_version").fetchone()[0] == 3:
        db.execute(textwrap.dedent("""\
        CREATE TABLE DocumentSettings (
          rowid INTEGER NOT NULL PRIMARY KEY CHECK (rowid == 1),
          page_height INTEGER NOT NULL CHECK (page_height > 0),
          page_width INTEGER NOT NULL CHECK (page_width > 0),
          margin_top INTEGER NOT NULL CHECK (margin_top >= 0),
          margin_bottom INTEGER NOT NULL CHECK (margin_bottom >= 0),
          margin_left INTEGER NOT NULL CHECK (margin_left >= 0),
          margin_right INTEGER NOT NULL CHECK (margin_right >= 0),
          image_spacing_horizontal INTEGER NOT NULL CHECK (image_spacing_horizontal >= 0),
          image_spacing_vertical INTEGER NOT NULL CHECK (image_spacing_vertical >= 0),
          draw_cut_markers INTEGER NOT NULL CHECK (draw_cut_markers in (0, 1))
        );"""))
        db.execute(f"PRAGMA user_version = 4")





























|


















|














>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
        self.page_index_cache.clear()
        self.page_index_cache.update(
            (id(page), index) for index, page in enumerate(self.pages)
        )


def _migrate_database(db):
    if db.execute("PRAGMA user_version\n").fetchone()[0] == 2:
        for statement in [
            "ALTER TABLE Card RENAME TO Card_old",
            textwrap.dedent("""\
            CREATE TABLE Card (
              page INTEGER NOT NULL CHECK (page > 0),
              slot INTEGER NOT NULL CHECK (slot > 0),
              is_front INTEGER NOT NULL CHECK (is_front IN (0, 1)) DEFAULT 1,
              scryfall_id TEXT NOT NULL,
              PRIMARY KEY(page, slot)
            ) WITHOUT ROWID"""),
            textwrap.dedent("""\
            INSERT INTO Card (page, slot, scryfall_id, is_front)
                SELECT page, slot, scryfall_id, 1 AS is_front
                FROM Card_old"""),
            "DROP TABLE Card_old",
            "PRAGMA user_version = 3",
        ]:
            db.execute(f"{statement};\n")
    if db.execute("PRAGMA user_version\n").fetchone()[0] == 3:
        db.execute(textwrap.dedent("""\
        CREATE TABLE DocumentSettings (
          rowid INTEGER NOT NULL PRIMARY KEY CHECK (rowid == 1),
          page_height INTEGER NOT NULL CHECK (page_height > 0),
          page_width INTEGER NOT NULL CHECK (page_width > 0),
          margin_top INTEGER NOT NULL CHECK (margin_top >= 0),
          margin_bottom INTEGER NOT NULL CHECK (margin_bottom >= 0),
          margin_left INTEGER NOT NULL CHECK (margin_left >= 0),
          margin_right INTEGER NOT NULL CHECK (margin_right >= 0),
          image_spacing_horizontal INTEGER NOT NULL CHECK (image_spacing_horizontal >= 0),
          image_spacing_vertical INTEGER NOT NULL CHECK (image_spacing_vertical >= 0),
          draw_cut_markers INTEGER NOT NULL CHECK (draw_cut_markers in (0, 1))
        );"""))
        db.execute(f"PRAGMA user_version = 4")
    if db.execute("PRAGMA user_version").fetchone()[0] == 4:
        for statement in [
            "ALTER TABLE DocumentSettings RENAME TO DocumentSettings_Old",
            textwrap.dedent("""\
            CREATE TABLE DocumentSettings (
              rowid INTEGER NOT NULL PRIMARY KEY CHECK (rowid == 1),
              page_height INTEGER NOT NULL CHECK (page_height > 0),
              page_width INTEGER NOT NULL CHECK (page_width > 0),
              margin_top INTEGER NOT NULL CHECK (margin_top >= 0),
              margin_bottom INTEGER NOT NULL CHECK (margin_bottom >= 0),
              margin_left INTEGER NOT NULL CHECK (margin_left >= 0),
              margin_right INTEGER NOT NULL CHECK (margin_right >= 0),
              image_spacing_horizontal INTEGER NOT NULL CHECK (image_spacing_horizontal >= 0),
              image_spacing_vertical INTEGER NOT NULL CHECK (image_spacing_vertical >= 0),
              draw_cut_markers INTEGER NOT NULL CHECK (draw_cut_markers in (TRUE, FALSE)),
              draw_sharp_corners INTEGER NOT NULL CHECK (draw_sharp_corners in (TRUE, FALSE))
            )"""),
            "INSERT INTO DocumentSettings SELECT *, FALSE FROM DocumentSettings_Old",
            "DROP TABLE DocumentSettings_Old",
            "PRAGMA user_version = 5",
        ]:
            db.execute(f"{statement}\n")

Changes to mtg_proxy_printer/model/document_loader.py.

65
66
67
68
69
70
71

72
73
74
75
76
77
78
79
80
81
82
83
84
85

86
87
88
89
90
91
92
    margin_top: int = 0
    margin_bottom: int = 0
    margin_left: int = 0
    margin_right: int = 0
    image_spacing_horizontal: int = 0
    image_spacing_vertical: int = 0
    draw_cut_markers: bool = False


    @classmethod
    def create_from_settings(cls):
        document_settings = mtg_proxy_printer.settings.settings["documents"]
        return cls(
            document_settings.getint("paper-height-mm"),
            document_settings.getint("paper-width-mm"),
            document_settings.getint("margin-top-mm"),
            document_settings.getint("margin-bottom-mm"),
            document_settings.getint("margin-left-mm"),
            document_settings.getint("margin-right-mm"),
            document_settings.getint("image-spacing-horizontal-mm"),
            document_settings.getint("image-spacing-vertical-mm"),
            document_settings.getboolean("print-cut-marker"),

        )

    def __lt__(self, other):
        if not isinstance(other, self.__class__):
            raise TypeError(
                f"'<' not supported between instances of '{self.__class__.__name__}' and '{other.__class__.__name__}'")
        return self.compute_page_row_count(PageType.REGULAR) < other.compute_page_card_capacity(PageType.REGULAR) or \







>














>







65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
    margin_top: int = 0
    margin_bottom: int = 0
    margin_left: int = 0
    margin_right: int = 0
    image_spacing_horizontal: int = 0
    image_spacing_vertical: int = 0
    draw_cut_markers: bool = False
    draw_sharp_corners: bool = False

    @classmethod
    def create_from_settings(cls):
        document_settings = mtg_proxy_printer.settings.settings["documents"]
        return cls(
            document_settings.getint("paper-height-mm"),
            document_settings.getint("paper-width-mm"),
            document_settings.getint("margin-top-mm"),
            document_settings.getint("margin-bottom-mm"),
            document_settings.getint("margin-left-mm"),
            document_settings.getint("margin-right-mm"),
            document_settings.getint("image-spacing-horizontal-mm"),
            document_settings.getint("image-spacing-vertical-mm"),
            document_settings.getboolean("print-cut-marker"),
            document_settings.getboolean("print-sharp-corners")
        )

    def __lt__(self, other):
        if not isinstance(other, self.__class__):
            raise TypeError(
                f"'<' not supported between instances of '{self.__class__.__name__}' and '{other.__class__.__name__}'")
        return self.compute_page_row_count(PageType.REGULAR) < other.compute_page_card_capacity(PageType.REGULAR) or \
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
                self.network_error_occurred.emit(
                    f"Error count: {error_count}. Most common error message:\n"
                    f"{self.network_errors_during_load.most_common(1)[0][0]}"
                )
                self.network_errors_during_load.clear()

        def on_network_error_occurred(self, card: Card, error: str):
            card.image_file = self.image_db.blank_image
            self.network_errors_during_load[error] += 1

        def load_document(self):
            self.should_run = True
            try:
                unknown_ids, migrated_ids = self._load_document()
            except AssertionError as e:







|







200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
                self.network_error_occurred.emit(
                    f"Error count: {error_count}. Most common error message:\n"
                    f"{self.network_errors_during_load.most_common(1)[0][0]}"
                )
                self.network_errors_during_load.clear()

        def on_network_error_occurred(self, card: Card, error: str):
            card.set_image_file(self.image_db.blank_image)
            self.network_errors_during_load[error] += 1

        def load_document(self):
            self.should_run = True
            try:
                unknown_ids, migrated_ids = self._load_document()
            except AssertionError as e:
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318








319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342

343
344
345
346
347
348
349
350
351

352
353
354
355
356
357
358
        def _read_card_data_from_database(db: sqlite3.Connection, user_version: int) -> DocumentSaveFormat:
            card_data = []
            if user_version == 2:
                query = textwrap.dedent("""\
                    SELECT page, slot, scryfall_id, 1 AS is_front
                        FROM Card
                        ORDER BY page ASC, slot ASC""")
            elif user_version in {3, 4}:
                query = textwrap.dedent("""\
                    SELECT page, slot, scryfall_id, is_front
                        FROM Card
                        ORDER BY page ASC, slot ASC""")
            else:
                raise AssertionError(f"Unknown database schema version: {user_version}")
            for row_number, row_data in enumerate(db.execute(query)):
                assert_that(row_data, contains_exactly(
                    all_of(instance_of(int), greater_than_or_equal_to(0)),
                    all_of(instance_of(int), greater_than_or_equal_to(0)),
                    all_of(instance_of(str), matches_regexp(r"[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}")),
                    is_in((0, 1))
                ), f"Invalid data found in the save data at row {row_number}. Aborting")
                page, slot, scryfall_id, is_front = row_data
                card_data.append((page, slot, scryfall_id, bool(is_front)))
            return card_data

        @staticmethod
        def _read_page_layout_data_from_database(db, user_version):
            if user_version == 4:








                assert_that(
                    db.execute("SELECT COUNT(*) FROM DocumentSettings").fetchone(),
                    contains_exactly(1),
                )
                document_settings_query = textwrap.dedent("""\
                    SELECT page_height, page_width,
                           margin_top, margin_bottom, margin_left, margin_right,
                           image_spacing_horizontal, image_spacing_vertical, draw_cut_markers
                        FROM DocumentSettings
                        WHERE rowid == 1
                    """)
                settings = PageLayoutSettings(*db.execute(document_settings_query).fetchone())
                assert_that(
                    settings,
                    has_properties(
                        page_height=all_of(instance_of(int), greater_than(0)),
                        page_width=all_of(instance_of(int), greater_than(0)),
                        margin_top=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        margin_bottom=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        margin_left=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        margin_right=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        image_spacing_horizontal=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        image_spacing_vertical=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        draw_cut_markers=is_in((0, 1)),

                    ),
                    "Document settings contain invalid data or data types"
                )
                assert_that(
                    settings.compute_page_card_capacity(),
                    is_(greater_than_or_equal_to(1)),
                    "Document settings invalid: At least one card has to fit on a page."
                )
                settings.draw_cut_markers = bool(settings.draw_cut_markers)

            else:
                settings = PageLayoutSettings.create_from_settings()
            return settings

        @staticmethod
        def _validate_database_schema(db_unsafe: sqlite3.Connection) -> int:
            """







|



















|
>
>
>
>
>
>
>
>




<
<
<
<
<
<
<













>









>







293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332







333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
        def _read_card_data_from_database(db: sqlite3.Connection, user_version: int) -> DocumentSaveFormat:
            card_data = []
            if user_version == 2:
                query = textwrap.dedent("""\
                    SELECT page, slot, scryfall_id, 1 AS is_front
                        FROM Card
                        ORDER BY page ASC, slot ASC""")
            elif user_version in {3, 4, 5}:
                query = textwrap.dedent("""\
                    SELECT page, slot, scryfall_id, is_front
                        FROM Card
                        ORDER BY page ASC, slot ASC""")
            else:
                raise AssertionError(f"Unknown database schema version: {user_version}")
            for row_number, row_data in enumerate(db.execute(query)):
                assert_that(row_data, contains_exactly(
                    all_of(instance_of(int), greater_than_or_equal_to(0)),
                    all_of(instance_of(int), greater_than_or_equal_to(0)),
                    all_of(instance_of(str), matches_regexp(r"[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}")),
                    is_in((0, 1))
                ), f"Invalid data found in the save data at row {row_number}. Aborting")
                page, slot, scryfall_id, is_front = row_data
                card_data.append((page, slot, scryfall_id, bool(is_front)))
            return card_data

        @staticmethod
        def _read_page_layout_data_from_database(db, user_version):
            if user_version >= 4:
                document_settings_query = textwrap.dedent(f"""\
                    SELECT page_height, page_width,
                           margin_top, margin_bottom, margin_left, margin_right,
                           image_spacing_horizontal, image_spacing_vertical, draw_cut_markers,
                           {'1' if user_version == 4 else 'draw_sharp_corners'}
                        FROM DocumentSettings
                        WHERE rowid == 1
                    """)
                assert_that(
                    db.execute("SELECT COUNT(*) FROM DocumentSettings").fetchone(),
                    contains_exactly(1),
                )







                settings = PageLayoutSettings(*db.execute(document_settings_query).fetchone())
                assert_that(
                    settings,
                    has_properties(
                        page_height=all_of(instance_of(int), greater_than(0)),
                        page_width=all_of(instance_of(int), greater_than(0)),
                        margin_top=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        margin_bottom=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        margin_left=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        margin_right=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        image_spacing_horizontal=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        image_spacing_vertical=all_of(instance_of(int), greater_than_or_equal_to(0)),
                        draw_cut_markers=is_in((0, 1)),
                        draw_sharp_corners=is_in((0, 1)),
                    ),
                    "Document settings contain invalid data or data types"
                )
                assert_that(
                    settings.compute_page_card_capacity(),
                    is_(greater_than_or_equal_to(1)),
                    "Document settings invalid: At least one card has to fit on a page."
                )
                settings.draw_cut_markers = bool(settings.draw_cut_markers)
                settings.draw_sharp_corners = bool(settings.draw_sharp_corners)
            else:
                settings = PageLayoutSettings.create_from_settings()
            return settings

        @staticmethod
        def _validate_database_schema(db_unsafe: sqlite3.Connection) -> int:
            """

Changes to mtg_proxy_printer/model/imagedb.py.

311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
            self.network_error_occurred.emit(self.last_error_message)
        # Unconditionally forget any previously stored error messages when changing the batch processing state.
        # This prevents re-raising already reported, previous errors when starting a new batch
        self.last_error_message = ""
        self.batch_processing_state_changed.emit(value)

    def _handle_network_error_during_download(self, card: Card, reason_str: str):
        card.image_file = self.image_database.blank_image
        logger.warning(
            f"Image download failed for card {card}, reason is \"{reason_str}\". Using blank replacement image.")
        # Only return the error message for storage, if the queue currently processes a batch job.
        # Otherwise, it’ll be re-raised if a batch job starts right after a singular request failed.
        if not self.batch_processing_state:
            self.network_error_occurred.emit(reason_str)
        return reason_str







|







311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
            self.network_error_occurred.emit(self.last_error_message)
        # Unconditionally forget any previously stored error messages when changing the batch processing state.
        # This prevents re-raising already reported, previous errors when starting a new batch
        self.last_error_message = ""
        self.batch_processing_state_changed.emit(value)

    def _handle_network_error_during_download(self, card: Card, reason_str: str):
        card.set_image_file(self.image_database.blank_image)
        logger.warning(
            f"Image download failed for card {card}, reason is \"{reason_str}\". Using blank replacement image.")
        # Only return the error message for storage, if the queue currently processes a batch job.
        # Otherwise, it’ll be re-raised if a batch job starts right after a singular request failed.
        if not self.batch_processing_state:
            self.network_error_occurred.emit(reason_str)
        return reason_str
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
            pixmap = self.image_database.loaded_images[key]
        except KeyError:
            logger.debug("Image not in memory, requesting from disk")
            pixmap = self._fetch_image(card)
            self.image_database.loaded_images[key] = pixmap
            self.image_database.images_on_disk.add(key)
            logger.debug("Image loaded")
        card.image_file = pixmap

    def _fetch_image(self, card: Card) -> QPixmap:
        key = ImageKey(card.scryfall_id, card.is_front, card.highres_image)
        cache_file_path = self.image_database.db_path / key.format_relative_path()
        cache_file_path.parent.mkdir(parents=True, exist_ok=True)
        pixmap = None
        if cache_file_path.exists():







|







342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
            pixmap = self.image_database.loaded_images[key]
        except KeyError:
            logger.debug("Image not in memory, requesting from disk")
            pixmap = self._fetch_image(card)
            self.image_database.loaded_images[key] = pixmap
            self.image_database.images_on_disk.add(key)
            logger.debug("Image loaded")
        card.set_image_file(pixmap)

    def _fetch_image(self, card: Card) -> QPixmap:
        key = ImageKey(card.scryfall_id, card.is_front, card.highres_image)
        cache_file_path = self.image_database.db_path / key.format_relative_path()
        cache_file_path.parent.mkdir(parents=True, exist_ok=True)
        pixmap = None
        if cache_file_path.exists():

Changes to mtg_proxy_printer/resources/ui/page_config_widget.ui.

10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149










150



































































151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
    <height>475</height>
   </rect>
  </property>
  <property name="title">
   <string>Default settings for new documents</string>
  </property>
  <layout class="QGridLayout" name="gridLayout">
   <item row="6" column="1" colspan="3">
    <widget class="Line" name="line">
     <property name="orientation">
      <enum>Qt::Horizontal</enum>
     </property>
    </widget>
   </item>
   <item row="17" column="3">
    <widget class="QSpinBox" name="image_spacing_vertical">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Space above and below images. Images are this many millimeters apart.&lt;/p&gt;&lt;p&gt;If set to zero, you only need one cut to separate two images, otherwise you need two cuts but require less precision hitting the exact middle.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="17" column="1">
    <widget class="QLabel" name="image_spacing_vertical_label">
     <property name="text">
      <string>Vertical image spacing</string>
     </property>
     <property name="buddy">
      <cstring>image_spacing_vertical</cstring>
     </property>
    </widget>
   </item>
   <item row="8" column="1">
    <widget class="QLabel" name="page_height_label">
     <property name="text">
      <string>Page height</string>
     </property>
     <property name="buddy">
      <cstring>page_height</cstring>
     </property>
    </widget>
   </item>
   <item row="15" column="1">
    <widget class="QLabel" name="margin_right_label">
     <property name="text">
      <string>Right margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_right</cstring>
     </property>
    </widget>
   </item>
   <item row="15" column="3">
    <widget class="QSpinBox" name="margin_right">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the right paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="12" column="3">
    <widget class="QSpinBox" name="margin_bottom">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the bottom paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="16" column="1">
    <widget class="QLabel" name="image_spacing_horizontal_label">
     <property name="text">
      <string>Horizontal image spacing</string>
     </property>
     <property name="buddy">
      <cstring>image_spacing_horizontal</cstring>
     </property>
    </widget>
   </item>
   <item row="12" column="1">
    <widget class="QLabel" name="margin_bottom_label">
     <property name="text">
      <string>Bottom Margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_bottom</cstring>
     </property>
    </widget>
   </item>
   <item row="8" column="3">
    <widget class="QSpinBox" name="page_height">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>Default paper height in millimeters</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>










   <item row="14" column="3">



































































    <widget class="QSpinBox" name="margin_left">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the left paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="14" column="1">
    <widget class="QLabel" name="margin_left_label">
     <property name="text">
      <string>Left margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_left</cstring>
     </property>
    </widget>
   </item>
   <item row="10" column="1">
    <widget class="QLabel" name="page_width_label">
     <property name="text">
      <string>Page width</string>
     </property>
     <property name="buddy">
      <cstring>page_width</cstring>
     </property>
    </widget>
   </item>
   <item row="11" column="3">
    <widget class="QSpinBox" name="margin_top">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the top paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="10" column="3">
    <widget class="QSpinBox" name="page_width">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>Default paper width in millimeters</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="11" column="1">
    <widget class="QLabel" name="margin_top_label">
     <property name="text">
      <string>Top margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_top</cstring>
     </property>
    </widget>
   </item>
   <item row="0" column="3">
    <widget class="QCheckBox" name="draw_cut_markers">
     <property name="toolTip">
      <string>Enable printing additional lines to aid cutting the printed sheets.</string>
     </property>
     <property name="text">
      <string>Print cut markers</string>
     </property>
    </widget>
   </item>
   <item row="16" column="3">
    <widget class="QSpinBox" name="image_spacing_horizontal">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Space left and right of images. Images are this many millimeters apart.&lt;/p&gt;&lt;p&gt;If set to zero, you only need one cut to separate two images, otherwise you need two cuts but require less precision hitting the exact middle.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="18" column="1">
    <widget class="QLabel" name="page_capacity_label">
     <property name="text">
      <string>Resulting page capacity:</string>



     </property>
    </widget>
   </item>
   <item row="18" column="3">
    <widget class="QLabel" name="page_capacity">
     <property name="toolTip">
      <string>Number of regular-size cards fitting on each page,
based on the page size and spacings configured</string>
     </property>
     <property name="text">
      <string/>
     </property>
    </widget>
   </item>
  </layout>
 </widget>
 <tabstops>
  <tabstop>draw_cut_markers</tabstop>







<
<
<
<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<









|
<
<
<
<
<
<
<
<
<
<



















|







|









|
|
|
|

|
|



|
<
<
<
<
<
<
<
<
<
<


















>
>
>
>
>
>
>
>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


















|
|

|


|



|
|

|

<
<
<


|


















|
|







|









|
|

|


|



<
<
<
<
<
<
<
<
<
<
|


















|
|

|
>
>
>



|
|
<
<
<
<

|







10
11
12
13
14
15
16







17





























18
19
20
21
22
23
24
25
26
27










28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75










76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254










255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285




286
287
288
289
290
291
292
293
294
    <height>475</height>
   </rect>
  </property>
  <property name="title">
   <string>Default settings for new documents</string>
  </property>
  <layout class="QGridLayout" name="gridLayout">







   <item row="10" column="1">





























    <widget class="QLabel" name="page_height_label">
     <property name="text">
      <string>Page height</string>
     </property>
     <property name="buddy">
      <cstring>page_height</cstring>
     </property>
    </widget>
   </item>
   <item row="17" column="3">










    <widget class="QSpinBox" name="margin_right">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the right paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="12" column="3">
    <widget class="QSpinBox" name="page_width">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>Default paper width in millimeters</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="0" column="3">
    <widget class="QCheckBox" name="draw_cut_markers">
     <property name="toolTip">
      <string>Enable printing additional lines to aid cutting the printed sheets.</string>
     </property>
     <property name="text">
      <string>Print cut markers</string>
     </property>
    </widget>
   </item>
   <item row="10" column="3">










    <widget class="QSpinBox" name="page_height">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>Default paper height in millimeters</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="13" column="1">
    <widget class="QLabel" name="margin_top_label">
     <property name="text">
      <string>Top margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_top</cstring>
     </property>
    </widget>
   </item>
   <item row="14" column="3">
    <widget class="QSpinBox" name="margin_bottom">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the bottom paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="14" column="1">
    <widget class="QLabel" name="margin_bottom_label">
     <property name="text">
      <string>Bottom Margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_bottom</cstring>
     </property>
    </widget>
   </item>
   <item row="8" column="1" colspan="3">
    <widget class="Line" name="line">
     <property name="orientation">
      <enum>Qt::Horizontal</enum>
     </property>
    </widget>
   </item>
   <item row="20" column="3">
    <widget class="QLabel" name="page_capacity">
     <property name="toolTip">
      <string>Number of regular-size cards fitting on each page,
based on the page size and spacings configured</string>
     </property>
     <property name="text">
      <string/>
     </property>
    </widget>
   </item>
   <item row="17" column="1">
    <widget class="QLabel" name="margin_right_label">
     <property name="text">
      <string>Right margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_right</cstring>
     </property>
    </widget>
   </item>
   <item row="19" column="1">
    <widget class="QLabel" name="image_spacing_vertical_label">
     <property name="text">
      <string>Vertical image spacing</string>
     </property>
     <property name="buddy">
      <cstring>image_spacing_vertical</cstring>
     </property>
    </widget>
   </item>
   <item row="16" column="3">
    <widget class="QSpinBox" name="margin_left">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the left paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="18" column="1">
    <widget class="QLabel" name="image_spacing_horizontal_label">
     <property name="text">
      <string>Horizontal image spacing</string>
     </property>
     <property name="buddy">
      <cstring>image_spacing_horizontal</cstring>
     </property>
    </widget>
   </item>
   <item row="20" column="1">
    <widget class="QLabel" name="page_capacity_label">
     <property name="text">
      <string>Resulting page capacity:</string>
     </property>



    </widget>
   </item>
   <item row="13" column="3">
    <widget class="QSpinBox" name="margin_top">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum margin between the top paper border and the page content.&lt;/p&gt;&lt;p&gt;Most printers have a minimum printing margin of 3 to 5 mm.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="19" column="3">
    <widget class="QSpinBox" name="image_spacing_vertical">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Space above and below images. Images are this many millimeters apart.&lt;/p&gt;&lt;p&gt;If set to zero, you only need one cut to separate two images, otherwise you need two cuts but require less precision hitting the exact middle.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="16" column="1">
    <widget class="QLabel" name="margin_left_label">
     <property name="text">
      <string>Left margin</string>
     </property>
     <property name="buddy">
      <cstring>margin_left</cstring>
     </property>
    </widget>
   </item>










   <item row="18" column="3">
    <widget class="QSpinBox" name="image_spacing_horizontal">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="toolTip">
      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Space left and right of images. Images are this many millimeters apart.&lt;/p&gt;&lt;p&gt;If set to zero, you only need one cut to separate two images, otherwise you need two cuts but require less precision hitting the exact middle.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
     </property>
     <property name="suffix">
      <string> mm</string>
     </property>
     <property name="maximum">
      <number>10000</number>
     </property>
    </widget>
   </item>
   <item row="12" column="1">
    <widget class="QLabel" name="page_width_label">
     <property name="text">
      <string>Page width</string>
     </property>
     <property name="buddy">
      <cstring>page_width</cstring>
     </property>
    </widget>
   </item>
   <item row="1" column="3">
    <widget class="QCheckBox" name="draw_sharp_corners">




     <property name="text">
      <string>Draw 90° card corners, instead of round ones</string>
     </property>
    </widget>
   </item>
  </layout>
 </widget>
 <tabstops>
  <tabstop>draw_cut_markers</tabstop>

Changes to mtg_proxy_printer/settings.py.

49
50
51
52
53
54
55









56
57
58
59
60
61
62
VERSION_CHECK_RE = re.compile(
    # sourced from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
    r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
    r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][\da-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][\da-zA-Z-]*))*))?"
    r"(?:\+(?P<buildmetadata>[\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$"
)










DEFAULT_SETTINGS["images"] = {
    "preferred-language": "en",
    "automatically-add-opposing-faces": "True",
}
DEFAULT_SETTINGS["card-filter"] = {
    "hide-cards-depicting-racism": "True",
    "hide-cards-without-images": "True",







>
>
>
>
>
>
>
>
>







49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
VERSION_CHECK_RE = re.compile(
    # sourced from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
    r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
    r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][\da-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][\da-zA-Z-]*))*))?"
    r"(?:\+(?P<buildmetadata>[\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$"
)

# Below are the default application settings. How to define new ones:
# - Add a key-value pair (String keys and values only) to a section or add a new section
# - If adding a new section, also add a validator function for that section.
# - Add the new key to the validator of the section it’s in. The validator has to check that the value can be properly
#   cast into the expected type and perform a value range check.
# - Add the option to the Settings window UI
# - Wire up save and load functionality for the new key in the Settings UI
# - The Settings GUI class has to also do a value range check.

DEFAULT_SETTINGS["images"] = {
    "preferred-language": "en",
    "automatically-add-opposing-faces": "True",
}
DEFAULT_SETTINGS["card-filter"] = {
    "hide-cards-depicting-racism": "True",
    "hide-cards-without-images": "True",
84
85
86
87
88
89
90

91
92
93
94
95
96
97
    "margin-bottom-mm": "10",
    "margin-left-mm": "7",
    "margin-right-mm": "7",
    "image-spacing-horizontal-mm": "0",
    "image-spacing-vertical-mm": "0",
    "print-cut-marker": "False",
    "pdf-page-count-limit": "0",

}
DEFAULT_SETTINGS["default-filesystem-paths"] = {
    "document-save-path": QStandardPaths.locate(QStandardPaths.DocumentsLocation, "", QStandardPaths.LocateDirectory),
    "pdf-export-path": QStandardPaths.locate(QStandardPaths.DocumentsLocation, "", QStandardPaths.LocateDirectory),
    "deck-list-search-path": QStandardPaths.locate(QStandardPaths.DownloadLocation, "", QStandardPaths.LocateDirectory),
}
DEFAULT_SETTINGS["gui"] = {







>







93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
    "margin-bottom-mm": "10",
    "margin-left-mm": "7",
    "margin-right-mm": "7",
    "image-spacing-horizontal-mm": "0",
    "image-spacing-vertical-mm": "0",
    "print-cut-marker": "False",
    "pdf-page-count-limit": "0",
    "print-sharp-corners": "False",
}
DEFAULT_SETTINGS["default-filesystem-paths"] = {
    "document-save-path": QStandardPaths.locate(QStandardPaths.DocumentsLocation, "", QStandardPaths.LocateDirectory),
    "pdf-export-path": QStandardPaths.locate(QStandardPaths.DocumentsLocation, "", QStandardPaths.LocateDirectory),
    "deck-list-search-path": QStandardPaths.locate(QStandardPaths.DownloadLocation, "", QStandardPaths.LocateDirectory),
}
DEFAULT_SETTINGS["gui"] = {
189
190
191
192
193
194
195
196
197
198
199

200
201
202
203
204
205
206
207
208
        _restore_default(section, defaults, "preferred-language")


def _validate_documents_section(settings: configparser.ConfigParser, section_name: str = "documents"):
    sizes: mtg_proxy_printer.units_and_sizes.CardSize = mtg_proxy_printer.units_and_sizes.CardSizes.OVERSIZED.value
    section = settings[section_name]
    defaults = DEFAULT_SETTINGS[section_name]
    _validate_boolean(section, defaults, "print-cut-marker")
    # Check syntax
    for key in section.keys():
        if key in ("print-cut-marker",):

            continue
        _validate_non_negative_int(section, defaults, key)
    # Check some semantic properties
    available_height = section.getint("paper-height-mm") - \
        (section.getint("margin-top-mm") + section.getint("margin-bottom-mm"))
    available_width = section.getint("paper-width-mm") - \
        (section.getint("margin-left-mm") + section.getint("margin-right-mm"))

    if available_height < sizes.height:







|


|
>
|
|







199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
        _restore_default(section, defaults, "preferred-language")


def _validate_documents_section(settings: configparser.ConfigParser, section_name: str = "documents"):
    sizes: mtg_proxy_printer.units_and_sizes.CardSize = mtg_proxy_printer.units_and_sizes.CardSizes.OVERSIZED.value
    section = settings[section_name]
    defaults = DEFAULT_SETTINGS[section_name]
    boolean_settings = {"print-cut-marker", "print-sharp-corners"}
    # Check syntax
    for key in section.keys():
        if key in boolean_settings:
            _validate_boolean(section, defaults, key)
        else:
            _validate_non_negative_int(section, defaults, key)
    # Check some semantic properties
    available_height = section.getint("paper-height-mm") - \
        (section.getint("margin-top-mm") + section.getint("margin-bottom-mm"))
    available_width = section.getint("paper-width-mm") - \
        (section.getint("margin-left-mm") + section.getint("margin-right-mm"))

    if available_height < sizes.height:

Changes to mtg_proxy_printer/ui/page_config_widget.py.

51
52
53
54
55
56
57



58
59
60
61
62
63
64
        self.margin_right.valueChanged[int].connect(partial(setattr, page_layout, "margin_right"))
        self.image_spacing_horizontal.valueChanged[int].connect(
            partial(setattr, page_layout, "image_spacing_horizontal"))
        self.image_spacing_vertical.valueChanged[int].connect(partial(setattr, page_layout, "image_spacing_vertical"))
        self.draw_cut_markers: QCheckBox
        self.draw_cut_markers.stateChanged.connect(
            lambda new: setattr(page_layout, "draw_cut_markers", new == Qt.Checked))



        return page_layout

    @Slot()
    def page_layout_setting_changed(self):
        """
        Recomputes and updates the page capacity value, whenever any page layout widget changes.
        Qt Signal/Slot connections from editor widgets valueChanged[int] signals are defined in the UI file.







>
>
>







51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
        self.margin_right.valueChanged[int].connect(partial(setattr, page_layout, "margin_right"))
        self.image_spacing_horizontal.valueChanged[int].connect(
            partial(setattr, page_layout, "image_spacing_horizontal"))
        self.image_spacing_vertical.valueChanged[int].connect(partial(setattr, page_layout, "image_spacing_vertical"))
        self.draw_cut_markers: QCheckBox
        self.draw_cut_markers.stateChanged.connect(
            lambda new: setattr(page_layout, "draw_cut_markers", new == Qt.Checked))
        self.draw_sharp_corners: QCheckBox
        self.draw_sharp_corners.stateChanged.connect(
            lambda new: setattr(page_layout, "draw_sharp_corners", new == Qt.Checked))
        return page_layout

    @Slot()
    def page_layout_setting_changed(self):
        """
        Recomputes and updates the page capacity value, whenever any page layout widget changes.
        Qt Signal/Slot connections from editor widgets valueChanged[int] signals are defined in the UI file.
81
82
83
84
85
86
87
88
89
90

91
92
93
94
95
96
97
98
        self.page_width: QSpinBox
        self.page_height.setMinimum(min_page_height)
        self.page_width.setMinimum(min_page_width)

    def load_document_settings_from_config(self, settings: configparser.ConfigParser):
        logger.debug(f"About to load document settings from the global settings")
        document_section = settings["documents"]
        widgets_with_settings = self._get_document_settings_widgets()
        for widget, setting in widgets_with_settings:
            widget.setValue(document_section.getint(setting))

        self.draw_cut_markers.setChecked(document_section.getboolean("print-cut-marker"))
        logger.debug(f"Loading from settings finished")

    def load_from_page_layout(self, other: PageLayoutSettings):
        """Loads the page layout from another PageLayoutSettings instance"""
        logger.debug(f"About to load document settings from a document instance")
        layout = self.page_layout
        for key in layout.__annotations__.keys():







<
|
|
>
|







84
85
86
87
88
89
90

91
92
93
94
95
96
97
98
99
100
101
        self.page_width: QSpinBox
        self.page_height.setMinimum(min_page_height)
        self.page_width.setMinimum(min_page_width)

    def load_document_settings_from_config(self, settings: configparser.ConfigParser):
        logger.debug(f"About to load document settings from the global settings")
        document_section = settings["documents"]

        for spinbox, setting in self._get_integer_settings_widgets():
            spinbox.setValue(document_section.getint(setting))
        for checkbox, setting in self._get_boolean_settings_widgets():
            checkbox.setChecked(document_section.getboolean(setting))
        logger.debug(f"Loading from settings finished")

    def load_from_page_layout(self, other: PageLayoutSettings):
        """Loads the page layout from another PageLayoutSettings instance"""
        logger.debug(f"About to load document settings from a document instance")
        layout = self.page_layout
        for key in layout.__annotations__.keys():
107
108
109
110
111
112
113
114
115
116

117
118
119
120
121
122
123
124
125
126
127
128
129
130
131







        self.validate_paper_size_settings()
        self.page_layout_setting_changed()
        logger.debug(f"Loading from document settings finished")

    def save_document_settings_to_config(self):
        logger.info("About to save document settings to the global settings")
        documents_section = mtg_proxy_printer.settings.settings["documents"]
        widgets_and_settings = self._get_document_settings_widgets()
        for widget, setting in widgets_and_settings:
            documents_section[setting] = str(widget.value())

        documents_section["print-cut-marker"] = str(self.draw_cut_markers.isChecked())
        logger.debug("Saving done.")

    def _get_document_settings_widgets(self):
        widgets_with_settings: typing.List[typing.Tuple[QSpinBox, str]] = [
            (self.page_height, "paper-height-mm"),
            (self.page_width, "paper-width-mm"),
            (self.margin_top, "margin-top-mm"),
            (self.margin_bottom, "margin-bottom-mm"),
            (self.margin_left, "margin-left-mm"),
            (self.margin_right, "margin-right-mm"),
            (self.image_spacing_horizontal, "image-spacing-horizontal-mm"),
            (self.image_spacing_vertical, "image-spacing-vertical-mm"),
        ]
        return widgets_with_settings














<
|
|
>
|


|











>
>
>
>
>
>
>
110
111
112
113
114
115
116

117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
        self.validate_paper_size_settings()
        self.page_layout_setting_changed()
        logger.debug(f"Loading from document settings finished")

    def save_document_settings_to_config(self):
        logger.info("About to save document settings to the global settings")
        documents_section = mtg_proxy_printer.settings.settings["documents"]

        for spinbox, setting in self._get_integer_settings_widgets():
            documents_section[setting] = str(spinbox.value())
        for checkbox, setting in self._get_boolean_settings_widgets():
            documents_section[setting] = str(checkbox.isChecked())
        logger.debug("Saving done.")

    def _get_integer_settings_widgets(self):
        widgets_with_settings: typing.List[typing.Tuple[QSpinBox, str]] = [
            (self.page_height, "paper-height-mm"),
            (self.page_width, "paper-width-mm"),
            (self.margin_top, "margin-top-mm"),
            (self.margin_bottom, "margin-bottom-mm"),
            (self.margin_left, "margin-left-mm"),
            (self.margin_right, "margin-right-mm"),
            (self.image_spacing_horizontal, "image-spacing-horizontal-mm"),
            (self.image_spacing_vertical, "image-spacing-vertical-mm"),
        ]
        return widgets_with_settings

    def _get_boolean_settings_widgets(self):
        widgets_with_settings: typing.List[typing.Tuple[QCheckBox, str]] = [
            (self.draw_cut_markers, "print-cut-marker"),
            (self.draw_sharp_corners, "print-sharp-corners"),
        ]
        return widgets_with_settings

Changes to mtg_proxy_printer/ui/page_renderer.py.

20
21
22
23
24
25
26

27
28
29
30
31
32
33
    pyqtSignal as Signal, QEvent
from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QWidget, QAction
from PyQt5.QtGui import QColor, QPixmap, QWheelEvent, QKeySequence, QPalette, QBrush, QResizeEvent
import pint

from mtg_proxy_printer.units_and_sizes import PageType, CardSizes, CardSize, unit_registry, DPI
from mtg_proxy_printer.model.document import Document

from mtg_proxy_printer.model.card_list import PageColumns
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger


__all__ = [







>







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    pyqtSignal as Signal, QEvent
from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QWidget, QAction
from PyQt5.QtGui import QColor, QPixmap, QWheelEvent, QKeySequence, QPalette, QBrush, QResizeEvent
import pint

from mtg_proxy_printer.units_and_sizes import PageType, CardSizes, CardSize, unit_registry, DPI
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.carddb import Card, CardCorner
from mtg_proxy_printer.model.card_list import PageColumns
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger


__all__ = [
128
129
130
131
132
133
134


135
136
137
138


















139
140
141
142
143
144
145
            self.draw_card(row)

    def draw_card(self, row: int):
        index = self.selected_page.child(row, PageColumns.Image)
        position = self._compute_position_for_image(index)
        image: QPixmap = index.data(Qt.DisplayRole)
        if image is not None:


            pixmap = self.addPixmap(image)
            pixmap.setTransformationMode(Qt.SmoothTransformation)
            pixmap.setPos(position)



















    @Slot(QModelIndex)
    def on_page_type_changed(self, page: QModelIndex):
        if page.row() == self.selected_page.row():
            self.redraw()

    def on_data_changed(self, top_left: QModelIndex, bottom_right: QModelIndex, roles: typing.List[Qt.ItemDataRole]):
        if top_left.parent().row() == self.selected_page.row() and Qt.DisplayRole in roles:







>
>




>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
            self.draw_card(row)

    def draw_card(self, row: int):
        index = self.selected_page.child(row, PageColumns.Image)
        position = self._compute_position_for_image(index)
        image: QPixmap = index.data(Qt.DisplayRole)
        if image is not None:
            if self.document.page_layout.draw_sharp_corners:
                self._draw_corners(index, position)
            pixmap = self.addPixmap(image)
            pixmap.setTransformationMode(Qt.SmoothTransformation)
            pixmap.setPos(position)

    def _draw_corners(self, index: QModelIndex, position: QPointF):
        card: Card = index.internalPointer().card
        image = card.image_file
        corner_size = QSizeF(50, 50)
        # Needs to offset the corner position by some half pixels to not overlap
        self.addRect(
            QRectF(position + QPointF(0.5, 0.5), corner_size),
            card.corner_color(CardCorner.TOP_LEFT), card.corner_color(CardCorner.TOP_LEFT))
        self.addRect(
            QRectF(position + image.rect().topRight() - QPointF(49.5, -0.5), corner_size),
            card.corner_color(CardCorner.TOP_RIGHT), card.corner_color(CardCorner.TOP_RIGHT))
        self.addRect(
            QRectF(position + image.rect().bottomLeft() - QPointF(-0.5, 49.5), corner_size),
            card.corner_color(CardCorner.BOTTOM_LEFT), card.corner_color(CardCorner.BOTTOM_LEFT))
        self.addRect(
            QRectF(position + image.rect().bottomRight() - QPointF(49.5, 49.5), corner_size),
            card.corner_color(CardCorner.BOTTOM_RIGHT), card.corner_color(CardCorner.BOTTOM_RIGHT))

    @Slot(QModelIndex)
    def on_page_type_changed(self, page: QModelIndex):
        if page.row() == self.selected_page.row():
            self.redraw()

    def on_data_changed(self, top_left: QModelIndex, bottom_right: QModelIndex, roles: typing.List[Qt.ItemDataRole]):
        if top_left.parent().row() == self.selected_page.row() and Qt.DisplayRole in roles:
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
        spacing_horizontal = page_layout.image_spacing_horizontal

        x_pos = page_layout.margin_left + column * (card_size.width + spacing_horizontal)
        y_pos = page_layout.margin_top + row * (card_size.height + spacing_vertical)
        scaling_horizontal = self.width() / page_layout.page_width
        scaling_vertical = self.height() / page_layout.page_height
        return QPointF(
            x_pos * scaling_horizontal,
            y_pos * scaling_vertical,
        )

    def _draw_cut_markers(self):
        """Draws the optional cut markers that extend to the paper border"""
        page_type: PageType = self.selected_page.data(Qt.EditRole).page_type()
        if page_type == PageType.MIXED:
            logger.warning("Not drawing cut markers for page with mixed image sizes")







|
|







222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
        spacing_horizontal = page_layout.image_spacing_horizontal

        x_pos = page_layout.margin_left + column * (card_size.width + spacing_horizontal)
        y_pos = page_layout.margin_top + row * (card_size.height + spacing_vertical)
        scaling_horizontal = self.width() / page_layout.page_width
        scaling_vertical = self.height() / page_layout.page_height
        return QPointF(
            x_pos * scaling_horizontal + 0.5*column,
            y_pos * scaling_vertical + 0.5*row,
        )

    def _draw_cut_markers(self):
        """Draws the optional cut markers that extend to the paper border"""
        page_type: PageType = self.selected_page.data(Qt.EditRole).page_type()
        if page_type == PageType.MIXED:
            logger.warning("Not drawing cut markers for page with mixed image sizes")
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
    def _draw_vertical_markers(self, line_color: QColor, card_size: CardSize):
        page_layout = self.document.page_layout
        scaling_horizontal = self.width() / page_layout.page_width
        column_count = page_layout.compute_page_column_count(page_layout)
        if not page_layout.image_spacing_horizontal:
            column_count += 1
        for column in range(column_count):
            column_px = scaling_horizontal * (
                    page_layout.margin_left +
                    column * (card_size.width + page_layout.image_spacing_horizontal)
            )
            self._draw_vertical_line(column_px, line_color)
            if page_layout.image_spacing_horizontal:
                offset = 1 + card_size.width * scaling_horizontal
                self._draw_vertical_line(column_px + offset, line_color)
        logger.debug(f"Vertical cut markers drawn")

    def _draw_horizontal_markers(self, line_color: QColor, card_size: CardSize):
        page_layout = self.document.page_layout
        scaling_vertical = self.height() / page_layout.page_height
        row_count = page_layout.compute_page_row_count(page_layout)
        if not page_layout.image_spacing_vertical:
            row_count += 1
        for row in range(row_count):
            row_px = scaling_vertical * (
                    page_layout.margin_top +
                    row * (card_size.height + page_layout.image_spacing_vertical)
            )
            self._draw_horizontal_line(row_px, line_color)
            if page_layout.image_spacing_vertical:
                offset = 1 + card_size.height * scaling_vertical
                self._draw_horizontal_line(row_px + offset, line_color)
        logger.debug(f"Horizontal cut markers drawn")

    def _draw_vertical_line(self, column_px: int, line_color: QColor):
        self.addLine(column_px, 0, column_px, self.height(), line_color)

    def _draw_horizontal_line(self, row_px: int, line_color: QColor):







|
















|





|







246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
    def _draw_vertical_markers(self, line_color: QColor, card_size: CardSize):
        page_layout = self.document.page_layout
        scaling_horizontal = self.width() / page_layout.page_width
        column_count = page_layout.compute_page_column_count(page_layout)
        if not page_layout.image_spacing_horizontal:
            column_count += 1
        for column in range(column_count):
            column_px = 0.5 * column + scaling_horizontal * (
                    page_layout.margin_left +
                    column * (card_size.width + page_layout.image_spacing_horizontal)
            )
            self._draw_vertical_line(column_px, line_color)
            if page_layout.image_spacing_horizontal:
                offset = 1 + card_size.width * scaling_horizontal
                self._draw_vertical_line(column_px + offset, line_color)
        logger.debug(f"Vertical cut markers drawn")

    def _draw_horizontal_markers(self, line_color: QColor, card_size: CardSize):
        page_layout = self.document.page_layout
        scaling_vertical = self.height() / page_layout.page_height
        row_count = page_layout.compute_page_row_count(page_layout)
        if not page_layout.image_spacing_vertical:
            row_count += 1
        for row in range(row_count):
            row_px = 0.5 * row + scaling_vertical * (
                    page_layout.margin_top +
                    row * (card_size.height + page_layout.image_spacing_vertical)
            )
            self._draw_horizontal_line(row_px, line_color)
            if page_layout.image_spacing_vertical:
                offset = 0.5 + card_size.height * scaling_vertical
                self._draw_horizontal_line(row_px + offset, line_color)
        logger.debug(f"Horizontal cut markers drawn")

    def _draw_vertical_line(self, column_px: int, line_color: QColor):
        self.addLine(column_px, 0, column_px, self.height(), line_color)

    def _draw_horizontal_line(self, row_px: int, line_color: QColor):

Changes to tests/conftest.py.

44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
        card_db.db.execute("PRAGMA reverse_unordered_selects = TRUE")
    return card_db


@pytest.fixture(params=[False, True])
def empty_save_database(request) -> sqlite3.Connection:
    db = mtg_proxy_printer.sqlite_helpers.open_database(
            ":memory:", "document-v4", CardDatabase.MIN_SUPPORTED_SQLITE_VERSION, check_same_thread=False)
    if request.param:
        db.execute("PRAGMA reverse_unordered_selects = TRUE")
    return db


@pytest.fixture()
def image_db():







|







44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
        card_db.db.execute("PRAGMA reverse_unordered_selects = TRUE")
    return card_db


@pytest.fixture(params=[False, True])
def empty_save_database(request) -> sqlite3.Connection:
    db = mtg_proxy_printer.sqlite_helpers.open_database(
            ":memory:", "document-v5", CardDatabase.MIN_SUPPORTED_SQLITE_VERSION, check_same_thread=False)
    if request.param:
        db.execute("PRAGMA reverse_unordered_selects = TRUE")
    return db


@pytest.fixture()
def image_db():

Changes to tests/test_document.py.

229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
    assert_that(document.pages[1].page_type(), is_(PageType.OVERSIZED))
    assert_that(document.pages[2].page_type(), is_(PageType.REGULAR))
    assert_that(document.pages[3].page_type(), is_(PageType.OVERSIZED))
    assert_that(document.pages[4].page_type(), is_(PageType.MIXED))
    assert_that(document.pages[5].page_type(), is_(PageType.UNDETERMINED))


@pytest.mark.parametrize("source_version", [2, 3])
def test_save_migration(document: Document, source_version: int):
    """Tests migration of existing saves to the newest schema revision on save."""
    card = document.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    document.add_card(card, document.total_cards_per_page)
    with TemporaryDirectory() as temp_dir:
        document.save_file_path = _create_save_file(pathlib.Path(temp_dir), source_version)
        document.save_to_disk()







|







229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
    assert_that(document.pages[1].page_type(), is_(PageType.OVERSIZED))
    assert_that(document.pages[2].page_type(), is_(PageType.REGULAR))
    assert_that(document.pages[3].page_type(), is_(PageType.OVERSIZED))
    assert_that(document.pages[4].page_type(), is_(PageType.MIXED))
    assert_that(document.pages[5].page_type(), is_(PageType.UNDETERMINED))


@pytest.mark.parametrize("source_version", [2, 3, 4])
def test_save_migration(document: Document, source_version: int):
    """Tests migration of existing saves to the newest schema revision on save."""
    card = document.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    document.add_card(card, document.total_cards_per_page)
    with TemporaryDirectory() as temp_dir:
        document.save_file_path = _create_save_file(pathlib.Path(temp_dir), source_version)
        document.save_to_disk()
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def _validate_database_schema(db_path: pathlib.Path):
    """
    Validates the database schema of the user-provided file against a known-good schema.

    :raises AssertionError: If the provided file contains an invalid schema
    :returns: Database schema version
    """
    target_schema_version = 4
    db_unsafe = open_database(
        db_path, f"document-v{target_schema_version}", DocumentLoader.MIN_SUPPORTED_SQLITE_VERSION)
    if db_unsafe.execute("PRAGMA application_id").fetchone()[0] != 41325044:
        raise AssertionError("Not an MTGProxyPrinter save file!")
    user_schema_version = db_unsafe.execute("PRAGMA user_version").fetchone()[0]
    assert_that(user_schema_version, is_(equal_to(target_schema_version)))
    db_known_good = create_in_memory_database(







|







298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def _validate_database_schema(db_path: pathlib.Path):
    """
    Validates the database schema of the user-provided file against a known-good schema.

    :raises AssertionError: If the provided file contains an invalid schema
    :returns: Database schema version
    """
    target_schema_version = 5
    db_unsafe = open_database(
        db_path, f"document-v{target_schema_version}", DocumentLoader.MIN_SUPPORTED_SQLITE_VERSION)
    if db_unsafe.execute("PRAGMA application_id").fetchone()[0] != 41325044:
        raise AssertionError("Not an MTGProxyPrinter save file!")
    user_schema_version = db_unsafe.execute("PRAGMA user_version").fetchone()[0]
    assert_that(user_schema_version, is_(equal_to(target_schema_version)))
    db_known_good = create_in_memory_database(

Changes to tests/test_document_loader.py.

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.units_and_sizes import PageType
import mtg_proxy_printer.model.document
import mtg_proxy_printer.sqlite_helpers


@pytest.mark.parametrize("version", [-1, 0, 1, 5, 6])
def test_unknown_save_version_raises_exception(empty_save_database: sqlite3.Connection, version: int):
    empty_save_database.execute(f"PRAGMA user_version = {version};")
    assert_that(empty_save_database.execute("PRAGMA user_version").fetchone()[0], is_(version))
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock:
        mock.return_value = empty_save_database
        assert_that(
            calling(mtg_proxy_printer.model.document_loader.DocumentLoader.Worker._read_data_from_save_path).with_args(







|







27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.units_and_sizes import PageType
import mtg_proxy_printer.model.document
import mtg_proxy_printer.sqlite_helpers


@pytest.mark.parametrize("version", [-1, 0, 1, 6, 7])
def test_unknown_save_version_raises_exception(empty_save_database: sqlite3.Connection, version: int):
    empty_save_database.execute(f"PRAGMA user_version = {version};")
    assert_that(empty_save_database.execute("PRAGMA user_version").fetchone()[0], is_(version))
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock:
        mock.return_value = empty_save_database
        assert_that(
            calling(mtg_proxy_printer.model.document_loader.DocumentLoader.Worker._read_data_from_save_path).with_args(
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
    )

    assert_that(page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)))
    empty_save_database.execute(
        textwrap.dedent("""\
            INSERT INTO DocumentSettings (rowid, page_height, page_width,
                  margin_top, margin_bottom, margin_left, margin_right,
                  image_spacing_horizontal, image_spacing_vertical, draw_cut_markers)
              VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """),
        dataclasses.astuple(page_layout))
    loader = document.loader
    save_path = pathlib.Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_state_changed, timeout=1000, check_params_cb=lambda value: not value), \







|
|







76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
    )

    assert_that(page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)))
    empty_save_database.execute(
        textwrap.dedent("""\
            INSERT INTO DocumentSettings (rowid, page_height, page_width,
                  margin_top, margin_bottom, margin_left, margin_right,
                  image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners)
              VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """),
        dataclasses.astuple(page_layout))
    loader = document.loader
    save_path = pathlib.Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_state_changed, timeout=1000, check_params_cb=lambda value: not value), \
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
            (1, 2, 1, "650722b4-d72b-4745-a1a5-00a34836282b"),
         ]
    )
    empty_save_database.execute(
        textwrap.dedent("""\
            INSERT INTO DocumentSettings (rowid, page_height, page_width,
                  margin_top, margin_bottom, margin_left, margin_right,
                  image_spacing_horizontal, image_spacing_vertical, draw_cut_markers)
              VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """),
        dataclasses.astuple(mtg_proxy_printer.model.document.PageLayoutSettings.create_from_settings()))
    loader = document.loader
    save_path = pathlib.Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_state_changed, timeout=1000, check_params_cb=lambda value: not value), \







|
|







119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
            (1, 2, 1, "650722b4-d72b-4745-a1a5-00a34836282b"),
         ]
    )
    empty_save_database.execute(
        textwrap.dedent("""\
            INSERT INTO DocumentSettings (rowid, page_height, page_width,
                  margin_top, margin_bottom, margin_left, margin_right,
                  image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners)
              VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """),
        dataclasses.astuple(mtg_proxy_printer.model.document.PageLayoutSettings.create_from_settings()))
    loader = document.loader
    save_path = pathlib.Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_state_changed, timeout=1000, check_params_cb=lambda value: not value), \
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
        empty_save_database: sqlite3.Connection):
    empty_save_database.execute("DROP TABLE DocumentSettings")
    # LIMIT clause in the definition below is a safety measure.
    empty_save_database.execute(textwrap.dedent("""\
        CREATE VIEW DocumentSettings (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers) AS 
        WITH RECURSIVE settings_gen (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers
        ) AS (
                SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1
                UNION ALL 
                SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1
                FROM settings_gen
                LIMIT 100000
            )
        SELECT * FROM settings_gen
        """))
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock:







|



|

|

|







212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
        empty_save_database: sqlite3.Connection):
    empty_save_database.execute("DROP TABLE DocumentSettings")
    # LIMIT clause in the definition below is a safety measure.
    empty_save_database.execute(textwrap.dedent("""\
        CREATE VIEW DocumentSettings (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners) AS 
        WITH RECURSIVE settings_gen (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners
        ) AS (
                SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1
                UNION ALL 
                SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1
                FROM settings_gen
                LIMIT 100000
            )
        SELECT * FROM settings_gen
        """))
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document.mtg_proxy_printer.sqlite_helpers.open_database") as mock: