forked from tallforasmurf/PPQT
-
Notifications
You must be signed in to change notification settings - Fork 0
/
pqPngs.py
486 lines (465 loc) · 24.4 KB
/
pqPngs.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# These imports move Python 2.x almost to Python 3.
# They must precede anything except #comments, including even the docstring
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from future_builtins import *
__version__ = "1.3.0"
__author__ = "David Cortesi"
__copyright__ = "Copyright 2011, 2012, 2013 David Cortesi"
__maintainer__ = "David Cortesi"
__email__ = "tallforasmurf@yahoo.com"
__license__ = '''
License (GPL-3.0) :
This file is part of PPQT.
PPQT is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You can find a copy of the GNU General Public License in the file
extras/COPYING.TXT included in the distribution of this program, or see:
<http://www.gnu.org/licenses/>.
'''
'''
Display the pngs to match the page being edited.
The object consists of a vertical box layout containing, above,
a QLabel widget initialized with a 700x1000 QPixmap with fill(QColor("gray"))
and enclosed in a QScrollArea. Below it, a small label to display the current
page number and folio, initialized with "No page". Below that, a
spinBox for the current zoom factor and buttons "to Width" and "to Height"
for zoom factor changes. Zooming is done by changing the size hint of the
pixmap; it scales, and the parent scrollarea scrolls.
Reimplements keyPressEvent() to respond to certain keys when the focus
is in this widget:
ctl-plus/minus zoom the image when an image exists.
Page Up/Down cause display of a different png.
N.B. there is a cryptic comment in the QPixmap doc page that "QPixmaps are
automatically added to the QPixmapCache when loaded from a file." This seems
to mean that it will avoid a second disk load when we revisit a page, and
the performance would indicate this is so.
'''
from PyQt4.QtCore import ( Qt, QFileInfo, QString, QSettings, QVariant, SIGNAL )
from PyQt4.QtGui import (
QColor, QImage, QPixmap,
QFrame, QKeyEvent, QLabel, QPalette, QPushButton,
QScrollArea, QSizePolicy, QSlider, QSpinBox,
QHBoxLayout, QVBoxLayout, QWidget)
import pqPages
class pngDisplay(QWidget):
def __init__(self, parent=None):
super(pngDisplay, self).__init__(parent)
#self.profiler = cProfile.Profile()
# create the label that displays the image - cribbing from the Image
# Viewer example in the Qt docs.
self.imLabel = QLabel()
self.imLabel.setBackgroundRole(QPalette.Base)
self.imLabel.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self.imLabel.setScaledContents(True)
# Create a gray field to use when no png is available
self.defaultPM = QPixmap(700,900)
self.defaultPM.fill(QColor("gray"))
# Create a scroll area within which to display our imLabel, this
# enables the creation of horizontal and vertical scroll bars, when
# the imLabel exceeds the size of the scroll area.
self.scarea = QScrollArea()
# The following two lines make sure that page up/dn gets through
# the scrollarea widget and up to us.
self.setFocusPolicy(Qt.ClickFocus)
self.scarea.setFocusProxy(self)
self.scarea.setBackgroundRole(QPalette.Dark)
#self.scarea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
#self.scarea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
self.scarea.setWidget(self.imLabel)
# create the text label that will have the page number in it
self.txLabel = QLabel(u"No image")
self.txLabel.setAlignment(Qt.AlignBottom | Qt.AlignHCenter)
self.txLabel.setFrameStyle(QFrame.Sunken | QFrame.StyledPanel)
# Create a spinbox to set the zoom from 15 to 200 with a label:
# (originally a slider, hence the name)
self.minZoom = 0.15
self.maxZoom = 2.00
self.zlider = QSpinBox()
self.zlider.setRange(int(100*self.minZoom),int(100*self.maxZoom))
# connect the value change signal to a slot to handle it
self.connect(self.zlider, SIGNAL("valueChanged(int)"), self.newZoomFactor)
# create the to-width and to-height zoom buttons
zoomWidthButton = QPushButton(u'to Width')
self.connect(zoomWidthButton, SIGNAL("clicked()"), self.zoomToWidth)
zoomHeightButton = QPushButton(u'to Height')
self.connect(zoomHeightButton, SIGNAL("clicked()"), self.zoomToHeight)
# Make an hbox to contain the spinbox and two pushbuttons, with
# stretch on left and right to center the group.
zlabel = QLabel(
u'&Zoom ' + str(self.zlider.minimum())
+ '-' + str(self.zlider.maximum()) + '%')
zlabel.setBuddy(self.zlider)
zhbox = QHBoxLayout()
zhbox.addStretch(1)
zhbox.addWidget(zlabel,0,Qt.AlignLeft)
zhbox.addWidget(self.zlider,0)
zhbox.addStretch(1)
zhbox.addWidget(zoomWidthButton)
zhbox.addWidget(zoomHeightButton)
zhbox.addStretch(1)
# With all the pieces in hand, create our layout basically a
# vertical stack: scroll area, label, slider box.
vbox = QVBoxLayout()
# the image gets a high stretch and default alignment, the text
# label hugs the bottom and doesn't stretch at all.
vbox.addWidget(self.txLabel,0,Qt.AlignBottom)
vbox.addWidget(self.scarea,10)
vbox.addLayout(zhbox,0)
self.setLayout(vbox)
# Initialize assuming no book is open.
self.ready = False # nothing to display
# Recover the last-set zoom factor from the settings object, default 1.0
qv = IMC.settings.value("pngs/zoomFactor",QVariant(1.0))
self.zoomFactor = qv.toFloat()[0]
# The following causes entry into newZoomFactor, below, which tests
# self.ready, hence the latter has to be assigned-to first.
self.zlider.setValue(int(self.zoomFactor*100))
self.clear()
# local subroutine to initialize our contents for an empty edit.
# called from _init_ and from newPosition when we discover the file
# has been cleared on us. Don't reset the zoomFactor, leave it as
# the user last set it.
def clear(self):
# Clear the page name, used by pqNotes
IMC.currentImageNumber = None # will be name of last png file e.g. "002"
# Clear the page filename, used in our caption label
self.lastPage = QString() # last file name e.g. "002.png"
# Clear the path to the pngs folder, used to fetch image files
self.pngPath = QString()
# Clear the index of the last-shown page in the page table
# -1 means no page is being displayed.
self.lastIndex = -1
# Clear the index of the next page to display, normally same as last
self.nextIndex = -1
# Set not-ready to indicate no pngs directory available.
self.ready = False
# Clear out & release storage of our QImage and QPixmaps
self.pixmap = QPixmap() # null pixmap
self.image = QImage()
self.noImage() # show gray image
# Display a blank gray frame and "No Image" below.
# Called from clear() above and from showPage when no valid image.
def noImage(self) :
self.imLabel.setPixmap(self.defaultPM)
self.txLabel.setText(u"No image")
self.lastIndex = -1 # didn't see a prior page
self.nextIndex = -1
# This slot gets the main window's signal shuttingDown.
# We save our current zoom factor into IMC.settings.
def shuttingDown(self):
IMC.settings.setValue("pngs/zoomFactor",QVariant(self.zoomFactor))
# This slot gets pqMain's signal docHasChanged(QString), telling
# us that a different document has been loaded. This could be for
# a successful File>Open, or a failed File>Open or File>New.
# The bookPath is a null QString for File>New, or the full bookPath.
# If the latter, we convert that into the path to the pngs folder,
# and see if bookPath/pngs is a directory. If so, we set self.ready
# to true, indicating it is worthwhile to try opening image files.
# At this point the gray image is displayed and previously would remain displayed
# until the user moved the cursor in some way, generating cursorPositionChanged.
# That's a minor annoyance, to avoid it we will fake that signal now.
def newFile(self, bookPath):
if not bookPath.isNull(): # this was successful File>Open
finf = QFileInfo(bookPath)
self.pngPath = finf.absolutePath().append(u"/pngs/")
finf = QFileInfo(self.pngPath)
if finf.exists() and finf.isDir(): # looking good
self.ready = True
self.newPosition()
else:
# We could inform the user we couldn't find a pngs folder,
# but you know -- the user is probably already aware of that.
self.clear() # just put up the gray default image
else: # It was a File>New
self.clear()
# This function is the slot that is connected to the editor's
# cursorPositionChanged signal. Its input is cursor position and
# the page table. Its output is to set self.nextIndex to the
# desired next image table row, and to call showPage.
def newPosition(self):
if self.ready :
# We have a book and some pngs. Find the position of the higher end
# of the current selection.
pos = IMC.editWidget.textCursor().selectionEnd()
# Get the page table index that matches this position, or -1
# if that is above the first psep, or there is no page data
self.nextIndex = IMC.pageTable.getIndex(pos)
else :# No file loaded or no pngs folder found.
self.nextIndex = -1
if self.nextIndex != self.lastIndex :
self.showPage()
# Display the page indexed by self.nextIndex. This is called when the cursor
# moves to a new page (newPosition, above), or when the PageUp/Dn keys are used,
# (keyPressEvent, below) or when the zoom factor changes in any of several ways.
def showPage(self):
# If self.lastIndex is different from self.nextIndex, the page has
# changed, and we need to load a new image.
if self.lastIndex != self.nextIndex :
self.lastIndex = self.nextIndex # don't come here again until it changes.
if self.lastIndex > -1 :
# Form the image filename as a Qstring, e.g. "025" and save that for
# use by pqNotes:
IMC.currentImageNumber = IMC.pageTable.getScan(self.lastIndex)
# dbg = unicode(IMC.currentImageNumber)
# Form the complete filename by appending ".png" and save as
# self.lastPage for use in forming our caption label.
self.lastPage = QString(IMC.currentImageNumber).append(QString(u".png"))
# dbg = unicode(self.lastPage)
# Form the full path to the image. Try to load it as a QImage.
pngName = QString(self.pngPath).append(self.lastPage)
self.image = QImage(pngName,'PNG')
# dbg = unicode(self.image)
# dbg = self.image.isNull()
# If that successfully loaded an image, make sure it is one byte/pixel.
if not self.image.isNull() \
and self.image.format() != QImage.Format_Indexed8 :
# It might be Format_Mono (1 bit/pixel) or even Format_RGB32.
self.image = self.image.convertToFormat(QImage.Format_Indexed8,Qt.ColorOnly)
# Convert the image to a pixmap. If it's null, so is the pixmap.
self.pixmap = QPixmap.fromImage(self.image,Qt.ColorOnly)
else :
IMC.currentImageNumber = QString(u"n.a.")
self.lastPage = QString()
self.image = QImage()
self.pixmap = QPixmap()
if not self.pixmap.isNull():
# We successfully found and loaded an image and converted it to pixmap.
# Load it in our label for display, set the zoom factor, and the caption.
# We do this every time through (even if lastIndex equalled nextIndex)
# because the zoomfactor might have changed.
self.imLabel.setPixmap(self.pixmap)
self.imLabel.resize( self.zoomFactor * self.pixmap.size() )
folio = IMC.pageTable.getDisplay(self.lastIndex)
self.txLabel.setText(u"image {0} (folio {1})".format(self.lastPage,folio))
else: # no file was loaded. It's ok if pages are missing
self.noImage() # display the gray image.
# Catch the signal from the Zoom spinbox with a new value.
# Store the new value as a float, and if we have a page, repaint it.
def newZoomFactor(self,new_value):
self.zoomFactor = new_value / 100
if self.ready :
self.showPage()
# Catch the click on zoom-to-width and zoom-to height. The job is basically
# the same for both. 1: Using the QImage that should be in self.image,
# scan the pixels to find the width (height) of the nonwhite area.
# 2. Get the ratio of that to our image label's viewport width (height).
# 4. Set that ratio as the zoom factor and redraw the image. And finally
# 5. Set the scroll position(s) of our scroll area to left-justify the text.
#
# We get access to the pixels using QImage:bits() which gives us a PyQt4
# "voidptr" that we can index to get byte values.
#
def zoomToWidth(self):
if (not self.ready) or (self.image.isNull()) :
return # nothing to do here
#self.profiler.enable() #dbg
# Query the Color look-up table and build a list of the Green values
# corresponding to each possible pixel value. Probably there are just
# two colors so colortab is [0,255] but there could be more, depending
# on how the PNG was defined, 16 or 32 or even 255 grayscale.
colortab = [ int((self.image.color(c) >> 4) & 255)
for c in range(self.image.colorCount()) ]
ncols = self.image.width() # number of logical pixels across
stride = (ncols + 3) & (-4) # number of bytes per scanline
nrows = self.image.height() # number of pixels high
vptr = self.image.bits() # uchar * bunch-o-pixel-bytes
vptr.setsize(stride * nrows) # make the pointer indexable
# Scan in from left and right to find the outermost dark spots.
# Looking for single pixels yeilds too many false positives, so we
# look for three adjacent pixels that sum to less than 32.
# Most pages start with many lines of white pixels so in hopes of
# establishing the outer edge early, we start at the middle, go to
# the end, then do the top half.
left_side = int(ncols/2) # leftmost dark spot seen so far
# scan from the middle down
for r in xrange(int(nrows/2)*stride, (nrows-1)*stride, stride) :
pa, pb = 255, 255 # virtual white outside border
for c in xrange(left_side):
pc = colortab[ ord(vptr[c + r]) ]
if (pa + pb + pc) < 32 : # black or dark gray pair
left_side = c # new, further-left, left margin
break # no need to look further on this line
pa = pb
pb = pc
# scan from the top to the middle, hopefully left_side is small now
for r in xrange(0, int(nrows/2)*stride, stride) :
pa, pb = 255, 255 # virtual white outside border
for c in xrange(left_side):
pc = colortab[ ord(vptr[c + r]) ]
if (pa + pb + pc) < 32 : # black or dark gray pair
left_side = c # new, further-left, left margin
break # no need to look further on this line
pa = pb
pb = pc
# Now do the same for the right margin.
right_side = int(ncols/2) # rightmost dark spot seen so far
for r in xrange(int(nrows/2)*stride, (nrows-1)*stride, stride) :
pa, pb = 255, 255 # virtual white outside border
for c in xrange(ncols-1,right_side,-1) :
pc = colortab[ ord(vptr[c + r]) ]
if (pa + pb + pc) < 32 : # black or dark gray pair
right_side = c # new, further-right, right margin
break
pa = pb
pb = pc
for r in xrange(0, int(nrows/2)*stride, stride) :
pa, pb = 255, 255 # virtual white outside border
for c in xrange(ncols-1,right_side,-1) :
pc = colortab[ ord(vptr[c + r]) ]
if (pa + pb + pc) < 32 : # black or dark gray pair
right_side = c # new, further-right, right margin
break
pa = pb
pb = pc
# The area with color runs from left_side to right_side. How does
# that compare to the size of our viewport? Scale to that and redraw.
#print('ls {0} rs {1} vp {2}'.format(left_side,right_side,self.scarea.viewport().width()))
text_size = right_side - left_side + 2
port_width = self.scarea.viewport().width()
self.zoomFactor = max( self.minZoom, min( self.maxZoom, port_width / text_size ) )
# the next line signals newZoomFactor, which calls showPage.
self.zlider.setValue(int(100*self.zoomFactor))
# Set the scrollbar to show the page from its left margin.
self.scarea.horizontalScrollBar().setValue(int( left_side * self.zoomFactor) )
#self.profiler.disable() #dbg
#pstats.Stats(self.profiler).print_stats() # dbg
def zoomToHeight(self):
if (not self.ready) or (self.image.isNull()) :
return # nothing to do here
# Query the Color look-up table and build a list of the Green values
# corresponding to each possible pixel value. Probably there are just
# two colors so colortab is [0,255] but there could be more, depending
# on how the PNG was defined, 16 or 32 or even 255 grayscale.
colortab = [ int((self.image.color(c) >> 4) & 255)
for c in range(self.image.colorCount()) ]
ncols = self.image.width() # number of logical pixels across
stride = (ncols + 3) & (-4) # number of bytes per scanline
nrows = self.image.height() # number of pixels high
vptr = self.image.bits() # uchar * bunch-o-pixel-bytes
vptr.setsize(stride * nrows) # make the pointer indexable
# Scan in from top and bottom to find the outermost rows with
# significant pixels.
top_side = -1 # The uppermost row with a significant spot of black
offset = 0 # vptr index to the first/next pixel row
for r in range(nrows) :
pa, pb = 255, 255 # virtual white outside border
for c in range(ncols) :
pc = colortab[ ord(vptr[offset + c]) ]
if (pa + pb + pc) < 32 : # black or dark gray triplet
top_side = r # that's the row,
break # ..so stop scanning
pa, pb = pb, pc
if top_side >= 0 : # we hit
break # ..so don't scan down any further
offset += stride # continue to next row
# top_side indexes the first row with a significant blot
if top_side == -1 : # never found one: an all-white page. bug out.
return
bottom_side = nrows # The lowest row with a significant blot
offset = stride * nrows # vptr index to last/next row of pixels
for r in range(nrows,top_side,-1) :
offset -= stride
pa, pb = 255, 255 # virtual white outside border
for c in range(ncols) :
pc = colortab[ ord(vptr[offset + c]) ]
if (pa + pb + pc) < 32 : # black or dark gray triplet
bottom_side = r
break
pa, pb = pb, pc
if bottom_side < nrows : # we hit
break
# bottom_side is the lowest row with significant pixels. It must be
# < nrows, there is at least one row (top_side) with a dot in it.
# However if the page is mostly white, don't zoom to that extent.
if bottom_side < (top_side+100) :
return # seems to be a mostly-white page, give up
# The text area runs from scanline top_side to bottom_side.
text_height = bottom_side - top_side + 1
port_height = self.scarea.viewport().height()
self.zoomFactor = max( self.minZoom, min( self.maxZoom, port_height / text_height ) )
self.zlider.setValue(int(100*self.zoomFactor)) # signals newZoomFactor->showPage
# Set the scrollbar to show the page from its top margin.
self.scarea.verticalScrollBar().setValue(int( top_side * self.zoomFactor) )
# Re-implement the parent's keyPressEvent in order to provide zoom:
# ctrl-plus increases the image size by 1.25
# ctrl-minus decreases the image size by 0.8
# Also trap pageup/dn and use to walk through images.
# At this point we do not reposition the editor to match the page viewed.
# we page up/dn but as soon as focus returns to the editor and the cursor
# moves, this display will snap back to the edited page. As a user that
# seems best, come over to Pngs and page ahead to see what's coming, then
# back to the editor to read or type.
def keyPressEvent(self, event):
# assume we will not handle this key and clear its accepted flag
event.ignore()
# If we are initialized and have displayed some page, look at the key
if self.ready:
kkey = int( int(event.modifiers()) & IMC.keypadDeModifier) | int(event.key())
if kkey in IMC.zoomKeys :
# ctl/cmd + or -, do the zoom
event.accept()
fac = (0.8) if (kkey == IMC.ctl_minus) else (1.25)
fac *= self.zoomFactor # target zoom factor
if (fac >= self.minZoom) and (fac <= self.maxZoom): # keep in bounds
self.zoomFactor = fac
self.zlider.setValue(int(100*fac))
self.showPage()
elif (event.key() == Qt.Key_PageUp) or (event.key() == Qt.Key_PageDown) :
event.accept() # real pgUp or pgDn, we do it
fac = 1 if (event.key() == Qt.Key_PageDown) else -1
fac += self.lastIndex
if (fac >= 0) and (fac < IMC.pageTable.size()) :
# not off either end of the book, so,
self.nextIndex = fac
self.showPage()
if not event.isAccepted() : # we don't do those, pass them on
super(pngDisplay, self).keyPressEvent(event)
if __name__ == "__main__":
pass
import sys
from PyQt4.QtCore import (Qt,QSettings,QFileInfo,QDir,QStringList)
from PyQt4.QtGui import (QApplication,QFileDialog,QPlainTextEdit,QTextCursor)
app = QApplication(sys.argv) # create an app
import pqIMC
IMC = pqIMC.tricorder() # set up a fake IMC for unit test
IMC.settings = QSettings()
IMC.editWidget = QPlainTextEdit()
import pqPages
pqPages.IMC = IMC
IMC.pageTable=pqPages.pagedb()
widj = pngDisplay()
widj.pngPath = QFileDialog.getExistingDirectory(widj,"Pick a Folder of Pngs",".")
widj.pngPath.append(u'/')
png_dir = QDir(widj.pngPath)
png_dir.setFilter(QDir.Files | QDir.NoSymLinks)
png_dir.setSorting(QDir.Name)
png_dir.setNameFilters(QStringList(QString(u'*.png')))
fnumber = 1
for finf in png_dir.entryInfoList():
# Read all the pngs and for each cobble up a "page" just to get
# some data into the page table
fname = finf.baseName()
IMC.editWidget.textCursor().insertText(fname)
fakemd = "{0} {1} {2} {3} {4} {5}\n".format(
IMC.editWidget.textCursor().position(),
fname, QString(u'\\foo\\foo'),
IMC.FolioRuleAdd1, IMC.FolioFormatArabic, fnumber )
IMC.pageTable.metaStringIn(fakemd)
fnumber +=1
widj.lastIndex = -1
widj.nextIndex = 0
widj.ready = True
widj.showPage()
widj.show()
#widj.zoomToWidth()
#pstats.Stats(pr).print_stats()
app.exec_()
pass