commit 4dc9f4687991fc33c957a29832fc0d27e7f22e48 Author: Jörn-Michael Miehe Date: Fri Oct 10 22:53:11 2025 +0000 ✨ initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a80c9d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/pdfsign.venv +/*.png +/*.pdf \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5123d1e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Yavook.de + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pdfsign.py b/pdfsign.py new file mode 100644 index 0000000..3f182fd --- /dev/null +++ b/pdfsign.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# make_text_invisible.py +# Usage: python make_text_invisible.py text.pdf out.pdf +# Produces a single-page PDF identical visually but with text made invisible (selectable/searchable). +# Requires: pip install pymupdf + +import sys +import fitz # pymupdf +import os + +if len(sys.argv) != 4: + print("Usage: python make_text_invisible.py text.pdf sig.png out.pdf", file=sys.stderr) + sys.exit(2) + +in_path, sig_path, out_path = sys.argv[1], sys.argv[2], sys.argv[3] + +doc = fitz.open(in_path) +if doc.page_count < 1: + print("Input has no pages", file=sys.stderr) + sys.exit(1) + +# operate only on first page (single-page input assumed) +page = doc.load_page(0) +w, h = page.rect.width, page.rect.height + +# create new doc and a blank page with same size +out = fitz.open() +newp = out.new_page(width=w, height=h) + +# # copy original page's visible content as an image to preserve exact appearance +# pix = page.get_pixmap(matrix=fitz.Matrix(1, 1), alpha=False) +# img_bytes = pix.tobytes("png") +# newp.insert_image(fitz.Rect(0, 0, w, h), stream=img_bytes) + +newp.insert_image(newp.rect, filename=sig_path, keep_proportion=False) + +# extract text spans (positions, fonts, sizes) +td = page.get_text("dict") + +for block in td.get("blocks", []): + for line in block.get("lines", []): + for span in line.get("spans", []): + + txt = span.get("text", "") + if not txt: + continue + + origin = span.get("origin") + if origin: + x, y = origin[0], origin[1] + else: + bbox = span.get("bbox", [0,0,0,0]) + x, y = bbox[0], bbox[3] + + # Use PDF invisible text rendering: render_mode=3 (neither fill nor stroke) is invisible but selectable. + try: + size = span.get("size", 12) + newp.insert_text((x, y), txt, fontsize=size, color=(0, 0, 0, 0), render_mode=3) + except Exception: + print(f"Failed to insert text: '{txt}'") + +out.save(out_path, garbage=4, deflate=True) +out.close() +doc.close() diff --git a/pdfsign.sh b/pdfsign.sh new file mode 100755 index 0000000..d0f19b2 --- /dev/null +++ b/pdfsign.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +################# +# PREREQUISITES # +################# + +# executables in PATH +for prereq in "python3" "gs" "pdftk"; do + if ! command -v "${prereq}" &> /dev/null; then + echo "required executable '${prereq}' not found!" + exit 1 + fi +done + +# python virtual env with PyMuPDF +VENV_DIR="pdfsign.venv" +if [ -d "${VENV_DIR}" ]; then + # venv exists, activate it + source "${VENV_DIR}/bin/activate" +else + for prereq in "venv"; do + if ! python3 -c "import ${prereq}" &> /dev/null; then + echo "python module '${prereq}' not found!" + exit 1 + fi + done + + # create venv and activate + python3 -m "venv" "${VENV_DIR}" + source "${VENV_DIR}/bin/activate" + # install PyMuPDF + python3 -m "pip" install PyMuPDF +fi + + +############ +# CLI ARGS # +############ + +if [ $# -lt 2 ]; then + echo "Usage: ${0} [PAGE]" + echo + echo "Replaces page PAGE with the signed version." + echo "If PAGE is not given, the last page is replaced." + exit 1 +fi + +IN_TEXT_PDF="${1}" +IN_SCAN_IMG="${2}" + +OUT_PDF="${IN_TEXT_PDF%%.*}_pdfsign.pdf" + +IN_TEXT_PDF_LEN="$( pdftk "${IN_TEXT_PDF}" dump_data | grep NumberOfPages | awk '{print $2}' )" +IN_PAGE="${3:-${IN_TEXT_PDF_LEN}}" + +RANGES="B1" +if [ ${IN_PAGE} -gt 1 ]; then + RANGES="A1-$(( IN_PAGE - 1 )) ${RANGES}" +fi + +if [ ${IN_PAGE} -lt ${IN_TEXT_PDF_LEN} ]; then + RANGES="${RANGES} A$(( IN_PAGE + 1 ))-${IN_TEXT_PDF_LEN}" +fi + + +################ +# MAIN PROGRAM # +################ + +GSFLAGS="-sDEVICE=pdfwrite -dSAFER -dNOPAUSE -dQUIET -dBATCH" + +set -e + +# extract IN_PAGE (text-only) from IN_TEXT_PDF +gs ${GSFLAGS} \ + -dFirstPage="${IN_PAGE}" -dLastPage="${IN_PAGE}" \ + -dFILTERIMAGE -dFILTERVECTOR -dFILTERTEXT=false \ + -sOutputFile=".pdfsign_textonly.pdf" \ + "${IN_TEXT_PDF}" + +# use IN_SCAN_IMG as background, layering text from extracted page on top +python3 ./pdfsign.py \ + ".pdfsign_textonly.pdf" "${IN_SCAN_IMG}" \ + ".pdfsign_signed.pdf" + +rm ".pdfsign_textonly.pdf" + +# replace IN_PAGE in IN_TEXT_PDF layered page +pdftk A="${IN_TEXT_PDF}" B=".pdfsign_signed.pdf" \ + cat ${RANGES} \ + output ".pdfsign_concat.pdf" + +rm ".pdfsign_signed.pdf" + +# postprocess/compress pdf +gs ${GSFLAGS} \ + -sPAPERSIZE=a4 -dFIXEDMEDIA -dPDFFitPage \ + -dCompatibilityLevel=1.7 -dPDFSETTINGS=/ebook \ + -sOutputFile="${OUT_PDF}" \ + ".pdfsign_concat.pdf" + +rm ".pdfsign_concat.pdf"