File: thumbspage/thumbspage.py

File: thumbspage/thumbspage.py

#!/usr/bin/env python3
"""
===========================================================================
thumbspage.py - Turn Folders into HTML Image Galleries
Version: 3.0.1, August 2025 (patch to 3.0, June 2025)
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-2025.
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.5+, 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:
- (Aug-2025) Version 3.0.1 applied just one mod to the viewer-page
 template file. It added CSS to disable pull-to-refresh in Android 
 Chrome, because that browser alone recently started intercepting 
 and consuming down swipes used by galleries for image info.
 Pull-to-refresh intercepts cropped up in AC around version 135.
 AC is 67% of the mobile market today per statista and statcounter, 
 so its churn (bugs?) can't be ignored. Stats vary per gallery, 
 but even the 27% for Chrome+Webview at the hosting site matters.
 
 The fix sets overscroll-behavior-y to none to compensate for 
 JS preventDefault() now being ignored in AC. More info at 
 [https://learning-python.com/thumbspage/UserGuide.html#3.0.1].
- (Jun-2025) As of 3.0:
 + Filename extensions can be omitted from image labels on both 
 index and viewer pages to better support filenames as captions,
 subject to character limitations of hosting filesystems.
 + End-of-gallery during viewer-page navigation may be auto called out 
 by (1) 2-second messages and/or (2) an end-of-gallery image not shown 
 in index pages. Both apply to Next/Prev (and their swipes) and Auto.
 + Image taps emit a message before changing to raw-view mode, so 
 users know totap Back to return to the gallery.
 + The text of image Notes now allows HTML tags and entities to be embedded
 and applied in viewer pages. Use _<...> and _&...; to escape any tags 
 and entities in notes. Tags allow bold, italics, <hr>, spans, and links
 (_<a href="xxx">xxx_</a>); entities allow Unicode and more (_&#x1F44D;).
 + If present in the image folder, ORDER.txt provides the order of 
 a gallery's images in both index and viewer pages. Top-to-bottom
 order of filename lines in this file gives gallery image order.
 + If present in the images folder, CAPTIONS.py provides a Python
 dictionary that maps image filenames to image labels used in index
 and viewer pages. This augments and overrides filename-based labels.
 + If present in the images folder, NOTES.py provides a Python dictionary 
 that maps image filenames to image note text used in viewer pages. 
 This augments and overrides individual "filename.note" text files.
 + If useCannedDarkTheme, the CSS code in template-autothemes.html is 
 inserted into both index and viewer pages to support a dark theme
 that can be forced or based on and responsive to the host's mode.
 Tactical mods and user configs are are also applied in templates.
 + If textLineSpacing, it provides an explict value used to set line
 spacing in page text that overides browsers' defaults.
 + Image filename is now displayed in info popup (label tap, downswipe)
 in case extensions are omitted or CAPTIONS.py is used.
 + In viewer pages, keyboard keys [p, n, i, t, a, f, ?, ., Enter] are 
 now the same as taps (or equivalent swipes) for [Prev, Next, Index, Note,
 Auto, Full, label (info), image (raw view), and OK (when open)]. 
 + Swipes on touchpads and mousewheels now work, in addition to the 
 former touchscreen swipes, and may be disabled in configs. This
 is currently laggy compared to touchscreens, due to debouncing.
 + Verifies that filenames in .note files and the contents of ORDER.txt,
 NOTES.py, and CAPTIONS.py reference real image filenames in the images
 folder. This caught a trnpix note sans ".jpg" and unused for years.
 + Forces Notes to stay up in viewer pages until explicitly closed,
 even after image naviations and returns from embedded links via Back.
 This normalizes URLs (in JS) so all variants reopen notes on navs.
 + Some preset defaults in user_configs.py have changed. E.g., index
 pages now use dynamic (responsive) layout mode as preset. This and
 the new syntax for note-text tags can be backwards incompatible.
- (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)
 + Use 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.
As of 3.0, there are four new config-related files:
- "template-autothemes.html" in this program implements color themes
- optional "ORDER.txt" in the image folder gives image ordering
- optional "CAPTIONS.html" in the image folder gives image labels
- optional "NOTES.html" in the image folder gives image note text
[== See UserGuide.html for additional docs originally located here ==]
===========================================================================
"""
VERSION = '3.0.1' # 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-arg/file 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.
#
# ALGORITHM: This replaces options in the module's namespace with any 
# option=value command-line arguments, shrinking sys.argv in the process.
# The net effect overrides the file with the arguments, leaving no 
# option=value. This could be more explicit (and defer sys.argv processing
# until after fetching the module's settings), but this coding stems from 
# this script's history and its use of globals--subpar, perhaps, but legacy.
#
# 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 by 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
 # end-of-gallery automation settings (raw-view message always emitted) [3.0]
 useEndOfGalleryPage, # True=add an auto end-of-gallery page for all wraparounds
 useEndOfGalleryMessage, # True=show a 2-second transient message for all wraparounds 
 
 # suppress filename extensions in index and viewer pages? [3.0]
 omitFilenameExtensions, # True=do not show filename extensions in image labels
 
 # use color themes based on and responsive to host-device settings? [3.0]
 useCannedDarkTheme, # template-autothemes.html theme: False, 'host', or 'always'
 darkThemeLinksColor, # color of all <a> links in theme, incl. viewer nav links
 darkThemePopupBgColor, # background color for all viewer popups in dark theme 
 darkThemePopupFgColor, # foreground color for all viewer popups in dark theme 
 darkThemePopupBorderColor,
 darkThemeViewerBgColor, # viewer pages backgrouns color (surface) in dark theme
 darkThemeViewerFgColor, # viewer pages foreground color (text) in dark theme
 darkThemeViewerBorderColor, # fullsize image border in viewer pages
 darkThemeIndexBgColor, # index page background (surface) in dark theme
 darkThemeIndexFgColor, # index page foreground (text) in dark theme
 darkThemeThumbsBgColor, # thumbs table background (surface) in dark theme
 darkThemeThumbsFgColor, # thumbs table foregrouns (text) in dark theme
 darkThemeThumbsBorderColor, # thumbs image border in index pages
 # set explicit line spacing for text, overriding browsers' defaults? [3.0]
 textLineSpacing, # None = browsers' defaults as before
 # respond to swipes on touchpads and mousewheels (not just touchscreens) [3.0]
 doTouchpadMouseSwipes, # False to disable
 # colorize <a> links in Note text, subject to darkThemeLinksColor overide [3.0]
 popupLinksColor, # None/False = inherit from note's popupFgColor
 # full-page colors for index pages (outside the thumbnails table) [3.0]
 indexBgColor, # background (sureface) CSS body color
 indexFgColor, # foreground (text) CSS body color
)
#==========================================================================
# CONFIGURE, PART 2: console inputs for per-run options, enter=default
#==========================================================================
# [3.0] regrettably still top-level code with globals; improve me...
#--------------------------------------------------------------------------
# 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: # NOTE: all option=value arguments have been stripped!
 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 and config-file mods/loads
