File: thumbspage/thumbspage.py

#!/usr/bin/env python3
"""
===========================================================================
thumbspage.py - Turn Folders into HTML Image Galleries

Version:  2.0, July 18, 2020.
Web page: https://www.learning-python.com/thumbspage.html
Examples: See learning-python.com/trnpix/, and examples/ here.
License:  Provided freely but with no warranties of any kind.
Author:   © M. Lutz (learning-python.com), 2016-2020.

Synopsis: Make an HTML thumbnail-links page plus HTML image-viewer 
          pages for all the images in a source folder.  The static 
          results can be viewed offline or online in any browser.

Requires: Any Python 3.X, plus the Pillow (PIL) image library 
          available at https://pypi.python.org/pypi/Pillow.

Runs on:  Any platform supporting both Python 3.X and Pillow (e.g.,
          Mac OS, Windows, Linux, and Android).  Generated pages 
          can be viewed in any desktop or mobile web browser.  

Usage:    Run thumbspage.py, input parameters in the console.
          A sole optional argument can give the subject folder,
          and canned input lines can be piped in from files :

              $ python3 thumbspage.py
              ...all parameters prompted from stdin...

              $ python3 thumbspage.py folder
              ...all other parameters prompted from stdin...

              $ python3 thumbspage.py < inputs.txt
              ...all parameters taken from lines in file...
 
          Results are created in the images source folder.
          See also USAGE EXAMPLE and CUSTOMIZATION ahead, and
          the more complete documentation in UserGuide.html.

*CAUTION*: by design, this program will modify your images source 
folder in-place.  It adds an HTML index-page file (by default named
"index.html") and a subfolder with thumbnails and HTML viewer pages 
(by default named "_thumbspage"), and as preconfigured rotates any 
tilted images after saving backup copies of their originals with  
".original" extensions.  Run this program on folder copies if you
don't want it to change your valued photo collections directly,
and consult UserGuide.html for full program-usage details.

===========================================================================
OVERVIEW
===========================================================================

Given a folder of image files, this script generates an HTML index
page with thumbnail links for each image in the folder.  This page's
links in turn open either generated HTML viewer pages with navigation
links, or the full-size images directly using browser-native display.

The net effect is intentionally static: generated results reflect 
the folder's contents at build time only, but do not require a web 
server, and can be viewed both offline and online in any desktop
or mobile browser.  As such, this script can be used both for
websites and non-web use cases such as program documentation.

When run, this script skips non-image files; uses optional header 
and footer HTML index inserts; makes an automatic bullet list for 
subfolders in the images folder (not named with a leading "_" or "."); 
and creates the output index page in the images folder itself, along
with a subfolder for thumbnail images and viewer pages' HTML.  

After a run:
 - To view results, open the output index page created in your 
   images folder (it's named "index.html" by default).

 - To publish results, copy the entire images folder, including its 
   generated thumbs subfolder and index file (named "_thumbspage" 
   and "index.html" by default, respectively).  

 - To publish results to a remote website, upload the entire images 
   folder to the folder representing your page on your site's 
   web-server host; zip first for convenience.

===========================================================================
VERSIONS
===========================================================================

Recent version highlights:

- (Jul-2020) As of 2.0:
  + Viewer-page toolbars gain an Auto that toggles an automatic 
    slideshow; the former Raw is gone, but subsumed by 1.7's image taps
  + Viewer-page toolbars gain an optional Full that toggles a one-page
    fullscreen display; this is limited and may be disabled per gallery
  + Index pages have an optional floating Top button that jumps
    to page top when present, and appears only after downscrolls
  + Improved styling handles horizontal overflow of page viewports
    in viewer-page toolbars (via scrolls) and default index-page 
    folder names (via wraps); viewer-page filenames already scrolled
  + Info popups add Device, and use a custom dialog instead of alert()

  For the full story on all 2.0 upgrades, see UserGuide.html#2.0.  For
  screenshots of its visual changes, see examples/2.0-upgrades/index.html.

- (Feb~Jun-2020) As of 1.7, viewer pages now accommodate large-font user 
  settings without clipping the bottom of image displays, by calculating
  actual used space; and avoid toolbar button run-together for very 
  large fonts, by dropping the former small-screen font scale up.  
  As a bonus, both of these fixes also make the image display larger.

  Version 1.7 viewer pages also now:
  + Respond to filename clicks/taps, opening a simple image-info 
    dialog with details mostly captured at page-generation time 
  + Respond to image-display clicks/taps, opening the full image 
    in the browser just like the Raw button, for convenience
  + Include version number and page-generation date in comments
    (these are also now noted in index pages too)
  + Uses JavaScript scaling for iOS landscape, because it beats the
    former CSS scheme, and iOS 13's hide-toolbars option fixes Safari

  For the full story on all 1.7 upgrades, see UserGuide.html#1.7.  For
  screenshots of its visual changes, see examples/1.7-upgrades/index.html.

- (Oct-2018) As of 1.6, JavaScript is now used to dynamically scale 
  viewer-page images to page size, without changing aspect ratio 
  or overflowing pages; and tilted images are automatically rotated 
  to display right-side up, with originals saved as backups.

- (Aug-2018) As of 1.5, formatted image-viewer pages with next/previous 
  links can also be generated; when omitted, index links open images per 
  browsers as before.

- (Mar-2018) As of 1.4, all output pages are more mobile-friendly,
  and have improved styling. 

- (Aug-2016) As of 1.3, non-ASCII Unicode filenames and content are 
  fully supported. 

===========================================================================
USAGE EXAMPLE
===========================================================================

thumbspage is run from a command line (e.g., Terminal on Mac OS and
Linux, Command Prompt on Windows, Termux on Android).  Its main options 
are chosen with console replies or their enter-key defaults on each run 
(as of 1.7, images folder path may instead be a command-line argument):

   /.../test$ python3 $CODE/thumbspage/thumbspage.py 
   Images folder path [. or dir] (enter=.)? trnpix
   Clean thumbs folder [y or n] (enter=y)? y
   Thumbs per row [int] (enter=4)? 
   Thumb max size [x, y] (enter=(100, 100))? 
   Use image-viewer pages [y or n] (enter=y)? y
   Running
   Cleaning: trnpix/_thumbspage/1996-first-pybook.png
   Cleaning: trnpix/_thumbspage/1996-first-pybook.png.html
   ...etc...
   Skipping: .DS_Store
   Making thumbnail: trnpix/_thumbspage/1996-first-pybook.png
   Making thumbnail: trnpix/_thumbspage/1998-puertorico-1.jpg
   ...etc...
   Skipping: _cut
   Skipping: _HOW.txt
   ...etc...
   Generating thumbnails index page
   Generating view page for: 1996-first-pybook.png
   Generating view page for: 1998-puertorico-1.jpg
   ...etc...
   Finished: see the results in the images folder.

You should generally clean the thumbs folder (reply #2) unless 
images have only been added, and use viewer pages (reply #5).
Replies #3 and #4 allow you to tailor the index-page thumbs;
thumbs size should generally be square (x=y).  Reply #1 accepts 
an absolute or relative folder pathname ("." means current dir);
this is the source-image folder, where results will also appear.

===========================================================================
CUSTOMIZATION
===========================================================================

The most common thumbspage options are available as console inputs
on each run; see the preceding section USAGE EXAMPLE.

Additional customizations are available as Python settings in file
"user_configs.py".  See that file's comments for more on its options.
As examples, that file defines the names of the generated index page 
and thumbs folder; as of 1.5, it configures most colors; as of 1.6, 
it allows images to expand beyond actual sizes, and allows users 
to control image auto-rotation; and 1.7 and 2.0 add more options.

For more custom behavior, add unique HTML code to the top and bottom
of the index page by placing it in files in the images folder named
"HEADER.html" and "FOOTER.html", respectively.  Both are optional;
if these files are not present, generic HTML and text is generated 
in the index page around the thumbs table.  

For details on coding these files, see UserGuide.html#Customization,
as well as the examples in the examples/ folder.  In brief:

HEADER.html 
   should be a full HTML preamble, followed by the start of <body> 

FOOTER.html 
   should add any post-table content and close both <body> and <html>

The HEADER.html file also allows index-page fonts to be tailored 
with CSS code; see the docstring in "user_configs.py," as well as
the online demo site learning-python.com/site-mobile-screenshots/.

As of 1.6, viewer pages can also be changed arbitrarily by editing
the template file "template-viewpage.html" in this script's folder.
For example, such edits might add site icons or navigation widgets. 
Edit with care and rerun this script to use your customized template.

As of 2.0, the Top button generated for index pages can be tailored
both with "user_configs.py" settings, and arbitrary edits to template
file "template-floatingtop.html" in this script's folder.  A custom
FOOTER.html may need to provide space below final content for TOP.

[== See UserGuide.html for additional docs originally located here ==]

===========================================================================
"""

