#!/usr/bin/env python3
"""
Generate a HTML+JavaScript-based gallery from images in the current directory.
Generates an 'index.html' file, a 'download.php' file (if the --download-all
option is used), and a directory 'i' with thumbnails and preview images.

Options:manman
  -h, --help             display this help text and exit
  -V, --version          display version number and exit
  -C DIR, --chdir=DIR    work in directory DIR instead of current directory
  -f, --force            $"don't " if ForceCreateImages else ""$force re-creation of images even if they exist
  -j N, --jobs N         run N threads in parallel (default: detected as $Threads$)
  -T TEXT, --title TEXT  set gallery title to TEXT
  -t WxH, --thumb WxH    set thumbnail size to WxH (default: $'x'.join(map(str, ThumbnailSize))$)
  -s WxH, --size WxH     set preview image size to WxH (default: $'x'.join(map(str, PreviewSize))$)
  -d D, --diagonal D     determine preview image size by diagonal D (or WxH)
  -a WxH, --area WxH     determine preview image size by area equivalent of WxH
  -q, --quality          set preview image JPEG quality (default: $PreviewQuality$)
  -Q, --thumbquality     set thumbnail JPEG quality (default: $ThumbnailQuality$)
  -w, --wrap             $"disable" if WrapAround else "enable"$ wrap-around
  -r, --right-clicks     $"don't capture" if RightClicks else "capture"$ right clicks
  -D, --download-links   $"don't " if DownloadLinks else ""$generate links for full-size downloads
  -A, --download-all     $"don't " if DownloadAll else ""$generate script to download all images (requires PHP)
"""
__version__ = "1.2.1"
__author__ = "Martin J. Fiedler <keyj@emphy.de>"
__copyright__ = """
This software is published under the terms of KeyJ's Research License,
version 0.2. Usage of this software is subject to the following conditions:
0. There's no warranty whatsoever. The author(s) of this software can not
   be held liable for any damages that occur when using this software.
1. This software may be used freely for both non-commercial and commercial
   purposes.
2. This software may be redistributed freely as long as no fees are charged
   for the distribution and this license information is included.
3. This software may be modified freely except for this license information,
   which must not be changed in any way.
4. If anything other than configuration, indentation or comments have been
   altered in the code, the original author(s) must receive a copy of the
   modified code.
"""
__changelog__ = """
1.2.1 [2024-08-18]
- Python 3 port

1.2.0 [2018-07-22]
- parallel processing
- default image size increased to 1024

1.1.1 [2018-07-19]
- fixed crash in --help
- made -r/--right-click option work

1.1.0 [2017-10-03]
- multiple thumbnails are now combined into atlases (using "CSS sprites")
- storing "configuration cache" file for all parameters, not just gallery title
- common output directory "i" for previews, thumbnail atlases and config cache
- added autorotation based on Exif data
- fixed crash on Exif rational numbers with zero denominator (e.g. aperture
  "0/0" when no aperture information is available on Fujifilm cameras)
- fixed -f/--force option (has been rejected by command-line parser)

1.0.6 [2017-09-17]
- another Firefox fix
- Pillow import fix

1.0.5 [2013-02-03]
- added generation of a PHP script to download all original images

1.0.4 [2013-02-02]
- re-create images only when needed (obsoletes -n parameter)
- import title from existing index.html if not specified

1.0.3 [2013-01-27]
- added autosize and fade-in/fade-out on image change
- improved Firefox compatibility
- preloading now starts only after the current image has already been loaded
- added "loading" image

1.0.2 [2013-01-12]
- first public version
"""
import sys, os, re, math, getopt, unicodedata, base64, gzip, threading
__prog__ = os.path.splitext(os.path.basename(sys.argv[0]))[0]
from PIL import Image, ImageFile
ImageFile.MAXBLOCK = 256*1024*1024

WidthHeight, Diagonal, Area = range(3)

Title = None
ForceCreateImages = False

DataDir = "i"

ThumbnailAtlasSize = 2048
ThumbnailSize = (160, 120)
ThumbnailQuality = 70

PreviewSize = (1024, 1024)
PreviewSizeMode = WidthHeight
PreviewQuality = 95

WrapAround = True
RightClicks = False
DownloadLinks = False
DownloadAll = False

Mutex = threading.Lock()
try:
    Threads = int(os.getenv("NUMBER_OF_PROCESSORS") if (sys.platform == "win32") else os.sysconf("SC_NPROCESSORS_ONLN"))
except (AttributeError, ValueError):
    Threads = 1
if sys.platform == "win32":
    Threads = 0  # hack: disable MT on Win32 because it's non-interruptible

DownloadIcon = """
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="12">
<style>*{fill:#888888;stroke:none}</style>
<rect width="8" height="2" x="0" y="10"/>
<path d="M0,5 4,9 8,5 5,5 5,1 3,1 3,5z"/>
</svg>
""".replace('\n', '').replace('\r', '')

DownloadAllIcon = """
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="14" height="12">
<style>*{fill:#888888;stroke:none}</style>
<rect width="13" height="2" x="0" y="10"/>
<path d="M0,5 4,9 8,5 5,5 5,1 3,1 3,5z"/>
<g style="fill-opacity:0.7;"><path d="M11,5 7,9 6,8 9,5z"/><rect width="2" height="3" x="6" y="1"/></g>
<g style="fill-opacity:0.4;"><path d="M14,5 10,9 9,8 12,5z"/><rect width="2" height="3" x="9" y="1"/></g>
</svg>
""".replace('\n', '').replace('\r', '')