#--------------------------------------------------------------------------
print('\nRunning...') # [3.0] separate inputs and outputs
# 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')
# [3.0] drop prior run's auto image so doesn't appear in index page
endGalleryImageName = 'End of gallery.jpg'
for eogname in (endGalleryImageName, endGalleryImageName.replace('.', '-light.')):
 if os.path.exists(os.path.join(imageDir, eogname)):
 os.remove(os.path.join(imageDir, eogname))
# [3.0] load optional image-order, image-captions, image-notes files
orderPath = os.path.join(imageDir, 'ORDER.txt') 
captionsPath = os.path.join(imageDir, 'CAPTIONS.py')
notesPath = os.path.join(imageDir, 'NOTES.py')
if os.path.exists(orderPath):
 try: # index and viewer pages
 _orderfile = open(orderPath, encoding=insertEncoding) # encoding=header/footer
 orderList = [line.rstrip('\n') for line in _orderfile.readlines()]
 _orderfile.close()
 assert isinstance(orderList, list)
 except:
 print('**Error loading', orderPath) # unlikely, but...
 print(sys.sxc_info())
 orderList = []
else:
 orderList = []
if os.path.exists(captionsPath):
 try: # index and viewer pages
 _captionsfile = open(captionsPath, encoding='utf8') # encoding=py 3.x code norm
 captionsDict = eval(_captionsfile.read(), {})
 _captionsfile.close()
 assert isinstance(captionsDict, dict)
 except:
 print('**Error loading', captionsPath)
 print(sys.exc_info())
 captionsDict = {}