VERSION = '2.0'   # reported in both index and viewer pages


#
# Library tools
#

import os, sys, glob, re                    # [1.5] re for input tests
import html, cgi, urllib.parse              # [1.3] text escapes
import time                                 # [1.7] page-generation date/time

if sys.version[0] == 2: input = raw_input   # 2.X compatibility (now unused!)

from viewer_thumbs import makeThumbs        # courtesy of the book PP4E
from viewer_thumbs import isImageFileName   # courtesy of standalone PyPhoto
from viewer_thumbs import imageWideHigh     # courtesy of Pillow/PIL

from viewer_thumbs import openImageSafely, getExifTags    # [1.7] date-taken fetch



#==========================================================================
# LIBRARY HELPERS (and usage docs)
#==========================================================================



def html_escape(text, **options):
    """
    -----------------------------------------------------------------------
    HTML escapes - for text inserted in HTML code
 
    [1.5] Both the HTML and CGI escaping functions take an additional
    'quote' argument which defaults to True for html.escape, but False 
    for cgi.escape; a True is needed iff the result is embedded in a 
    quoted HTML attribute (e.g., <tag attr="%s">, but not <tag>%s</tag>).

    [1.3] cgi.escape is subsumed by html.escape which was new in 3.2.
    Both escape HTML-syntax text: 'good>&day' => 'good&gt;&amp;day'.
    -----------------------------------------------------------------------
    """
    escaper = html.escape if hasattr(html, 'escape') else cgi.escape
    return escaper(text, **options)



