diff --git a/.gitignore b/.gitignore index 5d90984aa8cd0607c9d1192fd1166eeaea1f013a..0db9e6a46b9fa4611784c4cce0d509c708f3a1d4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ Kodebok_*.pdf # Generated SOI-files SOI_*_*_*_*.txt -SOI_*_*_*_*.json \ No newline at end of file +SOI_*_*_*_*.json +SOI_*_*_*_*.pdf diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py index 4758b5c2e7846cef8483f17032f4bea10a882c81..93941ece68f28062a99d8a57d0eb5d8795ceb46c 100644 --- a/soitool/inline_editable_soi_view.py +++ b/soitool/inline_editable_soi_view.py @@ -1,4 +1,5 @@ """Includes functionality for inline editing of SOI.""" +from datetime import datetime from PySide2.QtCore import Qt, QRectF, QTimer, QPoint, QMarginsF from PySide2.QtWidgets import ( QApplication, @@ -12,6 +13,7 @@ from PySide2.QtGui import QFont, QPixmap, QBrush, QPalette, QPainter from PySide2.QtPrintSupport import QPrinter from soitool.soi import ModuleLargerThanBinError from soitool.dialog_wrappers import exec_warning_dialog +from soitool.serialize_export_import_soi import generate_soi_filename class InlineEditableSOIView(QScrollArea): @@ -82,7 +84,7 @@ class InlineEditableSOIView(QScrollArea): # self.launch_auto_zoom() - def produce_pdf(self, filename): + def produce_pdf(self, filename=None): """Produce PDF using QGraphicsScene. Renders the QGraphicsScene-representation of the SOI as a PDF. This @@ -98,13 +100,18 @@ class InlineEditableSOIView(QScrollArea): filename : str Name of file to store PDF in. If file exists it will be overwritten. Note that the filename should contain the extension - '.pdf' to be properly handled by operating systems. + '.pdf' to be properly handled by operating systems. If no filename + is supplied one will be generated following the same convention as + for exported JSON and TXT files Raises ------ ValueError If filename is invalid. """ + if filename is None: + filename = generate_soi_filename(self.soi) + ".pdf" + printer = QPrinter(QPrinter.HighResolution) printer.setOutputFormat(QPrinter.PdfFormat) printer.setOutputFileName(filename) @@ -177,9 +184,16 @@ class InlineEditableSOIView(QScrollArea): self.update_number_of_pages() self.draw_pages() + # Ignoring Pylint's errors "Too many local variables" and + # "Too many statements" for this function because it's doing GUI layout + # work, which in nature is tedious and repetitive. + # pylint: disable=R0914,R0915 def draw_header(self, x, y, page_number): """Draw header staring at given position. + Source for rotation approach: + * https://stackoverflow.com/a/43389394/3545896 + Parameters ---------- x : int @@ -187,52 +201,145 @@ class InlineEditableSOIView(QScrollArea): """ # Title label_title = QLabel(self.soi.title) - label_title.move(x, y) + label_title.move(x, y + self.soi.HEADER_HEIGHT) label_title.setStyleSheet("background-color: rgba(0,0,0,0%)") - label_title.setFont(QFont("Times New Roman", 50)) - self.scene.addWidget(label_title) + label_title.setFont(QFont("Times New Roman", 35)) + proxy = self.scene.addWidget(label_title) + proxy.setRotation(-90) + + # Description + label_description = QLabel(self.soi.description) + label_description.move(x + 57, y + self.soi.HEADER_HEIGHT) + label_description.setStyleSheet("background-color: rgba(0,0,0,0%)") + label_description.setFont(QFont("Times New Roman", 15)) + proxy = self.scene.addWidget(label_description) + proxy.setRotation(-90) + + # Creation date + creation_date = soi_date_string_to_user_friendly_string(self.soi.date) + label_creation_date = QLabel("Opprettet: {}".format(creation_date)) + label_creation_date.setStyleSheet("background-color: rgba(0,0,0,0%)") + label_creation_date.setFont(QFont("Times New Roman", 15)) + # Source: https://stackoverflow.com/a/8638114/3545896 + # CAUTION: does not work if font is set through stylesheet + label_width = ( + label_creation_date.fontMetrics() + .boundingRect(label_creation_date.text()) + .width() + ) + label_creation_date.move(x + 80, y + self.soi.HEADER_HEIGHT) + proxy = self.scene.addWidget(label_creation_date) + proxy.setRotation(-90) + + # Patch + pixmap = QPixmap(self.soi.icon) + patch = QLabel() + patch.setPixmap( + pixmap.scaled(self.soi.HEADER_WIDTH - 2, self.soi.HEADER_WIDTH - 2) + ) + patch.move( + x + 1, y + self.soi.HEADER_HEIGHT / 2 + self.soi.HEADER_WIDTH + 10 + ) + proxy = self.scene.addWidget(patch) + proxy.setRotation(-90) + + # Copy number + copy_number = QLabel("1 av N") + copy_number.setStyleSheet("background-color: rgba(0,0,0,0%)") + copy_number.setFont(QFont("Times New Roman", 40)) + # Source: https://stackoverflow.com/a/8638114/3545896 + # CAUTION: does not work if font is set through stylesheet + label_width = ( + copy_number.fontMetrics().boundingRect(copy_number.text()).width() + ) + # Store for usage when placing copy number title and page number + copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100 + copy_number.move(x + 18, copy_number_y_pos + label_width / 2) + proxy = self.scene.addWidget(copy_number) + proxy.setRotation(-90) + + # Copy number title + label_copy_number_header = QLabel("Eksemplar") + label_copy_number_header.setStyleSheet( + "background-color: rgba(0,0,0,0%)" + ) + label_copy_number_header.setFont(QFont("Times New Roman", 15)) + # Source: https://stackoverflow.com/a/8638114/3545896 + # CAUTION: does not work if font is set through stylesheet + label_width = ( + label_copy_number_header.fontMetrics() + .boundingRect(label_copy_number_header.text()) + .width() + ) + label_copy_number_header.move(x, copy_number_y_pos + label_width / 2) + proxy = self.scene.addWidget(label_copy_number_header) + proxy.setRotation(-90) # Page numbering page_number = QLabel( - "{} av {}".format(page_number, self.number_of_pages) + "Side {} av {}".format(page_number, self.number_of_pages) ) page_number.setStyleSheet("background-color: rgba(0,0,0,0%)") - page_number.setFont(QFont("Times New Roman", 50)) - # source: https://stackoverflow.com/a/8638114/3545896 + page_number.setFont(QFont("Times New Roman", 15)) + # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( page_number.fontMetrics().boundingRect(page_number.text()).width() ) - page_number.move(x + (self.soi.CONTENT_WIDTH - label_width) / 2, y) - self.scene.addWidget(page_number) + page_number.move(x + 85, copy_number_y_pos + label_width / 2) + proxy = self.scene.addWidget(page_number) + proxy.setRotation(-90) # Classification classification = QLabel(self.soi.classification) classification.setStyleSheet( "background-color: rgba(0,0,0,0%); " "color: red" ) - classification.setFont(QFont("Times New Roman", 50)) - # source: https://stackoverflow.com/a/8638114/3545896 + classification.setFont(QFont("Times New Roman", 35)) + # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( classification.fontMetrics() .boundingRect(classification.text()) .width() ) - x_pos = ( - x + self.soi.CONTENT_WIDTH - label_width - self.soi.HEADER_HEIGHT - ) - classification.move(x_pos, y) - self.scene.addWidget(classification) + classification.move(x, y + label_width) + proxy = self.scene.addWidget(classification) + proxy.setRotation(-90) - # Patch - pixmap = QPixmap(self.soi.icon) - patch = QLabel() - patch.setPixmap( - pixmap.scaled(self.soi.HEADER_HEIGHT, self.soi.HEADER_HEIGHT) + # From date + valid_from = soi_date_string_to_user_friendly_string( + self.soi.valid_from ) - patch.move(x + self.soi.CONTENT_WIDTH - self.soi.HEADER_HEIGHT, y) - self.scene.addWidget(patch) + label_valid_from = QLabel("Gyldig fra: {}".format(valid_from)) + label_valid_from.setStyleSheet("background-color: rgba(0,0,0,0%)") + label_valid_from.setFont(QFont("Times New Roman", 15)) + # Source: https://stackoverflow.com/a/8638114/3545896 + # CAUTION: does not work if font is set through stylesheet + label_width = ( + label_valid_from.fontMetrics() + .boundingRect(label_valid_from.text()) + .width() + ) + label_valid_from.move(x + 57, y + label_width) + proxy = self.scene.addWidget(label_valid_from) + proxy.setRotation(-90) + + # To date + valid_to = soi_date_string_to_user_friendly_string(self.soi.valid_to) + label_valid_to = QLabel("Gyldig til: {}".format(valid_to)) + label_valid_to.setStyleSheet("background-color: rgba(0,0,0,0%)") + label_valid_to.setFont(QFont("Times New Roman", 15)) + # Source: https://stackoverflow.com/a/8638114/3545896 + # CAUTION: does not work if font is set through stylesheet + label_width = ( + label_valid_to.fontMetrics() + .boundingRect(label_valid_to.text()) + .width() + ) + label_valid_to.move(x + 80, y + label_width) + proxy = self.scene.addWidget(label_valid_to) + proxy.setRotation(-90) def draw_page(self, x, y): """Draw page starting at given position. @@ -254,19 +361,13 @@ class InlineEditableSOIView(QScrollArea): self.scene.addRect( x + self.soi.PADDING, y + self.soi.PADDING, - self.soi.CONTENT_WIDTH, - self.soi.CONTENT_HEIGHT, - ) - self.scene.addRect( - x + self.soi.PADDING, - y + self.soi.PADDING, - self.soi.CONTENT_WIDTH, - self.soi.CONTENT_HEIGHT, + self.soi.WIDTH - self.soi.PADDING * 2, + self.soi.HEIGHT - self.soi.PADDING * 2, ) self.scene.addRect( x + self.soi.PADDING, y + self.soi.PADDING, - self.soi.CONTENT_WIDTH, + self.soi.HEADER_WIDTH, self.soi.HEADER_HEIGHT, ) @@ -321,3 +422,20 @@ class InlineEditableSOIView(QScrollArea): pos_new = self.view.mapToScene(self.soi.WIDTH / 2, self.soi.HEIGHT / 2) delta = pos_new - pos_old self.view.translate(delta.x(), delta.y()) + + +def soi_date_string_to_user_friendly_string(date_string): + """Convert SOI date to user-friendly format. + + Parameters + ---------- + date_string : str + String following convention used in SOI, which is 'YYYY-MM-DD' + + Returns + ------- + str + String following user friendly convention 'DD.MM.YYYY' + """ + parsed_date = datetime.strptime(date_string, "%Y-%m-%d") + return parsed_date.strftime("%d.%m.%Y") diff --git a/soitool/main_window.py b/soitool/main_window.py index 4779dc3fec3281c2082deda4e0f50b00ce5bdea7..cdea98bf8acc2356c6ac5e293eace2d966fb425d 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -36,6 +36,14 @@ class ModuleType(Enum): ATTACHMENT_MODULE = 1 +class ExportMedium(Enum): + """Enumerate with mediums possible for export.""" + + COMPRESSED = 0 + UNCOMPRESSED = 1 + PDF = 2 + + class MainWindow(QMainWindow): """MainWindow, shell of the entire application. @@ -133,16 +141,23 @@ class MainWindow(QMainWindow): export_compressed.setShortcut("Ctrl+e") export_compressed.setStatusTip("Eksporter komprimert SOI") export_compressed.triggered.connect( - lambda: self.try_export_soi(compressed=True) + lambda: self.try_export_soi(medium=ExportMedium.COMPRESSED) ) # Uncompressed SOI export_uncompressed = QAction("Ukomprimert", self) export_uncompressed.setStatusTip("Eksporter ukomprimert SOI") export_uncompressed.triggered.connect( - lambda: self.try_export_soi(compressed=False) + lambda: self.try_export_soi(medium=ExportMedium.UNCOMPRESSED) + ) + # SOI PDF + export_pdf = QAction("PDF", self) + export_pdf.setStatusTip("Eksporter til PDF klar for utskrift") + export_pdf.triggered.connect( + lambda: self.try_export_soi(medium=ExportMedium.PDF) ) export_serialized_soi.addAction(export_compressed) export_serialized_soi.addAction(export_uncompressed) + export_serialized_soi.addAction(export_pdf) file_menu.addMenu(export_serialized_soi) # View/edit Codebook @@ -228,7 +243,7 @@ class MainWindow(QMainWindow): self.tabs.removeTab(index) - def try_export_soi(self, compressed=True): + def try_export_soi(self, medium=ExportMedium.COMPRESSED): """Export the SOI in the current tab. Feedback is given through a dialog if the current tab does not contain @@ -236,14 +251,27 @@ class MainWindow(QMainWindow): Parameters ---------- - compressed : bool, optional - Serialized SOI is compressed if True (default) + medium : ExportMedium + Which medium to export SOI to. Must be one of the enums in + ExportMedium. + + Raises + ------ + ValueError + If export medium is unknown. """ tab_widget = self.tabs.currentWidget() # If tab contains an SOI if isinstance(tab_widget, SOIWorkspaceWidget): - export_soi(tab_widget.soi, compressed) + if medium == ExportMedium.COMPRESSED: + export_soi(tab_widget.soi, True) + elif medium == ExportMedium.UNCOMPRESSED: + export_soi(tab_widget.soi, False) + elif medium == ExportMedium.PDF: + tab_widget.view.produce_pdf() + else: + raise ValueError(f"Unknown medium for export '{medium}'") else: exec_info_dialog( "Valgt tab er ingen SOI-tab", diff --git a/soitool/serialize_export_import_soi.py b/soitool/serialize_export_import_soi.py index 62bec1e889b203df60cd333465e55c47b307fa59..3900c67ec274156ef40b34aea9124f3ad1b7ad0e 100644 --- a/soitool/serialize_export_import_soi.py +++ b/soitool/serialize_export_import_soi.py @@ -151,9 +151,6 @@ def export_soi(soi, compressed=True): A .txt-file is created to contain compressed SOI. A .json-file is created to contain uncompressed SOI. - The generated file-name will be on the format: "SOI_title_YYYY_mm_dd", - where title is the SOI-title. - Parameters ---------- soi: soitool.soi.SOI @@ -164,10 +161,7 @@ def export_soi(soi, compressed=True): # Serialize SOI serialized = serialize_soi(soi) - # Generate filename - title = soi.title - date = datetime.now().strftime("%Y_%m_%d") - file_name = f"SOI_{title}_{date}" + file_name = generate_soi_filename(soi) if compressed: serialized = compress(serialized) @@ -319,3 +313,22 @@ def construct_soi_from_serialized(serialized, compressed=False): ) return soi + + +def generate_soi_filename(soi): + """Generate filename for SOI without extension. + + Parameters + ---------- + soi : SOI + SOI to generate filename for. + + Returns + ------- + str + Filename for the SOI of the format 'SOI_title_YYYY_mm_dd'. + """ + title = soi.title + parsed_date = datetime.strptime(soi.date, "%Y-%m-%d") + date_string = parsed_date.strftime("%Y_%m_%d") + return f"SOI_{title}_{date_string}" diff --git a/soitool/soi.py b/soitool/soi.py index 0f8db341bef6aae460b6ab0e115d55c7dfd7ed75..fcb67a2360e13a1ce094bcf7e864079ffd2494ed 100644 --- a/soitool/soi.py +++ b/soitool/soi.py @@ -149,30 +149,46 @@ class SOI: the class variables. ```text + + SOI.WIDTH + | + v + _________________ + SOI.PADDING - | SOI.CONTENT_WIDTH - | | SOI.PADDING - | | | - v v v - +-------------+ - | | <- SOI.PADDING - | +---------+ | - | | HEADER | | <- SOI.HEADER_HEIGHT - | +---------+ | - | | MODULES | | <- SOI.CONTENT_HEIGHT - | +---------+ | - | | <- SOI.PADDING - +-------------+ - <- SOI.PADDING - +-------------+ - | | <- SOI.PADDING - | +---------+ | - | | HEADER | | <- SOI.HEADER_HEIGHT - | +---------+ | - | | MODULES | | <- SOI.CONTENT_HEIGHT - | +---------+ | - | | <- SOI.PADDING - +-------------+ + | SOI.HEADER_WIDTH + | | SOI.CONTENT_WIDTH + | | | SOI.PADDING + | | | | + v v v v + _ ___ _________ _ + + +-----------------+ + | | | <- SOI.PADDING | <- SOI.HEIGHT + | +---+---------+ | | + | | H | MODULES | | | <- SOI.CONTENT_HEIGHT | + | | E | | | | | + | | A | | | | | + | | D | | | | | + | | E | | | | | + | | R | | | | | + | +---+---------+ | | + | | | <- SOI.PADDING | + +-----------------+ + | <- SOI.PADDING + +-----------------+ + | | | <- SOI.PADDING | <- SOI.HEIGHT + | +---+---------+ | | + | | H | MODULES | | | <- SOI.CONTENT_HEIGHT | + | | E | | | | | + | | A | | | | | + | | D | | | | | + | | E | | | | | + | | R | | | | | + | +---+---------+ | | + | | | <- SOI.PADDING | + +-----------------+ + ``` `MODULES` is where all the modules of the page will show up. Each module @@ -188,10 +204,19 @@ class SOI: * `module["meta"]["y"]` is a value between `0` and `SOI.CONTENT_HEIGHT`. This value is determined by the placement strategy. * `module["widget"].pos().x()` is calculated from `module["meta"]["x"]` - as follows: `module["meta"]["x"] + SOI.PADDING` + as follows: + + ```text + module["meta"]["x"] + SOI.PADDING + SOI.HEADER_WIDTH + ``` + * `module["widget"].pos().y()` is calculated from `module["meta"]["y"]` - as follows: `module["meta"]["y"] + SOI.PADDING + SOI.HEADER_HEIGHT + - (SOI.PADDING + SOI.HEIGHT) * (module["meta"]["page"] - 1)` + as follows: + + ```text + module["meta"]["y"] + SOI.PADDING + + (SOI.PADDING + SOI.HEIGHT) * (module["meta"]["page"] - 1) + ``` The function `SOI.update_module_widget_position` is responsible for updating the widget positions based on the "meta" positions, using the @@ -246,10 +271,11 @@ class SOI: HEIGHT = 1700 WIDTH = HEIGHT * A4_RATIO PADDING = 100 - CONTENT_WIDTH = WIDTH - PADDING * 2 - CONTENT_HEIGHT = HEIGHT - PADDING * 2 - HEADER_HEIGHT = 100 + HEADER_WIDTH = 110 + HEADER_HEIGHT = HEIGHT - PADDING * 2 MODULE_PADDING = 10 + CONTENT_WIDTH = WIDTH - PADDING * 2 - HEADER_WIDTH + CONTENT_HEIGHT = HEIGHT - PADDING * 2 @classmethod def construct_from_compressed_soi_file(cls, filename): @@ -389,20 +415,17 @@ class SOI: itself a dict with fields "x", "y" and "page", and "widget" is a widget based on "ModuleBase" """ - distance_to_start_of_next_soi_content_y = ( - self.CONTENT_HEIGHT + self.PADDING * 2 + self.HEADER_HEIGHT - ) + distance_to_start_of_next_soi_content_y = self.HEIGHT + self.PADDING scene_skip_distance_page_height = ( distance_to_start_of_next_soi_content_y * (module["meta"]["page"] - 1) ) - new_x = module["meta"]["x"] + self.PADDING + new_x = module["meta"]["x"] + self.PADDING + self.HEADER_WIDTH new_y = ( module["meta"]["y"] + self.PADDING - + self.HEADER_HEIGHT + scene_skip_distance_page_height ) @@ -509,9 +532,7 @@ class SOI: # See https://github.com/secnot/rectpack/blob/master/README.md#api packer.add_bin( - self.CONTENT_WIDTH, - self.CONTENT_HEIGHT - self.HEADER_HEIGHT, - float("inf"), + self.CONTENT_WIDTH, self.CONTENT_HEIGHT, float("inf"), ) packer.pack() diff --git a/test/test_serialize_export_import.py b/test/test_serialize_export_import.py index c1c2afa1e3d7b85e65ae73da6792352935a64b3d..3a9e20815cf18a62ae65564c8731f17ffc68e838 100644 --- a/test/test_serialize_export_import.py +++ b/test/test_serialize_export_import.py @@ -24,9 +24,9 @@ SOITOOL_ROOT_PATH = Path(__file__).parent.parent TITLE = "testSOI" DESCRIPTION = "This is a description" VERSION = "1" -DATE = "01.01.2020" -VALID_FROM = "01.01.2020" -VALID_TO = "02.01.2020" +DATE = "2020-01-01" +VALID_FROM = "2020-01-01" +VALID_TO = "2020-01-02" ICON = "soitool/media/HVlogo.png" CLASSIFICATION = "UGRADERT" ORIENTATION = "portrait" @@ -74,9 +74,14 @@ class SerializeTest(unittest.TestCase): modules=MODULES, attachments=MODULES, ) - date = datetime.now().strftime("%Y_%m_%d") - file_name_uncompressed = f"SOI_{TITLE}_{date}.json" - file_name_compressed = f"SOI_{TITLE}_{date}.txt" + date = datetime.strptime(DATE, "%Y-%m-%d") + date_formatted_for_file_path = date.strftime("%Y_%m_%d") + file_name_uncompressed = ( + f"SOI_{TITLE}_{date_formatted_for_file_path}.json" + ) + file_name_compressed = ( + f"SOI_{TITLE}_{date_formatted_for_file_path}.txt" + ) self.file_path_uncompressed = os.path.join( SOITOOL_ROOT_PATH, file_name_uncompressed diff --git a/test/test_soi.py b/test/test_soi.py index 54be88112622bad9e302033fc9c3f46e252c912e..4045214d1af11cf35a64c47c32d087716a5dc891 100644 --- a/test/test_soi.py +++ b/test/test_soi.py @@ -174,7 +174,7 @@ class TestSOI(unittest.TestCase): def test_reorganize_of_too_big_module(self): """Test exception thrown by reorganize in case of too large modules.""" maximum_width = self.soi.CONTENT_WIDTH - maximum_height = self.soi.CONTENT_HEIGHT - self.soi.HEADER_HEIGHT + maximum_height = self.soi.CONTENT_HEIGHT module_too_wide = { "widget": TestModule("red", maximum_width + 1, 100), @@ -230,7 +230,7 @@ class TestSOI(unittest.TestCase): """ # Append module of maximum size maximum_width = self.soi.CONTENT_WIDTH - maximum_height = self.soi.CONTENT_HEIGHT - self.soi.HEADER_HEIGHT + maximum_height = self.soi.CONTENT_HEIGHT module_maximum_size = { "widget": TestModule("red", maximum_width, maximum_height), "meta": {"x": 0, "y": 0, "page": 1, "name": "maximum_size"}, @@ -284,13 +284,14 @@ class TestSOI(unittest.TestCase): self.soi.update_module_widget_position(self.soi.modules[0]) # Calculate expected widget positions. See SOI class docstring for an - # explanation of the calculation neededfor y - expected_x = page_2_module["meta"]["x"] + self.soi.PADDING + # explanation of the calculation needed for y + expected_x = ( + page_2_module["meta"]["x"] + + self.soi.PADDING + + self.soi.HEADER_WIDTH + ) expected_y = ( - page_2_module["meta"]["y"] - + self.soi.PADDING * 2 - + self.soi.HEIGHT - + self.soi.HEADER_HEIGHT + page_2_module["meta"]["y"] + self.soi.PADDING * 2 + self.soi.HEIGHT ) self.assertEqual(