File: thumbspage/thumbspage.py

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

Version:  2.3, May 9, 2022
Patches:  Nov-21-23 for cgi module removal in Python 3.13 (tag=nov23)
          Dec-21-23 for string-escape breakage in Python 3.13 (tag=dec23)

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-2023.

Synopsis: Makes thumbnails, an HTML thumbnail-links page, and 
          HTML image-viewer pages, for image folders.  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 passed in as needed:

              $ 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...

              # in a shell script:
              python3 thumbspage.py <<EOF
              ...
              EOF
              ...all parameters from shell 'here document'...

          As of 2.2, config arguments can override config-file settings:

              $ python3 thumbspage.py setting=value setting=value ...
              $ python3 thumbspage.py folder setting=value setting=value ...

          As of 2.3, the config file or arguments can replace console inputs:

              $ python3 $C/thumbspage/thumbspage.py . \
                  useDynamicIndexLayout=True \
                  inputUseViewerPages=True inputThumbMaxSize=128,128 \
                  inputCleanThumbsFolder=True
              ...no parameters prompted from stdin...

          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 folder in-place, and
   may modify some of its images after backups.  To the folder, it adds 
   an HTML index-page file (named index.html by default), along with a 
   subfolder (named _thumbspage by default) containing thumbnails and 
   HTML viewer pages.  As preconfigured, it also rotates any tilted 
   images and deletes their embedded thumbnails, after saving unmodified
   backup copies 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, see build/ for tips.


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

Recent version highlights:

- (Jan~May-2022) As of 2.3:
  + Viewer pages may include a Note toolbar button, to display the text 
    of an <imagename>.note file if one is present at gallery build time.
    This can be disabled, and is automatically, if no .note files exist.
  + Up-swipe now opens the Note display if notes are enabled, instead of
    raw-image view; the latter is still available via a simple image tap.
  + All console inputs can now be provided with config-file or -argument 
    settings; if not None, these are no longer requested at the console.
  + Tooltips are now on by preset, because viewers are getting busy with 
    Note; tooltips were added to Prev/Next/Index buttons for consistency.
  + Viewer pages dropped all hrefs on toolbar buttons to kill URL popups.
  + Viewer-page Info and Note popups use rounded corners too; it's 2022.
  + Note and info popups may use custom colors that differ from viewer.
  + Note and info popups properly escape popup text for both JS and HTML. 
  + Dynamic index-page layout improves column spacing: .5 'em' (vs 'ch').
    
- (Dec-2021) As of 2.2:
  + Image-viewer pages support left/right and up/down touch gestures
    on the image-display area.  Up/down invoke image info and raw view,
    and left/right move to the previous and next image per configs.
  + Config settings can now be passed in per run as "setting=value" 
    command-line arguments, which override settings in the configs file.
    The value is a Python expression: quote strings via \' or \".
  + Tooltips can be enabled for mouse hovers over index-page images, and
    viewer-page image, filename, and Auto/Full buttons.  Off by default.

- (Jul~Aug-2021) As of 2.1:
  + Thumbnails may now be enhanced via user-customizable settings in the  
    user_configs.py file.  This includes enhancements for color, contrast,
    sharpness, brightness, and save quality.
  + Thumbnail-enhancement presets boost save quality to avoid loss/noise
    produced by JPEG compression; sharpen all thumbnail images to negate 
    the blurring inherent in Pillow downscale resizes; and precode a basic
    black-and-white mode.  The first two have trivial space tradeoffs.
  + A new layout model is provided for thumbnail index pages, which 
    arranges thumbnails dynamically on resizes, and is available as an
    experimental alternative to the former fixed-columns table scheme.
  + Builds are automated, by scripts and examples in a new build/ folder.
  + 'Auto' changes font when active; rotations remove embedded thumbs; etc.

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