def url_escape(link):
    """
    -----------------------------------------------------------------------
    URL escapes - for the text of inserted links
 
    [1.5] Always use UTF-8 here, not outputEncoding, per the following.
 
    [1.3]: The 'encoding' here is used only to preencode to bytes before 
    applying escape replacements.  The returned URL is an ASCII str string 
    with '%xx' escapes; it's a URL-escaped format of Unicode-encoded text. 

    How the resulting URL link is interpreted depends on the agent that 
    unescapes it later, but general UTF-8 handles encoding of arbitrary 
    content, and its unescaped (but still encoded) bytes are recognized 
    everywhere that this script's results have been tested. 

    Subtly, the encoding used for the whole enclosing HTML page's content 
    and declared in its <meta> tag (this script's 'outputEncoding') has 
    nothing to do with the encoding used for embedded and escaped URL links
    (e.g., the HTML/URL encodings pair UTF-16/UTF-16 fails in browsers 
    tested, but UTF-16/UTF-8 works correctly).

    In fact, UTF-8 appears to be required for URLs per standards docs,
    which makes urllib's alternative encoding option seem a bit dubious:
        https://tools.ietf.org/html/rfc3986#section-2.5 (older)
        https://tools.ietf.org/html/rfc3987#section-6.4 (newer)

    Tool examples:
    >>> ord('☞'), hex(ord('☞'))                   # code points
    (9758, '0x261e')
    >>> '☞'.encode('utf8'), '☞'.encode('utf16')   # encoded bytes
    (b'\xe2\x98\x9e', b'\xff\xfe\x1e&')

    >>> from urllib.parse import quote            
    >>> quote('http://a+b&c☞', encoding='utf8')   # encode + escape
    'http%3A//a%2Bb%26c%E2%98%9E'
    >>> quote('http://a+b&c☞', encoding='utf16')
    '%FF%FEh%00t%00t%00p%00%3A%00/%00/%00a%00%2B%00b%00%26%00c%00%1E%26'

    Other ideas: it's possible to skip urllib's Unicode encoding step by 
    calling its quote_from_bytes(), but this just passes the buck - it 
    requires a manually encoded bytes.  URLs might also be embedded in 
    HTML pages using the whole-page Unicode encoding, with only HTML 
    escapes, or with no escapes (e.g., url_escape = lambda link: link,
    or url_escape = lambda link: html_escape(link, quote=True)); this 
    almost works for UTF-16, but some pathological filename links fail.
    -----------------------------------------------------------------------
    """
    return urllib.parse.quote(link, encoding='UTF-8')



#==========================================================================
# CONFIGURE, PART 1: manual settings for rarely changed options
#==========================================================================



# Now a separate module for easier access [1.6]

from user_configs import (
    THUMBS,                 # built subfolder name (thumbs+viewers)
    INDEX,                  # built index-page name ('default'?, 'home'?)

    listSubfolders,         # auto folder list? (or via header)
    uniformColumns,         # same-width columns? (else content)
    #spanFullWindow,        # stretch table to window's width? (now always [1.5])

    useViewPort,            # add mobile-friendly viewport? [1.4]
    caseSensOrder,          # index/nav order case sensitive? [1.5]

    thumbsBgColor,          # thumbs page table background color (was 'white') [1.5]
    thumbsFgColor,          # thumbs page table foreground color (text) [1.6]
    thumbsBorderColor,      # index-page thumbnail border color [1.6]

    viewerBgColor,          # viewer pages background color [1.5]
    viewerFgColor,          # viewer pages foreground color (text) [1.6]
    viewerJSColor,          # no-JavaScript note text color [1.6]
    viewerBorderColor,      # viewer-page image border color (=Fg?) [1.6]

    expandSmallImages,      # stretch beyond actual size on viewer pages? [1.6]

    insertEncoding,         # Unicode: header/footer loads
    outputEncoding,         # all generated pages 
    templateEncoding,       # viewer template load [1.6]

    chromeiOSBackFixed,     # stop disabling viewer-page history destacking? [1.6]

    autoRotateImages,       # rotate images+thumbnails to display right-side up? [1.6]
    backupRotatedImages,    # copy rotated source images to ".original" backups? [1.6]

    noiOSIndexTextBoost,    # disable index-page text upscale in iOS Safari landscape? [1.7]
    iOSSafariLandscapeCSS,  # i+S+L uses legacy CSS display instead of JS scaling? [1.7]

    autoSlideShowDelayMS,   # milliseconds between pages in slideshows [2.0]

    floatingTopEnabled,     # emit code for floating Top? [2.0]
    floatingTopAppearAt,    # show Top when scroll to this pixel offset+ [2.0]
    floatingTopSpaceBelow,  # Top's pixel offset from page bottom [2.0]
    
    floatingTopFgColor,     # Top's foreground color [2.0]
    floatingTopBgColor,     # Top's background color [2.0]

    showFullscreenButton    # show limited Full button on viewer pages? [2.0]
)



#==========================================================================
# CONFIGURE, PART 2: console inputs for per-run options, enter=default
#==========================================================================



#--------------------------------------------------------------------------
# Tools [1.5] ([1.7] reworked to simplify defaults and make bool's variable)
#--------------------------------------------------------------------------

# [2.0] show user-friendly messages on errors instead of exception traces


def exit(message, bad=1):
    print('**%s: %s' % 
         (message, ('please try again' if bad else 'run cancelled')), end='\n\n')
    sys.exit(bad)


def ask(prompt, hint, default):
    try:
        reply = input('%s [%s] (enter=%s)? ' % (prompt, hint, default))
        return reply or default
    except (EOFError, KeyboardInterrupt):
        print()
        exit('Input ended', 0)


def askbool(prompt, default='n'):
    reply = ask(prompt, 'y or n', default).lower()
    try:
        assert reply in ['y', 'n', 'yes', 'no']
        return reply in ['y', 'yes']
    except AssertionError: 
        exit('Invalid yes/no reply')


