1""" 2Copyright (c) 2017 Wolthera van Hövell tot Westerflier <griffinvalley@gmail.com> 3 4This file is part of the Comics Project Management Tools(CPMT). 5 6CPMT is free software: you can redistribute it and/or modify 7it under the terms of the GNU General Public License as published by 8the Free Software Foundation, either version 3 of the License, or 9(at your option) any later version. 10 11CPMT is distributed in the hope that it will be useful, 12but WITHOUT ANY WARRANTY; without even the implied warranty of 13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14GNU General Public License for more details. 15 16You should have received a copy of the GNU General Public License 17along with the CPMT. If not, see <http://www.gnu.org/licenses/>. 18""" 19 20""" 21This is a docker that helps you organise your comics project. 22""" 23import sys 24import json 25import os 26import zipfile # quick reading of documents 27import shutil 28import enum 29from math import floor 30import xml.etree.ElementTree as ET 31from PyQt5.QtCore import QElapsedTimer, QSize, Qt, QRect, QFileSystemWatcher, QTimer 32from PyQt5.QtGui import QStandardItem, QStandardItemModel, QImage, QIcon, QPixmap, QFontMetrics, QPainter, QPalette, QFont 33from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QListView, QToolButton, QMenu, QAction, QPushButton, QSpacerItem, QSizePolicy, QWidget, QAbstractItemView, QProgressDialog, QDialog, QFileDialog, QDialogButtonBox, qApp, QSplitter, QSlider, QLabel, QStyledItemDelegate, QStyle, QMessageBox 34import math 35from krita import * 36from . import comics_metadata_dialog, comics_exporter, comics_export_dialog, comics_project_setup_wizard, comics_template_dialog, comics_project_settings_dialog, comics_project_page_viewer, comics_project_translation_scraper 37 38""" 39A very simple class so we can have a label that is single line, but doesn't force the 40widget size to be bigger. 41This is used by the project name. 42""" 43 44 45class Elided_Text_Label(QLabel): 46 mainText = str() 47 48 def __init__(self, parent=None): 49 super(QLabel, self).__init__(parent) 50 self.setMinimumWidth(self.fontMetrics().width("...")) 51 self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) 52 53 def setMainText(self, text=str()): 54 self.mainText = text 55 self.elideText() 56 57 def elideText(self): 58 self.setText(self.fontMetrics().elidedText(self.mainText, Qt.ElideRight, self.width())) 59 60 def resizeEvent(self, event): 61 self.elideText() 62 63class CPE(enum.IntEnum): 64 TITLE = Qt.DisplayRole 65 URL = Qt.UserRole + 1 66 KEYWORDS = Qt.UserRole+2 67 DESCRIPTION = Qt.UserRole+3 68 LASTEDIT = Qt.UserRole+4 69 EDITOR = Qt.UserRole+5 70 IMAGE = Qt.DecorationRole 71 72class comic_page_delegate(QStyledItemDelegate): 73 74 def __init__(self, devicePixelRatioF, parent=None): 75 super(QStyledItemDelegate, self).__init__(parent) 76 self.devicePixelRatioF = devicePixelRatioF 77 78 def paint(self, painter, option, index): 79 80 if (index.isValid() == False): 81 return 82 painter.save() 83 painter.setOpacity(0.6) 84 if(option.state & QStyle.State_Selected): 85 painter.fillRect(option.rect, option.palette.highlight()) 86 if (option.state & QStyle.State_MouseOver): 87 painter.setOpacity(0.25) 88 painter.fillRect(option.rect, option.palette.highlight()) 89 painter.setOpacity(1.0) 90 painter.setFont(option.font) 91 metrics = QFontMetrics(option.font) 92 regular = QFont(option.font) 93 italics = QFont(option.font) 94 italics.setItalic(True) 95 icon = QIcon(index.data(CPE.IMAGE)) 96 rect = option.rect 97 margin = 4 98 decoratonSize = QSize(option.decorationSize) 99 imageSize = icon.actualSize(option.decorationSize) 100 imageSizeHighDPI = imageSize*self.devicePixelRatioF 101 leftSideThumbnail = (decoratonSize.width()-imageSize.width())/2 102 if (rect.width() < decoratonSize.width()): 103 leftSideThumbnail = max(0, (rect.width()-imageSize.width())/2) 104 topSizeThumbnail = ((rect.height()-imageSize.height())/2)+rect.top() 105 thumbImage = icon.pixmap(imageSizeHighDPI).toImage() 106 thumbImage.setDevicePixelRatio(self.devicePixelRatioF) 107 painter.drawImage(QRect(leftSideThumbnail, topSizeThumbnail, imageSize.width(), imageSize.height()), thumbImage) 108 109 labelWidth = rect.width()-decoratonSize.width()-(margin*3) 110 111 if (decoratonSize.width()+(margin*2)< rect.width()): 112 113 textRect = QRect(decoratonSize.width()+margin, margin+rect.top(), labelWidth, metrics.height()) 114 textTitle = metrics.elidedText(str(index.row()+1)+". "+index.data(CPE.TITLE), Qt.ElideRight, labelWidth) 115 painter.drawText(textRect, Qt.TextWordWrap, textTitle) 116 117 if rect.height()/(metrics.lineSpacing()+margin) > 5 or index.data(CPE.KEYWORDS) is not None: 118 painter.setOpacity(0.6) 119 textRect = QRect(textRect.left(), textRect.bottom()+margin, labelWidth, metrics.height()) 120 if textRect.bottom() < rect.bottom(): 121 textKeyWords = index.data(CPE.KEYWORDS) 122 if textKeyWords == None: 123 textKeyWords = i18n("No keywords") 124 painter.setOpacity(0.3) 125 painter.setFont(italics) 126 textKeyWords = metrics.elidedText(textKeyWords, Qt.ElideRight, labelWidth) 127 painter.drawText(textRect, Qt.TextWordWrap, textKeyWords) 128 129 painter.setFont(regular) 130 131 if rect.height()/(metrics.lineSpacing()+margin) > 3: 132 painter.setOpacity(0.6) 133 textRect = QRect(textRect.left(), textRect.bottom()+margin, labelWidth, metrics.height()) 134 if textRect.bottom()+metrics.height() < rect.bottom(): 135 textLastEdit = index.data(CPE.LASTEDIT) 136 if textLastEdit is None: 137 textLastEdit = i18n("No last edit timestamp") 138 if index.data(CPE.EDITOR) is not None: 139 textLastEdit += " - " + index.data(CPE.EDITOR) 140 if (index.data(CPE.LASTEDIT) is None) and (index.data(CPE.EDITOR) is None): 141 painter.setOpacity(0.3) 142 painter.setFont(italics) 143 textLastEdit = metrics.elidedText(textLastEdit, Qt.ElideRight, labelWidth) 144 painter.drawText(textRect, Qt.TextWordWrap, textLastEdit) 145 146 painter.setFont(regular) 147 148 descRect = QRect(textRect.left(), textRect.bottom()+margin, labelWidth, (rect.bottom()-margin) - (textRect.bottom()+margin)) 149 if textRect.bottom()+metrics.height() < rect.bottom(): 150 textRect.setBottom(textRect.bottom()+(margin/2)) 151 textRect.setLeft(textRect.left()-(margin/2)) 152 painter.setOpacity(0.4) 153 painter.drawLine(textRect.bottomLeft(), textRect.bottomRight()) 154 painter.setOpacity(1.0) 155 textDescription = index.data(CPE.DESCRIPTION) 156 if textDescription is None: 157 textDescription = i18n("No description") 158 painter.setOpacity(0.3) 159 painter.setFont(italics) 160 linesTotal = floor(descRect.height()/metrics.lineSpacing()) 161 if linesTotal == 1: 162 textDescription = metrics.elidedText(textDescription, Qt.ElideRight, labelWidth) 163 painter.drawText(descRect, Qt.TextWordWrap, textDescription) 164 else: 165 descRect.setHeight(linesTotal*metrics.lineSpacing()) 166 totalDescHeight = metrics.boundingRect(descRect, Qt.TextWordWrap, textDescription).height() 167 if totalDescHeight>descRect.height(): 168 if totalDescHeight-metrics.lineSpacing()>descRect.height(): 169 painter.setOpacity(0.5) 170 painter.drawText(descRect, Qt.TextWordWrap, textDescription) 171 descRect.setHeight((linesTotal-1)*metrics.lineSpacing()) 172 painter.drawText(descRect, Qt.TextWordWrap, textDescription) 173 descRect.setHeight((linesTotal-2)*metrics.lineSpacing()) 174 painter.drawText(descRect, Qt.TextWordWrap, textDescription) 175 else: 176 painter.setOpacity(0.75) 177 painter.drawText(descRect, Qt.TextWordWrap, textDescription) 178 descRect.setHeight((linesTotal-1)*metrics.lineSpacing()) 179 painter.drawText(descRect, Qt.TextWordWrap, textDescription) 180 else: 181 painter.drawText(descRect, Qt.TextWordWrap, textDescription) 182 183 painter.setFont(regular) 184 185 painter.restore() 186 187 188""" 189This is a Krita docker called 'Comics Manager'. 190 191It allows people to create comics project files, load those files, add pages, remove pages, move pages, manage the metadata, 192and finally export the result. 193 194The logic behind this docker is that it is very easy to get lost in a comics project due to the massive amount of files. 195By having a docker that gives the user quick access to the pages and also allows them to do all of the meta-stuff, like 196meta data, but also reordering the pages, the chaos of managing the project should take up less time, and more time can be focused on actual writing and drawing. 197""" 198 199 200class comics_project_manager_docker(DockWidget): 201 setupDictionary = {} 202 stringName = i18n("Comics Manager") 203 projecturl = None 204 pagesWatcher = None 205 updateurls = [] 206 207 def __init__(self): 208 super().__init__() 209 self.setWindowTitle(self.stringName) 210 211 # Setup layout: 212 base = QHBoxLayout() 213 widget = QWidget() 214 widget.setLayout(base) 215 baseLayout = QSplitter() 216 base.addWidget(baseLayout) 217 self.setWidget(widget) 218 buttonLayout = QVBoxLayout() 219 buttonBox = QWidget() 220 buttonBox.setLayout(buttonLayout) 221 baseLayout.addWidget(buttonBox) 222 223 # Comic page list and pages model 224 self.comicPageList = QListView() 225 self.comicPageList.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 226 self.comicPageList.setDragEnabled(True) 227 self.comicPageList.setDragDropMode(QAbstractItemView.InternalMove) 228 self.comicPageList.setDefaultDropAction(Qt.MoveAction) 229 self.comicPageList.setAcceptDrops(True) 230 self.comicPageList.setItemDelegate(comic_page_delegate(self.devicePixelRatioF())) 231 self.pagesModel = QStandardItemModel() 232 self.comicPageList.doubleClicked.connect(self.slot_open_page) 233 self.comicPageList.setIconSize(QSize(128, 128)) 234 # self.comicPageList.itemDelegate().closeEditor.connect(self.slot_write_description) 235 self.pagesModel.layoutChanged.connect(self.slot_write_config) 236 self.pagesModel.rowsInserted.connect(self.slot_write_config) 237 self.pagesModel.rowsRemoved.connect(self.slot_write_config) 238 self.pagesModel.rowsMoved.connect(self.slot_write_config) 239 self.comicPageList.setModel(self.pagesModel) 240 pageBox = QWidget() 241 pageBox.setLayout(QVBoxLayout()) 242 zoomSlider = QSlider(Qt.Horizontal, None) 243 zoomSlider.setRange(1, 8) 244 zoomSlider.setValue(4) 245 zoomSlider.setTickInterval(1) 246 zoomSlider.setMinimumWidth(10) 247 zoomSlider.valueChanged.connect(self.slot_scale_thumbnails) 248 self.projectName = Elided_Text_Label() 249 pageBox.layout().addWidget(self.projectName) 250 pageBox.layout().addWidget(zoomSlider) 251 pageBox.layout().addWidget(self.comicPageList) 252 baseLayout.addWidget(pageBox) 253 254 self.btn_project = QToolButton() 255 self.btn_project.setPopupMode(QToolButton.MenuButtonPopup) 256 self.btn_project.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) 257 menu_project = QMenu() 258 self.action_new_project = QAction(i18n("New Project"), self) 259 self.action_new_project.triggered.connect(self.slot_new_project) 260 self.action_load_project = QAction(i18n("Open Project"), self) 261 self.action_load_project.triggered.connect(self.slot_open_config) 262 menu_project.addAction(self.action_new_project) 263 menu_project.addAction(self.action_load_project) 264 self.btn_project.setMenu(menu_project) 265 self.btn_project.setDefaultAction(self.action_load_project) 266 buttonLayout.addWidget(self.btn_project) 267 268 # Settings dropdown with actions for the different settings menus. 269 self.btn_settings = QToolButton() 270 self.btn_settings.setPopupMode(QToolButton.MenuButtonPopup) 271 self.btn_settings.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) 272 self.action_edit_project_settings = QAction(i18n("Project Settings"), self) 273 self.action_edit_project_settings.triggered.connect(self.slot_edit_project_settings) 274 self.action_edit_meta_data = QAction(i18n("Meta Data"), self) 275 self.action_edit_meta_data.triggered.connect(self.slot_edit_meta_data) 276 self.action_edit_export_settings = QAction(i18n("Export Settings"), self) 277 self.action_edit_export_settings.triggered.connect(self.slot_edit_export_settings) 278 menu_settings = QMenu() 279 menu_settings.addAction(self.action_edit_project_settings) 280 menu_settings.addAction(self.action_edit_meta_data) 281 menu_settings.addAction(self.action_edit_export_settings) 282 self.btn_settings.setDefaultAction(self.action_edit_project_settings) 283 self.btn_settings.setMenu(menu_settings) 284 buttonLayout.addWidget(self.btn_settings) 285 self.btn_settings.setDisabled(True) 286 287 # Add page drop down with different page actions. 288 self.btn_add_page = QToolButton() 289 self.btn_add_page.setPopupMode(QToolButton.MenuButtonPopup) 290 self.btn_add_page.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) 291 292 self.action_add_page = QAction(i18n("Add Page"), self) 293 self.action_add_page.triggered.connect(self.slot_add_new_page_single) 294 self.action_add_template = QAction(i18n("Add Page from Template"), self) 295 self.action_add_template.triggered.connect(self.slot_add_new_page_from_template) 296 self.action_add_existing = QAction(i18n("Add Existing Pages"), self) 297 self.action_add_existing.triggered.connect(self.slot_add_page_from_url) 298 self.action_remove_selected_page = QAction(i18n("Remove Page"), self) 299 self.action_remove_selected_page.triggered.connect(self.slot_remove_selected_page) 300 self.action_resize_all_pages = QAction(i18n("Batch Resize"), self) 301 self.action_resize_all_pages.triggered.connect(self.slot_batch_resize) 302 self.btn_add_page.setDefaultAction(self.action_add_page) 303 self.action_show_page_viewer = QAction(i18n("View Page In Window"), self) 304 self.action_show_page_viewer.triggered.connect(self.slot_show_page_viewer) 305 self.action_scrape_authors = QAction(i18n("Scrape Author Info"), self) 306 self.action_scrape_authors.setToolTip(i18n("Search for author information in documents and add it to the author list. This does not check for duplicates.")) 307 self.action_scrape_authors.triggered.connect(self.slot_scrape_author_list) 308 self.action_scrape_translations = QAction(i18n("Scrape Text for Translation"), self) 309 self.action_scrape_translations.triggered.connect(self.slot_scrape_translations) 310 actionList = [] 311 menu_page = QMenu() 312 actionList.append(self.action_add_page) 313 actionList.append(self.action_add_template) 314 actionList.append(self.action_add_existing) 315 actionList.append(self.action_remove_selected_page) 316 actionList.append(self.action_resize_all_pages) 317 actionList.append(self.action_show_page_viewer) 318 actionList.append(self.action_scrape_authors) 319 actionList.append(self.action_scrape_translations) 320 menu_page.addActions(actionList) 321 self.btn_add_page.setMenu(menu_page) 322 buttonLayout.addWidget(self.btn_add_page) 323 self.btn_add_page.setDisabled(True) 324 325 self.comicPageList.setContextMenuPolicy(Qt.ActionsContextMenu) 326 self.comicPageList.addActions(actionList) 327 328 # Export button that... exports. 329 self.btn_export = QPushButton(i18n("Export Comic")) 330 self.btn_export.clicked.connect(self.slot_export) 331 buttonLayout.addWidget(self.btn_export) 332 self.btn_export.setDisabled(True) 333 334 self.btn_project_url = QPushButton(i18n("Copy Location")) 335 self.btn_project_url.setToolTip(i18n("Copies the path of the project to the clipboard. Useful for quickly copying to a file manager or the like.")) 336 self.btn_project_url.clicked.connect(self.slot_copy_project_url) 337 self.btn_project_url.setDisabled(True) 338 buttonLayout.addWidget(self.btn_project_url) 339 340 self.page_viewer_dialog = comics_project_page_viewer.comics_project_page_viewer() 341 342 self.pagesWatcher = QFileSystemWatcher() 343 self.pagesWatcher.fileChanged.connect(self.slot_start_delayed_check_page_update) 344 345 buttonLayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.MinimumExpanding)) 346 347 """ 348 Open the config file and load the json file into a dictionary. 349 """ 350 351 def slot_open_config(self): 352 self.path_to_config = QFileDialog.getOpenFileName(caption=i18n("Please select the JSON comic config file."), filter=str(i18n("JSON files") + "(*.json)"))[0] 353 if os.path.exists(self.path_to_config) is True: 354 if os.access(self.path_to_config, os.W_OK) is False: 355 QMessageBox.warning(None, i18n("Config cannot be used"), i18n("Krita doesn't have write access to this folder, so new files cannot be made. Please configure the folder access or move the project to a folder that can be written to."), QMessageBox.Ok) 356 return 357 configFile = open(self.path_to_config, "r", newline="", encoding="utf-16") 358 self.setupDictionary = json.load(configFile) 359 self.projecturl = os.path.dirname(str(self.path_to_config)) 360 configFile.close() 361 self.load_config() 362 """ 363 Further config loading. 364 """ 365 366 def load_config(self): 367 self.projectName.setMainText(text=str(self.setupDictionary["projectName"])) 368 self.fill_pages() 369 self.btn_settings.setEnabled(True) 370 self.btn_add_page.setEnabled(True) 371 self.btn_export.setEnabled(True) 372 self.btn_project_url.setEnabled(True) 373 374 """ 375 Fill the pages model with the pages from the pages list. 376 """ 377 378 def fill_pages(self): 379 self.loadingPages = True 380 self.pagesModel.clear() 381 if len(self.pagesWatcher.files())>0: 382 self.pagesWatcher.removePaths(self.pagesWatcher.files()) 383 pagesList = [] 384 if "pages" in self.setupDictionary.keys(): 385 pagesList = self.setupDictionary["pages"] 386 progress = QProgressDialog() 387 progress.setMinimum(0) 388 progress.setMaximum(len(pagesList)) 389 progress.setWindowTitle(i18n("Loading Pages...")) 390 for url in pagesList: 391 absurl = os.path.join(self.projecturl, url) 392 relative = os.path.relpath(absurl, self.projecturl) 393 if (os.path.exists(absurl)): 394 #page = Application.openDocument(absurl) 395 page = zipfile.ZipFile(absurl, "r") 396 thumbnail = QImage.fromData(page.read("mergedimage.png")) 397 if thumbnail.isNull(): 398 thumbnail = QImage.fromData(page.read("preview.png")) 399 thumbnail.setDevicePixelRatio(self.devicePixelRatioF()) 400 pageItem = QStandardItem() 401 dataList = self.get_description_and_title(page.read("documentinfo.xml")) 402 if (dataList[0].isspace() or len(dataList[0]) < 1): 403 dataList[0] = os.path.basename(url) 404 pageItem.setText(dataList[0].replace("_", " ")) 405 pageItem.setDragEnabled(True) 406 pageItem.setDropEnabled(False) 407 pageItem.setEditable(False) 408 pageItem.setIcon(QIcon(QPixmap.fromImage(thumbnail))) 409 pageItem.setData(dataList[1], role = CPE.DESCRIPTION) 410 pageItem.setData(relative, role = CPE.URL) 411 self.pagesWatcher.addPath(absurl) 412 pageItem.setData(dataList[2], role = CPE.KEYWORDS) 413 pageItem.setData(dataList[3], role = CPE.LASTEDIT) 414 pageItem.setData(dataList[4], role = CPE.EDITOR) 415 pageItem.setToolTip(relative) 416 page.close() 417 self.pagesModel.appendRow(pageItem) 418 progress.setValue(progress.value() + 1) 419 progress.setValue(len(pagesList)) 420 self.loadingPages = False 421 """ 422 Function that is triggered by the zoomSlider 423 Resizes the thumbnails. 424 """ 425 426 def slot_scale_thumbnails(self, multiplier=4): 427 self.comicPageList.setIconSize(QSize(multiplier * 32, multiplier * 32)) 428 429 """ 430 Function that takes the documentinfo.xml and parses it for the title, subject and abstract tags, 431 to get the title and description. 432 433 @returns a stringlist with the name on 0 and the description on 1. 434 """ 435 436 def get_description_and_title(self, string): 437 xmlDoc = ET.fromstring(string) 438 calligra = str("{http://www.calligra.org/DTD/document-info}") 439 name = "" 440 if ET.iselement(xmlDoc[0].find(calligra + 'title')): 441 name = xmlDoc[0].find(calligra + 'title').text 442 if name is None: 443 name = " " 444 desc = "" 445 if ET.iselement(xmlDoc[0].find(calligra + 'subject')): 446 desc = xmlDoc[0].find(calligra + 'subject').text 447 if desc is None or desc.isspace() or len(desc) < 1: 448 if ET.iselement(xmlDoc[0].find(calligra + 'abstract')): 449 desc = xmlDoc[0].find(calligra + 'abstract').text 450 if desc is not None: 451 if desc.startswith("<![CDATA["): 452 desc = desc[len("<![CDATA["):] 453 if desc.startswith("]]>"): 454 desc = desc[:-len("]]>")] 455 keywords = "" 456 if ET.iselement(xmlDoc[0].find(calligra + 'keyword')): 457 keywords = xmlDoc[0].find(calligra + 'keyword').text 458 date = "" 459 if ET.iselement(xmlDoc[0].find(calligra + 'date')): 460 date = xmlDoc[0].find(calligra + 'date').text 461 author = [] 462 if ET.iselement(xmlDoc[1].find(calligra + 'creator-first-name')): 463 string = xmlDoc[1].find(calligra + 'creator-first-name').text 464 if string is not None: 465 author.append(string) 466 if ET.iselement(xmlDoc[1].find(calligra + 'creator-last-name')): 467 string = xmlDoc[1].find(calligra + 'creator-last-name').text 468 if string is not None: 469 author.append(string) 470 if ET.iselement(xmlDoc[1].find(calligra + 'full-name')): 471 string = xmlDoc[1].find(calligra + 'full-name').text 472 if string is not None: 473 author.append(string) 474 475 return [name, desc, keywords, date, " ".join(author)] 476 477 """ 478 Scrapes authors from the author data in the document info and puts them into the author list. 479 Doesn't check for duplicates. 480 """ 481 482 def slot_scrape_author_list(self): 483 listOfAuthors = [] 484 if "authorList" in self.setupDictionary.keys(): 485 listOfAuthors = self.setupDictionary["authorList"] 486 if "pages" in self.setupDictionary.keys(): 487 for relurl in self.setupDictionary["pages"]: 488 absurl = os.path.join(self.projecturl, relurl) 489 page = zipfile.ZipFile(absurl, "r") 490 xmlDoc = ET.fromstring(page.read("documentinfo.xml")) 491 calligra = str("{http://www.calligra.org/DTD/document-info}") 492 authorelem = xmlDoc.find(calligra + 'author') 493 author = {} 494 if ET.iselement(authorelem.find(calligra + 'full-name')): 495 author["nickname"] = str(authorelem.find(calligra + 'full-name').text) 496 497 if ET.iselement(authorelem.find(calligra + 'creator-first-name')): 498 author["first-name"] = str(authorelem.find(calligra + 'creator-first-name').text) 499 500 if ET.iselement(authorelem.find(calligra + 'initial')): 501 author["initials"] = str(authorelem.find(calligra + 'initial').text) 502 503 if ET.iselement(authorelem.find(calligra + 'creator-last-name')): 504 author["last-name"] = str(authorelem.find(calligra + 'creator-last-name').text) 505 506 if ET.iselement(authorelem.find(calligra + 'email')): 507 author["email"] = str(authorelem.find(calligra + 'email').text) 508 509 if ET.iselement(authorelem.find(calligra + 'contact')): 510 contact = authorelem.find(calligra + 'contact') 511 contactMode = contact.get("type") 512 if contactMode == "email": 513 author["email"] = str(contact.text) 514 if contactMode == "homepage": 515 author["homepage"] = str(contact.text) 516 517 if ET.iselement(authorelem.find(calligra + 'position')): 518 author["role"] = str(authorelem.find(calligra + 'position').text) 519 listOfAuthors.append(author) 520 page.close() 521 self.setupDictionary["authorList"] = listOfAuthors 522 523 """ 524 Edit the general project settings like the project name, concept, pages location, export location, template location, metadata 525 """ 526 527 def slot_edit_project_settings(self): 528 dialog = comics_project_settings_dialog.comics_project_details_editor(self.projecturl) 529 dialog.setConfig(self.setupDictionary, self.projecturl) 530 531 if dialog.exec_() == QDialog.Accepted: 532 self.setupDictionary = dialog.getConfig(self.setupDictionary) 533 self.slot_write_config() 534 self.projectName.setMainText(str(self.setupDictionary["projectName"])) 535 536 """ 537 This allows users to select existing pages and add them to the pages list. The pages are currently not copied to the pages folder. Useful for existing projects. 538 """ 539 540 def slot_add_page_from_url(self): 541 # get the pages. 542 urlList = QFileDialog.getOpenFileNames(caption=i18n("Which existing pages to add?"), directory=self.projecturl, filter=str(i18n("Krita files") + "(*.kra)"))[0] 543 544 # get the existing pages list. 545 pagesList = [] 546 if "pages" in self.setupDictionary.keys(): 547 pagesList = self.setupDictionary["pages"] 548 549 # And add each url in the url list to the pages list and the model. 550 for url in urlList: 551 if self.projecturl not in urlList: 552 newUrl = os.path.join(self.projecturl, self.setupDictionary["pagesLocation"], os.path.basename(url)) 553 shutil.move(url, newUrl) 554 url = newUrl 555 relative = os.path.relpath(url, self.projecturl) 556 if url not in pagesList: 557 page = zipfile.ZipFile(url, "r") 558 thumbnail = QImage.fromData(page.read("preview.png")) 559 dataList = self.get_description_and_title(page.read("documentinfo.xml")) 560 if (dataList[0].isspace() or len(dataList[0]) < 1): 561 dataList[0] = os.path.basename(url) 562 newPageItem = QStandardItem() 563 newPageItem.setIcon(QIcon(QPixmap.fromImage(thumbnail))) 564 newPageItem.setDragEnabled(True) 565 newPageItem.setDropEnabled(False) 566 newPageItem.setEditable(False) 567 newPageItem.setText(dataList[0].replace("_", " ")) 568 newPageItem.setData(dataList[1], role = CPE.DESCRIPTION) 569 newPageItem.setData(relative, role = CPE.URL) 570 self.pagesWatcher.addPath(url) 571 newPageItem.setData(dataList[2], role = CPE.KEYWORDS) 572 newPageItem.setData(dataList[3], role = CPE.LASTEDIT) 573 newPageItem.setData(dataList[4], role = CPE.EDITOR) 574 newPageItem.setToolTip(relative) 575 page.close() 576 self.pagesModel.appendRow(newPageItem) 577 578 """ 579 Remove the selected page from the list of pages. This does not remove it from disk(far too dangerous). 580 """ 581 582 def slot_remove_selected_page(self): 583 index = self.comicPageList.currentIndex() 584 self.pagesModel.removeRow(index.row()) 585 586 """ 587 This function adds a new page from the default template. If there's no default template, or the file does not exist, it will 588 show the create/import template dialog. It will remember the selected item as the default template. 589 """ 590 591 def slot_add_new_page_single(self): 592 templateUrl = "templatepage" 593 templateExists = False 594 595 if "singlePageTemplate" in self.setupDictionary.keys(): 596 templateUrl = self.setupDictionary["singlePageTemplate"] 597 if os.path.exists(os.path.join(self.projecturl, templateUrl)): 598 templateExists = True 599 600 if templateExists is False: 601 if "templateLocation" not in self.setupDictionary.keys(): 602 self.setupDictionary["templateLocation"] = os.path.relpath(QFileDialog.getExistingDirectory(caption=i18n("Where are the templates located?"), options=QFileDialog.ShowDirsOnly), self.projecturl) 603 604 templateDir = os.path.join(self.projecturl, self.setupDictionary["templateLocation"]) 605 template = comics_template_dialog.comics_template_dialog(templateDir) 606 607 if template.exec_() == QDialog.Accepted: 608 templateUrl = os.path.relpath(template.url(), self.projecturl) 609 self.setupDictionary["singlePageTemplate"] = templateUrl 610 if os.path.exists(os.path.join(self.projecturl, templateUrl)): 611 self.add_new_page(templateUrl) 612 613 """ 614 This function always asks for a template showing the new template window. This allows users to have multiple different 615 templates created for back covers, spreads, other and have them accessible, while still having the convenience of a singular 616 "add page" that adds a default. 617 """ 618 619 def slot_add_new_page_from_template(self): 620 if "templateLocation" not in self.setupDictionary.keys(): 621 self.setupDictionary["templateLocation"] = os.path.relpath(QFileDialog.getExistingDirectory(caption=i18n("Where are the templates located?"), options=QFileDialog.ShowDirsOnly), self.projecturl) 622 623 templateDir = os.path.join(self.projecturl, self.setupDictionary["templateLocation"]) 624 template = comics_template_dialog.comics_template_dialog(templateDir) 625 626 if template.exec_() == QDialog.Accepted: 627 templateUrl = os.path.relpath(template.url(), self.projecturl) 628 self.add_new_page(templateUrl) 629 630 """ 631 This is the actual function that adds the template using the template url. 632 It will attempt to name the new page projectName+number. 633 """ 634 635 def add_new_page(self, templateUrl): 636 637 # check for page list and or location. 638 pagesList = [] 639 if "pages" in self.setupDictionary.keys(): 640 pagesList = self.setupDictionary["pages"] 641 if not "pageNumber" in self.setupDictionary.keys(): 642 self.setupDictionary['pageNumber'] = 0 643 644 if (str(self.setupDictionary["pagesLocation"]).isspace()): 645 self.setupDictionary["pagesLocation"] = os.path.relpath(QFileDialog.getExistingDirectory(caption=i18n("Where should the pages go?"), options=QFileDialog.ShowDirsOnly), self.projecturl) 646 647 # Search for the possible name. 648 extraUnderscore = str() 649 if str(self.setupDictionary["projectName"])[-1].isdigit(): 650 extraUnderscore = "_" 651 self.setupDictionary['pageNumber'] += 1 652 pageName = str(self.setupDictionary["projectName"]).replace(" ", "_") + extraUnderscore + str(format(self.setupDictionary['pageNumber'], "03d")) 653 url = os.path.join(str(self.setupDictionary["pagesLocation"]), pageName + ".kra") 654 655 # open the page by opening the template and resaving it, or just opening it. 656 absoluteUrl = os.path.join(self.projecturl, url) 657 if (os.path.exists(absoluteUrl)): 658 newPage = Application.openDocument(absoluteUrl) 659 else: 660 booltemplateExists = os.path.exists(os.path.join(self.projecturl, templateUrl)) 661 if booltemplateExists is False: 662 templateUrl = os.path.relpath(QFileDialog.getOpenFileName(caption=i18n("Which image should be the basis the new page?"), directory=self.projecturl, filter=str(i18n("Krita files") + "(*.kra)"))[0], self.projecturl) 663 newPage = Application.openDocument(os.path.join(self.projecturl, templateUrl)) 664 newPage.waitForDone() 665 newPage.setFileName(absoluteUrl) 666 newPage.setName(pageName.replace("_", " ")) 667 newPage.save() 668 newPage.waitForDone() 669 670 # Get out the extra data for the standard item. 671 newPageItem = QStandardItem() 672 newPageItem.setIcon(QIcon(QPixmap.fromImage(newPage.thumbnail(256, 256)))) 673 newPageItem.setDragEnabled(True) 674 newPageItem.setDropEnabled(False) 675 newPageItem.setEditable(False) 676 newPageItem.setText(pageName.replace("_", " ")) 677 newPageItem.setData("", role = CPE.DESCRIPTION) 678 newPageItem.setData(url, role = CPE.URL) 679 newPageItem.setData("", role = CPE.KEYWORDS) 680 newPageItem.setData("", role = CPE.LASTEDIT) 681 newPageItem.setData("", role = CPE.EDITOR) 682 newPageItem.setToolTip(url) 683 684 # close page document. 685 while os.path.exists(absoluteUrl) is False: 686 qApp.processEvents() 687 688 self.pagesWatcher.addPath(absoluteUrl) 689 newPage.close() 690 691 # add item to page. 692 self.pagesModel.appendRow(newPageItem) 693 694 """ 695 Write to the json configuration file. 696 This also checks the current state of the pages list. 697 """ 698 699 def slot_write_config(self): 700 701 # Don't load when the pages are still being loaded, otherwise we'll be overwriting our own pages list. 702 if (self.loadingPages is False): 703 print("CPMT: writing comic configuration...") 704 705 # Generate a pages list from the pagesmodel. 706 pagesList = [] 707 for i in range(self.pagesModel.rowCount()): 708 index = self.pagesModel.index(i, 0) 709 url = str(self.pagesModel.data(index, role=CPE.URL)) 710 if url not in pagesList: 711 pagesList.append(url) 712 self.setupDictionary["pages"] = pagesList 713 714 # Save to our json file. 715 configFile = open(self.path_to_config, "w", newline="", encoding="utf-16") 716 json.dump(self.setupDictionary, configFile, indent=4, sort_keys=True, ensure_ascii=False) 717 configFile.close() 718 print("CPMT: done") 719 720 """ 721 Open a page in the pagesmodel in Krita. 722 """ 723 724 def slot_open_page(self, index): 725 if index.column() == 0: 726 # Get the absolute url from the relative one in the pages model. 727 absoluteUrl = os.path.join(self.projecturl, str(self.pagesModel.data(index, role=CPE.URL))) 728 729 # Make sure the page exists. 730 if os.path.exists(absoluteUrl): 731 page = Application.openDocument(absoluteUrl) 732 733 # Set the title to the filename if it was empty. It looks a bit neater. 734 if page.name().isspace or len(page.name()) < 1: 735 page.setName(str(self.pagesModel.data(index, role=Qt.DisplayRole)).replace("_", " ")) 736 737 # Add views for the document so the user can use it. 738 Application.activeWindow().addView(page) 739 Application.setActiveDocument(page) 740 else: 741 print("CPMT: The page cannot be opened because the file doesn't exist:", absoluteUrl) 742 743 """ 744 Call up the metadata editor dialog. Only when the dialog is "Accepted" will the metadata be saved. 745 """ 746 747 def slot_edit_meta_data(self): 748 dialog = comics_metadata_dialog.comic_meta_data_editor() 749 750 dialog.setConfig(self.setupDictionary) 751 if (dialog.exec_() == QDialog.Accepted): 752 self.setupDictionary = dialog.getConfig(self.setupDictionary) 753 self.slot_write_config() 754 755 """ 756 An attempt at making the description editable from the comic pages list. 757 It is currently not working because ZipFile has no overwrite mechanism, 758 and I don't have the energy to write one yet. 759 """ 760 761 def slot_write_description(self, index): 762 763 for row in range(self.pagesModel.rowCount()): 764 index = self.pagesModel.index(row, 1) 765 indexUrl = self.pagesModel.index(row, 0) 766 absoluteUrl = os.path.join(self.projecturl, str(self.pagesModel.data(indexUrl, role=CPE.URL))) 767 page = zipfile.ZipFile(absoluteUrl, "a") 768 xmlDoc = ET.ElementTree() 769 ET.register_namespace("", "http://www.calligra.org/DTD/document-info") 770 location = os.path.join(self.projecturl, "documentinfo.xml") 771 xmlDoc.parse(location) 772 xmlroot = ET.fromstring(page.read("documentinfo.xml")) 773 calligra = "{http://www.calligra.org/DTD/document-info}" 774 aboutelem = xmlroot.find(calligra + 'about') 775 if ET.iselement(aboutelem.find(calligra + 'subject')): 776 desc = aboutelem.find(calligra + 'subject') 777 desc.text = self.pagesModel.data(index, role=Qt.EditRole) 778 xmlstring = ET.tostring(xmlroot, encoding='unicode', method='xml', short_empty_elements=False) 779 page.writestr(zinfo_or_arcname="documentinfo.xml", data=xmlstring) 780 for document in Application.documents(): 781 if str(document.fileName()) == str(absoluteUrl): 782 document.setDocumentInfo(xmlstring) 783 page.close() 784 785 """ 786 Calls up the export settings dialog. Only when accepted will the configuration be written. 787 """ 788 789 def slot_edit_export_settings(self): 790 dialog = comics_export_dialog.comic_export_setting_dialog() 791 dialog.setConfig(self.setupDictionary) 792 793 if (dialog.exec_() == QDialog.Accepted): 794 self.setupDictionary = dialog.getConfig(self.setupDictionary) 795 self.slot_write_config() 796 797 """ 798 Export the comic. Won't work without export settings set. 799 """ 800 801 def slot_export(self): 802 803 #ensure there is a unique identifier 804 if "uuid" not in self.setupDictionary.keys(): 805 uuid = str() 806 if "acbfID" in self.setupDictionary.keys(): 807 uuid = str(self.setupDictionary["acbfID"]) 808 else: 809 uuid = QUuid.createUuid().toString() 810 self.setupDictionary["uuid"] = uuid 811 812 exporter = comics_exporter.comicsExporter() 813 exporter.set_config(self.setupDictionary, self.projecturl) 814 exportSuccess = exporter.export() 815 if exportSuccess: 816 print("CPMT: Export success! The files have been written to the export folder!") 817 QMessageBox.information(self, i18n("Export success"), i18n("The files have been written to the export folder."), QMessageBox.Ok) 818 819 """ 820 Calls up the comics project setup wizard so users can create a new json file with the basic information. 821 """ 822 823 def slot_new_project(self): 824 setup = comics_project_setup_wizard.ComicsProjectSetupWizard() 825 setup.showDialog() 826 self.path_to_config = os.path.join(setup.projectDirectory, "comicConfig.json") 827 if os.path.exists(self.path_to_config) is True: 828 configFile = open(self.path_to_config, "r", newline="", encoding="utf-16") 829 self.setupDictionary = json.load(configFile) 830 self.projecturl = os.path.dirname(str(self.path_to_config)) 831 configFile.close() 832 self.load_config() 833 """ 834 This is triggered by any document save. 835 It checks if the given url in in the pages list, and if so, 836 updates the appropriate page thumbnail. 837 This helps with the management of the pages, because the user 838 will be able to see the thumbnails as a todo for the whole comic, 839 giving a good overview over whether they still need to ink, color or 840 the like for a given page, and it thus also rewards the user whenever 841 they save. 842 """ 843 844 def slot_start_delayed_check_page_update(self, url): 845 # It can happen that there are multiple signals from QFileSystemWatcher at once. 846 # Since QTimer cannot take any arguments, we need to keep a list of files to update. 847 # Otherwise only the last file would be updated and all subsequent calls 848 # of `slot_check_for_page_update` would not know which files to update now. 849 # https://bugs.kde.org/show_bug.cgi?id=426701 850 self.updateurls.append(url) 851 QTimer.singleShot(200, Qt.PreciseTimer, self.slot_check_for_page_update) 852 853 854 def slot_check_for_page_update(self): 855 url = self.updateurls.pop(0) 856 if url: 857 if "pages" in self.setupDictionary.keys(): 858 relUrl = os.path.relpath(url, self.projecturl) 859 if relUrl in self.setupDictionary["pages"]: 860 index = self.pagesModel.index(self.setupDictionary["pages"].index(relUrl), 0) 861 if index.isValid(): 862 if os.path.exists(url) is False: 863 # we cannot check from here whether the file in question has been renamed or deleted. 864 self.pagesModel.removeRow(index.row()) 865 return 866 else: 867 # Krita will trigger the filesystemwatcher when doing backupfiles, 868 # so ensure the file is still watched if it exists. 869 self.pagesWatcher.addPath(url) 870 pageItem = self.pagesModel.itemFromIndex(index) 871 page = zipfile.ZipFile(url, "r") 872 dataList = self.get_description_and_title(page.read("documentinfo.xml")) 873 if (dataList[0].isspace() or len(dataList[0]) < 1): 874 dataList[0] = os.path.basename(url) 875 thumbnail = QImage.fromData(page.read("preview.png")) 876 pageItem.setIcon(QIcon(QPixmap.fromImage(thumbnail))) 877 pageItem.setText(dataList[0]) 878 pageItem.setData(dataList[1], role = CPE.DESCRIPTION) 879 pageItem.setData(relUrl, role = CPE.URL) 880 pageItem.setData(dataList[2], role = CPE.KEYWORDS) 881 pageItem.setData(dataList[3], role = CPE.LASTEDIT) 882 pageItem.setData(dataList[4], role = CPE.EDITOR) 883 self.pagesModel.setItem(index.row(), index.column(), pageItem) 884 885 """ 886 Resize all the pages in the pages list. 887 It will show a dialog with the options for resizing. 888 Then, it will try to pop up a progress dialog while resizing. 889 The progress dialog shows the remaining time and pages. 890 """ 891 892 def slot_batch_resize(self): 893 dialog = QDialog() 894 dialog.setWindowTitle(i18n("Resize all Pages")) 895 buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 896 buttons.accepted.connect(dialog.accept) 897 buttons.rejected.connect(dialog.reject) 898 sizesBox = comics_export_dialog.comic_export_resize_widget("Scale", batch=True, fileType=False) 899 exporterSizes = comics_exporter.sizesCalculator() 900 dialog.setLayout(QVBoxLayout()) 901 dialog.layout().addWidget(sizesBox) 902 dialog.layout().addWidget(buttons) 903 904 if dialog.exec_() == QDialog.Accepted: 905 progress = QProgressDialog(i18n("Resizing pages..."), str(), 0, len(self.setupDictionary["pages"])) 906 progress.setWindowTitle(i18n("Resizing Pages")) 907 progress.setCancelButton(None) 908 timer = QElapsedTimer() 909 timer.start() 910 config = {} 911 config = sizesBox.get_config(config) 912 for p in range(len(self.setupDictionary["pages"])): 913 absoluteUrl = os.path.join(self.projecturl, self.setupDictionary["pages"][p]) 914 progress.setValue(p) 915 timePassed = timer.elapsed() 916 if (p > 0): 917 timeEstimated = (len(self.setupDictionary["pages"]) - p) * (timePassed / p) 918 passedString = str(int(timePassed / 60000)) + ":" + format(int(timePassed / 1000), "02d") + ":" + format(timePassed % 1000, "03d") 919 estimatedString = str(int(timeEstimated / 60000)) + ":" + format(int(timeEstimated / 1000), "02d") + ":" + format(int(timeEstimated % 1000), "03d") 920 progress.setLabelText(str(i18n("{pages} of {pagesTotal} done. \nTime passed: {passedString}:\n Estimated:{estimated}")).format(pages=p, pagesTotal=len(self.setupDictionary["pages"]), passedString=passedString, estimated=estimatedString)) 921 qApp.processEvents() 922 if os.path.exists(absoluteUrl): 923 doc = Application.openDocument(absoluteUrl) 924 listScales = exporterSizes.get_scale_from_resize_config(config["Scale"], [doc.width(), doc.height(), doc.resolution(), doc.resolution()]) 925 doc.scaleImage(listScales[0], listScales[1], listScales[2], listScales[3], "bicubic") 926 doc.waitForDone() 927 doc.save() 928 doc.waitForDone() 929 doc.close() 930 931 def slot_show_page_viewer(self): 932 index = int(self.comicPageList.currentIndex().row()) 933 self.page_viewer_dialog.load_comic(self.path_to_config) 934 self.page_viewer_dialog.go_to_page_index(index) 935 self.page_viewer_dialog.show() 936 937 """ 938 Function to copy the current project location into the clipboard. 939 This is useful for users because they'll be able to use that url to quickly 940 move to the project location in outside applications. 941 """ 942 943 def slot_copy_project_url(self): 944 if self.projecturl is not None: 945 clipboard = qApp.clipboard() 946 clipboard.setText(str(self.projecturl)) 947 948 """ 949 Scrape text files with the textlayer keys for text, and put those in a POT 950 file. This makes it possible to handle translations. 951 """ 952 953 def slot_scrape_translations(self): 954 translationFolder = self.setupDictionary.get("translationLocation", "translations") 955 fullTranslationPath = os.path.join(self.projecturl, translationFolder) 956 os.makedirs(fullTranslationPath, exist_ok=True) 957 textLayersToSearch = self.setupDictionary.get("textLayerNames", ["text"]) 958 959 scraper = comics_project_translation_scraper.translation_scraper(self.projecturl, translationFolder, textLayersToSearch, self.setupDictionary["projectName"]) 960 # Run text scraper. 961 language = self.setupDictionary.get("language", "en") 962 metadata = {} 963 metadata["title"] = self.setupDictionary.get("title", "") 964 metadata["summary"] = self.setupDictionary.get("summary", "") 965 metadata["keywords"] = ", ".join(self.setupDictionary.get("otherKeywords", [""])) 966 metadata["transnotes"] = self.setupDictionary.get("translatorHeader", "Translator's Notes") 967 scraper.start(self.setupDictionary["pages"], language, metadata) 968 QMessageBox.information(self, i18n("Scraping success"), str(i18n("POT file has been written to: {file}")).format(file=fullTranslationPath), QMessageBox.Ok) 969 """ 970 This is required by the dockwidget class, otherwise unused. 971 """ 972 973 def canvasChanged(self, canvas): 974 pass 975 976 977""" 978Add docker to program 979""" 980Application.addDockWidgetFactory(DockWidgetFactory("comics_project_manager_docker", DockWidgetFactoryBase.DockRight, comics_project_manager_docker)) 981