summaryrefslogtreecommitdiff
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rw-r--r--bin/README.md19
-rwxr-xr-xbin/astro-transits75
-rwxr-xr-xbin/docsync314
-rwxr-xr-xbin/imgcollage122
-rw-r--r--bin/photocollage/__init__.py0
-rw-r--r--bin/photocollage/collage.py568
-rw-r--r--bin/photocollage/render.py313
-rwxr-xr-xbin/system-theme29
-rw-r--r--bin/systemd/docsync-backup.service8
-rw-r--r--bin/systemd/docsync-backup.timer10
-rw-r--r--bin/systemd/docsync-check.service8
-rw-r--r--bin/systemd/docsync-check.timer10
-rw-r--r--bin/systemd/docsync-forget.service8
-rw-r--r--bin/systemd/docsync-forget.timer10
14 files changed, 1494 insertions, 0 deletions
diff --git a/bin/README.md b/bin/README.md
new file mode 100644
index 0000000..6c32c13
--- /dev/null
+++ b/bin/README.md
@@ -0,0 +1,19 @@
+# bin/
+
+Scripts that sections of `config.org` drive. Each is a terminal program first,
+and the config looks for it on `PATH` before falling back to this directory.
+
+| Script | Used by | Needs |
+|------------------|----------------------------|-----------------------------------------|
+| `astro-transits` | Astrology | astrolog, imv (or `ASTRO_VIEWER`) |
+| `docsync` | Backups | restic, ssh, jq, notify-send |
+| `imgcollage` | Media thumbnails > Collage | Python 3, Pillow |
+| `system-theme` | Appearance > Theme | the COSMIC desktop |
+
+Each script's header or usage message (`docsync help`) says how to set it up.
+`systemd/` holds docsync's user units: copy them to `~/.config/systemd/user/`,
+put `docsync` at `~/.local/bin/docsync`, and enable the three timers.
+
+`photocollage/` is vendored unchanged from
+[PhotoCollage](https://github.com/adrienverge/PhotoCollage) by Adrien Vergé,
+and is licensed GPL-2.0-or-later, as its file headers say.
diff --git a/bin/astro-transits b/bin/astro-transits
new file mode 100755
index 0000000..8455050
--- /dev/null
+++ b/bin/astro-transits
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+set -e
+
+# The current sky against a natal chart, with astrolog: an ordered transit
+# table and a biwheel image.
+#
+# ASTRO_LAT, ASTRO_LONG where you are, signed decimal degrees (N and E positive)
+# ASTRO_ZONE your standard (winter) UTC offset in hours west,
+# e.g. 5 for New York, -1 for Berlin
+# ASTRO_NATAL the natal chart, an astrolog chart file
+# (default ~/.local/share/astrology/natal.dat)
+# ASTROLOG astrolog's data directory, holding the Swiss
+# Ephemeris files; without seas_18.se1,
+# Chiron/Ceres/Pallas/Juno/Vesta come out as 0Ari00
+# ASTRO_VIEWER image viewer that stays open until closed (default imv)
+#
+# With --emacs, prints TABLE=path and IMAGE=path instead of showing them.
+
+: "${ASTRO_LAT:?set ASTRO_LAT to your latitude}"
+: "${ASTRO_LONG:?set ASTRO_LONG to your longitude}"
+: "${ASTRO_ZONE:?set ASTRO_ZONE to your standard UTC offset in hours west}"
+NATAL="${ASTRO_NATAL:-$HOME/.local/share/astrology/natal.dat}"
+: "${ASTROLOG:=$HOME/.local/share/astrolog}"
+export ASTROLOG
+[ -f "$NATAL" ] || { echo "astro-transits: no natal chart at $NATAL" >&2; exit 1; }
+
+# astrolog wants coordinates as DD:MMH, not the signed decimal degrees
+# ASTRO_LAT/ASTRO_LONG are stored as.
+to_dms() {
+ awk -v v="$1" -v pos="$2" -v neg="$3" 'BEGIN {
+ h = (v < 0) ? neg : pos
+ v = (v < 0) ? -v : v
+ d = int(v)
+ m = int((v - d) * 60 + 0.5)
+ if (m == 60) { d++; m = 0 }
+ printf "%d:%02d%s", d, m, h
+ }'
+}
+LAT_DMS=$(to_dms "$ASTRO_LAT" N S)
+LONG_DMS=$(to_dms "$ASTRO_LONG" E W)
+
+# ASTRO_ZONE is the standard (winter) UTC offset in hours west; derive
+# whether the current moment is in daylight time by comparing it to
+# the actual local UTC offset right now.
+off=$(date +%z)
+sign=${off:0:1}
+offset_min=$((10#${off:1:2} * 60 + 10#${off:3:2}))
+[ "$sign" = "-" ] && offset_min=$(( -offset_min ))
+standard_min=$(( -ASTRO_ZONE * 60 ))
+if [ "$offset_min" -ne "$standard_min" ]; then DAYLIGHT=1; else DAYLIGHT=0; fi
+
+COMMON_ARGS=(-zl "$LONG_DMS" "$LAT_DMS" -z "$ASTRO_ZONE" -z0 "$DAYLIGHT")
+
+TABLE=$(mktemp --suffix=.txt)
+IMAGE=$(mktemp --suffix=.png)
+
+# --emacs hands back the table/image paths instead of displaying them,
+# so a caller (e.g. Emacs) takes ownership of the files and their
+# cleanup; only clean up here if we fail before handing them off.
+if [ "$1" = "--emacs" ]; then
+ trap 'rm -f "$TABLE" "$IMAGE"' ERR
+else
+ trap 'rm -f "$TABLE" "$IMAGE"' EXIT
+fi
+
+astrolog "${COMMON_ARGS[@]}" -i "$NATAL" -Tn -os "$TABLE" >/dev/null
+astrolog "${COMMON_ARGS[@]}" -y "$NATAL" -X -Xbp -Xo "$IMAGE" >/dev/null
+
+if [ "$1" = "--emacs" ]; then
+ echo "TABLE=$TABLE"
+ echo "IMAGE=$IMAGE"
+else
+ cat "$TABLE"
+ "${ASTRO_VIEWER:-imv}" "$IMAGE"
+fi
diff --git a/bin/docsync b/bin/docsync
new file mode 100755
index 0000000..5f0c19c
--- /dev/null
+++ b/bin/docsync
@@ -0,0 +1,314 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/docsync"
+STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/docsync"
+INCLUDE_FILE="$CONFIG_DIR/include"
+EXCLUDE_FILE="$CONFIG_DIR/exclude"
+
+# The repository is reached over SFTP. $CONFIG_DIR/config sets SSH_HOST to a
+# Host in ~/.ssh/config that logs in without a prompt (a key, BatchMode-safe),
+# and may set REPO_PATH, the repository's path on that host. The password
+# file holds the repository's password, kept in a file so the timer never
+# waits on a prompt; restic's other keys (a paper recovery passphrase, say)
+# still open the repository as usual.
+SSH_HOST=
+REPO_PATH=repo
+# shellcheck source=/dev/null
+[[ -f "$CONFIG_DIR/config" ]] && . "$CONFIG_DIR/config"
+export RESTIC_REPOSITORY="sftp:$SSH_HOST:$REPO_PATH"
+export RESTIC_PASSWORD_FILE="$CONFIG_DIR/restic-password"
+
+# Every docsync snapshot carries this tag, and snapshots are grouped by host
+# and tag rather than by path list, so adding or dropping a path continues the
+# same history (and retention) instead of starting a new one.
+TAG=docsync
+GROUP_BY=host,tags
+
+# Retention for 'docsync forget': every hourly snapshot for two days, then one
+# a day for a month, one a week for a year, and one a month for good.
+KEEP=(--keep-hourly 48 --keep-daily 30 --keep-weekly 52 --keep-monthly unlimited)
+
+# How much of the stored data 'docsync check' downloads and verifies per run.
+CHECK_SUBSET=5%
+
+# Desktop notifications (only when run by a systemd timer): a failure while the
+# server is reachable is worth hearing about soon, being offline only once no
+# backup has landed for a day. At most one notification per window either way.
+ERROR_NOTIFY_EVERY=$((6 * 3600))
+OFFLINE_NOTIFY_AFTER=$((24 * 3600))
+
+usage() {
+ cat <<EOF
+Usage: docsync COMMAND
+
+ backup take a snapshot now (the hourly timer runs this)
+ forget thin out old snapshots and free their space (weekly timer)
+ check verify the repository and $CHECK_SUBSET of its data (monthly timer)
+ status when the last backup landed, and the latest snapshots
+ paths the paths and exclude patterns a backup would use
+ manifest rewrite the list of files in the latest snapshot (backup does this)
+ restic ARGS any restic command against the repository, e.g.
+ docsync restic snapshots
+ docsync restic mount ~/mnt/docsync
+
+Setup, all in $CONFIG_DIR:
+ config shell assignments: SSH_HOST=<a Host in ~/.ssh/config>,
+ and optionally REPO_PATH=<path on it> (default: repo)
+ restic-password the repository's password, mode 600
+ include one path per line, relative to ~ unless it starts with /
+ exclude restic exclude patterns; a leading ~/ means your home
+Blank lines and lines starting with # are ignored in include and exclude.
+Create the repository once with: docsync restic init
+The systemd units beside this script run backup hourly, forget weekly and
+check monthly; they expect it at ~/.local/bin/docsync.
+
+State for other tools (Emacs) is in $STATE_DIR:
+ last-success epoch seconds of the last backup that saved a snapshot
+ last-result "EPOCH STATUS MESSAGE", STATUS one of ok, warning, offline, error
+ missing include paths that didn't exist at the last backup
+ manifest every file in the latest snapshot as TYPE SIZE MTIME PATH,
+ tab-separated (jq @tsv escaping), after a "# snapshot ID TIME"
+ line; Emacs compares it with the disk to mark what's changed
+EOF
+}
+
+die() { echo "docsync: $*" >&2; exit 1; }
+
+now() { date +%s; }
+
+# A list file's entries, without comments, blank lines or surrounding space.
+list_lines() {
+ [[ -f "$1" ]] || return 0
+ sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$1"
+}
+
+include_paths() {
+ local p
+ list_lines "$INCLUDE_FILE" | while IFS= read -r p; do
+ p="${p#\~/}"
+ [[ "$p" == /* ]] || p="$HOME/$p"
+ [[ "$p" == / ]] || p="${p%/}"
+ printf '%s\n' "$p"
+ done
+}
+
+exclude_patterns() {
+ local p
+ list_lines "$EXCLUDE_FILE" | while IFS= read -r p; do
+ [[ "$p" == "~/"* ]] && p="$HOME/${p#\~/}"
+ printf '%s\n' "$p"
+ done
+}
+
+# ---------------------------------------------------------------- state
+
+put_state() { # NAME LINE...
+ if (( $# > 1 )); then printf '%s\n' "${@:2}"; fi > "$STATE_DIR/$1.tmp"
+ mv "$STATE_DIR/$1.tmp" "$STATE_DIR/$1"
+}
+
+get_state() { # NAME DEFAULT
+ if [[ -s "$STATE_DIR/$1" ]]; then head -n1 "$STATE_DIR/$1"; else printf '%s\n' "$2"; fi
+}
+
+notify() { # WINDOW URGENCY MESSAGE
+ [[ -n "${INVOCATION_ID:-}" ]] || return 0
+ local last; last=$(get_state notified 0)
+ (( $(now) - last >= $1 )) || return 0
+ notify-send -a docsync -u "$2" docsync "$3" 2>/dev/null || true
+ put_state notified "$(now)"
+}
+
+# A run that dies mid-operation (the server dropping off during a prune, say)
+# leaves its lock behind, and restic never clears one by itself: every later
+# run would wait out --retry-lock and fail. Without --remove-all, unlock
+# removes only stale locks, ones whose process has exited or that haven't been
+# refreshed for 30 minutes (a running restic refreshes its lock every 5).
+unlock_stale() {
+ restic unlock || true
+}
+
+# A cheap "is the server reachable" probe. With an SFTP-only account, whose
+# forced command answers any login with an SFTP server that exits at EOF, this
+# still returns as soon as the connection is up.
+reachable() {
+ ssh -o BatchMode=yes "$SSH_HOST" true </dev/null >/dev/null 2>&1
+}
+
+# Not a failure (a laptop's network comes and goes, and the next hourly run
+# tries again), so only worth a notification once no backup has landed for a day.
+backup_offline() {
+ put_state last-result "$(now) offline can't reach $SSH_HOST"
+ if (( $(now) - $(get_state last-success 0) >= OFFLINE_NOTIFY_AFTER )); then
+ notify "$OFFLINE_NOTIFY_AFTER" normal "No backup for over a day: can't reach $SSH_HOST."
+ fi
+ echo "docsync: can't reach $SSH_HOST, skipping this backup" >&2
+ exit 0
+}
+
+ago() { # EPOCH
+ local s=$(( $(now) - $1 ))
+ if (( s < 90 )); then echo "just now"
+ elif (( s < 5400 )); then echo "$(( s / 60 )) min ago"
+ elif (( s < 172800 )); then echo "$(( s / 3600 )) h ago"
+ else echo "$(( s / 86400 )) days ago"
+ fi
+}
+
+# ---------------------------------------------------------------- commands
+
+# Every file in the latest snapshot with its size and modification time, so
+# Emacs can tell what has changed since without asking the repository. A
+# failure keeps the old manifest: Emacs then shows more as changed than has,
+# never less.
+cmd_manifest() {
+ mkdir -p "$STATE_DIR"
+ local out="$STATE_DIR/manifest"
+ if restic ls --json --tag "$TAG" latest 2>/dev/null | jq -r '
+ if .struct_type == "snapshot" then "# snapshot \(.id) \(.time)"
+ elif .struct_type == "node" then [.type, (.size // 0), .mtime, .path] | @tsv
+ else empty end' > "$out.tmp"; then
+ mv "$out.tmp" "$out"
+ else
+ rm -f "$out.tmp"
+ echo "docsync: couldn't list the latest snapshot; the manifest is stale" >&2
+ return 1
+ fi
+}
+
+cmd_backup() {
+ mkdir -p "$STATE_DIR"
+ exec 9>"$STATE_DIR/lock"
+ flock -n 9 || { echo "docsync: a backup is already running" >&2; exit 0; }
+
+ [[ -f "$RESTIC_PASSWORD_FILE" ]] ||
+ die "no password file; put the repository's password in $RESTIC_PASSWORD_FILE (mode 600)"
+ [[ -f "$INCLUDE_FILE" ]] || die "nothing to back up: $INCLUDE_FILE is missing"
+
+ # Global, not local: the EXIT trap runs after this function has returned.
+ tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' EXIT
+
+ local -a present=() missing=()
+ local p
+ while IFS= read -r p; do
+ if [[ -e "$p" || -L "$p" ]]; then present+=("$p"); else missing+=("$p"); fi
+ done < <(include_paths)
+ put_state missing "${missing[@]}"
+ for p in "${missing[@]}"; do echo "docsync: listed but missing: $p" >&2; done
+
+ if (( ${#present[@]} == 0 )); then
+ put_state last-result "$(now) error none of the listed paths exist"
+ notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: none of the listed paths exist."
+ die "none of the paths in $INCLUDE_FILE exist"
+ fi
+ printf '%s\n' "${present[@]}" > "$tmp/include"
+ exclude_patterns > "$tmp/exclude"
+
+ reachable || backup_offline
+ unlock_stale
+ local rc=0
+ restic backup \
+ --files-from-verbatim "$tmp/include" \
+ --exclude-file "$tmp/exclude" \
+ --exclude-caches \
+ --tag "$TAG" --group-by "$GROUP_BY" \
+ --skip-if-unchanged \
+ --retry-lock 1h \
+ 2>"$tmp/err" || rc=$?
+ # Echoed by bash rather than cat: journald files a line under its unit by
+ # looking up the writing process, and cat has exited by then, so restic's
+ # error would be missing from journalctl --user -u docsync-backup.
+ [[ ! -s "$tmp/err" ]] || printf '%s\n' "$(<"$tmp/err")" >&2
+ local err; err=$(grep -v '^[[:space:]]*$' "$tmp/err" | tail -n1 || true)
+
+ case $rc in
+ 0|3) put_state last-success "$(now)"
+ rm -f "$STATE_DIR/notified"
+ cmd_manifest || true ;;
+ esac
+ case $rc in
+ 0) if (( ${#missing[@]} )); then
+ put_state last-result "$(now) warning ${#missing[@]} listed path(s) missing"
+ notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but ${#missing[@]} listed path(s) no longer exist."
+ else
+ put_state last-result "$(now) ok"
+ fi ;;
+ 3) put_state last-result "$(now) warning some files couldn't be read: $err"
+ notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but some files couldn't be read." ;;
+ *) # Losing the server partway through is being offline, not an error.
+ reachable || backup_offline
+ put_state last-result "$(now) error ${err:-restic exited with $rc}"
+ notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: ${err:-restic exited with $rc}"
+ exit "$rc" ;;
+ esac
+}
+
+# forget and check: on failure under a timer, say so, unless the server dropped
+# off; the next scheduled run clears the stale lock that leaves and starts over.
+maintenance() { # WHAT RESTIC-ARGS...
+ local what=$1; shift
+ unlock_stale
+ if ! restic "$@" --retry-lock 1h; then
+ [[ -n "${INVOCATION_ID:-}" ]] && reachable &&
+ notify-send -a docsync -u critical docsync "docsync $what failed; see journalctl --user -u docsync-$what" 2>/dev/null
+ exit 1
+ fi
+}
+
+cmd_forget() {
+ maintenance forget forget --tag "$TAG" --group-by "$GROUP_BY" "${KEEP[@]}" --prune
+}
+
+cmd_check() {
+ maintenance check check --read-data-subset "$CHECK_SUBSET"
+}
+
+cmd_status() {
+ local last result
+ last=$(get_state last-success "")
+ result=$(get_state last-result "")
+ if [[ -n "$last" ]]; then
+ echo "Last backup: $(date -d "@$last" '+%F %R') ($(ago "$last"))"
+ else
+ echo "Last backup: never"
+ fi
+ if [[ -n "$result" ]]; then
+ read -r when status msg <<<"$result"
+ echo "Last run: $(date -d "@$when" '+%F %R') $status${msg:+: $msg}"
+ fi
+ if [[ -s "$STATE_DIR/missing" ]]; then
+ echo "Missing:"; sed 's/^/ /' "$STATE_DIR/missing"
+ fi
+ echo
+ restic snapshots --tag "$TAG" --group-by "$GROUP_BY" --latest 5 --compact ||
+ echo "(couldn't list snapshots; is $SSH_HOST reachable?)"
+}
+
+cmd_paths() {
+ local p
+ echo "Include:"
+ include_paths | while IFS= read -r p; do
+ if [[ -e "$p" || -L "$p" ]]; then echo " $p"; else echo " $p (missing)"; fi
+ done
+ echo "Exclude:"
+ exclude_patterns | sed 's/^/ /'
+}
+
+case "${1:-}" in
+ backup|forget|check|status|manifest|restic)
+ [[ -n "$SSH_HOST" ]] || die "set SSH_HOST in $CONFIG_DIR/config" ;;
+esac
+
+case "${1:-}" in
+ backup) cmd_backup ;;
+ forget) cmd_forget ;;
+ check) cmd_check ;;
+ status) cmd_status ;;
+ paths) cmd_paths ;;
+ manifest) cmd_manifest ;;
+ restic) shift; exec restic "$@" ;;
+ -h|--help|help|"") usage ;;
+ *) usage >&2; exit 2 ;;
+esac
diff --git a/bin/imgcollage b/bin/imgcollage
new file mode 100755
index 0000000..6211f87
--- /dev/null
+++ b/bin/imgcollage
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""imgcollage: build a single masonry-style collage image from a folder.
+
+A headless CLI wrapper around the layout/rendering engine from PhotoCollage
+(https://github.com/adrienverge/PhotoCollage, GPL-2.0-or-later). It reuses
+that project's `photocollage.collage` and `photocollage.render` modules
+verbatim (vendored alongside this script) and drives them with the same
+auto-layout heuristic PhotoCollage's GTK app uses (see UserCollage.make_page
+in its gtkgui.py), without needing GTK/pycairo or any GUI at all.
+"""
+import argparse
+import math
+import os
+import random
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from photocollage import collage, render # noqa: E402
+
+IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}
+
+
+def collect_images(paths):
+ files = []
+ skipped = []
+ for p in paths:
+ if os.path.isdir(p):
+ for name in sorted(os.listdir(p)):
+ full = os.path.join(p, name)
+ if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
+ files.append(full)
+ elif os.path.isfile(full):
+ skipped.append(full)
+ elif os.path.splitext(p)[1].lower() in IMAGE_EXTS:
+ files.append(p)
+ else:
+ skipped.append(p)
+ if skipped:
+ print(f"Ignoring {len(skipped)} non-image file(s): "
+ f"{', '.join(os.path.basename(s) for s in skipped)}",
+ file=sys.stderr)
+ return files
+
+
+def auto_no_cols(photolist, ratio):
+ avg_ratio = sum(p.h / p.w for p in photolist) / len(photolist)
+ virtual_no_imgs = 2 * len(photolist)
+ return max(1, int(round(math.sqrt(avg_ratio / ratio * virtual_no_imgs))))
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="Arrange a folder of differently-sized images into one "
+ "masonry-style collage image, auto-computing the layout.")
+ ap.add_argument("images", nargs="+",
+ help="image files and/or directories of images")
+ ap.add_argument("-o", "--output", default="collage.jpg",
+ help="output file (default: collage.jpg)")
+ ap.add_argument("-W", "--width", type=int, default=2000,
+ help="output width in px (default: 2000)")
+ ap.add_argument("-H", "--height", type=int, default=1500,
+ help="output height in px (default: 1500)")
+ ap.add_argument("-c", "--cols", type=int, default=0,
+ help="number of columns (default: auto)")
+ ap.add_argument("-b", "--border", type=float, default=1.0,
+ help="border width as %% of poster size (default: 1.0, "
+ "use 0 for no border)")
+ ap.add_argument("--border-color", default="black",
+ help="border color, any PIL-recognized name/hex "
+ "(default: black)")
+ ap.add_argument("--seed", type=int, default=None,
+ help="random seed, for a reproducible layout")
+ args = ap.parse_args()
+
+ if args.seed is not None:
+ random.seed(args.seed)
+
+ filelist = collect_images(args.images)
+ if not filelist:
+ sys.exit("No images found.")
+
+ try:
+ photolist = render.build_photolist(filelist)
+ except render.BadPhoto as e:
+ sys.exit(f"Could not read image: {e.photoname}")
+
+ ratio = args.height / args.width
+ no_cols = args.cols or auto_no_cols(photolist, ratio)
+
+ page = collage.Page(1.0, ratio, no_cols)
+ random.shuffle(photolist)
+ for photo in photolist:
+ page.add_cell(photo)
+ page.adjust()
+
+ enlargement = args.width / page.w
+ page.scale(enlargement)
+
+ errors = []
+
+ task = render.RenderingTask(
+ page,
+ border_width=args.border / 100.0 * max(page.w, page.h),
+ border_color=args.border_color,
+ quality=render.QUALITY_BEST,
+ output_file=args.output,
+ on_fail=errors.append,
+ )
+ task.run() # run synchronously in this thread; no need for start()/join()
+
+ if errors:
+ sys.exit(f"Rendering failed: {errors[0]}")
+
+ if os.path.exists(args.output):
+ print(f"Wrote {args.output} "
+ f"({int(page.w)}x{int(page.h)}, {no_cols} columns, "
+ f"{len(photolist)} images)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bin/photocollage/__init__.py b/bin/photocollage/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bin/photocollage/__init__.py
diff --git a/bin/photocollage/collage.py b/bin/photocollage/collage.py
new file mode 100644
index 0000000..134735b
--- /dev/null
+++ b/bin/photocollage/collage.py
@@ -0,0 +1,568 @@
+# Copyright (C) 2014 Adrien Vergé
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import random
+
+"""
+Summary of collage objects:
+
+----------------------
+| |
+| |
+| Page | The "Page" object represents the whole page that
+| | will give the final assembled image.
+| |
+----------------------
+
+----------------------
+| | | |
+| |Column| |
+| | |Column| A page is divided into columns.
+|Column| | |
+| | | |
+----------------------
+
+----------------------
+| Cell | Cell | Each column contains cells. When a
+|------| x--|----- CellExtent is located in several columns,
+| Cell |-------------| its "extended" flag is set, and a
+|------| Cell | | CellExtent object is added to the
+| Cell | | Cell | column on the right to reserve the
+---------------------- place.
+
+ ,------> Photo
+ / ,---------> Photo
+----------------------
+| | | Each cell is associated
+|------| | to a photo.
+| |-------------|
+|------| | |----> Photo
+| | | |
+----------------------
+ `----------> Photo
+
+The layout placing process is divided in three phases.
+
+Phase A: Fill columns with photos.
+ Photos are added to columns, one by one, until there are no more photos.
+ Each new photo is put in the smallest column, so as to have balanced
+ columns. If two columns have approximately the same height, a photo can be
+ "extended" to fit two columns. In this case, the "Cell" object is put in
+ the first column, and the second column takes a "CellExtent" object to
+ reserve the taken space.
+
+Phase B: Set all columns to same height.
+ A global common height is computed for all columns, and every of them is
+ stressed or extended to this common length. This can result in a decrease
+ or increase in columns' width.
+
+Phase C: Adapt columns' width.
+ Since cells in a column may have different widths, each column width is set
+ to the smallest width amongst its images.
+
+"""
+
+
+class Photo:
+ def __init__(self, filename, w, h, orientation=0):
+ self.filename = filename
+ self.w = w
+ self.h = h
+ self.orientation = orientation
+ self.offset_w = 0.5
+ self.offset_h = 0.5
+
+ @property
+ def ratio(self):
+ return float(self.h) / float(self.w)
+
+ def move(self, x, y):
+ self.offset_w = self.calculate_new_offset(self.offset_w, x)
+ self.offset_h = self.calculate_new_offset(self.offset_h, y)
+
+ @staticmethod
+ def calculate_new_offset(offset, value):
+ new_offset = offset + value
+ if new_offset < 0:
+ new_offset = 0
+ elif new_offset > 1:
+ new_offset = 1
+ return new_offset
+
+
+class Cell:
+ """Represents a cell in a column
+
+ Properties:
+ <- x -><- w ->
+ ---------------------- ^
+ | | | |y
+ |------| | |
+ | |-------------| v
+ |------| Cell | | ^
+ | | | | |h
+ ---------------------- v
+
+ """
+ def __init__(self, parents, photo):
+ self.parents = parents
+ self.photo = photo
+ self.extent = None
+ self.h = self.w * self.wanted_ratio
+
+ def __repr__(self):
+ """Representation of the cell in ASCII art"""
+ end = "]"
+ if self.extent is not None:
+ end = "--"
+ return "[%d %d%s" % (self.w, self.h, end)
+
+ @property
+ def x(self):
+ return self.parents[0].x
+
+ @property
+ def y(self):
+ """Returns the cell's y coordinate
+
+ It assumes that the cell is in a single column, so it is the previous
+ cell's y + h.
+
+ """
+ prev = None
+ for c in self.parents[0].cells:
+ if self is c:
+ if prev:
+ return prev.y + prev.h
+ return 0
+ prev = c
+
+ @property
+ def w(self):
+ return sum(c.w for c in self.parents)
+
+ @property
+ def ratio(self):
+ return self.h / self.w
+
+ @property
+ def wanted_ratio(self):
+ return self.photo.ratio
+
+ def scale(self, alpha):
+ self.h *= alpha
+
+ def is_extended(self):
+ return hasattr(self, 'extent') and self.extent is not None
+
+ def is_extension(self):
+ return isinstance(self, CellExtent)
+
+ def content_coords(self):
+ """Returns the coordinates of the contained image
+
+ These are computed in order not to loose space, so the content area
+ will always be greater than the cell itself. It is the space taken by
+ the contained image if it wasn't cropped.
+
+ """
+ # If the contained image is too thick to fit
+ if self.wanted_ratio < self.ratio:
+ h = self.h
+ w = self.h / self.wanted_ratio
+ y = self.y
+ x = self.x - (w - self.w) / 2.0
+ # If the contained image is too tall to fit
+ elif self.wanted_ratio > self.ratio:
+ w = self.w
+ h = self.w * self.wanted_ratio
+ x = self.x
+ y = self.y - (h - self.h) / 2.0
+ else:
+ w = self.w
+ h = self.h
+ x = self.x
+ y = self.y
+ return x, y, w, h
+
+ def top_neighbor(self):
+ """Returns the cell above this one"""
+ prev = None
+ for c in self.parents[0].cells:
+ if self is c:
+ return prev
+ prev = c
+
+ def bottom_neighbor(self):
+ """Returns the cell below this one"""
+ prev = None
+ for c in reversed(self.parents[0].cells):
+ if self is c:
+ return prev
+ prev = c
+
+
+class CellExtent(Cell):
+ def __init__(self, cell):
+ self.origin = cell
+ self.origin.extent = self
+
+ def __repr__(self):
+ """Representation of the cell in ASCII art"""
+ return "------]"
+
+ @property
+ def parents(self):
+ return (self.origin.parents[1],)
+
+ @property
+ def photo(self):
+ return self.origin.photo
+
+ @property
+ def y(self):
+ return self.origin.y
+
+ @property
+ def h(self):
+ return self.origin.h
+
+ def scale(self, alpha):
+ pass
+
+
+class Column:
+ """Represents a column in a page
+
+ Properties:
+ <----- x ----><-- w ->
+ ---------------------- ^
+ | | | | |
+ | | | |
+ | | |Column| h
+ | | | |
+ -------| | | |
+ | |------- v
+ --------
+
+ """
+ def __init__(self, parent, w):
+ self.parent = parent
+ self.cells = []
+ self.w = w
+
+ def __repr__(self):
+ """Representation of the column in ASCII art"""
+ return "\n".join(c.__repr__() for c in self.cells)
+
+ @property
+ def h(self):
+ """Returns the column's total height
+
+ This is not simply the sum of its cells heights, because there can be
+ empty spaces between cells.
+
+ """
+ if not self.cells:
+ return 0
+ return self.cells[-1].y + self.cells[-1].h
+
+ @property
+ def x(self):
+ x = 0
+ for c in self.parent.cols:
+ if self is c:
+ break
+ x += c.w
+ return x
+
+ def scale(self, alpha):
+ self.w *= alpha
+ for c in self.cells:
+ c.scale(alpha)
+
+ def left_neighbor(self):
+ """Returns the column on the left of this one"""
+ prev = None
+ for c in self.parent.cols:
+ if self is c:
+ return prev
+ prev = c
+
+ def right_neighbor(self):
+ """Returns the column on the right of this one"""
+ prev = None
+ for c in reversed(self.parent.cols):
+ if self is c:
+ return prev
+ prev = c
+
+ def adjust_height(self, target_h):
+ """Set the column's height to a given value by resizing cells"""
+ # First, make groups of "movable" cells. Since cell extents are not
+ # movable, these groups only contain pure cell objects. We only resize
+ # those groups.
+ class Group:
+ def __init__(self, y):
+ self.y = y
+ self.h = 0
+ self.cells = []
+
+ groups = []
+ groups.append(Group(0))
+ for c in self.cells:
+ # While a cell extent is not reached, keep add cells to the group
+ if not c.is_extension():
+ groups[-1].cells.append(c)
+ else:
+ # Close current group and create a new one
+ groups[-1].h = c.y - groups[-1].y
+ groups.append(Group(c.y + c.h))
+ groups[-1].h = target_h - groups[-1].y
+
+ # Adjust height for each group independently
+ for group in groups:
+ if not group.cells:
+ continue
+ alpha = group.h / sum(c.h for c in group.cells)
+ for c in group.cells:
+ c.h = c.h * alpha
+
+
+class Page:
+ """Represents a whole page
+
+ Properties:
+ <-------- w -------->
+ ---------------------- ^
+ | | |
+ | |
+ | Page | h
+ | |
+ | | |
+ ---------------------- v
+
+ """
+ def __init__(self, w, target_ratio, no_cols):
+ self.target_ratio = target_ratio
+ col_w = float(w)/no_cols
+ self.cols = []
+ for i in range(no_cols):
+ self.cols.append(Column(self, col_w))
+
+ def __repr__(self):
+ """Representation of the page in ASCII art
+
+ Returns something like:
+ [62 52] [125 134-- ------] [62 87]
+ [62 47] [62 66] [125 132-- [62 45]
+ [62 46] ------] [62 49] ------]
+ [62 78] ------] [62 49] [62 45]
+ [125 102-- ------] [62 49] [62 65]
+ [125 135-- [62 85] [62 53]
+ [125 91-- [125 89-- [62 64]
+ ------]
+ """
+ lines = []
+ n = 0
+ end = False
+ while not end:
+ lines.append("")
+ end = True
+ for col in self.cols:
+ cells = col.__repr__().split("\n")
+ w = max(len(cell) for cell in cells)
+ if col != self.cols[-1]:
+ w += 1
+ cell = w * " "
+ if n < len(cells):
+ cell = cells[n] + (w - len(cells[n])) * " "
+ if n < len(cells) - 1:
+ end = False
+ lines[-1] += cell
+ n += 1
+ return "\n".join(lines)
+
+ @property
+ def no_cols(self):
+ return len(self.cols)
+
+ @property
+ def w(self):
+ return sum(c.w for c in self.cols)
+
+ @property
+ def h(self):
+ return max(c.h for c in self.cols)
+
+ @property
+ def ratio(self):
+ return self.h / self.w
+
+ def scale(self, alpha):
+ for c in self.cols:
+ c.scale(alpha)
+
+ def scale_to_fit(self, max_w, max_h=None):
+ if max_h is None or self.w * max_h > self.h * max_w:
+ self.scale(max_w / self.w)
+ else:
+ self.scale(max_h / self.h)
+
+ def next_free_col(self):
+ """Returns the column with lowest height"""
+ minimum = min(c.h for c in self.cols)
+ candidates = []
+ for c in self.cols:
+ if c.h == minimum:
+ candidates.append(c)
+ return random.choice(candidates)
+
+ def add_cell_single_col(self, col, photo):
+ col.cells.append(Cell((col,), photo))
+
+ def add_cell_multi_col(self, col1, col2, photo):
+ cell = Cell((col1, col2), photo)
+ extent = CellExtent(cell)
+ col1.cells.append(cell)
+ col2.cells.append(extent)
+
+ def add_cell(self, photo):
+ """Add a new cell in the best computed place
+
+ If possible, and if it's worth, make a "multiple-column" cell.
+
+ """
+ col = self.next_free_col()
+ left = col.left_neighbor()
+ right = col.right_neighbor()
+ if 2 * random.random() > photo.ratio:
+ if left and abs(col.h - left.h) < 0.5 * col.w:
+ return self.add_cell_multi_col(left, col, photo)
+ elif right and abs(col.h - right.h) < 0.5 * col.w:
+ return self.add_cell_multi_col(col, right, photo)
+
+ self.add_cell_single_col(col, photo)
+
+ def remove_empty_cols(self):
+ i = 0
+ while i < len(self.cols):
+ if len(self.cols[i].cells) == 0:
+ self.cols.pop(i)
+ else:
+ i += 1
+
+ def remove_bottom_holes(self):
+ """Remove holes created by extended cells
+
+ Example (case A):
+ The bottom-right cell should be extended to fill the hole.
+ ---------------------- ----------------------
+ | | | | | | | |
+ | |-------------| | |-------------|
+ |------| | |------| |
+ | |-------------- | |--------------
+ | | | ^ | | ^ | |
+ --------------- hole -------- hole --------
+
+ Example (case B):
+ The bottom cell should be moved under the other extended cell.
+ ---------------------- ----------------------
+ | | | | | | | |
+ |------|-------------| |-------------|------|
+ | | | | | |
+ |--------------------- ---------------------|
+ | | <-- hole hole -> | |
+ --------------- ---------------
+
+ """
+ for col in self.cols:
+ cell = col.cells[-1]
+ if cell == col.cells[0]:
+ continue
+
+ # Case A
+ # If cell is not extended, is below an extended cell and has no
+ # neighbour under the latter, it should be extended.
+ if not cell.is_extended() and not cell.is_extension():
+ # Case A1
+ if cell.top_neighbor().is_extended() \
+ and cell.top_neighbor().extent \
+ .bottom_neighbor() is None:
+ # Extend cell to right
+ extent = CellExtent(cell)
+ col.right_neighbor().cells.append(extent)
+ cell.parents = (col, col.right_neighbor())
+ # Case A2
+ elif cell.top_neighbor().is_extension() \
+ and cell.top_neighbor().origin \
+ .bottom_neighbor() is None:
+ # Extend cell to left
+ col.cells.remove(cell)
+ col.left_neighbor().cells.append(cell)
+ extent = CellExtent(cell)
+ col.cells.append(extent)
+ cell.parents = (col.left_neighbor(), col)
+ # Case B
+ # If cell is extended and one of the cells above is extended too,
+ # the bottom cell should be placed right below the top one.
+ elif cell.is_extended() and cell.extent.bottom_neighbor() is None:
+ # Case B1
+ if cell.extent.top_neighbor().is_extended() \
+ and cell.extent.top_neighbor().extent \
+ .bottom_neighbor() is None:
+ # Move cell to right
+ col.cells.remove(cell)
+ col.right_neighbor().cells.remove(cell.extent)
+ col.right_neighbor().cells.append(cell)
+ col.right_neighbor().right_neighbor().cells \
+ .append(cell.extent)
+ cell.parents = (col.right_neighbor(),
+ col.right_neighbor().right_neighbor())
+ # Case B2
+ elif cell.top_neighbor().is_extension() \
+ and cell.top_neighbor().origin \
+ .bottom_neighbor() is None:
+ # Move cell to left
+ col.cells.remove(cell)
+ col.right_neighbor().cells.remove(cell.extent)
+ col.left_neighbor().cells.append(cell)
+ col.cells.append(cell.extent)
+ cell.parents = (col.left_neighbor(), col)
+
+ def adjust_cols_heights(self):
+ """Set all columns' heights to same value by shrinking them"""
+ target_h = self.w * self.target_ratio
+ for c in self.cols:
+ c.adjust_height(target_h)
+
+ def adjust(self):
+ self.remove_empty_cols()
+ self.remove_bottom_holes()
+ self.adjust_cols_heights()
+
+ def get_cell_at_position(self, x, y):
+ for col in self.cols:
+ if x >= col.x and x < col.x + col.w:
+ for cell in col.cells:
+ if y >= cell.y and y < cell.y + cell.h:
+ if cell.is_extension():
+ return cell.origin
+ return cell
+ return None
+
+ def swap_photos(self, cell1, cell2):
+ cell1.photo, cell2.photo = cell2.photo, cell1.photo
diff --git a/bin/photocollage/render.py b/bin/photocollage/render.py
new file mode 100644
index 0000000..f9b9024
--- /dev/null
+++ b/bin/photocollage/render.py
@@ -0,0 +1,313 @@
+# Copyright (C) 2014 Adrien Vergé
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import random
+from threading import Thread
+import time
+
+import PIL.Image
+import PIL.ImageDraw
+import PIL.ImageFile
+
+from photocollage.collage import Photo
+
+
+QUALITY_SKEL = 0
+QUALITY_FAST = 1
+QUALITY_BEST = 2
+
+
+# Try to continue even if the input file is corrupted.
+# See issue at https://github.com/adrienverge/PhotoCollage/issues/65
+PIL.ImageFile.LOAD_TRUNCATED_IMAGES = True
+
+
+class PIL_SUPPORTED_EXTS:
+ """File extensions supported by PIL
+
+ Compiled from:
+ - http://pillow.readthedocs.org/en/2.3.0/handbook/image-file-formats.html
+ - https://github.com/python-imaging/Pillow/blob/master/PIL/*ImagePlugin.py
+
+ """
+ RW = {
+ "BMP": ("bmp",),
+ # "EPS": ("ps", "eps",), # doesn't seem to work
+ "GIF": ("gif",),
+ "IM": ("im",),
+ "JPEG": ("jfif", "jpe", "jpg", "jpeg",),
+ "MSP": ("msp",),
+ "PCX": ("pcx",),
+ "PNG": ("png",),
+ "PPM": ("pbm", "pgm", "ppm",),
+ "TGA": ("tga",),
+ "TIFF": ("tif", "tiff",),
+ "WebP": ("webp",),
+ "XBM": ("xbm",),
+ }
+ RO = {
+ "CUR": ("cur",),
+ "DCX": ("dcx",),
+ "FLI": ("fli", "flc",),
+ "FPX": ("fpx",),
+ "GBR": ("gbr",),
+ "ICO": ("ico",),
+ "IPTC/NAA": ("iim",),
+ "PCD": ("pcd",),
+ "PSD": ("psd",),
+ "SGI": ("bw", "rgb", "rgba", "sgi",),
+ "XPM": ("xpm",),
+ }
+ WO = {
+ # "PALM": ("palm",), # doesn't seem to work
+ # "PDF": ("pdf",), # doesn't seem to work
+ }
+
+
+def random_color():
+ r = random.randrange(256)
+ g = random.randrange(256)
+ b = random.randrange(256)
+ if r + g + b > 0.7 * 3 * 256:
+ r -= 50
+ g -= 50
+ b -= 50
+ return (r, g, b)
+
+
+class BadPhoto(Exception):
+ def __init__(self, photoname):
+ self.photoname = photoname
+
+
+def build_photolist(filelist):
+ ret = []
+
+ for name in filelist:
+ try:
+ img = PIL.Image.open(name)
+ except OSError:
+ raise BadPhoto(name)
+ w, h = img.size
+
+ orientation = 0
+ try:
+ exif = img._getexif()
+ if 274 in exif: # orientation tag
+ orientation = exif[274]
+ if orientation == 6 or orientation == 8:
+ w, h = h, w
+ except Exception:
+ pass
+
+ ret.append(Photo(name, w, h, orientation))
+ return ret
+
+
+cache = {}
+
+
+class RenderingTask(Thread):
+ """Execution thread to do the actual poster rendering
+
+ Image computation is a heavy task, that can take several seconds. During
+ this, the program might be unresponding. To avoid this, rendering is done
+ is a separated thread.
+
+ """
+ def __init__(self, page, border_width=0.01, border_color=(0, 0, 0),
+ quality=QUALITY_FAST, output_file=None,
+ on_update=None, on_complete=None, on_fail=None):
+ super().__init__()
+
+ self.page = page
+ self.border_width = border_width
+ self.border_color = border_color
+ self.quality = quality
+
+ self.output_file = output_file
+
+ self.on_update = on_update
+ self.on_complete = on_complete
+ self.on_fail = on_fail
+
+ self.canceled = False
+
+ def abort(self):
+ self.canceled = True
+
+ def draw_skeleton(self, canvas):
+ for col in self.page.cols:
+ for c in col.cells:
+ if c.is_extension():
+ continue
+ color = random_color()
+ x, y, w, h = c.content_coords()
+ xy = (x, y)
+ xY = (x, y + h - 1)
+ Xy = (x + w - 1, y)
+ XY = (x + w - 1, y + h - 1)
+
+ draw = PIL.ImageDraw.Draw(canvas)
+ draw.line(xy + Xy, fill=color)
+ draw.line(xy + xY, fill=color)
+ draw.line(xY + XY, fill=color)
+ draw.line(Xy + XY, fill=color)
+ draw.line(xy + XY, fill=color)
+ draw.line(xY + Xy, fill=color)
+ return canvas
+
+ def draw_borders(self, canvas):
+ if self.border_width == 0:
+ return
+
+ W = self.page.w - 1
+ H = self.page.h - 1
+ border = self.border_width - 1
+ color = self.border_color
+
+ draw = PIL.ImageDraw.Draw(canvas)
+ draw.rectangle((0, 0) + (border, H), color)
+ draw.rectangle((W - border, 0) + (W, H), color)
+ draw.rectangle((0, 0) + (W, border), color)
+ draw.rectangle((0, H - border) + (W, H), color)
+
+ for col in self.page.cols:
+ # Draw horizontal borders
+ for c in col.cells[1:]:
+ xy = (col.x, c.y - border / 2)
+ XY = (col.x + col.w, c.y + border / 2)
+ draw.rectangle(xy + XY, color)
+ # Draw vertical borders
+ if col.x > 0:
+ for c in col.cells:
+ if not c.is_extension():
+ xy = (col.x - border / 2, c.y)
+ XY = (col.x + border / 2, c.y + c.h)
+ draw.rectangle(xy + XY, color)
+ return canvas
+
+ def resize_photo(self, cell, use_cache=False):
+ # If a thumbnail is already in cache, let's use it. But only if it is
+ # bigger than what we need, because we don't want to lose quality.
+ if (use_cache and cell.photo.filename in cache and
+ cache[cell.photo.filename].size[0] >= int(round(cell.w)) and
+ cache[cell.photo.filename].size[1] >= int(round(cell.h))):
+ img = cache[cell.photo.filename].copy()
+ else:
+ img = PIL.Image.open(cell.photo.filename)
+
+ # Rotate image is EXIF says so
+ if cell.photo.orientation == 3:
+ img = img.rotate(180, expand=True)
+ elif cell.photo.orientation == 6:
+ img = img.rotate(270, expand=True)
+ elif cell.photo.orientation == 8:
+ img = img.rotate(90, expand=True)
+
+ if self.quality == QUALITY_FAST:
+ method = PIL.Image.NEAREST
+ else:
+ method = PIL.Image.LANCZOS # renamed from ANTIALIAS in Pillow 10
+
+ shape = img.size[0] * cell.h - img.size[1] * cell.w
+ if shape > 0: # image is too thick
+ img = img.resize((int(round(cell.h * img.size[0] / img.size[1])),
+ int(round(cell.h))), method)
+ elif shape < 0: # image is too tall
+ img = img.resize((int(round(cell.w)),
+ int(round(cell.w * img.size[1] / img.size[0]))),
+ method)
+ else:
+ img = img.resize((int(round(cell.w)), int(round(cell.h))), method)
+
+ # Save this new image to cache (if it is larger than the previous one)
+ if (use_cache and (cell.photo.filename not in cache or
+ cache[cell.photo.filename].size[0] < img.size[0])):
+ cache[cell.photo.filename] = img
+
+ if shape > 0: # image is too thick
+ width_to_crop = img.size[0] - cell.w
+ img = img.crop((
+ int(round(width_to_crop * cell.photo.offset_w)),
+ 0,
+ int(round(img.size[0] - width_to_crop *
+ (1 - cell.photo.offset_w))),
+ int(round(cell.h))
+ ))
+ elif shape < 0: # image is too tall
+ height_to_crop = img.size[1] - cell.h
+ img = img.crop((
+ 0,
+ int(round(height_to_crop * cell.photo.offset_h)),
+ int(round(cell.w)),
+ int(round(img.size[1] - height_to_crop *
+ (1 - cell.photo.offset_h)))
+ ))
+
+ return img
+
+ def paste_photo(self, canvas, cell, img):
+ canvas.paste(img, (int(round(cell.x)), int(round(cell.y))))
+ return canvas
+
+ def run(self):
+ try:
+ canvas = PIL.Image.new(
+ "RGB", (int(self.page.w), int(self.page.h)), "white")
+
+ self.draw_skeleton(canvas)
+ self.draw_borders(canvas)
+
+ if self.quality != QUALITY_SKEL:
+ n = sum([len([cell for cell in col.cells if not
+ cell.is_extension()]) for col in self.page.cols])
+ i = 0.0
+ if self.on_update:
+ self.on_update(canvas, 0.0)
+ last_update = time.time()
+
+ for col in self.page.cols:
+ for c in col.cells:
+ if self.canceled: # someone clicked "abort"
+ return
+
+ if c.is_extension():
+ continue
+
+ img = self.resize_photo(c, use_cache=True)
+ self.paste_photo(canvas, c, img)
+
+ # Only needed for interactive rendering
+ if self.on_update:
+ self.draw_borders(canvas)
+
+ i += 1
+ now = time.time()
+ if self.on_update and now > last_update + 0.1:
+ self.on_update(canvas, i / n)
+ last_update = now
+
+ self.draw_borders(canvas)
+
+ if self.output_file:
+ canvas.save(self.output_file)
+
+ if self.on_complete:
+ self.on_complete(canvas)
+ except Exception as e:
+ if self.on_fail:
+ self.on_fail(e)
diff --git a/bin/system-theme b/bin/system-theme
new file mode 100755
index 0000000..862b243
--- /dev/null
+++ b/bin/system-theme
@@ -0,0 +1,29 @@
+#!/usr/bin/env sh
+# Switch the COSMIC desktop between light and dark mode, and optionally the
+# wallpaper with it: set SYSTEM_THEME_WALLPAPER_DARK and
+# SYSTEM_THEME_WALLPAPER_LIGHT to image files. Emacs's auto-dark follows the
+# desktop's mode on its own. On another desktop, this is the file to rewrite.
+
+COSMIC="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic"
+
+set_wallpaper() {
+ [ -n "$1" ] || return 0
+ wallpaper="$(realpath "$1")" || return 1
+ sed -i 's|^.*source: Path(".*"),$| source: Path("'"$wallpaper"'"),|' \
+ "$COSMIC/com.system76.CosmicBackground/v1/all"
+}
+
+case "$1" in
+ dark)
+ echo "true" > "$COSMIC/com.system76.CosmicTheme.Mode/v1/is_dark"
+ set_wallpaper "${SYSTEM_THEME_WALLPAPER_DARK:-}"
+ ;;
+ light)
+ echo "false" > "$COSMIC/com.system76.CosmicTheme.Mode/v1/is_dark"
+ set_wallpaper "${SYSTEM_THEME_WALLPAPER_LIGHT:-}"
+ ;;
+ *)
+ echo "usage: system-theme dark|light" >&2
+ exit 1
+ ;;
+esac
diff --git a/bin/systemd/docsync-backup.service b/bin/systemd/docsync-backup.service
new file mode 100644
index 0000000..1c83da8
--- /dev/null
+++ b/bin/systemd/docsync-backup.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=docsync: encrypted hourly backup
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/bin/docsync backup
+Nice=10
+IOSchedulingClass=idle
diff --git a/bin/systemd/docsync-backup.timer b/bin/systemd/docsync-backup.timer
new file mode 100644
index 0000000..0ad450c
--- /dev/null
+++ b/bin/systemd/docsync-backup.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=docsync: hourly backup
+
+[Timer]
+OnCalendar=hourly
+RandomizedDelaySec=5min
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/bin/systemd/docsync-check.service b/bin/systemd/docsync-check.service
new file mode 100644
index 0000000..c577352
--- /dev/null
+++ b/bin/systemd/docsync-check.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=docsync: verify the backup repository
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/bin/docsync check
+Nice=10
+IOSchedulingClass=idle
diff --git a/bin/systemd/docsync-check.timer b/bin/systemd/docsync-check.timer
new file mode 100644
index 0000000..ae9d03e
--- /dev/null
+++ b/bin/systemd/docsync-check.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=docsync: monthly repository check
+
+[Timer]
+OnCalendar=monthly
+RandomizedDelaySec=1h
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/bin/systemd/docsync-forget.service b/bin/systemd/docsync-forget.service
new file mode 100644
index 0000000..72efeb9
--- /dev/null
+++ b/bin/systemd/docsync-forget.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=docsync: thin out old snapshots and free their space
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/bin/docsync forget
+Nice=10
+IOSchedulingClass=idle
diff --git a/bin/systemd/docsync-forget.timer b/bin/systemd/docsync-forget.timer
new file mode 100644
index 0000000..548669d
--- /dev/null
+++ b/bin/systemd/docsync-forget.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=docsync: weekly snapshot thinning
+
+[Timer]
+OnCalendar=weekly
+RandomizedDelaySec=1h
+Persistent=true
+
+[Install]
+WantedBy=timers.target