def askint(prompt, default):
    reply = ask(prompt, 'int', default)
    try:
        return int(reply)
    except ValueError:
        exit('Invalid integer reply')


def askeval(prompt, hint, default, require=None):
    reply = ask(prompt, hint, default)
    if require: 
        # else eval() mildly dangerous [1.5]
        try:
            assert re.match(require, reply)
        except AssertionError:
            exit('Invalid input form "%s"' % reply)
    try:
        return eval(reply)
    except:
        exit('Invalid input value "%s"' % reply)    # 'require' may prevent



#--------------------------------------------------------------------------
# Inputs (nit: could be more user friendly on errors; [2.0]: now it is)
#--------------------------------------------------------------------------

# str => images folder path: images, header/footer, output; [1.7] now first - main!
if len(sys.argv) == 2:
    imageDir = sys.argv[1]    # [1.7] allow as arg, for shell auto-complete
else:
    imageDir = ask('Images folder path', '. or dir', '.')    # default is cwd

# don't make a thumbs folder if input dir bad [1.5]
if not os.path.isdir(imageDir):
    exit('Invalid image folder')    # report asap [2.0]


# y or n => remove any existing thumb files?
cleanFirst = askbool('Clean thumbs folder', 'y')    # [1.7 default is now 'y'


# int => fixed row size, irrespective of window
thumbsPerRow = askint('Thumbs per row', '4')        # 5->4 so less scrolling [1.4]


# (int, int) => _max_ (x, y) pixels limit, preserving original aspect ratio
require = '\(?[0-9]+\s*,\s*[0-9]+\)?'   # 2-tuple of ints, parens optional
thumbMaxSize = askeval('Thumb max size', 'x, y', '(100, 100)', require)


# y or n => create image viewer pages? [1.5]
useViewerPages = askbool('Use image-viewer pages', 'y')    # [1.7] default is now 'y'



#--------------------------------------------------------------------------
# Calcs
#--------------------------------------------------------------------------

# the output page created in the images folder
indexPath = os.path.join(imageDir, INDEX + '.html')


# optional inserts in images folder, else generic text
headerPath = os.path.join(imageDir, 'HEADER.html')    
footerPath = os.path.join(imageDir, 'FOOTER.html')



#==========================================================================
# MAKE THUMBNAILS in image-folder subdir (via viewer_thumbs.py)
#==========================================================================



def makeThumbnails(imageDir):
    """
    -----------------------------------------------------------
    Reuse the (now modified) thumbnail generator from PP4E.
    Its [(imgname, thumbobj)] return value is unused here: 
    os.listdir() is run later to collect thumb names, and
    thumb objects are not used (building pages, not GUIs).
    -----------------------------------------------------------
    """
    if cleanFirst:
        # this cleans viewer pages too [1.5]
        for thumbpath in glob.glob(os.path.join(imageDir, THUMBS, '*')):
            print('Cleaning: %s' % thumbpath)
            try:
                os.remove(thumbpath)
            except:
                # ignore subfolder, locked file, etc. [1.6]
                print('**Cannot remove %s: skipped' % thumbpath)

    makeThumbs(imageDir,                     # create thumb images in subfolder
               size=thumbMaxSize,            # per user's replies and configs
               subdir=THUMBS, 
               rotate=autoRotateImages,
               backups=backupRotatedImages)



#==========================================================================
# GENERATE full index web page in images folder
#==========================================================================



#--------------------------------------------------------------------------
# Content defs: HTML/CSS constants and templates
#--------------------------------------------------------------------------


# [1.5] work around vanishing <hr> bug on desktop Chrome at zoom <= 90% 
styleTableThumbs = """                        /* not used: table-layout:fixed; */
    background-color: %s;                     /* for fun (former 'white') */
    width: 100%%;                             /* expand table+borderlines */
    margin-top: 5px; margin-bottom: 5px;      /* above/below borderlines */
    padding-top: 25px; padding-bottom: 5px;   /* under/over borderlines  */
    border-top: 1px solid black;              /* manual lines, _not_ <hr>s: */ 
    border-bottom: 1px solid black;           /* chrome botches <hr> at zoom <= 90%% */
""" % thumbsBgColor                            # configurable, default=light grey


# [1.6] use thin (not 1px) so Chrome still draws at zoom <= 90%, allow color config 
styleImgThumbs = 'border: thin solid %s;' % thumbsBorderColor   # was 1px, html border=1


# opening doctype for uniform layout and browser nags
doctype = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">'


# generate UTF-8 content tag for non-ASCII use cases
contype = '<!--unicode-->\n  ' \
          '<meta http-equiv="content-type" content="text/html; charset=%s">'
contype = contype % outputEncoding


# [1.4]: be mobile friendly
viewmeta = '<!--mobile-->\n  ' \
           '<meta name="viewport" content="width=device-width, initial-scale=1.0">'


# [1.5]: default-header font for index pages (same in viewer pages)
indexfont = '<!--fonts-->\n  ' \
            '<style>body {font-family: Arial, Helvetica, sans-serif;}</style>'


# [1.5]: what made this page? (for readers of page code), late in page due to custom headers
# [1.7]: expand to include version # and page-generation date, for auditing and version ctrl
createdby = '\n<!-- Generated %s, by thumbspage %s: learning-python.com/thumbspage.html -->\n'