else:
 captionsDict = {}
if os.path.exists(notesPath):
 try: # viewer pages only
 _notesfile = open(notesPath, encoding='utf8') # encoding=py 3.x code norm
 notesDict = eval(_notesfile.read(), {})
 _notesfile.close()
 assert isinstance(notesDict, dict)
 except:
 print('**Error loading', notesPath)
 print(sys.exc_info())
 notesDict = {}
else:
 notesDict = {}
# [3.0 warn if any filenames used for notes (files or dict), captions (dict), or 
# explicit ordering (list) do not match an actual image filename; here, so early 
# in output; bogus names are unused by notes and captions, skipped by ordering;
# must use case-sensitive matching here because later use of loaded entries does 
# too (actually, .note matching did not, but is does now for global consistency);
allimgfolderfiles = os.listdir(imageDir) # case-sensitive, and avoid N os.path.exists()
filerefs = [
 ('.note file', [os.path.splitext(ref)[0] for ref in glob.glob('*.note')]),
 ('NOTES.py key', notesDict.keys()),
 ('CAPTIONS.py key', captionsDict.keys()),
 ('ORDER.txt name', orderList)
]
for (kind, filenames) in filerefs:
 fileless = [ref for ref in filenames if not ref in allimgfolderfiles]
 if fileless:
 print('*CAUTION: %s%s sans images: %s' % 
 (kind, ('s' if len(fileless) > 1 else ''), fileless)) # f'' requires py3.6+
#==========================================================================
# 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 %= 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>'
# [3.0] explicit value for index-page text line spacing if != None (also in viewers)
linespacingcss = '<style>p, li {line-height: %s;} </style>'
# [3.0] configurable body colors (outide thumbnails table, overridden in dark mode)
indexcolors = '<!--colors-->\n ' \
 '<style>body {color: %s; background-color: %s;}</style>'
indexcolors %= (indexFgColor, indexBgColor)
#--------------------------------------------------------------------------
# Back to Python code (mostly)
#--------------------------------------------------------------------------
def loadAndExpandThemeTemplate():
 """
 ----------------------------------------------------------------
 [3.0] Trivial helper for dark-theme code inserts, used for both
 index and viewer pages. If config is 'always', %(FORCEDARKMODE)s
 in the template is replaced with CSS code that matches _both_ 
 light and dark mode. Like TOP, this template augments pages and
 differs from the broader viewer-page template.
 Some tactical mods are also applied in main template-viewpage.html
 for settings that are difficult to override via template-autothemes.
 Search on useCannedDarkTheme and see template files for more info.
 Note: iOS Safari (version 15.8, at least) is weirdly picky about
 media-query header syntax. It barfed when using an initial format 
 that spanned lines, and again later when using an "or" in the
 insert below instead of a "," - despite "or" being accepted by 
 every other browser and documented widely. This cost hours...
 ----------------------------------------------------------------
 """
 text = loadTemplateFile('template-autothemes.html')
 if useCannedDarkTheme == 'host': 
 insert = '' # py "match" is 3.10+.
 elif useCannedDarkTheme == 'always':
 insert = '(prefers-color-scheme: light), ' # a comma means 'or'
 else:
 print("invalid useCannedDarkTheme setting: using 'host'")
 insert = ''
 # expand template with query + configs (some also used in main viewer template
 return text % dict(FORCEDARKMODE=insert, 
 DARKLINKSCOLOR=darkThemeLinksColor,
 DARKPOPUPBG=darkThemePopupBgColor,
 DARKPOPUPFG=darkThemePopupFgColor,
 DARKPOPUPBD=darkThemePopupBorderColor,
 DARKINDEXBGCOLOR=darkThemeIndexBgColor,
 DARKINDEXFGCOLOR=darkThemeIndexFgColor,
 DARKTHUMBSBGCOLOR=darkThemeThumbsBgColor,
 DARKTHUMBSFGCOLOR=darkThemeThumbsFgColor,
 DARKTHUMBSBDCOLOR=darkThemeThumbsBorderColor,
 DARKVIEWERBDCOLOR=darkThemeViewerBorderColor)
