pax_global_header00006660000000000000000000000064152713362430014520gustar00rootroot0000000000000052 comment=c8a0fa9b319ef40aa793408da9335d28b54487a3 carygravel-scantpaper-c8a0fa9/000077500000000000000000000000001527133624300165125ustar00rootroot00000000000000carygravel-scantpaper-c8a0fa9/.github/000077500000000000000000000000001527133624300200525ustar00rootroot00000000000000carygravel-scantpaper-c8a0fa9/.github/ISSUE_TEMPLATE/000077500000000000000000000000001527133624300222355ustar00rootroot00000000000000carygravel-scantpaper-c8a0fa9/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000046331527133624300251360ustar00rootroot00000000000000name: Bug report description: Report a problem with scantpaper title: "[Bug]: " labels: [bug] body: - type: markdown attributes: value: | Thanks for reporting! A **debug log** is required to diagnose the issue (see below). Issues without a log may be closed as unactionable. - type: textarea id: description attributes: label: What happened? description: What did you do, and what went wrong? validations: required: true - type: textarea id: expected attributes: label: What did you expect to happen? validations: required: true - type: textarea id: repro attributes: label: Steps to reproduce description: Numbered steps, starting from launching the app. placeholder: | 1. 2. 3. validations: required: true - type: markdown attributes: value: "## Environment" - type: dropdown id: install attributes: label: How was scantpaper installed? options: - Debian/Ubuntu package - pip - From git source - Other validations: required: true - type: input id: os attributes: label: Operating system and desktop environment description: e.g. Debian 12, Xfce 4.18 validations: required: true - type: markdown attributes: value: | ## Debug log (required) Generate it like this, then reproduce the bug: ``` scantpaper --log=scantpaper.log ``` (from source: `python3 scantpaper/app.py --log=scantpaper.log`) 1. Run the command above. 2. Reproduce the bug. 3. Quit scantpaper. The log is compressed to `scantpaper.log.xz` on exit. If it wasn't compressed (e.g. after a crash), attach the uncompressed `scantpaper.log` instead. 4. Attach the file to this issue (drag & drop it below once the issue is opened). > Debug logs contain file and scanner device names. Redact anything you > don't want to share before attaching. - type: checkboxes id: log attributes: label: Log file options: - label: I have attached the debug log file (`scantpaper.log.xz`) required: true - type: textarea id: extra attributes: label: Additional context description: Screenshots, error messages, or anything else that helps. carygravel-scantpaper-c8a0fa9/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000004711527133624300242270ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: Translations about: We'd like to organise translations on Launchpad Rosetta — but if that's too much effort for you, attaching a .po file to an issue or merge request is just as welcome. url: https://translations.launchpad.net/scantpaper carygravel-scantpaper-c8a0fa9/.github/ISSUE_TEMPLATE/feature_request.yml000066400000000000000000000022371527133624300261670ustar00rootroot00000000000000name: Feature request description: Suggest a new feature or an improvement to scantpaper title: "[Feature]: " labels: [enhancement] body: - type: markdown attributes: value: | Thanks for taking the time to suggest an improvement! - type: textarea id: problem attributes: label: What is the problem you're trying to solve? description: A clear description of what you want to do that scantpaper can't currently do well. placeholder: | e.g. When I do X, I have to do Y manually because scantpaper can't Z. validations: required: true - type: textarea id: proposal attributes: label: What change would you like to see? description: Describe the feature or improvement you'd like, and roughly how it should behave. validations: required: true - type: textarea id: alternative attributes: label: What alternatives have you considered? description: Any workarounds or alternative approaches you've tried. - type: textarea id: context attributes: label: Additional context description: Screenshots, mockups, or links that help explain the request. carygravel-scantpaper-c8a0fa9/.github/workflows/000077500000000000000000000000001527133624300221075ustar00rootroot00000000000000carygravel-scantpaper-c8a0fa9/.github/workflows/deb.yml000066400000000000000000000117171527133624300233730ustar00rootroot00000000000000name: Build packages on: push: pull_request: workflow_dispatch: inputs: source_ref: description: "Git revision to package (default: github.sha)" required: false type: string permissions: contents: write packages: write jobs: build: runs-on: ubuntu-latest # The packaging needs debhelper-compat 14, and Debian unstable provides # every dependency without a PPA. container: debian:sid-slim steps: # debian:sid-slim has no git or CA certificates, and actions run inside # the container, so install them before checking out. - name: Install build prerequisites run: | apt-get update apt-get install -y --no-install-recommends \ ca-certificates \ git \ python3 \ build-essential \ debhelper \ devscripts \ fakeroot \ equivs - name: Checkout code uses: actions/checkout@v7 with: # The renderer derives the version from the commit count, so it # needs the full history and the tags. fetch-depth: 0 # The job container runs as a different user from the workspace owner, # so git refuses to read the checkout unless it is marked safe. - name: Trust the checkout run: git config --global --add safe.directory "$GITHUB_WORKSPACE" # Every packaging file comes from packaging/debian and is rendered into # a throwaway tree under build/deb. Nothing is authored in this # workflow, and no debian/ directory is created in the checkout. - name: Render the Debian source tree env: SOURCE_REF: ${{ inputs.source_ref || github.sha }} run: | version=$(python3 dev/prepare_deb.py --source-ref "$SOURCE_REF") echo "VERSION=${version}" >> "$GITHUB_ENV" - name: Install build dependencies run: | # mk-build-deps's default tool is apt-get without -y, which aborts # on the confirmation prompt in a non-interactive job. mk-build-deps --install --remove \ -t "apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends -y" \ "build/deb/scantpaper-${VERSION}/debian/control" - name: Build DEB Package run: | cd "build/deb/scantpaper-${VERSION}" # Lintian runs in the checks job, so do not run it here. debuild --no-lintian -us -uc -b dpkg-deb --contents "../scantpaper_${VERSION}_all.deb" - name: Upload packages run: ls -l build/deb - uses: actions/upload-artifact@v7 with: # The .changes and .buildinfo are needed to lint the upload. name: scantpaper_${{ env.VERSION }} path: build/deb/scantpaper_${{ env.VERSION }}_* - name: Release packages uses: softprops/action-gh-release@v3 if: github.ref_type == 'tag' with: files: | build/deb/scantpaper_${{ env.VERSION }}_all.deb debian-checks: needs: build runs-on: ubuntu-latest # A fresh container, so the test dependencies are resolved from the # package's own metadata rather than from the build job's build deps. container: debian:sid-slim steps: - name: Install container prerequisites run: | apt-get update apt-get install -y --no-install-recommends \ ca-certificates \ git \ lintian \ autopkgtest \ passwd \ python3 - name: Checkout code uses: actions/checkout@v7 with: fetch-depth: 0 # The job container runs as a different user from the workspace owner, # so git refuses to read the checkout unless it is marked safe. - name: Trust the checkout run: git config --global --add safe.directory "$GITHUB_WORKSPACE" # Autopkgtest needs a test source, not just the binary: debian/tests is # not installed into the .deb. Render an unbuilt tree for it. - name: Render the source tree env: SOURCE_REF: ${{ inputs.source_ref || github.sha }} run: | version=$(python3 dev/prepare_deb.py --source-ref "$SOURCE_REF") echo "VERSION=${version}" >> "$GITHUB_ENV" - name: Download the built package uses: actions/download-artifact@v7 with: name: scantpaper_${{ env.VERSION }} path: artifacts - name: Run lintian run: lintian --fail-on warning artifacts/*.changes # -B stops autopkgtest from rebuilding the tree and using those binaries # in place of the downloaded .deb. The tests run as a normal user: some # of them simulate write failures with chmod, which root does not see. - name: Run autopkgtest run: | useradd -m tester chown -R tester "build/deb/scantpaper-${VERSION}" autopkgtest --user tester -B artifacts/*.deb \ "./build/deb/scantpaper-${VERSION}" -- null carygravel-scantpaper-c8a0fa9/.github/workflows/fedora.yml000066400000000000000000000221621527133624300240750ustar00rootroot00000000000000name: Build Fedora packages on: push: pull_request: workflow_dispatch: inputs: source_ref: description: "Git revision to package (default: github.sha)" required: false type: string permissions: contents: write jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: fedora: [43, 44] container: image: fedora:${{ matrix.fedora }} steps: - name: Install RPM build tools run: | dnf -y update dnf -y install \ git \ rpm-build \ rpmdevtools \ dnf-plugins-core \ gcc \ gcc-c++ \ make \ pkgconf-pkg-config \ python3-devel \ python3-setuptools \ python3-pip \ python3-wheel \ python3-Cython \ pyproject-rpm-macros - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 # The job container runs as a different user from the workspace owner, # so git refuses to read the checkout unless it is marked safe. - name: Trust the checkout run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Prepare RPM build tree run: | mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} # The dependency packages (cysignals, tesserocr, iso639, python3-sane) # are only rebuilt when their own inputs change, i.e. when the cache # key (a hash of the Fedora version, the spec and any patch files) no # longer exists. On a cache hit the build steps below are skipped and # the packaged RPMs are restored from the cache; on a miss they are # rebuilt and the cache action stores the new entries at the end of # the job. # # Each Fedora release has its own cache namespace (the "fedoraN" # component of the key), since the built RPMs are only valid against # the release they were compiled for. Bump the extra suffix (if any) # when system build dependencies change to force a rebuild. - name: Restore cysignals cache id: cache-cysignals uses: actions/cache@v6 with: path: cache/cysignals key: fedora${{ matrix.fedora }}-cysignals-cache-${{ runner.os }}-${{ hashFiles('packaging/fedora/python3-cysignals.spec') }} - name: Restore tesserocr cache id: cache-tesserocr uses: actions/cache@v6 with: path: cache/tesserocr key: fedora${{ matrix.fedora }}-tesserocr-cache-${{ runner.os }}-${{ hashFiles('packaging/fedora/python3-tesserocr.spec') }} - name: Restore python3-iso639 cache id: cache-iso639 uses: actions/cache@v6 with: path: cache/iso639 key: fedora${{ matrix.fedora }}-iso639-cache-${{ runner.os }}-${{ hashFiles('packaging/fedora/python3-iso639.spec') }} - name: Restore python3-sane cache id: cache-sane uses: actions/cache@v6 with: path: cache/sane key: fedora${{ matrix.fedora }}-sane-cache-${{ runner.os }}-${{ hashFiles('packaging/fedora/python3-sane.spec', 'packaging/fedora/python3-sane-snap-params-cache.patch') }} # Make cached RPMs available for the install steps below, which read # from the conventional ~/rpmbuild/RPMS/ locations. - name: Restore dependency RPMs into build tree run: | for rpmfile in cache/*/*.rpm; do [ -e "$rpmfile" ] || continue arch=$(rpm -qp --qf '%{ARCH}\n' "$rpmfile") mkdir -p ~/rpmbuild/RPMS/"$arch" cp -f "$rpmfile" ~/rpmbuild/RPMS/"$arch"/ done - name: Build cysignals if: steps.cache-cysignals.outputs.cache-hit != 'true' run: | cp packaging/fedora/python3-cysignals.spec \ ~/rpmbuild/SPECS/ spectool -g -R \ ~/rpmbuild/SPECS/python3-cysignals.spec rpmbuild -ba \ ~/rpmbuild/SPECS/python3-cysignals.spec mkdir -p cache/cysignals cp ~/rpmbuild/RPMS/*/python3-cysignals-*.rpm \ cache/cysignals/ - name: Install cysignals run: | dnf -y install \ ~/rpmbuild/RPMS/$(rpm --eval '%{_arch}')/python3-cysignals-*.rpm - name: Install tesserocr build dependencies run: | dnf -y install \ gcc \ gcc-c++ \ make \ pkgconf-pkg-config \ python3-devel \ python3-setuptools \ python3-Cython \ tesseract-devel \ leptonica-devel - name: Verify dependencies run: | rpm -q python3-cysignals rpm -q tesseract-devel rpm -q leptonica-devel - name: Build tesserocr if: steps.cache-tesserocr.outputs.cache-hit != 'true' run: | cp packaging/fedora/python3-tesserocr.spec \ ~/rpmbuild/SPECS/ spectool -g -R \ ~/rpmbuild/SPECS/python3-tesserocr.spec rpmbuild -ba \ ~/rpmbuild/SPECS/python3-tesserocr.spec mkdir -p cache/tesserocr cp ~/rpmbuild/RPMS/*/python3-tesserocr-*.rpm \ cache/tesserocr/ - name: Install tesserocr run: | dnf -y install \ ~/rpmbuild/RPMS/$(rpm --eval '%{_arch}')/python3-tesserocr-*.rpm - name: Build python3-iso639 if: steps.cache-iso639.outputs.cache-hit != 'true' run: | cp packaging/fedora/python3-iso639.spec \ ~/rpmbuild/SPECS/ spectool -g -R \ ~/rpmbuild/SPECS/python3-iso639.spec rpmbuild -ba \ ~/rpmbuild/SPECS/python3-iso639.spec mkdir -p cache/iso639 cp ~/rpmbuild/RPMS/*/python3-iso639-*.rpm \ cache/iso639/ - name: Install python3-iso639 run: | dnf -y install \ ~/rpmbuild/RPMS/noarch/python3-iso639-*.rpm - name: Install python3-sane build dependencies run: | dnf -y install \ sane-backends-devel \ sane-backends-libs - name: Build python3-sane if: steps.cache-sane.outputs.cache-hit != 'true' run: | cp packaging/fedora/python3-sane.spec \ ~/rpmbuild/SPECS/ cp packaging/fedora/python3-sane-snap-params-cache.patch \ ~/rpmbuild/SOURCES/ spectool -g -R \ ~/rpmbuild/SPECS/python3-sane.spec rpmbuild -ba \ ~/rpmbuild/SPECS/python3-sane.spec mkdir -p cache/sane cp ~/rpmbuild/RPMS/*/python3-sane-*.rpm \ cache/sane/ - name: Install python3-sane run: | dnf -y install \ ~/rpmbuild/RPMS/$(rpm --eval '%{_arch}')/python3-sane-*.rpm - name: Install scantpaper build dependencies run: | dnf -y install \ gtk4 \ gobject-introspection-devel \ cairo-devel \ python3-gobject \ python3-cairo \ ImageMagick \ libtiff-tools \ poppler-utils \ ocrmypdf \ pandoc \ gettext \ help2man \ python3-polib \ appstream \ ghostscript \ librsvg2 \ sane-backends - name: Build scantpaper env: SOURCE_REF: ${{ inputs.source_ref || github.sha }} run: | python3 dev/prepare_rpm.py \ --repo-root "$GITHUB_WORKSPACE" \ --template packaging/fedora/scantpaper.spec \ --source-ref "$SOURCE_REF" \ --spec-out ~/rpmbuild/SPECS/scantpaper.spec \ --sources-dir ~/rpmbuild/SOURCES rpmbuild -ba ~/rpmbuild/SPECS/scantpaper.spec # This job never installed the package, so an artifact built from a # stale spec would still build "successfully". On a tag build, install # what was just built and assert the version it reports. - name: Verify the built package matches the release tag if: github.ref_type == 'tag' run: | dnf -y install ~/rpmbuild/RPMS/noarch/scantpaper-*.rpm installed=$(scantpaper --version) echo "$installed" expected="scantpaper ${GITHUB_REF_NAME#v}" if [ "$installed" != "$expected" ]; then echo "::error::installed version '$installed' does not match" \ "tag '$expected'" exit 1 fi - name: List RPMs run: | find ~/rpmbuild/RPMS \ -type f \ -name '*.rpm' \ -print - name: Copy packages run: | mkdir -p artifacts find ~/rpmbuild/RPMS \ -type f \ -name '*.rpm' \ -exec cp {} artifacts/ \; - name: Upload RPMs uses: actions/upload-artifact@v7 with: name: scantpaper-fedora-${{ matrix.fedora }} path: artifacts/*.rpm - name: Release RPMs uses: softprops/action-gh-release@v2 if: github.ref_type == 'tag' with: files: artifacts/*.rpm carygravel-scantpaper-c8a0fa9/.github/workflows/opensuse.yml000066400000000000000000000416721527133624300245050ustar00rootroot00000000000000name: Build openSUSE packages on: push: pull_request: workflow_dispatch: inputs: source_ref: description: "Git revision to package (default: github.sha)" required: false type: string permissions: contents: write jobs: build: runs-on: ubuntu-latest container: image: opensuse/leap:16.0 steps: # Without git, actions/checkout falls back to a REST download with no # .git directory, and the renderer needs full history to count commits. - name: Install git run: zypper -n --gpg-auto-import-keys install git - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 # The job container runs as a different user from the workspace owner, # so git refuses to read the checkout unless it is marked safe. - name: Trust the checkout run: git config --global --add safe.directory "$GITHUB_WORKSPACE" # Everything in the old "Enable non-oss", "Install RPM build tools", # "Define distribution release macro" and "Prepare RPM build tree" # steps now lives in packaging/opensuse/build.sh, which is the single # source of truth for the openSUSE build. - name: Install RPM build tools run: bash packaging/opensuse/build.sh setup # The dependency packages are only rebuilt when their own inputs # change, i.e. when the cache key (a hash of the spec and any patch # files) no longer exists. On a cache hit the corresponding build step # below is skipped and the packaged RPMs are restored; on a miss they # are rebuilt and stored at the end of the job. Bump the extra suffix # (if any) when system build dependencies change to force a rebuild. - name: Restore cysignals cache id: cache-cysignals uses: actions/cache@v6 with: path: cache/cysignals key: opensuse-${{ runner.os }}-cysignals-cache-${{ hashFiles('packaging/opensuse/python313-cysignals.spec') }} - name: Restore setuptools-scm cache id: cache-setuptools-scm uses: actions/cache@v6 with: path: cache/setuptools-scm key: opensuse-${{ runner.os }}-setuptools-scm-cache-${{ hashFiles('packaging/opensuse/python313-setuptools-scm.spec') }} - name: Restore hatch-vcs cache id: cache-hatch-vcs uses: actions/cache@v6 with: path: cache/hatch-vcs key: opensuse-${{ runner.os }}-hatch-vcs-cache-${{ hashFiles('packaging/opensuse/python313-hatch-vcs.spec') }} - name: Restore tesserocr cache id: cache-tesserocr uses: actions/cache@v6 with: path: cache/tesserocr key: opensuse-${{ runner.os }}-tesserocr-cache-${{ hashFiles('packaging/opensuse/python313-tesserocr.spec') }} - name: Restore python313-python-iso639 cache id: cache-iso639 uses: actions/cache@v6 with: path: cache/iso639 key: opensuse-${{ runner.os }}-iso639-cache-${{ hashFiles('packaging/opensuse/python313-python-iso639.spec') }} - name: Restore python313-sane cache id: cache-sane uses: actions/cache@v6 with: path: cache/sane key: opensuse-${{ runner.os }}-sane-cache-${{ hashFiles('packaging/opensuse/python313-sane.spec', 'packaging/opensuse/python3-sane-snap-params-cache.patch') }} - name: Restore pdfminer.six cache id: cache-pdfminer uses: actions/cache@v6 with: path: cache/pdfminer key: opensuse-${{ runner.os }}-pdfminer-cache-${{ hashFiles('packaging/opensuse/python313-pdfminer.six.spec') }} - name: Restore img2pdf cache id: cache-img2pdf uses: actions/cache@v6 with: path: cache/img2pdf key: opensuse-${{ runner.os }}-img2pdf-cache-${{ hashFiles('packaging/opensuse/python313-img2pdf.spec') }} - name: Restore ocrmypdf cache id: cache-ocrmypdf uses: actions/cache@v6 with: path: cache/ocrmypdf key: opensuse-${{ runner.os }}-ocrmypdf-cache-${{ hashFiles('packaging/opensuse/python313-ocrmypdf.spec') }} # Make cached RPMs available for the install steps below, which read # from the conventional ~/rpmbuild/RPMS/ locations. - name: Restore dependency RPMs into build tree run: | for rpmfile in cache/*/*.rpm; do [ -e "$rpmfile" ] || continue arch=$(rpm -qp --qf '%{ARCH}\n' "$rpmfile") mkdir -p ~/rpmbuild/RPMS/"$arch" cp -f "$rpmfile" ~/rpmbuild/RPMS/"$arch"/ done - name: Build cysignals if: steps.cache-cysignals.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build cysignals mkdir -p cache/cysignals cp ~/rpmbuild/RPMS/*/python313-cysignals-*.rpm cache/cysignals/ - name: Install cysignals run: bash packaging/opensuse/build.sh install cysignals - name: Build python313-setuptools-scm if: steps.cache-setuptools-scm.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build setuptools-scm mkdir -p cache/setuptools-scm cp ~/rpmbuild/RPMS/*/python313-setuptools-scm-*.rpm cache/setuptools-scm/ - name: Install python313-setuptools-scm run: bash packaging/opensuse/build.sh install setuptools-scm - name: Build python313-hatch-vcs if: steps.cache-hatch-vcs.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build hatch-vcs mkdir -p cache/hatch-vcs cp ~/rpmbuild/RPMS/*/python313-hatch-vcs-*.rpm cache/hatch-vcs/ - name: Install python313-hatch-vcs run: bash packaging/opensuse/build.sh install hatch-vcs - name: Verify dependencies run: | rpm -q python313-cysignals rpm -q python313-Cython rpm -q python313-setuptools-scm rpm -q python313-hatch-vcs rpm -q tesseract-ocr-devel rpm -q leptonica-devel - name: Build tesserocr if: steps.cache-tesserocr.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build tesserocr mkdir -p cache/tesserocr cp ~/rpmbuild/RPMS/*/python313-tesserocr-*.rpm cache/tesserocr/ - name: Install tesserocr run: bash packaging/opensuse/build.sh install tesserocr - name: Build python313-python-iso639 if: steps.cache-iso639.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build iso639 mkdir -p cache/iso639 cp ~/rpmbuild/RPMS/*/python313-python-iso639-*.rpm cache/iso639/ - name: Install python313-python-iso639 run: bash packaging/opensuse/build.sh install iso639 - name: Build python313-sane if: steps.cache-sane.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build sane mkdir -p cache/sane cp ~/rpmbuild/RPMS/*/python313-sane-*.rpm cache/sane/ - name: Install python313-sane run: bash packaging/opensuse/build.sh install sane - name: Verify PDF toolchain dependencies run: | rpm -q python313-flit-core rpm -q python313-hatchling rpm -q python313-Pillow rpm -q python313-pikepdf rpm -q python313-cryptography rpm -q python313-charset-normalizer - name: Build python313-pdfminer.six if: steps.cache-pdfminer.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build pdfminer mkdir -p cache/pdfminer cp ~/rpmbuild/RPMS/*/python313-pdfminer.six-*.rpm cache/pdfminer/ - name: Install python313-pdfminer.six run: bash packaging/opensuse/build.sh install pdfminer - name: Build python313-img2pdf if: steps.cache-img2pdf.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build img2pdf mkdir -p cache/img2pdf cp ~/rpmbuild/RPMS/*/python313-img2pdf-*.rpm cache/img2pdf/ - name: Install python313-img2pdf run: bash packaging/opensuse/build.sh install img2pdf - name: Build python313-ocrmypdf if: steps.cache-ocrmypdf.outputs.cache-hit != 'true' run: | bash packaging/opensuse/build.sh build ocrmypdf mkdir -p cache/ocrmypdf cp ~/rpmbuild/RPMS/*/python313-ocrmypdf-*.rpm cache/ocrmypdf/ - name: Install python313-ocrmypdf run: bash packaging/opensuse/build.sh install ocrmypdf - name: Verify OCR toolchain installation run: | python3 -c 'import ocrmypdf, img2pdf; print(ocrmypdf.__version__)' rpm -q python313-ocrmypdf python313-img2pdf python313-pdfminer.six rpm -q ghostscript tesseract-ocr qpdf poppler-tools rsvg-convert - name: Build scantpaper env: SOURCE_REF: ${{ inputs.source_ref || github.sha }} run: bash packaging/opensuse/build.sh build scantpaper - name: Install scantpaper run: bash packaging/opensuse/build.sh install scantpaper - name: Verify scantpaper installation run: | rpm -q scantpaper python313-sane python313-tesserocr python313-python-iso639 python3 -c 'import sane, tesserocr, iso639; print("imports ok")' - name: List RPMs run: | find ~/rpmbuild/RPMS \ -type f \ -name '*.rpm' \ -print # Only the runtime packages are published; the build-only helpers # (python313-hatch-vcs, python313-setuptools-scm) and the -debuginfo / # -debugsource packages are not useful on a target system. - name: Copy packages run: bash packaging/opensuse/build.sh collect-runtime artifacts # A python3dist() require is never satisfiable on Leap 16: nothing in # the distro provides those names, and the third-party OBS Python # repositories do not either. The specs declare their dependencies by # concrete python313-* name, so this asserts the built artifacts agree - # the defect this guards against was invisible at review time and only # surfaced on a user's machine. - name: Assert no python3dist() requires run: | rc=0 for rpmfile in artifacts/*.rpm; do requires=$(rpm -qp --requires "$rpmfile") if printf '%s\n' "$requires" | grep -q 'python3dist('; then echo "::error::$rpmfile carries a python3dist() require" printf '%s\n' "$requires" | grep 'python3dist(' rc=1 fi done exit "$rc" # No spec may hand-write python3dist() metadata, and no spec may try to # suppress the pythondist dependency generator. Leap 16's # fileattrs/pythondist.attr matches a flat .dist-info *file*, but a # PEP 427 wheel unpacks to a -.dist-info/ *directory*, so # the generator never fires for these pip-built specs: the built RPMs # carry neither python3dist() requires nor provides, with or without a # suppression macro. A macro here would look load-bearing while doing # nothing, so it is banned rather than pinned. - name: Assert no hand-written python3dist() metadata run: | rc=0 if grep -nE '^[[:space:]]*(Requires|Provides|BuildRequires):.*python3dist\(' \ packaging/opensuse/*.spec; then echo "::error::a spec hand-writes python3dist() metadata; declare" \ "dependencies by concrete python313-* package name instead" rc=1 fi if grep -rn '__pythondist_requires\|disable_python_dependency_generator' \ packaging/opensuse/*.spec; then echo "::error::a spec suppresses the pythondist generator, which" \ "never runs for pip-built wheels; remove the macro" rc=1 fi exit "$rc" - name: Upload RPMs uses: actions/upload-artifact@v7 with: name: scantpaper-opensuse-leap16 path: artifacts/*.rpm - name: Release RPMs uses: softprops/action-gh-release@v2 if: github.ref_type == 'tag' with: files: artifacts/*.rpm # Install the published artifact set on a container that has never seen a # ScantPaper build, so the check exercises the RPMs a user downloads rather # than the build tree they were produced from. Two scenarios: # # stock - default repositories only, nothing pre-installed. # seeded - repo-oss' python313-sane installed first, so the set has to # displace a package the target already owns and then survive # the next update. # # The seed comes from repo-oss only. Enabling devel:languages:python would # make this job depend on a third-party repository with its own update # cadence. clean-room-install: name: clean-room install (${{ matrix.scenario }}) needs: build runs-on: ubuntu-latest container: image: opensuse/leap:16.0 strategy: fail-fast: false matrix: scenario: - stock - seeded steps: - name: Checkout uses: actions/checkout@v7 - name: Download published RPMs uses: actions/download-artifact@v7 with: name: scantpaper-opensuse-leap16 path: artifacts # Import the distribution's repository keys. The stock scenario # deliberately enables no repository beyond the image defaults. - name: Refresh repositories run: zypper -n --gpg-auto-import-keys ref - name: Seed repo-oss python313-sane if: matrix.scenario == 'seeded' run: | zypper -n --gpg-auto-import-keys install python313-sane rpm -q python313-sane - name: Install the published RPM set run: | # GitHub runs this with `bash -e`, so a bare failing command would # abort the step before the log and the conflict check below could # report anything. Capture the status in an `if` instead. if zypper -n install --allow-unsigned-rpm artifacts/*.rpm \ > /tmp/install.log 2>&1; then rc=0 else rc=$? fi cat /tmp/install.log # zypper's exit status alone does not name the offending files, so # report them explicitly. Match only a real conflict: on every run, # success included, zypper prints the progress line # "Checking for file conflicts: [.......done]", so grepping for # "file conflicts" alone would fail every green build. A real one # reads "Detected N file conflict:" / "conflicts with file from # package". if grep -qE 'Detected [0-9]+ file conflict|conflicts with file from package' \ /tmp/install.log; then echo "::error::zypper reported file conflicts" grep -iE -B2 -A8 'Detected [0-9]+ file conflict|conflicts with file from package' \ /tmp/install.log rc=1 fi exit "$rc" - name: Verify installed set run: | rpm -q scantpaper \ python313-cysignals python313-tesserocr \ python313-python-iso639 python313-sane \ python313-pdfminer.six python313-img2pdf python313-ocrmypdf # The build container could never answer this: it imports from the # build tree, so a broken install would still look fine. Here the only # source of these modules is the installed artifact set. - name: Verify the installed application runs run: | scantpaper --version python3 -c 'import sane, tesserocr, iso639, img2pdf, ocrmypdf; print("imports ok")' # The step above would also pass for an artifact built from a stale # spec: it reports whatever version was compiled into it. On a tag # build, assert that version is the tag the artifacts are attached to. - name: Verify the installed version matches the release tag if: github.ref_type == 'tag' run: | installed=$(scantpaper --version) echo "$installed" expected="scantpaper ${GITHUB_REF_NAME#v}" if [ "$installed" != "$expected" ]; then echo "::error::installed version '$installed' does not match" \ "tag '$expected'" exit 1 fi # An install that succeeds and is then reverted by the next update is # the failure mode the ".0" version suffix exists to prevent, so a # green run that only checks the install's exit status would miss it. - name: Survive a subsequent update if: matrix.scenario == 'seeded' run: | before=$(rpm -q python313-sane) echo "after install: $before" echo "$before" | grep -q '^python313-sane-2\.9\.2\.0-' zypper -n --gpg-auto-import-keys update after=$(rpm -q python313-sane) echo "after update: $after" echo "$after" | grep -q '^python313-sane-2\.9\.2\.0-' carygravel-scantpaper-c8a0fa9/.github/workflows/test.yml000066400000000000000000000161061527133624300236150ustar00rootroot00000000000000name: Test package on: [push] jobs: check-launchpad: runs-on: ubuntu-latest outputs: launchpad_accessible: ${{ steps.check.outputs.accessible }} steps: - name: Check if launchpad.net is accessible id: check run: | # Try to access launchpad.net (timeout after 5 seconds) if curl -s --max-time 5 https://launchpad.net > /dev/null; then echo "accessible=true" >> $GITHUB_OUTPUT else echo "accessible=false" >> $GITHUB_OUTPUT fi latest_w_ppa: runs-on: ubuntu-latest needs: check-launchpad if: needs.check-launchpad.outputs.launchpad_accessible == 'true' steps: - uses: actions/checkout@v7 - name: Install dependencies run: | sudo add-apt-repository ppa:jeffreyratcliffe/ppa sudo apt-get update # Runtime dependencies such as ocrmypdf, pikepdf, qpdf and polib # come from apt/PPA, matching what users of the PPA get. Only the # test runner is installed from pip rather than from Debian # packages, because the system python3-coverage (7.4.4 on noble) # predates the coverage.py releases that aligned statement and # branch accounting with newer Python: 7.5.2 stopped counting # module docstrings as executable statements, 7.6.2 stopped # reporting lambdas/comprehensions that do not complete as missing # branches, and 7.7.0 stopped counting constant if tests as # branches. Measured with the system packages, the coverage gate # below reports ~1% less than the identical test run measured # with a modern coverage.py. Switch back to the system packages # once the base image ships python3-coverage >= 7.7.0 (Resolute/ # 26.04 ships 7.13.5). sudo apt-get install -y appstream djvulibre-bin fonts-dejavu-core \ gettext \ gir1.2-gtk-4.0 gobject-introspection imagemagick img2pdf \ jbig2 libtiff-tools ocrmypdf poppler-utils python3-gi python3-gi-cairo \ python3-iso639 python3-polib python3-sane python3-tesserocr \ python3-tomli qpdf tesseract-ocr-eng tesseract-ocr-deu unpaper \ xauth xvfb python3 -m pip install --upgrade pip pip install pytest pytest-cov pytest-mock pytest-timeout pytest-xvfb \ ruff==0.16.7 ty==0.0.85 # add locale, needed for a couple of tests sudo locale-gen de_DE.UTF-8 - name: Display Python version run: python3 -c "import sys; print(sys.version)" - name: Ruff check run: ruff check . - name: Ruff format check run: ruff format --check . # No ty check here: this job validates the PPA's system runtime packages, # where the older system python3-pil (10.2.0 on noble) has no type hints. # Type checking is already covered by the `latest` and `oldest` jobs using # the modern pip Pillow. # TODO: re-enable the ty check step once this job's base image ships # python3-pil >= 11.0.0 (the first release with the `rotate` return # annotation; resolute/26.04 ships 12.1.1). ubuntu-latest is switching to # resolute soon, which will satisfy this. - name: Test run: pytest latest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y appstream djvulibre-bin fonts-dejavu-core \ gettext \ gobject-introspection gir1.2-gtk-4.0 imagemagick jbig2 \ libtiff-tools poppler-utils python3-gi python3-gi-cairo python3-sane \ python3-tesserocr qpdf tesseract-ocr-eng tesseract-ocr-deu unpaper python3 -m pip install --upgrade pip pip install --upgrade pip setuptools wheel packaging build pip install .[test] pip install ruff==0.16.7 ty==0.0.85 # add locale, needed for a couple of tests sudo locale-gen de_DE.UTF-8 - uses: actions/setup-node@v7 with: node-version: 22 - name: Install OpenSpec run: | # Pinned so a new upstream rule cannot turn CI red without a # deliberate upgrade; bump together with the local CLI. npm install -g @fission-ai/openspec@1.14.3 - name: Validate OpenSpec specs run: openspec validate --specs --strict --no-interactive - name: Display Python version run: python3 -c "import sys; print(sys.version)" - name: Ruff check run: ruff check . - name: Ruff format check run: ruff format --check . - name: ty check # The runner's non-root `pip install` puts packages in the user site # (~/.local), which ty does not scan by default. Add it to the module # search path so ty can resolve the pip-installed runtime/test deps. run: ty check --extra-search-path "$(python3 -m site --user-site)" . - name: Test run: pytest oldest: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v7 - name: Define variables run: | ( echo "GITHUB_REPO_NAME=$(echo "${GITHUB_REPOSITORY}" | cut -d/ -f2)" echo "VERSION=$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' pyproject.toml)" ) >> $GITHUB_ENV - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y appstream djvulibre-bin fonts-dejavu-core \ gettext \ gir1.2-gtk-4.0 gobject-introspection imagemagick libtiff-tools \ poppler-utils python3-gi python3-gi-cairo python3-sane \ python3-tesserocr qpdf tesseract-ocr-eng tesseract-ocr-deu unpaper python -m pip install --upgrade pip pip install --upgrade pip setuptools wheel packaging build pip install .[test] pip install ruff==0.16.5 ty==0.0.85 # add locale, needed for a couple of tests sudo locale-gen de_DE.UTF-8 - name: Display Python version run: python -c "import sys; print(sys.version)" - name: Ruff check run: ruff check . - name: Ruff format check run: ruff format --check . - name: ty check # The runner's non-root `pip install` puts packages in the user site # (~/.local), which ty does not scan by default. Add it to the module # search path so ty can resolve the pip-installed runtime/test deps. run: ty check --extra-search-path "$(python3 -m site --user-site)" . - name: Build wheel run: python3 -m build --wheel - uses: actions/upload-artifact@v7 with: name: ${{ env.GITHUB_REPO_NAME }}-${{ env.VERSION }}-py3-none-any.whl path: dist/${{ env.GITHUB_REPO_NAME }}-${{ env.VERSION }}-py3-none-any.whl - name: Release packages uses: softprops/action-gh-release@v3 if: github.ref_type == 'tag' with: files: dist/${{ env.GITHUB_REPO_NAME }}-${{ env.VERSION }}-py3-none-any.whl - name: Test run: pytest carygravel-scantpaper-c8a0fa9/.github/workflows/translations-check.yml000066400000000000000000000067251527133624300264400ustar00rootroot00000000000000name: Check Launchpad translations on: schedule: # Daily at 07:00 UTC. Like all scheduled workflows this is best-effort on # GitHub: it can be delayed, and is disabled after long repository # inactivity. It is a release reminder, not a gate. - cron: '0 7 * * *' workflow_dispatch: permissions: contents: read issues: write jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # The tool only needs typing_extensions (for the @override decorator); # the committed baseline at po/launchpad-state.json comes with the # checkout. gettext (xgettext/msgcat) is installed below and is used to # regenerate the local template on demand; the generator imports # scantpaper.const, so src must be on PYTHONPATH. - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y gettext pip install typing_extensions - name: Check for upstream and local translation changes id: check env: PYTHONPATH: src run: | python3 dev/check_launchpad.py --json | tee report.json changed=$(python3 -c "import json; print(json.load(open('report.json'))['changed'])") local_pot_changed=$(python3 -c "import json; print(json.load(open('report.json'))['local_pot_changed'])") echo "changed=$changed" >> "$GITHUB_OUTPUT" echo "local_pot_changed=$local_pot_changed" >> "$GITHUB_OUTPUT" # A detected change exits successfully and the tool's JSON report is # filed as one living reminder. A run that "could not determine" the # state exits non-zero, so the step (and job) fails loudly instead of # silently reporting "unchanged"; no issue is filed for that. - name: Report upstream changes in a single issue if: ${{ steps.check.outputs.changed == 'True' }} env: GH_TOKEN: ${{ github.token }} run: | label="launchpad-translations" gh label create "$label" \ --description "Launchpad translation changes were detected" --force body=$(cat report.json) number=$(gh issue list --label "$label" --state open --json number \ --jq '.[0].number // empty') if [ -n "$number" ]; then gh issue edit "$number" \ --title "Launchpad translations: upstream changes detected" \ --body "$body" else gh issue create \ --title "Launchpad translations: upstream changes detected" \ --body "$body" --label "$label" fi - name: Report a stale local pot in a separate issue if: ${{ steps.check.outputs.local_pot_changed == 'True' }} env: GH_TOKEN: ${{ github.token }} run: | label="launchpad-pot-stale" gh label create "$label" \ --description "New source strings are ready to upload to Launchpad" \ --force body=$(cat report.json) number=$(gh issue list --label "$label" --state open --json number \ --jq '.[0].number // empty') if [ -n "$number" ]; then gh issue edit "$number" \ --title "Launchpad pot is out of date: new strings ready to upload" \ --body "$body" else gh issue create \ --title "Launchpad pot is out of date: new strings ready to upload" \ --body "$body" --label "$label" fi carygravel-scantpaper-c8a0fa9/.gitignore000066400000000000000000000002561527133624300205050ustar00rootroot00000000000000__pycache__ .coverage .pytest_cache env/ venv/ .eggs/ node_modules/ dist/ .env .tox/ build/ # Build artifact regenerated by dev/generate_pot.py po/scantpaper/scantpaper.pot carygravel-scantpaper-c8a0fa9/.pre-commit-config.yaml000066400000000000000000000002031527133624300227660ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.6 hooks: - id: ruff - id: ruff-format carygravel-scantpaper-c8a0fa9/.zed/000077500000000000000000000000001527133624300173525ustar00rootroot00000000000000carygravel-scantpaper-c8a0fa9/.zed/settings.json000066400000000000000000000012761527133624300221130ustar00rootroot00000000000000{ "languages": { "Python": { "language_servers": [ // This project standardizes on ty (type checking) and ruff (linting), // both configured in pyproject.toml. basedpyright, pyright and pylsp // are disabled because they duplicate ty/ruff: pyright is a second // full type-checker whose gi.repository access errors are GTK-untyped // noise (and it mis-resolves the stdlib here), and pylsp re-runs // pyflakes/pycodestyle/mypy/pylint over the same code. "..." keeps any // other registered Python servers active (e.g. ruff). "ty", "!basedpyright", "!pyright", "!pylsp", "..." ] } } }carygravel-scantpaper-c8a0fa9/AGENTS.md000066400000000000000000000277421527133624300200310ustar00rootroot00000000000000# Collaboration Guide This document provides essential context for anyone working on this project. Adhering to these guidelines will ensure consistency and maintain code quality. ## 1. Project Overview & Purpose - **Primary Goal:** To provide a graphical user interface (GUI) for producing PDFs or DjVus from scanned documents. The application allows for scanning, editing (cropping, rotating), and saving documents with OCR and metadata support. - **Business Domain:** Document Management and Scanning. This is a utility application for end-users. ## 2. Core Technologies & Stack - **Languages:** Python 3. - **Frameworks & Runtimes:** - GUI: PyGObject with GTK4. - Runtime: Python 3. - **Databases:** SQLite is used to store all session data, which was a major architectural change in the rewrite from Perl to Python. - **Key Libraries/Dependencies:** - `ocrmypdf`: Core dependency for creating OCR'd PDFs. - `img2pdf`: Used for PDF conversion. - `python-sane`: For interfacing with SANE-compatible scanners. - `PyGObject`: Python bindings for GTK4. - `pycairo`: For 2D graphics. - `tesserocr`: OCR. - **Package Manager(s):** `pip` is used for Python dependencies, managed via `pyproject.toml`. System-level dependencies are managed by the OS package manager (e.g., `apt`). ## 3. Architectural Patterns - **Overall Architecture:** Monolithic Desktop Application. It is a standalone GUI application, not a client-server or web application. - **Directory Structure Philosophy:** - `/src/scantpaper`: Contains the primary Python source code for the application. - `/src/scantpaper/dialog`: Contains UI dialog components. - `/dev`: Contains development-related scripts (e.g., `generate_pot.py` for translations). This folder is not a Python package. - `/src/scantpaper/tests`: Contains all unit and integration tests, managed by pytest. ## 4. Coding Conventions & Style Guide @MEMORY.md - **Markdown line length:** Wrap long lines in Markdown files (`.md`, including README.md and the OpenSpec artifacts) at 80 characters. There must be a very good reason for having any line longer than 512 characters. - **Formatting:** The project uses `ruff` for automated code formatting and linting. - **Type checking:** The project uses `ty` (the type checker Zed uses for Python) for static type checking. Its rule blacklist lives in `[tool.ty.rules]` in `pyproject.toml`; keep `ty check .` clean in the same manner as `ruff`. - **Naming Conventions:** - `classes`: `PascalCase` (e.g., `Application`, `ApplicationWindow`). - `variables`, `functions`: `snake_case` (e.g., `register_icon`, `parse_arguments`). - This follows standard PEP 8 conventions. - **Constants:** Define any constant that is used in more than one place (across modules, or across code and tests) in `src/scantpaper/const.py` and import it from there, rather than duplicating the literal. This keeps a single source of truth for shared values (e.g. `MM_PER_INCH`, `POINTS_PER_INCH`, `A4_WIDTH_MM`). File-local values that are used in only one place may stay as a local module constant. - **API Design:** Not applicable, as this is a desktop GUI application, not a web service. - **Error Handling:** Standard `try...except` blocks are used for error handling. The application also features a robust logging system configured via command-line arguments, which is crucial for diagnostics. ## 5. Key Files & Entrypoints - **Main Entrypoint(s):** `src/scantpaper/app.py` is the main entrypoint for the application. From a source checkout it is run as `PYTHONPATH=src python3 -m scantpaper.app`. - **Configuration:** - User configuration is stored in `~/.config/scantpaperrc`. - Project configuration for development tools includes: `pyproject.toml`. - **CI/CD Pipeline:** A `.github/workflows` directory exists for GitHub Actions. ## 6. Development & Testing Workflow - **Local Development Environment:** Set up a Python virtual environment and install dependencies using `pip install .`. From a source checkout the application is run with `PYTHONPATH=src python3 -m scantpaper.app`. - **Testing:** Tests are written using the `pytest` framework. They can be executed by running the `pytest` command in the root directory; tests run directly against the source tree via `pythonpath = ["src"]`. The `pyproject.toml` file configures test runs to include coverage reports and to fail if coverage drops below a certain threshold. - **Translations:** Translation strings are marked with `_()` in the source. The workflow for handling translations is: 1. Regenerate the translation template with `PYTHONPATH=src python3 dev/generate_pot.py`, which creates the `.pot` file. 2. Missing strings in `po/scantpaper/*.po` may be translated locally, but every such addition MUST be marked `#, fuzzy` (needs review). Never add a translation that is not marked fuzzy. The point of the marker is to hold every new translation behind review until a translator confirms it, not to block a release: a fuzzy translation is never shown to the user, because it falls back to the English msgid (see the release gate below), so an unreviewed translation cannot reach users by accident. 3. A `#, fuzzy` flag that another author or tool added MAY be cleared (removed) as part of a translation review, but only when the maintainer explicitly directs it. Do not clear a fuzzy flag on your own initiative, and do not alter the translated text as part of clearing unless specifically asked. This is the only sanctioned way to ship a previously-fuzzy string without a Rosetta pass; it stays gated on human review rather than an agent's judgement. 4. Upload the seeded `.po` files (not just the `.pot`) to Rosetta (Launchpad) so translators can confirm and clear the fuzzy entries. 5. Check for upstream changes before a release with `python3 dev/check_launchpad.py`. It compares the template and every language's last-changed time against the committed baseline `po/launchpad-state.json`, and the scheduled translations-check workflow files one labelled issue when anything moved. It also reports the reverse direction — whether the local template is stale relative to the last pot uploaded to Launchpad — by comparing the generated message set against the `uploaded_pot` fingerprint in the baseline, and the workflow files a separate issue when new strings are ready to upload. It never downloads a catalog (Launchpad requires a login to export one) and is advisory: only a state the check could not read — never a detected change — exits non-zero, so "no changes" is a truthful silence. After the download below, advance the baseline with `python3 dev/check_launchpad.py --update`. After a new template has been uploaded to Rosetta, record its message set with `python3 dev/check_launchpad.py --record-pot`, so the next check no longer reports the pot as stale. 6. Download the translated `.po` files from Rosetta before a release. 7. Ensure every `po/scantpaper/*.po` passes the deterministic catalog checks: run `PYTHONPATH=src python3 dev/check_po.py` (CI enforces the same via `test_po_files.py`), which runs `msgfmt --check` (format-specifier drift, escaping, plural-entry counts) and validates each catalog's `Plural-Forms` header against CLDR. Never disable a check to make a catalog pass. Source conventions: counted strings must use `ngettext`, never `_()`; new-language catalogs must declare a CLDR-correct `Plural-Forms` header. `dev/check_po.py` also reports source-side *advisories* for strings that are hard to translate, which is where translation-authoring guidance comes from. The conventions to follow so the warnings stay at zero: - Never build a translatable sentence out of parts. A string ending in a colon or an ellipsis with no value of its own is a concatenation fragment: it fixes English word order into the translation. Pass the value in the same string with a named field, e.g. `_("Error opening device: %(status)s") % {"status": status}`. The same applies to a translated sentence joined to a non-translated list. - Never hand-write a plural as `file(s)` or `file(es)`. Use `ngettext`; a parenthesised unit or abbreviation such as `(Mb)` is fine. - Do not let two msgids differ only by case, such as `_Ok` and `_OK`. Use `_OK` for a mnemonic on a button, matching the label's own capitalisation. - Match the case of the surrounding text for unit labels: a `ppi` next to a spin button is lower case, an `OK` in a dialog is upper. - Keep msgids short; over 200 characters is flagged as a translation effort risk. Release gate: only non-fuzzy translations ship. Release builds never pass `--use-fuzzy` to `msgfmt`, so a fuzzy translation is excluded from the release and the English msgid is shown instead; a fuzzy string is therefore never visible to the user. A release is not blocked by the presence of fuzzy entries. - **CI/CD Process:** The `.github/workflows/test.yml` file runs the test suite. ## 7. Contribution Guidelines - Follow TDD, DRY & YAGNI - Before finishing a task ensure: - Any changes visible to the user are documented in README.md - *all* tests pass (`pytest`) - the code is formatted with `ruff`. - the numbers of uncovered and partially covered lines are the same or better. The only acceptable newly-uncovered lines are the type-only imports inside `if TYPE_CHECKING:` blocks, which by design never execute; - no ruff linting errors. Do not disable checks with `noqa=` or `per-file-ignores` without first obtaining explicit agreement from the maintainer. The only standing exceptions are `gi.repository` imports following `gi.require_version()` and the pre-existing test-suite suppressions listed in `pyproject.toml`. - no `ty` type-check diagnostics (`ty check .` reports none). As with ruff, do not add rules to the `[tool.ty.rules]` blacklist without explicit agreement from the maintainer. - **Security:** Be mindful of security. Do not hardcode secrets or keys. - **Dependencies:** To add a new Python dependency, add it to `pyproject.toml`. Ensure it is available on common distributions or via `pip`. - **OpenSpec:** The `openspec` CLI manages change workflows. It is installed globally via npm: `npm install -g @fission-ai/openspec@latest`. - **imports:** The project uses package-absolute imports for first-party modules: import statements always carry a leading `scantpaper` prefix, e.g. `from scantpaper.const import VERSION`. ## 8. Spec-Driven Development (SDD) Non-trivial changes **must** follow a spec-first workflow via OpenSpec before any implementation begins. - **What counts as non-trivial** (any one of these qualifies): - Adds or changes user-visible behaviour (UI, CLI options, output files). - Changes architecture, data model, or inter-module contracts. - Touches more than one module, or more than ~50 lines. - Adds/removes a dependency. - Fixes a bug whose root cause is not immediately obvious from the report. - **What is trivial** (may proceed directly): typo fixes, comment/log message updates, formatting, and small self-contained fixes with an obvious cause and a test. - **Workflow:** create a change proposal first (`openspec new change`, or the `/opsx-propose` skill), get it approved, then implement against its tasks (`/opsx-apply-change`). Do not start coding with only a verbal description. - Exploration and thinking are encouraged at any time (e.g. `/opsx-explore`), but insights must be captured into proposal/spec/design artifacts before they turn into code. carygravel-scantpaper-c8a0fa9/CONTRIBUTING.md000066400000000000000000000206041527133624300207450ustar00rootroot00000000000000# Contribution Guidelines for Scantpaper This document provides guidelines for contributing to the scantpaper project. By following these guidelines, you can help ensure that your contributions are consistent with the project's standards and easily integrated into the codebase. ## Project Overview Scantpaper is a graphical user interface (GUI) application for scanning, processing, and managing documents. It allows users to create PDF or DjVu files from scanned images, with features such as batch scanning, optical character recognition (OCR), and image editing. The project is written in Python and uses the GTK+ toolkit for its user interface. ## Architecture * **Overall Architecture:** Monolithic Desktop Application. It is a standalone GUI application, not a client-server or web application. * **Directory Structure:** * `src/scantpaper/`: Contains the primary Python source code for the application. * `src/scantpaper/dialog/`: Contains UI dialog components. * `src/scantpaper/scanner/`: Contains SANE scanner interface code. * `src/scantpaper/frontend/`: Contains the scanner frontend implementations (e.g. SANE). * `src/scantpaper/tests/`: Contains all unit and integration tests, managed by pytest. * `dev/`: Contains development-related scripts (e.g., `generate_pot.py` for translations). This folder is not a Python package. * **Main Entrypoint:** `src/scantpaper/app.py`, invoked as `PYTHONPATH=src python3 -m scantpaper.app` from a source checkout. When installed, the package provides a `gui_scripts` entrypoint via `pyproject.toml`. * **Imports:** first-party modules are imported with a `scantpaper.` absolute prefix (e.g. `from scantpaper.const import VERSION`). * **Key Dependencies:** `ocrmypdf`, `img2pdf`, `pikepdf`, `python-sane`, `PyGObject`, `pycairo`, `tesserocr`, `python-iso639`. All listed in `pyproject.toml`. ## Getting Started Before you begin, make sure you have a local clone of the scantpaper repository and have installed the necessary dependencies. ### Prerequisites - Python 3 - The python dependencies listed in `pyproject.toml` and system-level dependencies given in the `README.md` file. ### Installation 1. **Clone the repository:** ```bash git clone https://github.com/carygravel/scantpaper.git cd scantpaper ``` 2. **Install dependencies:** It is recommended to use a virtual environment to manage dependencies: ```bash python3 -m venv .venv source .venv/bin/activate pip install -e . ``` You will also need to install the system-level dependencies mentioned in the `README.md` file. ## How to Contribute When contributing to scantpaper, please follow these steps: 1. **Discuss your changes:** Before you start working on a new feature or bug fix, it's a good idea to discuss it with the project maintainers. You can do this by creating an issue on the GitHub repository. 2. **Create a new branch:** For each new feature or bug fix, create a new branch in your local repository: ```bash git checkout -b my-new-feature ``` 3. **Make your changes:** Write your code, following the project's coding style and conventions. 4. **Write or update tests:** If you are adding a new feature, please include tests that cover the new functionality. If you are fixing a bug, consider adding a test that reproduces the bug and verifies that your fix resolves it. 5. **Run the tests:** Before submitting your changes, make sure that all tests pass: ```bash pytest ``` 6. **Commit your changes:** Write a clear and concise commit message that explains the purpose of your changes. 7. **Push your changes:** Push your changes to your forked repository. 8. **Create a pull request:** Open a pull request on the scantpaper GitHub repository. ## Coding Style Please run `ruff format` over new or changed code to automatically format it, and `ruff check` to lint it. Both are configured in `pyproject.toml` and are enforced in CI (`ruff format --check .` and `ruff check .`). Please also run `ty check .` (the project's type checker) and keep it clean, matching the ruff discipline. Its rule blacklist is configured in `[tool.ty.rules]` in `pyproject.toml`. `ty check .` runs in CI alongside the ruff checks, so new or changed code should not add `ty` diagnostics. Scantpaper follows the PEP 8 style guide for Python code. Please ensure that your code adheres to these guidelines. Ruff is used to check your code; the configuration lives in `pyproject.toml`. ### Lint Suppression **Ask before adding any suppression.** Do not add `noqa` comments on your own initiative. If a linter warning cannot be resolved by correcting the code, stop and discuss the case with a maintainer first, and only add the suppression after it has been explicitly approved. Approvals may be given per-case or once for a clearly-scoped class of cases. Whenever a suppression is approved and added, it must carry an explanation that states why the warning is being suppressed. Either: - put the explanation inline after an em dash (`# noqa: RULE — reason`), or - put a comment line immediately above the offending code explaining the reason. Keep suppressions intentional, documented, and limited to cases where they are truly necessary. Do not add rules to the `ignore` list in `pyproject.toml` without prior agreement from a maintainer. Each suppression should be discussed and justified in the pull request that introduces it. ### Line Length Wrap markdown documentation at 80 characters per line. ### Type Annotations Do not use `typing.Any` (or bare `Any`) as a type annotation. `Any` disables type checking entirely and hides real bugs, so prefer the most specific type that is accurate. Where a value genuinely has several possible types, use a `Union` (e.g. `int | None`, `list | tuple | None`) of the concrete types; for a truly polymorphic payload use `object` rather than `Any`, so consumers are forced to narrow it explicitly. The classifier rules `ANN001`-`ANN003` and `ANN201`/`ANN202` are enforced, so every module is fully annotated. The codebase adopts the following conventions: - Add `from __future__ import annotations` at the top of every module, so annotations are evaluated lazily and forward references need no quoting. - Type-only imports (types referenced only in annotations) live in an `if TYPE_CHECKING:` block at the top of the module instead of being imported at runtime; import `TYPE_CHECKING` from `typing`. `ruff` (`TC003`) enforces this. - GTK signal handlers are annotated with the concrete `gi.repository` types (e.g. `Gtk.Widget`, `Gdk.Rectangle`, `Gdk.Event`) where the parameter corresponds to an emitted widget or payload; fall back to `object` only when the value is genuinely duck-typed. Handlers return `-> None`. - Because annotations are lazy, a `@GObject.Property` getter/setter must keep an explicit `type=` argument on the `@GObject.Property` decorator; it cannot rely on the (stringised) return annotation. Methods with `@GObject.Property` must annotate the getter with `-> ` and the setter with both the parameter and `-> None`. ## Commit messages Please ensure that all commits have meaningful messages: 1. Start with at least one [git emoji](see https://gitmoji.dev) 2. Include at least one sentence describing the change ## Testing Scantpaper uses `pytest` for testing. The tests are located in the `src/scantpaper/tests/` directory. To run the tests, use the following command: ```bash pytest ``` This will run all tests and generate a coverage report. Please ensure that your changes do not increase the number of uncovered or partially-covered lines. The only acceptable newly-uncovered lines are the type-only imports inside `if TYPE_CHECKING:` blocks, which by design never execute. ## Documentation If you are adding a new feature, please update the documentation to reflect the changes. The documentation is located in the `README.md` file. ## Submitting a Pull Request When you are ready to submit your changes, please create a pull request on the scantpaper GitHub repository. In your pull request, please include the following information: - A clear and concise title and description of your changes. - A reference to the issue that your pull request addresses (if applicable). - A summary of the changes you have made. - Any additional information that may be helpful for the reviewers. Thank you for contributing to scantpaper! carygravel-scantpaper-c8a0fa9/README.md000066400000000000000000001357151527133624300200050ustar00rootroot00000000000000# Scantpaper A GUI to produce PDFs or DjVus from scanned documents. Scantpaper is a Linux application (it needs GTK4, SANE, and a Python 3 interpreter, all of which are available on other Unix-like systems like MacOS or BSD as well). It is the Python rewrite (v3) of the popular [gscan2pdf](https://gscan2pdf.sourceforge.net/). [![Test package](https://github.com/carygravel/scantpaper/actions/workflows/test.yml/badge.svg)](https://github.com/carygravel/scantpaper/actions/workflows/test.yml) [![Build packages](https://github.com/carygravel/scantpaper/actions/workflows/deb.yml/badge.svg)](https://github.com/carygravel/scantpaper/actions/workflows/deb.yml) [![Release](https://img.shields.io/github/v/release/carygravel/scantpaper)](https://github.com/carygravel/scantpaper/releases) [![License](https://img.shields.io/badge/License-GPL--3.0--only-blue.svg)](https://www.gnu.org/licenses/) - **Scan** single- or double-sided, with automatic interleaving - **OCR** with tesseract for searchable PDF/A, without the need for temporary files - **Edit** with crop, rotate, threshold, unsharp mask, and unpaper clean-up - **Save** as PDF, DjVu, TIFF, PS, TXT, hOCR, or image files - **Recover** crashed sessions and restore them on the next start