# [1.7]: don't upscale (boost) text size in landscape on iOS Safari
noiostextboost = (
'<!--safari ios: no landscape text boost-->\n  '
'<style>'
'@media screen and (max-device-width: 640px) {'
    'html {-webkit-text-size-adjust: 100%;}'
'}</style>')


# [2.0] add a comment that scripts can search for to insert <head> code
headinsertkey = '<!-- Plus analytics code, custom styles, etc. (replace me) -->'


# [2.0] wrap long <h1> filenames in default indexes to avoid viewport overflow 
wrappedstyle = 'style="overflow-wrap: break-word;"'



#--------------------------------------------------------------------------
# Back to Python code (mostly)
#--------------------------------------------------------------------------



def orderedListing(dirpath, casesensitive=caseSensOrder):
    """
    ----------------------------------------------------------------
    [1.5] A platform- and filesystem-neutral directory listing,
    which is case sensitive by default.  Called out here because 
    order must agree between index and view (navigation) pages.
    Uppercase matters by default: assumed to be more important.

    The os.listdir() order matters only on the build machine, 
    not server (pages built here are static), but varies widely:
    on Mac OS, HFS is case-insensitive, but APFS is nearly random.
    The difference, on APFS ("ls" yields the second of these):

    >>> os.listdir('.')
    ['LA2.png', 'NYC.png', 'la1.png', '2018-x.png', 'nyc-more.png']
    >>> sorted(os.listdir('.'))
    ['2018-x.png', 'LA2.png', 'NYC.png', 'la1.png', 'nyc-more.png']
    >>> sorted(os.listdir('.'), key=str.lower)
    ['2018-x.png', 'la1.png', 'LA2.png', 'nyc-more.png', 'NYC.png']
    ----------------------------------------------------------------
    """
    if casesensitive:
        return sorted(os.listdir(dirpath))
    else:
        return sorted(os.listdir(dirpath), key=str.lower)



def formatImageLinks(imageDir, styleImgThumbs):
    """
    ----------------------------------------------------------------
    Format index-page links text for each thumb.  When a thumb is
    clicked, open either the raw image or a 1.5 view/navigate page.
    ----------------------------------------------------------------
    """
    imglinks = []
    for thumbname in orderedListing(os.path.join(imageDir, THUMBS)):
        if not isImageFileName(thumbname):
            # skip prior viewer-page files if not cleaned [1.5]
            continue    

        if not useViewerPages:
            # click opens raw image in . (1.4)
            target = url_escape(thumbname)
        else:
            # click opens viewer page in thumbs/ [1.5]
            viewpage = thumbname + '.html'
            target = url_escape(THUMBS + '/' + viewpage)

        # index page uses image in thumbs/
        source = url_escape(THUMBS + '/' + thumbname)

        link = ('<A href="%s">\n\t<img src="%s" style="%s"></A>' %
                      (target, source, styleImgThumbs)) 
                  
        imglinks.append((html_escape(thumbname), link))   # use Unix / for web!
    return imglinks



def formatSubfolderLinks(imageDir):
    """
    ----------------------------------------------------------------
    Format index-page links text for any and all subfolders in the 
    images folder.  On link click, open folder or its index.html.
    [1.7] added '.*' to '_*' skip test, to skip Unix hidden dirs.
    ----------------------------------------------------------------
    """
    sublinks = []
    for item in orderedListing(imageDir):                 # uniform ordering [1.5]
        if (item != THUMBS and                            # skip thumbnails folder
            not item.startswith(('_', '.'))):             # skip '[_.]*' private|hidden
 
            # valid name
            itempath = os.path.join(imageDir, item)       # uses local path here
            if os.path.isdir(itempath):

                # folder: make link                       # may not have index.html
                escsub = html_escape(item)                # add "/" to href [1.6]
                target = url_escape(item + '/')
                sublinks.append('<A href="%s">%s</A>' % (target, escsub))
    return sublinks



def formatDateTime(usetime=None):
    """
    ----------------------------------------------------------------
    [1.7] Format a date+time string uniformly, for inclusion in the 
    comments of both index and image-viewer pages (e.g., gen date).
    '%b-%d-%Y @%X' => 'Feb-12-2020 @13:54:07', but match Exif tags.
    ----------------------------------------------------------------
    """
    if not usetime: 
        usetime = time.localtime()
    return time.strftime('%Y-%m-%d @%X', usetime)      # '2020-02-12 @12:54:07'



def sectionSeparator(message):
    """
    ----------------------------------------------------------------
    Utility: emit a uniform formatted section-separator block [2.0].
    ----------------------------------------------------------------
    """
    print()
    print('<!-- ' + '=' * 71 + ' -->')
    print('<!-- %s -->' % message.upper())
    print('<!-- ' + '=' * 71 + ' -->')
    print()



def loadTemplateFile(filename):
    """
    ----------------------------------------------------------------
    Load the text of a template file from this script's code folder.
    This was split off to here in [2.0] because it's now also used 
    for the floating Top button's code template, in addition to its 
    former viewer-pages template role.  Note: this means that both 
    files used templateEncoding Unicode setting in user_configs.py.
    ----------------------------------------------------------------
    """
    templatedir  = os.path.dirname(__file__)   # this script's folder
    templatepath = os.path.join(templatedir, filename)
    templatefile = open(templatepath, mode='r', encoding=templateEncoding)
    templatetext = templatefile.read()
    templatefile.close()
    return templatetext