def orderedListing(dirpath, casesensitive=caseSensOrder, subs=False):
 """
 ----------------------------------------------------------------
 [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 viewer (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']
 [3.0] If present in the images folder, a file ORDER.txt now
 gives an explicit ordering of images in the gallery, which 
 overrides filename ordering. ORDER.txt contains one image
 filename per line; their top-to-bottom order in the file is
 used to order filenames fetched from the directory listing.
 Any filenames in the folder but not in ORDER.txt appear after 
 those listed in ORDER.txt and ordered by filename as before.
 This is used by both index and viewer pages (both call here),
 and orderList is loaded from ORDER.txt earlier in this script.
 Also called for subfolder links generation: don't reorder if 
 subs=True (ORDER.txt is just for explicitly ordering images). 
 ----------------------------------------------------------------
 """
 # order per filename
 if casesensitive:
 ordered = sorted(os.listdir(dirpath)) # omits folder path
 else:
 ordered = sorted(os.listdir(dirpath), key=str.lower)
 # reorder image names per ORDER.txt file?
 if orderList and not subs:
 reordered = []
 for filename in orderList:
 if filename in ordered: # case-sensitive
 reordered.append(filename)
 ordered.remove(filename)
 else:
 pass # warned earlier in this script
 ordered = reordered + ordered
 return ordered # ordered per file + any leftovers (or all) by filename
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.
 [3.0] use CAPTIONS.py label instead of filename, if present,
 and drop filename extension from filename if so configured.
 ----------------------------------------------------------------
 """
 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)) 
 # [3.0] try CAPTIONS.py file dict first, then filename (case-sensitive)
 if thumbname in captionsDict:
 thumbname = str(captionsDict[thumbname])
 # [3.0] drop filename extension for display in index page?
 elif omitFilenameExtensions: 
 thumbname = os.path.splitext(thumbname)[0]
 
 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, subs=True): # 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.
 Results for usetime!=None are derived from the time passed in.
 Results for usetime==None may vary across calls (tbd: cache?).
 ----------------------------------------------------------------
 """
 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.
 [3.0] this is now also used to load template-autothemes.html.
 ----------------------------------------------------------------
 """
 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 id="thumbslinks" style="%s">' % styleTableThumbs) # [3.0] add id for styles
 # 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
 print(indexcolors) # [3.0] body fg/bg color configs
 if noiOSIndexTextBoost: # [1.7] don't upscale landscape text? 
 print(noiostextboost)
 print('<title>Index of %s</title>' % # [3.0] title before marker, for ga4 
 escfoldername)
 print(headinsertkey) # [2.0] add marker for search/replace
 fldrkind = 'Image ' if imglinks else '' # [2.1] drop 'Image' is there are none
 print('\n</head>\n<body>\n'
 '<h1 %s>Index of %sFolder "%s"</h1>\n' % 
 (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')
 # [3.0] late in file to override earlier CSS, subject to CSS specificity;
 # will override custom header but not footer when ordering selects rules;
 if useCannedDarkTheme or textLineSpacing:
 sectionSeparator('start theme and spacing')
 # [3.0] template's CSS dark theme code, if enabled
 if useCannedDarkTheme:
 print('\n' + loadAndExpandThemeTemplate())
 # [3.0] line-spacing CSS for all index text
 if textLineSpacing: 
 print('\n' + (linespacingcss % textLineSpacing) + '\n')
 sectionSeparator('end theme and spacing')
 # 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.
 [3.0] Assigning note text to the DOM's innerText instead innerHTML
 may have made some escaping optional, but this is now required by 
 the new support for embedded HTML tags in note tags. Note that 
 nested tags in innerHTML require a special CSS * rule to inherit 
 colors because they are added post DOM build time; see template. 
 [3.0] embedded HTML tags and entities embedded in note text and 
 escaped with a leading underscore (_<a...>, _&mdash;) are no 
 longer displayed verbatim, but are retained and passed on to 
 the browser for rendering. See escapeNoteWithEmbeddedHTMLTags().
 -----------------------------------------------------------------------
 """
 # [< &] => [&lt &amp] for HTML, [" '] => [&quot; &#x27;] for JS
 text = html_escape(text)
 # [\] => [\\] for JS
 text = text.replace('\\', '\\\\') # must do before adding any \ ahead!
 # [$] => [\$] 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 '\$' in 3.13+: personality disorders... 
 # [' "] => [\' \"] for JS (for older html_escape in Pythons 3.1-)
 text = text.replace("'", '\\\'').replace('"', '\\"')
 # caller may now add ,円 \n, and <br> for formatting
 return text