HTML = """<!DOCTYPE html>
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="Generator" content="KeyJs Gallery $__version__$" />
<link rel="next" href="#" />
<title>$H(Title)$</title>
<style type="text/css">

html, body {
    background: #111;
    margin: 0;
    padding: 0;
    color: #ccc;
    height: 100%;
    width: 100%;
    overflow: hidden;
    font-family: sans-serif;
}

a {
    color: #ccc;
    text-decoration: none;
    border: none;
}

.shift {
    margin-left: $ThumbnailBarWidth$px;
}

#thumbs {
    position: absolute;
    background: #333;
    width: $ThumbnailBarWidth$px;
    height: 100%;
    overflow-y: scroll;
}

#thumbs a {
    display: block;
    margin-left: auto;
    margin-right: auto;
    text-align: center;
    margin-top: 2px;
    margin-bottom: 2px;
    border: 2px solid #111;
    opacity: 0.5;
}

#thumbs a:hover, #thumbs a.active {
    border: 2px solid #fff;
    opacity: 1;
}

#viewer {
    display: table;
    height: 100%;
    width: 100%;
    overflow: hidden;
}

.loading {
    background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC0AAAAHAQMAAACm41aJAAAAAXNSR0IArs4c6QAAAAZQTFRFERERRERESv/MrQAAAAJ0Uk5TAP9bkSK1AAAAMklEQVQI12M4+LB/3pkPDIeNz5x5O4PhsFnOmb8NEGoHw2HzN2fuQgTPzmD4+SwfqBIAue8cmDiHpmsAAAAASUVORK5CYII=) no-repeat 55% 50%;
}

#image {
    display: table-cell;
    vertical-align: middle;
}

#image img {
    display: block;
    margin-left: auto;
    margin-right: auto;
    box-shadow: 0px 0px 12px #000;
}

#header {
    text-align: center;
    font-weight: bold;
    display: table-header-group;
}
#header span {
    text-shadow: 0px 1px 0px #444;
}
#nav {
    float: right;
}
#nav a {
    display: inline-block;
    margin: 0;
    font-size: 13px;
    width: 20px;
    height: 20px;
}
#nav a:hover {
    background: #ccc;
    color: #111;
    box-shadow: 0px 0px 3px #ccc;
}
#nav a.disabled {
    color: #444;
}
#nav img {
    border: 0;
}
#dl {
    margin: 0;
    padding: 0;
    font-size: 1px;
}
#dl img {
    border: 0;
}

#info {
    display: table-footer-group;
    text-align: left;
    line-height: 1px;
    font-size: 0.8em;
}
#exif {
    display: block;
    margin-top: 1ex;
    margin-bottom: 1ex;
    color: #444;
    text-align: center;
}
#exif:hover {
    color: #ccc;
}

#noscript {
    display: block;
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow-y: scroll;
    margin: 0;
    padding: 0;
    text-align: center;
    color: #ccc;
}

.nsitem {
    width: 100%;
    margin-left: auto;
    margin-right: auto;
}
.nsitem + .nsitem {
    margin-top: 20px;
}
.nsheader {
    font-weight: bold;
    margin-top: 2px;
}
.nsexif {
    font-size: 0.8em;
    color: #444;
}
.nsexif:hover {
    color: #ccc;
}

</style><script type="text/javascript">

var Images = {
!$Photos:    '$iid$': [$preview_size[0]$, $preview_size[1]$, "$H(title)$", "$info_str$"$', "'+raw_path.replace(' ','%20')+'"' if DownloadLinks else ''$],$!
}
var currentImage = "";
var firstNode = null, lastNode = null;
var prevNode = null, nextNode = null;
var compWidth = 0, compHeight = 0;
var currWidth = 0, currHeight = 0;
var preview = null;
var target = "";
var fadeStart = 0;
var animate = (function(func) { setTimeout(func, 20); });
var waitClear = true;
var waitCount = 0;
var preload = null;

function prev(node) { do { node = node.previousSibling; } while (node && (node.nodeType != 1)); return node; }
function next(node) { do { node = node.nextSibling;     } while (node && (node.nodeType != 1)); return node; }
function getid(a) {
    var i = a.href.indexOf('#');
    if (i < 0) return "";
    return a.href.substr(i + 1);
}

function fixnav(a, targetNode, target) {
    if (targetNode) {
        target = getid(targetNode);
        a.className = "";
    } else
        a.className = "disabled";
    a.href = '#' + target;
}

function fixsize() {
    var thumbWidth = document.getElementById('thumbs').offsetWidth;
    var maxWidth = window.innerWidth - 8 - thumbWidth;
    var maxHeight = Math.max(1, window.innerHeight - 8 - document.getElementById('header').offsetHeight - document.getElementById('info').offsetHeight);
    var width = currWidth;
    var height = currHeight;
    if (width > maxWidth) {
        width = maxWidth;
        height = Math.round((maxWidth * currHeight) / currWidth);
    }
    if (height > maxHeight) {
        height = maxHeight;
        width = Math.round((maxHeight * currWidth) / currHeight);
    }
    document.getElementById('viewer').style['background-position'] = (maxWidth / 2 + thumbWidth - 18) + "px";
    if ((compWidth == width) && (compHeight == height)) {
        return;
    }
    compWidth = width;
    compHeight = height;
    var img = document.getElementById('preview');
    img.width = width;
    img.height = height;
    window.setTimeout(fixsize, 30);
}

function now() {
    var d = new Date();
    return d.getTime();
}

function fadein() {
    var alpha = (now() - fadeStart) * 0.008;
    if (alpha < 1.0) {
        preview.style['opacity'] = alpha;
        animate(fadein);
    } else {
        preview.style['opacity'] = 1;
    }
}

function waitcomplete() {
    var complete = preview.complete;
    if (complete && ((preview.naturalWidth != currWidth) || (preview.naturalHeight != currHeight))) {
        complete = false;
        if (waitClear && (preview.src.substr(0, 4) == "data")) {
            preview.src = "$DataDir$/" + target + ".jpg";
            waitClear = false;
            waitCount = 1;
        }
    }
    if (complete) {
        document.getElementById('viewer').className = "";
        fadeStart = now();
        animate(fadein);
        preload = new Image();
        preload.src = "$DataDir$/" + getid(nextNode) + ".jpg";
    } else {
        if (waitCount) {
            if (waitCount == 2) {
                document.getElementById('viewer').className = "loading";
                waitCount = 0;
            } else {
                waitCount += 1;
            }
        }
        setTimeout(waitcomplete, 10);
    }
}

function switchimg() {
    var img = Images[target];
    var node = document.getElementById('I_' + target);
    waitClear = true;
    waitCount = 0;
    preview.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg==";
    if (currentImage) document.getElementById('I_' + currentImage).className = "";
    node.className = "active";
    prevNode = prev(node);
    nextNode = next(node);
    fixnav(document.getElementById('prev'), prevNode, target);
    fixnav(document.getElementById('next'), nextNode, target);
    currWidth = img[0];
    currHeight = img[1];
    document.getElementById('title').innerHTML = img[2];
    document.getElementById('exif').innerHTML = img[3];
$"    document.getElementById('dl').href = img[4];\\n" if DownloadLinks else ""$    currentImage = target;
    if (prevNode) {
        node = prev(prevNode);
        if (!node) node = prevNode;
    }
    node.scrollIntoView();
    if (!prevNode) prevNode = $"lastNode" if WrapAround else "firstNode"$;
    if (!nextNode) nextNode = $"firstNode" if WrapAround else "lastNode"$;
    fixsize();
    setTimeout(waitcomplete, 10);
}

function fadeout() {
    var alpha = 1.0 - (now() - fadeStart) * 0.008;
    if (alpha > 0.0) {
        preview.style['opacity'] = alpha;
        animate(fadeout);
    } else {
        preview.style['opacity'] = 0;
        switchimg();
    }
}

function go(sender) {
    target = getid(sender);
    fadeStart = now();
    animate(fadeout);
}

function fakeclick(node) {
    var ev = document.createEvent("MouseEvents");
    ev.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    node.dispatchEvent(ev);
}

function previmg() { fakeclick(prevNode); return false; }
function nextimg() { fakeclick(nextNode); return false; }

function key(ev) {
    var c = ev.keyCode;
    if ((c == 8) || (c == 37) || (c == 38) || (c == 33)) { previmg(); } else
    if ((c == 32) || (c == 39) || (c == 40) || (c == 34)) { nextimg(); } else
    { return false; }
    return true;
}

function scroll(ev) {
    var delta = 0;
    if (ev.wheelDeltaX) { delta = ev.wheelDeltaX; } else
    if (ev.detail && ev.axis && (ev.axis == ev.HORIZONTAL_AXIS)) { delta = ev.detail; }
    if (!delta) { return false; }
    if (delta > 0) { nextimg(); } else { previmg(); }
    return true;
}

function init() {
    preview = document.getElementById('preview');
    document.getElementById('thumbs').style['visibility'] = 'visible';
    document.getElementById('viewer').style['visibility'] = 'visible';
    var node = document.getElementById('thumbs').firstChild;
    while (node) {
        if (node.nodeType == 1) {
            if (!firstNode) firstNode = node;
            lastNode = node;
        }
        node = node.nextSibling;
    }
    target = getid(location);
    if (!target) target = getid(firstNode);
    switchimg();
    document.addEventListener('keydown', key);
    document.addEventListener('DOMMouseScroll', scroll);  // Gecko
    document.addEventListener('mousewheel', scroll);  // all others
    var raf = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame;
    if (raf) animate = (function(func) { raf(func); } );
}

</script></head><body id="body" onload="init()" onresize="fixsize()">
<div id="thumbs" style="visibility:hidden;">
!$Photos:    <a id="I_$iid$" href="#$iid$" onclick="go(this)" style="width:$thumb_size[0]-2$px; height:$thumb_size[1]-2$px; background:url($thumb_path$) $-1-thumb_offset[0]$px $-1-thumb_offset[1]$px;"></a>$!
</div>
<div id="viewer" class="loading" style="visibility:hidden;">
<div id="header"><div class="shift">
<span id="title">&nbsp;</span>
<div id="nav">$'<a href="download.php" title="download all images"><img src="'+DataURI("image/svg+xml", DownloadAllIcon)+'" width="14" height="12" alt="Download All" /></a>' if DownloadAll else ''$$'<a id="dl" target="fullsize" href="#" title="download this image"><img src="'+DataURI("image/svg+xml", DownloadIcon)+'" width="8" height="12" alt="Download" /></a>' if DownloadLinks else ''$<a id="prev" href="#" onclick="return previmg();">&#9664;</a><a id="next" href="#" onclick="return nextimg();">&#9654;</a></div>
</div></div>
<div id="image"><div class="shift">
<img id="preview" src="about:blank" style="opacity:0;" alt="" onclick="nextimg()" $'oncontextmenu="return previmg();" ' if RightClicks else ''$/></div></div>
<div id="info"><div id="exif" class="shift">&nbsp;</div></div>
</div>
<noscript><div id="noscript">
!$Photos:<div class="nsitem"><div class="nsheader"><a name="$iid$">$H(title)$</a></div><div class="nsimg"><img src="$preview_path$" width="$preview_size[0]$" height="$preview_size[1]$" alt="$H(title)$" /></div><div class="nsexif">$info_str$</div></div>$!
</div></noscript>
</body></html>
"""