Screenshot of the scantpaper main window with thumbnails on the left and a page preview on the right Screenshot: Main page

--- ## Table of Contents - [Quick Start](#quick-start) - [Description](#description) - [Command-line Options](#command-line-options) - [Diagnostics](#diagnostics) - [Configuration](#configuration) - [Dependencies](#dependencies) - [Download, Installation & Removal](#download-installation--removal) - [Support](#support) - [Reporting Bugs](#reporting-bugs) - [Translations](#translations) - [FAQs](#faqs) - [Known Limitations](#known-limitations) - [See Also](#see-also) - [History](#history) - [Author](#author) - [Thanks To](#thanks-to) - [Contributing](#contributing) - [Donate](#donate) - [License](#license) --- ## Quick Start Install scantpaper and its dependencies (see [Download, Installation & Removal](#download-installation--removal)), then: 1. Start the application with `scantpaper` (or `python3 -m scantpaper.app` from a source checkout, with `PYTHONPATH=src` set). Add `--debug|info|warn|error|fatal` to enable logging at the required level. 2. Scan one or several pages with **File → Scan**. 3. Select the pages and create a PDF with **File → Save**. 4. To make the saved PDF searchable, enable **OCR** in the scan window or run **Tools → OCR** before saving. --- ## Description scantpaper provides a GUI for scanning, editing, and saving documents as PDF, DjVu, TIFF, PS, TXT, hOCR, SDB (scantpaper session), or image files (PNG, JPEG, PNM, GIF), and can prepend or append to an existing PDF. It supports batch scanning, metadata, OCR, and various editing tools. ### How it works Scans are acquired with SANE and held in a session database while you edit them. When saving, PDFs are produced with `img2pdf` and OCR'd with `ocrmypdf` (which produces PDF/A out of the box); DjVu export uses `djvulibre-bin`, TIFF export uses `libtiff`, and images are written with ImageMagick. ``` ┌─────────┐ ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ SANE │ │ SQLite session │ │ edit tools / │ │ img2pdf / │──▶ PDF (PDF/A) │ scanner │──▶│ (pages in temp │──▶│ OCR │──▶│ ocrmypdf │ │ │ │ directory) │ │ (tesseract) │ ├──────────────────┤──▶ DjVu └─────────┘ └─────────────────┘ └──────────────┘ │ djvulibre-bin │ ├──────────────────┤──▶ TIFF │ libtiff │ ├──────────────────┤──▶ PNG, JPEG, PNM, GIF │ imagemagick │ └──────────────────┘ ``` ### Page Numbering Page numbers are always consecutive (1, 2, 3, …). Deleting a page renumbers the remainder automatically, and editing a page's number in the document table moves that page to the corresponding position. ### Scan Flow - **Single-sided:** Each scan is appended at the end. - **Double-sided:** Scan all facing pages first (front 1, front 2, …, front n); they are appended in order. When you flip the stack the ADF feeds them in reverse (back of page n, then back of page n-1, …, back of page 1). Each reverse page is inserted immediately after its matching front page, producing a fully interleaved result: front 1, back 1, front 2, back 2, …, front n, back n. ``` scan pass 1 (fronts): front 1, front 2, ..., front n flip stack scan pass 2 (backs): back n, back n-1, ..., back 1 interleaved result: front 1, back 1, front 2, back 2, ..., front n, back n ``` - **Extended mode (insert before page N):** Each new scan is inserted before the selected page, advancing the insertion point for the next scan. #### ADF / duplex scanning Automatic document feeder (ADF) and duplex scans now import every side of the document. Some Brother scanners (e.g. the DS-740D) prefetch the reverse side of a sheet as soon as the front side finishes reading; cancelling the scan session between pages discarded that buffered side, so only the first page of a duplex job was imported. Scantpaper no longer cancels the session between pages when scanning from a feeder, which preserves the prefetched side. The session is still cancelled at the end of the batch, when the requested page count is reached, on error, or when you cancel the scan. For multi-page flatbed batches the behaviour follows gscan2pdf semantics: the **Force new scan job between pages** preference (enabled via **Edit → Preferences**, only available when **Allow batch scanning from flatbed** is enabled) controls whether the session is cancelled between flatbed pages; the session is always cancelled at the end of the batch. Switching to a flatbed source starts **# Pages** at one page. To scan an unbounded flatbed batch, choose **All** explicitly; the dialog then warns that scanning continues until you cancel. Cancelling stops the batch after the page already in progress. If the scanner does not respond to cancel, scantpaper reports the failure and rebuilds the device session for the next scan; a device still held by the operating system is reported as busy rather than leaving the application unable to scan. When scanning a book destructively (cutting off the binding and aligning every untorn outer edge to the same rim), enable **Alternate rotation every 2nd page** in the **Postprocessing** tab. Odd pages then use the configured rotation angle, even pages use that angle plus 180 degrees, so the flipped back of each sheet comes out upright too. The toggle is only visible for flatbed scanning (including one-page-at-a-time manual scans) when batch scanning from the flatbed is allowed; rotation still runs before OCR. In manual (single-page) mode the alternating parity persists across consecutive scans until you explicitly turn the toggle off. ### Main Features - **Scan:** Options for device, page count, source document, side to scan, and device-dependent options (page size, mode, resolution, batch-scan, etc.). Optionally rotate (including alternating by page parity for book flatbed scans), clean up with unpaper, or run a user-defined tool on each page on scan, and OCR each page. When applying a scan-options profile, the scanner driver may revert an option (some drivers couple or alias options, e.g. page size and scan area); such an option is then set at most twice and dropped for the rest of that apply, so the scan still proceeds instead of ending in a reload-recursion error. If the scan options request 1-bit output - a 1-bit depth, or a bilevel scan mode such as Lineart - the delivered pages are checked against that request. Bilevel transfers always arrive as 8-bit grayscale (the SANE front-end expands them, even when the driver sends 1-bit data, for example when a hardware compression option is active), so scantpaper converts each such page to black-and-white at scan time, using the colour-aware Threshold semantics and value, shows a notice in the progress bar, and logs a warning - so "Automatic" PDF compression encodes the pages as Group 4 or JBIG2 rather than JPEG. Scan-area and page-size fields accept fractional values typed with your locale's decimal separator (e.g. `115,2` mm in a German locale), show them without trailing zeros (`174` mm, not `174,00`), and step by whole units with the arrows while keeping the typed fraction. Only separators your locale actually uses are accepted when typing numbers; a period in a comma locale is rejected rather than silently misinterpreted. The same fractional entry applies to the page-properties resolution fields and to the blank/dark-page and unpaper white/black thresholds, so e.g. `300` dpi and `0,9` are shown exactly as typed. - **Save:** Save selected/all pages in multiple formats. Supports metadata. The Title, Author, Subject, and Keywords fields offer autocompletion, suggesting values from imported documents and values you have entered before. When saving as PDF, the progress bar tracks each page as it is written and reports the PDF conversion step. Imported images are stored in a compact format (JPEG for scanned pages, the original bytes for imported JPEG/PNG files, lossless PNG for bilevel, transparent, or posterised grayscale pages). When saving as PDF, the default **Automatic** compression picks an encoding per page: lossless CCITT Group 4 for bilevel pages, lossless Flate for images with transparency or posterised (few-level) grayscale, and JPEG for other continuous-tone colour or grayscale pages at the configured **Quality**. The **Compression** menu also offers an explicit codec for every page: **G4**, **Flate**, **JPEG** and **JPEG 2000**, plus **JBIG2** when the optional `jbig2` encoder is installed. JPEG 2000 usually produces smaller files than JPEG for scanned pages but is slower to encode and less widely supported by viewers; JBIG2 typically halves the size of a bilevel page compared with Group 4, but its output depends on the installed encoder, and like G4 it thresholds grayscale and colour pages to bilevel first. The **Bilevel compression** preference in **Edit → Preferences** (Group 4 by default, JBIG2 when available) tells **Automatic** how to encode bilevel pages, so a user who never changes the preference keeps Group 4 output. The **Quality** spin-button steers both JPEG and JPEG 2000, and lower values shrink those streams. Unmodified imported JPEG pages are embedded byte-for-byte, while pages that scantpaper generated by scanning or editing are re-encoded at the configured quality, so lowering **Quality** shrinks the PDF. A lossless encoding (Automatic's Group 4 or Flate handling, or the explicit **Flate** option) is preserved through the PDF/A conversion, so the page image is not silently transcoded to JPEG, on every supported OCRmyPDF version. Saving to a file that already exists asks for confirmation first: declining leaves the existing file untouched and keeps the save dialog open, and a save that fails reports the error instead of silently doing nothing. When saving as PDF, pages are processed one at a time and page images are decoded on demand, so saving a document with many high-resolution pages no longer keeps every decoded page in memory and no longer exhausts the memory of the process. - **Thumbnails:** Page thumbnails are generated with a decimate-then-LANCZOS downscale for a sharp preview in the thumbnail panel without slowing down bulk imports, and bilevel or palette pages (common in scanned/OCR PDFs) are anti-aliased so their previews stay legible. - **Lists:** Typing with a list widget focused (the thumbnail panel, or a picker such as the **File → Open crashed session** dialog) no longer opens GTK's interactive-search overlay, which showed nothing because no list configures a search column. It also removes the `gtk_css_node_insert_after` Gtk-CRITICAL that GTK 4.24 printed in its place on the first such keystroke. - **Email as PDF:** Attach pages as PDF to a blank email (requires xdg-email). - **Print:** Print selected/all pages. - **Dialogs:** Every dialog insets its content and its action buttons from the window edge, spaces its rows apart and spaces the controls within a row apart, so no control touches the window border and a label no longer sits flush against the field beside it. Action buttons are also separated from each other; a message dialog keeps its joined button bar. In the scan, Clean up and Preferences dialogs, rows keep the height their content needs when the window is taller than the page: the surplus appears as empty space below the last row instead of stretching a frame, checkbox or label-and-control row. ### Edit Menu - **Undo, Redo:** Undo or redo the last action. An operation applied to several pages at once (for example rotating or cropping a selection) is undone or redone as a single step, reverting every affected page together. Text and annotation corrections are undone and redone the same way; undoing an edit also restores the page's previous saved state. - **Cut, Copy, Paste:** Cut, copy, or paste selected pages. - **Delete:** Remove selected pages. - **Select:** Select all, odd, even, inverted, blank, dark, or modified pages, or pages without (up-to-date) OCR. - **Properties:** Edit image metadata. - **Preferences:** Configure default behaviors and frontends. ### View Menu - Pan, Select, Select & Pan tools: - Pan: Use the left mouse button to drag the image or canvas to pan the view - Select: Use the left mouse button to select a rectangular box - Select & Pan: Use the left mouse button to select a rectangular box, and the middle mouse button to drag the image or canvas to pan the view - In all of the above, the mouse wheel zooms in or out. - Zoom: 100%, fit to window, in, and out. - Rendering: pages are shown at high quality whenever they are static, so the full-page view is easy to assess; a faster rendering is used only while you are panning or zooming, for smooth interaction. - Rotate: 90° clockwise, 180°, and 90° anticlockwise. - Layer editing: choose **Edit text layer** or **Edit annotations** to work on that layer. In the tabbed view the selected tab decides which layer is shown, and its control bar appears below the image only while that tab is open (on the Image tab neither layer is selected and no control bar is shown). In a split view the chosen layer is displayed beside the image, so the annotation layer can be corrected next to the scan as well as the text layer. The layer you were last editing is remembered when you restart. Click a slice to focus it for correction; press or drag anywhere else in the pane to start a new selection rectangle. ### Keyboard Shortcuts | Action | Shortcut | |--------------|----------------| | New | Ctrl+N | | Open | Ctrl+O | | Scan | Ctrl+G | | Save | Ctrl+S | | Email as PDF | Ctrl+E | | Print | Ctrl+P | | Quit | Ctrl+Q | | Undo | Ctrl+Z | | Redo | Ctrl+Shift+Z | | Cut | Ctrl+X | | Copy | Ctrl+C | | Paste | Ctrl+V | | Delete | Del | | **Select** | | | All | Ctrl+A | | Odd | Ctrl+1 | | Even | Ctrl+2 | | Invert | Ctrl+I | | Blank | Ctrl+B | | Dark | Ctrl+D | | Modified | Ctrl+M | | **View** | | | Zoom in | + | | Zoom out | − | | Rotate 90° clockwise | Ctrl+Shift+R | | Rotate 180° | Ctrl+Shift+F | | Rotate 90° anticlockwise | Ctrl+Shift+C | | Help | Ctrl+H | ### Tools - **Threshold:** Binarize images. The value is an ink-strength cutoff: pixels that differ from the paper colour by more than the given percentage are rendered black, so coloured text and annotations (stamps, highlighters) on white paper are kept. The default is 20; values saved by older versions are migrated automatically. - **Brightness / Contrast:** Adjust brightness and contrast. - **Negate:** Invert colours. - **Unsharp mask:** Sharpen images. - **Crop:** Crop selected pages. The selection rectangle stays visible when you move between pages, so you can preview where the crop will land on each page before applying it. - **Clean up:** Use unpaper to clean up scans. - **Split:** Split pages vertically or horizontally. - **OCR:** Use tesseract to create a text layer for the selected pages. The text layer is embedded into saved PDFs, making them searchable, and can be viewed and edited in the text layer window. Deleting a slice in that window removes the corresponding text from the saved PDF's text layer, and correcting one replaces the text that is saved and drawn; emptying a slice deletes it. Accepting a correction moves on to the next slice in the current sort order, so a confidence-sorted pass walks from the least to the most confident text without re-selecting each one. To add a slice, first draw a rectangle - on the image or directly in the text layer pane - then type the text and press the + control; the Add control stays disabled, with an explanatory tooltip, until a rectangle has been drawn or selected. Drawing that first rectangle works on a page that has not been OCRed yet, so the text layer can be started from scratch. A focused slice can be rotated 90° clockwise or anticlockwise to author vertical text (e.g. rotated index columns) whose glyphs tesseract read wrongly; slices added while a slice is rotated inherit that rotation. A slice added without an inherited rotation is oriented automatically: a tall, narrow selection becomes vertical text and a wide, short one stays upright, so a drawn box for a vertical column renders legibly instead of as shrunken horizontal text. Rotated slices are embedded at their angle in saved PDFs. Upright and rotated slices can be mixed on one page: each word is saved at its own size and angle, so a search highlight covers the word it found rather than a larger block around it. Re-importing a PDF cannot recover that rotation, because the imported text layer carries positions but not text direction. Page pixels are fed to tesseract in memory and the stored image is reused, so OCR is faster than in previous versions. - **User-defined:** Run user-defined commands. #### User-defined Tool Variables - `%i` - input filename - `%o` - output filename - `%r` - resolution By default a user-defined tool is run once per selected page, with `%i` (and `%o`/`%r` where used) substituted for that page. Selecting "Process selected pages at once" in the user-defined tools dialog instead passes every selected page to a single invocation: `%i` expands to all the working files, space-separated, so a tool that edits files in place (such as `mogrify -negate %i`) can process the whole selection in one go. Only the pages whose file actually changed are replaced, and the whole batch is a single undo step, so one undo restores every page. `%o` and `%r` are not available in batch mode; a tool that uses them must be run one page at a time. ### OCR Languages and Tesseract Options Tesseract can recognise several languages at once. In the scan window and the **Tools → OCR** dialog, pick one **Language to recognise** (the primary language) and tick any **Additional languages** in the popover. The resulting specification is stored in the `ocr language` setting as a `+`-joined list, for example `deu+eng`. The primary language is listed first, so put the main language of the page first; tesseract prioritises the first language, and adding more languages makes recognition slower and can reduce accuracy, so select only the languages you need. Next to the language, two selectors control how tesseract runs: - **Page segmentation mode** tells tesseract how to lay out the page, from **Automatic** (the default, suitable for most pages) through **Single column**, **Single block**, **Single line**, **Single word**, **Single character**, **Sparse text**, and **Raw line**. - **OCR engine mode** chooses the engine: **Default** (the engine's own choice), **LSTM only**, **Legacy**, or **Combined**. **Legacy** needs language data that includes the legacy engine; if it is missing, OCR does not start and the error names the engine mode. Both are stored and restored with the other OCR options. Do not use `tessedit_pageseg_mode` or `tessedit_ocr_engine_mode` in the advanced field instead: those variables are set by these controls, so the field rejects them. The **Advanced Tesseract engine parameters** expander accepts one `name=value` engine parameter per line, for example: ``` user_words_file=/home/me/words.txt tessedit_char_whitelist=0123456789 ``` These are the values of tesseract's `-c` option, passed straight to the engine, and cover options that have no dedicated control, such as a **user word list** (`user_words_file`) or a **user pattern list** (`user_patterns_file`). A leading `-c` is optional, so `-c user_words_file=/path` works too. Press **Tab** to leave the field for the next control, so OK (or Scan) stays reachable without the mouse. **Ctrl+Tab** and **Ctrl+Shift+Tab** move focus forward and backward too. A literal tab character is not needed. Do not paste tesseract command-line options such as `--user-words`: the field expects engine parameters, and the error names the parameter to use instead: | Command-line option | Engine parameter | |---------------------|----------------------| | `--user-words` | `user_words_file` | | `--user-patterns` | `user_patterns_file` | | `--dpi` | `user_defined_dpi` | Lines beginning with `#` are comments; blank lines are ignored; a value may itself contain `=`. Paths are not expanded, so use absolute paths. A `user_words_file` or `user_patterns_file` that does not exist or cannot be read is reported before OCR starts, and the file paths that will be applied are written to the log. Invalid or unknown options are reported before any page is modified. The `tessdata_best` language models give more accurate OCR but are larger and notably slower, and they only support the LSTM engine. Point the **Tessdata directory** control (see below) at their directory to use them; the `TESSDATA_PREFIX` environment variable and replacing the system models also work. #### Tessdata directory The **Tessdata directory** control at the top of the OCR options chooses the directory that holds the tesseract language data (the `.traineddata` files). Leave it empty for the automatic directory, shown in the entry as **Default (system tessdata)**; **Browse** picks a folder and **Default** clears the choice. The directory is resolved in this order: 1. the chosen **Tessdata directory**; 2. the `TESSDATA_PREFIX` environment variable, if it is set; 3. the system tessdata directory. A chosen directory must directly contain at least one `.traineddata` file. This also applies to `TESSDATA_PREFIX`: point it at the directory that contains the files, not at a parent such as `/usr/share`. A broad value makes tesseract search the whole tree and hang, so it is rejected with an error instead of being used. Nothing falls back to another directory: a wrong path is reported before OCR starts. Changing the directory rebuilds the language list from it. If the current language is not present in the new directory it stays selected and is marked *(not installed)*, and OCR refuses to start until a language from that directory is chosen. The selected directory is stored with the other OCR options and restored on the next start. Tesseract 3's `m_data_sub_dir` parameter no longer exists in Tesseract 4 and later, so it is rejected like any other unknown engine parameter. --- ## Command-line Options scantpaper supports the following options: - `--device [...]` Specifies the device(s) to use, instead of getting the list from the SANE API. Useful for remote scanners. May be repeated, or given multiple space-separated devices. - `--help` Displays help and exits. - `--log=` Specifies a file to store logging messages. On exit, the log is compressed to `.xz`. - `--debug`, `--info`, `--warn`, `--error`, `--fatal` Defines the log level. Defaults to `--debug` if a log file is specified, otherwise `--warn`. - `--import=` Imports the specified file(s). For multi-page documents, a window is displayed to select required pages. - `--import-all=` Imports all pages of the specified file(s). - `--locale=` Sets the directory containing translated messages. See [Translations](#translations). - `--version` Displays the program version and exits. ### Example output ```sh $ scantpaper --version scantpaper X.Y.Z ``` (Replace `X.Y.Z` with your installed version.) ```sh $ scantpaper --help usage: scantpaper [-h] [--device DEVICE [DEVICE ...]] [--import IMPORT_FILES [IMPORT_FILES ...]] [--import-all IMPORT_ALL [IMPORT_ALL ...]] [--locale LOCALE] [--log LOG] [--version] [--debug] [--info] [--warn] [--error] [--fatal] A GUI to produce PDFs or DjVus from scanned documents options: -h, --help show this help message and exit --device DEVICE [DEVICE ...] --import IMPORT_FILES [IMPORT_FILES ...] --import-all IMPORT_ALL [IMPORT_ALL ...] --locale LOCALE --log LOG --version show program's version number and exit --debug --info --warn --error --fatal Please see /usr/share/doc/C/scantpaper/documentation.html for more detail ``` ### Examples ```sh # Import every page of a PDF, letting you edit before saving scantpaper --import-all ~/scans/document.pdf # Import a PDF, choosing the pages to import in a dialog scantpaper --import ~/scans/document.pdf # Use a remote scanner scantpaper --device "net:scanner.example.com:6566" ``` Scanning is handled with SANE. PDF conversion uses `img2pdf` and `ocrmypdf`. TIFF export uses `libtiff`. Saved PDFs are marked as created by scantpaper: the document info `Creator` field and the XMP creator tool begin with `scantpaper v`, followed by the OCR toolchain used, e.g. `scantpaper v3.0.16 / OCRmyPDF 16.13.0 / Tesseract OCR-hOCR 5.5.0`. The `Producer` field continues to name the library that wrote the file. --- ## Diagnostics To diagnose errors, start scantpaper from the command line with logging enabled: ```sh PYTHONPATH=src python3 -m scantpaper.app --debug ``` --- ## Configuration scantpaper creates a config file at `~/.config/scantpaperrc`. The directory can be changed by setting `$XDG_CONFIG_HOME`. Preferences are usually set via **Edit → Preferences**. If the config file cannot be read as JSON (for example after a partial write or a hand edit), scantpaper rescues as many recognised settings as it can and resets the rest to their defaults. The unreadable original is kept as a backup at `~/.config/scantpaperrc.old`, and the start-up message tells you which settings were kept. Wrongly typed values (e.g. a string where a number is expected) are corrected when possible; each correction is written to the log rather than shown in the start-up message. Only values that cannot be corrected are reported. The original file is never silently overwritten: saving on exit waits until the message has been acknowledged. Scan options saved in the configuration file by gscan2pdf 2.x (whose `default-scan-options` block has no `frontend` list) are recognised and applied to the scan dialog on startup, and are kept when the file is written back. ### Sessions An unsaved session's data (pages, edits, OCR, annotations) is stored in an SQLite database in a temporary directory named `scantpaper-????????`, created under `$TMPDIR` (or `/tmp`) by default. You can change this location in **Edit → Preferences**. On exit the temporary directory is cleaned up. If scantpaper crashes, it survives, and on the next start you are asked whether to restore it via **File → Open crashed session**. A session saved as an `.sdb` file (**File → Save As**) can be reopened with **File → Open**. The opened file itself becomes the working session: edits are written to it as you work, so they persist even if you quit without saving the session again, and quitting does not warn about unsaved pages. Saving over an existing `.sdb` file asks for confirmation before the old file is replaced; the replacement is atomic, so a failed save leaves the previous file intact. Because the opened file is the source of truth, an in-place session is not offered for restoration as a crashed session after a crash. A session with no recorded actions opens as an empty document, and a session whose latest action removed every page reopens empty. The first page scanned into a restored session is recorded as its first undo step. --- ## Dependencies Package names below are the Debian package names. Equivalent packages for other distributions are given in the [wheel file](#from-a-wheel-file) installation instructions. ### Required - gir1.2-gdkpixbuf-2.0 - gir1.2-gtk-4.0 - imagemagick - img2pdf - libtiff-tools - ocrmypdf - poppler-utils - python3-gi - python3-gi-cairo - python3-iso639 - python3-pil - python3-sane - python3-tesserocr ### Optional - djvulibre-bin - jbig2 (for JBIG2 PDF compression) - qpdf (for PDF encryption) - unpaper - xdg-utils ### Development - python3-pytest-mock - python3-pytest-cov - python3-pytest-timeout - python3-pytest-xvfb --- ## Download, Installation & Removal ### Requirements - Python 3.10 or later. - GTK 4.6 or later and PyGObject 3.42 or later. - The [dependencies](#dependencies) listed below. They are installed automatically when installing from a wheel or with `uv`, but must be installed manually when running from a tarball or the repository. Ubuntu 22.04 (jammy), with GTK 4.6 and PyGObject 3.42, is supported until 2027-06. Ubuntu 24.04 (noble) and newer toolchains are also supported. ### Packaged installs #### Debian-based - Debian `sid` should automatically have the latest version. ```sh sudo apt update sudo apt install scantpaper ``` - Ubuntu users can use the PPA: ```sh sudo apt-add-repository ppa:jeffreyratcliffe/ppa sudo apt update sudo apt install scantpaper ``` In either case to remove scantpaper afterwards: ```sh sudo apt remove scantpaper ``` #### From Fedora RPM packages Download the RPM files from [Github](https://github.com/carygravel/scantpaper/releases/) and install them all in a single transaction: ```sh sudo dnf install *.rpm ``` The release provides scantpaper together with locally rebuilt versions of `python3-sane`, `python3-tesserocr`, `python3-cysignals` and `python3-iso639` (packaged as RPMs because the Fedora versions do not satisfy scantpaper's dependencies, and with a `python3-sane` patch for Epson `epsonscan2` scanners). scantpaper requires these packages by their `python3dist(...)` names, so they must be installed in the same transaction: installing scantpaper first fails with `nothing provides python3.14dist(...)`. To remove scantpaper afterwards: ```sh sudo dnf remove scantpaper ``` Weak dependencies such as `qpdf` are installed by default, but can be skipped with `--setopt=install_weak_deps=0`, in which case the optional features they provide (e.g. PDF encryption) are disabled. #### From openSUSE RPM packages Download the RPM files from [Github](https://github.com/carygravel/scantpaper/releases/) and install them all in a single transaction (Leap 16.0): ```sh sudo zypper install --allow-unsigned-rpm *.rpm ``` The release provides scantpaper together with locally rebuilt, Leap 16.0 versions of the Python dependencies that the distro does not package (`python313-sane`, `python313-tesserocr`, `python313-cysignals`, `python313-python-iso639`, `python313-pdfminer.six`, `python313-img2pdf`, `python313-ocrmypdf`, `python313-setuptools-scm` and `python313-hatch-vcs`, with a `python313-sane` patch for Epson `epsonscan2` scanners). Installing the set replaces any same-named packages you already have from repo-oss or from a third-party repository such as `devel:languages:python`. That is intended: the rebuilt packages carry fixes the repository builds do not, the `python313-sane` `snap()` patch being the important one. Three of them are versioned so that a later system update keeps your build instead of reverting it to the distribution's. To remove scantpaper afterwards: ```sh sudo zypper remove scantpaper ``` The RPMs are unsigned (the workflow builds them from source), hence the `--allow-unsigned-rpm`. That is the only flag needed. PostScript export needs `tiff2ps`, which is not packaged on Leap 16.0, so the PostScript save type is not available there. #### From a wheel file Download `.whl` from [Github](https://github.com/carygravel/scantpaper/releases/). ```sh # Install the C-libraries that pip cannot handle: # For Debian/Ubuntu sudo apt update sudo apt install libgirepository-2.0-dev libcairo2-dev libsane-dev pkg-config python3-dev gir1.2-glib-2.0 # For Fedora sudo dnf install gobject-introspection-devel cairo-devel sane-backends-devel pkgconf-pkg-config python3-devel # For openSUSE sudo zypper install gobject-introspection-devel cairo-devel sane-backends-devel pkgconf-pkg-config python3-devel # For Arch sudo pacman -S gobject-introspection cairo sane pkgconf python # For Homebrew brew install pygobject3 gobject-introspection cairo sane-backends pkg-config # Possibly upgrade pip python3 -m pip install --upgrade pip # Install from the wheel file, automatically including python dependencies pip install scantpaper-x.x.x-py3-none-any.whl ``` If you haven't already, you will then probably have to add `~/.local/bin` to your path in order to find the new executable, after which you can start it with: ```sh scantpaper ``` To then remove it: ```sh pip uninstall scantpaper ``` #### With `uv` To install the runtime dependencies with `uv`: ```sh uv sync ``` or with the additional development dependencies: ```sh uv sync --extra test ``` After which you can start it with: ```sh uv scantpaper ``` ### From source #### From a tarball Download `.tar.gz` from [Github](https://github.com/carygravel/scantpaper/releases/). After installing the [dependencies](#dependencies) listed above: ```sh tar xvfz scantpaper-x.x.x.tar.gz cd scantpaper-x.x.x PYTHONPATH=src python3 -m scantpaper.app ``` #### From the repository Browse the code at [Github](https://github.com/carygravel/scantpaper). After installing the [dependencies](#dependencies) listed above: ```sh git clone https://github.com/carygravel/scantpaper.git cd scantpaper PYTHONPATH=src python3 -m scantpaper.app ``` In either of the above two cases, just delete the source directory to remove it. --- ## Support - **Mailing lists:** - [gscan2pdf-announce](https://lists.sourceforge.net/lists/listinfo/gscan2pdf-announce) (announcements) - [gscan2pdf-help](https://lists.sourceforge.net/lists/listinfo/gscan2pdf-help) (general support) --- ## Reporting Bugs - Please read the [FAQs](#faqs) first. - Report bugs preferably against the [Debian package](https://packages.debian.org/sid/scantpaper) or [Debian Bugs](https://www.debian.org/Bugs/). - Alternatively, use the [Github issue tracker](https://github.com/carygravel/scantpaper/issues). - Include the log file created by `scantpaper --log=log` with your report. On exit the log is compressed to `log.xz`, so submit that file. --- ## Translations scantpaper is partly translated into several languages. We'd like to organise the translations on [Launchpad Rosetta](https://translations.launchpad.net/scantpaper) — but if that's too much effort for you, attaching a `.po` file to an [issue](https://github.com/carygravel/scantpaper/issues) or a merge request is just as welcome. Missing strings are seeded as `#, fuzzy` (needs review) entries and fall back to English until a translator confirms them, so only confirmed translations reach users. The catalogs live in the `po/scantpaper/` template directory, one `po/scantpaper/.po` per language next to the generated `po/scantpaper/scantpaper.pot` — the layout Rosetta expects, so the directory can be tarballed and uploaded as-is. - Scanner option translations come from sane-backends. Contribute via the [sane-devel mailing list](mailto:sane-devel@lists.alioth.debian.org) or [SANE project](http://www.sane-project.org/cvs.html). To test updated `.po` files: ```sh python3 dev/compile_mo.py --src po/scantpaper --out src/scantpaper/locale --domain scantpaper PYTHONPATH=src python3 -m scantpaper.app --log=log --locale=locale ``` Set locale variables as needed (e.g., for Russian): ```sh LC_ALL=ru_RU.utf8 LC_MESSAGES=ru_RU.utf8 LC_CTYPE=ru_RU.utf8 LANG=ru_RU.utf8 LANGUAGE=ru_RU.utf8 PYTHONPATH=src python3 -m scantpaper.app --log=log --locale=locale ``` ### Checking for upstream translations ```sh python3 dev/check_launchpad.py ``` reports whether anything has moved on [Launchpad](https://translations.launchpad.net/scantpaper) since the last sync, by comparing the current state of the translation template and of every language against the committed baseline `po/launchpad-state.json`. Two triggers are watched: the template (the message set / `.pot`), read from the Launchpad JSON API with a conditional request, and each language's last-changed time, which exists only on the public series translation page. A language is reported when its last-changed time is newer than the baseline, when a new locale appears, or when the template changes. It also reports the reverse direction: whether the local template generated from source is stale relative to the last pot uploaded to Launchpad. The local message set is compared against the `uploaded_pot` fingerprint recorded in the baseline; a difference (new, removed or changed strings) is reported as `Local pot is stale:` naming the added and removed strings, and the exit code is unaffected unless `--fail-on-change` is given. The check is advisory by default: it exits successfully whether or not it found changes, and it never downloads a catalog (Launchpad requires a login to export one). `--fail-on-change` makes a detected change non-zero for CI. The one always-non-zero outcome is a state the check could not read, so a broken check is loud rather than mistaken for "nothing new". The committed baseline is advanced deliberately, after the catalogs have been synced: ```sh python3 dev/check_launchpad.py --update ``` After a new template has been uploaded to Launchpad, record its message set so the next run no longer reports the pot as stale: ```sh python3 dev/check_launchpad.py --record-pot ``` The [translations-check workflow](.github/workflows/translations-check.yml) runs the same check daily and on demand (`workflow_dispatch`), and files one labelled issue when it detects upstream changes and a separate one when new strings are ready to upload. ### Validating catalogs and source strings ```sh python3 dev/check_po.py ``` This needs `xgettext`, `msgcat` and `msgfmt` on `PATH`. `intltool` is not needed: the translatable strings in the `.ui` files are extracted with the GtkBuilder ITS rules vendored in `dev/`, so the template does not depend on the gettext version installed. CI runs the same script via `test_po_files.py`. `dev/check_po.py` compares those vendored rules against the copies gettext installed and reports any drift as an advisory. Errors fail the script; advisories are printed but do not. A release is therefore never blocked by an advisory, but they are worth reading. The hard checks catch defects that break a catalog or a translation: a bad format specifier, an unbalanced or renamed placeholder, a plural entry with the wrong number of forms, a `Plural-Forms` header that disagrees with CLDR, an obsolete-entry count over the catalog's ceiling, and counted strings written as `%(plural)s` rather than `ngettext`. The advisories fall into two groups. Source-side ones report strings that are hard to translate, so they are best fixed by rewording: a concatenation fragment that fixes English word order into a translatable string, a msgid differing from another only by case, a doubled space, a msgid over 200 characters, and a `μs` written with GREEK SMALL LETTER MU. Catalog-side ones report mistranslations that only a native speaker can judge: a `msgstr` copied verbatim from a different msgid (the signature of a bad fuzzy match), and two sibling controls such as `Select Odd` and `Select Even` sharing one translation. `dev/check_po.py` has a `--pot` option that points it at an existing template instead of generating one, which the test suite uses to exercise the source-side checks. --- ## FAQs ### Why isn't option xyz available in the scan window? It may not be supported by SANE or your scanner. If you see it in `scanimage --help` but not in scantpaper, send the output to the maintainer. ### How do I scan a multipage document with a flatbed scanner? Enable "Allow batch scanning from flatbed" in Preferences. Some scanners require additional settings. Switching to the flatbed starts **# Pages** at one page; choose a finite count, or choose **All** to scan until you cancel. ### How do I scan a book? Cut off the binding and scan every sheet one at a time, always aligning the untorn outer edge to the same rim. Scan in landscape to save time, set the rotation for the front pages in the **Postprocessing** tab, then tick **Alternate rotation every 2nd page** so the backs (which are rotated 180 degrees relative to the fronts) can be scanned too without manual rotation. For one-page-at-a-time flatbed scanning, the toggle remains active across consecutive scans so every second scan is flipped automatically; turn it off to reset parity. ### Why is option xyz ghosted out? The required package may not be installed (e.g., xdg-email, unpaper, imagemagick). ### Why can I not scan from the flatbed of my HP scanner? Set "# Pages" to "1" and "Batch scan" to "No". ### Why is the list of changes not displayed when updating in Ubuntu? Only changelogs from official Ubuntu builds are shown. ### Why can't scantpaper find my scanner? If the scanner is remote and not found automatically, specify the device: ```sh scantpaper --device ``` ### How can I search for text in the OCR layer? Use `pdftotext` or `djvutxt` to extract text. Many viewers support searching the embedded text layer. ### How can I change the colour of the selection box or OCR output? The selection rectangle is styled with the `.rubberband` CSS class. scantpaper ships a default rule for it that follows the active GTK theme, so the selection is visible out of the box. To use a colour of your own, create or edit `~/.config/gtk-4.0/gtk.css` (a user stylesheet overrides the application default): ```css .rubberband { border: 1px solid #2a76c6; background-color: rgba(42, 118, 198, 0.2); } #scantpaper-ocr-output { color: black; } ``` ### What's in a name? "scant" (https://en.wiktionary.org/wiki/scant) in this sense means "short (of)", as I am trying to digitalise my paperwork, and I liked the play on "scan". --- ## Known Limitations ### PDFs larger than 2 GiB Saving more than approximately 250 uncompressed scanned pages (at 300 dpi, 8-bit grayscale) produces a PDF exceeding 2 GiB. At that size, three tools in the save pipeline overflow 32-bit file offsets and produce truncated or corrupt output: | Component | Tested version | Overflow point | Symptom | |---|---|---|---| | **img2pdf** (pikepdf engine, linearization) | 0.6.2 | 32-bit xref offsets in linearized output | Truncated PDF; first pages readable, later pages missing | | **Ghostscript** (PDF/A conversion via ocrmypdf) | 10.07.1 | 32-bit file access in gs interpreter | Ghostscript error or corrupt output | | **pikepdf** / **qpdf** (xref-stream linearization, metadata save) | pikepdf 10.5.0, qpdf 12.4.0 | 32-bit offsets in xref streams | "unable to find /Root dictionary"; PDF unopenable | Scantpaper now estimates the output size before conversion and refuses to save when it would exceed 2 GiB, showing an error message suggesting fewer pages. **When updating dependencies**, re-test by saving ~250 high-resolution uncompressed pages (e.g., 7000×5000 px grayscale TIFFs) and verifying the output PDF opens correctly in a PDF viewer. Note that Ghostscript's 64-bit integer support (needed for >2 GiB files) is build-dependent — see the [Ghostscript documentation on word size](https://ghostscript.readthedocs.io/en/latest/Use.html#word-size-32-or-64-bits). --- ## See Also - [gscan2pdf](https://gscan2pdf.sourceforge.net/) (the Perl predecessor of scantpaper) - [XSane](http://xsane.org/) - [Scan Tailor](http://scantailor.org/) --- ## History I started writing `gscan2pdf` as a Perl & Gtk2 project in 2006. Version 2 switched to Gtk3, but kept the basic software architecture. This stored the pages as temporary files with hashed names, which had a couple of major disadvantages: - Difficult to support PDF/A directly - It was impossible to create documents with more than a few hundred pages, as it ran out of open file handles. - In the event of a crash, it was tedious to recreate the document from the image files. - AFAIK, Perl's support for Gtk4 never extended beyond that provided by introspection. Therefore I decided in 2022 to completely rewrite `gscan2pdf` in Python and renamed it for v3 `scantpaper`. The rewrite: - Supports PDF/A by using `ocrmypdf` to write PDFs - Stores all session data in a single Sqlite database - Should be simple to migrate to Gtk4 See also the [changelog](changelog.md) for detailed release notes. --- ## Author Jeffrey Ratcliffe (jffry at posteo dot net) --- ## Thanks To - All contributors (patches, translations, bugs, feedback) - The SANE project - The authors of `img2pdf` and `ocrmypdf`, without which this would have been much harder. --- ## Contributing See [contributing](CONTRIBUTING.md). --- ## Donate [![Donate via PayPal](https://www.paypalobjects.com/en_US/DE/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=GYQGXYD5UZS6S) --- ## License Copyright © 2006–2026 Jeffrey Ratcliffe This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License v3 as published by the Free Software Foundation. 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](https://www.gnu.org/licenses/) for more details. carygravel-scantpaper-c8a0fa9/changelog.md000066400000000000000000001130401527133624300207620ustar00rootroot00000000000000## 4.0.2 (2026-10-31) * Fix a start-up warning for legacy boolean settings such as `post_save_hook` that gscan2pdf 2.x stored as an empty string for false. The value is now corrected and logged instead of being reported on every start, and the corrected `false` is written back on exit. * A scan batch that ends because the document feeder is empty is no longer logged as an error with a traceback. An empty feeder is the normal end of a batch, so it is now recorded at INFO level; genuine scan errors still log at ERROR with their traceback. * A saved PDF no longer silently re-encodes a lossless page image as JPEG during the PDF/A conversion. When the "Flate" option is used, or "G4" (or Automatic resolves a page to Group 4 or Flate), the image now stays Flate or Group 4 in the final file, on every supported OCRmyPDF version. A save whose pages are all JPEG keeps them as JPEG, so an already-lossy document is not inflated. * A posterised grayscale page - a scan whose pixels use only a few distinct intensity levels, such as a document with a half-tone or line-art picture - is now stored and saved losslessly. On import it is kept as PNG instead of being re-encoded to JPEG, and "Automatic" PDF compression writes it with lossless Flate rather than JPEG. JPEG's transform assumes smooth tone, so for these pages it both smeared the levels and produced a larger file; the same 8-level page now saves smaller and without loss. Genuinely continuous-tone grayscale and colour pages still use JPEG. * The misleading "None" option is gone from the PDF compression menu. It never produced uncompressed output: it resolved to exactly the same lossless Flate stream as the "Flate" option, so it promised something it did not deliver. A configuration that had selected it now loads as "Flate", keeping the same output with an honest label. * The OCR "Advanced Tesseract engine parameters" field now says what it wants: engine parameters in `name=value` form - the values of tesseract's `-c` option - not command-line options. A pasted option such as `--user-words` or `--dpi` is refused with the equivalent parameter named (`user_words_file`, `user_defined_dpi`), and a leading `-c` is accepted so a line copied from tesseract's documentation works. A `user_words_file` or `user_patterns_file` that does not exist or cannot be read is reported before OCR starts instead of failing silently, and the resolved paths that will be applied are written to the log. * The OCR settings now include a **Page segmentation mode** and an **OCR engine mode** selector, alongside the language. The segmentation mode tells tesseract how to lay out the page (Automatic, Single column, Single block, Single line, Single word, Single character, Sparse text, or Raw line), and the engine mode chooses the engine (Default, LSTM only, Legacy, or Combined). Both default to the previous behaviour and are stored with the other OCR options. Choosing an engine mode the installed language data cannot provide no longer fails with a misleading "invalid tessdata path"; the error names the engine mode instead. Because these two modes are set by the new controls, `tessedit_pageseg_mode` and `tessedit_ocr_engine_mode` are now rejected in the advanced field with a message pointing to the controls. * Fixed about dialog * The OCR options now include a **Tessdata directory** control, so a `tessdata_best`, `tessdata_fast`, or per-user language data set can be chosen without replacing the system models or exporting `TESSDATA_PREFIX` before launch. Leave it empty for the automatic directory; **Browse** picks a folder and **Default** clears the choice. The directory is resolved as the chosen path first, then `TESSDATA_PREFIX`, then the system default, and it must directly contain `.traineddata` files. A `TESSDATA_PREFIX` that names a broad parent such as `/usr/share` is now rejected with an explanatory error instead of making tesseract search the whole tree and hang while listing languages. Changing the directory rebuilds the language list and keeps the current language, marked "(not installed)" when the new directory lacks it. The selection is stored with the other OCR options. Tesseract 3's removed `m_data_sub_dir` parameter remains rejected. * The OCR **Advanced Tesseract engine parameters** field and the text-layer editor now let **Tab** move focus to the next control, so OK (or Scan) stays reachable without the mouse; **Ctrl+Tab** and **Ctrl+Shift+Tab** move focus forward and backward. A literal tab character is no longer entered in these fields. * The OCR dialog now returns to the height it had before the **Advanced Tesseract engine parameters** expander was opened when that expander is collapsed. The **Page Range** frame also keeps its natural height instead of stretching to fill surplus window height in any dialog that shows it. * Rows in the scan, Clean up and Preferences dialogs now keep the height their content needs when the window is taller than the page. The surplus appears as empty space below the last row instead of stretching a frame, a checkbox, or a label-and-control row. This keeps the scan **# Pages** and **Source document** frames, the rotation controls, the Clean up options and the Preferences **General options** page at their natural heights. * Dialog content no longer runs flush against the window edge, and rows are no longer packed together. GTK4 dropped the content-area inset that GTK3 derived from the theme, and a box's spacing now defaults to zero, so every dialog sets a consistent inset and spacing - including the ones opened from the Tools menu and the ones built directly on `Gtk.Dialog`. * Dialog action buttons no longer touch each other or the window edge. GTK3's theme supplied the action area's button spacing and border; GTK4's action area is a plain box with zero spacing, so the app now applies a consistent inset and gap to every dialog with separate action buttons, including file choosers. A message dialog keeps its joined button bar. * Clicking a word in the text or annotation layer now selects the word under the pointer on HiDPI displays and on pages whose horizontal and vertical resolutions differ. The click was mapped with an older transform than the one the layer is drawn with, so it could land on a neighbouring word or on the page, and a correction only began after clicking several times. * A PDF whose text layer mixes upright and rotated slices now saves every word at its own size, so searching a word highlights the word found rather than a large surrounding block. A single tall rotated slice used to widen the grouping tolerance for the whole flat layer, which merged upright words from different rows into one oversized line and made the renderer draw them at that line's scale. * The scan dialog now opens with every saved postprocessing setting. A saved alternate-rotation, image clean-up or user-defined-tool setting, a missing clean-up tool, or an empty user-defined-tool list no longer raises a `TypeError` while the dialog is being built. Applying Preferences after removing the last tool also leaves an open scan dialog usable. * Restoring an empty crashed session no longer fails on the first scan with an internal error. It opens as a usable empty document, and the imported page becomes its first undo step. Reopening a session whose latest action removed every page now stays empty instead of showing the deleted pages again; undo still brings them back. * Selecting a flatbed source now starts **# Pages** at one page instead of inheriting the ADF `All` default. An unbounded flatbed batch is only possible after choosing `All` explicitly, and the dialog warns that scanning continues until cancelled. * Cancelling a scan between pages no longer queues the next page. If the scanner does not respond to cancel, scantpaper reports the failure and rebuilds the device session for the next scan; a device still held by the operating system is reported as busy instead of leaving the application unable to scan. * The Debian package attached to the GitHub release is now built from `packaging/debian/`, which is kept in the repository and rendered from the selected git revision, instead of the workflow writing the packaging files itself. Its version is `-0..g`, so it sorts above earlier snapshots and below the Debian package for the same release. It is built, linted and tested on Debian unstable before it is published. * The Debian and Ubuntu packaging steps are scripted: `dev/sync_salsa.py` copies the shared packaging into the salsa checkout and drafts the changelog, and `dev/build_ppa.py` builds the per-series PPA source packages from `packaging/ubuntu/suites.toml`. Signing and uploads remain manual. ## 4.0.1 (2026-10-25) * The selection rectangle is visible again when dragging a selection on the Image tab or in a text layer/annotations pane. After the move to GTK4 the selection is painted with the `.rubberband` style class, which GTK4's default stylesheet leaves unstyled, so the box was invisible; scantpaper now ships its own default `.rubberband` rule that follows the active GTK theme, so the selection shows up out of the box. A user `.rubberband` rule in `~/.config/gtk-4.0/gtk.css` still overrides the default (see the FAQ). * Restore middle-click panning after the GTK4 migration ## 4.0.0 (2026-10-24) * Migrated Gtk3->4 * User-defined tools can now process all selected pages in a single invocation: tick "Process selected pages at once" in the user-defined tools dialog and `%i` expands to every page's working file, space-separated, so a tool that edits in place (e.g. `mogrify -negate %i`) handles the whole selection in one run. Only the pages whose file actually changed are replaced, and the whole batch is a single undo step (one Undo restores every page). `%o` and `%r` are not available in batch mode, so a tool that uses them still runs one page at a time. * Saving a PDF with the "JBIG2" compression option now encodes greyscale and colour pages with JBIG2 as well, by thresholding them to black-and-white first, exactly as the "G4" option does. Previously such pages were silently embedded as JPEG, so a JBIG2 save of a non-black-and-white document could be much larger than expected. Pages with transparency keep lossless Flate encoding. * When the scan options request 1-bit output - a 1-bit depth, or a bilevel scan mode such as Lineart - delivered pages are now checked against that request. Bilevel transfers always arrive as 8-bit grayscale, because the SANE front-end expands them even when the driver sends 1-bit data; such pages are now converted to black-and-white at scan time with the colour-aware Threshold semantics and value, a notice is shown in the progress bar, and a warning is logged. Previously the pages stayed grayscale, so saving with "Automatic" PDF compression embedded them as JPEG instead of Group 4 or JBIG2. * Rotated text slices are now rendered at their angle in saved PDFs. The effective angle is written to the OCR line that OCRmyPDF reads, mixed-angle lines are split, and manually added vertical words no longer spill down the page as oversized horizontal text. The minimum OCRmyPDF version is now 16.10.0, the first release that honours the hOCR text angle. * Long OCR and processing batches no longer run out of file descriptors. Each completed background request now closes its PID file immediately instead of waiting for a full garbage-collection pass. A PID-file failure is also reported through the normal error dialog with the original operating-system error, rather than being masked by a callback-signature `TypeError`. * OCR can now recognise several languages at once. In the scan window and the **Tools → OCR** dialog, pick a primary language and tick additional languages in the popover; they are stored as a `+`-joined list (for example `deu+eng`) with the primary first. An **Advanced Tesseract options** field accepts one `name=value` engine variable per line, such as a user word list (`user_words_file=/home/me/words.txt`); these are applied when the engine is initialised and invalid options are reported before any page is modified. A stored language that is no longer installed is shown as "(not installed)" and blocks OCR instead of being silently replaced. * The Fedora and openSUSE RPM specs are now templates rendered at build time from the selected git revision. The package version and source archive match the release, a build is reproducible from `source_ref`, and the `0..g` RPM release keeps upstream builds below distribution packages without an `Epoch`. * Saving a PDF now processes one page at a time and page images are decoded on demand, so a document with many high-resolution pages no longer keeps every decoded page in memory at once. Previously a save decoded and held every page (roughly 66 MB per page), so a 330-page scan could exhaust the process's memory and be killed; such documents now save within a single page's working set. ## 3.0.24 (2026-10-17) * OCR text is now aligned to its baseline instead of being vertically centred in each bounding box, so words with and without descenders line up correctly with the scanned image. * hOCR font metrics (x_size, x_height, x_ascenders, x_descenders, x_fsize) are now preserved when loading and saving documents. * A horizontal or vertical split view is now shown on startup and the imported image appears immediately, instead of the split panes never being attached (the tabbed view stayed in place) so the image only appeared after cycling through the view modes. The split divider's position is remembered across sessions. * "Clear OCR" no longer fails with an internal error. It now removes the OCR text layer from every selected page, saves the change so it survives a reload, and makes clearing several pages at once a single undoable step (one Undo restores all of them). * Saving a PDF with "Automatic" compression now produces much smaller files for the same scan: bilevel pages are encoded as CCITT Group 4, pages with transparency use lossless Flate, and colour/grayscale pages use JPEG. * The JPEG quality setting in the Save dialog now affects PDF output, so lowering it reduces the file size. An imported JPEG saved with "Automatic" compression is still embedded unchanged. * The Save dialog gained two new PDF compression choices: "JPEG 2000" for smaller continuous-tone images and, when the `jbig2` program is installed, "JBIG2" for roughly half-size bilevel (black-and-white) pages. Both are opt-in; "Automatic" behaves as before. * A new "Bilevel compression" preference (Edit → Preferences) chooses the codec "Automatic" uses for black-and-white pages: CCITT Group 4 (the default) or JBIG2 when the encoder is available. It leaves colour and transparent pages unaffected. * The PDF "Compression" menu no longer offers "LZW" or "G3". Both were misleading: "LZW" always produced the same Flate output as the "Flate" option, and "G3" produced CCITT Group 4 like the "G4" option. A saved preference for one of the removed options keeps its output (Flate or Group 4) automatically. * Text you add manually to the OCR text layer (drawing a rectangle and typing a word) is now included in the text layer of the saved PDF. Previously such manually added words were silently dropped, so the saved PDF's searchable text contained only the automatic OCR output. * You can now draw the first selection rectangle in a layer pane (text layer or annotations) on a page that has not been OCRed, or whose layer is empty, so a text layer can be started by hand before OCR is ever run. Previously drawing was disabled until the pane already had a layer, which only OCR (or a prior slice) could provide. * A focused OCR text slice can now be rotated 90 degrees clockwise or anticlockwise from the text layer controls, so vertical text (e.g. rotated index columns) that tesseract read wrongly can be authored by hand: the corrected glyphs are drawn and saved at the rotated angle, and the box itself stays axis-aligned. Slices added while a slice is rotated inherit that rotation, letting you type a run of vertical text in one go. * Reversed the order of the rotate controls, both for the page rotate icons in the toolbar and for the text in the layer editor. * A word you add to the OCR text layer is now oriented automatically from the rectangle you draw: a tall, narrow selection becomes vertical text and a wide, short one stays upright, so a box drawn for a vertical column renders legibly instead of as shrunken horizontal text. This also applies to the first slice that creates a new text layer and to duplicated slices, unless a rotation is already inherited. * The selection rectangle now stays aligned between the image and text windows in a split view at every zoom level, and when the scan has a non-square resolution or the display uses HiDPI scaling. Previously the rectangle in the text window drifted out of alignment as you zoomed in or out. * The text layer's sort criteria now takes effect as soon as you enter the editor. Previously it was shown as "Sort by confidence" but navigation actually went by position until you changed the selection and changed it back; now the selected order is applied immediately, so the first entry navigates the way the control says. * Accepting a correction in the text layer editor now moves on to the next slice in the current sort order, instead of staying on the one you just corrected. In a confidence-sorted pass this walks from the least to the most confident text automatically, so you no longer have to re-select the next slice after every fix. Correcting the final slice keeps it focused. ## 3.0.23 (2026-10-11) * The "+" (Add) control in the text layer and annotations editor is now disabled until a rectangle has been drawn or selected, and shows a tooltip explaining that one is needed, instead of silently doing nothing or placing a box at an unpredictable position. * You can now draw the selection rectangle directly in the text layer or annotations pane, as well as on the image. A single rectangle is shared between the image and the layer panes, so adding or correcting a slice no longer requires drawing it on the image tab first. * The selection rectangle is no longer remembered across application launches. A stale selection from a previous session is no longer drawn over a freshly opened document, so you can start selecting immediately instead of having to clear a leftover box first. The selection still persists while you work, e.g. across page changes, within a session. * Cropping no longer fails with a confusing internal error when the selection is invalid (e.g. has zero or negative width). An empty or corrupted selection is rejected with a clear message instead of leaving the page unchanged mid-operation. * A page that no longer exists in the document (for example one lost after an interrupted scan) is now removed from the page list the first time it fails to load, instead of being reported as missing on every display. The view shows a valid page (or clears if none remain), and the remaining pages are renumbered to stay consecutive. * Fix error preventing running OCR from Tools menu ## 3.0.22 (2026-10-10) * Optimize page display: creating a page pixbuf now uses pixel data directly instead of encoding the full-resolution image as a PNG to a temporary file, dramatically speeding up page switching for large color scans. * Fix saving a session (SDB) - selecting "scantpaper session" in the Save dialog now actually saves the full session database to the chosen location so it can be reopened and work resumed later. * Reopened `.sdb` sessions are now edited in place, as intended: the opened file is the working session and keeps your edits. Crash recovery no longer offers a stale snapshot of it, and saving or reopening a session no longer triggers an unsaved-pages warning on quit. * Allow the "Alternate rotation every 2nd page" toggle to be used with one-page-at-a-time flatbed scans (no scanner hardware button). Parity persists across consecutive scans until the toggle is explicitly turned off. * Fix text-layer and annotation corrections not being undoable. Accepting, adding, duplicating or deleting a word or annotation is now a real undo step: one Undo brings the box back, one Redo re-applies the edit, and the page repaints with the restored layer. Previously Undo only reset the view. * Editing a page's text, annotations or resolution now marks it unsaved again, so quitting after the edit offers to save it; undoing the edit restores the page's earlier saved/unsaved state. * Undo a resolution change applied to several pages from the Properties dialog in a single step, instead of one undo per page. * Fix the text-layer and annotation editor keeping a box selected from the old page after switching pages, so a following accept or delete can no longer act on a stale box. ## 3.0.21 (2026-10-04) * Undo and redo now treat an operation applied to several pages at once (e.g. rotating or cropping a selection) as a single step: one Undo reverts every affected page together, and one Redo re-applies the whole operation, instead of requiring one undo per page. * No longer log a spurious "unable to use None for temporary storage" warning when no temporary directory has been configured; the system default is used silently, and the warning is only shown when a configured directory has to be replaced. * Fix the selection rectangle disappearing when moving between pages: it is now re-applied against the full-resolution page image, so it stays visible (bounded to each page) and you can preview where a crop will land before applying it. * Fix cancelling a scan sometimes appearing to do nothing. The application could be left showing the job as still running, never reporting that it had finished or been cancelled, with no way out of that state but closing the window. A progress update that fails is now reported as an error instead, and cancelling always completes. * Fix PDFs imported from another PDF (or any page whose text layer has no paragraph or line grouping) losing their searchable text layer when saved: the saved PDF now embeds an extractable, searchable text layer again instead of a blank one. Previously such pages saved with no usable text at all. * Fix deleting a box in the text layer or annotations window not removing its text from the saved PDF/DjVu. Deletions now remove the word from the stored text layer, including when the last word of a line, paragraph or column is removed, while the page keeps its own geometry. * Fix a corrected box still being drawn with its old text. The page now repaints with the corrected text, and the corrected text is the one written to the saved output. * Fix emptying a box being rejected and springing back to its previous text. Clearing a box's text and accepting now deletes the box and moves the editor on to the next one, in both the text layer and the annotations window. * Fix the published openSUSE Leap 16 RPMs not installing. The package names now follow the distribution's own `python313-*` scheme, so the artifact filenames changed: `python3-sane`, `python3-tesserocr`, `python3-cysignals`, `python3-iso639`, `python3-pdfminer.six`, `python3-img2pdf`, `python3-ocrmypdf`, `python3-setuptools-scm` and `python3-hatch-vcs` are now `python313-sane`, `python313-tesserocr`, `python313-cysignals`, `python313-python-iso639`, `python313-pdfminer.six`, `python313-img2pdf`, `python313-ocrmypdf`, `python313-setuptools-scm` and `python313-hatch-vcs`. Download the whole new set: RPM files from an earlier release are superseded and will not satisfy the new dependencies. ## 3.0.20 (2026-09-27) * Accept fractional values in the numeric spin-button scan options (e.g. the scan-area fields `br-x`, `br-y`): typing a value with the locale's decimal separator (e.g. `115,2` mm in a German locale) now keeps the fraction instead of rounding to whole millimetres. Whole-millimetre values still display without trailing zeros, and the arrow buttons step by whole units while preserving the typed fraction. * Enforce the locale's decimal and grouping separators strictly when typing numbers: in a comma locale a period is now rejected (`115.2` no longer silently passes) instead of being accepted alongside the comma. * Extend the same fractional entry to the page-properties resolution fields and to the blank/dark-page and unpaper white/black thresholds: they accept the locale's decimal separator, reject a non-locale separator or non-numeric text at commit time (the previous value is restored), and display whole values without trailing zeros (e.g. `300` dpi, `0,9` threshold). * Fixed cancel button in paper size editor * For people destructively scanning books - a new "Alternate rotation every 2nd page" option in the scan dialog's Postprocessing tab for flatbed batches: odd pages are rotated by the configured facing angle and even pages by that angle plus 180 degrees, so the fronts and flipped-over backs of a destructively scanned book all end up upright without rotating every other page by hand afterwards. * + builds for Fedora 43 and 44 to CI/CD. Closes: #63 (Fedora) * + builds for OpenSuse Leap 16 to CI/CD. Closes: #72 (openSUSE Leap 16 GNOME) * Updated Brazilian Portuguese, Catalan, Czech, Danish, Dutch, French, German, Italian, Japanese, Korean, Polish, Portuguese, Russian, Simplified Chinese, Spanish, Swedish, & Ukrainian translations ## 3.0.19 (2026-09-19) * Log automatic corrections of wrongly typed configuration values instead of showing them in the start-up message; only values that cannot be corrected are reported to the user. * Fix PDF encryption to also work with Ubuntu Jammy's (22.04) qpdf 10 * Fix scan options saved by gscan2pdf 2.x (with no frontend list) being silently ignored at startup and then wiped from the config file. The stored options are now applied to the scan dialog and kept on save. * Fix startup warning about a legacy `null` image type from gscan2pdf 2.x configs by migrating it to the default `pdf`. * Fix applying a scan-options profile getting stuck in a reload loop and ending in a reload-recursion error when the scanner driver reverts or couples options (e.g. page size and scan area). Such an option is now set at most twice and then dropped for that apply, so the scan proceeds with the options the driver accepts. * Keep sub-millimetre precision (e.g. 115.2 mm instead of 115 mm) when defining custom paper sizes in the scan dialog. Fractional dimensions are now accepted and preserved, while whole-millimetre sizes are still shown without a trailing `.0`. * Fix the paper-sizes editor showing the Width, Height, Left and Top values blank: the dimension cells now display their measurements again (e.g. 210, 297, 115.2). * Show and accept the locale's decimal separator (e.g. 115,2 in a German locale) when entering fractional paper dimensions; a period still works too. * Show and accept the locale's decimal separator for numeric scan-option values too: list/combobox items (e.g. resolutions) and free-text entry defaults display with the locale separator at full driver precision, and typed comma values are sent to the backend as canonical numbers. Applying a scan profile written with a decimal comma no longer crashes. * Fix the Postprocessing rotate controls always defaulting to 90° in the scan dialog, ignoring the "rotate facing" and "rotate reverse" values stored in the configuration file (e.g. 270°). The configured angles are now honoured when the dialog opens. ## 3.0.18 (2026-09-11) * Generate thumbnails with high-quality resampling * Rescue intact settings from an unreadable configuration file instead of resetting everything to defaults, keep the original as a backup at `~/.config/scantpaperrc.old`, and tell the user what was kept. Wrongly typed values are corrected when possible, and the broken file is never silently overwritten. * Fix the scan dialog crashing on startup for profiles written by gscan2pdf 2.x that contain scanner options but no frontend options. Such profiles are now loaded with empty frontend options instead of aborting the application. * Fix a startup error when crashed-session files are found in the temporary directory: the restore dialog could not be built because the file paths were passed to the list as native paths instead of strings. ## 3.0.17 (2026-09-05) * Fix 0-based page numbering after undo * Render with high-quality interpolation at all zoom levels. A faster filter is used only while panning or zooming. Closes #75 (Rendering quality is significantly worse than gscan2pdf for A4) * Make the progress bar's Cancel button actually stop the running background work. Cancelling an import, save or conversion now kills the underlying helper processes (rather than just hiding the bar), so the job stops promptly and the application remains usable: pages already imported are kept and further files are not processed. * Speed up drag-and-drop page reordering in large documents. Reordering a page no longer duplicates it through the database (image blobs are left untouched); the pages are moved in place as a single undoable step, so dragging a page across a large scan is several times faster. * Update to Italian translation (thanks to Albano Battistella) ## 3.0.16 (2026-08-22) * Fix import creating an extra page when a PDF contains a soft mask or stencil image, e.g. a PDF created including an image with a transparent layer. Closes #43 (Added page when opening a PDF created from a transparent image) * Improve threshold tool behaviour for colour images. Closes #20 (threshold does poorly for colour images) * Optimise PDF pipeline tasks by storing a compact intermediate blob instead of always re-encoding to PNG * When importing many pages, continue to display thumbnail in image viewer to show the UI is alive, but only show the last page at the correct resolution in order to allow the UI to keep up. * Refuse to save PDFs estimated to exceed 2 GiB, since downstream tools (img2pdf, Ghostscript, pikepdf) overflow 32-bit file offsets at that size and produce corrupt output. Documented the three known overflow bugs in README.md for future reference. * Fix Undo resurrecting pages that existed before "New File" was used. New File now clears the document through the document thread like ordinary page deletion, making the clear itself an undoable step. Closes #74 (Undo brings old pages back to life) * Fix ADF/duplex scans importing only the first page on some Brother scanners (e.g. the DS-740D), which prefetch the reverse side of a sheet and discard it when the session is cancelled between pages. The session is no longer cancelled between pages when scanning from a feeder; it is still cancelled at the end of the batch, when the requested page count is reached, on error, or when the scan is cancelled. Multi-page flatbed batches now follow gscan2pdf semantics, honouring the "Force new scan job between pages" preference. Closes #73 (Second page is ignored in duplex scanning mode) ## 3.0.15 (2026-08-09) * Add new metadata to suggestions * Match metadata suggestions against any part of the entered text, ordering prefix matches above the rest * Ensure progress bar is hidden after scanner error * Fix untitled title in metadata * Change page numbering to always run from 1-n with no gaps. Scanning reverse pages therefore interleaves. Please see README.md for more details. ## 3.0.14 (2026-07-31) * Rewrite canvas widget displaying text layer to vastly improve performance and also to allow later upgrade to Gtk4. ## 3.0.13 (2026-07-26) * Implement thread dispatch without polling to improve app responsiveness * Fix AttributeError and HocrTransform error ## 3.0.12 (2026-07-19) * Implement adaptive thread dispatch polling to improve app responsiveness ## 3.0.11 (2026-07-11) * Fix previous profile not being set on startup * When adding a user-defined tool in preferences, update the combobox in the scan window immediately if the tool is executable and otherwise display an error. * Fix various bugs causing the progress bar not to display properly ## 3.0.10 (2026-07-04) * Plumb the OCRmyPDF progress into the GUI progress bar * Fix parsing scan options when name is None. Thanks to Christoph Moench-Tegeder for the patch. ## 3.0.8 (2026-06-20) * When opening the crop dialog, if the pan tool is selected, switch to the combined tool. Closes #64 (Opening crop dialog should automatically activate rectangular select or combined tool) * Various performance improvements for the image viewer * Fix mismatch between image tool and menu/toolbar on start-up. Closes #62 (After initial scan, cursor is always in move mode) * Fix saving and loading session database and dealing with crashed sessions. * Fix displaying local documentation ## 3.0.7 (2026-05-10) * Fix bug checking for helper tools. Closes #65 (Graphicsmagick false positive) * Fix traceback during crop caused by selection being None * Fix updating date metadata between save operations. Closes #68 (Datestamps saving wrong) ## 3.0.6 (2026-05-03) * Improve error messages when helper tools not found * Fix bugs adding and removing paper sizes * More improvements to UI responsiveness showing hidden text layer * Fix race condition causing occasional hang at startup ## 3.0.5 (2026-04-18) * Refactor hOCR parsing into thread to improve UI responsiveness * Batch insertions of words in hidden text layer to improve UI responsiveness ## 3.0.4 (2026-04-11) * Fix error running OCR from dialog. Closes #59 (OCR Fails: `int` object has no attribute `get_size`) * Eliminate extra tesseract call when saving PDFs. * Fix message 'Some pages have not been saved' when all pages have been saved Closes #61 * Fix delete button in Edit/Preferences/User-defined tools * Wrap post-save tool combobox in a scrollbar in case it is too long ## 3.0.3 (2026-04-03) * + `Error during device I/O` to errors triggering "Error opening the last device used" dialog * Question caching device list including libusb devices * Fix race conditions when new actions triggered before previous finished Closes #55 (Save error) ## 3.0.2 (2026-03-27) * Also support old method of specifying multiple CLI arguments. Closes #56 (--import-all only importing the first image) Closes #57 (Support more than one instance of a command-line option) Thanks to Chris Mayo for the patch. * Fix cursor position when typing datetime data in save dialog. Closes #54 (Save Dialogue: Reversed order of Digits) * Suppress warnings like: `Warning: ../../../gobject/gsignal.c:2685: instance '0x29948460' has no handler with id '117'` when processes finish * Suppress warnings like: `Warning: value "100" of type 'gint' is invalid or out of range for property 'page-number-start' of type 'gint'` when importing more than 99 images * Raise `RuntimeError` if `file` returns no stdout * Don't complain about empty hocr files being invalid * Fix roundtrip for PDF without text layer ## 3.0.1 (2026-03-15) * Fix selection changed callback. Closes #45 Thanks to Chris Mayo for the patch. * Fix opening an encrypted PDF. Closes #46 Thanks to Chris Mayo for the patch. * Improve session logs. Closes #47 Thanks to Chris Mayo for the patch. * Fix image context menu mnemonics. Closes #49 Thanks to Chris Mayo for the patch. * Fix missing message when PDF has no images. Closes #50 Thanks to Chris Mayo for the patch. * Fix updating image resolution via property dialog. Closes #44 (AttributeError when changing resolution) * Replace deprecated IconSet and IconFactory. Closes #3 Thanks to Chris Mayo for the patch. * Use qpdf to encrypt PDFs instead of pdftk. Closes #48 Thanks to Chris Mayo for the patch. * Fix messages dialog text width. Closes #52 Thanks to Chris Mayo for the patch. * Rename scanner icon to scan. Closes #53 Thanks to Chris Mayo for the patch. ## 3.0.0 (2026-03-11) * + minimal en_US translation to prevent warnings. Closes #41 * Be graceful if the previous current working directory no longer exists. Closes #42 ## 3.0.0-rc5 (2026-03-08) * Fix search path for icon files. * Remove unnecessary dependency on xz. Closes #40 Thanks to Chris Mayo for the patch. ## 3.0.0-rc4 (2026-03-06) * Fix copying or deleting a word prevents saving PDF. Closes #35 Thanks to Chris Mayo for the patch. * Fix error clicking on an empty canvas. Closes #36 * Don't include PDF metadata in text layer. * Fix failure to start when pdftk is not installed. Closes #37 Thanks to Chris Mayo for the patch. * Enforce default image type. Closes #39 (Save Dialog: No Document type selected by default) ## 3.0.0-rc3 (2026-03-04) * Fixed error cancelling scan. Closes #32 Thanks to Chris Mayo for the patch. * Fixed error saving PDF after correcting text layer. Closes #33 ## 3.0.0-rc2 (2026-03-03) * Fixed error printing. Closes #15 * Fixed error updating text layer. Closes #29 * Fixed error pressing Encrypt PDF ok/cancel buttons. Closes #30 ## 3.0.0-rc1 (2026-03-01) * Rewrite of gscan2pdf in Python * The UI is basically identical to gscan2pdf * scantpaper uses OCRmyPDF as the backend to create PDFs. These means we get PDF/A out of the box. * scantpaper uses an SQLite database to store all session data. This eliminates the need for gscan2pdf's big hairy temporary directory, and does not suffer from gscan2pdf's requirement to hold an open file handle for each page, which meant that some users ran out of file handles when they created several hundred pages. * Storing sessions in a database also means that the number of undo/redo steps is only limited by available storage. gscan2pdf can undo/redo one step. carygravel-scantpaper-c8a0fa9/dev/000077500000000000000000000000001527133624300172705ustar00rootroot00000000000000carygravel-scantpaper-c8a0fa9/dev/build_ppa.py000066400000000000000000000361631527133624300216120ustar00rootroot00000000000000"""Build per-series Ubuntu PPA source packages from the synced Debian tree. Run from a clean salsa checkout after the Debian sync has been committed: python3 dev/build_ppa.py --all python3 dev/build_ppa.py --series noble --dry-run Each series is built from a throwaway tree of the Debian branch, with the series' ``debhelper-compat`` and any configured test-dependency removals from ``packaging/ubuntu/suites.toml``, the version ``-0~ppa1`` and ``Distribution: ``. The pristine-tar orig tarball is reused. Sources are built unsigned. ``debsign`` and ``dput`` are printed, never run. """ from __future__ import annotations import argparse import re import shutil import subprocess import sys import tarfile from dataclasses import dataclass from pathlib import Path from typing import Final, NoReturn try: import tomllib except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback import tomli as tomllib MATRIX: Final = "packaging/ubuntu/suites.toml" CHANGELOG: Final = "debian/changelog" CONTROL: Final = "debian/control" TESTS_CONTROL: Final = "debian/tests/control" COMPAT_RE: Final = re.compile(r"debhelper-compat \(= \d+\)") VERSION_RE: Final = re.compile(r"(?m)^scantpaper \(([^)]+)\)") DISTRIBUTION_RE: Final = re.compile(r"(?m)^scantpaper \([^)]+\) \S+;") class PpaError(Exception): """Raised when a series source package cannot be prepared.""" def _fail(message: str) -> NoReturn: """Raise a build error with a message built by the caller.""" raise PpaError(message) @dataclass(frozen=True) class SeriesSpec: """The differences a series needs.""" name: str version: str compat: int test_depends_to_drop: tuple[str, ...] @dataclass(frozen=True) class TreeSource: """Where a series tree comes from and where its package is written.""" root: Path branch: str upstream: str series_dir: Path orig: Path @dataclass(frozen=True) class Context: """State shared by every selected series.""" root: Path branch: str upstream: str output: Path orig: Path dput_target: str dry_run: bool def _run(root: Path, *args: str) -> subprocess.CompletedProcess[str]: """Run a command in ``root`` without raising.""" return subprocess.run( [*args], cwd=root, capture_output=True, check=False, text=True, ) def _git(root: Path, *args: str) -> str: """Run git in ``root`` and return stripped output.""" result = _run(root, "git", *args) if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() _fail(f"git {' '.join(args)} failed: {detail or 'unknown error'}") return result.stdout.strip() def _matrix(path: Path) -> dict[str, dict[str, object]]: """Read and validate the suite matrix.""" try: data = tomllib.loads(path.read_text(encoding="utf-8")) except (OSError, tomllib.TOMLDecodeError) as error: message = f"cannot read {path}: {error}" raise PpaError(message) from error series = data.get("series") if not isinstance(series, dict): _fail(f"{path} has no [series.] tables") validated: dict[str, dict[str, object]] = {} for name, settings in series.items(): if not isinstance(settings, dict): _fail(f"series {name} is not a table") compat = settings.get("debhelper-compat") if not isinstance(compat, int) or isinstance(compat, bool): _fail(f"series {name} has no integer debhelper-compat") drops = settings.get("drop-test-depends", []) if not isinstance(drops, list) or not all( isinstance(item, str) for item in drops ): _fail(f"series {name} has an invalid drop-test-depends") validated[str(name)] = {"debhelper-compat": compat, "drops": drops} return validated def _upstream_version(root: Path) -> str: """Return the upstream part of the synced Debian version.""" try: text = (root / CHANGELOG).read_text(encoding="utf-8") except OSError as error: message = f"cannot read {root / CHANGELOG}: {error}" raise PpaError(message) from error match = VERSION_RE.search(text) if match is None: _fail("debian/changelog has no scantpaper entry") version = str(match.group(1)) if "-" not in version: _fail(f"version {version} has no Debian revision; run the sync first") return version.rsplit("-", 1)[0] def _orig_tarball(root: Path, upstream: str) -> Path: """Find or export the orig tarball for ``upstream``.""" candidates = sorted(root.parent.glob(f"scantpaper_{upstream}.orig.tar.*")) if candidates: return candidates[0] if shutil.which("gbp") is None: _fail( f"no orig tarball for {upstream}; install gbp or place one next to " "the checkout" ) result = _run(root, "gbp", "export-orig", "--pristine-tar") if result.returncode != 0: detail = (result.stderr or result.stdout).strip() _fail( f"cannot create the orig tarball for {upstream}: " f"{detail or 'unknown error'}" ) candidates = sorted(root.parent.glob(f"scantpaper_{upstream}.orig.tar.*")) if not candidates: _fail(f"gbp export-orig did not create an orig tarball for {upstream}") return candidates[0] def _extract_tree(root: Path, branch: str, destination: Path) -> None: """Extract the committed branch into a throwaway tree.""" destination.mkdir(parents=True) process = subprocess.Popen( ["git", "-C", str(root), "archive", "--format=tar", branch], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stream = process.stdout errors = process.stderr if stream is None or errors is None: # pragma: no cover - both pipes requested process.kill() _fail("git archive did not expose its output streams") try: with tarfile.open(fileobj=stream, mode="r|") as archive: archive.extractall(destination, filter="data") except (OSError, tarfile.TarError) as error: process.kill() process.wait() message = f"cannot extract {branch}: {error}" raise PpaError(message) from error detail = errors.read().decode(errors="replace").strip() if process.wait() != 0: _fail(f"git archive failed for {branch}: {detail or 'unknown error'}") def _rewrite_changelog(path: Path, version: str, series: str) -> None: """Replace the top entry's version and distribution.""" text = path.read_text(encoding="utf-8") replaced, count = DISTRIBUTION_RE.subn( f"scantpaper ({version}) {series};", text, count=1 ) if count != 1: _fail(f"cannot rewrite the top entry of {path}") path.write_text(replaced, encoding="utf-8") def _rewrite_control(path: Path, compat: int) -> None: """Set the series' debhelper compatibility level.""" text = path.read_text(encoding="utf-8") replaced, count = COMPAT_RE.subn(f"debhelper-compat (= {compat})", text) if count != 1: _fail(f"expected one debhelper-compat in {path}, found {count}") path.write_text(replaced, encoding="utf-8") def _drop_test_depends(path: Path, depends: tuple[str, ...]) -> None: """Remove test dependencies the series does not carry.""" text = path.read_text(encoding="utf-8") for name in depends: pattern = re.compile(rf"(?m)^ {re.escape(name)},?$\n?") text, count = pattern.subn("", text) if count == 0: _fail(f"{name} is not in {path}") path.write_text(text, encoding="utf-8") def _provides(text: str) -> set[int]: """Return the compat levels a debhelper package advertises.""" return {int(value) for value in re.findall(r"debhelper-compat \(= (\d+)\)", text)} def _verify_series(series: str, compat: int) -> None: """Check the configured compat against the series' archive.""" runtime = shutil.which("podman") or shutil.which("docker") if runtime is None: _fail("--verify needs podman or docker") script = "apt-get update -qq >/dev/null 2>&1 && apt-cache show debhelper" result = subprocess.run( [runtime, "run", "--rm", f"ubuntu:{series}", "bash", "-c", script], capture_output=True, check=False, text=True, ) if result.returncode != 0: detail = (result.stderr or result.stdout).strip() _fail(f"cannot query ubuntu:{series}: {detail or 'unknown error'}") available = _provides(result.stdout) if compat not in available: _fail(f"{series} does not provide debhelper-compat {compat}") print(f"{series}: debhelper-compat {compat} verified") def _spec(series: str, settings: dict[str, object], upstream: str) -> SeriesSpec: """Build a validated series specification.""" compat = settings.get("debhelper-compat") if not isinstance(compat, int) or isinstance(compat, bool): _fail(f"series {series} has no integer debhelper-compat") drops = settings.get("drops", []) if not isinstance(drops, list): drops = [] return SeriesSpec( name=series, version=f"{upstream}-0~ppa1{series}", compat=compat, test_depends_to_drop=tuple(str(item) for item in drops), ) def _prepare_tree(source: TreeSource, spec: SeriesSpec) -> Path: """Create the series tree with its differences applied.""" tree = source.series_dir / f"scantpaper-{source.upstream}" if tree.exists(): shutil.rmtree(tree) _extract_tree(source.root, source.branch, tree) _rewrite_changelog(tree / CHANGELOG, spec.version, spec.name) _rewrite_control(tree / CONTROL, spec.compat) if spec.test_depends_to_drop: _drop_test_depends(tree / TESTS_CONTROL, spec.test_depends_to_drop) shutil.copy2(source.orig, source.series_dir / source.orig.name) return tree def _build(tree: Path) -> None: """Build an unsigned source package.""" result = _run(tree, "debuild", "-S", "-us", "-uc", "-sa") if result.returncode != 0: tail = "\n".join((result.stdout + result.stderr).splitlines()[-15:]) _fail(f"debuild failed in {tree}:\n{tail}") def _changes_file(series_dir: Path) -> Path: """Return the single .changes produced for a series.""" changes = sorted(series_dir.glob("*.changes")) if len(changes) != 1: _fail(f"expected one .changes in {series_dir}, found {len(changes)}") return changes[0] def _print_commands(changes: Path, target: str) -> None: """Print the signing and upload commands.""" print("Next steps:") print(f" debsign {changes}") print(f" dput {target} {changes}") def _output_dir(root: Path, configured: Path) -> Path: """Resolve the output directory against the checkout.""" return configured if configured.is_absolute() else root / configured def _load( args: argparse.Namespace, ) -> tuple[Path, dict[str, dict[str, object]], list[str]]: """Validate the checkout and return the root, matrix and selection.""" root = Path(str(args.root)).resolve() matrix_name = str(args.matrix) matrix_path = root / matrix_name if not (root / CHANGELOG).is_file(): _fail(f"no {CHANGELOG}; this is not a salsa checkout") if not matrix_path.is_file(): _fail(f"no {matrix_name}; add the suite matrix first") if _git(root, "diff", "--name-only", "HEAD", "--", "debian", "packaging"): _fail("uncommitted packaging changes; commit or clean the Debian sync first") _git(root, "rev-parse", "--verify", str(args.debian_branch)) matrix = _matrix(matrix_path) selected = ( sorted(matrix) if args.all else [str(name) for name in (args.series or [])] ) if not selected: _fail("select --all or at least one --series") unknown = [name for name in selected if name not in matrix] if unknown: _fail("unknown series: " + ", ".join(unknown)) return root, matrix, selected def _build_series(context: Context, spec: SeriesSpec) -> None: """Prepare, build and report one series.""" series_dir = context.output / spec.name series_dir.mkdir(parents=True, exist_ok=True) tree = _prepare_tree( TreeSource( root=context.root, branch=context.branch, upstream=context.upstream, series_dir=series_dir, orig=context.orig, ), spec, ) if context.dry_run: print(f"{spec.name}: prepared {tree} for {spec.version}") _print_commands( series_dir / f"scantpaper_{spec.version}_source.changes", context.dput_target, ) return _build(tree) changes = _changes_file(series_dir) shutil.rmtree(tree) print(f"{spec.name}: built {changes}") _print_commands(changes, context.dput_target) def build(args: argparse.Namespace) -> None: """Prepare and optionally build the selected series.""" root, matrix, selected = _load(args) if args.verify: for series in selected: spec = _spec(series, matrix[series], "0") _verify_series(series, spec.compat) upstream = _upstream_version(root) context = Context( root=root, branch=args.debian_branch, upstream=upstream, output=_output_dir(root, args.output_dir), orig=_orig_tarball(root, upstream), dput_target=args.dput_target, dry_run=args.dry_run, ) context.output.mkdir(parents=True, exist_ok=True) for series in selected: _build_series(context, _spec(series, matrix[series], upstream)) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--root", type=Path, default=Path.cwd(), help="salsa checkout (default: current directory)", ) parser.add_argument( "--matrix", default=MATRIX, help=f"suite matrix relative to the root (default: {MATRIX})", ) parser.add_argument( "--debian-branch", default="debian/master", help="branch to package (default: debian/master)", ) parser.add_argument("--series", action="append", help="series to build") parser.add_argument("--all", action="store_true", help="build every series") parser.add_argument( "--output-dir", type=Path, default=Path("build/ppa"), help="directory for the source packages (default: build/ppa)", ) parser.add_argument( "--dput-target", default="gscan2pdf-ppa", help="dput target to print (default: gscan2pdf-ppa)", ) parser.add_argument( "--verify", action="store_true", help="check each compat against its series archive in a container", ) parser.add_argument( "--dry-run", action="store_true", help="prepare the trees and print the commands without building", ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: """Build the selected series, returning a process exit status.""" args = parse_args(argv) try: build(args) except PpaError as error: print(f"error: {error}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main()) carygravel-scantpaper-c8a0fa9/dev/check_launchpad.py000066400000000000000000000721341527133624300227450ustar00rootroot00000000000000"""Report Launchpad translation changes against a committed baseline. Launchpad exposes the state of the `scantpaper` translation template and of each language without authentication, but nothing tells the maintainer when that state has moved between releases. This tool answers "has anything changed on Launchpad since we last pulled the catalogs?" by comparing the current state against a snapshot committed as `po/launchpad-state.json`. It watches two independent triggers, each read from the only public surface that carries it: * the **template** (the message set / `.pot`), from the Launchpad JSON API resource for the template, using `date_last_updated` and the HTTP entity tag with a conditional request (an unchanged template costs a `304`); * **per-language translation activity**, from the series translation page, whose per-language last-changed timestamps exist nowhere else. That page is read on every run, because a translation can change without the template changing. The tool never downloads a catalog: every Launchpad `+export` endpoint requires a logged-in session. It also never authenticates. It is advisory by default: finding a change is not an error, so the exit status is zero either way and CI opts into `--fail-on-change`. A state that genuinely could not be read is reported as "could not determine" and exits non-zero, which is deliberately distinct from "unchanged". The baseline is a sampled snapshot of what Launchpad reported, not something derived from git, and is advanced only by an explicit `--update` run after the catalogs have been synced (see `release_procedure.md`). Usage: python3 dev/check_launchpad.py [--json] [--fail-on-change] python3 dev/check_launchpad.py --update [--dry-run] python3 dev/check_launchpad.py --record-pot [--pot PATH] [--dry-run] """ from __future__ import annotations import argparse import datetime import hashlib import http.client import importlib.util import json import re import subprocess import sys import urllib.parse from dataclasses import dataclass from html.parser import HTMLParser from pathlib import Path from typing import TYPE_CHECKING, cast from typing_extensions import override if TYPE_CHECKING: from collections.abc import Callable REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_API_URL = "https://api.launchpad.net/devel/scantpaper/trunk/+pots/scantpaper" DEFAULT_HTML_URL = "https://translations.launchpad.net/scantpaper/trunk/+translations" DEFAULT_BASELINE = REPO_ROOT / "po" / "launchpad-state.json" SERIES = "trunk" POT = "scantpaper" USER_AGENT = ( "scantpaper-translation-check/0.1 (+https://github.com/carygravel/scantpaper)" ) HTTP_OK = 200 HTTP_NOT_MODIFIED = 304 REQUEST_TIMEOUT = 30 # The per-language link on the series page, from which the language code is # taken. The `$` keeps it from matching the `?show=...` links in the same row. _LANGUAGE_ANCHOR = re.compile(r"/\+pots/[^/]+/(?P[A-Za-z0-9_@.+-]+)/\+translate$") class LaunchpadError(Exception): """An error whose message is safe to show the user.""" @dataclass(frozen=True) class TemplateState: """The template's message-set state as Launchpad reports it.""" date_last_updated: str etag: str @dataclass(frozen=True) class LanguageState: """One language's activity as the series page reports it.""" last_changed: str untranslated: int suggestions: int @dataclass(frozen=True) class LocalPotState: """The message-set fingerprint of a local template.""" msgids: tuple[str, ...] digest: str @dataclass(frozen=True) class LocalPotResult: """How the local message set compares with the last uploaded pot.""" changed: bool added: list[str] removed: list[str] msgid_count: int @dataclass class CheckResult: """The outcome of comparing Launchpad against the baseline.""" template: TemplateState | None languages: dict[str, LanguageState] | None problems: list[str] template_changed: bool changed_languages: dict[str, LanguageState] new_languages: dict[str, LanguageState] local: LocalPotResult | None local_undetermined_reason: str | None @property def determined(self) -> bool: """Return True when every part of the upstream state was read.""" return not self.problems @property def changed(self) -> bool: """Return True when any upstream unit differs from the baseline.""" return ( self.template_changed or bool(self.changed_languages) or bool(self.new_languages) ) @property def local_pot_changed(self) -> bool | None: """Return True when the local template is stale, None when unknown.""" if self.local is None: return None return self.local.changed class _TranslationPageParser(HTMLParser): """Extract one record per language from the series translation page. The parser keys on the anchors the page emits (the per-language `…//+translate` link plus the optional `?show=untranslated` and `?show=new_suggestions` links) and on the `