image_fitter_docker.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. from krita import DockWidget
  2. from PyQt5.QtWidgets import QWidget, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QButtonGroup, QRadioButton, QComboBox, QPushButton, QCheckBox
  3. from PyQt5.QtCore import QPointF
  4. from math import ceil, floor
  5. DOCKER_TITLE = 'Image Fitter'
  6. class ImageFitter(DockWidget):
  7. def __init__(self):
  8. super().__init__()
  9. self.setWindowTitle(DOCKER_TITLE)
  10. widget = QWidget()
  11. layout = QVBoxLayout()
  12. widget.setLayout(layout)
  13. layout.addSpacing(10)
  14. layout.addWidget(QLabel('Fit an image to a rectangle defined by guides.'))
  15. # Perform scaling or just centering? Many options below should be in the
  16. # same button group to be disabled en-masse.
  17. self.scalingCheck = QCheckBox('Scaling')
  18. self.scalingCheck.setChecked(False)
  19. layout.addWidget(self.scalingCheck)
  20. self.aspectCheck = QCheckBox('Keep Aspect Ratio')
  21. self.aspectCheck.setChecked(True)
  22. layout.addWidget(self.aspectCheck)
  23. # Scale image to fit horizontal or vertical rectangle bounds.
  24. self.matchMode = QComboBox()
  25. modes = ['Horizontal', 'Vertical']
  26. self.matchMode.addItems(modes)
  27. row0 = QHBoxLayout()
  28. row0.addWidget(QLabel('Scaling Match Mode:'))
  29. row0.addWidget(self.matchMode)
  30. layout.addLayout(row0)
  31. # Scaling strategy.
  32. # 'Box' is 'Nearest Neighbor'.
  33. # https://api.kde.org/appscomplete-api/krita-apidocs/libs/image/html/kis__filter__strategy_8h_source.html#l00083
  34. self.stratInput = QComboBox()
  35. strategies = ['Box', 'Bicubic', 'Bilinear', 'Lancoz3', 'Bell', 'BSpline', 'Mitchell', 'Hermite']
  36. self.stratInput.addItems(strategies)
  37. row1 = QHBoxLayout()
  38. row1.addWidget(QLabel('Scaling Strategy:'))
  39. row1.addWidget(self.stratInput)
  40. layout.addLayout(row1)
  41. # Button!
  42. layout.addSpacing(20)
  43. goButton = QPushButton("Fit")
  44. goButton.setIcon( Krita.instance().icon('animation_play') )
  45. layout.addWidget(goButton)
  46. # Add a stretch to prevent the rest of the content from stretching.
  47. layout.addStretch()
  48. # Add widget to the docker.
  49. self.setWidget(widget)
  50. # Hook up the action to the button.
  51. goButton.clicked.connect( self.fitImage )
  52. # notifies when views are added or removed
  53. # 'pass' means do not do anything
  54. def canvasChanged(self, canvas):
  55. pass
  56. ##########
  57. # Slots
  58. ##########
  59. # Actually fits the image.
  60. def fitImage(self, e):
  61. # Get the current layer.
  62. doc = Krita.instance().activeDocument()
  63. layer = doc.activeNode()
  64. if layer.type() != 'paintlayer':
  65. dialog = QDialog()
  66. dialog.setWindowTitle("Paint Layer Required")
  67. layout = QVBoxLayout()
  68. layout.addWidget(QLabel('Page slicer only works on paint layers. Please select one.'))
  69. dialog.setLayout(layout)
  70. dialog.exec_()
  71. return
  72. # Get centres and bounds for the layer and the target rectangle.
  73. bds = layer.bounds()
  74. layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
  75. tbds = self.getTargetBounds(doc, layerCentre)
  76. targetCentre = self.getCentre(tbds['left'], tbds['right'], tbds['top'], tbds['bottom'])
  77. # Handle Scaling. Don't scale if not needed.
  78. # Repeated scaling does weird things to images.
  79. if self.scalingCheck.checkState():
  80. doScaling = False
  81. targetWidth = tbds['right'] - 1 - tbds['left']
  82. targetHeight = tbds['bottom'] - 1 - tbds['top']
  83. layerWidth = bds.right() - bds.left()
  84. layerHeight = bds.bottom() - bds.top()
  85. # Deal with aspect ratio. Simply adjust target dimensions.
  86. if self.aspectCheck.checkState():
  87. aspectRatio = layerWidth / layerHeight
  88. mode = self.matchMode.currentIndex()
  89. if mode == 0: # fit horizontally
  90. targetHeight = targetWidth / aspectRatio
  91. if layerHeight != targetHeight:
  92. doScaling = True
  93. else: # fit vertically
  94. targetWidth = targetHeight * aspectRatio
  95. if layerWidth != targetWidth:
  96. doScaling = True
  97. else:
  98. if layerWidth != targetWidth or layerHeight != targetHeight:
  99. doScaling = True
  100. # Actual scaling.
  101. if doScaling:
  102. origin = QPointF(bds.left(), bds.top())
  103. layer.scaleNode(origin, targetWidth, targetHeight, self.stratInput.currentText())
  104. # Refresh bounds and centre, which are now changed.
  105. bds = layer.bounds()
  106. layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
  107. # Round centre coordinates so that odd centres tend towards top-left.
  108. # Rule that was worked out:
  109. # - target values are always rounded down.
  110. # - layer values are rounded up if target is even, down if odd.
  111. for axis in ['x', 'y']:
  112. if layerCentre[axis].is_integer() == False and targetCentre[axis].is_integer():
  113. layerCentre[axis] = ceil(layerCentre[axis])
  114. else:
  115. layerCentre[axis] = floor(layerCentre[axis])
  116. targetCentre[axis] = floor(targetCentre[axis])
  117. # Reposition.
  118. # The layer move() function works according to the "actual" position of
  119. # the layer, which starts off as the top corner of the image (0, 0).
  120. # So, instead of giving move() an absolute position, we have to
  121. # translate the layer's position by applying the difference between the
  122. # actual layer bounds and the target destination.
  123. # mid = getLayerCentre(layer) # Bounds may have changed, if scaling.
  124. diffX = layerCentre['x'] - targetCentre['x']
  125. diffY = layerCentre['y'] - targetCentre['y']
  126. layerPos = layer.position()
  127. layer.move(layerPos.x() - diffX, layerPos.y() - diffY)
  128. # Refresh the view, or the moved will not be immediately reflected.
  129. doc.refreshProjection()
  130. def getCentre(self, left, right, top, bottom):
  131. return {'x': (left + right)/2, 'y': (top + bottom)/2}
  132. def getTargetBounds(self, doc, layerCentre):
  133. # Horizontal guides provide the Vertical y-axis points and vice versa.
  134. # Also include the edges of the image for the rectangle.
  135. vPoints = doc.horizontalGuides()
  136. hPoints = doc.verticalGuides()
  137. vPoints.insert(0, 0)
  138. vPoints.append(doc.height())
  139. hPoints.insert(0, 0)
  140. hPoints.append(doc.width())
  141. # Sanitize points. Guide values are floats, and may contain unexpected
  142. # extra fractional values.
  143. vPoints = [round(v) for v in vPoints]
  144. vPoints.sort()
  145. hPoints = [round(h) for h in hPoints]
  146. hPoints.sort()
  147. # Determine the boundaries most relevant to the layer
  148. bounds = {}
  149. prevV = 0
  150. for v in vPoints:
  151. if v >= layerCentre['y']:
  152. bounds['top'] = prevV
  153. bounds['bottom'] = v
  154. break
  155. prevV = v
  156. prevH = 0
  157. for h in hPoints:
  158. if h >= layerCentre['x']:
  159. bounds['left'] = prevH
  160. bounds['right'] = h
  161. break
  162. prevH = h
  163. return bounds