emin

Wed, 26 Feb 2020 12:48:21 +0000

author
Andy Buckley <andy@insectnation.org>
date
Wed, 26 Feb 2020 12:48:21 +0000
changeset 28
1088bb11189f
parent 26
79167c58c3a5
permissions
-rwxr-xr-x

Update to the 2020s

     1 #! /usr/bin/env python3
     2 # -*- python -*-
     4 """%prog [opts] dir [outdir=./gallery]
     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.4.0"
    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=250, 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 as e:
    90     logging.error("Couldn't import required Cheetah package")
    91     exit(1)
    92 try:
    93     import PIL.Image as PILI
    94 except Exception as e:
    95     logging.error("Couldn't import required Python Imaging Library / Pillow 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("./gallery") #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.debug("Making output dir in %s" % opts.OUTDIR)
   154         os.makedirs(opts.OUTDIR)
   155     except Exception as 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.debug("Making thumbs dir in %s" % THUMBDIR)
   166         os.makedirs(THUMBDIR)
   167 except Exception as 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 ## Process images
   216 logging.info("Processing {:d} images...".format(len(outimgs)))
   217 imgsinfo = {}
   218 for picpath in outimgs:
   219     picname = os.path.basename(picpath)
   220     picbase = os.path.splitext(picname)[0]
   222     ## Convert EPS, PDF, TIFF to Web-viewable formats
   223     picversions = {}
   224     picpathparts = os.path.splitext(picpath)
   225     picnameparts = os.path.splitext(picname)
   226     extn = picnameparts[1].lower()
   227     pictype = extn[1:].upper(); pictype = "TIFF" if pictype == "TIF" else pictype
   228     picversions[pictype] = picname
   229     convcmd = None
   230     if pictype in ["TIFF", "EPS"]:
   231         newpictype = "PNG" if pictype == "EPS" else "JPG"
   232         newextn = "." + newpictype.lower()
   233         newpicname = picname + newextn
   234         newpicpath = picpath + newextn
   235         picversions[newpictype] = newpicname
   236         logging.debug("Converting {} to {}".format(picpath, newpicpath))
   237         img = PILI.open(picpath, "r")
   238         img.save(newpicpath)
   239         picname = newpicname
   240         picpath = newpicpath
   241     elif extn in [".pdf"]:
   242         newpicname = picname + ".png"
   243         newpicpath = picpath + ".png"
   244         picversions["PNG"] = newpicname
   245         logging.debug("Converting {} to {}".format(picpath, newpicpath))
   246         try:
   247             import pdf2image, tempfile
   248             with tempfile.TemporaryDirectory() as tmppath:
   249                 pdfimgs = pdf2image.convert_from_path(picpath, output_folder=tmppath, first_page=0, last_page=1) #, fmt="png")
   250                 #pdfimgs = pdf2image.convert_from_path(picpath, output_folder=tmppath)
   251                 pdfimg = pdfimgs[0].save(newpicpath)
   252                 if len(pdfimgs) > 1:
   253                     logging.warning("Multi-page PDF {}: showing only page 1".format(picpath))
   254         except ImportError:
   255             import subprocess
   256             try:
   257                 convcmd = ["convert", "-density", "200", "-resize", "800x700", picpath, newpicpath]
   258                 subprocess.check_call(convcmd)
   259             except:
   260                 logging.error("PDF conversion failed for {}... skipping".format(picpath))
   261                 continue
   262         picname = newpicname
   263         picpath = newpicpath
   265     ## Main pic info
   266     info = ImageInfo()
   267     info.name = picname
   268     info.path = picpath
   269     info.versions = picversions
   271     ## Thumb info
   272     ## TODO: Un-hard-code PNG thumb format
   273     thumbname = picname + ".png"
   274     thumbpath = os.path.join(THUMBDIR, thumbname)
   275     info.thumbname = thumbname
   276     info.thumbpath = thumbpath
   278     ## Make thumbnail
   279     ## TODO: Be lazy!
   280     #if opts.FORCE or not os.access(thumbpath, os.R_OK) or os.stat(thumbpath).st_mtime > os.stat(pic).st_mtime:
   281     try:
   282         logging.debug("Making new thumbnail %s for %s (max height %d)" % (thumbpath, picname, opts.THUMB_HEIGHT))
   283         thumbimg = PILI.open(picpath, "r")
   284         thumbimg.thumbnail((100000000, opts.THUMB_HEIGHT), resample=PILI.ANTIALIAS)
   285         thumbimg.save(thumbpath)
   286         info.thumbsize = thumbimg.size
   287     except Exception as e:
   288         logging.warning("Problem when making thumbnail from %s... exiting" % picpath)
   289         exit(1)
   291     ## Store info
   292     imgsinfo[picname] = info
   295 #####################
   298 ## Calculate how many pages will be needed
   299 if opts.NUM_ROWS >= 1:
   300     NUM_PER_PAGE = opts.NUM_ROWS * opts.NUM_COLS
   301     NUM_PAGES = int(math.ceil( len(imgs)/float(NUM_PER_PAGE) ))
   302 else:
   303     NUM_PER_PAGE = len(imgs)
   304     NUM_PAGES = 1
   307 if NUM_PAGES > 1:
   308     logging.warn("%d gallery pages will be made. If you just want one page, use the -1 or --one-page option" % NUM_PAGES)
   311 ## TODO: Move HTML extension-setting to option parser
   312 ## (or take from template name, e.g. page.html.template -> html)
   313 opts.EXTN = "html"
   316 def getPageFilename(pagenum):
   317     if pagenum == 1:
   318         pagefile = "index.%s" % opts.EXTN
   319     else:
   320         pagefile = "index%02d.%s" % (pagenum, opts.EXTN)
   321     return pagefile
   324 def mkPageLinkStr(pagenum):
   325     "Write the linked page list"
   326     global NUM_PAGES
   327     out = ''
   328     if NUM_PAGES > 1:
   329         out += ""
   330         ## Previous
   331         prev = pagenum - 1
   332         if prev > 0:
   333             out += '<a href="%s">prev</a>' % getPageFilename(prev)
   334         else:
   335             out += 'prev'
   336         out += '&nbsp;'
   337         ## Numbers
   338         for n in range(1, NUM_PAGES+1):
   339             if n != pagenum:
   340                 out += '<a href="%s">%d</a>' % (getPageFilename(n), n)
   341             else:
   342                 out += "%d" % n
   343             out += '&nbsp;'
   344         ## Next
   345         next = pagenum + 1
   346         if next <= NUM_PAGES:
   347             out += '<a href="%s">next</a>' % getPageFilename(next)
   348         else:
   349             out += 'next'
   350     return out
   353 ## Make a zip archive
   354 ZIPFILE = "photo-album.zip"
   355 if True: #opts.WRITE_ZIPFILE:
   356     logging.debug("Making zipped picture archive")
   357     if opts.ZIPFILE is not None:
   358         ZIPFILE = opts.ZIPFILE
   359     elif opts.TITLE is not None or len(opts.TITLE) > 0:
   360         ZIPFILE = safeencode(opts.TITLE)
   361         #ZIPFILE = safename(opts.OUTDIR)
   362     if not "." in ZIPFILE or os.path.splitextn(ZIPFILE)[1] != ".zip":
   363         ZIPFILE += ".zip"
   364     ## Do the zipping
   365     if ZIPFILE:
   366         from zipfile import ZipFile
   367         zf = ZipFile(os.path.join(opts.OUTDIR, ZIPFILE), "w")
   368         for img in imgs:
   369             zf.write(img, os.path.basename(img))
   370         zf.close()
   371     else:
   372         logging.warning("No zip file made because zip filename is empty")
   375 ## Copy Lightbox stuff into ZIP
   376 #if opts.USE_JS:
   377 #    from zipfile import ZipFile
   378 #    zf = ZipFile(os.path.join(opts.OUTDIR, "lightbox.zip"), "r")
   379 #    for img in imgs:
   380 #        zf.write(img, os.path.basename(img))
   381 #    zf.close()
   384 ## Default template
   385 tmplstr = \
   386 '''<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
   387         "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
   388 <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
   389   <head>
   390     #set title = $PAGETITLE
   391     #if $NUM_PAGES > 1
   392     #set title = $title + " (page %s)" % $PAGENUM
   393     #end if
   394     <title>$title</title>
   395     <style>
   396       img { border:0; padding:10 10 0 0; }
   397       body { padding:1em; background:white; font-family:sans-serif; }
   398       h1 { font-family:sans-serif; }
   399       a.format { text-decoration:none; font-variant:small-caps; color:grey; font-size:small; }
   400       a.format:hover { color:deeppink; }
   401       a.format:active { color:deeppink; }
   402       .pagelinks { text-decoration:none; font-variant:small-caps; color:grey; margin-top:1em; margin-bottom:1em; }
   403       .pagelinks a:link { color:#22c; text-decoration:none; }
   404       .pagelinks a:hover { color:#55c; text-decoration:none; }
   405       .pagelinks a:active { color:#55c; text-decoration:none; }
   406     </style>
   407     #if $OPTS.USE_JS:
   408     <link rel="stylesheet" href="lightbox/css/lightbox.css" type="text/css" media="screen" />
   409     <script src="lightbox/js/prototype.js" type="text/javascript"></script>
   410     <script src="lightbox/js/scriptaculous.js?load=effects,builder" type="text/javascript"></script>
   411     <script src="lightbox/js/lightbox.js" type="text/javascript"></script>
   412     #end if
   413   </head>
   414   <body>
   415     <h1>$PAGETITLE</h1>
   416     #if $NUM_PAGES > 1
   417     <div class="pagelinks">Pages: $LINKSTR</div>
   418     #end if
   420     <table>
   421     <tr>
   422     #set jsrel = ''
   423     #if $OPTS.USE_JS:
   424     #set jsrel = 'rel="lightbox[emin]"'
   425     #end if
   426     #for n, thumb in enumerate($PAGEPICS)
   427       #if $n % $NUM_COLS == 0 and $n not in (0, len($PAGEPICS)-1)
   428       <tr/><tr>
   429       #end if
   430       #set info = $PICINFO[$thumb]
   431       <td style="text-align:right;">
   432         <a href="$info.relpath" $jsrel><img alt="$thumb" src="$info.relthumbpath" style="border:0;" width="$info.thumbx" height="$info.thumby" /></a><br/>
   433         #for fmt, name in $info.versions.items()
   434         <a class="format" href="$name">$fmt.lower()</a>
   435         #end for
   436       </td>
   437     #end for
   438     </tr>
   439     </table>
   441     #if $NUM_PAGES > 1
   442     <div class="pagelinks">Pages: $LINKSTR</div>
   443     #end if
   445     #if $OPTS.WRITE_ZIPFILE and $ZIPFILE:
   446     <p>All zipped up: <a href="$ZIPFILE">$ZIPFILE</a></p>
   447     #end if
   448   </body>
   449 </html>
   450 '''
   453 ## Override default template with a template file
   454 if opts.TEMPLATE is not None:
   455     logging.info("Using index template file %s" % opts.TEMPLATE)
   456     tf = open(opts.TEMPLATE, "r")
   457     tmplstr = tf.read()
   458     tf.close()
   461 ## Make each index page
   462 for n in range(NUM_PAGES):
   463     PAGENUM = n + 1
   465     ## Choose and open page file
   466     PAGEFILE = getPageFilename(PAGENUM)
   467     PAGEPATH = os.path.join(opts.OUTDIR, PAGEFILE)
   469     ## Write the title
   470     PAGETITLE = opts.TITLE
   472     ## Write the linked page list
   473     LINKSTR = mkPageLinkStr(PAGENUM)
   475     ## Work out the picture offsets for this page
   476     pics_start = n * NUM_PER_PAGE
   477     pics_end = (n+1) * NUM_PER_PAGE - 1
   478     if pics_end >= len(imgs):
   479         pics_end = len(imgs) - 1
   481     PAGEPICS = sorted(imgsinfo.keys())[pics_start: pics_end+1]
   482     PAGEPICNUMS = range(len(PAGEPICS))
   483     relthumbdir = opts.THUMBDIR
   484     #relthumbdir = os.path.relpath(THUMBDIR, OUTDIR)
   485     reloutdir = "."
   486     for k in imgsinfo.keys():
   487         imgsinfo[k].relthumbpath = os.path.normpath(os.path.join(relthumbdir, imgsinfo[k].thumbname))
   488         imgsinfo[k].relpath = os.path.normpath(os.path.join(reloutdir, imgsinfo[k].name))
   489     PICINFO = imgsinfo
   490 #     THUMBNAMES = [t.name for t in thumbsinfo.values()[pics_start : pics_end]]
   491 #     print THUMBNAMES
   492 #     THUMBPATHS = [os.path.join(relthumbdir, name) for name in THUMBNAMES]
   493 #     print THUMBPATHS
   494 #     THUMBDIMS = [t.size for t in thumbsinfo.values()[pics_start : pics_end]]
   496     logging.debug("Writing to index file %s" % PAGEPATH)
   497     f = open(PAGEPATH, "w")
   498     logging.debug("Images on page: %s" % PAGEPICS)
   499     tdict = {}
   500     tdict["NUM_PAGES"] = NUM_PAGES
   501     tdict["NUM_PER_PAGE"] = NUM_PER_PAGE
   502     tdict["NUM_COLS"] = opts.NUM_COLS
   503     tdict["PAGEPICS"] = PAGEPICS
   504     tdict["PAGENUM"] = PAGENUM
   505     tdict["PAGETITLE"] = PAGETITLE
   506     tdict["LINKSTR"] = LINKSTR
   507     tdict["PAGEPICNUMS"] = PAGEPICNUMS
   508     tdict["PICINFO"] = imgsinfo
   509 #     tdict["THUMBS"] = thumbsinfo
   510 #     tdict["THUMBNAMES"] = THUMBNAMES
   511 #     tdict["THUMBPATHS"] = THUMBPATHS
   512 #     tdict["THUMBDIMS"] = THUMBDIMS
   513     tdict["ZIPFILE"] = ZIPFILE
   514     tdict["OPTS"] = opts
   515     indexstr = Template(tmplstr, searchList=[tdict])
   516     #print indexstr
   517     f.write(str(indexstr))
   518     f.close()
   520 ## It's over. Nothing to see here.
   521 logging.debug("All done!")

mercurial