✨ initial commit
This commit is contained in:
commit
4dc9f46879
4 changed files with 190 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/pdfsign.venv
|
||||||
|
/*.png
|
||||||
|
/*.pdf
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||||
64
pdfsign.py
Normal file
64
pdfsign.py
Normal file
|
|
@ -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()
|
||||||
102
pdfsign.sh
Executable file
102
pdfsign.sh
Executable file
|
|
@ -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} <input.pdf> <signed.png> [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"
|
||||||
Loading…
Reference in a new issue