sent-tools

A small toolkit of shell scripts for compiling suckless sent slideshows.
git clone https://scm.kuandu.systems/git-raw/sent-tools.git
Log | Files | Refs | README

commit 76a3330d51a2f999a98ee24a0227ca2ab9f924a1
Author: Fred Großkopf <fred@kuandu.systems>
Date:   Tue, 14 Oct 2025 23:50:11 +0200

Init

Diffstat:
AREADME | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acompile-sent-tables.sh | 36++++++++++++++++++++++++++++++++++++
Acompile-sentspec-wrapper.sh | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acompile-sentspec.sh | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconfig.example | 8++++++++
Ainstall.sh | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asent-dev.sh | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 747 insertions(+), 0 deletions(-)

diff --git a/README b/README @@ -0,0 +1,59 @@ +# sent-tools + +A small toolkit of shell scripts for compiling suckless sent slideshows. + +## Scripts + +- `compile-sent-tables.sh` - Converts Markdown tables in tables/ to images/tables +- `compile-sentspec.sh` - Compiles .sentspec to .sent (see -h for more info) +- `sent-dev.sh` - Developer helper for automated workflow (requires `tmux`, `entr`). +- `compile-sentspec-wrapper.sh` - a helper script which allows to use a `config` + +**Important:** + +Scripts have `.sh` in the repo but are intended to be called without `.sh` after installation. + +## Dependencies + +- Required: + `md-table-to-jpg`, `curl`, `convert`, `identify`, `mktemp`, `cut`, `grep`, `awk`, `sha256sum` (or `sha256`), `stat` + +- For developer workflows: + `tmux`, `entr` + +- Optional: + Config file providing a `compile_sentspec_args()` function + +## Installation + +1. Clone this repository: + ``` + git clone https://some-url/sent-tools.git + cd sent-tools + ``` + +2. Run the install script to symlink scripts (without `.sh`) to `~/bin`: + ``` + ./install.sh + ``` + If you want to symlink to a different directory in your `PATH`, provide it: + ``` + ./install.sh /desired/path/bin + ``` +3. Make sure `~/bin` (or your chosen directory) is in your `PATH`: + ``` + export PATH="$HOME/bin:$PATH" + ``` + Add that line to your `.bashrc` or `.zshrc` for persistence. + +4. Optional: copy/edit `config.example` to `your-project/config` + +## Usage + +Run tools using their name (without `.sh`), for example: + +``` +compile-sentspec input.sentspec > output.sent +compile-sent-tables +sent-dev input.sentspec +``` diff --git a/compile-sent-tables.sh b/compile-sent-tables.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# convert-tables.sh — Convert tables/*.md to images/tables/*.jpg, with dependency checking + +MD_TABLE_TO_JPG="${MD_TABLE_TO_JPG:-md2jpg-table}" + +status() { printf "[*] %s\n" "$1" >&2; } + +# Dependency check +for dep in basename "$MD_TABLE_TO_JPG"; do + if ! command -v "$dep" > /dev/null 2>&1; then + status "Dependency not found: $dep" + status "Please install the missing dependency and try again." + exit 1 + fi +done + +mkdir -p images/tables + +# Convert or update images from markdown files +for f in tables/*.md; do + [ -f "$f" ] || continue + out="images/tables/$(basename "${f%.md}.jpg")" + if [ ! -e "$out" ] || [ -n "$(find "$f" -prune -newer "$out")" ]; then + "$MD_TABLE_TO_JPG" "$f" "$out" + fi +done + +# Remove .jpg files that have no corresponding .md file +for img in images/tables/*.jpg; do + [ -f "$img" ] || continue + base="$(basename "$img" .jpg)" + if [ ! -f "tables/${base}.md" ]; then + status "Removing orphan image $img" + rm "$img" + fi +done diff --git a/compile-sentspec-wrapper.sh b/compile-sentspec-wrapper.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# +# Usage: ./compile-sentspec-wrapper.sh file.sentspec +# +# This script: +# - Checks that the input ends with .sentspec. +# - Converts the input to an output filename ending with .sent. +# - Optionally loads extra arguments from a 'config' file in the same directory. +# - Runs compile-sentspec with these options and writes output to the .sent file. +# +# Config file format: +# - Place a file named "config" in the script directory (optional). +# - To add options, define a shell function named compile_sentspec_args that +# prints each argument on its own line, for example: +# +# compile_sentspec_args() { +# printf '%s\n' \ +# -b black \ +# -f '#00ff00' \ +# -F 'Comic Neue Regular' +# } +# +# - If no config or function is given, only the input file is used. + +set -eu + +case $1 in + *.sentspec) ;; + *) + echo "Usage: $0 file.sentspec" >&2 + exit 1 + ;; +esac + +status() { + printf '[*] %s\n' "$*" >&2 +} + +specfile="$1" +outfile="${specfile%.sentspec}.sent" + +# Source "config" (if it exists) for optional argument customization +specdir=$(dirname -- "$specfile") +CONFIG="$specdir/config" +if [ -f "$CONFIG" ]; then + status "Sourcing $CONFIG" + # shellcheck disable=SC1090 + . "$CONFIG" + + # If the user provided a compile_sentspec_args function in config, use its output for options + if command -v compile_sentspec_args > /dev/null 2>&1; then + # shellcheck disable=SC2046 + set -- $(compile_sentspec_args) + # Add the .sentspec file passed as the first arg to this script + set -- "$@" "$specfile" + else + stauts "No compile_sentspec_args() found in config!" + fi +else + status "No config file found" +fi + +status "Calling: exec compile-sentspec $@ > $outfile" +compile-sentspec "$@" > "$outfile" && status "Done" diff --git a/compile-sentspec.sh b/compile-sentspec.sh @@ -0,0 +1,367 @@ +#!/bin/sh +# +# compile-sent.sh - Compile a .sentspec meta-format file into a sent-compatible +# slide deck, supporting download/local images and source bars. +# + +set -eu + +VERBOSE=0 +readonly DOWNLOADED_DIR="./images/downloaded" +readonly COMPILED_DIR="./images/compiled" +readonly HASHES_FILE="./images.hashes" + +usage() { + cat << EOF +Usage: $0 [-b bgcolor] [-f fontcolor] [-F fontname] [-W width] [-H height] [-v] [-h] <file.sentspec> + +Compile a .sentspec meta-format file into a sent-compatible slide deck. + +Options: + -b bgcolor Set background color (default: white) + -f fontcolor Set font color (default: black) + -F fontname Set font family (default: DejaVu-Sans-Mono) + -W width Set output width in pixels (default: 1920) + -H height Set output height in pixels (default: 1080) + -v Verbose output + -h Show this help and exit + +Lines starting with 'L|', 'R|', or 'C|' are interpreted as: + + pos|mode|file|url|title|subtitle + + pos L for image left, R for image right, C for image center + mode D for download, F for local file + file Local filename for the image (or to save download as) + url Image URL (optional, if present adds a source bar) + title Title text (L|R only) + subtitle Subtitle text (L|R only, optional) + +All other lines are passed through unchanged. +EOF +} + +error() { + printf "Error: %s\n" "$1" >&2 + exit 1 +} + +status() { + if [ "$VERBOSE" = 1 ]; then + printf "[*] %s\n" "$1" >&2 + fi +} + +check_dependencies() { + for cmd in curl convert identify mktemp cut grep awk sha256 stat; do + command -v "$cmd" > /dev/null 2>&1 \ + || error "Required command '$cmd' not found." + done +} + +check_font() { + font="$1" + if ! convert -list font | grep -qi "^ *Font: *$font\$"; then + status "Font '$font' not found in ImageMagick. Using anyway, but rendering may fail." + fi +} + +download_image() { + url="$1" + filename="$2" + status "Downloading $url" + curl -sSL "$url" -o "$filename" || error "Failed to download $url" +} + +scale_image() { + input="$1" + output="$2" + max_width="$3" + max_height="$4" + + img_w=$(identify -format "%w" "$input") + img_h=$(identify -format "%h" "$input") + + scale_w=$(awk -v mw="$max_width" -v iw="$img_w" 'BEGIN {print mw/iw}') + scale_h=$(awk -v mh="$max_height" -v ih="$img_h" 'BEGIN {print mh/ih}') + scale=$(awk -v sw="$scale_w" -v sh="$scale_h" \ + 'BEGIN {if (sw < sh) print sw; else print sh}') + new_w=$(awk -v iw="$img_w" -v s="$scale" 'BEGIN {printf "%d", iw*s}') + new_h=$(awk -v ih="$img_h" -v s="$scale" 'BEGIN {printf "%d", ih*s}') + + convert "$input" -background white -flatten -resize "${new_w}x${new_h}" \ + "$output" +} + +combine_image_text() { + pos="$1" + imgfile="$2" + outfile="$3" + title="$4" + subtitle="$5" + screen_w="$6" + screen_h="$7" + bgcolor="$8" + fontcolor="$9" + font="${10}" + tmpdir="${11}" + + half_w=$((screen_w / 2)) + title_pt=68 + subtitle_pt=52 + title_y=$((-1 * (screen_h / 10))) + subtitle_y=$((title_y + 200)) + + text_img=$(mktemp "$tmpdir/textimg.XXXXXX.png") + + if [ -n "$subtitle" ]; then + convert -size "${half_w}x${screen_h}" xc:"$bgcolor" \ + -font "$font" -pointsize $title_pt -fill "$fontcolor" \ + -gravity Center -annotate +0+$title_y "$title" \ + -font "$font" -pointsize $subtitle_pt \ + -gravity Center -annotate +0+$subtitle_y "$subtitle" \ + "$text_img" + else + convert -size "${half_w}x${screen_h}" xc:"$bgcolor" \ + -font "$font" -pointsize $title_pt -fill "$fontcolor" \ + -gravity North -annotate +0+$title_y "$title" \ + "$text_img" + fi + cp "$text_img" test.jpg + + canvas=$(mktemp "$tmpdir/canvas.XXXXXX.png") + convert -size "${screen_w}x${screen_h}" xc:"$bgcolor" \ + -colorspace sRGB "$canvas" + convert "$canvas" -fill "#00ff00" -draw "point 0,0" "$canvas" + + img_w=$(identify -format "%w" "$imgfile") + half_w=$((screen_w / 2)) + + if [ "$img_w" -lt "$half_w" ]; then + offset=$((half_w - img_w)) + else + offset=0 + fi + + if [ "$pos" = "L" ]; then + convert "$canvas" \ + \( "$imgfile" -geometry +${offset}+0 -gravity west \) -composite \ + \( "$text_img" -gravity east -geometry +0+0 \) -composite \ + "$outfile" + else + convert "$canvas" \ + \( "$text_img" -gravity west -geometry +0+0 \) -composite \ + \( "$imgfile" -geometry +${offset}+0 -gravity east \) -composite \ + "$outfile" + fi +} + +add_source_bar() { + input_img="$1" + output_img="$2" + url="$3" + date="$4" + source_bar_font="$5" + source_bar_font_size="$6" + source_bar_height="$7" + + img_w=$(identify -format "%w" "$input_img") + source_text="Source: $url, $date" + source_text_escaped=$(printf '%s' "$source_text" | sed 's/%/%%/g') + + bar_img=$(mktemp "$TMPDIR/bar.XXXXXX.png") + convert -size "${img_w}x${source_bar_height}" xc:"rgba(255,255,255,0.8)" \ + -font "$source_bar_font" -pointsize "$source_bar_font_size" -fill "black" \ + -gravity northwest -annotate +10+0 "$source_text_escaped" \ + "$bar_img" + + convert "$input_img" "$bar_img" -gravity south -composite "$output_img" +} + +compute_slide_hash() { + imgfile="${1:-}" + line="${2:-}" + font="${3:-}" + fontcolor="${4:-}" + bgcolor="${5:-}" + { + sha256 < "${imgfile:-/dev/null}" + printf "%s\n%s\n%s\n%s\n" "$line" "$font" "$fontcolor" "$bgcolor" + } | sha256 | awk '{print $1}' +} + +get_stored_hash() { + [ -f "$HASHES_FILE" ] || return 1 + grep -F -- "$1 " "$HASHES_FILE" 2> /dev/null | awk '{print $2}' +} + +update_hash() { + tmpf=$(mktemp "$TMPDIR/hashupdate.XXXXXX") + if [ -f "$HASHES_FILE" ]; then + grep -v -F -- "$1 " "$HASHES_FILE" > "$tmpf" || true + fi + printf "%s %s\n" "$1" "$2" >> "$tmpf" + mv "$tmpf" "$HASHES_FILE" +} + +cleanup() { + [ -n "${TMPDIR:-}" ] && [ -d "$TMPDIR" ] && rm -rf "$TMPDIR" +} + +main() { + bgcolor="white" + fontcolor="black" + font="DejaVu-Sans-Mono" + screen_w=1920 + screen_h=1080 + source_bar_font="DejaVu-Sans-Condensed" + source_bar_font_size=12 + source_bar_height=18 + + while getopts "b:f:F:W:H:vh" opt; do + case "$opt" in + b) bgcolor="$OPTARG" ;; + f) fontcolor="$OPTARG" ;; + F) font="$OPTARG" ;; + W) screen_w="$OPTARG" ;; + H) screen_h="$OPTARG" ;; + v) VERBOSE=1 ;; + h) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; + esac + done + shift $((OPTIND - 1)) + + if [ $# -ne 1 ]; then + usage + exit 1 + fi + + input_file="$1" + [ -f "$input_file" ] || error "Input file '$input_file' not found" + + check_dependencies + check_font "$font" + + + status "Using background color: $bgcolor" + status "Using font color: $fontcolor" + status "Using font: $font" + status "Output size: ${screen_w}x${screen_h}" + + TMPDIR=$(mktemp -d "/tmp/compile-sent.XXXXXX") + readonly TMPDIR + trap cleanup EXIT INT TERM + + for d in "$DOWNLOADED_DIR" "$COMPILED_DIR"; do + [ -d "$d" ] || mkdir -p "$d" + done + + if [ ! -f "$HASHES_FILE" ]; then + : > "$HASHES_FILE" + fi + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + L\|* | R\|* | C\|*) + pos=$(printf '%s\n' "$line" | cut -d'|' -f1) + mode=$(printf '%s\n' "$line" | cut -d'|' -f2) + filename=$(printf '%s\n' "$line" | cut -d'|' -f3) + url=$(printf '%s\n' "$line" | cut -d'|' -f4) + title="" + subtitle="" + if [ "$pos" = "L" ] || [ "$pos" = "R" ]; then + title=$(printf '%s\n' "$line" | cut -d'|' -f5) + subtitle=$(printf '%s\n' "$line" | cut -d'|' -f6-) + fi + + if [ -z "$filename" ]; then + status "Malformed meta line, skipping: $line" + continue + fi + + if [ "$mode" = "F" ]; then + imgfile="$filename" + basefile=$(basename "$filename") + if [ ! -f "$imgfile" ]; then + status "Local image not found: $filename" + continue + fi + elif [ "$mode" = "D" ]; then + basefile=$(basename "$filename") + imgfile="$DOWNLOADED_DIR/$basefile" + if [ -f "$imgfile" ]; then + #status "Already downloaded: $imgfile" + : + else + if [ -z "$url" ]; then + status "No URL for download mode, skipping: $line" + continue + fi + download_image "$url" "$imgfile" + + fi + else + status "Unknown mode: $mode" + continue + fi + + outimg="$COMPILED_DIR/${basefile%.*}.jpg" + + slide_hash=$(compute_slide_hash "$imgfile" "$line" "$font" "$fontcolor" "$bgcolor") + stored_hash=$(get_stored_hash "$outimg" || true) + + if [ "$slide_hash" = "$stored_hash" ] && [ -f "$outimg" ]; then + status "Slide unchanged, skipping processing." + printf "@%s\n" "$outimg" + continue + fi + + status "Compiling: $outimg" + + scaled_img="$imgfile" + max_img_w="$screen_w" + max_img_h="$screen_h" + if [ "$pos" = "L" ] || [ "$pos" = "R" ]; then + max_img_w=$((screen_w / 2)) + max_img_h=$screen_h + fi + scaled_img=$(mktemp "$TMPDIR/scaledimg.XXXXXX.jpg") + scale_image "$imgfile" "$scaled_img" "$max_img_w" "$max_img_h" + + img_with_bar="$scaled_img" + if [ -n "$url" ]; then + if stat -f %B "$imgfile" 2> /dev/null | grep -qv '^0$'; then + date=$(stat -f "%SB" -t "%Y-%m-%d %H:%M:%S" "$imgfile") + else + date=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$imgfile") + fi + img_with_bar=$(mktemp "$TMPDIR/withbar.XXXXXX.jpg") + add_source_bar "$scaled_img" "$img_with_bar" "$url" "$date" \ + "$source_bar_font" "$source_bar_font_size" "$source_bar_height" + fi + + if [ "$pos" = "L" ] || [ "$pos" = "R" ]; then + combine_image_text "$pos" "$img_with_bar" "$outimg" "$title" "$subtitle" \ + "$screen_w" "$screen_h" "$bgcolor" "$fontcolor" "$font" "$TMPDIR" + else + cp "$img_with_bar" "$outimg" + fi + + printf "@%s\n" "$outimg" + update_hash "$outimg" "$slide_hash" + ;; + *) + printf "%s\n" "$line" + ;; + esac + done < "$input_file" +} + +main "$@" diff --git a/config.example b/config.example @@ -0,0 +1,8 @@ +compile_sentspec_args() { + # Each line becomes one argument, so spaces are preserved! + printf '%s\n' \ + -b black \ + -f '#00ff00' \ + -F 'Comic-Neue-Regular' \ + -v +} diff --git a/install.sh b/install.sh @@ -0,0 +1,57 @@ +#!/bin/sh + +# Simple POSIX install script to symlink all *.sh scripts (except itself) +# into a target directory (default: ~/bin), removing the .sh extension. +# Warns if target dir is not in PATH. + +main() { + # Set install directory + if [ "$1" ]; then + INSTALL_DIR="$1" + else + INSTALL_DIR="$HOME/bin" + fi + + # Create target directory if needed + if [ ! -d "$INSTALL_DIR" ]; then + mkdir -p "$INSTALL_DIR" + fi + + printf 'Symlinking scripts to %s ...\n' "$INSTALL_DIR" + + for script in *.sh; do + # Exclude this install script + if [ "$script" = "install.sh" ]; then + continue + fi + + base=`basename "$script" .sh` + src="$PWD/$script" + dest="$INSTALL_DIR/$base" + + chmod +x "$src" + + if [ -e "$dest" ] || [ -L "$dest" ]; then + rm -f "$dest" + fi + + ln -s "$src" "$dest" + printf ' -> %s\n' "$dest" + done + + printf 'Done.\n' + + # Quick check if INSTALL_DIR is in PATH + case ":$PATH:" in + *:"$INSTALL_DIR":*) + # Already in PATH + ;; + *) + printf '\nWARNING: %s is not in your PATH.\n' "$INSTALL_DIR" + printf 'Add the following to your shell'\''s rc file (e.g., ~/.profile):\n' + printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR" + ;; + esac +} + +main "$@" diff --git a/sent-dev.sh b/sent-dev.sh @@ -0,0 +1,156 @@ +#!/bin/sh +set -eu +# sent-dev.sh — tmux-based development environment for sent slide decks +# +# This script: +# - Opens your editor on a sent slide spec file, creating it if missing. +# - Watches that file for changes and recompiles it to .sent. +# - Provides a pane with a shell for interactive work. +# - Watches the tables/ directory for Markdown tables + auto compiles. +# +# Each process runs in its own tmux pane/window. +# Requires: tmux, entr, compile-sentspec-wrapper, compile-sentspec, compile-sent-tables +# +# Usage: sent-dev.sh <deck.sentspec> +# + +# === Globals === +SPEC="" +SPECDIR="" +CONFIG_FILE="" +SESSION="" +COMPILE_SENTSPEC_ARGS="" +EDITOR_CMD="${EDITOR:-vi}" + +# === Functions === + +error() { + printf "Error: %s\n" "$1" >&2 + exit 1 +} + +usage() { + printf "Usage: %s <deck.sentspec>\n" "$0" >&2 + exit 1 +} + +check_deps() { + missing=0 + for dep in tmux entr compile-sentspec-wrapper compile-sentspec compile-sent-tables; do + if ! command -v "$dep" > /dev/null 2>&1; then + printf "Dependency not found: %s\n" "$dep" >&2 + missing=1 + fi + done + if [ "$missing" -eq 1 ]; then + error "Please install the missing dependencies and try again." + fi +} + +parse_args() { + if [ "$#" -ne 1 ]; then + usage + fi + + case "$1" in + *.sentspec) ;; + *) error "Input file must have .sentspec extension." ;; + esac + + # Make SPEC absolute + case "$1" in + /*) SPEC="$1" ;; + *) + SPEC=$(cd "$(dirname -- "$1")" && pwd)/$(basename -- "$1") \ + || error "Invalid spec path: $1" + ;; + esac + + [ -f "$SPEC" ] || : > "$SPEC" + + SPECDIR=$(dirname -- "$SPEC") + CONFIG_FILE="$SPECDIR/config" + + if [ -f "$CONFIG_FILE" ]; then + # shellcheck disable=SC1090 + . "$CONFIG_FILE" + fi + + COMPILE_SENTSPEC_ARGS=${COMPILE_SENTSPEC_ARGS:-} +} + +ensure_workdirs() { + [ -d "$SPECDIR/tables" ] || mkdir -p "$SPECDIR/tables" +} + +start_tmux() { + # Detect if inside tmux BEFORE unsetting TMUX + inside_tmux=0 + if [ -n "${TMUX:-}" ]; then + inside_tmux=1 + fi + + if tmux has-session -t "$SESSION" 2> /dev/null; then + if [ "$inside_tmux" -eq 1 ]; then + tmux switch-client -t "$SESSION" + else + exec tmux attach-session -t "$SESSION" + fi + exit 0 + fi + + unset TMUX # Prevent nesting issues + + # === Pane Layout === + + # 1. Editor pane + tmux new-session -d -s "$SESSION" -n edit -c "$SPECDIR" + tmux send-keys -t "$SESSION:0.0" "$EDITOR_CMD '$SPEC'" C-m + + # 2. Compile watcher (top right) + tmux split-window -h -t "$SESSION:0" -c "$SPECDIR" + tmux send-keys -t "$SESSION:0.1" "ls '$SPEC' | entr -c compile-sentspec-wrapper '$SPEC'" C-m + + # 3. Table watcher (below compile watcher) + if [ -d "$SPECDIR/tables" ]; then + if ! find "$SPECDIR/tables" -name '*.md' | grep -q .; then + : > "$SPECDIR/tables/.sent-empty.md" + fi + tmux split-window -v -t "$SESSION:0.1" -c "$SPECDIR" + tmux send-keys -t "$SESSION:0.2" \ + "while true; do find \"$SPECDIR/tables\" -name '*.md' | entr -d compile-sent-tables; done" C-m + else + # Table dir doesn't exist – create pane as shell + tmux split-window -v -t "$SESSION:0.1" -c "$SPECDIR" + fi + + # 4. Shell pane (always at bottom) + tmux split-window -v -t "$SESSION:0.2" + tmux send-keys -t "$SESSION:0.2" "sleep 0.1; cd '$SPECDIR'" C-m + + # Cleanup: Focus back to editor + tmux select-window -t "$SESSION:0" + tmux select-pane -t "$SESSION:0.0" + + # Attach or switch based on whether inside tmux *detected earlier* + if [ "$inside_tmux" -eq 1 ]; then + tmux switch-client -t "$SESSION" + else + exec tmux attach-session -t "$SESSION" + fi +} + +main() { + check_deps + parse_args "$@" + + # Unique session name based on SPEC path + basename=$(basename "$SPEC" | sed 's|\.sentspec$||') + cksum_val=$(printf "%s" "$SPEC" | cksum | awk '{print $1}') + SESSION="sentdev-${basename}-${cksum_val}" + + ensure_workdirs + start_tmux +} + +main "$@"