emin

Wed, 24 Apr 2013 10:49:39 +0200

author
Andy Buckley <andy@insectnation.org>
date
Wed, 24 Apr 2013 10:49:39 +0200
changeset 26
79167c58c3a5
parent 25
fb778c6b6d4b
child 28
1088bb11189f
permissions
-rwxr-xr-x

More tweaks. Tagging as 0.3.4

     1 #! /usr/bin/env python
     2 # -*- python -*-
     4 """%prog [opts] dir [outdir]
     6 emin - a static web gallery builder
     8 emin makes static Web pages for presenting lots of imagey things: photos, PDFs,
     9 graphs with thumbnails as well as links to the image/doc file proper.
    11 It's primarily intended for making Web photo galleries for the sorts of people
    12 who don't want to install some PHP monstrosity just to put their photos
    13 online. On the assumption that most people will want to tweak their gallery's
    14 appearance, the output is fully customisable using the Cheetah templating
    15 engine.
    17 Supported image formats are JPEG, PNG, GIF, TIFF, PDF and EPS, with the latter
    18 two being converted to PNG for Web display.  Image resizing, renaming and
    19 thumbnailing is supported, as is building a zip file to download the whole
    20 set. Large image sets can be split over several pages.
    22 As for the name, this is a program to make pretty simple galleries, so it's
    23 named after a pretty crappy artist. And, thankfully, e-m-i-n is not many
    24 characters to type.
    26 TODO:
    27  * Make Cheetah templating optional, or use Genshi/Jinja/Mako?
    28  * Clean up image filenames to avoid/minimise duplicate extensions
    29  * Try to validate the HTML output
    30  * Add all on one page option
    31  * Resize option
    32  * Rename option
    33  * Crop-to-thumb option
    34  * Auto-rotate by EXIF orientation
    35  * Copy Lightbox stuff into place
    36  * Allow complete rollback if any failure (or on demand?)
    38 Author: Andy Buckley, http://www.insectnation.org\
    39 """
    41 __author__ = "Andy Buckley <andy@insectnation.org>"
    42 __version__ = "0.3.4"
    45 import logging
    46 from optparse import OptionParser, OptionGroup
    47 parser = OptionParser(usage=__doc__, version=__version__)
    48 parser.add_option("-t", "--title", dest="TITLE", default=None,
    49                   help="title of this gallery")
    50 parser.add_option("--template", dest="TEMPLATE", default=None,
    51                   help="specify the template file to be used for the index pages")
    52 parser.add_option("--zipfile", dest="ZIPFILE", default=None,
    53                   help="name of zip archive file. Default is based on the title.")
    54 parser.add_option("--no-zipfile", action="store_false",
    55                   dest="WRITE_ZIPFILE", default=True,
    56                   help="disable writing out of a zipped archive of photos from this gallery")
    57 parser.add_option("-1", "--one-page", dest="ONE_PAGE", action="store_true", default=False,
    58                   help="put all thumbnails on one page (default: %default)")
    59 parser.add_option("-c", "--num-cols", "--cols", dest="NUM_COLS", default=5, type=int,
    60                   help="max number of thumbnail columns on one page (default: %default)")
    61 parser.add_option("-r", "--num-rows", "--rows", dest="NUM_ROWS", default=6, type=int,
    62                   help="max number of thumbnail rows on one page (default: %default). Set < 1 for unlimited (i.e. all on one page)")
    63 parser.add_option("--thumb-height", dest="THUMB_HEIGHT", default=150, type=int,
    64                   help="thumbnail height, in pixels (default: %default)")
    65 parser.add_option("--max-imgsize", dest="MAX_IMGSIZE", default=800, type=int,
    66                   help="max large image dimension in pixels (default: %default)")
    67 parser.add_option("--exclude", dest="EXCLUDE", default=None,
    68                   help="a regex pattern specifying image files to be excludes (default: %default)")
    69 parser.add_option("--no-js", dest="USE_JS", action="store_false", default=True,
    70                   help="disable use of funky JavaScript display stuff")
    71 parser.add_option("--force", dest="FORCE", action="store_true", default=False,
    72                   help="force creation of gallery: regen thumbnails etc.")
    73 parser.add_option("--no-table", dest="USE_TABLE", action="store_false", default=True,
    74                   help="don't use an HTML table for thumbnail presentation: just let the thumbs flow into the browser window")
    75 verbgroup = OptionGroup(parser, "Verbosity control")
    76 verbgroup.add_option("-v", "--verbose", action="store_const", const=logging.DEBUG, dest="LOGLEVEL",
    77                      default=logging.INFO, help="print debug (very verbose) messages")
    78 verbgroup.add_option("-q", "--quiet", action="store_const", const=logging.WARNING, dest="LOGLEVEL",
    79                      default=logging.INFO, help="be very quiet")
    80 parser.add_option_group(verbgroup)
    81 opts, args = parser.parse_args()
    82 logging.basicConfig(level=opts.LOGLEVEL, format="%(message)s")
    85 ## More imports
    86 import sys, os, glob, re, math, shutil, fnmatch
    87 try:
    88     from Cheetah.Template import Template
    89 except Exception, e:
    90     logging.error("Couldn't import required Cheetah package")
    91     exit(1)
    92 try:
    93     import PIL.Image as PILI
    94 except Exception, e:
    95     logging.error("Couldn't import required Python Imaging Library package")
    96     exit(1)
    99 ## Set processing/output dir
   100 if len(args) < 1 or len(args) > 2:
   101     parser.print_usage()
   102     exit(1)
   103 if len(args) >= 1:
   104     opts.SRCDIR = os.path.normpath(args[0])
   105     opts.OUTDIR = os.path.normpath(args[0])
   106 if len(args) == 2:
   107     opts.OUTDIR = os.path.normpath(args[1])
   110 ## Deal with consequences of interacting optional settings
   111 if opts.ONE_PAGE:
   112     opts.NUM_ROWS = -1
   113 # TODO: These should be options...
   114 opts.RENAME = False
   115 opts.CONVERT = False
   118 def safeencode(s):
   119     """Encode a string for use as a filename."""
   120     newstr = s.replace(" ", "-").replace(",", "").replace("/", "").replace(".", "")
   121     return newstr
   123 ## Better title autodetection
   124 if not opts.TITLE:
   125     opts.TITLE = os.path.basename(os.path.abspath(opts.SRCDIR))
   127 logging.debug("Title: %s" % opts.TITLE)
   128 logging.debug("Thumb height: %d" % opts.THUMB_HEIGHT)
   131 class ImageInfo(object):
   132     def __init__(self):
   133         self.name = None
   134         self.path = None
   135         self.thumbname = None
   136         self.thumbpath = None
   137         self.thumbx = None
   138         self.thumby = None
   140     def setsize(self, sizetuple):
   141         self.thumbx = sizetuple[0]
   142         self.thumby = sizetuple[1]
   144     def _getsize(self):
   145         return self.thumbx, self.thumby
   147     thumbsize = property(_getsize, setsize)
   150 ## Go to the gallery directory and test if it's writeable
   151 if not os.access(opts.OUTDIR, os.W_OK):
   152     try:
   153         logging.info("Making output dir in %s" % opts.OUTDIR)
   154         os.makedirs(opts.OUTDIR)
   155     except Exception, e:
   156         logging.error("Problem when making output dir %s... exiting" % opts.OUTDIR)
   157         exit(1)
   160 ## Make thumbnail directory if needed
   161 opts.THUMBDIR = "thumbs"
   162 THUMBDIR = os.path.join(opts.OUTDIR, opts.THUMBDIR)
   163 try:
   164     if not os.path.isdir(THUMBDIR):
   165         logging.info("Making thumbs dir in %s" % THUMBDIR)
   166         os.makedirs(THUMBDIR)
   167 except Exception, e:
   168     logging.error("Problem when making thumbnails dir... exiting")
   169     exit(1)
   172 ## Build the list of pictures to display
   173 # TODO: types: PNG/GIF, JPEG, PDF
   174 # TODO: match formats & store thumb filenames
   175 EXTENSIONS = \
   176     ["*.jpg", "*.jpeg"] + \
   177     ["*.png", "*.gif"]  + \
   178     ["*.tif", "*.tiff"] + \
   179     ["*.eps", "*.pdf"]
   180 imgs = []
   181 for img in os.listdir(opts.SRCDIR):
   182     for e in EXTENSIONS:
   183         if not fnmatch.fnmatch(img.lower(), e):
   184             continue
   185         if opts.EXCLUDE and re.search(opts.EXCLUDE, img):
   186             continue
   187         imgpath = os.path.join(opts.SRCDIR, img)
   188         imgs.append(imgpath)
   189         break
   191 ## Count the pictures
   192 logging.debug("Number of pictures = %d" % len(imgs))
   193 if len(imgs) == 0:
   194     logging.debug("No pictures from which to build a gallery...")
   195     exit(2)
   196 logging.debug("Images: " + str(sorted(imgs)))
   199 ## Rename/move if needed
   200 outimgs = []
   201 for n, imgpath in enumerate(imgs):
   202     imgname = os.path.basename(imgpath)
   203     imgnameparts = os.path.splitext(imgname)
   204     targetname = imgname
   205     if opts.RENAME:
   206         targetname = "%s-%03d%s" % (safename(opts.OUTDIR), n, imgnameparts[1])
   207     targetpath = os.path.join(opts.OUTDIR, targetname)
   208     outimgs.append(targetpath)
   209     if imgpath != targetpath:
   210         logging.debug("Copying %s -> %s" % (imgpath, targetpath))
   211         import shutil
   212         shutil.copy(imgpath, targetpath)
   215 ## Store some image info
   216 imgsinfo = {}
   217 for picpath in outimgs:
   218     picname = os.path.basename(picpath)
   219     picbase = os.path.splitext(picname)[0]
   221     ## Convert EPS, PDF, TIFF to Web-viewable formats
   222     picversions = {}
   223     picpathparts = os.path.splitext(picpath)
   224     picnameparts = os.path.splitext(picname)
   225     extn = picnameparts[1].lower()
   226     convcmd = None
   227     if extn in [".tif", ".tiff"]:
   228         picversions["TIFF"] = picname
   229         newpicname = picname + ".jpg"
   230         newpicpath = picpath + ".jpg"
   231         picversions["JPG"] = newpicname
   232         convcmd = ["convert", picpath, newpicpath]
   233         picname = newpicname
   234         picpath = newpicpath
   235     elif extn in [".eps", ".pdf"]:
   236         picversions[extn[1:].upper()] = picname
   237         newpicname = picname + ".png"
   238         newpicpath = picpath + ".png"
   239         picversions["PNG"] = newpicname
   240         convcmd = ["convert", "-density", "200", "-resize", "800x700", picpath, newpicpath]
   241         picname = newpicname
   242         picpath = newpicpath
   243     else:
   244         picversions[extn[1:].upper()] = picname
   246     ## Do the conversion
   247     if convcmd:
   248         try:
   249             # TODO: threading / multiprocessing for speed-up?
   250             import subprocess
   251             subprocess.check_call(convcmd)
   252         except:
   253             raise
   255     ## Main pic info
   256     info = ImageInfo()
   257     info.name = picname
   258     info.path = picpath
   259     info.versions = picversions
   261     ## Thumb info
   262     ## TODO: Un-hard-code PNG thumb format
   263     thumbname = picname + ".png"
   264     thumbpath = os.path.join(THUMBDIR, thumbname)
   265     info.thumbname = thumbname
   266     info.thumbpath = thumbpath
   268     ## Make thumbnail
   269     ## TODO: Be lazy!
   270     #if opts.FORCE or not os.access(thumbpath, os.R_OK) or os.stat(thumbpath).st_mtime > os.stat(pic).st_mtime:
   271     try:
   272         logging.debug("Making new thumbnail %s for %s (max height %d)" % \
   273                           (thumbpath, picname, opts.THUMB_HEIGHT))
   274         thumbimg = PILI.open(picpath, "r")
   275         thumbimg.thumbnail((100000000, opts.THUMB_HEIGHT), resample=PILI.ANTIALIAS)
   276         thumbimg.save(thumbpath)
   277         info.thumbsize = thumbimg.size
   278     except Exception, e:
   279         logging.warning("Problem when making thumbnail from %s... exiting" % picpath)
   280         exit(1)
   282     ## Store info
   283     imgsinfo[picname] = info
   286 #####################
   289 ## Calculate how many pages will be needed
   290 if opts.NUM_ROWS >= 1:
   291     NUM_PER_PAGE = opts.NUM_ROWS * opts.NUM_COLS
   292     NUM_PAGES = int(math.ceil( len(imgs)/float(NUM_PER_PAGE) ))
   293 else:
   294     NUM_PER_PAGE = len(imgs)
   295     NUM_PAGES = 1
   298 if NUM_PAGES > 1:
   299     logging.warn("%d gallery pages will be made. If you just want one page, use the -1 or --one-page option" % NUM_PAGES)
   302 ## TODO: Move HTML extension-setting to option parser
   303 ## (or take from template name, e.g. page.html.template -> html)
   304 opts.EXTN = "html"
   307 def getPageFilename(pagenum):
   308     if pagenum == 1:
   309         pagefile = "index.%s" % opts.EXTN
   310     else:
   311         pagefile = "index%02d.%s" % (pagenum, opts.EXTN)
   312     return pagefile
   315 def mkPageLinkStr(pagenum):
   316     "Write the linked page list"
   317     global NUM_PAGES
   318     out = ''
   319     if NUM_PAGES > 1:
   320         out += ""
   321         ## Previous
   322         prev = pagenum - 1
   323         if prev > 0:
   324             out += '<a href="%s">prev</a>' % getPageFilename(prev)
   325         else:
   326             out += 'prev'
   327         out += '&nbsp;'
   328         ## Numbers
   329         for n in range(1, NUM_PAGES+1):
   330             if n != pagenum:
   331                 out += '<a href="%s">%d</a>' % (getPageFilename(n), n)
   332             else:
   333                 out += "%d" % n
   334             out += '&nbsp;'
   335         ## Next
   336         next = pagenum + 1
   337         if next <= NUM_PAGES:
   338             out += '<a href="%s">next</a>' % getPageFilename(next)
   339         else:
   340             out += 'next'
   341     return out
   344 ## Make a zip archive
   345 ZIPFILE = "photo-album.zip"
   346 if True: #opts.WRITE_ZIPFILE:
   347     logging.debug("Making zipped picture archive")
   348     if opts.ZIPFILE is not None:
   349         ZIPFILE = opts.ZIPFILE
   350     elif opts.TITLE is not None or len(opts.TITLE) > 0:
   351         ZIPFILE = safeencode(opts.TITLE)
   352         #ZIPFILE = safename(opts.OUTDIR)
   353     if not "." in ZIPFILE or os.path.splitextn(ZIPFILE)[1] != ".zip":
   354         ZIPFILE += ".zip"
   355     ## Do the zipping
   356     if ZIPFILE:
   357         from zipfile import ZipFile
   358         zf = ZipFile(os.path.join(opts.OUTDIR, ZIPFILE), "w")
   359         for img in imgs:
   360             zf.write(img, os.path.basename(img))
   361         zf.close()
   362     else:
   363         logging.warning("No zip file made because zip filename is empty")
   366 ## Copy Lightbox stuff into ZIP
   367 #if opts.USE_JS:
   368 #    from zipfile import ZipFile
   369 #    zf = ZipFile(os.path.join(opts.OUTDIR, "lightbox.zip"), "r")
   370 #    for img in imgs:
   371 #        zf.write(img, os.path.basename(img))
   372 #    zf.close()
   375 ## Default template
   376 tmplstr = \
   377 '''<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
   378         "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
   379 <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
   380   <head>
   381     #set title = $PAGETITLE
   382     #if $NUM_PAGES > 1
   383     #set title = $title + " (page %s)" % $PAGENUM
   384     #end if
   385     <title>$title</title>
   386     <style>
   387       img { border:0; padding:10 10 0 0; }
   388       body { padding:1em; background:white; font-family:sans-serif; }
   389       h1 { font-family:sans-serif; }
   390       a.format { text-decoration:none; font-variant:small-caps; color:grey; font-size:small; }
   391       a.format:hover { color:deeppink; }
   392       a.format:active { color:deeppink; }
   393       .pagelinks { text-decoration:none; font-variant:small-caps; color:grey; margin-top:1em; margin-bottom:1em; }
   394       .pagelinks a:link { color:#22c; text-decoration:none; }
   395       .pagelinks a:hover { color:#55c; text-decoration:none; }
   396       .pagelinks a:active { color:#55c; text-decoration:none; }
   397     </style>
   398     #if $OPTS.USE_JS:
   399     <link rel="stylesheet" href="lightbox/css/lightbox.css" type="text/css" media="screen" />
   400     <script src="lightbox/js/prototype.js" type="text/javascript"></script>
   401     <script src="lightbox/js/scriptaculous.js?load=effects,builder" type="text/javascript"></script>
   402     <script src="lightbox/js/lightbox.js" type="text/javascript"></script>
   403     #end if
   404   </head>
   405   <body>
   406     <h1>$PAGETITLE</h1>
   407     #if $NUM_PAGES > 1
   408     <div class="pagelinks">Pages: $LINKSTR</div>
   409     #end if
   411     <table>
   412     <tr>
   413     #set jsrel = ''
   414     #if $OPTS.USE_JS:
   415     #set jsrel = 'rel="lightbox[emin]"'
   416     #end if
   417     #for n, thumb in enumerate($PAGEPICS)
   418       #if $n % $NUM_COLS == 0 and $n not in (0, len($PAGEPICS)-1)
   419       <tr/><tr>
   420       #end if
   421       #set info = $PICINFO[$thumb]
   422       <td>
   423         <a href="$info.relpath" $jsrel><img alt="$thumb" src="$info.relthumbpath" style="border:0;" width="$info.thumbx" height="$info.thumby" /></a><br/>
   424         #for fmt, name in $info.versions.iteritems()
   425         <a class="format" href="$name">$fmt.lower()</a>
   426         #end for
   427       </td>
   428     #end for
   429     </tr>
   430     </table>
   432     #if $NUM_PAGES > 1
   433     <div class="pagelinks">Pages: $LINKSTR</div>
   434     #end if
   436     #if $OPTS.WRITE_ZIPFILE and $ZIPFILE:
   437     <p>All zipped up: <a href="$ZIPFILE">$ZIPFILE</a></p>
   438     #end if
   439   </body>
   440 </html>
   441 '''
   444 ## Override default template with a template file
   445 if opts.TEMPLATE is not None:
   446     logging.info("Using index template file %s" % opts.TEMPLATE)
   447     tf = open(opts.TEMPLATE, "r")
   448     tmplstr = tf.read()
   449     tf.close()
   452 ## Make each index page
   453 for n in range(NUM_PAGES):
   454     PAGENUM = n + 1
   456     ## Choose and open page file
   457     PAGEFILE = getPageFilename(PAGENUM)
   458     PAGEPATH = os.path.join(opts.OUTDIR, PAGEFILE)
   460     ## Write the title
   461     PAGETITLE = opts.TITLE
   463     ## Write the linked page list
   464     LINKSTR = mkPageLinkStr(PAGENUM)
   466     ## Work out the picture offsets for this page
   467     pics_start = n * NUM_PER_PAGE
   468     pics_end = (n+1) * NUM_PER_PAGE - 1
   469     if pics_end >= len(imgs):
   470         pics_end = len(imgs) - 1
   472     PAGEPICS = sorted(imgsinfo.keys())[pics_start: pics_end+1]
   473     PAGEPICNUMS = range(len(PAGEPICS))
   474     relthumbdir = opts.THUMBDIR
   475     #relthumbdir = os.path.relpath(THUMBDIR, OUTDIR)
   476     reloutdir = "."
   477     for k in imgsinfo.keys():
   478         imgsinfo[k].relthumbpath = os.path.normpath(os.path.join(relthumbdir, imgsinfo[k].thumbname))
   479         imgsinfo[k].relpath = os.path.normpath(os.path.join(reloutdir, imgsinfo[k].name))
   480     PICINFO = imgsinfo
   481 #     THUMBNAMES = [t.name for t in thumbsinfo.values()[pics_start : pics_end]]
   482 #     print THUMBNAMES
   483 #     THUMBPATHS = [os.path.join(relthumbdir, name) for name in THUMBNAMES]
   484 #     print THUMBPATHS
   485 #     THUMBDIMS = [t.size for t in thumbsinfo.values()[pics_start : pics_end]]
   487     logging.info("Writing to index file %s" % PAGEPATH)
   488     f = open(PAGEPATH, "w")
   489     logging.debug("Images on page: %s" % PAGEPICS)
   490     tdict = {}
   491     tdict["NUM_PAGES"] = NUM_PAGES
   492     tdict["NUM_PER_PAGE"] = NUM_PER_PAGE
   493     tdict["NUM_COLS"] = opts.NUM_COLS
   494     tdict["PAGEPICS"] = PAGEPICS
   495     tdict["PAGENUM"] = PAGENUM
   496     tdict["PAGETITLE"] = PAGETITLE
   497     tdict["LINKSTR"] = LINKSTR
   498     tdict["PAGEPICNUMS"] = PAGEPICNUMS
   499     tdict["PICINFO"] = imgsinfo
   500 #     tdict["THUMBS"] = thumbsinfo
   501 #     tdict["THUMBNAMES"] = THUMBNAMES
   502 #     tdict["THUMBPATHS"] = THUMBPATHS
   503 #     tdict["THUMBDIMS"] = THUMBDIMS
   504     tdict["ZIPFILE"] = ZIPFILE
   505     tdict["OPTS"] = opts
   506     indexstr = Template(tmplstr, searchList=[tdict])
   507     #print indexstr
   508     f.write(str(indexstr))
   509     f.close()
   511 ## It's over. Nothing to see here.
   512 logging.debug("All done!")

mercurial