- (Jun~Oct-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()
  + A harmless-but-confusing Pillow DOS warning for large images is no more.

  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:

   /.../website$ 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, "trnpix".

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.
Reply #3 (thumbs per row) is absent when 2.1 dynamic layout is used.

Alternatives: as of version 1.7, images-folder path may instead 
be a command-line argument, and as of 2.2 and 2.3 both config-file 
settings and console inputs may be given by command-line arguments,
and thumb max size can be a single integer when given by configs:

   /.../camera$ python3 $Code/thumbspage/thumbspage.py photos2022/ \
       useDynamicIndexLayout=True \
       inputCleanThumbsFolder=True \
       inputThumbMaxSize=128 inputUseViewerPages=True \
       popupFgColor=\'#dddddd\'
   Running
   ...


===========================================================================
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; 1.7 and 2.0 add multiple options; 
and 2.1 appends thumbnail-enhancement and dynamic-layout settings.

As of 2.2, config-file settings can be overridden by command-line
arguments of the form "setting=value", where setting is the name 
of a variable assigned in the config file, and value is any Python
expression: use \' or \" for quotes required around strings, and 
quote any other special characters similarly as required by shells.
As of 2.3, this format can also be used to provide console inputs.

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.3'   # reported in both index and viewer pages


#
# Library tools
#

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

if sys.version_info[:2] < (3, 11):            # [2.3]+ nov23: cgi removal in py 3.13 
    import cgi                                # avoid deprecation warnings in 3.11+3.12
else:                                         # avoid except aborts in 3.13 and later
    cgi = None                                # cgi was used since 1995; yes, grrr...

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

    [2.3]+ nov23: don't try to import cgi above in pythons 3.11 and later;
    it emits deprecation warnings in 3.11+, and will be removed in 3.13.
    This will cause every program that uses cgi to fail on an exception.
    This module has been used by millions of programs/users since 1995, 
    but was removed per the opinions of a few in 2024.  Seriously: WTF?
 
    Coding note: the version test above prevents cgi import and sets 
    cgi to None in py 3.11+, but the code here relies on the fact that 
    html.escape exists if cgi is None - which it will in 3.11+, given 
    that html.escape was added in py 3.2 (and all bets are off if it's 
    ever cut too!).  Dropping older 3.X is simpler, but less inclusive.

    [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 required if the result is embedded in a 
    quoted HTML attribute (e.g., <tag attr="%s">, but not <tag>%s</tag>),
    but may also be useful in general (e.g., HTML code in JS strings).

    [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 html_unescape(text):    
    """
    -----------------------------------------------------------------------
    HTML unescapes - for text inserted in HTML code

    [2.1] html unescapes are also now used to compute the maximum
    filename-label width for the dynamic index-page layout option.  
    This call requires Python 3.4 or later, but an older equivalent 
    is used here for older 3.X.  The older form still works in 3.8 with
    a deprecation warning, but is gone in 3.9.  This could be avoided
    by retaining original unescaped text, but fewer changes is better.
    -----------------------------------------------------------------------
    """
    if hasattr(html, 'unescape'):
        return html.unescape(text)                        # py 3.4+ form
    else:
        return html.parser.HTMLParser().unescape(text)    # unlikely, but...



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: config-file/arg settings for rarely changed options
#==========================================================================


#--------------------------------------------------------------------------
# [2.3] ON ERROR CHECKS: we never check for errors in configs given by 
# file or command-line arguments, or console inputs overridden by config 
# file or arguments.  Only inputs actually taken from the console are
# checked, so exception messages may still appear for invalid settings
# provided otherwise.  Settings must be syntactically correct (on import
# for file and eval() for arguments), but their code may yield logically 
# invalid results.  Though grey, config-file settings have never been 
# validated, on the assumption that builders know what they're doing.
# Console inputs are checked only because they are true user inputs.
#
# NITS: because args are applied after the file is imported, A=B in the 
# file won't reflect a value assigned to B as an arg; this does not seem 
# worth addressing.  Also note that arg settings cannot reference other 
# args by name as they can in the file; they might do so if the module's 
# namespace dicts were passed to eval(), but scope seems overkill for args.
#--------------------------------------------------------------------------



# Now a separate module for easier access [1.6]
import user_configs


# [2.2] Override module's configs with any "setting=value" command-line args
for arg in sys.argv[1:]:
    if arg.count('=') == 1:
        setting, value = arg.split('=')
        if hasattr(user_configs, setting):
            try:
                evalue = eval(value, {}, {})   # trust developers
            except:
                print('**Error evaluating config argument - aborting: [%s]' % arg)
                sys.exit(1)
            else:
                setattr(user_configs, setting, evalue)
                sys.argv.remove(arg)    # discard arg
        else:
             print('**Invalid name in config argument - aborting: [%s]' % arg)
             sys.exit(1)


# use possibly-changed names in config module as globals here
from user_configs import (
    THUMBS,                 # built subfolder name (thumbs+viewers)
    INDEX,                  # built index-page name ('default'?, 'home'?)

    listSubfolders,         # show auto folder list? (or via header)
    subfolderSpacer,        # CSS space between folder links, new 7px default [2.1]
    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]
    deleteEmbeddedThumbs,   # remove embedded thumbnails on rotations to avoid skew? [2.1]

    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]

    useDynamicIndexLayout,  # generate dynamic index-page layout instead of fixed?  [2.1]
    dynamicLayoutPaddingH,  # horizontal space around thumbs in dynamic layout mode [2.1]
    dynamicLayoutPaddingV,  # vertical space around thumbs in dynamic layout mode [2.1]

    # Plus: viewer_thumbs.py imports 8 user configs for thumbnail enhancements [2.1]

    lrSwipesPerButtons,     # prev/next meaning of left/right touch swipe gestures [2.2]
    upSwipeOnAllBrowsers,   # has the Chrome Back-after-Up glitch been fixed? [2.2]
    useToolTips,            # image+filename title attrs for tooltip hover popups? [2.2]
    defaultFooterTagline,   # show thumbspage plug at end of default pages? [2.2]

    useImageNotes,          # enable/disable the new Notes button/swipe/display [2.3]
    noteBoxVSpace,          # empty space on left+right of note box (e.g., '15%') [2.3]
    noteEncoding,           # Unicode encoding for loading Note text files, if any [2.3]

    # console-input overrides [2.3]
    inputImagesFolderPath,  # iff not first argument
    inputCleanThumbsFolder, # None=ask, else use value and don't ask
    inputThumbsPerRow,      # iff not dynamic layout
    inputThumbMaxSize,      # 2-tuple (or for config only: int x == x,x)
    inputUseViewerPages,    # Boolean, not 'y'

    # Info/Note popup colors may vary from view page [2.3]
    popupBgColor,           # None=viewer page, else custom background
    popupFgColor,           # None=viewer page, else custom text
    popupBorderColor,       # None=viewer page, else custom border
    popupOpacity,           # background dimness, higher=darker

    # don't display filename labels on the index page? [2.3]
    omitIndexPageLabels     # True=thumbnail images only, False=thumb+label
)





#==========================================================================
# 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', bad=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, {}, {})                   # {}: don't expose module [2.2]
    except:
        exit('Invalid input value "%s"' % reply)     # 'require' may prevent



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


def configOrAsk(config, asker):
    """
    Use config file or arg setting if set, else ask user [2.3]
    Not in ask(): requires all configs to be str (name=\'y\' vs True)
    """
    if config != None:
        return config     # may be None, False, other
    else:
        return asker()    # prompt and ask at console


# 1) str => images folder path: images, header/footer, output; [1.7] now first - main!
if len(sys.argv) == 2:
    imageDir = sys.argv[1]          # allow as simple arg, for shell auto-complete [1.7]
elif len(sys.argv) == 1:
    imageDir = configOrAsk(
                 inputImagesFolderPath,                               # use config if set [2.3]
                 lambda: ask('Images folder path', '. or dir', '.'))  # default is cwd '.'
else:
    exit('Too many arguments')      # post config-arg discards: report+stop [2.2]

# 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]

# don't ask more if this is an imageless folder (also: default-footer tagline) [2.1]
imageless = not any(isImageFileName(os.path.join(imageDir, filename))
                     for filename in os.listdir(imageDir))

if imageless:
    cleanFirst = True         # irrelevant if no images 
    thumbsPerRow = None       # nit: cleanFirst may remove old content        
    thumbMaxSize = None              
    useViewerPages = False
else:


    # 2) y or n => remove any existing thumb files?
    cleanFirst = configOrAsk(
                   inputCleanThumbsFolder,                        # use config if set [2.3]
                   lambda: askbool('Clean thumbs folder', 'y'))   # default is now 'y' [1.7]


    # 3) int => fixed row size, irrespective of window
    if useDynamicIndexLayout:
        thumbsPerRow = None                                       # don't ask if dynamic [2.1]
    else:
        thumbsPerRow = configOrAsk(
                         inputThumbsPerRow,                       # use config if set [2.3]
                         lambda: askint('Thumbs per row', '4'))   # 5->4 so less scrolling [1.4]


    # 4) (int, int) => _max_ (x, y) pixels limit, preserving original aspect ratio

    # [2.3]+ dec23: use r'' or \\ for string-escape breakage in py 3.13 (3.12 warning)
    # '\other' no longer retains the \, after 30+ years of doing so... (yes, grrr!)
    #
    require = r'\(?[0-9]+\s*,\s*[0-9]+\)?'    # 2-tuple of ints, parens optional
    thumbMaxSize = configOrAsk(
                     inputThumbMaxSize,                           # use config if set [2.3]
                     lambda: askeval('Thumb max size', 'x, y', '(100, 100)', require))

    if isinstance(thumbMaxSize, int):
        thumbMaxSize = (thumbMaxSize, thumbMaxSize)               # int config => x,x [2.3]
    elif thumbMaxSize[0] != thumbMaxSize[1]:
        print('Note: x != y, so your results may be unexpected')  # warning [2.1]


    # 5) y or n => create image viewer pages? [1.5]
    useViewerPages = configOrAsk(
                       inputUseViewerPages,                            # use config if set [2.3]
                       lambda: askbool('Use image-viewer pages', 'y')) # default now 'y' [1.7]



#--------------------------------------------------------------------------
# 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 THUMBNAIL IMAGES, in image-folder subdir (via viewer_thumbs.py)
#==========================================================================



def makeThumbnails(imageDir):
    """
    -----------------------------------------------------------
    Reuse a (now much modified) thumbnail generator from PP4E.
    Its [(imgname, thumbobj)] return value is unused here;
    instead, os.listdir() is run later to collect thumb names
    both for the index-page and viewer-pages stages, and thumb
    objects are not used (this script builds pages, not GUIs).
    [2.1] Now enhances thumbs: see module for code/configs.
    [2.1] Now deletes embedded thumbnails to avoid tool skew.
    -----------------------------------------------------------
    """
    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,
               delthumbs=deleteEmbeddedThumbs)





