From 9b71b37dd054ee4986be523497be80726f8fad8b Mon Sep 17 00:00:00 2001 From: bitheaven Date: Fri, 24 Jul 2026 15:07:31 +0500 Subject: [PATCH] Initial commit --- .dockerignore | 4 + .gitignore | 17 ++++ Dockerfile | 53 +++++++++++ Makefile | 33 +++++++ README.md | 33 +++++++ compose.yaml | 22 +++++ docker-entrypoint.sh | 14 +++ scan-one | 210 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 386 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 docker-entrypoint.sh create mode 100755 scan-one diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0bd7d63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +* +!Dockerfile +!docker-entrypoint.sh +!scan-one diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd70cc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Generated scans +scans/ + +# Local environment overrides +.env +.env.* +!.env.example + +# Local tooling +.agents/ +.codex/ +.idea/ +.vscode/ + +# OS metadata +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f3c1f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +FROM alpine:3.24 + +ARG HPLIP_PLUGIN_VERSION=3.26.4 +ARG HPLIP_PLUGIN_SHA256=199f78f8af7f36894d7180e9090963ce2550a75ec701f8a4ba37665a9746fdf0 + +RUN apk add --no-cache \ + dbus \ + gcompat \ + hplip \ + imagemagick \ + imagemagick-pdf \ + sane-backend-hpaio \ + sane-utils \ + tzdata + +# The M1217 scanner needs HP's proprietary Marvell plug-in. HP ships it as a +# glibc binary, so gcompat is preloaded when SANE starts it on Alpine/musl. +RUN wget \ + -q \ + -O /tmp/hplip-plugin.run \ + "https://www.openprinting.org/download/printdriver/auxfiles/HP/plugins/hplip-${HPLIP_PLUGIN_VERSION}-plugin.run" \ + && echo "${HPLIP_PLUGIN_SHA256} /tmp/hplip-plugin.run" | sha256sum -c - \ + && mkdir -p /tmp/hplip-plugin /usr/share/hplip/scan/plugins \ + && sh /tmp/hplip-plugin.run \ + --target /tmp/hplip-plugin \ + --noexec \ + >/dev/null \ + && install \ + -m 0755 \ + /tmp/hplip-plugin/bb_marvell-x86_64.so \ + /usr/share/hplip/scan/plugins/bb_marvell-x86_64.so \ + && ln -sf \ + /usr/share/hplip/scan/plugins/bb_marvell-x86_64.so \ + /usr/share/hplip/scan/plugins/bb_marvell.so \ + && mkdir -p /var/lib/hp \ + && printf \ + '[plugin]\ninstalled = 1\neula = 1\nversion = %s\n' \ + "${HPLIP_PLUGIN_VERSION}" \ + > /var/lib/hp/hplip.state \ + && chmod 0644 /var/lib/hp/hplip.state \ + && rm -rf /tmp/hplip-plugin /tmp/hplip-plugin.run + +ENV LD_PRELOAD=/lib/libgcompat.so.0 + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY scan-one /usr/local/bin/scan-one + +RUN chmod 0755 \ + /usr/local/bin/docker-entrypoint.sh \ + /usr/local/bin/scan-one + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["/usr/local/bin/scan-one"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf3afb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +SHELL := /bin/ash +.SHELLFLAGS := -eu -c + +SCAN_CONTAINER := hp-m1217-scanner-run + +.PHONY: scan build clean check + +scan: check build + @mkdir -p scans + @docker rm -f "$(SCAN_CONTAINER)" >/dev/null 2>&1 || true + @cleanup() { \ + docker rm -f "$(SCAN_CONTAINER)" >/dev/null 2>&1 || true; \ + }; \ + trap cleanup EXIT HUP INT TERM; \ + PUID="$$(id -u)" PGID="$$(id -g)" \ + docker compose run --rm --name "$(SCAN_CONTAINER)" scanner + +build: check + @docker compose build scanner + +clean: check + @docker rm -f "$(SCAN_CONTAINER)" >/dev/null 2>&1 || true + @docker compose down --rmi all --remove-orphans + +check: + @command -v docker >/dev/null 2>&1 || { \ + echo "Error: docker is required." >&2; \ + exit 1; \ + } + @docker compose version >/dev/null 2>&1 || { \ + echo "Error: the Docker Compose plugin is required." >&2; \ + exit 1; \ + } diff --git a/README.md b/README.md new file mode 100644 index 0000000..12794a5 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# HP M1217 scanner + +The scanner runs in an Alpine Linux container. Build the image and scan one +page from the flatbed with: + +```sh +make scan +``` + +The resulting PDF is written to `scans/`. The command passes the current user +and group IDs to the container, so the PDF is owned by the invoking user. + +Optional settings can be passed to `make`: + +```sh +RESOLUTION=600 MODE=Gray make scan +``` + +Supported variables are `RESOLUTION`, `MODE`, `SCAN_SOURCE`, `SCAN_WIDTH`, +`SCAN_HEIGHT`, `JPEG_QUALITY`, `SCAN_TIMEOUT` (seconds), and `SANE_DEVICE`. +The default flatbed area is `215.9 × 297 mm`; override it when using a +different source or page size. + +If an interrupted run leaves a container behind, the next `make scan` removes +that exact scan container before opening the USB device. + +Remove the scanner container, Compose resources, and the built image with: + +```sh +make clean +``` + +Scanned PDFs in `scans/` are preserved. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..691a611 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,22 @@ +services: + scanner: + build: + context: . + image: hp-m1217-scanner:latest + privileged: true + restart: "no" + environment: + TZ: ${TZ:-Europe/Berlin} + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + RESOLUTION: ${RESOLUTION:-300} + MODE: ${MODE:-Color} + SCAN_SOURCE: ${SCAN_SOURCE:-Flatbed} + SCAN_WIDTH: ${SCAN_WIDTH:-215.9} + SCAN_HEIGHT: ${SCAN_HEIGHT:-297} + JPEG_QUALITY: ${JPEG_QUALITY:-90} + SCAN_TIMEOUT: ${SCAN_TIMEOUT:-300} + SANE_DEVICE: ${SANE_DEVICE:-} + volumes: + - /dev/bus/usb:/dev/bus/usb + - ./scans:/scans diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..8af8d34 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +mkdir -p /run/dbus + +rm -f /run/dbus/pid + +if [[ ! -s /etc/machine-id ]]; then + dbus-uuidgen --ensure=/etc/machine-id +fi + +dbus-daemon --system --fork + +exec "$@" diff --git a/scan-one b/scan-one new file mode 100755 index 0000000..f1b2ff0 --- /dev/null +++ b/scan-one @@ -0,0 +1,210 @@ +#!/bin/sh +set -eu + +OUTPUT_DIR="${OUTPUT_DIR:-/scans}" +RESOLUTION="${RESOLUTION:-300}" +MODE="${MODE:-Color}" +SCAN_SOURCE="${SCAN_SOURCE:-Flatbed}" +SCAN_WIDTH="${SCAN_WIDTH:-215.9}" +SCAN_HEIGHT="${SCAN_HEIGHT:-297}" +JPEG_QUALITY="${JPEG_QUALITY:-90}" +SCAN_TIMEOUT="${SCAN_TIMEOUT:-300}" +PUID="${PUID:-0}" +PGID="${PGID:-0}" + +mkdir -p "$OUTPUT_DIR" + +DEVICE="${SANE_DEVICE:-}" + +if [ -z "$DEVICE" ]; then + DEVICE="$(scanimage -f '%d%n' 2>/dev/null | sed -n '/^hpaio:/{p;q;}')" +fi + +if [ -z "$DEVICE" ]; then + echo "Error: HP scanner not found." >&2 + scanimage -L || true + exit 2 +fi + +STAMP="$(date '+%Y-%m-%d_%H-%M-%S')" + +TMP_PNM="$(mktemp "/tmp/${STAMP}.pnm.XXXXXX")" +TMP_FIXED="${TMP_PNM}.fixed" +OUTPUT_FILE="${OUTPUT_DIR}/${STAMP}.pdf" +OUTPUT_COMPLETE=0 + +cleanup() { + rm -f "$TMP_PNM" "$TMP_FIXED" + if [ "$OUTPUT_COMPLETE" -ne 1 ]; then + rm -f "$OUTPUT_FILE" + fi +} + +trap cleanup EXIT HUP INT TERM + +SUFFIX=1 +while [ -e "$OUTPUT_FILE" ]; do + OUTPUT_FILE="${OUTPUT_DIR}/${STAMP}_${SUFFIX}.pdf" + SUFFIX=$((SUFFIX + 1)) +done + +echo "Scanner: $DEVICE" +echo "Source: $SCAN_SOURCE" +echo "Resolution: $RESOLUTION dpi" +echo "Mode: $MODE" +echo "Area: ${SCAN_WIDTH} x ${SCAN_HEIGHT} mm" +echo "Output: $OUTPUT_FILE" + +set +e +timeout "$SCAN_TIMEOUT" scanimage \ + -d "$DEVICE" \ + --source "$SCAN_SOURCE" \ + --resolution "$RESOLUTION" \ + --mode "$MODE" \ + -x "$SCAN_WIDTH" \ + -y "$SCAN_HEIGHT" \ + --format=pnm \ + --output-file="$TMP_PNM" +SCAN_STATUS=$? +set -e + +if [ "$SCAN_STATUS" -ne 0 ]; then + if [ "$SCAN_STATUS" -eq 124 ] || [ "$SCAN_STATUS" -eq 137 ] || [ "$SCAN_STATUS" -eq 143 ]; then + echo "Error: scanner did not respond within ${SCAN_TIMEOUT} seconds." >&2 + else + echo "Error: scanimage failed with status ${SCAN_STATUS}." >&2 + fi + exit "$SCAN_STATUS" +fi + +if [ ! -s "$TMP_PNM" ]; then + echo "Error: scanner returned an empty PNM file." >&2 + exit 3 +fi + +normalize_pnm_height() { + MAGIC="$(sed -n '1p' "$TMP_PNM")" + COMMENT="$(sed -n '2p' "$TMP_PNM")" + DIMENSIONS="$(sed -n '3p' "$TMP_PNM")" + + if [ "$COMMENT" != '# SANE data follows' ]; then + echo "Error: scanner returned an unexpected PNM header." >&2 + return 1 + fi + + set -- $DIMENSIONS + if [ "$#" -ne 2 ]; then + echo "Error: scanner returned invalid PNM dimensions." >&2 + return 1 + fi + + WIDTH="$1" + HEIGHT="$2" + + case "${WIDTH}:${HEIGHT}" in + *[!0-9:]* | :* | *:) + echo "Error: scanner returned invalid PNM dimensions." >&2 + return 1 + ;; + esac + + case "$MAGIC" in + P6) + CHANNELS=3 + HEADER_LINES=4 + ;; + P5) + CHANNELS=1 + HEADER_LINES=4 + ;; + P4) + CHANNELS=0 + HEADER_LINES=3 + ;; + *) + echo "Error: scanner returned unsupported PNM type ${MAGIC}." >&2 + return 1 + ;; + esac + + if [ "$MAGIC" = P4 ]; then + ROW_BYTES=$(((WIDTH + 7) / 8)) + else + MAX_VALUE="$(sed -n '4p' "$TMP_PNM")" + case "$MAX_VALUE" in + '' | *[!0-9]*) + echo "Error: scanner returned an invalid PNM color depth." >&2 + return 1 + ;; + esac + + if [ "$MAX_VALUE" -gt 255 ]; then + SAMPLE_BYTES=2 + else + SAMPLE_BYTES=1 + fi + ROW_BYTES=$((WIDTH * CHANNELS * SAMPLE_BYTES)) + fi + + HEADER_BYTES="$(head -n "$HEADER_LINES" "$TMP_PNM" | wc -c)" + FILE_BYTES="$(wc -c < "$TMP_PNM")" + PAYLOAD_BYTES=$((FILE_BYTES - HEADER_BYTES)) + EXPECTED_BYTES=$((ROW_BYTES * HEIGHT)) + + if [ "$PAYLOAD_BYTES" -eq "$EXPECTED_BYTES" ]; then + return 0 + fi + + if [ "$PAYLOAD_BYTES" -lt 0 ] || [ $((PAYLOAD_BYTES % ROW_BYTES)) -ne 0 ]; then + echo "Error: scanner returned a truncated PNM row." >&2 + return 1 + fi + + ACTUAL_HEIGHT=$((PAYLOAD_BYTES / ROW_BYTES)) + MISSING_ROWS=$((HEIGHT - ACTUAL_HEIGHT)) + + if [ "$MISSING_ROWS" -ne 1 ]; then + echo "Error: scanner returned ${ACTUAL_HEIGHT} rows instead of ${HEIGHT}." >&2 + return 1 + fi + + echo "Warning: correcting the HPAIO one-row PNM metadata error." >&2 + + { + head -n 2 "$TMP_PNM" + printf '%s %s\n' "$WIDTH" "$ACTUAL_HEIGHT" + if [ "$HEADER_LINES" -eq 4 ]; then + printf '%s\n' "$MAX_VALUE" + fi + tail -c "+$((HEADER_BYTES + 1))" "$TMP_PNM" + } > "$TMP_FIXED" + + mv "$TMP_FIXED" "$TMP_PNM" +} + +normalize_pnm_height + +echo "Scan completed." + +magick \ + -density "$RESOLUTION" \ + "$TMP_PNM" \ + -compress JPEG \ + -quality "$JPEG_QUALITY" \ + "$OUTPUT_FILE" + +if [ ! -s "$OUTPUT_FILE" ]; then + echo "Error: PDF was not created." >&2 + exit 4 +fi + +PDF_MAGIC="$(dd if="$OUTPUT_FILE" bs=5 count=1 2>/dev/null)" +if [ "$PDF_MAGIC" != '%PDF-' ]; then + echo "Error: output file is not a valid PDF." >&2 + exit 5 +fi + +chown "$PUID:$PGID" "$OUTPUT_FILE" 2>/dev/null || true + +OUTPUT_COMPLETE=1 +echo "Saved: $OUTPUT_FILE"