DownloadPHP = r'''<?php

$Title = "%Title%";
$Files = array(
!%Photos:    "%raw_path%",%!
);

// tolerably fast CRC computation code, courtesy of 'chernyshevsky' and
// 'petteri' at http://php.net/manual/en/function.crc32.php
function crc32_file($f) {
    $old_crc = FALSE;
    $crc32 = FALSE;
    $buffer = "";
    while (!feof($f)) {
        $buffer = fread($f, 10485760);
        $len = strlen($buffer);
        $t = crc32($buffer);
        if ($old_crc) {
            $crc32 = crc32_combine($old_crc, $t, $len);
            $old_crc = $crc32;
        } else {
            $crc32 = $old_crc = $t;
        }
    }
    return $crc32;
}
function crc32_combine($crc1, $crc2, $len2) {
    $odd[0] = 0xedb88320;
    $row = 1;
    for($n = 1;  $n < 32;  $n++) {
        $odd[$n] = $row;
        $row <<= 1;
    }
    gf2_matrix_square($even, $odd);
    gf2_matrix_square($odd, $even);
    do {
        gf2_matrix_square($even, $odd);
        if ($len2 & 1) { $crc1=gf2_matrix_times($even, $crc1); }
        $len2 >>= 1;
        if ($len2 == 0) { break; }
        gf2_matrix_square($odd, $even);
        if ($len2 & 1) { $crc1 = gf2_matrix_times($odd, $crc1); }
        $len2 >>= 1;
    } while ($len2 != 0);
    $crc1 ^= $crc2;
    return $crc1;
}
function gf2_matrix_square(&$square, &$mat) {
    for ($n = 0;  $n < 32;  $n++) {
        $square[$n] = gf2_matrix_times($mat, $mat[$n]);
    }
}
function gf2_matrix_times($mat, $vec)  {
    $sum = 0;
    $i = 0;
    while ($vec) {
        if ($vec & 1) { $sum ^= $mat[$i]; }
        $vec = ($vec >> 1) & 0x7FFFFFFF;
        $i++;
    }
    return $sum;
}

// on-the-fly ZIP file generator (without compression)
$file_info = array();
$total_size = 22;
foreach($Files as $filename) {
    $mtime = @filemtime($filename);
    if (!$mtime) continue;
    $f = @fopen($filename, "rb");
    if (!$f) continue;
    @fseek($f, 0, SEEK_END);
    $size = @ftell($f);
    @fclose($f);
    if (!$size) continue;
    $file_info[] = array($filename, $size, $mtime);
    $total_size += 76 + 2 * strlen($filename) + $size;
}
if (!$total_size || !count($file_info)) exit("Nothing to download.");
error_reporting(0);
@ini_set('max_execution_time', 0);
@header("Content-Type: application/zip");
@header("Content-Disposition: attachment; filename=\"$Title.zip\"");
@header("Content-Transfer-Encoding: binary");
@header("Content-Length: $total_size");
while (@ob_end_flush());
$outpos = 0;
$central = "";
foreach($file_info as $fi) {
    list($filename, $size, $mtime) = $fi;
    $f = @fopen($filename, "rb");
    if (!$f) continue;
    @set_time_limit(0);
    $crc = crc32_file($f);
    $t = localtime($mtime, TRUE);
    $dt_crc_s_l = pack('vvVVVv',
        ($t['tm_hour'] << 11) + ($t['tm_min'] << 5) + ($t['tm_sec'] >> 1),
        (($t['tm_year'] - 80) << 9) + (($t['tm_mon'] + 1) << 5) + $t['tm_mday'],
        $crc, $size, $size, strlen($filename));
    $offset = pack('V', $outpos);
    // ----    [magic---][ver---][flags-][comp--]...........[extra-]
    $header = "PK\x03\x04\x14\x00\x00\x00\x00\x00$dt_crc_s_l\x00\x00$filename";
    // -----     [magic---][ver---][ver---][flags-][comp--]...........[extra-][comm--][disk--][attr--][ext_attr------]
    $central .= "PK\x01\x02\x14\x00\x2d\x00\x00\x00\x00\x00$dt_crc_s_l\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$offset$filename";
    echo $header;
    rewind($f);
    while (!feof($f)) {
        @set_time_limit(0);
        echo fread($f, 65536);
        @flush();
    }
    fclose($f);
    $outpos += strlen($header) + $size;
}
$ent_s_offset = pack('vvVV', count($file_info), count($file_info), strlen($central), $outpos);
// ------------ [magic---][disk--][start-].............[comm--]
echo "${central}PK\x05\x06\x00\x00\x00\x00$ent_s_offset\x00\x00";
exit(0);
?>'''

