#!/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 <.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 FOOTER.html should add any post-table content and close both and 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., , but not %s), 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>&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 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
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_
s: [2.2] */ border-radius: 6px; /* chrome botches
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 = '' # generate UTF-8 content tag for non-ASCII use cases contype = '\n ' \ '' contype = contype % outputEncoding # [1.4]: be mobile friendly viewmeta = '\n ' \ '' # [1.5]: default-header font for index pages (same in viewer pages) indexfont = '\n ' \ '' # [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\n' # [1.7]: don't upscale (boost) text size in landscape on iOS Safari noiostextboost = ( '\n ' '') # [2.0] add a comment that scripts can search for to insert code headinsertkey = '' # [2.0] wrap long

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 = '\n ' \ '' #-------------------------------------------------------------------------- # 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 = ('\n\t' % # [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('%s' % (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('') print('' % message.upper()) print('') 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 when a custom header is used. This argument seems less valid today, given that
Coding note: the "overflow-x: auto" on the top-level
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
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

') print('

\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

') # drop


print('
') # table autoscroll on small screens [1.4] # whole-window scroll breaks mobile widgets # [1.5] styled top/bottom borders, not
print('' % styleTableThumbs) # create thumb-link-cells code while imglinks: row, imglinks = imglinks[:thumbsPerRow], imglinks[thumbsPerRow:] print('') 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 = '' # link already has border print(colcode % (colstyle, link)) # filenames optional [2.3] else: colcode = '' # link has border print(colcode % (colstyle, link, labstyle, escname)) # name is html escaped print('') # end row (dropped [1.4]: use css) print('
\n\t%s\n\t%s\n\t

%s

\n\t

\n') # end table (dropped
[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('') 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('Index of %s' '\n\n\n' '

Index of %sFolder "%s"

\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('

Subfolders here:

    ') for link in sublinks: linkstyle = 'style="margin-bottom: %s;"' # add space for mobile [1.4] linkstyle %= subfolderSpacer # config, 6px->7px preset [2.1] print('
  • %s' % (linkstyle, link)) # add space below list [1.4] print('

') 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 = '%s built by thumbspage.py' tagline %= ('Page' if imageless else 'Gallery', webpage) else: tagline = '' print('\n%s

' % (extraAtBottom, tagline)) print('') 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 < and & " and ' are changed to " and ' 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<&b"c'd${}e\f\g >>> text = text.replace('\\', '\\\\'); print(text) a<&b"c'd${}e\\f\\g >>> text = text.replace('$', '\$'); print(text) a<&b"c'd\${}e\\f\\g >>> text = text.replace("'", '\\\'').replace('"', '\\"'); print(text) a<&b"c'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<&b"c'd${}e\f\g >>> text = text.replace('\\', '\\\\'); print(text) a<&b"c'd${}e\\f\\g >>> text = text.replace('$', '\$'); print(text) a<&b"c'd\${}e\\f\\g >>> text = text.replace("'", '\\\'').replace('"', '\\"'); print(text) a<&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

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. ----------------------------------------------------------------------- """ # [< &] => [< &] for HTML, [" '] => [" '] 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
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', '

\n\n') # "line\n" => "line\\n " for JS multiline str notecontent = notecontent.replace('\n', '\\\n ') # unlike info, Note now uses

instead of

 so text auto-wraps to 
            # fill popup (sans 
pbreaks); that makes \n mostly moot - original: # notecontent = notecontent.replace('\n', '\\\n\\n') # JS multiline: '\
\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