def generateIndexPage(imageDir):
    """
    ----------------------------------------------------------------
    Build and output the HTML for the thumbnails-index page in the
    images folder, referencing already-built thumbnail images.
    This uses all-inline CSS styling, because custom HEADER.html
    files are not expected to code or link to anything made here.
    This also uses individual prints, because most content varies.
    ----------------------------------------------------------------
    """
    print('Generating thumbnails index page')

    # collect href lists 
    imglinks = formatImageLinks(imageDir, styleImgThumbs)
    sublinks = formatSubfolderLinks(imageDir)

    # don't assume Unicode default (in locale module)
    save_stdout = sys.stdout
    sys.stdout = open(indexPath, 'w', encoding=outputEncoding)

    # header section
    if os.path.exists(headerPath):
        # assume complete, HTML-safe (pre-escaped), explicit Unicode
        insert = open(headerPath, 'r', encoding=insertEncoding)
        print(insert.read())
        sectionSeparator('end custom header')
    else:
        folderpath = os.path.abspath(imageDir)           # expand any '.' or '..' [1.6]
        foldername = os.path.basename(folderpath)        # use last (or only) component 
        escfoldername = html_escape(foldername)
        print(doctype)
        print('<html><head>')
        print(contype)
        if useViewPort:
            print(viewmeta)
        print(indexfont) 
        if noiOSIndexTextBoost:                 # [1.7] don't upscale landscape text?               
            print(noiostextboost)
        print(headinsertkey)                    # [2.0] add marker for search/replace
        print('<title>Index of %s</title>'
              '\n</head>\n<body>\n'
              '<h1 %s>Index of Image Folder "%s"</h1>\n' % 
                        (escfoldername, wrappedstyle, escfoldername))
        sectionSeparator('end default header')

    # floating Top button (if enabled) [2.0]
    if floatingTopEnabled:
        sectionSeparator('start floating top')
        templatetext = loadTemplateFile('template-floatingtop.html')
        replacements = dict(APPEAR_AT=floatingTopAppearAt,
                            SPACE_BELOW=floatingTopSpaceBelow,
                            FG_COLOR=floatingTopFgColor,
                            BG_COLOR=floatingTopBgColor) 
        print(templatetext % replacements)
        sectionSeparator('end floating top')

    # subfolders bullet list (skip other content)
    if sublinks and listSubfolders:
        sectionSeparator('start subfolder links')
        print('<p><b>Subfolders here:</b><div style="margin-bottom: 30px;"><p><ul>')
        for link in sublinks:
              linkstyle = 'style="margin-bottom: 6px;"'    # add space for mobile [1.4]
              print('<li %s>%s' % (linkstyle, link))       # add space below list [1.4]
        print('</ul></p></div>')
        sectionSeparator('end subfolder links')
          
    # thumb links table 
    sectionSeparator('start thumbs table')
    print('\n<p>')   # drop <hr>
    print('<div style="overflow-x: auto;">')   # table autoscroll on small screens [1.4]
                                               # whole-window scroll breaks mobile widgets
    # [1.5] styled top/bottom borders, not <hr> 
    print('<table style="%s">' %  styleTableThumbs)

    # thumb links cells
    while imglinks:
        row, imglinks = imglinks[:thumbsPerRow], imglinks[thumbsPerRow:]
        print('<tr>')
        for (escname, link) in row:
              colstyle  = 'style="'                # configurable text color [1.6]
              colstyle += 'padding: 3px; '         # avoid running together [1.4]
              colstyle += 'text-align: center; '   # center img in its cell [1.4]
              if  uniformColumns:
                  colstyle += 'width: %d%%; ' % (100 / thumbsPerRow)
              colstyle += 'color: %s;' % thumbsFgColor
              colstyle += '"'
              labstyle = 'style="white-space: nowrap; margin-top: 0px;"'
              print('<td %s>\n\t%s\n\t<p %s>%s</p></td>' % (colstyle, link, labstyle, escname))
        print('</tr>')
        #print('<tr><tr>')   # drop in [1.4]: use css as needed

    print('</table></div></p>\n')   # drop <hr>
    sectionSeparator('end thumbs table')

    # footer section
    createdBy = createdby % (formatDateTime(), VERSION)             # func callable here

    if os.path.exists(footerPath):
        # assume HTML-safe (pre-escaped), explicit Unicode
        print(createdBy)                                            # [1.7] date/version
        sectionSeparator('start custom footer')
        insert = open(footerPath, 'r', encoding=insertEncoding)
        print(insert.read())
    else:
        print(createdBy)                                            # [1.7] date/version
        sectionSeparator('start default footer')
        # [2.0] space above floating Top?
        if floatingTopEnabled:
            extraAtBottom = ' style="margin-bottom: 80px;"'         # nit: skip if no JS?
        else:
            extraAtBottom = ''
        print('\n<p%s><i>This page was generated by '
              '<A HREF="https://learning-python.com/thumbspage.html">thumbspage.py'
              '</A></i></p>' 
                      % extraAtBottom)
        print('</body></html>')

    sys.stdout.close()         # this used to be script exit
    sys.stdout = save_stdout   # there's now more to the show...



#==========================================================================
# GENERATE image-viewer/navigation web pages in thumbs subfolder [1.5]
#==========================================================================



