batchprint/batchprint.pyw

681 lines
21 KiB
Python
Executable File

#!/usr/bin/env python3
# requires: pywin32, pillow, qtpy, a module compatible with qtpy (try pyqt5)
import sys
import ctypes
import traceback
import win32clipboard
oldexcepthook = sys.excepthook
def newexcepthook(type,value,tb):
excText = "".join(traceback.format_exception(type,value,tb))
try:
for window in windows:
try:
window.close()
except:
pass
except:
pass
output = ctypes.windll.user32.MessageBoxW(None, u"" + excText + "\nThe program must close. Copy exception to clipboard?", u"Unhandled exception - " +prettyDistro, 0x00000114)
if output == 6:
copyString(excText)
oldexcepthook(type,value,tb)
sys.excepthook = newexcepthook
def copyString(s):
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardText(s, win32clipboard.CF_UNICODETEXT)
win32clipboard.CloseClipboard()
import os
p = os.path.join
pUp = os.path.dirname
s = False
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
s = os.path.realpath(sys.executable)
else:
s = os.path.realpath(__file__)
sp = pUp(s)
import qtpy
import qtpy.QtGui as QtGui
from qtpy.QtGui import *
from qtpy.QtWidgets import *
from qtpy.QtCore import *
import winreg
import json
import win32ui
import PIL.Image
import PIL.ImageWin
import subprocess
# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-getdevicecaps
HORZRES = 8
VERTRES = 10
PHYSICALWIDTH = 110
PHYSICALHEIGHT = 111
PHYSICALOFFSETX = 112
PHYSICALOFFSETY = 113
prettyDistro = "batchprint"
distro = "me.fier." +prettyDistro
configDir = p(os.environ["APPDATA"],distro)
defaultProfile = {}
defaultProfile["printer"] = None
defaultProfile["amount"] = 1
defaultProfile["stretch"] = True
defaultProfile["rotate"] = True
defaultProfile["offset-x"] = 0
defaultProfile["offset-y"] = 0
defaultProfile["scale-x"] = 1.00
defaultProfile["scale-y"] = 1.00
# Profiles
def saveProfile(profile,profileName):
with open(p(configDir,"profiles",profileName + ".json"),"w",encoding="utf-8") as f:
f.write(json.dumps(profile,sort_keys = True,indent = 1))
def loadProfile(profileName):
with open(p(configDir,"profiles",profileName + ".json"),"r",encoding="utf-8") as f:
profile = json.loads(f.read())
for i in defaultProfile:
if not i in profile: profile[i] = defaultProfile[i]
return profile
def deleteProfile(profileName):
os.remove(p(configDir,"profiles",profileName + ".json"))
def getProfiles():
profiles = []
for root,dirs,files in os.walk(p(configDir,"profiles")):
for file in files:
if not file.lower().endswith(".json"): continue
ffile = p(configDir,"profiles",file)
lfile = ffile.replace(p(configDir,"profiles") + os.path.sep,"",1)
profiles.append(lfile.rsplit(".",1)[0])
break
profiles.sort()
return profiles
def isProfileSame(profile1,profile2):
for i in profile1:
if not i in profile2: return False
for i in profile2:
if not i in profile1: return False
for i in profile1:
if profile1[i] != profile2[i]: return False
return True
# System
def getPrinters():
hive = winreg.ConnectRegistry(None,winreg.HKEY_LOCAL_MACHINE)
key = winreg.OpenKey(hive,r"SYSTEM\CurrentControlSet\Control\Print\Printers")
printers = []
index = 0
while True:
try:
printers.append(winreg.EnumKey(key,index))
index += 1
except OSError:
break
winreg.CloseKey(key)
hive.Close()
printers.sort()
return printers
# Config
def readConfigFile(filePath):
with open(p(configDir,filePath),"r",encoding="utf-8") as f:
return f.read()
def writeConfigFile(filePath,data):
with open(p(configDir,filePath),"w",encoding="utf-8") as f:
f.write(data)
# Qt Widgets
def limitWidgetSize(widget,small = False):
sizeHint = widget.sizeHint()
if small == False:
widget.setMaximumWidth(sizeHint.width())
else:
widget.setMaximumWidth(sizeHint.height())
widget.setMaximumHeight(sizeHint.height())
def createLine(style = "dotted"):
line = QLabel("")
line.setStyleSheet("border-width: 1px; border-style: " +style)
line.setMaximumHeight(1)
return line
class infoWindow(QMainWindow):
def __init__(self,parent,title,message,*args,**kwargs):
super().__init__(*args,**kwargs)
self.cParent = parent
self.cTitle = title
self.cMessage = message
self.setWindowTitle(self.cTitle)
self.cWidth = 320
self.cHeight = 80
self.resize(self.cWidth,self.cHeight)
self.cCreateElements()
self.show()
def cCreateElements(self):
self.cLabel = QLabel(self)
self.cLabel.setAlignment(Qt.AlignCenter)
self.cLabel.setText(self.cMessage)
self.cButton = QPushButton(self)
self.cButton.setText("OK")
self.cButton.clicked.connect(self.cClose)
self.cResizeElements()
def cResizeElements(self):
sizeHint = self.cButton.sizeHint()
self.cButton.move((self.cWidth * 0.5) - (sizeHint.width() * 0.5),self.cHeight - sizeHint.height() - 5)
self.cButton.resize(sizeHint.width(),sizeHint.height())
self.cLabel.move(5,5)
self.cLabel.resize(self.cWidth - 10,self.cHeight - sizeHint.height() - 15)
def resizeEvent(self,event):
self.cWidth = self.width()
self.cHeight = self.height()
self.cResizeElements()
def cClose(self):
self.close()
def closeEvent(self,event):
windows.remove(self)
self.cParent.setEnabled(True)
class saveWindow(QMainWindow):
def __init__(self,parent,profile,callback,*args,**kwargs):
super().__init__(*args,**kwargs)
self.cParent = parent
self.cProfile = profile
self.cCallback = callback
self.setWindowTitle("save")
self.cWidth = 320
self.cHeight = 80
self.resize(self.cWidth,self.cHeight)
self.cCreateElements()
self.show()
def cCreateElements(self):
self.cInput = QLineEdit(self)
self.cInput.setText(self.cProfile)
self.cInput.selectAll()
self.cButtonCancel = QPushButton(self)
self.cButtonCancel.setText("Cancel")
self.cButtonCancel.clicked.connect(self.cClose)
self.cButtonSave = QPushButton(self)
self.cButtonSave.setText("Save")
self.cButtonSave.clicked.connect(self.cSave)
self.cResizeElements()
def cResizeElements(self):
sizeHint = self.cButtonCancel.sizeHint()
self.cInput.move(5,5 + ((self.cHeight - sizeHint.height() - 10) * 0.5) - (self.cInput.sizeHint().height() * 0.5))
self.cInput.resize(self.cWidth - 10,self.cInput.sizeHint().height())
self.cButtonCancel.move(5,self.cHeight - sizeHint.height() - 5)
self.cButtonCancel.resize(sizeHint.width(),sizeHint.height())
self.cButtonSave.move(self.cWidth - sizeHint.width() - 5,self.cHeight - sizeHint.height() - 5)
self.cButtonSave.resize(sizeHint.width(),sizeHint.height())
def resizeEvent(self,event):
self.cWidth = self.width()
self.cHeight = self.height()
self.cResizeElements()
def cSave(self):
self.close()
self.cCallback(self.cInput.text())
def cClose(self):
self.close()
def closeEvent(self,event):
windows.remove(self)
self.cParent.setEnabled(True)
class mainWindow(QMainWindow):
def __init__(self,*args,**kwargs):
super().__init__(*args,**kwargs)
self.setAcceptDrops(True)
self.cTitle = prettyDistro
self.setWindowTitle(self.cTitle)
self.cWidth = 300
self.cHeight = 265
self.resize(self.cWidth,self.cHeight)
self.cCreateElements()
self.cPrinters = getPrinters()
self.cPrinterDropdown.addItems(self.cPrinters)
self.cProfiles = getProfiles()
self.cProfileDropdown.addItems(self.cProfiles)
selectProfile = self.cProfiles[0]
try:
selectProfile = readConfigFile("lastProfile.txt")
except:
pass
if not selectProfile in self.cProfiles: selectProfile = self.cProfiles[0]
self.cProfileDropdown.setCurrentIndex(self.cProfiles.index(selectProfile))
def cLoadCurrentProfile(self):
profile = self.cProfileDropdown.currentText()
if profile == "": return
#print("profile loaded")
self.cProfileOld = loadProfile(profile)
self.cProfile = self.cProfileOld.copy()
try:
printerIndex = self.cPrinters.index(self.cProfile["printer"])
except:
printerIndex = 0
self.cProfile["printer"] = self.cPrinters[printerIndex]
self.cPrinterDropdown.setCurrentIndex(printerIndex)
self.cAmountInput.setText(str(self.cProfile["amount"]))
self.cRotateTick.setChecked(self.cProfile["rotate"])
self.cStretchTick.setChecked(self.cProfile["stretch"])
self.cOffsetHorzInput.setText(str(self.cProfile["offset-x"]))
self.cOffsetVertInput.setText(str(self.cProfile["offset-y"]))
self.cScaleHorzInput.setText(str(self.cProfile["scale-x"]))
self.cScaleVertInput.setText(str(self.cProfile["scale-y"]))
def cSaveProfile(self):
self.setEnabled(False)
profile = self.cMenuToProfile()
if type(profile) == str:
windows.append(infoWindow(self,prettyDistro,"The profile can't be saved: '" +profile+ "' is set to an invalid value."))
return
windows.append(saveWindow(self,self.cProfileDropdown.currentText(),self.cSaveProfileCallback))
def cSaveProfileCallback(self,profileName):
profile = self.cMenuToProfile()
saveProfile(profile,profileName)
if not profileName in self.cProfiles:
self.cProfiles.append(profileName)
self.cProfiles.sort()
self.cProfileDropdown.clear()
self.cProfileDropdown.addItems(self.cProfiles)
self.cProfileDropdown.setCurrentIndex(self.cProfiles.index(profileName))
def cDeleteProfile(self):
if len(self.cProfiles) < 2:
self.setEnabled(False)
windows.append(infoWindow(self,prettyDistro,"You can't delete your last profile!"))
return
profile = self.cProfileDropdown.currentText()
profileIndex = self.cProfiles.index(profile)
deleteProfile(profile)
self.cProfiles.pop(profileIndex)
self.cProfileDropdown.removeItem(profileIndex)
def cPrinterSettings(self):
self.hide()
subprocess.Popen(["rundll32","printui.dll,PrintUIEntry","/e","/n" + self.cPrinterDropdown.currentText()]).wait()
self.show()
def cMenuToProfile(self):
profile = {}
try:
error = "Printer"
profile["printer"] = self.cPrinterDropdown.currentText()
error = "Print amount"
profile["amount"] = int(self.cAmountInput.text())
error = "Stretch images"
profile["stretch"] = self.cStretchTick.isChecked()
error = "Auto-rotate images"
profile["rotate"] = self.cRotateTick.isChecked()
error = "Offset horizontal"
profile["offset-x"] = int(self.cOffsetHorzInput.text())
error = "Offset vertical"
profile["offset-y"] = int(self.cOffsetVertInput.text())
error = "Scale horizontal"
profile["scale-x"] = float(self.cScaleHorzInput.text())
error = "Scale vertical"
profile["scale-y"] = float(self.cScaleVertInput.text())
except:
return error
return profile
def cCreateLayout(self,detached = False):
widget = QWidget(self)
layout = QHBoxLayout(widget)
layout.cWidget = widget
layout.setAlignment(Qt.AlignTop)
layout.setSpacing(2)
layout.setContentsMargins(0,0,0,0)
if not detached: self.cLayout.addWidget(widget)
return layout
def dragEnterEvent(self,event):
if not event.mimeData().hasUrls(): return
self.activateWindow()
self.cStatus.setStyleSheet("font-weight: bold")
urls = event.mimeData().urls()
if len(urls) == 1:
path = str(urls[0].toLocalFile()).replace("/",os.path.sep)
self.cStatus.showMessage("Print " +path)
else:
self.cStatus.showMessage("Print " +str(len(urls))+ " images")
event.accept()
def dragLeaveEvent(self,event):
self.cStatus.setStyleSheet("")
self.cStatus.showMessage("Drag & Drop an image to start printing")
def dropEvent(self,event):
self.setEnabled(False)
self.cStatus.setStyleSheet("")
self.cStatus.showMessage("Drag & Drop an image to start printing")
profile = self.cMenuToProfile()
if type(profile) == str:
windows.append(infoWindow(self,prettyDistro,"Cannot print: '" +profile+ "' is set to an invalid value."))
return
dc = win32ui.CreateDC()
dc.CreatePrinterDC(profile["printer"])
printerPhysicalSize = [dc.GetDeviceCaps(PHYSICALWIDTH),dc.GetDeviceCaps(PHYSICALHEIGHT)]
printerPrintSize = [dc.GetDeviceCaps(HORZRES),dc.GetDeviceCaps(VERTRES)]
printerPrintOffset = [dc.GetDeviceCaps(PHYSICALOFFSETX),dc.GetDeviceCaps(PHYSICALOFFSETY)]
for url in event.mimeData().urls():
path = str(url.toLocalFile()).replace("/",os.path.sep)
self.cStatus.showMessage("Converting ... (" +path+ ")")
self.repaint()
bmp = PIL.Image.open(path)
if profile["rotate"]:
self.cStatus.showMessage("Rotating ... (" +path+ ")")
self.repaint()
if bmp.size[1] > bmp.size[0]:
if printerPrintSize[0] > printerPrintSize[1]:
bmp = bmp.transpose(PIL.Image.ROTATE_90)
elif bmp.size[0] > bmp.size[1]:
if printerPrintSize[1] > printerPrintSize[0]:
bmp = bmp.transpose(PIL.Image.ROTATE_90)
imStartX = printerPrintOffset[0]
imStartY = printerPrintOffset[1]
if profile["stretch"]:
imEndX = printerPrintSize[0]
imEndY = printerPrintSize[1]
else:
imEndX = imStartX + bmp.size[0]
imEndY = imStartY + bmp.size[1]
imStartX += profile["offset-x"]
imEndX += profile["offset-x"]
imStartY += profile["offset-y"]
imEndY += profile["offset-y"]
addX = round((imEndX - imStartX) * profile["scale-x"]) - (imEndX - imStartX)
addY = round((imEndY - imStartY) * profile["scale-y"]) - (imEndY - imStartY)
imEndX = imEndX + (round(addX*0.5))
imEndY = imEndY + (round(addY*0.5))
imStartX = imStartX - (round(addX*0.5))
imStartY = imStartY - (round(addY*0.5))
copies = profile["amount"]
dib = PIL.ImageWin.Dib(bmp)
while copies > 0:
self.cStatus.showMessage("Plotting ... (" +path+ ")")
dc.StartDoc(path)
dc.StartPage()
self.repaint()
dib.draw(dc.GetHandleOutput(),(
imStartX,imStartY,
imEndX,imEndY
))
dc.EndPage()
dc.EndDoc()
copies -= 1
dc.DeleteDC()
self.cStatus.showMessage("Drag & Drop an image to start printing")
self.setEnabled(True)
def cCreateElements(self):
mainWidget = QWidget(self)
self.cLayout = QVBoxLayout(mainWidget)
self.cLayout.setAlignment(Qt.AlignTop)
self.cLayout.setSpacing(2)
self.cLayout.setContentsMargins(5,5,5,5)
self.cScroll = QScrollArea(self)
self.cScroll.setWidget(mainWidget)
self.cScroll.setWidgetResizable(True)
self.cProfileLayout = self.cCreateLayout(True)
self.cProfileLabel = QLabel("Profile:")
self.cProfileLabel.setStyleSheet("font-weight: bold")
self.cProfileLayout.addWidget(self.cProfileLabel)
self.cProfileDropdown = QComboBox()
self.cProfileDropdown.currentIndexChanged.connect(self.cLoadCurrentProfile)
self.cProfileLayout.addWidget(self.cProfileDropdown)
self.cProfileCreateButton = QPushButton()
self.cProfileCreateButton.setText("+")
self.cProfileCreateButton.clicked.connect(self.cSaveProfile)
self.cProfileLayout.addWidget(self.cProfileCreateButton)
self.cProfileDeleteButton = QPushButton()
self.cProfileDeleteButton.setText("-")
self.cProfileDeleteButton.clicked.connect(self.cDeleteProfile)
self.cProfileLayout.addWidget(self.cProfileDeleteButton)
# Printer
layout = self.cCreateLayout()
self.cPrinterLabel = QLabel("Printer:")
#self.cPrinterLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cPrinterLabel)
self.cPrinterDropdown = QComboBox()
layout.addWidget(self.cPrinterDropdown)
self.cPrinterSettingsButton = QPushButton()
self.cPrinterSettingsButton.setText("Configure")
self.cPrinterSettingsButton.clicked.connect(self.cPrinterSettings)
layout.addWidget(self.cPrinterSettingsButton)
# Print amount
layout = self.cCreateLayout()
self.cAmountLabel = QLabel("Print amount:")
#self.cAmountLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cAmountLabel)
line = createLine()
layout.addWidget(line)
self.cAmountInput = QLineEdit()
layout.addWidget(self.cAmountInput)
line = createLine("solid")
self.cLayout.addWidget(line)
# Stretch image
layout = self.cCreateLayout()
self.cStretchLabel = QLabel("Stretch images:")
#self.cStretchLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cStretchLabel)
line = createLine()
layout.addWidget(line)
self.cStretchTick = QCheckBox()
layout.addWidget(self.cStretchTick)
# Rotate image
layout = self.cCreateLayout()
self.cRotateLabel = QLabel("Auto-rotate images:")
#self.cRotateLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cRotateLabel)
line = createLine()
layout.addWidget(line)
self.cRotateTick = QCheckBox()
layout.addWidget(self.cRotateTick)
# Offset horizontal
layout = self.cCreateLayout()
self.cOffsetHorzLabel = QLabel("Offset horizontal (px):")
#self.cOffsetHorzLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cOffsetHorzLabel)
line = createLine()
layout.addWidget(line)
self.cOffsetHorzInput = QLineEdit()
layout.addWidget(self.cOffsetHorzInput)
# Offset vertical
layout = self.cCreateLayout()
self.cOffsetVertLabel = QLabel("Offset vertical (px):")
#self.cOffsetVertLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cOffsetVertLabel)
line = createLine()
layout.addWidget(line)
self.cOffsetVertInput = QLineEdit()
layout.addWidget(self.cOffsetVertInput)
# Scale horizontal
layout = self.cCreateLayout()
self.cScaleHorzLabel = QLabel("Scale horizontal (*):")
#self.cScaleHorzLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cScaleHorzLabel)
line = createLine()
layout.addWidget(line)
self.cScaleHorzInput = QLineEdit()
layout.addWidget(self.cScaleHorzInput)
# Scale vertical
layout = self.cCreateLayout()
self.cScaleVertLabel = QLabel("Scale vertical (*):")
#self.cScaleVertLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cScaleVertLabel)
line = createLine()
layout.addWidget(line)
self.cScaleVertInput = QLineEdit()
layout.addWidget(self.cScaleVertInput)
line = createLine("solid")
self.cLayout.addWidget(line)
# Information
layout = self.cCreateLayout()
self.cInfoResolutionLabel = QLabel("Paper resolution:")
#self.cInfoResolutionLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cInfoResolutionLabel)
line = createLine()
layout.addWidget(line)
self.cInfoResolutionValue = QLabel("0*0")
layout.addWidget(self.cInfoResolutionValue)
layout = self.cCreateLayout()
self.cInfoRealResolutionLabel = QLabel("Usable (real) resolution:")
#self.cInfoRealResolutionLabel.setStyleSheet("font-weight: bold")
layout.addWidget(self.cInfoRealResolutionLabel)
line = createLine()
layout.addWidget(line)
self.cInfoRealResolutionValue = QLabel("0*0 (100%)")
layout.addWidget(self.cInfoRealResolutionValue)
# Status bar
self.cStatus = QStatusBar(self)
self.cStatus.showMessage("Drag & Drop an image to start printing")
self.cResizeElements()
self.show()
def cResizeElements(self):
statusSize = self.cStatus.sizeHint().height()
# Profile
limitWidgetSize(self.cProfileLabel)
limitWidgetSize(self.cProfileCreateButton,True)
limitWidgetSize(self.cProfileDeleteButton,True)
profileSize = self.cProfileLayout.cWidget.sizeHint().height()
profilePadding = 2
self.cProfileLayout.cWidget.move(5,profilePadding)
self.cProfileLayout.cWidget.resize(self.cWidth - 10,profileSize)
self.cScroll.move(0,profileSize + (profilePadding * 2))
self.cScroll.resize(self.cWidth,self.cHeight - statusSize - profileSize - (profilePadding * 2))
# Printer
limitWidgetSize(self.cPrinterLabel)
limitWidgetSize(self.cPrinterSettingsButton)
# Print amount
limitWidgetSize(self.cAmountLabel)
self.cAmountInput.setMaximumWidth(64)
# Stretch image
limitWidgetSize(self.cStretchLabel)
limitWidgetSize(self.cStretchTick)
# Rotate image
limitWidgetSize(self.cRotateLabel)
limitWidgetSize(self.cRotateTick)
# Offset horizontal
limitWidgetSize(self.cOffsetHorzLabel)
self.cOffsetHorzInput.setMaximumWidth(64)
# Offset vertical
limitWidgetSize(self.cOffsetVertLabel)
self.cOffsetVertInput.setMaximumWidth(64)
# Scale horizontal
limitWidgetSize(self.cScaleHorzLabel)
self.cScaleHorzInput.setMaximumWidth(64)
# Scale vertical
limitWidgetSize(self.cScaleVertLabel)
self.cScaleVertInput.setMaximumWidth(64)
# Information
limitWidgetSize(self.cInfoResolutionLabel)
limitWidgetSize(self.cInfoResolutionValue)
limitWidgetSize(self.cInfoRealResolutionLabel)
limitWidgetSize(self.cInfoRealResolutionValue)
self.cStatus.move(0,self.cHeight - statusSize)
self.cStatus.resize(self.cWidth,statusSize)
def resizeEvent(self,event):
self.cWidth = self.width()
self.cHeight = self.height()
self.cResizeElements()
def closeEvent(self,event):
for window in windows:
if window != self:
window.close()
writeConfigFile("lastProfile.txt",self.cProfileDropdown.currentText())
windows.remove(self)
def onFocusChanged(old,current):
if type(current) == QLineEdit:
def f():
try:
current.selectAll()
except:
pass
global markTimer
markTimer = QTimer()
markTimer.setSingleShot(True)
markTimer.timeout.connect(f)
markTimer.start(0)
windows = []
def main():
if not os.path.isdir(p(configDir,"profiles")):
os.makedirs(p(configDir,"profiles"))
if len(getProfiles()) == 0:
saveProfile(defaultProfile,"default")
app = QApplication(sys.argv)
app.focusChanged.connect(onFocusChanged)
windows.append(mainWindow())
app.exec_()
main()