################################################################################

re_expr = re.compile(r'\$([^$]+)\$', re.S)
re_array_expr = re.compile(r'\!\$([^:]+):(.*?)\$\!', re.S)
class TemplateResolver:
    def __init__(self, locals=None):
        if hasattr(locals, '__dict__') and not(hasattr(locals, '__getitem__')):
            locals = locals.__dict__
        self.locals = locals or {}
    def resolve(self, m):
        return str(eval(m.group(1), globals(), self.locals))
    def apply(self, s):
        return re_expr.sub(self.resolve, s)
def resolve_array(m):
    return '\n'.join([TemplateResolver(item).apply(m.group(2)) for item in globals()[m.group(1)]])
def T(s, scope=None):
    return TemplateResolver(scope).apply(re_array_expr.sub(resolve_array, s))

def H(s):
    return s.replace('&', '&amp;').replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')

def DataURI(mime_type, data):
    def cmap(c):
        if c.upper() in "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ": return c
        return "%%%02X" % ord(c)
    return "data:%s,%s" % (mime_type, ''.join(map(cmap, data)))

def ZoomToFit(size, target, mode, align=1):
    iw, ih = size
    tw, th = target
    if mode > WidthHeight:
        if mode == Area:
            iv = iw * ih
            tv = tw * th
            if iv <= tv: return size
            scale = math.sqrt(tv / iv)
        else:
            iv = math.sqrt(iw * iw + ih * ih)
            tv = math.sqrt(tw * tw + th * th)
            if iv <= tv: return size
            scale = tv / iv
        scale /= align
        return (max(align, align * int(iw * scale + 0.5)), max(align, align * int(ih * scale + 0.5)))
    else:
        if (iw <= tw) and (ih <= th): return size
        s = ((ih * tw + ((iw * align) >> 1)) // (iw * align)) * align
        if s <= th:
            return (tw, max(s, align))
        else:
            return (max(((iw * th + ((ih * align) >> 1)) // (ih * align)) * align, align), th)

def dd(f):
    if f >= 10.0:
        return "%.0f" % f
    else:
        return "%.1f" % f

################################################################################

class ExifError(EnvironmentError): pass
class ExifError_CantOpenFile(ExifError): pass
class ExifError_IO(ExifError): pass
class ExifError_UnexpectedEOF(ExifError): pass
class ExifError_NoJPEG(ExifError): pass
class ExifError_NoExif(ExifError): pass
class ExifError_InvalidExif(ExifError): pass

class Rational:
    def __init__(self, n, d):
        self.n = n
        self.d = d
    def __repr__(self):
        return "Rational(%r, %r)" % (self.n, self.d)
    def __str__(self):
        return "%r/%r" % (self.n, self.d)
    def __float__(self):
        return (self.n / self.d) if self.d else 0.0

class SimpleExifParser(object):
    ExifTypeSizeScale = { 1:1, 2:1, 3:2, 4:4, 5:8, 7:1, 9:4, 10:8 }
    Name2Tag = None
    Tag2Name = {
        0x00000100: "ImageWidth",
        0x00000101: "ImageLength",
        0x00000102: "BitsPerSample",
        0x00000103: "Compression",
        0x00000106: "PhotometricInterpretation",
        0x00000112: "Orientation",
        0x00000115: "SamplesPerPixel",
        0x0000011C: "PlanarConfiguration",
        0x00000212: "YCbCrSubSampling",
        0x00000213: "YCbCrPositioning",
        0x0000011A: "XResolution",
        0x0000011B: "YResolution",
        0x00000128: "ResolutionUnit",
        0x00000111: "StripOffsets",
        0x00000116: "RowsPerStrip",
        0x00000117: "StripByteCounts",
        0x00000201: "JPEGInterchangeFormat",
        0x00000202: "JPEGInterchangeFormatLength",
        0x0000012D: "TransferFunction",
        0x0000013E: "WhitePoint",
        0x0000013F: "PrimaryChromaticities",
        0x00000211: "YCbCrCoefficients",
        0x00000214: "ReferenceBlackWhite",
        0x00000132: "DateTime",
        0x0000010E: "ImageDescription",
        0x0000010F: "Make",
        0x00000110: "Model",
        0x00000131: "Software",
        0x0000013B: "Artist",
        0x00008298: "Copyright",
        0x87699000: "ExifVersion",
        0x8769A000: "FlashpixVersion",
        0x8769A001: "ColorSpace",
        0x8769A500: "Gamma",
        0x87699101: "ComponentsConfiguration",
        0x87699102: "CompressedBitsPerPixel",
        0x8769A002: "PixelXDimension",
        0x8769A003: "PixelYDimension",
        0x8769927C: "MakerNote",
        0x87699286: "UserComment",
        0x8769A004: "RelatedSoundFile",
        0x87699003: "DateTimeOriginal",
        0x87699004: "DateTimeDigitized",
        0x87699290: "SubSecTime",
        0x87699291: "SubSecTimeOriginal",
        0x87699292: "SubSecTimeDigitized",
        0x8769A420: "ImageUniqueID",
        0x8769829A: "ExposureTime",
        0x8769829D: "FNumber",
        0x87698822: "ExposureProgram",
        0x87698824: "SpectralSensitivity",
        0x87698827: "ISOSpeedRatings",
        0x87698828: "OECF",
        0x87699201: "ShutterSpeedValue",
        0x87699202: "ApertureValue",
        0x87699203: "BrightnessValue",
        0x87699204: "ExposureBiasValue",
        0x87699205: "MaxApertureValue",
        0x87699206: "SubjectDistance",
        0x87699207: "MeteringMode",
        0x87699208: "LightSource",
        0x87699209: "Flash",
        0x8769920A: "FocalLength",
        0x87699214: "SubjectArea",
        0x8769A20B: "FlashEnergy",
        0x8769A20C: "SpatialFrequencyResponse",
        0x8769A20E: "FocalPlaneXResolution",
        0x8769A20F: "FocalPlaneYResolution",
        0x8769A210: "FocalPlaneResolutionUnit",
        0x8769A214: "SubjectLocation",
        0x8769A215: "ExposureIndex",
        0x8769A217: "SensingMethod",
        0x8769A300: "FileSource",
        0x8769A301: "SceneType",
        0x8769A302: "CFAPattern",
        0x8769A401: "CustomRendered",
        0x8769A402: "ExposureMode",
        0x8769A403: "WhiteBalance",
        0x8769A404: "DigitalZoomRatio",
        0x8769A405: "FocalLengthIn35mmFilm",
        0x8769A406: "SceneCaptureType",
        0x8769A407: "GainControl",
        0x8769A408: "Contrast",
        0x8769A409: "Saturation",
        0x8769A40A: "Sharpness",
        0x8769A40B: "DeviceSettingDescription",
        0x8769A40C: "SubjectDistanceRange",
        0x88250000: "GPSVersionID",
        0x88250001: "GPSLatitudeRef",
        0x88250002: "GPSLatitude",
        0x88250003: "GPSLongitudeRef",
        0x88250004: "GPSLongitude",
        0x88250005: "GPSAltitudeRef",
        0x88250006: "GPSAltitude",
        0x88250007: "GPSTimeStamp",
        0x88250008: "GPSSatellites",
        0x88250009: "GPSStatus",
        0x8825000A: "GPSMeasureMode",
        0x8825000B: "GPSDOP",
        0x8825000C: "GPSSpeedRef",
        0x8825000D: "GPSSpeed",
        0x8825000E: "GPSTrackRef",
        0x8825000F: "GPSTrack",
        0x88250010: "GPSImgDirectionRef",
        0x88250011: "GPSImgDirection",
        0x88250012: "GPSMapDatum",
        0x88250013: "GPSDestLatitudeRef",
        0x88250014: "GPSDestLatitude",
        0x88250015: "GPSDestLongitudeRef",
        0x88250016: "GPSDestLongitude",
        0x88250017: "GPSDestBearingRef",
        0x88250018: "GPSDestBearing",
        0x88250019: "GPSDestDistanceRef",
        0x8825001A: "GPSDestDistance",
        0x8825001B: "GPSProcessingMethod",
        0x8825001C: "GPSAreaInformation",
        0x8825001D: "GPSDateStamp",
        0x8825001E: "GPSDifferential",
        0xA0050001: "InteroperabilityIndex"
    }
    SubIFDs = (0x8769, 0x8825, 0xA005)

    def __init__(self, f, skip_undefined=True, tag_filter=[]):
        self.tags = {}
        self.tag_order = []
        self.skip_undefined = skip_undefined
        self.f = None

        # prepare Tag2Name resolver
        if not SimpleExifParser.Name2Tag:
            SimpleExifParser.Name2Tag = dict([(tag, name) for name, tag in SimpleExifParser.Tag2Name.items()])

        # prepare tag filter
        self.filter = set([SimpleExifParser.Name2Tag.get(t, t) for t in tag_filter])
        if self.filter:
            for sub_ifd in SimpleExifParser.SubIFDs:
                self.filter.add(sub_ifd)

        # open the file
        self.ownfile = isinstance(f, str)
        if self.ownfile:
            try:
                self.f = open(f, "rb")
            except EnvironmentError as e:
                raise ExifError_CantOpenFile(e.strerror)
        else:
            self.f = f
            try:
                self.f.seek(0)
            except EnvironmentError as e:
                raise ExifError_IO(e)

        # find APP1 marker
        marker = self.read(2)
        if marker != b"\xff\xd8":
            raise ExifError_NoJPEG("not a valid JPEG file")
        while True:
            marker = self.read(2)
            if marker[0] != 0xFF:
                raise ExifError_NoJPEG("not a valid JPEG file")
            marker = marker[1]
            if marker in (0xDA, 0xD9):  # would be SOS or EOI
                raise ExifError_NoExif("file does not contain Exif information")
            length = self.read(2)
            length = (length[0] << 8) + length[1] - 2
            if length < 0:
                raise ExifError_NoJPEG("not a valid JPEG file")
            data = self.read(length)
            if (marker == 0xE1) and (data[:6] == b"Exif\x00\x00"):
                break
        self.data = data[6:]

        # read TIFF header
        if self.data[:2] == b"II":
            self.u16 = self.u16_le
            self.u32 = self.u32_le
        elif self.data[:2] == b"MM":
            self.u16 = self.u16_be
            self.u32 = self.u32_be
        else:
            self.invalid()
        if self.u16(2) != 42:
            self.invalid()

        # now do the actual parsing
        self.visited_ifds = set()
        self.parse_ifd_chain(self.u32(4))
        for sub_ifd in SimpleExifParser.SubIFDs:
            try:
                offset = int(self.tags[sub_ifd])
            except (KeyError, TypeError, ValueError):
                continue  # IFD not present or wrong data type
            self.parse_ifd_chain(offset, sub_ifd << 16)

        # close the file again and free some memory
        self.close()
        del self.data

        # establish a constant tag order
        self.tag_order = list(self.tags.keys())
        self.tag_order.sort()

    def parse_ifd_chain(self, offset, tag_offset=0):
        while offset:
            if offset in self.visited_ifds:
                return  # recursive IFD loop detected
            self.visited_ifds.add(offset)
            count = self.u16(offset)
            offset += 2
            for i in range(count):
                self.parse_tag(offset, tag_offset)
                offset += 12
            offset = self.u32(offset)

    def parse_tag(self, offset, tag_offset=0):
        tag = self.u16(offset) + tag_offset
        ttype = self.u16(offset + 2)
        if self.skip_undefined and ((ttype == 7) or (tag == 0x8769927C)):
            return  # skip undefined data and MakerNotes if not desired
        if self.filter and not(tag in self.filter):
            return  # skip unfiltered tags
        tsize = SimpleExifParser.ExifTypeSizeScale.get(ttype, 0)
        count = self.u32(offset + 4)
        size = count * tsize
        if not size:
            return  # unknown type or invalid size, ignore this tag
        if size <= 4:
            pos = offset + 8
        else:
            pos = self.u32(offset + 8)

        if ttype == 2:
            value = self.data[pos : pos + size].rstrip(b"\0").decode('utf-8', 'replace')
        elif count > 1:
            value = [self.parse_value(ttype, pos + i * tsize) for i in range(count)]
        else:
            value = self.parse_value(ttype, pos)
        if not(value is None):
            self.tags[tag] = value

    def parse_value(self, ttype, pos):
        if ttype in (1, 7): return self.u8(pos)
        elif ttype == 3:    return self.u16(pos)
        elif ttype == 4:    return self.u32(pos)
        elif ttype == 5:    return Rational(self.u32(pos), self.u32(pos + 4))
        elif ttype == 9:    return self.s32(pos)
        elif ttype == 10:   return Rational(self.s32(pos), self.s32(pos + 4))

    def invalid(self):
        raise ExifError_InvalidExif("invalid Exif data")

    def u8(self, o):
        try:
            return self.data[o]
        except IndexError:
            self.invalid()
    def u16_le(self, o): return self.u8(o) + (self.u8(o+1) << 8)
    def u16_be(self, o): return (self.u8(o) << 8) + self.u8(o+1)
    def u32_le(self, o): return self.u16(o) + (self.u16(o+2) << 16)
    def u32_be(self, o): return (self.u16(o) << 16) + self.u16(o+2)
    def s32(self, o):
        v = self.u32(o)
        if v >= 0x80000000:
            v -= 0x100000000
        return v

    def read(self, size):
        try:
            data = self.f.read(size)
        except EnvironmentError as e:
            raise ExifError_IO(e)
        if len(data) != size:
            raise ExifError_UnexpectedEOF("unexpected EOF in JPEG file")
        return data

    def close(self):
        if self.ownfile and self.f:
            try:
                self.f.close()
            except EnvironmentError:
                pass
        self.f = None

    def __del__(self):
        self.close()

    def __len__(self):
        return len(self.tags)

    def __getattr__(self, name):
        try:
            return self.__dict__[name]
        except KeyError:
            pass
        try:
            return self.tags[name]
        except KeyError:
            pass
        try:
            return self.tags[SimpleExifParser.Name2Tag[name]]
        except KeyError:
            pass
        raise AttributeError

    def __getitem__(self, name):
        try:
            return self.tags[name]
        except KeyError:
            return self.tags[SimpleExifParser.Name2Tag[name]]

    def get(self, name, default=None):
        try:
            return self[name]
        except KeyError:
            return default

    def __iter__(self):
        for tag in self.tag_order:
            yield SimpleExifParser.Tag2Name.get(tag, tag)

    def items(self):
        for tag in self.tag_order:
            yield (SimpleExifParser.Tag2Name.get(tag, tag), self.tags[tag])

    def __contains__(self, name):
        return SimpleExifParser.Name2Tag.get(name, name) in self.tags

def AutorotateImage(img, orient):
    if orient == 2: return img.transpose(Image.FLIP_LEFT_RIGHT)
    if orient == 3: return img.transpose(Image.ROTATE_180)
    if orient == 4: return img.transpose(Image.FLIP_TOP_BOTTOM)
    if orient == 5: return img.transpose(Image.ROTATE_90).transpose(Image.FLIP_TOP_BOTTOM)
    if orient == 6: return img.transpose(Image.ROTATE_270)
    if orient == 7: return img.transpose(Image.ROTATE_90).transpose(Image.FLIP_LEFT_RIGHT)
    if orient == 8: return img.transpose(Image.ROTATE_90)
    else: return img

################################################################################

class Atlas(object):
    class NoFit(ValueError):
        pass

    class Row(object):
        def __init__(self, h, y0):
            self.h = h
            self.y0 = y0
            self.y1 = y0 + h
            self.x = 0

    def __init__(self, width, height=None):
        if not height: height = width
        self.img = Image.new('RGB', (width, height), (128, 128, 128))
        self.sx, self.sy = self.img.size
        self.rows = []
        self.y1 = 0

    def put(self, img):
        sx, sy = img.size
        if sx > self.sx:
            raise self.NoFit()
        best_row = None
        best_remain = (1 if ((self.y1 + sy) <= self.sy) else self.sy)
        for row in self.rows:
            remain = row.h - sy
            if (remain >= 0) and (remain < best_remain) and ((row.x + sx) <= self.sx):
                best_remain = remain
                best_row = row
        if not best_row:
            if (self.y1 + sy) > self.sy:
                raise self.NoFit()
            best_row = self.Row(sy, self.y1)
            self.rows.append(best_row)
            self.y1 = best_row.y1
        pos = (best_row.x, best_row.y0)
        best_row.x += sx
        self.img.paste(img.convert(self.img.mode), pos)
        return pos

    def get_img(self):
        if not self.rows: return None
        w = max(row.x for row in self.rows)
        h = self.rows[-1].y1
        if not min(w, h): return None
        return self.img.crop((0, 0, w, h))

CurrentAtlas = None
CurrentAtlasFile = None
AtlasN = 0
ExistingAtlases = {}

def CheckAtlas(filename, cw, ch):
    if not(filename in ExistingAtlases):
        try:
            img = Image.open(filename)
            ExistingAtlases[filename] = img.size
            del img
        except EnvironmentError:
            ExistingAtlases[filename] = (0, 0)
    ew, eh = ExistingAtlases[filename]
    return (ew >= cw) and (eh >= ch)

def FlushAtlas():
    global CurrentAtlas, CurrentAtlasFile, AtlasN
    if CurrentAtlas:
        img = CurrentAtlas.get_img()
        if img:
            img.save(CurrentAtlasFile, quality=ThumbnailQuality, optimize=True)
    while True:
        AtlasN += 1
        CurrentAtlasFile = "%s/_t%04d.jpg" % (DataDir, AtlasN)
        if not os.path.exists(CurrentAtlasFile):
            break
    CurrentAtlas = Atlas(ThumbnailAtlasSize)

def AddToAtlas(img):
    if not CurrentAtlas:
        FlushAtlas()
    while True:
        try:
            return CurrentAtlas.put(img)
        except Atlas.NoFit:
            FlushAtlas()
            WriteHTML()

################################################################################

class Photo:
    def __init__(self, filename):
        self._status = []
        self._add_status(filename)
        self.title = os.path.splitext(os.path.basename(filename))[0]

        # compute ID
        iid = self.title
        iid = unicodedata.normalize('NFKD', iid).lower()
        for c in " -+#": iid = iid.replace(c, '_')
        iid = ''.join([c for c in iid if c in "0123456789abcdefghijklmnopqrstuvwxyz_"])
        while '__' in iid: iid = iid.replace('__', '_')
        self.iid = iid

        # open and parse file
        f = open(filename, "rb")
        f.seek(0, 2)
        self.filesize = f.tell()
        f.seek(0)
        try:
            exif = SimpleExifParser(f)
        except EnvironmentError:
            exif = {}
        f.seek(0)

        # compute sizes and paths
        self.img = AutorotateImage(Image.open(f), exif.get('Orientation', 1))
        self.raw_path = filename
        self.raw_size = self.img.size
        self.thumb_size = ZoomToFit(self.raw_size, ThumbnailSize, WidthHeight, 16)
        # thumb_path and thumb_offset will be set later
        self.preview_path = "%s/%s.jpg" % (DataDir, self.iid)
        self.preview_size = tuple(x if ((x & 15) >= 4) else (x & (~15)) for x in ZoomToFit(self.raw_size, PreviewSize, PreviewSizeMode, 2))
        self.info = []

        # Info: Date/Time
        v = str(exif.get('DateTimeOriginal', '')) or str(exif.get('DateTime', ''))
        if v: self.info.append(v.replace(':', '-', 2))

        # Info: dimensions and size
        mpx = self.raw_size[0] * self.raw_size[1]
        if mpx > 10000000:
            mpx = "%d" % ((mpx + 500000) // 1000000)
        else:
            mpx = "%.1f" % (mpx / 1000000.0)
        self.info.append("%dx%d (%sM)" % (self.raw_size[0], self.raw_size[1], mpx))
        if DownloadLinks:
            self.info.append("%.1f MiB" % (self.filesize / float(1024 * 1024)))

        # Info: make and model
        make = str(exif.get('Make', '')).strip()
        model = str(exif.get('Model', '')).strip()
        if make and model:
            first = model.split()[0].lower()
            if make.lower().find(first) >= 0:
                make = None
        if make and model:
            self.info.append("%s %s" % (make, model))
        elif make:
            self.info.append(make)
        elif model:
            self.info.append(model)

        # Info: ISO, F, shutter speed, focal length
        v = exif.get('ISOSpeedRatings')
        if v: self.info.append("ISO %s" % v)
        v = float(exif.get('FNumber', 0))
        if v > 0.1: self.info.append("F" + dd(v))
        v = float(exif.get('ExposureTime', 0))
        if v > 0.5:
            self.info.append("%.1f s" % v)
        elif v > 1.0E-8:
            self.info.append("1/" + dd(1.0 / v) + " s")
        rawFL = float(exif.get('FocalLength', 0))
        equFL = float(exif.get('FocalLengthIn35mmFilm', 0))
        if (rawFL > 0.1) and (equFL > 0.1) and ((equFL / rawFL) > 3.0):
            rawFL = 0.0
        if (rawFL > 0.1) and (equFL > 0.1) and (abs(rawFL - equFL) > 1.0E-6):
            self.info.append(dd(rawFL) + " mm (equiv. " + dd(equFL) + "mm)")
        elif rawFL > 0.1:
            self.info.append(dd(rawFL) + " mm")
        elif equFL > 0.1:
            self.info.append(dd(equFL) + " mm (equiv.)")

        # generate HTML info string
        self.info_str = ' &bull; '.join(map(H, self.info))

        # create downscaled images
        self.thumb_size = self._genimg(True, self.thumb_size, ThumbnailQuality)
        self.preview_size = self._genimg(False, self.preview_size, PreviewQuality)

        with Mutex:
            print(" ".join(self._status))
            sys.stdout.flush()
        del self.img
        f.close()
        del f

    def _genimg(self, thumbnail, size, quality):
        if not ForceCreateImages:
            if thumbnail:
                fn, w, h, x0, y0 = Config.get_thumb(self.iid)
                if fn and w and h:
                    with Mutex:
                        ok = CheckAtlas(fn, w+x0, h+y0)
                else:
                    ok = False
                if ok:
                    self.thumb_path = fn
                    self.thumb_offset = (x0, y0)
                    self._add_status("[%dx%d (existing)]" % (w, h))
                    return (w, h)
            else:
                try:
                    img = Image.open(self.preview_path)
                    esize = img.size
                    del img
                    self._add_status("[%dx%d (existing)]" % esize)
                    return esize
                except EnvironmentError:
                    pass

        self._add_status("[%dx%d (new)]" % size)
        sys.stdout.flush()
        if thumbnail:
            new_mode = 'RGB'
        else:
            new_mode = { 'RGBA': 'RGB', 'LA': 'L' }.get(self.img.mode)
        if new_mode != self.img.mode:
            self.img = self.img.convert(new_mode)
        else:
            self.img.load()
        if size < self.img.size:
            sw, sh = self.img.size
            sw, sh = min((sw, (sw * size[1] + (size[0] >> 1)) // size[0]),
                         ((sh * size[0] + (size[1] >> 1)) // size[1], sh))
            x0 = (self.img.size[0] - sw) >> 1
            y0 = (self.img.size[1] - sh) >> 1
            img = self.img.crop((x0, y0, x0+sw, y0+sh)).resize(size, Image.BICUBIC)
        else:
            img = self.img
        img.info = {}
        if thumbnail:
            with Mutex:
                self.thumb_offset = AddToAtlas(img)
                self.thumb_path = CurrentAtlasFile
                Config.set_thumb(self.iid, self.thumb_path, *(size + self.thumb_offset))
        else:
            img.save(self.preview_path, quality=quality, optimize=True)
        return size

    def _add_status(self, word):
        if Threads < 2:
            print(word, end=' ')
            sys.stdout.flush()
        else:
            self._status.append(word)

    def __gt__(self, other):
        return self.iid > other.iid

    def __repr__(self):
        return "Photo(%r)" % self.raw_filename

class IngestThread(threading.Thread):
    def run(self):
        global IngestStop
        try:
            while not IngestStop:
                with Mutex:
                    try:
                        filename = FileList.pop()
                    except IndexError:
                        break
                try:
                    item = Photo(filename)
                except EnvironmentError as e:
                    with Mutex:
                        if Threads < 2:
                            print(filename, end=' ')
                        print("[ERROR: %s]" % e)
                        sys.stdout.flush()
                    continue
                with Mutex:
                    Photos.append(item)
        except KeyboardInterrupt:
            IngestStop = True

def GetPhotos():
    global FileList, Photos, IngestStop
    Photos = []
    FileList = [item for item in os.listdir('.') \
                if  not(item.startswith('.')) \
                and os.path.isfile(item) \
                and os.path.splitext(item)[-1].lower() in ('.jpg', '.jpeg', '.jpe')]
    if Threads < 2:
        Photos = sorted(map(Photo, FileList))
        return
    IngestStop = False
    if not FileList:
        return
    try:
        workers = [IngestThread() for t in range(Threads)]
        for t in workers: t.start()
        for t in workers: t.join()
    except KeyboardInterrupt:
        IngestStop = True
        raise
    Photos.sort()

################################################################################

class ConfigStore(object):
    ManagedItems = [
        "Title",
        "ThumbnailSize", "ThumbnailQuality",
        "PreviewSize", "PreviewSizeMode", "PreviewQuality",
        "WrapAround", "RightClicks", "DownloadLinks", "DownloadAll",
    ]

    def __init__(self, filename=None):
        self.filename = filename
        self.config = { '__version__.created': __version__ }
        self.thumbs = {}
        self.defaults = {}

    def get_thumb(self, iid):
        return self.thumbs.get(iid, (None, 0, 0, 0, 0))

    def set_thumb(self, iid, fn, w, h, x0, y0):
        self.thumbs[iid] = (fn, w, h, x0, y0)

    def set(self, key, value):
        self.config[key] = value

    def get(self, key, default=None):
        self.config.get(key, default)

    def import_defaults(self):
        for name in self.ManagedItems:
            self.defaults[name] = globals().get(name, None)
            globals()[name] = None

    def apply_config(self):
        for name in self.ManagedItems:
            value = globals().get(name, None)
            if value is None:
                value = self.config.get(name, self.defaults.get(name, None))
                globals()[name] = value
            self.config[name] = value

    def _update_filename(self, filename=None):
        if filename:
            self.filename = filename
        else:
            filename = self.filename
        return filename

    def load(self, filename=None):
        filename = self._update_filename(filename)
        try:
            if filename.endswith(".gz"):
                f = gzip.open(filename, 'rt', encoding='utf-8', errors='replace')
            else:
                f = open(filename, 'r', encoding='utf-8', errors='replace')
            n = 0
            for line in f:
                n += 1
                if not '=' in line: continue
                key, value = map(str.strip, line.split('=', 1))
                try:
                    value = eval(value)
                    if key.lower().startswith('thumb') and ('[' in key) and (']' in key):
                        self.thumbs[eval(key.split('[', 1)[-1].split(']', 1)[0].strip())] = value
                    else:
                        self.config[key] = value
                except Exception as e:
                    print("Error in configuration cache file (line %d):", (n, e), file=sys.stderr)
            f.close()
        except EnvironmentError as e:
            return False
        return True

    def save(self, filename=None):
        self.config['__version__.modified'] = __version__
        filename = self._update_filename(filename)
        try:
            if filename.endswith(".gz"):
                f = gzip.open(filename, 'wt', encoding='utf-8')
            else:
                f = open(filename, 'w', encoding='utf-8')
            f.write(''.join("%s = %r\n" % item for item in sorted(self.config.items())))
            f.write(''.join("Thumbs[%r] = %r\n" % item for item in sorted(self.thumbs.items())))
            f.close()
        except EnvironmentError as e:
            print("Could not write configuration cache file:", e, file=sys.stderr)


def WriteHTML():
    try:
        f = open("index.html", "w", encoding='utf-8')
        f.write(T(HTML).replace('\r\n', '\n'))
        f.close()
    except EnvironmentError as e:
        print("Could not write HTML file:", e, file=sys.stderr)

    if DownloadAll:
        try:
            f = open("download.php", "w", encoding='utf-8')
            f.write(T(DownloadPHP.replace('$', '\x00').replace('%', '$')).replace('\x00', '$').replace('\r\n', '\n'))
            f.close()
        except EnvironmentError as e:
            print("Could not write download PHP file:", e, file=sys.stderr)

    Config.save()

################################################################################

def errexit(code, msg, msg2=None):
    print("Error:", msg, file=sys.stderr)
    if (code==2) and not(msg2):
        msg2 = "Use `%s -h' to get help." % __prog__
    if msg2: print(msg2)
    sys.exit(code)

def parse_size(s):
    try:
        i = int(s)
        return (i, i)
    except ValueError:
        pass
    try:
        return tuple(map(int, s.lower().split('x', 1)))
    except ValueError:
        errexit(2, "invalid size '%s'" % s)

def parse_quality(s):
    try:
        i = int(s)
    except ValueError:
        i = 0
    if (i < 1) or (i > 99):
        errexit(2, "invalid quality '%s'" % s)
    return i

if __name__ == "__main__":
    if ("-h" in sys.argv) or ("--help" in sys.argv):
        print("Usage:", __prog__, "[OPTIONS]")
        print(T(__doc__))
        sys.exit(0)

    Config = ConfigStore()
    Config.import_defaults()

    try:
        opts, args = getopt.getopt(sys.argv[1:],
            "VC:nT:t:s:d:a:q:Q:wDAfrj:",
            ["chdir=", "noimgs", "title=", "thumb=",
             "size=", "diag=", "diagonal=", "area=", "qual=", "quality=",
             "thumbqual=", "thumbquality=", "wrap", "wraparound",
             "download-links", "download-all", "version", "force",
             "right", "right-clicks", "jobs="])
    except getopt.GetoptError as err:
        errexit(2, str(err))
    for opt, arg in opts:
        if opt in ("-V", "--version"):
            print(__version__)
            sys.exit(0)
        elif opt in ("-C", "--chdir"):
            try:
                os.chdir(arg)
            except EnvironmentError as e:
                errexit(1, "could not change into destination directory - %s" % e)
        elif opt in ("-f", "--force"):
            ForceCreateImages = not(ForceCreateImages)
        elif opt in ("-j", "--jobs"):
            try:
                Threads = max(int(arg), 1)
            except ValueError:
                errexit(2, "invalid argument to -j")
        elif opt in ("-T", "--title"):
            Title = arg
        elif opt in ("-t", "--thumb"):
            ThumbnailSize = parse_size(arg)
            if max(ThumbnailSize) > ThumbnailAtlasSize:
                errexit(2, "thumbnail size too large")
        elif opt in ("-s", "--size"):
            PreviewSizeMode = WidthHeight
            PreviewSize = parse_size(arg)
        elif opt in ("-d", "--diag", "--diagonal"):
            PreviewSizeMode = Diagonal
            try:
                d = int(int(arg) * math.sqrt(0.5) + 0.5)
                PreviewSize = (d, d)
            except ValueError:
                PreviewSize = parse_size(arg)
        elif opt in ("-a", "--area"):
            PreviewSizeMode = Area
            PreviewSize = parse_size(arg)
        elif opt in ("-q", "--qual", "--quality"):
            PreviewQuality = parse_quality(arg)
        elif opt in ("-Q", "--thumbqual", "--thumbquality"):
            ThumbnailQuality = parse_quality(arg)
        elif opt in ("-w", "--wrap", "--wraparound"):
            WrapAround = not(WrapAround)
        elif opt in ("-r", "--right", "--right-clicks"):
            RightClicks = not(RightClicks)
        elif opt in ("-D", "--download-links"):
            DownloadLinks = not(DownloadLinks)
        elif opt in ("-A", "--download-all"):
            DownloadAll = not(DownloadAll)
        else:
            errexit(2, "unrecognized option `%s'" % opt)

    try:
        os.mkdir(DataDir)
    except EnvironmentError:
        pass

    if not Title:  # try to read title from old pre-1.1 galleries
        try:
            f = open("index.html", "r")
            data = f.read(4096)
            f.close()
            data = data.split("<!-- T:", 1)
            if len(data) == 2:
                try:
                    Title = base64.b64decode(data[1].split(" ", 1)[0])
                except (IndexError, TypeError):
                    pass
        except EnvironmentError:
            pass

    configs = set()
    for cfgfile in (".gallery.cache", ".gallery.cache.gz"):
        if Config.load("%s/%s" % (DataDir, cfgfile)):
            configs.add(Config.filename)
    Config.apply_config()
    Config.save()
    for cfgfile in configs:
        if cfgfile != Config.filename:
            try:
                os.unlink(cfgfile)
            except EnvironmentError:
                pass

    ThumbnailBarWidth = ThumbnailSize[0] + 2*8 + 16
    if not Title:
        Title = os.path.basename(os.getcwd())
    try:
        GetPhotos()
        if not Photos:
            print("No photos found in work directory.")
    except KeyboardInterrupt:
        print("-- Aborted by user.", file=sys.stderr)

    if Photos:
        FlushAtlas()
        WriteHTML()