#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Template: HTML+CSS+JavaScript, upper for % dict-key replacements, %% = %
#
# [1.6] Split off to a separate file for easier mods and reads.
# With the addition of JavaScript to the template's HTML and CSS,
# this code was virtually incomprehensible when mixed with the 
# Python code here.  Four flavors of syntax plus browser-specific
# quirks make web-page coding more exciting than it should be...  
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

VIEWER_TEMPLATE = 'template-viewpage.html'



def generateViewerPages(imageDir):
    """
    -----------------------------------------------------------------------
    For each image and thumbnail, build and output the HTML for one 
    view/navigate page, opened on thumbnail clicks.  Navigation matches 
    the filesystem-neutral and case-specific image order on index page.

    Not run if not useViewerPages, in which case there may be some pages 
    from prior uncleaned runs, but we aren't linking to them, or making 
    any now.  If run, always makes new viewer pages (unlike thumbs). 

    This was an afterthought, might be improved with JavaScript for 
    scaling [and was in 1.6+], and could better support customizations,
    but its Prev/Next links already beat browser-native displays.
    Assumes that thumbnail filenames are the same as full-size image 
    filenames in .., and uses a template string (not many prints) 
    because most page content here is fixed (unlike index pages).

    CSS need not be inlined here, because no parts can be custom files
    (customization is via manual template-file edits and a few settings). 
    Caveat: this script may eventually need to use PyPhoto's pickle-file 
    storage scheme if many-photo use cases yield too many thumb files.

    -----------------------------------------------------------------------
    [UPDATE, 1.6] Despite the following, this script now uses JavaScript
    to dynamically scale images while retaining aspect ratio in all cases, 
    except iOS landscape orientation and no-JavaScript contexts (which both
    use the former CSS scaling scheme described below).  See the 1.6 version
    notes in UserGuide.html for background, and the viewer template  file's
    JavaScript code for more on scaling in general.  The prior CSS scaling 
    notes here were retained as backstory to the Saga of the Scaling.
     
    -----------------------------------------------------------------------
    [FORMER SCHEME, 1.5] About CSS image scaling: because scaling as 
    implemented here is less than ideal, viewer pages are optional.

    CSS is poor at this: it cannot emulate better browser-native display.
    This probably requires JavaScript + onresize event callbacks to do 
    better: CSS has no way to adjust as window size and aspect changes.

    As is, desktop users may need to resize their windows for optimal 
    viewing, because images may exceed page containers on window resizes.
    Mobile portrait mode shows landscape images well but portrait images 
    don't use all free space; landscape mode always requires scrolls.
    
    Dev notes on the assorted scaling attempts sketched below:
    - 'auto' seems busted for width; fractional % usage may vary
    - [max-height: 100%%; max-width: 100%%;] doesn't constrain high
    - [overflow-x/y: auto;] doesn't limit image size, on div or img
    - [object-fit: contain;] attempt didn't work on chrome (why?)
    - the math scales side1 to N%, and side2 to N% of its ratio to side1

    Failed attempt: portrait not scaled down, landscape no diff
    if imgwide >= imghigh:
        IMAGEWIDE, IMAGEHIGH = '100%', 'auto'
    else:
        IMAGEHIGH, IMAGEWIDE = '100%', 'auto'
 
    Failed attempt: portrait too tall, landscape no diff
    ratio = min(imgwide / imghigh, imghigh / imgwide)
    if imgwide >= imghigh:
        IMAGEWIDE, IMAGEHIGH = '100%', '%f%%' % (100 * (imghigh / imgwide))
    else:
        IMAGEHIGH, IMAGEWIDE = '100%', '%f%%' % (100 * (imgwide / imghigh))
    -----------------------------------------------------------------------
    """

    # load from a file in script's dir for easy edits (and humane reads) [1.6]
    templatetext = loadTemplateFile(VIEWER_TEMPLATE)    # now shared with Top [2.0]

    # get thumb/image names
    allthumbs = orderedListing(os.path.join(imageDir, THUMBS))
    allthumbs = [thumb for thumb in allthumbs if isImageFileName(thumb)]
    thumb1, thumbN = 0, len(allthumbs) - 1


    # build viewer pages
    for ix in range(len(allthumbs)):
        thumbname = allthumbs[ix]

        # image's original dimensions per Pillow (also in JS DOM)
        imgwide, imghigh = imageWideHigh(imageDir, thumbname)

        #------------------------------------------------------------------------------
        # [1.5] CSS scaling: these work well on mobile, and on desktop if window sized;
        # now subsumed by [1.6] JavaScript scaling, unless iOS landscape or no JS;
        #------------------------------------------------------------------------------

        if imgwide > imghigh:
            IMAGEWIDE, IMAGEHIGH = '100%', 'auto'    # landscape
        else:
            IMAGEHIGH, IMAGEWIDE = '80%',  '%f%%' % (80 * (imgwide / imghigh))

        #------------------------------------------------------------------------------
        # [1.7] get image mod/taken dates as available at page-generation time;
        # along with image filesize and dimensions, these are fully static: regen
        # viewer pages if/when images modified (as for image adds and deletes); 
        #
        # [1.7] May-2020: use 'Digitized' for photo scans (no original-tag value);
        # [2.0] Jul-2020: use 'Created' if date unknown, not 'Taken' (may be drawn);
        #------------------------------------------------------------------------------

        try:
            filetimestamp = os.path.getmtime(os.path.join(imageDir, thumbname))
            imageModDate = formatDateTime(time.localtime(filetimestamp))
        except:
            imageModDate = '(unknown)'

        # [2.0] pull this out to avoid double loads
        try:
            image = openImageSafely(os.path.join(imageDir, thumbname))
            exifs = getExifTags(image)    # always a dict
        except:
            exifs = {}

        try:
            tries = [('DateTimeOriginal', 'Taken'), ('DateTimeDigitized', 'Digitized')]
            for (trytag, label) in tries:
                taken = exifs.get(trytag, '').strip()      # normal: use 1st
                if taken:                                  # bursts: 1st='  '
                    taken = taken.replace(' ', ' @', 1)    # 'yyyy:mm:dd hh:mm:ss
                    taken = taken.replace(':', '-', 2)     # 'yyyy-mm-dd @hh:mm:ss'
                    imageTakenDate  = taken                # to match formatDate()
                    imageTakenLabel = label                # camera photo or scan?
                    break
            else:
                imageTakenDate  = '(unknown)'          # no tag worked
                imageTakenLabel = 'Created'            # not Taken [2.0]
        except: 
            imageTakenDate  = '(unknown)'              # something bombed
            imageTakenLabel = 'Created'                # not Taken [2.0]

        #------------------------------------------------------------------------------
        # [2.0]: add device line to info popup iff tag present (most photos, scans),
        # and try software info as a last resort if no device present (some drawns);
        #------------------------------------------------------------------------------
 
        maker = None
        try:
            tries = [('Model', 'Device'), ('Software', 'Software')]
            for (trytag, label) in tries:
                tagval = exifs.get(trytag, '').strip()
                if tagval:
                    maker = tagval[:40]    # at most 40 chars
                    if trytag == 'Model':
                        # tack on brand if present, short, not redundant
                        maketag = exifs.get('Make', '').strip()
                        if len(maketag.split()) == 1 and maketag not in maker.split():
                            maker += ' (%s)' % maketag
                    break 
        except:
            pass

        # full line as js code: sanitize any \ or ' (unlikely, but safe)
        if not maker:
            deviceLine = "''"    # i.e., concat nothing to info in js
        else:
            deviceLine = ("'\\n%s: %s'" % 
                         (label, maker.replace('\\', '?').replace('\'', '?')) )


        # collect template substitution values
        replacements = dict(
            # meta, not templateEncoding: load
            ENCODING = outputEncoding,

            # used in CSS text
            BGCOLOR = viewerBgColor,
            FGCOLOR = viewerFgColor,
            JSCOLOR = viewerJSColor,
            BDCOLOR = viewerBorderColor,

            # relative to '.' = thumbs/, always unix "/" on web
            IMAGENAME = html_escape(thumbname),
            IMAGEPATH = url_escape('../' + thumbname),  

            # nav links: pages in '.', wrap around at end/start 
            PREVPAGE = url_escape('%s.html' % allthumbs[(ix-1) if ix > thumb1 else -1]),
            NEXTPAGE = url_escape('%s.html' % allthumbs[(ix+1) if ix < thumbN else  0]),

            # scale larger dimension to viewport (see calcs and notes above)
            IMAGEWIDE = IMAGEWIDE,
            IMAGEHIGH = IMAGEHIGH,

            # stretch small images beyond actual sizes?
            IMAGESTRETCH = 'true' if expandSmallImages else 'false',     # js booleans

            # enable history destacking if this browser fixes location.replace()
            CHROMEIOSBACKFIXED = ['false', 'true'][chromeiOSBackFixed],  # js ditto

            # [1.7] additions: page comment, same as createdby here
            VERSION = VERSION,
            PAGEGENDATE = formatDateTime(),

            # [1.7] additions: filename-tap info popup
            IMAGEMODDATE = imageModDate,
            IMAGETAKENDATE = imageTakenDate,
            IMAGETAKENKIND = imageTakenLabel,
            IMAGESIZE = '{0:,}'.format(os.path.getsize(os.path.join(imageDir, thumbname))),
            ORIGWIDE = '{0:,}'.format(imgwide), 
            ORIGHIGH = '{0:,}'.format(imghigh),

            # [1.7] addition: i+S+L uses legacy CSS display instead of JS scaling??
            IOSSAFARILANDSCAPECSS = {True: 'true', False: 'false'}[iOSSafariLandscapeCSS], 

            # [2.0] addition: millisecs delay for auto slideshow (config per gallery)
            SLIDESHOWDELAY = autoSlideShowDelayMS,

            # [2.0] addition: show Full toggle in viewer toolbars? (config per gallery)
            FULLSCREENBUTTON = 'true' if showFullscreenButton else 'false',    # Py=>JS

            # [2.0] addition: show device/software line in info popup, if in Exif tags
            DEVICELINEORNOT = deviceLine
            )


        # generate the page and file
        print('Generating view page for: %s' % thumbname)
        viewerpath = os.path.join(imageDir, THUMBS, thumbname + '.html')
        viewerfile = open(viewerpath, mode='w', encoding=outputEncoding)
        viewerfile.write(templatetext % replacements)
        viewerfile.close()



#==========================================================================
# MAIN LOGIC: kick off the functions above (no longer top-level code [1.5])
#==========================================================================



if __name__ == '__main__':
    print('Running')

    makeThumbnails(imageDir)
    generateIndexPage(imageDir)
    if useViewerPages:
        generateViewerPages(imageDir)

    print('Finished: see the results in the images folder.')

    # and view index.html in images folder, zip/upload images folder to site



[Home] Books Programs Blog Python Author Training Search Email ©M.Lutz