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