|
|
@@ -0,0 +1,194 @@
|
|
|
+from krita import DockWidget
|
|
|
+from PyQt5.QtWidgets import QWidget, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QButtonGroup, QRadioButton, QComboBox, QPushButton, QCheckBox
|
|
|
+from PyQt5.QtCore import QPointF
|
|
|
+from math import ceil, floor
|
|
|
+
|
|
|
+DOCKER_TITLE = 'Image Fitter'
|
|
|
+
|
|
|
+class ImageFitter(DockWidget):
|
|
|
+
|
|
|
+ def __init__(self):
|
|
|
+ super().__init__()
|
|
|
+ self.setWindowTitle(DOCKER_TITLE)
|
|
|
+ widget = QWidget()
|
|
|
+ layout = QVBoxLayout()
|
|
|
+ widget.setLayout(layout)
|
|
|
+
|
|
|
+ layout.addSpacing(10)
|
|
|
+ layout.addWidget(QLabel('Fit an image to a rectangle defined by guides.'))
|
|
|
+
|
|
|
+ # Perform scaling or just centering? Many options below should be in the
|
|
|
+ # same button group to be disabled en-masse.
|
|
|
+ self.scalingCheck = QCheckBox('Scaling')
|
|
|
+ self.scalingCheck.setChecked(False)
|
|
|
+ layout.addWidget(self.scalingCheck)
|
|
|
+
|
|
|
+ self.aspectCheck = QCheckBox('Keep Aspect Ratio')
|
|
|
+ self.aspectCheck.setChecked(True)
|
|
|
+ layout.addWidget(self.aspectCheck)
|
|
|
+
|
|
|
+ # Scale image to fit horizontal or vertical rectangle bounds.
|
|
|
+ self.matchMode = QComboBox()
|
|
|
+ modes = ['Horizontal', 'Vertical']
|
|
|
+ self.matchMode.addItems(modes)
|
|
|
+ row0 = QHBoxLayout()
|
|
|
+ row0.addWidget(QLabel('Scaling Match Mode:'))
|
|
|
+ row0.addWidget(self.matchMode)
|
|
|
+ layout.addLayout(row0)
|
|
|
+
|
|
|
+ # Scaling strategy.
|
|
|
+ # 'Box' is 'Nearest Neighbor'.
|
|
|
+ # https://api.kde.org/appscomplete-api/krita-apidocs/libs/image/html/kis__filter__strategy_8h_source.html#l00083
|
|
|
+ self.stratInput = QComboBox()
|
|
|
+ strategies = ['Box', 'Bicubic', 'Bilinear', 'Lancoz3', 'Bell', 'BSpline', 'Mitchell', 'Hermite']
|
|
|
+ self.stratInput.addItems(strategies)
|
|
|
+ row1 = QHBoxLayout()
|
|
|
+ row1.addWidget(QLabel('Scaling Strategy:'))
|
|
|
+ row1.addWidget(self.stratInput)
|
|
|
+ layout.addLayout(row1)
|
|
|
+
|
|
|
+ # Button!
|
|
|
+ layout.addSpacing(20)
|
|
|
+ goButton = QPushButton("Fit")
|
|
|
+ goButton.setIcon( Krita.instance().icon('animation_play') )
|
|
|
+ layout.addWidget(goButton)
|
|
|
+
|
|
|
+ # Add a stretch to prevent the rest of the content from stretching.
|
|
|
+ layout.addStretch()
|
|
|
+
|
|
|
+ # Add widget to the docker.
|
|
|
+ self.setWidget(widget)
|
|
|
+
|
|
|
+ # Hook up the action to the button.
|
|
|
+ goButton.clicked.connect( self.fitImage )
|
|
|
+
|
|
|
+
|
|
|
+ # notifies when views are added or removed
|
|
|
+ # 'pass' means do not do anything
|
|
|
+ def canvasChanged(self, canvas):
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+ ##########
|
|
|
+ # Slots
|
|
|
+ ##########
|
|
|
+
|
|
|
+ # Actually fits the image.
|
|
|
+ def fitImage(self, e):
|
|
|
+
|
|
|
+ # Get the current layer.
|
|
|
+ doc = Krita.instance().activeDocument()
|
|
|
+ layer = doc.activeNode()
|
|
|
+ if layer.type() != 'paintlayer':
|
|
|
+ dialog = QDialog()
|
|
|
+ dialog.setWindowTitle("Paint Layer Required")
|
|
|
+ layout = QVBoxLayout()
|
|
|
+ layout.addWidget(QLabel('Page slicer only works on paint layers. Please select one.'))
|
|
|
+ dialog.setLayout(layout)
|
|
|
+ dialog.exec_()
|
|
|
+ return
|
|
|
+
|
|
|
+ # Get centres and bounds for the layer and the target rectangle.
|
|
|
+ bds = layer.bounds()
|
|
|
+ layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
|
|
|
+ tbds = self.getTargetBounds(doc, layerCentre)
|
|
|
+ targetCentre = self.getCentre(tbds['left'], tbds['right'], tbds['top'], tbds['bottom'])
|
|
|
+
|
|
|
+ # Handle Scaling. Don't scale if not needed.
|
|
|
+ # Repeated scaling does weird things to images.
|
|
|
+ if self.scalingCheck.checkState():
|
|
|
+ doScaling = False
|
|
|
+ targetWidth = tbds['right'] - 1 - tbds['left']
|
|
|
+ targetHeight = tbds['bottom'] - 1 - tbds['top']
|
|
|
+ layerWidth = bds.right() - bds.left()
|
|
|
+ layerHeight = bds.bottom() - bds.top()
|
|
|
+ # Deal with aspect ratio. Simply adjust target dimensions.
|
|
|
+ if self.aspectCheck.checkState():
|
|
|
+ aspectRatio = layerWidth / layerHeight
|
|
|
+ mode = self.matchMode.currentIndex()
|
|
|
+ if mode == 0: # fit horizontally
|
|
|
+ targetHeight = targetWidth / aspectRatio
|
|
|
+ if layerHeight != targetHeight:
|
|
|
+ doScaling = True
|
|
|
+ else: # fit vertically
|
|
|
+ targetWidth = targetHeight * aspectRatio
|
|
|
+ if layerWidth != targetWidth:
|
|
|
+ doScaling = True
|
|
|
+ else:
|
|
|
+ if layerWidth != targetWidth or layerHeight != targetHeight:
|
|
|
+ doScaling = True
|
|
|
+ # Actual scaling.
|
|
|
+ if doScaling:
|
|
|
+ origin = QPointF(bds.left(), bds.top())
|
|
|
+ layer.scaleNode(origin, targetWidth, targetHeight, self.stratInput.currentText())
|
|
|
+ # Refresh bounds and centre, which are now changed.
|
|
|
+ bds = layer.bounds()
|
|
|
+ layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
|
|
|
+
|
|
|
+ # Round centre coordinates so that odd centres tend towards top-left.
|
|
|
+ # Rule that was worked out:
|
|
|
+ # - target values are always rounded down.
|
|
|
+ # - layer values are rounded up if target is even, down if odd.
|
|
|
+ for axis in ['x', 'y']:
|
|
|
+ if layerCentre[axis].is_integer() == False and targetCentre[axis].is_integer():
|
|
|
+ layerCentre[axis] = ceil(layerCentre[axis])
|
|
|
+ else:
|
|
|
+ layerCentre[axis] = floor(layerCentre[axis])
|
|
|
+ targetCentre[axis] = floor(targetCentre[axis])
|
|
|
+
|
|
|
+ # Reposition.
|
|
|
+ # The layer move() function works according to the "actual" position of
|
|
|
+ # the layer, which starts off as the top corner of the image (0, 0).
|
|
|
+ # So, instead of giving move() an absolute position, we have to
|
|
|
+ # translate the layer's position by applying the difference between the
|
|
|
+ # actual layer bounds and the target destination.
|
|
|
+ # mid = getLayerCentre(layer) # Bounds may have changed, if scaling.
|
|
|
+ diffX = layerCentre['x'] - targetCentre['x']
|
|
|
+ diffY = layerCentre['y'] - targetCentre['y']
|
|
|
+ layerPos = layer.position()
|
|
|
+ layer.move(layerPos.x() - diffX, layerPos.y() - diffY)
|
|
|
+
|
|
|
+ # Refresh the view, or the moved will not be immediately reflected.
|
|
|
+ doc.refreshProjection()
|
|
|
+
|
|
|
+
|
|
|
+ def getCentre(self, left, right, top, bottom):
|
|
|
+ return {'x': (left + right)/2, 'y': (top + bottom)/2}
|
|
|
+
|
|
|
+
|
|
|
+ def getTargetBounds(self, doc, layerCentre):
|
|
|
+ # Horizontal guides provide the Vertical y-axis points and vice versa.
|
|
|
+ # Also include the edges of the image for the rectangle.
|
|
|
+ vPoints = doc.horizontalGuides()
|
|
|
+ hPoints = doc.verticalGuides()
|
|
|
+ vPoints.insert(0, 0)
|
|
|
+ vPoints.append(doc.height())
|
|
|
+ hPoints.insert(0, 0)
|
|
|
+ hPoints.append(doc.width())
|
|
|
+
|
|
|
+ # Sanitize points. Guide values are floats, and may contain unexpected
|
|
|
+ # extra fractional values.
|
|
|
+ vPoints = [round(v) for v in vPoints]
|
|
|
+ vPoints.sort()
|
|
|
+ hPoints = [round(h) for h in hPoints]
|
|
|
+ hPoints.sort()
|
|
|
+
|
|
|
+ # Determine the boundaries most relevant to the layer
|
|
|
+ bounds = {}
|
|
|
+ prevV = 0
|
|
|
+ for v in vPoints:
|
|
|
+ if v >= layerCentre['y']:
|
|
|
+ bounds['top'] = prevV
|
|
|
+ bounds['bottom'] = v
|
|
|
+ break
|
|
|
+ prevV = v
|
|
|
+
|
|
|
+ prevH = 0
|
|
|
+ for h in hPoints:
|
|
|
+ if h >= layerCentre['x']:
|
|
|
+ bounds['left'] = prevH
|
|
|
+ bounds['right'] = h
|
|
|
+ break
|
|
|
+ prevH = h
|
|
|
+
|
|
|
+ return bounds
|