def escapeNoteWithEmbeddedHTMLTags(text):
 r"""
 -----------------------------------------------------------------------
 [3.0] Escape note text per JS and HTML rules, but strip out any embedded
 HTML tags and entities coded with syntax '_<...>' and '_&...;' and escape
 them specially for pass-through to innerHTML: don't escape their HTML 
 special characters, but do escape their nested characters for embedding 
 in a JS string in the viewer page (e.g., quotes in <a> href attributes). 
 Tags allow notes to have hyperlinks, bold, italics, spans with colors and 
 fonts, hr dividers, etc. Entities support things like &nbsp;, &mdash;, 
 and Unicode _&#x1F44D; (though most special characters and emojis can be 
 coded literally in notes too, per encodings and editors). 
 To do this, we do a pattern split to extract tag|entity and other parts, 
 escape non-tag|entity parts for JS and HTLM as before, escape tag|entity
 parts per JS only, and put the parts back together before the special \n 
 handling here is applied. The result works in a JS string and HTML page. 
 This will fail if a note happens to have a stray '_<...>' or _&...;, but 
 it's very unlikely. This will also fail if a '>' is embedded in tag value, 
 but that's unlikely too; in worst cases, uses can preescape tag values 
 per HTML/URL conventions, and esapce stray leading underscores as '_&#95;'.
 
 Example before/after Python reprs follow, where after includes the \n tweaks
 made in the caller. Within them, '\\\\' is '\\' per both Py and the JS string
 in the viewer page, and JS passes the result on to an HTML processor where 
 '&lt;' and '&#x27;' become '<' and "'", respectively:
 'aa<aa>aa_<A HREF="google.com\\maps">bb!_bb_</A>_<B>cccc_</B>dd\n\nd d'
 'aa&lt;aa&gt;aa<A HREF="google.com\\\\maps">bb!_bb</A><B>cccc</B>dd<br><br>\\\n \\\n d d'
 "The _<B>best_</B> book\nyou'll find\n\nat _<A href='amazon.com'>$amazon_</A>!"
 "The <B>best</B> book\\\n you&#x27;ll find<br><br>\\\n \\\n at <A href=\\'amazon.com\\'>\\$amazon</A>!"
 Note that tags may span multiple lines in note text, thanks to re.DOTALL.
 This also makes a stray '_<' lookahead to end of string or first '>' 
 instead of giving up at the end of the line, but it's probably a net win?
 [3.0] Entities were added as an afterthought (hence the function name).
 They aren't as broadly useful as tags, since most special characters and 
 emojis can be coded literally in notes too (exception: _&nbsp;). 
 -----------------------------------------------------------------------
 """
 import re
 tagpatt = '(_<.+?>|_&.+?;)' # capture entire tag-or-entity sections
 tagpatt = re.compile(tagpatt, re.DOTALL) # so + matches any char, including newline
 parts = re.split(tagpatt, text) # list of html and non-html parts
 escparts = []
 for part in parts:
 if re.match(tagpatt, part):
 # drop _, [,円 '] => [\,円 \'] for JS '...' string (" ok, order matters!)
 escparts.append(part[1:].replace('\\', '\\\\').replace("'", "\\'"))
 else:
 # apply all JS/HTML escapes 
 escparts.append(escapeForJavaScriptandHTML(part))
 text = ''.join(escparts)
 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)]
 # [3.0] add auto end-of-gallery image if enabled (not shown in index page)
 if useEndOfGalleryPage:
 # copy ./endGalleryImageName to imageDir # TBD: light-mode alt?
 scriptdir = os.path.dirname(__file__) # this script's folder
 eogfrompath = os.path.join(scriptdir, endGalleryImageName) # see also shutil.copy2()
 eogtopath = os.path.join(imageDir, endGalleryImageName)
 eogfromfile = open(eogfrompath, mode='rb')
 eogtofile = open(eogtopath, mode='wb')
 eogtofile.write(eogfromfile.read())
 eogfromfile.close()
 eogtofile.close()
 # add to end of gallery, for view-pages nav only (manual and auto)
 allthumbs.append(endGalleryImageName) # no real thumb: not in index page
 # first/last indexes, may include auto eog image at end
 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 
 # [3.0] also enable notes if NOTES.py file present (and assume valid+nonempty)
 anynotesfound = anynotesfound or os.path.exists(notesPath)
 # 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 (+auto eog?)
 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
 # [3.0]: try NOTES.py dict for image Note text first, before a .note file
 # [3.0]: also make .note matching case-sensitive everywhere so it works same 
 # as NOTES.py, using os.listdir() instead of original os.path.exists(notepath)
 #------------------------------------------------------------------------------
 notename = thumbname + '.note'
 notepath = imagename + '.note'
 if thumbname in notesDict: # [3.0] NOTES.py first (case-sens)
 notecontent = str(notesDict[thumbname])
 elif notename in os.listdir(imageDir): # then filename.note ([3.0] case-sens)
 try:
 notecontent = open(notepath, 'r', encoding=noteEncoding).read()
 except:
 notecontent = '(Unloadable note)'
 else:
 notecontent = '' # becomes '(No note)' in JS
 if notecontent:
 # massage text to embed it in both JS string and HTML code
 notecontent = escapeNoteWithEmbeddedHTMLTags(notecontent) # [3.0] allow tags
 # 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 (space=separator)
 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
 # [3.0] try CAPTIONS.py file dict if present, else use filename
 # [3.0] drop extension for display in viewer page label+title?
 if thumbname in captionsDict: # [3.0] captions file first (case sens)
 thumbshow = str(captionsDict[thumbname])
 elif omitFilenameExtensions: # [3.0] filename extensions
 thumbshow = os.path.splitext(thumbname)[0]
 else:
 thumbshow = thumbname # else full filename
 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(thumbshow),
 IMAGEPATH = url_escape('../' + thumbname), 
 # nav links: pages in '.', wrap around at end/start ([3.0] may be auto end page)
 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
 IMAGEFILENAME = html_escape(thumbname), # [3.0]: for caption or !extension
 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] left/right swipes vs Prev/Next buttons (subject to users)?
 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 
 # [3.0] for end-of-gallery message in JavaScript
 FIRSTIMAGEMESSAGE = booleanJS(useEndOfGalleryMessage and ix == 0),
 LASTIMAGEMESSAGE = booleanJS(useEndOfGalleryMessage and ix == thumbN),
 # [3.0] add CSS themes code if enabled by non-empty config
 AUTOCOLORTHEMESCODE = loadAndExpandThemeTemplate() if useCannedDarkTheme else '',
 # [3.0] apply dark-mode mods in viewer template too? (see its docs) 
 ENABLEDARKMODEMQ = ('false-media-type' if not useCannedDarkTheme else 
 'all' if useCannedDarkTheme == 'always' else
 '(prefers-color-scheme: dark)'),
 # [3.0] dark-theme-only color configs (same also used in autothemes template)
 DARKBGCOLOR = darkThemeViewerBgColor,
 DARKFGCOLOR = darkThemeViewerFgColor,
 # [3.0] colorize <a> links in Note text, subject to darkThemeLinksColor overide
 POPUPLKCOLOR = popupLinksColor or 'inherit', # config or from parent note
 # [3.0] explicit value for Info/Note text line spacing if != None (also in index)
 TEXTLINESPACE = ('line-height: %s;' % textLineSpacing) if textLineSpacing else '',
 # [3.0] enable touchpad/mousewheel swipes too?
 DOTOUCHPADMOUSESWIPES = booleanJS(doTouchpadMouseSwipes),
 )
 #------------------------------------------------------------------------------
 # 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__':
 # now earlier: 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



AltStyle によって変換されたページ (->オリジナル) /