#==========================================================================
# GENERATE INDEX PAGE: in images folder, linked to thumbnails and viewers
#==========================================================================



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



# [1.5] work around vanishing <hr> bug on desktop Chrome at zoom <= 90% 
# [2.2] use rounded corners, and border on all four sides (not just t/b)

styleTableThumbs = """                        /* not used: table-layout:fixed; */
    background-color: %s;                     /* for fun (former 'white') */
    width: 100%%;                             /* expand table+borderlines */
    margin-top: 8px; margin-bottom: 8px;      /* above/below borderlines; 5=>8px [2.3] */
    padding-top: 25px; padding-bottom: 5px;   /* under/over borderlines  */
    border: 1px solid black;                  /* manual lines, _not_ <hr>s: [2.2] */ 
    border-radius: 6px;                       /* chrome botches <hr> at zoom <= 90%% [2.2] */
""" % 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;"'


# [2.2]: default-header body margin for index pages (not viewer pages)
indexmargin = '<!--margin-->\n  ' \
            '<style>body {margin-left: 12px; margin-right: 12px;}</style>'



#--------------------------------------------------------------------------
# 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)

        # [2.2] add title for image tooltip popup?
        imgtitle = ' title="View image"' if useToolTips else ''
 
        link = ('<A href="%s">\n\t<img src="%s" style="%s" '
                      'alt="Image thumbnail"%s></A>' %    # [2.2]
                      (target, source, styleImgThumbs, imgtitle)) 
                  
        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 use 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 generateDynamicThumbsLayout(imglinks):
    """
    ----------------------------------------------------------------
    [2.1] Dynamic index-page layout, an experimental alternative to
    the prior and still default fixed-columns layout.  In this new
    model, thumbnail columns are arranged dynamically to match page
    size, and rearranged when the page is expanded or shrunk.  This
    takes advantage of space on desktop and can avoid horizontal 
    scrolling on mobile, but can be subpar on mobile: phones may 
    display a single column, which makes for _much_ more vertical 
    scrolling.  Hence this is an option; as its results are unproven
    and differ for wide/narrow filenames, it's also experimental.  
 
    Coding notes: this generates inline CSS code like much else in 
    index pages, purportedly because thumbspage may not control the 
    <head> when a custom header is used.  This argument seems less
    valid today, given that <style> blocks are now allowed in <body>
    by HTML5.  Thus, this could instead generate embedded <style>:
    <style> #thumbslinks>div {...} </style> <div id=thumbslinks...>
    <style> .class thumblink {...} </style> <img class=thumblink...>

    Coding note: the "overflow-x: auto" on the top-level <div> is 
    crucial on mobile; else thumbnails too wide for the display 
    break the viewport, and the entire page scrolls horizontally;
    with it, just the images scroll, much like fixed layout.  A
    border/unusual case, perhaps, but one had by at least one user.

    Coding note: this uses 'ch' CSS units to try to match filename 
    label width, because 'em' wasn't usable.  A 'ch' is the width 
    of a '0' and may not work universally either, but has so far.
    As a fallback option, builders can config horizontal padding. 
    This also uses a min-width/width combo to set column size to 
    the max of the image and the label, because the pixel size of 
    a 'ch' (or 'em') is unknown here in Python; also good so far. 

    [2.2] use rounded corners, and border on all four sides.  This 
    makes it easier to tell where the index table starts and stops.
    Note: vertical spacing includes config padding; mod to shrink.
    [2.3] increase the top/bottom table margin from 20px to 24 px.
    [2.3] code refactored to support omitted filename labels.

    ----------------------------------------------------------------
    [2.3] UPDATE: the former 2.1 layout using 'ch' with the full 
    max-label size was changed to use 'em' with 1/2 the max-label 
    size (or equivalently, .5 'em' with the max-label size).  That
    is, the first of the following was replaced with the second:
    
    'min-width: %spx; width: %sch; ' % (maxthumbwidth, maxlabelwidth)
    'min-width: %spx; width: %sem; ' % (maxthumbwidth, maxlabelwidth / 2.0)

    This change applies only when images are narrower than labels, 
    but guesses well, and yields tighter column packing, which is 
    better in portrait on mobile (you may get > 1 column), and can be
    tweaked if needed with config dynamicLayoutPaddingH (set this higher
    to spread out thumbs columns more).  In fact, it's now nearly good 
    enough to be the default index layout, but unlike fixed, space 
    may be empty on the right side of the table for some window sizes.
    Ref: https://developer.mozilla.org/en-US/docs/Web/CSS/length.

    Discussion: the former 'ch' scheme always overestimated the size
    needed for the max-sized label.  More accurate estimates might 
    use HTML5's Canvas.getContext('2d').measureText('text').width,
    but this call must be run in JS at view time, and hence cannot 
    be used in the Python static code generation here at build time.  
    This might be employed by patching all cells' widths on page load,
    or building the entire table in JS; both seem steps too far (TBD).  

    Alternative: the index-page table might also drop HTML <div>s in
    full and be arranged in an HTML canvas dynamically, and scaled to 
    view-port size to better fill the view on page loads and resizes, 
    similar to viewer-page images.  This all-JS alternative has not
    been tested, but seems likely to be slow for larger galleries; 
    each window resize would need to run lots of complex JS code.
    ----------------------------------------------------------------
    """
    print('\n<p>')
    print('<div id="thumbslinks" \n\
                style="background-color: %s; \n\
                       border: 1px solid black; \n\
                       border-radius: 6px; \n\
                       margin-top: 24px; margin-bottom: 24px; \n\
                       padding-top: 15px; padding-bottom: 15px; \n\
                       overflow-x: auto;" \n\
                >' % thumbsBgColor)

    # caveat: images may be wider than labels, or vice versa, and we need to set 
    # a cell width to simulate columns (else images are scattered); this tries to 
    # size to the widest using CSS, but rendering differs (and em->px is complex);
    # [2.3] per the update above, this now uses the better 'em' * (maxlabelwidth/2);

    assert imglinks                    # else max() would raise an exception
    maxthumbwidth = thumbMaxSize[0]    # per user input, used to make thumbs (normally)
    maxthumbwidth += 16                # +8px margin on both sides if thumb > label; !32 [2.2]
    maxlabelwidth = max(len(html_unescape(escname)) for (escname, link) in imglinks)

    # create thumb-link-cells code
    for (escname, link) in imglinks:

        if omitIndexPageLabels:
            cellwidth = 'width: %spx; ' % maxthumbwidth    # [2.3]
        else:
            cellwidth = 'min-width: %spx; width: %sem; ' % \
                               (maxthumbwidth, maxlabelwidth / 2.0)

        celstyle = 'style="'
        celstyle += 'display: inline-block; vertical-align: middle; '
        celstyle += cellwidth
        celstyle += 'padding: %s %s;' % (dynamicLayoutPaddingV, dynamicLayoutPaddingH)
        celstyle += '"'
        print('<div %s>' % celstyle)

        lnkstyle = 'style="'
        lnkstyle += 'margin-top: 8px; text-align: center;'    # 8 or 16px min between rows
        lnkstyle += '"'

        labstyle = 'style="'                                  # moot if omitIndexPageLabels
        labstyle += 'color: %s; ' % thumbsFgColor
        labstyle += 'white-space: nowrap; '
        labstyle += 'margin-top: 0px; margin-bottom: 8px; text-align: center;'
        labstyle += '"'

        print('\t<div %s>%s</div>' % (lnkstyle, link))           # link already has border

        if not omitIndexPageLabels:                              # filenames optional [2.3]
            print('\t<div %s>%s</div>' % (labstyle, escname))    # name already html escaped

        print('</div>')     # end cell
          
    print('</div></p>\n')   # end table



