#!/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()