def generateFixedThumbsLayout(imglinks):
    """
    ----------------------------------------------------------------
    The original fixed-columns table layout for index-page thumbs.
    In this model, index pages render as a fixed number of columns
    on desktop and mobile, and resizes expand or shrink the space 
    between columns.  This is still the default in 2.1, because the
    new dynamic layout can be subpar (and even arguably awful) on 
    some mobiles--see the dynamic alternative above for more info.

    Coding notes: This code been polished over time, as its many 
    comments attest.  It also uses globals above unevenly for CSS 
    and more, which makes analyzing it a bit jumpy in retrospect.
    ----------------------------------------------------------------
    """
    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)

    # create thumb-link-cells code
    while imglinks:

        row, imglinks = imglinks[:thumbsPerRow], imglinks[thumbsPerRow:]
        print('<tr>')
        for (escname, link) in row:

              colstyle  = 'style="'                # configurable text color [1.6]
              colstyle += 'text-align: center; '   # center img in its cell [1.4]

              if uniformColumns:
                  colstyle += 'width: %d%%; ' % (100 / thumbsPerRow)

              if not omitIndexPageLabels:
                  colstyle += 'padding: 4px; '     # avoid running together [1.4] !3 [2.2]
              else:
                  colstyle += 'padding: 4px 16px 16px 4px; '    # imgs only: +space [2.3]

              colstyle += 'color: %s;' % thumbsFgColor
              colstyle += '"'

              labstyle = 'style="white-space: nowrap; margin-top: 0px;"'

              if omitIndexPageLabels:
                  colcode = '<td %s>\n\t%s</td>'       # link already has border
                  print(colcode % (colstyle, link))    # filenames optional [2.3]
              else:
                  colcode = '<td %s>\n\t%s\n\t<p %s>%s</p>\n\t</td>'     # link has border
                  print(colcode % (colstyle, link, labstyle, escname))   # name is html escaped

        print('</tr>')              # end row   (dropped <tr><tr> [1.4]: use css)

    print('</table></div></p>\n')   # end table (dropped <hr> [1.x]: use css)



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):
        # custom: assume complete, HTML-safe (pre-escaped), explicit Unicode
        insert = open(headerPath, 'r', encoding=insertEncoding)
        print(insert.read())
        sectionSeparator('end custom header')
    else:
        # default: standard opening
        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) 
        print(indexmargin)                        # [2.2] accommodate curved screens
        if noiOSIndexTextBoost:                   # [1.7] don't upscale landscape text?               
            print(noiostextboost)
        print(headinsertkey)                      # [2.0] add marker for search/replace
        fldrkind = 'Image ' if imglinks else ''   # [2.1] drop 'Image' is there are none
        print('<title>Index of %s</title>'
              '\n</head>\n<body>\n'
              '<h1 %s>Index of %sFolder "%s"</h1>\n' % 
                        (escfoldername, wrappedstyle, fldrkind, 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: %s;"'    # add space for mobile [1.4]
              linkstyle %= subfolderSpacer                # config, 6px->7px preset [2.1]                              
              print('<li %s>%s' % (linkstyle, link))      # add space below list [1.4]
        print('</ul></p></div>')
        sectionSeparator('end subfolder links')

    # thumb-links table 
    if imglinks:                                          # [2.1] no table if no links,
        sectionSeparator('start thumbs table')            # but do title+Top+subfolders
        if not useDynamicIndexLayout:
            generateFixedThumbsLayout(imglinks)           # original: preset # columns
        else:
            generateDynamicThumbsLayout(imglinks)         # alternative: sized to page
        sectionSeparator('end thumbs table')

    # footer section
    createdBy = createdby % (formatDateTime(), VERSION)             # func callable here
    if os.path.exists(footerPath):
        # custom: 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:
        # default: standard closing
        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 = ''
        if defaultFooterTagline:                                    # now optional [2.2]
            webpage = 'https://learning-python.com/thumbspage.html' # new tagline  [2.1]
            tagline = '<i>%s built by <A HREF="%s">thumbspage.py</A></i>'
            tagline %= ('Page' if imageless else 'Gallery', webpage)
        else:
            tagline = ''
        print('\n<p%s>%s</p>' % (extraAtBottom, tagline))
        print('</body></html>')

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





#==========================================================================
# GENERATE VIEWER PAGES: one per image, 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 escapeForJavaScriptandHTML(text):
    r"""
    -----------------------------------------------------------------------
    [2.3] Return the string text escaped so it can be embedded in both
    JavaScript (JS) and HTML.  This is used for both Note popup text
    loaded from .note files, and info popup text constructed by JS code
    from both static and dynamic content.  The former's content might be 
    anything; the latter's device/software vendor name may contain 
    arbitrary text; and either may include JS/HTML special characters.
            
    This is convoluted.  text will be embedded in an HTML file as a JS 
    '...' string literal, which will later be assigned to a DOM object's
    innerHTML property for display as plain text.  Hence, it must escape
    both characters special to JS per JS rules (to work as a JS string),
    and characters special to HTML per HTML rules (to render as plain text).

    Python's html.escape() handles HTML escapes, as well as both single  
    and double quotes as of Python 3.2.  For example:
        < and & are changed to &lt; and &amp;
        " and ' are changed to &quot; and &#x27;
    The quote escaping works in HTML for display, but also suffices to 
    make embedded quotes harmless within the JS '' string.
 
    In addition, all embedded backslashes must be doubled up, else they
    won't be displayed.  JS both may interpret some as escape sequences, 
    and, unlike Python, simply discards those that do not start escapes:
    'a\q\yb' is 'aqyb' of len 4 in JS, but 'a\q\yb' of len 6 in Python.
    Hence, all \ are also changed to \\ here, and before any \ or \n are
    inserted for formatting by the caller.  (Note that any \n inserted 
    by this script are first interpreted as eoln by Python; use \\\n in
    Python for a literal '\eoln' in a JS string printed by this script.)

    Border cases: 

    - Python 3.1 and earlier do not escape quotes in html_escape()
      (which runs cgi.escape() in 3.1; html.escape() was new in 3.2). 
      The code here probably does not need to care; 3.1 is quite 
      old and very unlikely to be used today.  But to be safe, any
      quotes lingering after HTML escaping are manually \ escaped.

    - Some JS strings may also contain ${} interpolation expressions,
      but only for `` templates; '' and "" strings ignore any ${}, and
      text is only ever embedded in '' strings today.  But to be both 
      general and futureproof, $ are changed to \$ too, to disable ${}.

    How this works in Python 3.2+ (\g is not a Python escape but \f is):

      >>> text = 'a<&b"c\'d${}e\\f\g'; print(text)
      a<&b"c'd${}e\f\g
      >>> text = html.escape(text); print(text)
      a&lt;&amp;b&quot;c&#x27;d${}e\f\g
      >>> text = text.replace('\\', '\\\\'); print(text)
      a&lt;&amp;b&quot;c&#x27;d${}e\\f\\g
      >>> text = text.replace('$', '\$'); print(text)
      a&lt;&amp;b&quot;c&#x27;d\${}e\\f\\g
      >>> text = text.replace("'", '\\\'').replace('"', '\\"'); print(text)
      a&lt;&amp;b&quot;c&#x27;d\${}e\\f\\g

    How this works in Python 3.1- (quote=False simulates cgi.escape()):

      >>> text = 'a<&b"c\'d${}e\\f\g'; print(text)
      a<&b"c'd${}e\f\g
      >>> text = html.escape(text, quote=False); print(text)
      a&lt;&amp;b"c'd${}e\f\g
      >>> text = text.replace('\\', '\\\\'); print(text)
      a&lt;&amp;b"c'd${}e\\f\\g
      >>> text = text.replace('$', '\$'); print(text)
      a&lt;&amp;b"c'd\${}e\\f\\g
      >>> text = text.replace("'", '\\\'').replace('"', '\\"'); print(text)
      a&lt;&amp;b\"c\'d\${}e\\f\\g

    Some of this drama is self-inflicted, but by design:

    - JS escaping might be skipped if text is embedded as an 
      initially hidden <p> instead of assigned to innerHTML by
      JS.  This would work for Note popups, but not info popups,
      whose text is built by JS from partly dynamic content. 

    - HTML escaping may be avoided if Note popup text is allowed
      to be HTML instead of constraining it to plain text.  This 
      won't help for info-popup text, though, and would require 
      .note writers to manually escape unintended HTML characters.

    For better or worse, web pages are a jumble of languages with very
    disparate syntax rules.  Accommodation seems an inevitable penalty.
    -----------------------------------------------------------------------
    """

    # [< &] => [&lt &amp] for HTML, [" '] => [&quot; &#x27;] for JS
    text = html_escape(text)

    # [\] => [\\] for JS
    text = text.replace('\\', '\\\\') 

    # [$] => [\$] for JS (templates)
    # [2.3]+ dec23: use r'' or \\ for string-escape breakage in py 3.13 (3.12 warning)
    #
    text = text.replace('$', '\\$')      # but not '\$': personality-disorder devs... 

    # [' "] => [\' \"] for JS (Pythons 3.1-)
    text = text.replace("'", '\\\'').replace('"', '\\"')

    # caller may now add \, \n, and <br> for formatting
    return text 



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.7] Despite the following, JS dynamic scaling is now used 
    for iOS landscape too, because it's better, especially paired with 
    iOS 13+ Safari's hide-toolbars option.  See 1.7 in the user guide.

    -----------------------------------------------------------------------
    [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 (and hence image) names
    allthumbs = orderedListing(os.path.join(imageDir, THUMBS))
    allthumbs = [thumb for thumb in allthumbs if isImageFileName(thumb)]
    thumb1, thumbN = 0, len(allthumbs) - 1


    # [2.3] disable Notes if no .note file found for any image
    for thumb in allthumbs:
        notefile = os.path.join(imageDir, thumb + '.note')
        if os.path.exists(notefile):
            anynotesfound = True
            break
    else:
        anynotesfound = False 

    # pass: eibti
    # anynotesfound = any(os.path.exists(os.pathjoin(imageDir, thumb + '.note')) 
    #                    for thumb in allthumbs)


    # build viewer pages
    for ix in range(len(allthumbs)):        # listing must agree with index
        thumbname = allthumbs[ix]
        imagename = os.path.join(imageDir, thumbname)

        # 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(imagename)
            imageModDate = formatDateTime(time.localtime(filetimestamp))
        except:
            imageModDate = '(unknown)'

        # [2.0] pull this out to avoid double loads
        try:
            image = openImageSafely(imagename)
            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:
            # massage text to embed it in both JS string and HTML code [2.3]
            maker = escapeForJavaScriptandHTML(maker)
            deviceLine = "'\\n%s: %s'" % (label, maker)

            # was: maker.replace('\\', '?').replace('\'', '?') [2.3]

        #------------------------------------------------------------------------------
        # [2.3]: fetch Note's text from an imagename.note file, if any for image
        #------------------------------------------------------------------------------

        notepath = imagename + '.note'
        if not os.path.exists(notepath):
            notecontent = ''  # becomes '(No note)' in JS
        else:
            try:
                notecontent = open(notepath, 'r', encoding=noteEncoding).read()
            except:
                notecontent = '(Undecodable note)'

            # massage text to embed it in both JS string and HTML code
            notecontent = escapeForJavaScriptandHTML(notecontent)

            # treat \n\n as html paragraph break in popup 
            notecontent = notecontent.replace('\n\n', '<br><br>\n\n')

            # "line\n" => "line\\n " for JS multiline str
            notecontent = notecontent.replace('\n', '\\\n ') 

            # unlike info, Note now uses <p> instead of <pre> so text auto-wraps to 
            # fill popup (sans <br> pbreaks); that makes \n mostly moot - original:
            # notecontent = notecontent.replace('\n', '\\\n\\n')   # JS multiline: '\<br>\n'

        #------------------------------------------------------------------------------
        # collect template substitution values
        #------------------------------------------------------------------------------

        def booleanJS(value): 
            """
            Python True/False => JavaScript true/false
            Or ['false', 'true'][value], {True: 'true', False: 'false'}[value]
            """
            return 'true' if value else 'false'

        def tooltipCode(tiptext):
            """
            Format an optional HTML attribute
            """
            return ('title="%s"' % tiptext) if useToolTips else ''

        def firstElseSecond(first, second):
            """
            Use first if not None, else second
            """
            return first if first != None else second

        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 = booleanJS(expandSmallImages),

            # enable history destacking if this browser fixes location.replace()
            CHROMEIOSBACKFIXED = booleanJS(chromeiOSBackFixed),

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

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

            # [1.7] i+S+L uses legacy CSS display instead of JS scaling??
            IOSSAFARILANDSCAPECSS = booleanJS(iOSSafariLandscapeCSS), 

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

            # [2.0] show Full toggle in viewer toolbars? (config per gallery)
            FULLSCREENBUTTON = booleanJS(showFullscreenButton),

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

            # [2.2] do left/right swipes match the order of Prev/Next buttons?
            LRSWIPESPERBUTTONS = booleanJS(lrSwipesPerButtons),

            # [2.2] has the Chrome Back-after-Up glitch been fixed?
            UPSWIPEONALL = booleanJS(upSwipeOnAllBrowsers),
            
            # [2.2] use tooltip popups on hover via title attrs?
            IMAGETIP      = tooltipCode('View raw image'),
            FILENAMETIP   = tooltipCode('View image info'),
            AUTOTIP       = tooltipCode('Toggle slideshow'),
            FULLSCREENTIP = tooltipCode('Toggle one-page fullscreen'),

            # [2.3] add tooltips to Prev/Next/Index for consistency
            PREVTIP  = tooltipCode('Go to previous image'),
            NEXTTIP  = tooltipCode('Go to next image'),
            INDEXTIP = tooltipCode('Go to thumbnails page'),

            # [2.3] patch note-file text, etcetera, into template
            ENABLENOTES   = booleanJS(anynotesfound and useImageNotes),
            NOTECONTENT   = notecontent,
            NOTEBOXVSPACE = noteBoxVSpace,
            NOTETIP       = tooltipCode('View image description'),

            # [2.3] Info/Note popup colors may vary from viewer page; overlay dimness
            POPUPBGCOLOR = firstElseSecond(popupBgColor,     viewerBgColor),
            POPUPFGCOLOR = firstElseSecond(popupFgColor,     viewerFgColor),
            POPUPBDCOLOR = firstElseSecond(popupBorderColor, viewerBorderColor),
            POPUPOPACITY = popupOpacity,

            # [2.3] pass index's name for Index button: can vary per build
            INDEX = INDEX    # viewer adds .html, key=value 
            )

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

    # and goto next image file/page





#==========================================================================
# 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)

    # +dir, with quotes ('.' means something in paths) [2.1]
    print('Finished: see the results in the %s folder, "%s".' % 
               ('imageless' if imageless else 'images', imageDir))    # kind [2.1]

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



[Home page] Books Code Blog Python Author Train Find ©M.Lutz