pax_global_header00006660000000000000000000000064145366373200014523gustar00rootroot0000000000000052 comment=81d152a687fcd1d058e0968351f1a497ff7d4150 pympress-1.8.5/000077500000000000000000000000001453663732000134205ustar00rootroot00000000000000pympress-1.8.5/.github/000077500000000000000000000000001453663732000147605ustar00rootroot00000000000000pympress-1.8.5/.github/ISSUE_TEMPLATE/000077500000000000000000000000001453663732000171435ustar00rootroot00000000000000pympress-1.8.5/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000035331453663732000216410ustar00rootroot00000000000000--- name: Bug report about: Report a problem in pympress title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - OS: [e.g. Ubuntu] - Python version: [e.g. 3.9] - Pympress version: [e.g. 1.5.0] - Installation method: [e.g. source, pip, binary installer, chocolatey, copr, other package manager] **Debug information (see below for file locations)** - What is reported in pympress.log? - Does the problem still happen if you remove your config file? (You can just move the config file to a different location to be able to restore it after testing) **Additional context** Add any other context about the problem here. pympress-1.8.5/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000003121453663732000211270ustar00rootroot00000000000000blank_issues_enabled: true contact_links: - name: Discussions url: https://github.com/Cimbali/pympress/discussions about: To ask and answer questions that don’t fit in the categories above. pympress-1.8.5/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011301453663732000226630ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for pympress title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. pympress-1.8.5/.github/workflows/000077500000000000000000000000001453663732000170155ustar00rootroot00000000000000pympress-1.8.5/.github/workflows/deploy_doc_l10n.yml000066400000000000000000000043651453663732000225230ustar00rootroot00000000000000name: Update docs and translatable strings on: push: branches: - master jobs: strings: name: Upload translatable strings runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | sudo apt-get update -q sudo apt-get install -qy jq pip install -e .[babel] - name: Extract run: python setup.py extract_messages - name: Upload env: poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }} run: ./scripts/poedit.sh upload build: name: Generate docs needs: strings runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | sudo apt-get update -q sudo apt-get install -qy jq gobject-introspection libgirepository-1.0-1 gir1.2-gtk-3.0 gir1.2-glib-2.0 gir1.2-gstreamer-1.0 gir1.2-poppler-0.18 python3-pip python3-setuptools python3-wheel libgirepository1.0-dev vlc pip install pygobject pycairo .[build_sphinx] .[vlc_video] - name: Build env: poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }} run: | ./scripts/poedit.sh contributors python3 -m sphinx -bhtml docs/ build/sphinx/html tar czf pympress-docs.tar.gz -C build/sphinx/html/ . - name: Upload uses: actions/upload-artifact@v3 with: name: pympress-docs.tar.gz path: pympress-docs.tar.gz deploy: name: Deploy docs needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: repository: pympress/pympress.github.io token: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} ref: main - name: Download uses: actions/download-artifact@v3 with: name: pympress-docs.tar.gz path: . - name: Extract and push run: | tar xzf pympress-docs.tar.gz rm pympress-docs.tar.gz git add . git -c user.email=me@cimba.li -c user.name="${GITHUB_ACTOR}" commit -m "Github Action-built docs update" git push pympress-1.8.5/.github/workflows/draft_release.yml000066400000000000000000000323641453663732000223500ustar00rootroot00000000000000name: 'Draft release: build binaries and run tests' on: # On new tags, build binaries and srpm, run a full brew test, and create a draft release automatically. create: # We often mess up the automatic build. Allow to correct manually (optionally with different build numbers) workflow_dispatch: inputs: tag: description: 'Release tag for which to build' required: true jobs: checks: runs-on: ubuntu-latest outputs: tag: ${{ steps.name.outputs.tag }} release_id: ${{ steps.release.outputs.release_id }} steps: - name: Make tag name id: name run: | ref=${{ github.ref }} [ "${ref::10}" = 'refs/tags/' ] && tag=${ref:10} || tag=${{ github.event.inputs.tag }} echo tag=${tag#v} | tee -a $GITHUB_OUTPUT - name: Checkout code uses: actions/checkout@v3 with: path: pympress ref: ${{ github.event.inputs.tag || github.ref }} - name: Check tag matches python package version continue-on-error: true run: > env PYTHONPATH=pympress python3 -c "import importlib; assert importlib.import_module('pympress.__init__').__version__ == '${{ steps.name.outputs.tag }}'" - name: Install dependencies run: | sudo apt-get update -q sudo apt-get install -qy gettext - name: Check latest translations are included run: | ./pympress/scripts/poedit.sh download git -C pympress status --porcelain $i18n_files | tee status if [ -s status ]; then echo "Unversioned translation updates:" git diff -- $i18n_files exit 1 fi env: poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }} i18n_files: README.md pympress/share/locale/pympress.pot pympress/share/locale/*/LC_MESSAGES/pympress.po - name: Create a tarball of the release since we can not rely on getting it from the github release env: basename: pympress-${{ steps.name.outputs.tag }} run: tar czf $basename.tar.gz --exclude-vcs --exclude=.github --transform="s/^pympress/$basename/" pympress/ - name: Archive production artifacts uses: actions/upload-artifact@v3 with: name: tarball path: pympress-${{ steps.name.outputs.tag }}.tar.gz - name: Create draft GitHub Release id: release uses: softprops/action-gh-release@v1 with: draft: true tag_name: v${{ steps.name.outputs.tag }} files: pympress-${{ steps.name.outputs.tag }}.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} macos: name: Install and run test on mac needs: checks runs-on: macos-latest steps: - name: Configure brew repo continue-on-error: true run: | cd "`brew --repo homebrew/core`" # Credentials and remotes git remote add gh "https://github.com/Cimbali/homebrew-core/" git fetch gh # Attempt a rebase of changes in our repo copy git checkout --detach git rebase origin/master gh/master && git branch -f master HEAD || git rebase --abort # Now use master and update remote so we can use the bump-formula-pr git checkout master git log -1 --decorate - name: Run the audit continue-on-error: true run: | brew audit --strict pympress - name: Install latest run: | brew install pympress --only-dependencies brew install pympress --build-from-source --HEAD - name: Test help output if: always() run: | pympress --help - name: Test starting pympress and quitting from the command line if: always() run: | pympress --quit - name: Check the log has been created from the previous step if: always() run: | head ~/Library/Logs/pympress.log - name: Run the brew test if: always() run: | brew test pympress - name: Debug the brew test if: failure() run: | # NB. don’t use --debug which is interactive brew test --keep-tmp --verbose pympress | tee test.log tempdir=`sed -n '/Temporary files retained at/{n;p}' test.log` tree -a $tempdir srpm: name: Source RPM needs: checks runs-on: ubuntu-latest outputs: file: ${{ steps.srpm.outputs.file }} steps: - uses: actions/checkout@v3 - name: Install dependencies run: | sudo apt-get update -q sudo apt-get install -qy python3-rpm python3 -m pip install --upgrade pip python3 -m pip install setuptools wheel twine babel pysrpm rpmlint - name: Compile translations run: | python3 setup.py compile_catalog - name: Build source rpm id: srpm env: BUILD_DIR: build/rpm run: | mkdir srpm pysrpm --dest-dir=srpm/ --source-only . echo file=`find srpm/ -name '*.src.rpm' -printf '%P\n' -quit` | tee -a $GITHUB_OUTPUT - name: Upload to GitHub Release uses: softprops/action-gh-release@v1 with: draft: true tag_name: v${{ needs.checks.outputs.tag }} fail_on_unmatched_files: true files: srpm/${{ steps.srpm.outputs.file }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} - name: Check built RPM with rpmlint run: rpmlint srpm/${{ steps.name.outputs.file }} continue-on-error: true - name: Archive production artifacts uses: actions/upload-artifact@v3 with: name: source-rpm path: srpm/${{ steps.name.outputs.file }} rpmbuild: name: Build binary RPMs for Source RPM needs: srpm runs-on: ubuntu-latest container: fedora:latest steps: - name: Download from GitHub uses: actions/download-artifact@v3 with: name: source-rpm path: . - name: Install dependencies # NB. querying requires on a source rpm should give us build requires run: dnf install -y rpm-build `rpm -qR "${{ needs.srpm.outputs.file }}" | awk '$1 !~ /^rpmlib(.*)/ {print $1}'` - name: Build binary from source spm id: build env: srpm: ${{ needs.srpm.outputs.file }} run: | rpm -q --qf "dest=`rpm --eval %{_rpmfilename}`\n" "$srpm" | tee -a $GITHUB_OUTPUT rpmbuild -D "_rpmdir ${PWD}" -ra "$srpm" suse-rpmbuild: name: Build Suse RPM end-to-end from spec to binary install needs: checks runs-on: ubuntu-latest container: opensuse/tumbleweed steps: - name: Install most basic dependencies run: zypper install -y rpm-build rpmlint osc - name: Download tarball uses: actions/download-artifact@v3 with: name: tarball path: . - name: Get the spec file from OpenBuildService run: | trap 'rm -f ./osc-config' EXIT && echo "$OPENBUILDSERVICE_TOKEN_SECRET" > ./osc-config osc --config ./osc-config co -o osc home:cimbali python-pympress sed -r ' s/^(Version: *)[0-9.]+$/\1${{ needs.checks.outputs.tag }}/ s/^(Source0: *pympress-)[0-9.]+(\.tar\.gz)/\1${{ needs.checks.outputs.tag }}\2/ ' osc/pympress.spec > pympress.spec env: OPENBUILDSERVICE_TOKEN_SECRET: ${{ secrets.OPENBUILDSERVICE_TOKEN_SECRET }} - name: rpmlint specfile continue-on-error: true run: rpmlint pympress.spec - name: Make a source rpm id: srpm run: | filename=`rpm -q --qf "%{name}-%{version}-%{release}.src.rpm" --specfile pympress.spec` echo "filename=$filename" | tee -a $GITHUB_OUTPUT rpmbuild -D "_sourcedir $PWD" -D "_srcrpmdir $PWD" -bs pympress.spec [ -f "$filename" ] # Check it’s the expected file name - name: rpmlint source rpm continue-on-error: true run: rpmlint ${{ steps.srpm.outputs.filename }} - name: Install build dependencies # NB. querying requires on a source rpm should give us build requires run: zypper install -y `rpm -qR "${{ steps.srpm.outputs.filename }}"` - name: Build binary from source spm id: build run: | filename=`rpm -q --qf "%{arch}/%{name}-%{version}-%{release}.%{arch}.rpm" "$srpm"` echo "filename=$filename" | tee -a $GITHUB_OUTPUT rpmbuild -D "_rpmdir ${PWD}" -ra "$srpm" env: srpm: ${{ steps.srpm.outputs.filename }} - name: rpmlint rpm continue-on-error: true run: rpmlint ${{ steps.build.outputs.filename }} - name: Install with runtime dependencies run: zypper install -y --allow-unsigned-rpm ${{ steps.build.outputs.filename }} - name: Run run: env PYMPRESS_HEADLESS_TEST=1 pympress --quit - name: Show log run: head ${XDG_CACHE_HOME:-$HOME/.cache}/pympress.log - name: Push changes to OpenBuildService run: | trap 'rm -f ./osc-config' EXIT && echo "$OPENBUILDSERVICE_TOKEN_SECRET" > ./osc-config cd osc/ osc="osc --config ../osc-config" $osc rm `sed -n 's/^Source0: *//p' pympress.spec` cp ../pympress-${{ needs.checks.outputs.tag }}.tar.gz ./ $osc add pympress-${{ needs.checks.outputs.tag }}.tar.gz cp ../pympress.spec ./ $osc ci -m "Update build to v${{ needs.checks.outputs.tag }}" env: OPENBUILDSERVICE_TOKEN_SECRET: ${{ secrets.OPENBUILDSERVICE_TOKEN_SECRET }} windows-build: name: Windows Binaries needs: checks runs-on: windows-latest defaults: run: shell: msys2 {0} strategy: matrix: include: - { arch: x86_64, msystem: MINGW64 } - { arch: i686, msystem: MINGW32 } steps: - name: Checkout code uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup msys and dependencies uses: msys2/setup-msys2@v2 with: msystem: ${{ matrix.msystem }} update: true install: >- git zip base-devel mingw-w64-${{ matrix.arch }}-jq mingw-w64-${{ matrix.arch }}-curl mingw-w64-${{ matrix.arch }}-gtk3 mingw-w64-${{ matrix.arch }}-cairo mingw-w64-${{ matrix.arch }}-poppler mingw-w64-${{ matrix.arch }}-python3 mingw-w64-${{ matrix.arch }}-python3-pip mingw-w64-${{ matrix.arch }}-python3-gobject mingw-w64-${{ matrix.arch }}-python3-cairo mingw-w64-${{ matrix.arch }}-python3-appdirs mingw-w64-${{ matrix.arch }}-python3-setuptools mingw-w64-${{ matrix.arch }}-python3-packaging mingw-w64-${{ matrix.arch }}-python3-cx_Freeze mingw-w64-${{ matrix.arch }}-python3-babel mingw-w64-${{ matrix.arch }}-python3-watchdog - name: Install ghostscript "base 35" fonts shell: msys2 {0} run: > curl -L https://sourceforge.net/projects/gs-fonts/files/latest/download | tar xzf - -C /${{ matrix.msystem }}/share/ - name: Install python-only dependencies run: | python3 -m pip install --disable-pip-version-check --upgrade pip python3 -m pip install python-vlc - name: Compile translations run: python3 setup.py compile_catalog - name: Build binary run: python3 setup.py --freeze build_exe - name: Make file basename id: name run: | echo file=pympress-${{ needs.checks.outputs.tag }}-${{ matrix.arch }} | tee -a $GITHUB_OUTPUT - name: Build installer run: python3 setup.py --freeze bdist_msi --target-name ${{ steps.name.outputs.file }}.msi --skip-build - name: Make portable install run: | cd build mv exe.* pympress cp ../pympress/share/defaults.conf pympress/pympress.conf zip -r ../dist/${{ steps.name.outputs.file }}.zip pympress/ cd - - name: Install pympress shell: pwsh run: | $installer = gci -path dist\* -include *.msi -name Start-Process msiexec.exe -Wait -NoNewWindow -ArgumentList ('/i "dist\{0}" /qn /norestart /L* installer.log' -f $installer) echo "::group::Installer log" get-content installer.log echo "::endgroup::" - name: Run pympress shell: pwsh run: | # Check pympress install dir is appended to one of the $PATH variables $dir = ( [System.Environment]::GetEnvironmentVariable("Path","Machine").split(";") + [System.Environment]::GetEnvironmentVariable("Path","User").split(";") ) | Select-String 'pympress' gci -path $dir -filter *exe Start-Process "$dir\pympress.exe" -Wait -NoNewWindow -ArgumentList "--quit" echo "::group::Pympress log" get-content "$env:LOCALAPPDATA\pympress.log" echo "::endgroup::" - name: Archive production artifacts uses: actions/upload-artifact@v3 with: name: dist-without-release path: | dist/*.zip dist/*.msi - name: Upload to GitHub Release uses: softprops/action-gh-release@v1 with: draft: true tag_name: v${{ needs.checks.outputs.tag }} fail_on_unmatched_files: true files: | dist/*.zip dist/*.msi env: GITHUB_TOKEN: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} pympress-1.8.5/.github/workflows/lint_pr.yml000066400000000000000000000032311453663732000212060ustar00rootroot00000000000000name: Linting on PR, with stricter rules on new code on: [pull_request] jobs: lint: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: [3.7, 3.8, 3.9, '3.10', '3.11'] steps: - name: Checkout PR merge commit uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 flake8-docstrings - name: Fetch pull request target branch run: git fetch origin ${{ github.base_ref }} - name: Lint with standard ignores on modified files shell: bash run: | flake8 . --count --show-source --statistics | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::error file=\1,line=\2,col=\3::/p;g' - name: Lint changes with flake8 shell: bash # Reduced list of ignores, applied on the changed lines only run: | git diff -z --name-only FETCH_HEAD -- '**.py' | xargs -r0 flake8 --exit-zero --ignore=D107,D200,D210,D413,E251,E302,E303,W504 > errors git diff FETCH_HEAD -U0 -- '**.py' | sed -rn -e '/^\+\+\+ /{s,^\+\+\+ ./,,;h}' -e '/^@@ /{G;s/^@@ -[0-9,]+ \+([0-9,]+) @@.*\n(.*)/\2,\1/p}' | ( while IFS=, read file start lines; do for (( l = start ; l < $start + ${lines:-1}; ++l )); do echo "^$file:$l:"; done; done ) > changed_lines # Invert return value, i.e. error iff matches ! grep -f changed_lines errors | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::error file=\1,line=\2,col=\3::/p;g' pympress-1.8.5/.github/workflows/lint_push.yml000066400000000000000000000017551453663732000215550ustar00rootroot00000000000000name: Linting on push on: [push] jobs: lint: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: [3.7, 3.8, 3.9, '3.10', '3.11'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 flake8-docstrings - name: Lint with flake8 # Full list of ignores, fail on errors. No Docs errors. run: | flake8 . --count --show-source --statistics --select=E,F,W,C | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::error file=\1,line=\2,col=\3::/p;g' shell: bash - name: Lint docstrings run: | flake8 . --count --show-source --statistics --select=D | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::warning file=\1,line=\2,col=\3::/p;g' shell: bash pympress-1.8.5/.github/workflows/publish_release.yml000066400000000000000000000221671453663732000227160ustar00rootroot00000000000000name: 'Publish package: upload to pypi, brew, obs, and copr' on: # When the draft release is converted to a public release, send out the binaries etc. to all the platforms release: types: [published] # Manual trigger workflow_dispatch: inputs: tag: description: 'Release tag for which to build' required: true pypi_upload: description: 'upload to pypi' required: false type: choice options: - upload - skip default: 'upload' jobs: pypi: runs-on: ubuntu-latest outputs: tag: ${{ steps.name.outputs.tag }} release: ${{ steps.name.outputs.release }} url: ${{ steps.info.outputs.url }} sha256: ${{ steps.info.outputs.sha256 }} steps: - name: Define name id: name run: | ref=${{ github.ref }} [ "${ref::10}" = 'refs/tags/' ] && tag=${ref:10} || tag=${{ github.event.inputs.tag }} if echo ${tag#v} | grep -qxE '[0-9]+(\.[0-9]+)*' ; then release=final; else release=prerelease; fi echo tag=${tag#v} | tee -a $GITHUB_OUTPUT echo release=$release | tee -a $GITHUB_OUTPUT - uses: actions/checkout@v3 with: ref: v${{ steps.name.outputs.tag }} - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine babel - name: Build catalogs and packages run: | python setup.py compile_catalog python setup.py sdist bdist_wheel - name: Upload env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} if: ${{ github.event_name == 'release' || github.event.inputs.pypi_upload == 'upload' }} run: | twine upload dist/* - name: Get info from pypi id: info env: tag: ${{ steps.name.outputs.tag }} run: | # Get releases from pypi, exiting with non-zero if expected version not found jq_script=".releases.\"${tag}\"[]? | select(.python_version == \"source\")" while ! curl -s https://pypi.org/pypi/pympress/json | jq -r -e "$jq_script" > lastsource.json ; do sleep 60 # be patient with pypi done echo url=`jq -r .url lastsource.json` | tee -a $GITHUB_OUTPUT echo sha256=`jq -r .digests.sha256 lastsource.json` | tee -a $GITHUB_OUTPUT - name: Run a check on the generated file run: | if ! jq -r '"\(.digests.sha256) dist/\(.filename)"' lastsource.json | sha256sum -c ; then echo '::warning:: Generated sdist file did not match pypi sha256sum' fi aur: name: Publish to AUR runs-on: ubuntu-latest steps: - name: Clone repo run: git clone https://github.com/Cimbali/pympress-pkgbuild aur-repo - name: Get info id: info run: | ref=${{ github.ref }} [ "${ref::10}" = 'refs/tags/' ] && tag=${ref:10} || tag=${{ github.event.inputs.tag }} tag=${tag#v} prev_pkgver=`awk -F= '$1 == "pkgver" {print $2}' aur-repo/PKGBUILD | tr -d "[()\"']"` if [[ "$prev_pkgver" = "$tag" ]]; then prev_pkgrel=`awk -F= '$1 == "pkgrel" {print $2}' aur-repo/PKGBUILD | tr -d "[()\"']"` else prev_pkrel=0 fi url="https://github.com/Cimbali/pympress/releases/download/v${tag}/pympress-${tag}.tar.gz" sha256=`curl -sL "$url" | sha256sum | awk '{ print $1 }'` printf '%s\n' "tag=$tag" "url=$url" "sha256=$sha256" "prev_pkgver=$prev_pkgver" "prev_pkgrel=$prev_pkgrel" | tee -a $GITHUB_OUTPUT - name: Update info run: | while read param value; do sed -i -r "s,^(\\s*$param ?=[('\" ]*)[A-Za-z0-9\${\}:/._-]+([ '\")]*)$,\1$value\2," aur-repo/.SRCINFO aur-repo/PKGBUILD done < ./ssh-key && chmod 0600 ./ssh-key ssh='ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i ../ssh-key' git -C aur-repo -c user.name=Cimbali -c user.email="me@cimba.li" commit -am "Update to v${{ steps.info.outputs.tag }}" git -C aur-repo -c core.sshCommand="$ssh" push git@github.com:Cimbali/pympress-pkgbuild.git "master:master" env: AUR_PRIVATE_KEY: ${{ secrets.AUR_PRIVATE_KEY }} copr: name: Download source RPM from release and upload to COPR and OpenBuildService runs-on: ubuntu-latest steps: - name: Install dependencies run: | sudo apt-get update -q sudo apt-get install -qy osc cpio rpm2cpio pandoc python3-m2crypto python3 -m pip install copr-cli - name: Get info id: info run: | ref=${{ github.ref }} [ "${ref::10}" = 'refs/tags/' ] && tag=${ref:10} || tag=${{ github.event.inputs.tag }} tag=${tag#v} url="https://github.com/Cimbali/pympress/releases/download/v${tag}/pympress-${tag}.tar.gz" sha256=`curl -sL "$url" | sha256sum | awk '{ print $1 }'` printf '%s\n' "tag=$tag" "url=$url" "sha256=$sha256" | tee -a $GITHUB_OUTPUT - name: Extract changes from release shell: bash env: tag: ${{ steps.info.outputs.tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} run: | curl -s -u "Cimbali:$GITHUB_TOKEN" -H "X-GitHub-Api-Version: 2022-11-28" -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/Cimbali/pympress/releases" -o - | jq ".[] | select(.tag_name == \"v$tag\") | del(.author, .assets[].uploader)" | tee release.json jq -r .body release.json | sed '/\(New Contributors\|Full Changelog\)/,/^\s*$/d;s/^- / &/;1i- Update to v${{ steps.info.outputs.tag }}' | pandoc --from=markdown --to=markdown --columns=67 | sed -r 's/^(\s+)-/\1*/;s/\#/#/g' | tee changes - name: Upload to OpenBuildService continue-on-error: true run: | trap 'rm -f ./osc-config' EXIT && echo "$OPENBUILDSERVICE_TOKEN_SECRET" > ./osc-config osc="osc --config $GITHUB_WORKSPACE/osc-config" $osc co -o osc home:cimbali python-pympress cd osc/ if grep -qxFe '- Update to v${{ steps.info.outputs.tag }}' pympress.changes; then echo "Version already in changelog ; skipping request" else $osc vc -F ../changes sed -i "2s/Cimba Li /me@cimba.li/" pympress.changes $osc ci -m "Release ${{ steps.info.outputs.tag }}" $osc sr --yes -m "Version ${{ steps.info.outputs.tag }}" 'X11:Utilities' pympress fi env: OPENBUILDSERVICE_TOKEN_SECRET: ${{ secrets.OPENBUILDSERVICE_TOKEN_SECRET }} - name: Get SRPM URL from GitHub Release and download env: tag: ${{ steps.info.outputs.tag }} run: | url="https://github.com/Cimbali/pympress/releases/download/v${tag}/python3-pympress-${tag}-1.src.rpm" curl -L "$url" -o "python3-pympress-${tag}-1.src.rpm" - name: Upload to COPR continue-on-error: true run: | trap 'rm -f ./copr-config' EXIT && echo "$COPR_TOKEN_CONFIG" > ./copr-config copr-cli --config ./copr-config build --nowait cimbali/pympress "python3-pympress-${{ steps.info.outputs.tag }}-1.src.rpm" env: COPR_TOKEN_CONFIG: ${{ secrets.COPR_TOKEN_CONFIG }} brew: name: Request new pypi package be pulled into Homebrew needs: pypi runs-on: macos-latest steps: - name: Install dependencies run: | brew update brew install pipgrip - name: Configure brew repo run: | cd "`brew --repo homebrew/core`" # Credentials and remotes git config user.name Cimbali git config user.email me@cimba.li git config credential.helper store echo -e "protocol=https\nhost=github.com\nusername=Cimbali\npassword=$PASSWORD" | git credential-store store git remote add gh "https://github.com/Cimbali/homebrew-core/" git fetch gh # Attempt a rebase of changes in our repo copy git checkout --detach git rebase origin/master gh/master && git branch -f master HEAD || git rebase --abort # Now use master and update remote so we can use the bump-formula-pr git checkout master git push gh -f master:master env: PASSWORD: ${{ secrets.GITHUB_HOMEBREW_TOKEN }} - name: Make a brew PR from pypi’s metadata if: ${{ needs.pypi.outputs.release == 'final' }} run: | brew bump-formula-pr --strict --no-browse --url="${{needs.pypi.outputs.url}}" --sha256="${{needs.pypi.outputs.sha256}}" pympress env: HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_HOMEBREW_TOKEN }} HUB_REMOTE: https://github.com/Cimbali/homebrew-core/ pympress-1.8.5/.gitignore000066400000000000000000000001301453663732000154020ustar00rootroot00000000000000*.pyc build/ dist/ pympress.egg-info/ pympress/share/locale/*/LC_MESSAGES/pympress.mo pympress-1.8.5/LICENSE.txt000066400000000000000000000432541453663732000152530ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. 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 for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. pympress-1.8.5/README.md000066400000000000000000000556771453663732000147230ustar00rootroot00000000000000# ![Pympress logo](https://raw.githubusercontent.com/Cimbali/pympress/master/pympress/share/pixmaps/pympress-32.png) What is Pympress? Pympress is a PDF presentation tool designed for dual-screen setups such as presentations and public talks. Highly configurable, fully-featured, and portable It comes with many great features ([more below](#functionalities)): - supports embedded gifs (out of the box), videos, and audios (with VLC or Gstreamer integration) - text annotations displayed in the presenter window - natively supports beamer's *notes on second screen*, as well as Libreoffice notes pages! Pympress is a free software, distributed under the terms of the GPL license (version 2 or, at your option, any later version). Pympress was originally created and maintained by [Schnouki](https://github.com/Schnouki), on [his repo](https://github.com/Schnouki/pympress). Here is what the 2 screen setup looks like, with a big notes slide next to 2 small slides (current and next) on the presenter side: ![A screenshot with Pympress’ 2 screens](https://pympress.github.io/resources/pympress-screenshot.png) # Installing [![github version badge][github_version]][github_release] - Ubuntu ![ubuntu logo][ubuntu] 20.04 focal or newer, Debian ![debian logo][debian] 11 Bullseye or newer [![ubuntu version badge][ubuntu_version]][ubuntu_package] [![debian version badge][debian_version]][debian_package] (maintained by [@mans0954](https://github.com/mans0954)) apt-get install pympress libgtk-3-0 libpoppler-glib8 libcairo2 python3-gi python3-gi-cairo gobject-introspection libgirepository-1.0-1 gir1.2-gtk-3.0 gir1.2-poppler-0.18 - RPM-based Linux (Fedora ![fedora logo][fedora] CentOS ![centos logo][centos] Mageia ![mageia logo][mageia] OpenSuse ![suse logo][suse] RHEL) [![Copr build version][copr_build_version]][copr_package] You can get pympress from the [pympress COPR repo][copr_repo] of your system. With yum or dnf, simply do: ```sh dnf copr enable cimbali/pympress dnf install python3-pympress ``` With zypper, fetch the link of the .repo in the table at the bottom of the COPR page and add it as a source. ```sh zypper addrepo https://copr.fedorainfracloud.org/coprs/cimbali/pympress/repo/opensuse-tumbleweed/cimbali-pympress-opensuse-tumbleweed.repo zypper install python3-pympress ``` - Arch Linux ![arch linux logo][arch_linux] from AUR [![AUR version badge][aur_version]][aur_package] (maintained by [@Jose1711](https://github.com/jose1711)) ```sh git clone https://aur.archlinux.org/python-pympress.git cd python-pympress makepkg -si ``` Or using any other tool to manage AUR packages (yay, pacaur, etc.): ```sh yay -S python-pympress ``` - macOS ![apple logo][apple] using [Homebrew](https://brew.sh/) ![homebrew version badge][homebrew_version] ```sh brew install pympress ``` - Windows ![windows logo][windows] with [Chocolatey](https://chocolatey.org/) [![chocolatey version badge][chocolatey_version]][chocolatey_package] (maintained by [@ComFreek](https://github.com/ComFreek)) ```batch choco install pympress ``` Or using the Windows Package Manager (winget) ![winget version badge][winget_version] ```batch winget install pympress ``` Or download the latest installer from the [latest Github release][github_release].
Troubleshooting - If you get an error message along the lines of "MSVCP100.dll is missing", get the Visual C++ 2010 redistributables from Microsoft ([x86 (32 bit)](https://www.microsoft.com/en-in/download/details.aspx?id=5555) or [x64 (64 bits)](https://www.microsoft.com/en-us/download/details.aspx?id=14632)). Those libraries really should already be installed on your system.
- Other systems, directly from PyPI ![pypi version badge][pypi_version] − requires [python, gtk+3, poppler, and their python bindings](#dependencies): ``` python3 -m pip install "pympress" ```
Troubleshooting - Make sure you have all [the dependencies](#dependencies). (These are already included in binary packages or their dependencies.) - Using pip, you may want to install with the `--user` option, or install from github or downloaded sources. See [the python documentation on installing](https://docs.python.org/3.7/installing/index.html). - If your python environment lacks the Gobject Introspections module, try 1. using `--system-site-packages` for [virtual environments](https://docs.python.org/3.7/library/venv.html), 2. installing pygobject from pip (`pip install pygobject`, which requires the correct development/header packages. See [the PyPI installation instructions of PyGObject for your system](https://pygobject.readthedocs.io/en/latest/getting_started.html)).
[ubuntu]: https://pympress.github.io/os-icons/ubuntu.png [debian]: https://pympress.github.io/os-icons/debian.png [centos]: https://pympress.github.io/os-icons/centos.png [windows]: https://pympress.github.io/os-icons/windows-10.png [suse]: https://pympress.github.io/os-icons/suse.png [linux]: https://pympress.github.io/os-icons/linux.png [fedora]: https://pympress.github.io/os-icons/fedora.png [mageia]: https://pympress.github.io/os-icons/mageia.png [arch_linux]: https://pympress.github.io/os-icons/archlinux.png [apple]: https://pympress.github.io/os-icons/apple.png [ubuntu_package]: https://packages.ubuntu.com/focal/pympress [debian_package]: https://packages.debian.org/testing/pympress [copr_package]: https://copr.fedorainfracloud.org/coprs/cimbali/pympress/package/python3-pympress/ [copr_repo]: https://copr.fedorainfracloud.org/coprs/cimbali/pympress/ [aur_package]: https://aur.archlinux.org/packages/python-pympress/ [chocolatey_package]: https://chocolatey.org/packages/pympress [github_release]: https://github.com/Cimbali/pympress/releases/latest [copr_build_version]: https://img.shields.io/badge/dynamic/json?label=COPR%20build&query=%24.items%5B0%5D.source_package.version&url=https%3A%2F%2Fcopr.fedorainfracloud.org%2Fapi_3%2Fbuild%2Flist%2F%3Fownername%3Dcimbali%26projectname%3Dpympress%26limit%3D1&prefix=v&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNC4zNzU0MjNtbSIgaGVpZ2h0PSIzNC40MTYyMjltbSIgdmlld0JveD0iMCAwIDM0LjM3NTQyMyAzNC40MTYyMjkiIHZlcnNpb249IjEuMSI+CjxlbGxpcHNlIHN0eWxlPSJmaWxsOiNkMzhkNWY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjEuODUyMDg7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1vcGFjaXR5OjEiIGN4PSIyMy45NTYwNDMiIGN5PSI0LjE1NzQwMiIgcng9IjE3LjIxMTEwMiIgcnk9IjE3LjE4NDcyMSIgdHJhbnNmb3JtPSJtYXRyaXgoMC44MTcyMjcxOSwwLjU3NjMxNTY0LC0wLjU3NDgzNTQ1LDAuODE4MjY5MDMsMCwwKSIgLz4KPGVsbGlwc2Ugc3R5bGU9ImZpbGw6Izc4NDQyMTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MS44NTIwODtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLW9wYWNpdHk6MSIgY3g9IjIzLjkwODM5IiBjeT0iNC4yOTczMjI4IiByeD0iMTIuNzYyMTE2IiByeT0iMTIuNzQzNTE3IiB0cmFuc2Zvcm09Im1hdHJpeCgwLjgyMDU3NzU0LDAuNTcxNTM1MjEsLTAuNTY1NjkzODMsMC44MjQ2MTUzNiwwLDApIiAvPgo8cGF0aCBzdHlsZT0iZmlsbDpub25lO2ZpbGwtcnVsZTpldmVub2RkO3N0cm9rZTojZDM4ZDVmO3N0cm9rZS13aWR0aDoyLjMwNTE4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1vcGFjaXR5OjEiIGQ9Im0gMjYuMTg3Nzg3LDUuMDc3Mzc3MyBjIDAsMCAtMTguNzQxMzc4NiwzLjI5MzI5OCAtMTcuMzE1MDI3OSwyNC4zMDM2MDg3IiAvPgo8cGF0aCBzdHlsZT0iZmlsbDpub25lO2ZpbGwtcnVsZTpldmVub2RkO3N0cm9rZTojZDM4ZDVmO3N0cm9rZS13aWR0aDoyLjMwNTE4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1vcGFjaXR5OjEiIGQ9Im0gMjUuNzE0NTY0LDQuNDQ0OTkwOCBjIDAsMCAzLjAyOTU5MywxOC43NTk5NzMyIC0xNy4zMTUwMjM3LDI0LjMwMzYwNDIiIC8+Cjwvc3ZnPgo= [pypi_version]: https://img.shields.io/pypi/v/pympress?logo=pypi&logoColor=yellow [aur_version]: https://img.shields.io/aur/version/python-pympress?logo=arch%20linux [homebrew_version]: https://img.shields.io/homebrew/v/pympress?logo=homebrew [ubuntu_version]: https://img.shields.io/ubuntu/v/pympress?logo=ubuntu [debian_version]: https://img.shields.io/debian/v/pympress/testing?logo=debian [chocolatey_version]: https://img.shields.io/chocolatey/v/pympress?logo=chocolatey [winget_version]: https://img.shields.io/badge/dynamic/xml?color=blue&label=Winget&query=%2F%2Ftr%5B%40id%3D%27winget%27%5D%2Ftd%5B3%5D%2Fspan%2Fa&url=https%3A%2F%2Frepology.org%2Fproject%2Fpympress%2Fversions [github_version]: https://img.shields.io/github/v/release/Cimbali/pympress?label=Latest%20GitHub%20release&logo=github ### Notes To support playing embedded videos in the PDFs, your system must have VLC installed (with the same bitness as pympress). VLC is not distributed with pympress, but it is certainly available in your system’s package manager and [on their website](https://www.videolan.org/vlc/). # Usage ## Opening a file Simply start Pympress and it will ask you what file you want to open. You can also start pympress from the command line with a file to open like so: `pympress slides.pdf` or `python3 -m pympress slides.pdf` ## Functionalities All functionalities are available from the menus of the window with slide previews. Don't be afraid to experiment with them! Keyboard shortcuts are also listed in these menus. Some more usual shortcuts are often available, for example `Ctrl`+`L`, and `F11` also toggle fullscreen, though the main shortcut is just `F`. A few of the fancier functionalities are listed here: - **Two-screen display**: See on your laptop or tablet display the current slide, the next slide, the talk time and wall-clock time, and annotations (either PDF annotations, beamer notes on second slide, or Libreoffice notes pages). The position of the beamer or Libreoffice notes in the slide is detected automatically and can be overridden via a menu option. If you do not want to use second-slide beamer notes but prefer to have notes on their own pages, you can enable auto-detection of these notes. Use the following snippet that prefixes the page labels with `notes:` on notes pages: ```latex \addtobeamertemplate{note page}{}{\thispdfpagelabel{notes:\insertframenumber}} ``` - **Media support**: supports playing video, audio, and gif files embedded in (or linked from) the PDF file, with optional start/end times and looping. - **Highlight mode**: Allows one to draw freehand on the slide currently on screen. - **Go To Slide**: To jump to a selected slide without flashing through the whole presentation on the projector, press `G` or click the "current slide" box. Using `J` or clicking the slide label will allow you to navigate slide labels instead of page numbers, useful e.g. for multi-page slides from beamer `\pause`. A spin box will appear, and you will be able to navigate through your slides in the presenter window only by scrolling your mouse, with the `Home`/`Up`/`Down`/`End` keys, with the + and - buttons of the spin box, or simply by typing in the number of the slide. Press `Enter` to validate going to the new slide or `Esc` to cancel. - **Deck Overview**: Pressing `D` will open an overview of your whole slide deck, and any slide can be opened from can simply clicking it. - **Software pointer**: Clicking on the slide (in either window) while holding `ctrl` down will display a software laser pointer on the slide. Or press `L` to permanently switch on the laser pointer. - **Talk time breakdown**: The `Presentation > Timing Breakdown` menu item displays a breakdown of how much time was spent on each slide, with a hierarchical breakdown per chapters/sections/etc. if available in the PDF. - **Automatic file reloading**: If the file is modified, pympress will reload it (and preserve the current slide, current time, etc.) - **Big button mode**: Add big buttons (duh) for touch displays. - **Swap screens**: If Pympress mixed up which screen is the projector and which is not, press `S` - **Automatic full screen**: pympress will automatically put the content window fullscreen on your non-primay screen when: - connecting a second screen, - extending your desktop to a second screen that was mirroring your main screen, - when starting pympress on a two-screen display. To disable this behaviour, untick “Content fullscreen” under the “Starting configuration” menu. - **Estimated talk time**: Click the `Time estimation` box and set your planned talk duration. The color will allow you to see at a glance how much time you have left. - **Adjust screen centering**: If your slides' form factor doesn't fit the projectors' and you don't want the slide centered in the window, use the "Screen Center" option in the "Presentation" menu. - **Resize Current/Next slide**: You can drag the bar between both slides on the Presenter window to adjust their relative sizes to your liking. - **Caching**: For efficiency, Pympress caches rendered pages (up to 200 by default). If this is too memory consuming for you, you can change this number in the configuration file. - **Configurability**: Your preferences are saved in a configuration file, and many options are accessible there directly. These include: - Customisable key bindings (or shortcuts), - Configurable layout of the presenter window, with 1 to 16 next slides preview - and many more. See the [configuration file documentation](docs/options.md) for more details, - **Editable PDF annotations**: Annotations can be added, removed, or changed, and the modified PDF files can be saved - **Automatic next slide and looping** ## Command line arguments - `-h, --help`: Shows a list of all command line arguments. - `-t mm[:ss], --talk-time=mm[:ss]`: The estimated (intended) talk time in minutes and optionally seconds. - `-n position, --notes=position`: Set the position of notes on the pdf page (none, left, right, top, or bottom). Overrides the detection from the file. - `--log=level`: Set level of verbosity in log file (DEBUG, INFO, WARNING, ERROR). ## Media and autoplay To enable media playback, you need to have either: - Gstreamer installed (enabled by default), with its gtk plugin (`libgstgtk`) which is sometimes packaged separately (e.g. as `gst-plugin-gtk` or `gstreamer1.0-gtk3`), and plugins gstreamer-good/-bad/-ugly based on which codecs you need, or - VLC installed (and the python-vlc module), with `enabled = on` under the `[vlc]` section of your config file. On macOS, issues with the gstreamer brew formula may require users to set `GST_PLUGIN_SYSTEM_PATH` manually. For default homebrew configurations the value should be `/opt/homebrew/lib/gstreamer-1.0/`. Make sure to set this environmental variable globally, or pympress might not pick it up. To produce PDFs with media inclusion, the ideal method is to use beamer’s multimedia package, always with `\movie`: ```latex \documentclass{beamer} \usepackage{multimedia} \begin{frame}{Just a mp4 here} \centering \movie[width=0.3\textwidth]{\includegraphics[width=0.9\textwidth]{frame1.png}}{movie.mp4} \movie[width=0.3\textwidth]{}{animation.gif} \movie[width=0.3\textwidth]{}{ding.ogg} \end{frame} ``` If you desire autoplay, ensure you have pympress ≥ 1.7.0 and poppler ≥ 21.04, and use the `movie15` package as follows: ```latex \documentclass{beamer} \usepackage{movie15} \begin{document} \begin{frame} \begin{center} \includemovie[attach=false,autoplay,text={% \includegraphics{files/mailto.png}% }]{0.4\linewidth}{0.3\linewidth}{files/random.mpg} \end{center} \end{frame} \end{document} ``` # Dependencies Pympress relies on: * Python (version ≥ 3.4, python 2.7 is supported only until pympress 1.5.1, and 3.x < 3.4 until v1.6.4). * [Poppler](http://poppler.freedesktop.org/), the PDF rendering library. * [Gtk+ 3](http://www.gtk.org/), a toolkit for creating graphical user interfaces, and [its dependencies](https://www.gtk.org/overview.php), specifically: * [Cairo](https://www.cairographics.org/) (and python bindings for cairo), the graphics library which is used to pre-render and draw over PDF pages. * Gdk, a lower-level graphics library to handle icons. * [PyGi, the python bindings for Gtk+3](https://wiki.gnome.org/Projects/PyGObject). PyGi is also known as *pygobject3*, just *pygobject* or *python3-gi*. * Introspection bindings for poppler may be shipped separately, ensure you have those as well (`typelib-1_0-Poppler-0_18` on OpenSUSE, `gir1.2-poppler-0.18` on Ubuntu) * optionally [VLC](https://www.videolan.org/vlc/), to play videos (with the same bitness as Python) and the [python-vlc](https://pypi.org/project/python-vlc/) bindings. * optionally Gstreamer to play videos (which is a Gtk library) ### On linux platforms The dependencies are often installed by default, or easily available through your package or software manager. For example, on ubuntu, you can run the following as root to make sure you have all the prerequisites *assuming you use python3*: ```sh apt-get install python3 python3-pip libgtk-3-0 libpoppler-glib8 libcairo2 python3-gi python3-cairo python3-gi-cairo gobject-introspection libgirepository-1.0-1 libgirepository1.0-dev gir1.2-gtk-3.0 gir1.2-poppler-0.18 ``` Different distributions might have different package naming conventions, for example the equivalent on OpenSUSE would be: ```sh zypper install python3 python3-pip libgtk-3-0 libpoppler-glib8 libcairo2 python3-gobject python3-gobject-Gdk python3-cairo python3-gobject-cairo typelib-1_0-GdkPixbuf-2_0 typelib-1_0-Gtk-3_0 typelib-1_0-Poppler-0_18 ``` On CentOS/RHEL/Fedora the dependencies would be: ```sh yum install python36 python3-pip gtk3 poppler-glib cairo gdk-pixbuf2 python3-gobject python3-cairo ``` And on Arch Linux: ```sh pacman -S --needed python python-pip gtk3 poppler cairo gobject-introspection poppler-glib python-gobject gst-plugin-gtk ``` ### On macOS Dependencies can be installed using [Homebrew](https://brew.sh/): ```sh brew install --only-dependencies pympress ``` ### On windows The [binary installer for windows](#installing-) comes with pympress and all its dependencies packaged. Alternately, in order to install from pypi or from source on windows, there are two ways to get the dependencies: 1. using MSYS2 (replace x86_64 with i686 if you're using a 32 bit machine). **Warning:** this can take a substantial amount of disk size as it requires a full software distribution and building platform. ```sh pacman -S --needed mingw-w64-x86_64-gtk3 mingw-w64-x86_64-cairo mingw-w64-x86_64-poppler mingw-w64-x86_64-python3 mingw-w64-x86_64-vlc python3-pip mingw-w64-x86_64-python3-pip mingw-w64-x86_64-python3-gobject mingw-w64-x86_64-python3-cairo ``` This is also the strategy used to automate [builds on appveyor](https://github.com/Cimbali/pympress/tree/master/scripts/build_msi_mingw.sh). 2. Using PyGobjectWin32. *Be sure to check the supported Python versions (up to 3.4 at the time of writing)*, they appear in the FEATURES list in the linked page. - Install native [python for windows](https://www.python.org/downloads/windows/) - Get GTK+3, Poppler and their python bindings by executing [the PyGi installer](https://sourceforge.net/projects/pygobjectwin32/). Be sure to tick all the necessary dependencies in the installer (Poppler, Cairo, Gdk-Pixbuf). Alternately, you can build your Gtk+3 stack from source using MSVC, see [the Gnome wiki](https://wiki.gnome.org/Projects/GTK+/Win32/MSVCCompilationOfGTKStack) and [this python script that compiles the whole Gtk+3 stack](https://github.com/wingtk/gvsbuild/). This strategy has not been used successfully yet, due to problems building Poppler with its introspection bidings (i.e. typelib) − see [#109](https://github.com/Cimbali/pympress/issues/109). # Contributing Feel free to clone this repo and use it, modify it, redistribute it, etc, under the GPLv2+. A [number of contributors](https://github.com/Cimbali/pympress/graphs/contributors) have taken part in the development of pympress and submitted pull requests to improve it. **Be respectful of everyone and keep this community friendly, welcoming, and harrasment-free. Abusive behaviour will not be tolerated, and can be reported by email at me@cimba.li − wrongdoers may be permanently banned.** Pympress has inline sphinx documentation ([Google style](http://www.sphinx-doc.org/en/latest/ext/example_google.html), contains rst syntax), and the [docs generated from it are hosted on the github pages of this repo](https://pympress.github.io/). ## Translations ![Chinese (simplified)](https://img.shields.io/poeditor/progress/301055/zh-Hans?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%A8%F0%9F%87%B3%20Chinese%20%28simplified%29) ![Chinese (traditional)](https://img.shields.io/poeditor/progress/301055/zh-Hant?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%A8%F0%9F%87%B3%20Chinese%20%28traditional%29) ![Czech](https://img.shields.io/poeditor/progress/301055/cs?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%A8%F0%9F%87%BF%20Czech) ![Hindi](https://img.shields.io/poeditor/progress/301055/hi?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%AE%F0%9F%87%B3%20Hindi) ![Italian](https://img.shields.io/poeditor/progress/301055/it?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%AE%F0%9F%87%B9%20Italian) ![Japanese](https://img.shields.io/poeditor/progress/301055/ja?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%AF%F0%9F%87%B5%20Japanese) ![Polish](https://img.shields.io/poeditor/progress/301055/pl?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%B5%F0%9F%87%B1%20Polish) ![French](https://img.shields.io/poeditor/progress/301055/fr?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%AB%F0%9F%87%B7%20French) ![German](https://img.shields.io/poeditor/progress/301055/de?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%A9%F0%9F%87%AA%20German) ![Spanish](https://img.shields.io/poeditor/progress/301055/es?token=7a666b44c0985d16a7b59748f488275c&label=%F0%9F%87%AA%F0%9F%87%B8%20Spanish) We thank the many contributors of translations: Agnieszka, atsuyaw, Cherrywoods, Dongwang, Estel-f, Fabio Pagnotta, Ferdinand Fichtner, Frederik. blome, FriedrichFröbel, GM, He. yifan. xs, Jaroslav Svoboda, Jeertmans, Kristýna, lazycat, Leonvincenterd, LogCreative, Lorenzo. pacchiardi, Luis Sibaja, Marcin Dohnalik, marquitul, Morfit, Mzn, Nico, Ogawa, Paul, Pierre BERTHOU, polaksta, Saulpierotti, Shebangmed, Stanisław Polak, susobaco, Tapia, Tejas, Timo Zhang, Tkoyama010, Toton95, Vojta Netrh, Vulpeculus, and Cimbali. If you also want to add or contribute to a translation, check [pympress’ page on POEditor](https://poeditor.com/join/project/nKfRxeN8pS). Note that old strings are kept and tagged `removed`, to give context and keep continuity between translations of succcessive versions. This means `removed` strings are unused and do not need translating. ## Packages Official releases are made to [PyPI](https://pypi.org/) and with [github releases](https://github.com/Cimbali/pympress/releases). The community maintains a number of other packages or recipes to install pympress (see [Install section](#installing-)). Any additions welcome. pympress-1.8.5/docs/000077500000000000000000000000001453663732000143505ustar00rootroot00000000000000pympress-1.8.5/docs/README.md000066400000000000000000000000641453663732000156270ustar00rootroot00000000000000```{include} ../README.md :relative-docs: docs/ ``` pympress-1.8.5/docs/_template/000077500000000000000000000000001453663732000163225ustar00rootroot00000000000000pympress-1.8.5/docs/_template/breadcrumbs.html000066400000000000000000000005461453663732000215060ustar00rootroot00000000000000{% extends "!breadcrumbs.html" %} {# do the setup for the github links to appear correctly #} {% set display_github = True %} {% set github_user = 'Cimbali' %} {% set github_repo = 'pympress' %} {% set github_version = 'master' %} {% if pagename == 'README' %} {% set conf_py_path = '/' %} {% else %} {% set conf_py_path = '/docs/source/' %} {% endif %} pympress-1.8.5/docs/_template/layout.html000066400000000000000000000013371453663732000205310ustar00rootroot00000000000000{% extends "!layout.html" %} {% block sidebartitle %} Pympress on GitHub
Docs home
v{{ version }}
{% include "searchbox.html" %} {% endblock %} {% block menu %} {{ super() }} {% if document_api %}
Index Module index {% endif %} {% endblock %} pympress-1.8.5/docs/conf.py000066400000000000000000000446251453663732000156620ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. """ pympress documentation build configuration file for sphinx_build. """ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use the absolute path. import sys import pathlib sys.path.insert(0, pathlib.Path(__file__).resolve().parents[1]) import subprocess import importlib from urllib.parse import urlsplit, urljoin from urllib.request import url2pathname from docutils.nodes import section as SectionNode, image as ImageNode # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.3' # for sphinx.ext.napoleon # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'myst_parser', ] # Whether to include code documentation. Override on the command line with –Dskip_api_doc=1 skip_api_doc = False # Extends extensions, if we document the API code_doc_extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', 'sphinx.ext.doctest', ] # Whether to skip install instructions for included packages packaged_docs = False # Add any paths that contain templates here, relative to this directory. templates_path = ['_template'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: source_suffix = ['.md'] github_doc_root = 'https://pympress.github.io/' def doc_transform(app, doctree, docname): """ Modify the doctree before generating docs """ # Remove images completely from man pages, alt texts are not useful if app.builder.name == 'man': for node in list(doctree.findall(ImageNode)): node.parent.remove(node) # Remove the genindex/modindex section if not relevant if app.builder.name == 'man' or app.config.skip_api_doc: for node in list(doctree.findall(SectionNode)): if 'indices-and-tables' in node['ids']: node.parent.remove(node) # Remove install instructions for docs that are part of a package if app.config.packaged_docs: for node in list(doctree.findall(SectionNode)): if {'installing', 'dependencies', 'packages'} & set(node['ids']): node.parent.remove(node) def doc_remove(app, env, docnames): """ Remove API page from list of source files, if we do not build API docs """ if app.config.skip_api_doc: env.found_docs.remove('pympress') docnames.remove('pympress') def add_extensions(app, config): """ Delayed configuration of extensions to allow enabling or skipping API documentation from the command line """ config.html_context['document_api'] = not config.skip_api_doc if not config.skip_api_doc: for ext in code_doc_extensions: app.setup_extension(ext) def setup(app): """ Function called by sphinx to setup this documentation """ # Only use default=False because it is hard to pass falsey things on CLI (i.e. -D...=0 works but not -D...=False) app.add_config_value('packaged_docs', False, 'env') app.add_config_value('skip_api_doc', False, 'env') app.connect('env-before-read-docs', doc_remove) app.connect('doctree-resolved', doc_transform) app.connect('config-inited', add_extensions) # When skipping API docs, we get WARNING: toctree contains reference to nonexisting document 'pympress' # This could be avoided by modifying the file on 'source-read' events, but not very clean approach myst_heading_anchors = 3 # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. Make sure we find the right module info. pkg_meta = importlib.import_module('pympress.__init__') project = 'pympress' copyright = '2009-2011, Thomas Jost; 2015-2023 Cimbali' # noqa: A001 -- sphinx-required name, like all the others author = 'Cimbali' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = pkg_meta.__version__ # The full version, including alpha/beta/rc tags. try: release = str(subprocess.check_output(["git", "describe"])[1:].strip()) except Exception: release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = 'en' # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # # today = '' # # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%d %B, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = 'obj' # If true, '()' will be appended to :func: etc. cross-reference text. # # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True def load_epydoc_as_intersphinx_v2(mappings): """ Get a epydoc objects lists from URLs and convert them to intershphinx v2 format. Arguments: mappings (`dict`: `str` to `str`): Mapping a module name to the URL where the documentation is available. In particular '{url}/api-objects.txt' must contain the list of objects generated by epydox. Returns: a dict mapping module names to (url, filename) tuples, where the file contains the intersphinx list of objects """ import codecs import requests import tempfile def guess_epydoc_role(name, uri): uri = urlsplit(uri) # filenames in URI are name-module.html or name-class.html base = pathlib.Path(url2pathname(uri.path)).stem.split('-')[-1] if not uri.fragment: return base elif base == 'class': # Is it a method or an attribute? return 'any' elif base == 'module': # Is it a function or a global member? return 'func' intersphinx_mapping = {} for module, url in mappings.items(): objects_inv = [] try: with requests.get(urljoin(url, 'api-objects.txt')) as epy: for name, uri in (line.strip().split() for line in epy.text.split('\n') if line.strip()): role = guess_epydoc_role(name, uri) objects_inv.append('{name} py:{role} 1 {uri} -'.format(name = name, role = role, uri = uri)) except Exception: # We likely don’t have internet during this build, and early loading of epydoc fails print('WARNING: Failed to load epydoc mapping from', url, file=sys.stderr) # NB. this will cause failure later on if intersphinx tries to load an epydoc url. # this is only to allow building with intersphinx disabled. intersphinx_mapping[module] = (url, None) continue if objects_inv: with tempfile.NamedTemporaryFile(mode = 'wb', delete = False) as translated: translated.write('\n'.join([ "# Sphinx inventory version 2", "# Project: {} {}".format('python-vlc', '2.2'), "# Version: {}".format('2.2.0-git-14816-gda488a7751100'), "# The remainder of this file is compressed using zlib.", "" ]).encode('ascii')) translated.write(codecs.encode('\n'.join(objects_inv + [""]).encode('ascii'), 'zlib')) filename = translated.name intersphinx_mapping[module] = (url, filename) return intersphinx_mapping # Link to outside documentations intersphinx_mapping = { 'Gtk': ('https://lazka.github.io/pgi-docs/Gtk-3.0', None), 'Gdk': ('https://lazka.github.io/pgi-docs/Gdk-3.0', None), 'GdkPixbuf': ('https://lazka.github.io/pgi-docs/GdkPixbuf-2.0', None), 'GObject': ('https://lazka.github.io/pgi-docs/GObject-2.0', None), 'Poppler': ('https://lazka.github.io/pgi-docs/Poppler-0.18', None), 'Pango': ('https://lazka.github.io/pgi-docs/Pango-1.0', None), 'GLib': ('https://lazka.github.io/pgi-docs/GLib-2.0', None), 'GdkX11': ('https://lazka.github.io/pgi-docs/GdkX11-3.0', None), 'python': ('https://docs.python.org/{}.{}'.format(*sys.version_info[:2]), None), 'cairo': ('https://www.cairographics.org/documentation/pycairo/3', None), **load_epydoc_as_intersphinx_v2({'vlc': 'https://www.olivieraubert.net/vlc/python-ctypes/doc/'}), # No mapping on https://gstreamer.freedesktop.org/documentation/gstreamer/ 'Gst': ('https://lazka.github.io/pgi-docs/Gst-1.0', None), } # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # # Read the docs theme try: import sphinx_rtd_theme except ImportError: html_theme = "alabaster" else: html_theme = "sphinx_rtd_theme" # Add any paths that contain custom themes here, relative to this directory. html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # The name for this set of Sphinx documents. # " v documentation" by default. # html_title = "Pympress developer documentation" # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = "Pympress v{}".format(version) # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = '../pympress/share/pixmaps/pympress.png' # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = '../pympress/share/pixmaps/pympress.ico' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # # html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # # html_last_updated_fmt = '%d %B, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # # html_additional_pages = {} # If false, no module index is generated. # # html_domain_indices = True # If false, no index is generated. # # html_use_index = True # If true, the index is split into individual pages for each letter. # # html_split_index = False # If true, links to the reST sources are added to the pages. # # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. # # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'pympressdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'pympress.tex', u'pympress documentation', u'Thomas Jost, Cimbali', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # # latex_use_parts = False # If true, show page references after internal links. # # latex_show_pagerefs = False # If true, show URL addresses after external links. # # latex_show_urls = False # Documents to append as an appendix to all manuals. # # latex_appendices = [] # It false, will not define \strong, \code, itleref, \crossref ... but only # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added # packages. # # latex_keep_old_macro_names = True # If false, no module index is generated. # # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'pympress', u'pympress documentation', [u'Cimbali'], 1) ] # If true, show URL addresses after external links. # man_show_urls = True # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'pympress', 'pympress Documentation', author, 'pympress', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # # texinfo_appendices = [] # If false, no module index is generated. # # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # # texinfo_no_detailmenu = False # -- Options for Epub output ---------------------------------------------- # Bibliographic Dublin Core info. epub_title = project epub_author = author epub_publisher = author epub_copyright = copyright # The basename for the epub file. It defaults to the project name. # epub_basename = project # The HTML theme for the epub output. Since the default themes are not # optimized for small screen space, using the same theme for HTML and epub # output is usually not wise. This defaults to 'epub', a theme designed to save # visual space. # # epub_theme = 'epub' # The language of the text. It defaults to the language option # or 'en' if the language is not set. # # epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. # epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. # # epub_identifier = '' # A unique identification for the text. # # epub_uid = '' # A tuple containing the cover image and cover page html template filenames. # # epub_cover = () # A sequence of (type, uri, title) tuples for the guide element of content.opf. # # epub_guide = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. # # epub_pre_files = [] # HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. # # epub_post_files = [] # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # The depth of the table of contents in toc.ncx. # # epub_tocdepth = 3 # Allow duplicate toc entries. # # epub_tocdup = True # Choose between 'default' and 'includehidden'. # # epub_tocscope = 'default' # Fix unsupported image types using the Pillow. # # epub_fix_images = False # Scale large images. # # epub_max_image_width = 0 # How to display URL addresses: 'footnote', 'no', or 'inline'. # # epub_show_urls = 'inline' # If false, no index is generated. # # epub_use_index = True pympress-1.8.5/docs/index.md000066400000000000000000000003041453663732000157760ustar00rootroot00000000000000# Welcome to pympress's documentation! ## Contents ```{toctree} :maxdepth: 2 README.md options.md pympress.md ``` ## Indices and tables ```{eval-rst} * :ref:`genindex` * :ref:`modindex` ``` pympress-1.8.5/docs/options.md000066400000000000000000000136201453663732000163670ustar00rootroot00000000000000# Configuration file Pympress has a number of options available from its configuration file. This file is usually located in: - `~/.config/pympress` on Linux, - `%APPDATA%/pympress.ini` on Windows, - `~/Library/Preferences/pympress` on macOS, - in the top-level of the pympress install directory for portable installations. The path to the currently used configuration file can be checked in the `Help > About` information window. ## Shortcuts The shortcuts are parsed using [`Gtk.accelerator_parse()`](https://lazka.github.io/pgi-docs/#Gtk-3.0/functions.html#Gtk.accelerator_parse): > The format looks like “\a” or “\\F1” or “\z” (the last one is for key release). > > The parser is fairly liberal and allows lower or upper case, and also abbreviations such as “\” and “\”. Key names are parsed using [`Gdk.keyval_from_name()`](https://lazka.github.io/pgi-docs/#Gdk-3.0/functions.html#Gdk.keyval_from_name). For character keys the name is not the symbol, but the lowercase name, e.g. one would use “\minus” instead of “\-”. This means that any value in this [list of key constants](https://lazka.github.io/pgi-docs/#Gdk-3.0/constants.html#Gdk.KEY_0) is valid (removing the initial `Gdk.KEY_` part). You can verify that this value is parsed correctly from the `Help > Shortcuts` information window. ## Layouts The panes (current slide, next slide, notes, annotations, etc.) can be rearranged arbitrarily by setting the entries of the `layout` section in the configuration file. Here are a couple examples of layouts, with `Cu` the current slide, `No` the notes half of the slide, `Nx` the next slide: - All-horizontal layout: +----+----+----+ | Cu | No | Nx | +----+----+----+ Setting: notes = {"children": ["current", "notes", "next"], "proportions": [0.33, 0.33, 0.33], "orientation": "horizontal", "resizeable": true} - All-vertical layout: +----+ | Cu | +----+ | No | +----+ | Nx | +----+ Setting: notes = {"children": ["current", "notes", "next"], "proportions": [0.33, 0.33, 0.33], "orientation": "vertical", "resizeable": true} - Vertical layout with horizontally divided top pane: +----+----+ | Cu | No | +----+----+ | Nx | +---------+ Setting: notes = {"children": [ {"children": ["current", "notes"], "proportions": [0.5, 0.5], "orientation": "horizontal", "resizeable": true}, "next" ], "proportions": [0.5, 0.5], "orientation": "vertical", "resizeable": true} - Horizontal layout with horizontally divided right pane: +----+----+ | | Nx | + Cu +----+ | | No | +---------+ Setting: notes = {"children": [ "current", {"children": ["next", "notes"], "proportions": [0.5, 0.5], "orientation": "vertical", "resizeable": true} ], "proportions": [0.5, 0.5], "orientation": "horizontal", "resizeable": true} And so on. You can play with the items, their nesting, their order, and the orientation in which a set of widgets appears. For each entry the widgets (strings that are leaves of "children" nodes in this representation) must be: - for `notes`: "current", "notes", "next" - for `plain`: "current", "next" and "annotations" (the annotations widget is toggled with the `A` key by default) - for `highlight`: same as `plain` with "highlight" instead of "current" A few further remarks: - If you set "resizeable" to `false`, the panes won’t be resizeable dynamically with a handle in the middle - "proportions" are normalized, and saved on exit if you resize panes during the execution. If you set them to `4` and `1`, the panes will be `4 / (4 + 1) = 20%` and `1 / (4 + 1) = 100%`, so the ini will contain something like `0.2` and `0.8` after executing pympress. ## Themes on Windows Pympress uses the default Gtk theme of your system, which makes it easy to change on many OSs either globally via your Gtk preferences or [per application](https://www.linuxuprising.com/2019/10/how-to-use-different-gtk-3-theme-for.html). Here’s the way to do it on windows: 1. Install a theme There are 2 locations, either install the theme for all your gtk apps, e.g. in `C:\Users\%USERNAME%\AppData\Local\themes`, or just for pympress, so in `%INSTALLDIR%\share\themes` (for me that’s `C:\Users\%USERNAME%\AppData\Local\Programs\pympress\share\themes`) Basically pick a theme [e.g. from this list of dark themes](https://www.gnome-look.org/browse/cat/135/ord/rating/?tag=dark) and make sure to unpack it in the selected directory, it needs at least `%THEMENAME%\gtk-3.0\gtk.css` and `%THEMENAME%\index.theme`, where `THEMENAME` is the name of the theme. There are 2 pitfalls to be aware of, to properly install a theme: - themes that are not self-contained (relying on re-using css from default linux themes that you might not have), and - linux links (files under gtk-3.0/ that point to a directory above and that need to be replaced by a directory containing the contents of the target directory that has the same name as the link file). 2. Set the theme as default Create a `settings.ini` file, either under `C:\Users\%USERNAME%\AppData\Local\gtk-3.0` (global setting) or `%INSTALLDIR%\etc\gtk-3.0` (just pympress) and set the contents: ```ini [Settings] gtk-theme-name=THEMENAME ``` Here’s what it looks like with the [Obscure-Orange](https://www.gnome-look.org/p/1254680/) theme. ![VirtualBox_Win64_16_05_2021_01_23_19](https://user-images.githubusercontent.com/6126377/118380851-70d3d080-b5e5-11eb-97ac-65961f343a2d.png) In testing this found these 2 stackoverflow questions useful: - [Change GTK+3 look on Windows](https://stackoverflow.com/a/39041558/1387346) which contains a list of all interesting directories - [How to get native windows decorations on GTK3 on Windows 7+ and MSYS2](https://stackoverflow.com/a/37060369/1387346) which details the process pympress-1.8.5/docs/pympress.md000066400000000000000000000043761453663732000165660ustar00rootroot00000000000000# Pympress package This page contains the inline documentation, generated from the code using sphinx. The code is documented in the source using the [Google style](https://google.github.io/styleguide/pyguide.html) for docstrings. Sphinx has gathered a [set of examples](http://www.sphinx-doc.org/en/latest/ext/example_google.html) which serves as a better crash course than the full style reference. Retructured text (rst) can be used inside the comments and docstrings. ## Modules ```{eval-rst} .. automodule:: pympress.__main__ :members: :undoc-members: :show-inheritance: .. automodule:: pympress.app :members: :undoc-members: :show-inheritance: .. automodule:: pympress.ui :members: :undoc-members: :show-inheritance: .. automodule:: pympress.document :members: :undoc-members: :show-inheritance: .. automodule:: pympress.builder :members: :undoc-members: :show-inheritance: .. automodule:: pympress.surfacecache :members: :undoc-members: :show-inheritance: .. automodule:: pympress.scribble :members: :undoc-members: :show-inheritance: .. automodule:: pympress.pointer :members: :undoc-members: :show-inheritance: .. automodule:: pympress.editable_label :members: :undoc-members: :show-inheritance: .. automodule:: pympress.talk_time :members: :undoc-members: :show-inheritance: .. automodule:: pympress.config :members: :undoc-members: :show-inheritance: .. automodule:: pympress.dialog :members: :undoc-members: :show-inheritance: .. automodule:: pympress.extras :members: :undoc-members: :show-inheritance: .. automodule:: pympress.deck :members: :undoc-members: :show-inheritance: .. automodule:: pympress.util :members: :undoc-members: :show-inheritance: .. automodule:: pympress.media_overlays.base :members: :undoc-members: :show-inheritance: .. automodule:: pympress.media_overlays.gif_backend :members: :undoc-members: :show-inheritance: .. automodule:: pympress.media_overlays.gst_backend :members: :undoc-members: :show-inheritance: .. automodule:: pympress.media_overlays.vlc_backend :members: :undoc-members: :show-inheritance: ``` pympress-1.8.5/pympress/000077500000000000000000000000001453663732000153025ustar00rootroot00000000000000pympress-1.8.5/pympress/__init__.py000066400000000000000000000025751453663732000174240ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # __init__.py # # Copyright 2015 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ A simple and powerful dual-screen PDF reader designed for presentations. """ # # DON'T IMPORT ANYTHING HERE (OR YOU WILL BREAK setup.py) # __version__ = '1.8.5' __author__ = """2009, 2010 Thomas Jost 2015-2023 Cimbali 2016 Christoph Rath 2016 Epithumia """ __all__ = ['app', 'builder', 'config', 'document', 'editable_label', 'extras', 'media_overlays', 'pointer', 'scribble', 'surfacecache', 'talk_time', 'ui', 'util'] pympress-1.8.5/pympress/__main__.py000066400000000000000000000105671453663732000174050ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # pympress # # Copyright 2009, 2010 Thomas Jost # Copyright 2015 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ :mod:`pympress.__main__` -- The entry point of pympress ------------------------------------------------------- """ import logging import os import sys import locale from pympress import util # Setup logging, and catch all uncaught exceptions in the log file. # Load pympress.util early (OS and path-specific things) to load and setup gettext translation asap. logger = logging.getLogger(__name__) logging.basicConfig(filename=util.get_log_path(), level=logging.DEBUG) def uncaught_handler(*exc_info): """ Exception handler, to log uncaught exceptions to our log file. """ logger.critical('Uncaught exception:\n{}'.format(logging.Formatter().formatException(exc_info))) sys.__excepthook__(*exc_info) sys.excepthook = uncaught_handler if util.IS_WINDOWS: if os.getenv('LANG') is None: lang, enc = locale.getdefaultlocale() os.environ['LANG'] = lang # Before any initialisation or imports util.make_windows_dpi_aware() try: loaded_locale = locale.setlocale(locale.LC_ALL, '') except locale.Error as err: logger.exception('Failed loading locale: {}'.format(err)) print('Failed loading locale: {}'.format(err), file=sys.stderr) util.get_translation('pympress').install() try: # python <3.6 does not have this ModuleNotFoundError except NameError: ModuleNotFoundError = ImportError # noqa: A001 -- not shadowing ModuleNotFoundError if it doesn’t exist # Load python bindings for gobject introspections, aka pygobject, aka gi, and pycairo. # These are dependencies that are not specified in the setup.py, so we need to start here. # They are not specified because: # - installing those via pip requires compiling (always for pygobject, if no compatible wheels exist for cairo), # - compiling requires a compiling toolchain, development packages of the libraries, etc., # - all of this makes more sense to be handled by the OS package manager, # - it is hard to make pretty error messages pointing this out at `pip install` time, # as they would have to be printed when the dependency resolution happens. # See https://github.com/Cimbali/pympress/issues/100 try: import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gdk, GLib, Gio import cairo except ModuleNotFoundError: logger.critical('Gobject Introspections and/or pycairo module is missing', exc_info = True) print('\n' + _('ERROR: Gobject Introspections and/or pycairo module is missing, ' + 'make sure Gtk, pygobject and pycairo are installed on your system.') + '\n') print(_('Try your operating system’s package manager, or try running: pip install pygobject pycairo')) print(_('pip will then download and compile pygobject and pycairo, ' + 'for which you need the Gtk and cairo headers (or development packages).') + '\n') print(_('For instructions, refer to https://github.com/Cimbali/pympress/blob/master/README.md#dependencies')) print(_('If using a virtualenv or anaconda, you can also try allowing system site packages.')) print() exit(1) # Finally the real deal: load pympress modules, handle command line args, and start up from pympress import app def main(argv = sys.argv[:]): """ Entry point of pympress. Parse command line arguments, instantiate the UI, and start the main loop. """ app.Pympress().run(argv) if __name__ == "__main__": main() ## # Local Variables: # mode: python # indent-tabs-mode: nil # py-indent-offset: 4 # fill-column: 80 # end: pympress-1.8.5/pympress/app.py000066400000000000000000000312311453663732000164340ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # pympress # # Copyright 2009, 2010 Thomas Jost # Copyright 2015 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ :mod:`pympress.app` -- The Gtk.Application managing the lifetime and CLI ------------------------------------------------------------------------ """ import logging logger = logging.getLogger(__name__) import os import sys import signal import platform import gi import cairo gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gdk, GLib, Gio from pympress import util, config, document, ui, builder class Pympress(Gtk.Application): """ Class representing the single pympress Gtk application. """ #: The :class:`~pympress.ui.UI` object that is the interface of pympress gui = None #: The :class:`~pympress.config.Config` object that holds pympress conferences config = None #: `list` of actions to be passsed to the GUI that were queued before GUI was created action_startup_queue = [] #: `bool` to automatically upgrade log level (DEBUG / INFO at init, then ERROR), False if user set log level auto_log_level = True options = { # long_name: (short_name (int), flags (GLib.OptionFlags), arg (GLib.OptionArg) 'talk-time': (ord('t'), GLib.OptionFlags.NONE, GLib.OptionArg.STRING), 'notes': (ord('N'), GLib.OptionFlags.NONE, GLib.OptionArg.STRING), 'log': (0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING), 'version': (ord('v'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'pause': (ord('P'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'reset': (ord('r'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'next': (ord('n'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'prev': (ord('p'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'first': (ord('f'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'last': (ord('l'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'blank': (ord('b'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), 'quit': (ord('q'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE), } option_descriptions = { # long_name: (description, arg_description) 'talk-time': (_('The estimated (intended) talk time in minutes') + ' ' + _('(and optionally seconds)'), 'mm[:ss]'), 'notes': (_('Set the position of notes on the pdf page') + ' ' + _('(none, left, right, top, bottom, after, odd, or prefix).') + ' ' + _('Overrides the detection from the file.'), ''), 'log': (_('Set level of verbosity in log file:') + ' ' + _('{}, {}, {}, {}, or {}').format('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'), ''), 'version': (_('Print version and exit'), None), 'pause': (_('Toggle pause of talk timer'), None), 'reset': (_('Reset talk timer'), None), 'next': (_('Next slide'), None), 'prev': (_('Previous slide'), None), 'first': (_('First slide'), None), 'last': (_('Last slide'), None), 'blank': (_('Blank/unblank content screen'), None), 'quit': (_('Close opened pympress instance'), None), } version_string = ' '.join([ 'Pympress:', util.get_pympress_meta()['version'], '; Python:', platform.python_version(), '; OS:', platform.system(), platform.release(), platform.version(), '; Gtk {}.{}.{}'.format(Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version()), '; GLib {}.{}.{}'.format(GLib.MAJOR_VERSION, GLib.MINOR_VERSION, GLib.MICRO_VERSION), '; Poppler', document.Poppler.get_version(), document.Poppler.get_backend().value_nick, '; Cairo', cairo.cairo_version_string(), ', pycairo', cairo.version, ]) def __init__(self): GLib.set_application_name('pympress') # GLib.set_prgname('pympress') # Let prgname be auto-determined from sys.argv[0] Gtk.Application.__init__(self, application_id='io.github.pympress', flags=Gio.ApplicationFlags.HANDLES_OPEN | Gio.ApplicationFlags.CAN_OVERRIDE_APP_ID) self.register(None) if not self.get_is_remote(): builder.Builder.setup_actions({ 'log-level': dict(activate=self.set_log_level, state=logger.getEffectiveLevel(), parameter_type=int), }, action_map=self) # Connect proper exit function to interrupt signal.signal(signal.SIGINT, self.quit) for opt in self.options: self.add_main_option(opt, *self.options[opt], *self.option_descriptions.get(opt, ['', None])) def quit(self, *args): # noqa: A003 -- we need to override app.quit() """ Quit and ignore other arguments e.g. sent by signals. """ if self.gui is not None and self.gui.unsaved_changes(): return Gtk.Application.quit(self) return False def do_startup(self): """ Common start-up tasks for primary and remote instances. NB. super(self) causes segfaults, Gtk.Application needs to be used as base. """ self.config = config.Config() # prefere X11 on posix systems because Wayland still has some shortcomings for us, # specifically libVLC and the ability to disable screensavers if util.IS_POSIX: Gdk.set_allowed_backends('x11,*') logger.info(self.version_string) # If there is no display, we can’t get further than this. if os.getenv('PYMPRESS_HEADLESS_TEST', ''): sys.exit(0) Gtk.Application.do_startup(self) def do_activate(self, timestamp=GLib.get_current_time()): """ Activate: show UI windows. Build them if they do not exist, otherwise bring to front. """ if self.gui is None: if self.auto_log_level: self.activate_action('log-level', logging.INFO) self.action_startup_queue.append(('log-level', logging.ERROR)) # Build the UI and windows self.gui = ui.UI(self, self.config) while self.action_startup_queue: self.activate_action(*self.action_startup_queue.pop(0)) Gtk.Application.do_activate(self) self.gui.p_win.present_with_time(timestamp) def set_action_enabled(self, name, value): """ Parse an action name and set its enabled state to True or False. Args: name (`str`): the name of the stateful action value (`bool`): wheether the action should be enabled or disabled """ self.lookup_action(name).set_enabled(value) def set_action_state(self, name, value): """ Parse an action name and set its state wrapped in a :class:`~GLib.Variant`. Args: name (`str`): the name of the stateful action value (`str`, `int`, `bool` or `float`): the value to set. """ self.lookup_action(name).change_state(GLib.Variant(builder.Builder._glib_type_strings[type(value)], value)) def get_action_state(self, name): """ Parse an action name and return its unwrapped state from the :class:`~GLib.Variant`. Args: name (`str`): the name of the stateful action Returns: `str`, `int`, `bool` or `float`: the value contained in the action """ state = self.lookup_action(name).get_state() return builder.Builder._glib_type_getters[state.get_type_string()](state) def activate_action(self, name, parameter=None): """ Parse an action name and activate it, with parameter wrapped in a :class:`~GLib.Variant` if it is not None. Args: name (`str`): the name of the stateful action parameter: an object or None to pass as a parameter to the action, wrapped in a GLib.Variant """ if not self.get_is_remote() and self.gui is None and name not in ['log-level']: self.action_startup_queue.append((name, parameter)) return if parameter is not None: parameter = GLib.Variant(builder.Builder._glib_type_strings[type(parameter)], parameter) Gio.ActionGroup.activate_action(self, name, parameter) def do_open(self, files, n_files, hint): """ Handle opening files. In practice we only open once, the last one. Args: files (`list` of :class:`~Gio.File`): representing an array of files to open n_files (`int`): the number of files passed. hint (`str`): a hint, such as view, edit, etc. Should always be the empty string. """ if not n_files: return self.do_activate(timestamp=GLib.get_current_time()) self.gui.swap_document(files[-1].get_uri()) def do_shutdown(self): """ Perform various cleanups and save preferences. """ if self.gui is not None: self.gui.cleanup() self.config.save_config() Gtk.Application.do_shutdown(self) util.close_opened_resources() def set_log_level(self, action, param): """ Action that sets the logging level (on the root logger of the active instance) Args: action (:class:`~Gio.Action`): The action activatd param (:class:~`GLib.Variant`): The desired level as an int wrapped in a GLib.Variant """ logging.getLogger(None).setLevel(param.get_int64()) action.change_state(param) def do_handle_local_options(self, opts_variant_dict): """ Parse command line options, returned as a VariantDict Returns: `tuple`: estimated talk time, log level, notes positions. """ # convert GVariantDict -> GVariant -> dict opts = opts_variant_dict.end().unpack() simple_actions = { 'pause': 'pause-timer', 'reset': 'reset-timer', 'next': 'next-page', 'prev': 'prev-page', 'blank': 'blank-screen', 'quit': 'quit', 'first': 'first-page', 'last': 'last-page', } for opt, arg in opts.items(): if opt == "version": print(self.version_string) return 0 elif opt == "log": numeric_level = getattr(logging, arg.upper(), None) if isinstance(numeric_level, int): self.auto_log_level = False self.activate_action('log-level', numeric_level) else: print(_("Invalid log level \"{}\", try one of {}").format( arg, "DEBUG, INFO, WARNING, ERROR, CRITICAL" )) elif opt == "notes": arg = arg.lower()[:1] if arg == 'n': self.activate_action('notes-pos', 'none') if arg == 'l': self.activate_action('notes-pos', 'left') if arg == 'r': self.activate_action('notes-pos', 'right') if arg == 't': self.activate_action('notes-pos', 'top') if arg == 'b': self.activate_action('notes-pos', 'bottom') if arg == 'a': self.activate_action('notes-pos', 'after') if arg == 'o': self.activate_action('notes-pos', 'odd') if arg == 'p': self.activate_action('notes-pos', 'map') # prefix -> map elif opt == "talk-time": t = ["0" + n.strip() for n in arg.split(':')] try: m = int(t[0]) s = int(t[1]) except ValueError: print(_("Invalid time (mm or mm:ss expected), got \"{}\"").format(arg)) return 2 except IndexError: s = 0 self.activate_action('set-talk-time', m * 60 + s) elif opt in simple_actions: self.activate_action(simple_actions[opt]) return -1 pympress-1.8.5/pympress/builder.py000066400000000000000000000362131453663732000173070ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # builder.py # # Copyright 2017 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ :mod:`pympress.ui_builder` -- abstract GUI management ----------------------------------------------------- This module contains the tools to load the graphical user interface of pympress, building the widgets/objects from XML (glade) files, applying translation "manually" to avoid dealing with all the mess of C/GNU gettext's bad portability. """ import logging logger = logging.getLogger(__name__) import copy from collections import deque import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, GObject, GLib, Gio from pympress import util class Builder(Gtk.Builder): """ GUI builder, inherits from :class:`~Gtk.Builder` to read XML descriptions of GUIs and load them. """ #: `set` of :class:`~Gtk.Widget`s that have been built by the builder, and translated __built_widgets = set() #: `dict` mapping :class:`~Gtk.Paned` names to a `tuple` of (handler id of the size-allocate signal, remaining #: number of times we allow this signal to run), and we run the signal 2 * (depth + 1) for each pane. #: This is because size allocation is done bottom-up but each pane sets a top-down constraint. pending_pane_resizes = {} _glib_type_strings = { float: 'd', bool: 'b', int: 'x', str: 's', } _glib_type_getters = { 'd': GLib.Variant.get_double, 'b': GLib.Variant.get_boolean, 'x': GLib.Variant.get_int64, 's': GLib.Variant.get_string, } def __init__(self): super(Builder, self).__init__() @staticmethod def __translate_widget_strings(a_widget): """ Calls gettext on all strings we can find in a_widgets. Args: a_widget (:class:`~GObject.Object`): an object built by the builder, usually a widget """ for str_prop in (prop.name for prop in a_widget.props if prop.value_type == GObject.TYPE_STRING): try: str_val = getattr(a_widget.props, str_prop) if str_val: setattr(a_widget.props, str_prop, _(str_val)) except TypeError: # Thrown when a string property is not readable pass @staticmethod def __recursive_translate_menu(menu): """ Calls gettext on all strings we can find in widgets, and recursively on its children. Args: menu (:class:`~Gio.Menu`): the menu to translate """ menu_items = [] for n in range(menu.get_n_items()): item = Gio.MenuItem.new_from_model(menu, n) label = item.get_attribute_value(Gio.MENU_ATTRIBUTE_LABEL, GLib.VariantType('s')) if label: item.set_label(_(label.get_string())) menu_items.append(item) link_iter = menu.iterate_item_links(n) while link_iter.next(): Builder.__recursive_translate_menu(link_iter.get_value()) menu.remove_all() for item in menu_items: menu.append_item(item) def get_callback_handler(self, handler_name): """ Returns the handler from its name, searching in target. Parse handler names and split on '.' to use recursion. Args: target (`object`): An object that has a method called `handler_name` handler_name (`str`): The name of the function to be connected to a signal Returns: `function`: A function bound to an object """ target = self for attr in handler_name.split('.'): try: target = getattr(target, attr) except AttributeError: logger.error('Can not reach target of signal {}.{}()'.format(self, handler_name), exc_info=True) return None return target def signal_connector(self, builder, obj, signal_name, handler_name, connect_object, flags, *user_data): """ Callback for signal connection. Implements the `~Gtk.BuilderConnectFunc` function interface. Args: builder (:class:`~pympress.builder.Builder`): The builder, unused obj (:class:`~GObject.Object`): The object (usually a wiget) that has a signal to be connected signal_name (`str`): The name of the signal handler_name (`str`): The name of the function to be connected to the signal connect_object (:class:`~GObject.Object`): unused flags (:class:`~GObject.ConnectFlags`): unused user_data (`tuple`): supplementary positional arguments to be passed to the handler """ try: handler = self.get_callback_handler(handler_name) obj.connect(signal_name, handler, *user_data) except Exception: logger.critical('Impossible to connect signal {} from object {} to handler {}' .format(signal_name, obj, handler_name), exc_info = True) def connect_signals(self, base_target): """ Signal connector connecting to properties of `base_target`, or properties of its properties, etc. Args: base_target (:class:`~pympress.builder.Builder`): The target object, that has functions to be connected to signals loaded in this builder. """ Builder.connect_signals_full(base_target, self.signal_connector) def load_ui(self, resource_name, **kwargs): """ Loads the UI defined in the file named resource_name using the builder. Args: resource_name (`str`): the basename of the glade file (without extension), identifying the resource to load. """ self.add_from_file(util.get_ui_resource_file(resource_name, **kwargs)) # Get all newly built objects new_objects = set(self.get_objects()) - self.__built_widgets self.__built_widgets.update(new_objects) for obj in new_objects: # pass new objects to manual translation if isinstance(obj, Gio.Menu): self.__recursive_translate_menu(obj) else: self.__translate_widget_strings(obj) # Instrospectively load objects. If we have a self.attr == None and this attr is the name of a built object, # link it together. if issubclass(type(obj), Gtk.Buildable): obj_id = Gtk.Buildable.get_name(obj) # set Gtk.Widget.name property to the value of the Gtk.Buildable id if issubclass(type(obj), Gtk.Widget): Gtk.Widget.set_name(obj, obj_id) if hasattr(self, obj_id) and getattr(self, obj_id) is None: setattr(self, obj_id, obj) def list_attributes(self, target): """ List the None-valued attributes of target. Args: target (`dict`): An object with None-valued attributes """ for attr in dir(target): try: if attr[:2] + attr[-2:] != '____' and getattr(target, attr) is None: yield attr except RuntimeError: pass def load_widgets(self, target): """ Fill in target with the missing elements introspectively. This means that all attributes of `target` that are None now must exist under the same name in the builder. Args: target (`dict`): An object with None-valued properties whose names correspond to ids of built widgets. """ for attr in self.list_attributes(target): setattr(target, attr, self.get_object(attr)) def replace_layout(self, layout, top_widget, leaf_widgets, pane_resize_handler=None): """ Remix the layout below top_widget with the layout configuration given in 'layout' (assumed to be valid!). Args: layout (`dict`): the json-parsed config string, thus a hierarchy of lists/dicts, with strings as leaves top_widget (:class:`~Gtk.Container`): The top-level widget under which we build the hierachyy leaf_widgets (`dict`): the map of valid leaf identifiers (strings) to the corresponding :class:`~Gtk.Widget` pane_resize_handler (function): callback function to be called when the panes are resized Returns: `dict`: The mapping of the used :class:`~Gtk.Paned` widgets to their relative handle position (in 0..1). """ # take apart the previous/default layout containers = [] widgets = top_widget.get_children() i = 0 while i < len(widgets): w = widgets[i] if w in self.placeable_widgets.values(): pass elif issubclass(type(w), Gtk.Box) or issubclass(type(w), Gtk.Paned): widgets.extend(w.get_children()) containers.append(w) w.get_parent().remove(w) i += 1 # cleanup widgets del widgets[:] while containers: containers.pop().destroy() self.pending_pane_resizes.clear() # iterate over new layout to build it, using a BFS widgets_to_add = deque([(top_widget, copy.deepcopy(layout))]) pane_resize = set() pane_handle_pos = {} while widgets_to_add: parent, w_desc = widgets_to_add.popleft() if type(w_desc) is str: w = leaf_widgets[w_desc] else: # get new container widget if 'resizeable' in w_desc and w_desc['resizeable']: orientation = getattr(Gtk.Orientation, w_desc['orientation'].upper()) w = Gtk.Paned.new(orientation) w.set_wide_handle(True) # Add on resize events if pane_resize_handler: w.connect('notify::position', pane_resize_handler) w.connect('button-release-event', pane_resize_handler) # left pane is first child widgets_to_add.append((w, w_desc['children'].pop())) if 'proportions' in w_desc: right_pane = w_desc['proportions'].pop() left_pane = w_desc['proportions'].pop() w_desc['proportions'].append(left_pane + right_pane) pane_handle_pos[w] = left_pane / (left_pane + right_pane) pane_resize.add(w) else: pane_handle_pos[w] = 0.5 hid = w.connect('size-allocate', self.resize_paned, pane_handle_pos[w]) w.set_name('GtkPaned{}'.format(len(self.pending_pane_resizes))) self.pending_pane_resizes[w.get_name()] = (hid, 2 * len(self.pending_pane_resizes) + 1) # if more than 2 children are to be added, add the 2+ from the right side in a new child Gtk.Paned widgets_to_add.append((w, w_desc['children'][0] if len(w_desc['children']) == 1 else w_desc)) else: w = Gtk.Box.new(getattr(Gtk.Orientation, w_desc['orientation'].upper()), 5) w.set_homogeneous(True) w.set_spacing(10) widgets_to_add.extend((w, c) for c in w_desc['children']) if issubclass(type(parent), Gtk.Box): parent.pack_start(w, True, True, 0) else: # it's a Gtk.Paned if parent.get_child2() is None: parent.pack2(w, True, True) if parent.get_orientation() == Gtk.Orientation.HORIZONTAL: w.set_margin_start(8) else: w.set_margin_top(8) else: parent.pack1(w, True, True) if parent.get_orientation() == Gtk.Orientation.HORIZONTAL: w.set_margin_end(8) else: w.set_margin_bottom(8) return pane_handle_pos def resize_paned(self, paned, rect, relpos): """ Resize `paned` to have its handle at `relpos`, then disconnect this signal handler. Called from the :func:`Gtk.Widget.signals.size_allocate` signal. Args: paned (:class:`~Gtk.Paned`): Panel whose size has just been allocated, and whose handle needs initial placement. rect (:class:`~Gdk.Rectangle`): The rectangle specifying the size that has just been allocated to `~paned` relpos (`float`): A number between `0.` and `1.` that specifies the handle position Returns: `True` """ size = rect.width if paned.get_orientation() == Gtk.Orientation.HORIZONTAL else rect.height handle_pos = int(round(relpos * size)) GLib.idle_add(paned.set_position, handle_pos) name = paned.get_name() handler_id, sizes = self.pending_pane_resizes[name] self.pending_pane_resizes[name] = (handler_id, sizes - 1) if sizes <= 0: paned.disconnect(handler_id) return True @staticmethod def setup_actions(actions, action_map=None): """ Sets up actions with a given prefix, using the Application as the ActionMap. Args: actions (`dict`): Maps the action names to dictionaries containing their parameters. action_map (:class:`~Gio.ActionMap`): The object implementing the action map interface to register actions """ if action_map is None: action_map = Gio.Application.get_default() for action_name, details in actions.items(): state = details.get('state') param = details.get('parameter_type') enabled = details.get('enabled') if param is not None: param = GLib.VariantType.new(Builder._glib_type_strings[param]) if state is not None: state = GLib.Variant(Builder._glib_type_strings[type(state)], state) action = Gio.SimpleAction.new_stateful(action_name, param, state) else: action = Gio.SimpleAction.new(action_name, param) for event in ['activate', 'change-state']: handler = details.get(event.replace('-', '_')) if handler is not None: action.connect(event, handler) action_map.add_action(action) if enabled is not None: action.set_enabled(enabled) return action_map pympress-1.8.5/pympress/config.py000066400000000000000000000546731453663732000171400ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # config.py # # Copyright 2017 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ :mod:`pympress.config` -- Configuration --------------------------------------- """ import logging logger = logging.getLogger(__name__) import os import pathlib import json import configparser from collections import deque import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, GLib, Gio from pympress import util class Config(configparser.ConfigParser, object): # python 2 fix """ Manage configuration :Get the configuration from its file and store its back. """ #: `dict`-tree of presenter layouts for various modes layout = {} #: `dict`-tree of presenter layouts that are not configurable static_layout = { 'deck-overview': 'deck', } #: `dict` of strings that are the valid representations of widgets from the presenter window #: that can be dynamically rearranged, mapping to their names placeable_widgets = {"notes": "p_frame_notes", "current": "p_frame_cur", "next": "grid_next", "annotations": "p_frame_annot", "highlight": "scribble_overlay"} #: `dict` mapping layout ids to tuples of their expected and optional widgets widget_reqs = { 'notes': (set(placeable_widgets.keys()) - {"annotations", "highlight"}, {"annotations"}), 'plain': (set(placeable_widgets.keys()) - {"notes", "highlight"},), 'note_pages': (set(placeable_widgets.keys()) - {"current", "highlight"},), 'highlight': ({"highlight"}, set(placeable_widgets.keys()) - {"highlight"}), 'highlight_notes': ({"highlight"}, set(placeable_widgets.keys()) - {"highlight"}), } #: `dict` mapping accelerator keys to actions shortcuts = {} @staticmethod def path_to_config(search_legacy_locations=False): """ Return the path to the currently used configuration file. Args: search_legacy_locations (`bool`): whether to look in previously used locations Returns: :class:`~pathlib.Path`: The path to the config file to use """ if Config.using_portable_config(): return util.get_portable_config() user_config = util.get_user_config() # migrate old configuration files from previously-used erroneous locations if search_legacy_locations and (util.IS_POSIX or util.IS_MAC_OS) and not user_config.exists(): for legacy_location in ['~/.pympress', '~/.config/pympress']: legacy_location = pathlib.Path(legacy_location).expanduser() if legacy_location.exists(): legacy_location.rename(user_config) return user_config @staticmethod def toggle_portable_config(gaction, param=None): """ Create or remove a configuration file for portable installs. The portable install file will be used by default, and deleting it causes the config to fall back to the user profile location. No need to populate the new config file, this will be done on pympress exit. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ portable_config = util.get_portable_config() if portable_config is None: return if Config.using_portable_config(): portable_config.unlink() else: with open(portable_config, 'w'): pass gaction.set_state(GLib.Variant.new_boolean(Config.using_portable_config())) @staticmethod def using_portable_config(): """ Checks which configuration file location is in use. Returns: `bool`: `True` iff we are using the portable (i.e. in install dir) location """ portable_config = util.get_portable_config() return portable_config is not None and portable_config.exists() def __init__(config): # Remove : from delimiters so we can use it in preferences super(Config, config).__init__(delimiters=('=',)) # populate values first from the default config file, then from the proper one config.read(util.get_default_config()) config.load_window_layouts() # modify defaults if 'I3SOCK' in os.environ: logger.info('Detected i3 window manager, disabled content window starting fullscreen by default') config.set('content', 'start_fullscreen', 'off') all_commands = dict(config.items('shortcuts')).keys() config.read(config.path_to_config(True)) config.upgrade() config.load_window_layouts() for command in all_commands: # NB only parsing commands from defaults parse_ok, action_name, target_value = Gio.Action.parse_detailed_name('app.' + command) if not parse_ok or not Gio.action_name_is_valid(action_name): logger.error('Failed parsing command ' + command) continue parsed_accels = {keys: Gtk.accelerator_parse(keys) for keys in config.get('shortcuts', command).split()} failed = [keys for keys, parsed in parsed_accels.items() if parsed == (0, 0)] if failed: logger.warning('Failed parsing shortcut(s) for "{}": "{}"'.format(command, '", "'.join(failed))) keep_accels = [keys for keys, parsed in parsed_accels.items() if parsed != (0, 0)] if keep_accels: config.shortcuts[command] = keep_accels def register_actions(self, builder): """ Register actions that impact the config file only. Args: builder (:class:`pympress.builder.Builder`): a builder to setup the actions """ p_full = self.getboolean('presenter', 'start_fullscreen') c_full = self.getboolean('content', 'start_fullscreen') blank = self.getboolean('content', 'start_blanked') portable = self.using_portable_config() can_port = util.get_portable_config() is not None builder.setup_actions({ 'start-content-fullscreen': dict(activate=self.toggle_start, state=c_full), 'start-presenter-fullscreen': dict(activate=self.toggle_start, state=p_full), 'start-blanked': dict(activate=self.toggle_start, state=blank), 'portable-config': dict(activate=self.toggle_portable_config, state=portable, enabled=can_port), }) def upgrade(self): """ Update obsolete config options when pympress updates. """ if self.get('presenter', 'pointer') == 'pointer_none': self.set('presenter', 'pointer', 'red') self.set('presenter', 'pointer_mode', 'disabled') if self.has_section('scribble') and self.has_option('scribble', 'color'): self.set('scribble', 'color_9', self.get('scribble', 'color')) self.remove_option('scribble', 'color') self.set('scribble', 'active_pen', '9') if self.has_section('scribble') and self.has_option('scribble', 'width'): self.set('scribble', 'width_9', self.get('scribble', 'width')) self.remove_option('scribble', 'width') self.set('scribble', 'active_pen', '9') if self.has_option('presenter', 'monitor'): self.remove_option('presenter', 'monitor') if self.has_option('content', 'monitor'): self.remove_option('content', 'monitor') if self.has_section('scribble'): for key, val in self.items('scribble'): self.set('highlight', key, val) self.remove_section('scribble') if self.has_section('gst'): for key, val in self.items('gst'): self.set('gstreamer', key, 'on' if key == 'enabled' else val) self.remove_section('gst') # When we went from gtk signal handlers to actions, some renaming had to be done for old, new in { 'next': 'next-page', 'prev': 'prev-page', 'next_label': 'next-label', 'prev_label': 'prev-label', 'hist_back': 'hist-back', 'hist_forward': 'hist-forward', 'first': 'first-page', 'last': 'last-page', 'goto_page': 'goto-page', 'jumpto_label': 'jumpto-label', 'fullscreen_content': 'content-fullscreen', 'fullscreen_presenter': 'presenter-fullscreen', 'pause_timer': 'pause-timer', 'reset_timer': 'reset-timer', 'talk_time': 'edit-talk-time', 'blank_screen': 'blank-screen', 'notes_mode': 'notes-mode', 'swap_screens': 'swap-screens', 'open_file': 'pick-file', 'close_file': 'close-file', 'validate': 'validate-input', 'cancel': 'cancel-input', 'undo_scribble': 'highlight-undo', 'redo_scribble': 'highlight-redo', 'scribble_preset_1': 'highlight-use-pen::1', 'scribble_preset_2': 'highlight-use-pen::2', 'scribble_preset_3': 'highlight-use-pen::3', 'scribble_preset_4': 'highlight-use-pen::4', 'scribble_preset_5': 'highlight-use-pen::5', 'scribble_preset_6': 'highlight-use-pen::6', 'scribble_preset_7': 'highlight-use-pen::7', 'scribble_preset_8': 'highlight-use-pen::8', 'scribble_preset_9': 'highlight-use-pen::9', 'scribble_preset_0': 'highlight-use-pen::eraser', 'toggle_pointermode': 'pointer-mode::toggle', }.items(): shortcut = self.get('shortcuts', old, fallback=None) if shortcut is not None: if old in {'hist_back', 'hist_forward'} and 'backspace' in shortcut.lower(): # In the Gio.Action operations, accelerators have precedence over widgets, which # means it’s very annoying to edit a shortcut if backspace is mapped to anything. logger.warning('Changing shortcut for "{}" to new default instead of keeping backspace'.format(new)) else: self.set('shortcuts', new, shortcut) self.remove_option('shortcuts', old) def getlist(self, *args): """ Parse a config value and return the list by splitting the value on commas. i.e. bar = foo,qux returns the list ['foo', 'qux'] Returns: `list`: a config value split into a list. """ return [t.strip() for t in self.get(*args).split(',') if t.strip()] def getint(self, *args, **kwargs): """ Wrapper for configparser’s getint to handle parsing errors when a fallback is given. See :meth:`~configparser.Configparser.getint()` """ try: return super(Config, self).getint(*args, **kwargs) except ValueError: if 'fallback' not in kwargs: raise logger.warning(_('Error parsing option from config file {}.{} "{}" to int'.format(*args, self.get(*args))), exc_info=True) return kwargs['fallback'] def getfloat(self, *args, **kwargs): """ Wrapper for configparser’s to handle parsing errors when a fallback is given. See :meth:`~configparser.Configparser.getfloat()` """ try: return super(Config, self).getfloat(*args, **kwargs) except ValueError: if 'fallback' not in kwargs: raise logger.warning(_('Error parsing option from config file {}.{} "{}" to float') .format(*args, self.get(*args)), exc_info=True) return kwargs['fallback'] def getboolean(self, *args, **kwargs): """ Wrapper for configparser’s getboolean to handle parsing errors when a fallback is given. :meth:`~configparser.Configparser.getboolean()` """ try: return super(Config, self).getboolean(*args, **kwargs) except ValueError: if 'fallback' not in kwargs: raise logger.warning(_('Error parsing option from config file {}.{} "{}" to bool'.format(*args, self.get(*args))), exc_info=True) return kwargs['fallback'] def save_config(self): """ Save the configuration to its file. """ # serialize the layouts for layout_name in self.layout: self.set('layout', layout_name, json.dumps(self.layout[layout_name], indent=4)) with open(self.path_to_config(), 'w') as configfile: self.write(configfile) def toggle_start(self, gaction, param=None): """ Generic function to toggle some boolean startup configuration. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ # action is named start(-presenter|-content)?-property start, *win, prop = gaction.get_name().split('-') window = win[0] if win else 'content' start_conf = 'start_' + prop new_state = not gaction.get_state().get_boolean() gaction.set_state(GLib.Variant.new_boolean(new_state)) self.set(window, start_conf, 'on' if new_state else 'off') def validate_layout(self, layout, expected_widgets, optional_widgets = set()): """ Validate layout: check whether the layout of widgets built from the config string is valid. Args: layout (`dict`): the json-parsed config string expected_widgets (`set`): strings with the names of widgets that have to be used in this layout optional_widgets (`set`): strings with the names of widgets that may or may not be used in this layout Layout must have all self.placeable_widgets (leaves of the tree, as `str`) and only allowed properties on the nodes of the tree (as `dict`). Constraints on the only allowed properties of the nodes are: - resizeable: `bool` (optional, defaults to no), - orientation: `str`, either "vertical" or "horizontal" (mandatory) - children: `list` of size >= 2, containing `str`s or `dict`s (mandatory) - proportions: `list` of `float` with sum = 1, length == len(children), representing the relative sizes of all the resizeable items (if and only if resizeable). """ next_visits = deque([layout]) widget_seen = set() while next_visits: w_desc = next_visits.popleft() if type(w_desc) is str: if w_desc not in expected_widgets and w_desc not in optional_widgets: raise ValueError('Unrecognized widget "{}", pick one of: {}' .format(w_desc, ', '.join(expected_widgets))) elif w_desc in widget_seen: raise ValueError('Duplicate widget "{}", all expected_widgets can only appear once'.format(w_desc)) widget_seen.add(w_desc) elif type(w_desc) is dict: if 'orientation' not in w_desc or w_desc['orientation'] not in ['horizontal', 'vertical']: raise ValueError('"orientation" is mandatory and must be "horizontal" or "vertical" at node {}' .format(w_desc)) elif 'children' not in w_desc or type(w_desc['children']) is not list or len(w_desc['children']) < 2: raise ValueError('"children" is mandatory and must be a list of 2+ items at node {}'.format(w_desc)) elif 'resizeable' in w_desc and type(w_desc['resizeable']) is not bool: raise ValueError('"resizeable" must be boolean at node {}'.format(w_desc)) elif 'proportions' in w_desc: if 'resizeable' not in w_desc or not w_desc['resizeable']: raise ValueError('"proportions" is only valid for resizeable widgets at node {}'.format(w_desc)) elif type(w_desc['proportions']) is not list or \ any(type(n) is not float for n in w_desc['proportions']) or \ len(w_desc['proportions']) != len(w_desc['children']) or \ sum(w_desc['proportions']) < 1e-2: raise ValueError('"proportions" must be a list of floats (one per separator), ' 'that sum to 1, at node {}'.format(w_desc)) elif abs(sum(w_desc['proportions']) - 1) > 1e-10: w_desc['proportions'] = [p / sum(w_desc['proportions']) for p in w_desc['proportions']] next_visits.extend(w_desc['children']) else: raise ValueError('Unexpected type {}, nodes must be dicts or strings, at node {}' .format(type(w_desc), w_desc)) widget_missing = expected_widgets - widget_seen if widget_missing: raise ValueError('Following placeable_widgets were not specified: {}'.format(', '.join(widget_missing))) def update_layout_tree(self, layout_name, layout): """ Update the layout named `~layout_name`. Throws ValueError on invalid layouts. Args: layout_name (`str`): the name of the layout to update layout (`dict`): the hierarchy of dictionaries, lists, and strings representing the layout """ self.validate_layout(layout, *self.widget_reqs[layout_name]) self.layout[layout_name] = layout def load_window_layouts(self): """ Parse and validate layouts loaded from config, with fallbacks if needed. """ for layout_name in self.widget_reqs: # Log error and keep default layout try: self.update_layout_tree(layout_name, json.loads(self.get('layout', layout_name))) except ValueError: logger.exception('Invalid layout for {}'.format(layout_name)) def widget_layout_to_tree(self, widget, pane_handle_pos): """ Build a tree representing a widget hierarchy, leaves are strings and nodes are `dict`. Recursive function. See validate_layout() for more info on the tree structure. Args: widget (:class:`~Gtk.Widget`): the widget where to start pane_handle_pos (`dict`): Map of :class:`~Gtk.Paned` to the relative handle position (float in 0..1) Returns: `dict`: A tree of dicts reprensenting the widget hierarchy """ orientation_names = {Gtk.Orientation.HORIZONTAL: 'horizontal', Gtk.Orientation.VERTICAL: 'vertical'} name = widget.get_name() matching_widget_names = [k for k, v in self.placeable_widgets.items() if v == name] if matching_widget_names: return matching_widget_names[0] elif issubclass(type(widget), Gtk.Box): return {'resizeable': False, 'orientation': orientation_names[widget.get_orientation()], 'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in widget.get_children()]} elif issubclass(type(widget), Gtk.Paned): proportions = [1] reverse_children = [] orientation = widget.get_orientation() if orientation == Gtk.Orientation.HORIZONTAL: get_size = Gtk.Widget.get_allocated_width else: get_size = Gtk.Widget.get_allocated_height while issubclass(type(widget), Gtk.Paned) and orientation == widget.get_orientation(): left_pane = widget.get_child1() right_pane = widget.get_child2() visible = left_pane.get_visible() and right_pane.get_visible() position = widget.get_position() widget_size = get_size(widget) if not visible or widget_size <= 1: # reuse number that was in config initially, otherwise gets overwritten with 0 ratio = pane_handle_pos[widget] else: ratio = float(position) / widget_size pane_handle_pos[widget] = ratio proportions = [ratio] + [(1 - ratio) * p for p in proportions] reverse_children.append(right_pane) widget = left_pane reverse_children.append(left_pane) return {'resizeable': True, 'proportions': proportions, 'orientation': orientation_names[orientation], 'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in reversed(reverse_children)]} raise ValueError('Error serializing layout: widget of type {} '.format(type(widget)) + 'is not an expected container or named widget: "{}"'.format(name)) def get_layout(self, layout_name): """ Getter for the `~layout_name` layout. """ try: return self.layout[layout_name] except KeyError: return self.static_layout[layout_name] def update_layout_from_widgets(self, layout_name, widget, pane_handle_pos): """ Setter for the notes layout. Args: layout_name (`str`): the name of the layout to update widget (:class:`~Gtk.Widget`): the widget that will contain the layout. pane_handle_pos (`dict`): Map of :class:`~Gtk.Paned` to the relative handle position (float in 0..1) """ if layout_name not in self.static_layout: return self.update_layout_tree(layout_name, self.widget_layout_to_tree(widget, pane_handle_pos)) pympress-1.8.5/pympress/deck.py000066400000000000000000000311151453663732000165630ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # deck.py # # Copyright 2023 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ :mod:`pympress.deck` -- Manage user drawings on the current slide --------------------------------------------------------------------- """ import logging logger = logging.getLogger(__name__) import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gdk, GLib from pympress import builder class Overview(builder.Builder): """ UI that allows to draw free-hand on top of the current slide. Args: config (:class:`~pympress.config.Config`): A config object containing preferences builder (:class:`~pympress.builder.Builder`): A builder from which to load widgets notes_mode (`bool`): The current notes mode, i.e. whether we display the notes on second slide """ #: Whether we are displaying the deck overview on screen deck_mode = False #: :class:`~Gtk.Viewport` that replaces normal panes when deck is shown deck_viewport = None #: :class:`~Gtk.Grid` that displays all the slides of the overview deck_grid = None #: The :class:`~Gtk.DrawingArea` for the first slide deck0 = None #: A :class:`~Gtk.OffscreenWindow` where we render the deck interface when it's not shown deck_off_render = None #: :class:`~Gtk.Box` in the Presenter window, where we insert deck. p_central = None #: callback, to be connected to :func:`~pympress.ui.UI.compute_frame_grid` compute_frame_grid = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.load_layout` load_layout = lambda *args: None #: callback, to be connected to :func:`~pympress.surfacecache.SurfaceCache.resize_widget` resize_cache = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.goto_page` goto_page = lambda *args: None #: :class:`~pympress.surfacecache.SurfaceCache` instance. cache = None #: `tuple` of rows/columns in the grid grid_size = (0, 0) #: `bool` whether we show all pages or remove consecutive identically labeled pages, keeping only the last all_pages = False #: `int` How large (at most) to make rows max_row_size = 6 #: The :class:`~Gtk.DrawingArea` in the content window c_da = None def __init__(self, config, builder, notes_mode): super(Overview, self).__init__() self.cache = builder.cache self.load_ui('deck') builder.load_widgets(self) self.deck_da_list = [self.deck0] self.get_application().add_window(self.deck_off_render) self.load_layout = builder.get_callback_handler('load_layout') self.goto_page = builder.get_callback_handler('goto_page') self.compute_frame_grid = builder.get_callback_handler('compute_frame_grid') self.setup_doc_callbacks(builder.doc) self.connect_signals(self) self.max_row_size = config.getint('deck-overview', 'max-slides-per-row') # Whether to show all pages or only distinctly labeled pages (useful for latex) self.all_pages = not config.get('deck-overview', 'distinct-labels-only') self.setup_actions({ 'deck-overview': dict(activate=self.switch_deck_overview, state=False), }) def on_deck_hover(self, widget, event): """ Track when each deck in the slide is hovered """ ctx = widget.get_style_context() ctx.set_state(Gtk.StateFlags.PRELIGHT if event.type == Gdk.EventType.ENTER_NOTIFY else Gtk.StateFlags.NORMAL) widget.queue_draw() def setup_doc_callbacks(self, doc): """ Callbacks that need to be setup again at every new document Args: doc (:class:`~pympress.document.Document`): The new document that got loaded """ self.pages_number = doc.pages_number self.has_labels = doc.has_labels self.get_last_label_pages = doc.get_last_label_pages self.create_drawing_areas() def try_cancel(self): """ Cancel deck, if it is enabled. Returns: `bool`: `True` if deck got cancelled, `False` if it was already disabled. """ if not self.deck_mode: return False self.disable_deck_overview() return True def create_drawing_areas(self): """ Build DrawingArea and AspectFrame elements to display later on """ pages = self.get_last_label_pages() if self.has_labels() else range(self.pages_number()) self.grid_size = (len(pages), 1) # Always keep the first drawing area as it is used to provide surfaces in the cache for row in range(1, self.grid_size[0]): self.deck_grid.remove_row(row) for col in range(1, self.grid_size[1]): self.deck_grid.remove_row(col) # Set drawing areas for num, da in enumerate(self.deck_da_list[1:], 1): self.deck_grid.attach(da.get_parent(), 1, num, 1, 1) for num in range(len(self.deck_da_list), len(pages)): da = Gtk.DrawingArea() da.add_events(Gdk.EventMask.TOUCH_MASK | Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK) da.connect('draw', self.on_deck_draw) da.connect('button-release-event', self.on_deck_click) da.connect('touch-event', self.on_deck_click) da.connect('enter-notify-event', self.on_deck_hover) da.connect('leave-notify-event', self.on_deck_hover) self.deck_da_list.append(da) frame = Gtk.AspectFrame() frame.get_style_context().add_class('grid-frame') frame.set_shadow_type(Gtk.ShadowType.NONE) frame.add(da) self.deck_grid.attach(frame, 1, num, 1, 1) ratio = self.c_da.get_allocated_width() / self.c_da.get_allocated_height() for page, da in zip(pages, self.deck_da_list): da.get_parent().set(.5, .5, ratio, False) da.set_name('deck{}'.format(page)) def reset_grid(self, *args): """ Set the slides configuration and size in the grid """ # Gather info about slides to display num_pages = self.pages_number() if self.all_pages or not self.has_labels() else len(self.get_last_label_pages()) ratio = self.c_da.get_allocated_width() / self.c_da.get_allocated_height() ww, wh = self.deck_grid.get_allocated_width(), self.deck_grid.get_allocated_height() sw, sh = self.deck_grid.get_row_spacing(), self.deck_grid.get_column_spacing() rows, cols = self.compute_frame_grid(ww / wh, num_pages) window = self.deck_viewport.get_window() scale = window.get_scale_factor() if not rows or not cols: rows, cols = 1, 1 dw, dh = (ww + sw) / cols - sw, (wh + sh) / rows - sh dw, dh = min(dw, dh * ratio), min(dw / ratio, dh) if cols > self.max_row_size: cols = self.max_row_size rows = (num_pages + cols - 1) // cols dw = (ww + sw) / cols - sw dh = dw / ratio self.cache.resize_widget('deck', int(dw * scale), int(dh * scale)) frames = [da.get_parent() for da in self.deck_da_list] for frame in frames: frame.set(.5, .5, ratio, False) frame.set_size_request(dw / scale, dh / scale) if self.grid_size != (rows, cols): for frame in frames[1:]: self.deck_grid.remove(frame) # Always keep the first drawing area as it is used to provide surfaces in the cache for row in range(rows, self.grid_size[0]): self.deck_grid.remove_row(row) for col in range(cols, self.grid_size[1]): self.deck_grid.remove_row(col) for num, frame in enumerate(frames[1:], 1): self.deck_grid.attach(frame, num % cols, num // cols, 1, 1) # resize grid and cache self.grid_size = rows, cols for da in self.deck_da_list: GLib.idle_add(self.prerender, da) self.deck_grid.show_all() def prerender(self, da): """ Perform in-cache rendering Args: da (:class:`~Gtk.DrawingArea`): the widget for which we’re rendering """ self.cache.renderer('deck', int(da.get_name()[4:])) da.queue_draw() return GLib.SOURCE_REMOVE def on_deck_draw(self, widget, cairo_context): """ Actually draw the deck slide -- only do this from cache, to limit overhead Args: widget (:class:`~Gtk.Widget`): the widget to update cairo_context (:class:`~cairo.Context`): the Cairo context (or `None` if called directly) """ page_num = int(widget.get_name()[4:]) pb = self.cache.get('deck', page_num) if pb is None: # We’ll redraw later widget.queue_draw() return window = widget.get_window() scale = window.get_scale_factor() cairo_context.scale(1. / scale, 1. / scale) cairo_context.set_source_surface(pb, 0, 0) cairo_context.paint() ctx = widget.get_style_context() if ctx.get_state() != Gtk.StateFlags.PRELIGHT: return # Draw a hover border manually color = ctx.get_property('border-color', Gtk.StateFlags.PRELIGHT) width = 2 cairo_context.set_source_rgba(*color) cairo_context.set_line_width(width) ww, wh = widget.get_allocated_width(), widget.get_allocated_height() cairo_context.move_to(width / 2, width / 2) cairo_context.line_to(width / 2, wh - width / 2) cairo_context.line_to(ww - width / 2, wh - width / 2) cairo_context.line_to(ww - width / 2, width / 2) cairo_context.close_path() cairo_context.stroke() def on_deck_click(self, widget, event): """ A slide has been clicked, go to it Args: widget (:class:`~Gtk.Widget`): the widget which has received the key stroke event (:class:`~Gdk.Event`): the GTK event, which contains the key stroke details """ page_num = int(widget.get_name()[4:]) self.goto_page(page_num, False) self.disable_deck_overview() def switch_deck_overview(self, gaction, target=None): """ Starts the mode where one can read on top of the screen. Args: Returns: `bool`: whether the event was consumed """ if target is not None and target == self.deck_mode: return False # Perform the state toggle if self.deck_mode: return self.disable_deck_overview() else: return self.enable_deck_overview() def enable_deck_overview(self): """ Enable the deck view. Returns: `bool`: whether it was possible to enable (thus if it was not enabled already) """ if self.deck_mode: return False self.deck_off_render.remove(self.deck_viewport) self.load_layout('deck-overview') self.p_central.queue_draw() self.deck_viewport.queue_draw() GLib.idle_add(self.reset_grid) self.deck_mode = True self.get_application().lookup_action('deck-overview').change_state(GLib.Variant.new_boolean(self.deck_mode)) self.p_central.queue_draw() return True def disable_deck_overview(self): """ Disable the deck view. Returns: `bool`: whether it was possible to disable (thus if it was not disabled already) """ if not self.deck_mode: return False self.deck_mode = False self.load_layout(None) self.deck_off_render.add(self.deck_viewport) self.get_application().lookup_action('deck-overview').change_state(GLib.Variant.new_boolean(self.deck_mode)) self.p_central.queue_draw() return True pympress-1.8.5/pympress/dialog.py000066400000000000000000000611241453663732000171170ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # extras.py # # Copyright 2021 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ :mod:`pympress.extras` -- Manages the display of fancy extras such as annotations, videos and cursors ----------------------------------------------------------------------------------------------------- """ import logging logger = logging.getLogger(__name__) import sys import copy import itertools import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, GLib from pympress import builder class TimingReport(builder.Builder): """ Widget tracking and displaying hierachically how much time was spent in each page/section of the presentation. """ #: `list` of time at which each page was reached page_time = [] #: `int` the time at which the clock was reset end_time = -1 #: The :class:`~Gtk.TreeView` containing the timing data to display in the dialog timing_treeview = None #: A :class:`~Gtk.Dialog` to contain the timing to show time_report_dialog = None #: `bool` marking whether next page transition should reset the history of page timings clear_on_next_transition = False #: A `dict` containing the structure of the current document doc_structure = {} #: A `list` with the page label of each page of the current document page_labels = [] #: `bool` tracking whether a document is opened document_open = False def __init__(self, parent): super(TimingReport, self).__init__() self.load_ui('time_report_dialog') self.time_report_dialog.set_transient_for(parent.p_win) self.time_report_dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE) self.connect_signals(self) parent.setup_actions({ 'timing-report': dict(activate=self.show_report), }) def transition(self, page, time): """ Record a transition time between slides. Args: page (`int`): the page number of the current slide time (`int`): the number of seconds elapsed since the beginning of the presentation """ if not self.document_open: return if self.clear_on_next_transition: self.clear_on_next_transition = False del self.page_time[:] self.page_time.append((page, time)) def reset(self, reset_time): """ A timer reset. Clear the history as soon as we start changing pages again. """ self.end_time = reset_time self.clear_on_next_transition = True @staticmethod def format_time(secs): """ Formats a number of seconds as `minutes:seconds`. Returns: `str`: The formatted time, with 2+ digits for minutes and 2 digits for seconds. """ return '{:02}:{:02}'.format(*divmod(int(secs), 60)) def set_document_metadata(self, doc_structure, page_labels): """ Show the popup with the timing infortmation. Args: doc_structure (`dict`): the structure of the document page_labels (`list`): the page labels for each of the pages """ self.document_open = len(page_labels) != 0 # Do not update if we only close the document. # That way, the report is still accessible when the document is closed. if not self.document_open: return self.doc_structure = doc_structure self.page_labels = page_labels # Clear the report when there is a new document opened. del self.page_time[:] def show_report(self, gaction, param=None): """ Show the popup with the timing infortmation. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ times = [time for page, time in self.page_time] durations = (e - s for s, e in zip(times, times[1:] + [self.end_time])) min_time = min(time for page, time in self.page_time) if self.page_time else 0 infos = {'time': min_time, 'duration': 0, 'children': [], 'page': 0} infos['title'] = 'Full presentation' for (page, start_time), duration in zip(self.page_time, durations): if not duration: continue infos['duration'] += duration # lookup the position of the page in the document structure (section etc) lookup = self.doc_structure cur_info_pos = infos while lookup: try: pos = max(p for p in lookup if p <= page) except ValueError: break item = lookup[pos] lookup = item.get('children', None) if cur_info_pos['children'] and cur_info_pos['children'][-1]['page'] == pos: cur_info_pos['children'][-1]['duration'] += duration else: cur_info_pos['children'].append({'page': pos, 'title': item['title'], 'children': [], 'duration': duration, 'time': start_time}) cur_info_pos = cur_info_pos['children'][-1] # add the actual page as a leaf node label = self.page_labels[page] if 0 <= page < len(self.page_labels) else 'None' cur_info_pos['children'].append({'page': page, 'title': _('slide #') + label, 'duration': duration, 'time': start_time}) treemodel = self.timing_treeview.get_model() if treemodel: treemodel.clear() treemodel = Gtk.TreeStore(str, str, str, str) npages = len(self.page_labels) maxlen = len(str(npages)) dfs_info = [(None, infos)] while dfs_info: first_it, first = dfs_info.pop() page = first['page'] label = self.page_labels[page] if 0 <= page < len(self.page_labels) else 'None' label += '\u2007' * (maxlen - len(str(page))) last_col = '{} ({}/{})'.format(label, page, npages) row = [first['title'], self.format_time(first['time']), self.format_time(first['duration']), last_col] it = treemodel.append(first_it, row) if 'children' in first: dfs_info.extend((it, child) for child in reversed(first['children'])) self.timing_treeview.set_model(treemodel) self.timing_treeview.expand_row(Gtk.TreePath.new_first(), False) self.time_report_dialog.run() self.time_report_dialog.hide() class LayoutEditor(builder.Builder): """ Widget tracking and displaying hierachically how much time was spent in each page/section of the presentation. """ #: The :class:`~Gtk.TreeView` displaying the hierarchical layouts layout_treeview = None #: The :class:`~Gtk.TreeModel` containing the model of the layouts to view in the treeview layout_treemodel = None #: The :class:`~Gtk.ListModel` containing the possible orientations orientations_model = None #: A :class:`~Gtk.Dialog` to contain the layout edition dialog layout_dialog = None #: A :class:`~Gtk.Label` to contain the description of the layout layout_description = None #: A :class:`~Gtk.ComboBoxText` to select the layout to edit layout_selector = None #: :class:`~pympress.config.Config` to remember preferences config = None #: :class:`~Gio.Action` containing the number of next frames next_frames_action = None #: :class:`~Gio.Action` containing the orientation hltools_orientation_action = None #: `str` containing the layout currently edited current_layout = 'plain' #: callback, to be connected to :func:`~pympress.ui.UI.load_layout` ui_load_layout = lambda *args: None layout_descriptions = { 'notes': _('Layout for beamer notes on second screen (no current slide preview in notes)'), 'plain': _('Plain layout, without note slides'), 'note_pages': _('Layout for libreoffice notes on separate pages (with current slide preview in notes)'), 'highlight': _('Layout to draw on the current slide'), 'highlight_notes': _('Layout to draw on the current slide with notes displayed'), } _model_columns = ['widget', 'has_resizeable', 'resizeable', 'has_orientation', 'orientation', 'next_slide_count', 'widget_name'] def __init__(self, parent, config): super(LayoutEditor, self).__init__() self.load_ui('layout_dialog') self.layout_dialog.set_transient_for(parent.p_win) self.layout_dialog.add_button(Gtk.STOCK_APPLY, Gtk.ResponseType.APPLY) self.layout_dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.layout_selector.get_child().set_editable(False) self.config = config self.ui_load_layout = parent.get_callback_handler('load_layout') self.connect_signals(self) parent.setup_actions({ 'edit-layout': dict(activate=self.show_editor), }) def load_layout(self): """ Load the given layout in the treemodel for display and manipulation in the treeview """ self.layout_description.set_text(self.layout_descriptions[self.current_layout]) self.layout_treemodel.clear() # Display names for the widget ids names = { 'box': _('box'), 'notes': _('notes'), 'current': _('current slide'), 'next': _('next slide(s)'), 'highlight': _('highlighting'), 'annotations': _('annotations (hideable)'), 'vertical': _('vertical'), 'horizontal': _('horizontal'), } next_count = self.next_frames_action.get_state().get_int64() hltools_orientation = self.hltools_orientation_action.get_state().get_string() dfs_info = [(None, self.config.get_layout(self.current_layout))] while dfs_info: it, node = dfs_info.pop() if type(node) is str: orientation = names[hltools_orientation] if node == 'highlight' else '' next_slides = next_count if node == 'next' else 0 self.layout_treemodel.append(it, [node, False, None, bool(orientation), orientation, next_slides, names[node]]) else: next_it = self.layout_treemodel.append(it, ['box', True, node['resizeable'], True, names[node['orientation']], 0, names['box']]) dfs_info.extend((next_it, child) for child in reversed(node['children'])) self.layout_treeview.expand_all() def set_current_layout(self, layout): """ Update which is the layout currently used by the UI Args: layout (`str`): the layout id """ self.current_layout = layout def layout_selected(self, widget, event=None): """ Manage events for the layout selector drop-down menu Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified event (:class:`~Gdk.Event`): the GTK event """ self.current_layout = widget.get_active_id() self.load_layout() def get_info(self, path): """ Given a path string, look up the appropriate item in both the actual and GtkStore models Args: path (`str`): A string representing a path in the treemodel Returns: `dict`, :class:`~Gtk.TreeIter`: the node and iterator representing the position in the layout and model """ pos = Gtk.TreePath.new_from_string(path) tree_it = self.layout_treemodel.get_iter(pos) node = {'children': [self.config.get_layout(self.current_layout)]} for n in pos.get_indices(): node = node['children'][n] return node, tree_it def resizeable_toggled(self, widget, path): """ Handle when box’ resizeable value is toggled Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified path (`str`): A string representing the path to the modfied item """ node, tree_it = self.get_info(path) value = not node['resizeable'] node['resizeable'] = value self.layout_treemodel.set_value(tree_it, self._model_columns.index('resizeable'), value) self.normalize_layout(reload=False) def orientation_changed(self, widget, path, orient_it): """ Handle when the orientation of a box is changed Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified path (`str`): A string representing the path to the modfied item orient_it (:class:`~Gtk.TreeIter`): the row of the newly selected value in the orientations liststore model """ value = self.orientations_model.get_value(orient_it, 1) node, tree_it = self.get_info(path) if node == 'highlight': self.hltools_orientation_action.activate(GLib.Variant.new_string(value)) else: node['orientation'] = value self.layout_treemodel.set_value(tree_it, self._model_columns.index('orientation'), value) self.normalize_layout(reload=False) def next_slide_count_edited(self, widget, path, value): """ Handle when the next slide count is modified Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified path (`str`): A string representing the path to the modfied item value (`int`): the new number of next slides """ node, tree_it = self.get_info(path) self.layout_treemodel.set_value(tree_it, self._model_columns.index('next_slide_count'), int(value)) self.next_frames_action.activate(GLib.Variant.new_int64(int(value))) def treemodel_to_tree(self, iterator, parent_horizontal=False, parent_resizeable=False): """ Recursive function to transform the treemodel back into our dict-based representation of the layout Args: iterator (:class:`~Gtk.TreeIter`): the position in the treemodel parent_horizontal (`bool`): whether the parent node is horizontal parent_resieable (`bool`): whether the parent node is resizeable Returns: `list`: the list of `dict` or `str` representing the widgets at this level """ nodes = [] while iterator is not None: values = self.layout_treemodel.get(iterator, *range(len(self._model_columns[:-2]))) node = dict(zip(self._model_columns, values)) # Make the node conform to either a string or a dictionary with 'children' key if node.pop('has_resizeable'): node['children'] = [] del node['widget'] else: node = node['widget'] if self.layout_treemodel.iter_has_child(iterator): children = self.treemodel_to_tree(self.layout_treemodel.iter_children(iterator), *( [parent_horizontal, parent_resizeable] if type(node) is str else [node['orientation'] == 'horizontal', node['resizeable']] )) if len(children) > 1 and type(node) is not str: # Only assign children if there are any, allows to prune empty boxes node['children'] = children elif children and type(node) is not str: # Single-child box replaced by its children node = children[0] elif children: # Non-box node with children: create a new box and set the non-box as first child node = {'children': [node] + children, 'resizeable': not parent_resizeable, 'orientation': 'vertical' if parent_horizontal else 'horizontal'} # Only append widgets, and box nodes that have children if type(node) is str or node['children']: nodes.append(node) iterator = self.layout_treemodel.iter_next(iterator) return nodes def normalize_layout(self, widget=None, drag_context=None, reload=True): """ Handler at the end of a drag-and-drop in the treeview Here we transform the listmodel modified by drag-and-drop back to a valid `dict` and `str` hierarchy, and then trigger the loading of the layout again to display the corrected layout. Args: widget (:class:`~Gtk.Widget`): The object which received the signal drag_context (:class:`~Gdk.DragContext`): the drag context reload (`bool`): whether to reload the layout into the treemodel """ layout = self.treemodel_to_tree(self.layout_treemodel.get_iter_first()) if len(layout) > 1: layout = {'children': layout, 'orientation': 'horizontal', 'resizeable': True} else: layout = layout[0] # This validates self.config.update_layout_tree(self.current_layout, layout) self.ui_load_layout(None) if reload: self.load_layout() def show_editor(self, gaction, param=None): """ Show the popup to edit the layout. Gather info to populate it, and handle apply/cancel at the end. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ restore_layouts = {layout: copy.deepcopy(self.config.get_layout(layout)) for layout in self.layout_descriptions} self.next_frames_action = self.get_application().lookup_action('next-frames') self.hltools_orientation_action = self.get_application().lookup_action('highlight-tools-orientation') restore_next_count = self.next_frames_action.get_state().get_int64() restore_hltools_orientation = self.hltools_orientation_action.get_state().get_string() self.layout_selector.set_active_id(self.current_layout) self.load_layout() if self.layout_dialog.run() != Gtk.ResponseType.APPLY: for layout_name, layout in restore_layouts.items(): self.config.update_layout_tree(layout_name, layout) self.next_frames_action.activate(GLib.Variant.new_int64(restore_next_count)) self.hltools_orientation_action.activate(GLib.Variant.new_string(restore_hltools_orientation)) self.ui_load_layout(None) self.layout_dialog.hide() class AutoPlay(builder.Builder): """ Widget and machinery to setup and play slides automatically, optionally in a loop """ #: A :class:`~Gtk.Dialog` to contain the layout edition dialog autoplay_dialog = None #: The :class:`~Gtk.SpinButton` for the lower page autoplay_spin_lower = None #: The :class:`~Gtk.SpinButton` for the upper page autoplay_spin_upper = None #: The :class:`~Gtk.SpinButton` for the transition between slides autoplay_spin_time = None #: The :class:`~Gtk.CheckButton` to loop autoplay_button_loop = None #: :class:`~Glib.Source` which is the source id of the periodic slide transition, or `None` if there is no autoplay source = None #: if the timeout has been paused, `int` which represents the number of milliseconds until the next page slide remain = None #: callback, to be connected to :func:`~pympress.ui.UI.goto_page` goto_page = lambda *args: None def __init__(self, parent): super(AutoPlay, self).__init__() self.load_ui('autoplay') self.autoplay_dialog.set_transient_for(parent.p_win) self.autoplay_dialog.add_button(Gtk.STOCK_APPLY, Gtk.ResponseType.APPLY) self.autoplay_dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.autoplay_dialog.set_default_response(Gtk.ResponseType.APPLY) parent.setup_actions({ 'autoplay': dict(activate=self.run), }) self.connect_signals(self) self.goto_page = parent.get_callback_handler('goto_page') def set_doc_pages(self, n_pages): """ Callback for when a document number of pages changes Args: n_pages (`int`): the number of pages of the loaded document """ self.autoplay_spin_lower.set_range(1, n_pages - 2) self.autoplay_spin_lower.set_value(1) self.autoplay_spin_upper.set_range(2, n_pages) self.autoplay_spin_upper.set_value(n_pages) def page_changed(self, spin_button, scroll_direction): """ Callback for when a page spin button is modified, maintains a delta of at least 2 pages between first and last page of the intended loop. (No loops needed to loop a single slide.) Args: spin_button (:class:`~Gtk.SpinButton`): The button whose value was changed scroll_direction (:class:`~Gtk.ScrollType`): The speed and amount of change """ if spin_button == self.autoplay_spin_lower: minval = self.autoplay_spin_lower.get_value() + 2 if self.autoplay_spin_upper.get_value() < minval: self.autoplay_spin_upper.set_value(minval) elif spin_button == self.autoplay_spin_upper: maxval = self.autoplay_spin_upper.get_value() - 2 if self.autoplay_spin_lower.get_value() > maxval: self.autoplay_spin_lower.set_value(maxval) def pause(self): """ Pause the looping if it’s running """ if self.source is None or self.remain is not None: return self.remain = self.source.get_ready_time() - self.source.get_time() self.source.set_ready_time(sys.maxsize) def unpause(self): """ Unpause the looping if it’s paused """ if self.source is None or self.remain is None: return self.source.set_ready_time(self.source.get_time() + self.remain) self.remain = None def is_looping(self): """ Return whether an auto-playing """ return self.source is not None def stop_looping(self): """ Stop the auto-playing """ if self.source is not None: self.source.destroy() self.source = None self.remain = None def start_looping(self): """ Start the auto-playing """ self.stop_looping() it = itertools.cycle(range(*self.pages[:2])) if self.pages[2] else iter(range(*self.pages[:2])) self.next_page(it) self.source = GLib.timeout_source_new(self.pages[3]) self.source.attach(GLib.MainContext.default()) self.source.set_callback(self.next_page, it) def next_page(self, it): """ Callback to turn the page to the next slide Args: it (`iterator`): An iterator that contains the next pages to load. Stop when there are no more pages. Returns: `bool`: `True` if he callback needs to be called again, otherwise `False` """ try: self.goto_page(next(it), autoplay=True) except StopIteration: self.stop_looping() return False else: return True def get_page_range(self): """ Return the autoplay info Returns: `tuple`: (first page, stop page, looping, delay i ms) """ return self.pages def run(self, gaction, param=None): """ Show the dialog to setup auto-play, and start the autoplay if « apply » is selected Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ reply = self.autoplay_dialog.run() self.autoplay_dialog.hide() if reply != Gtk.ResponseType.APPLY: return self.pages = (self.autoplay_spin_lower.get_value_as_int() - 1, self.autoplay_spin_upper.get_value_as_int(), self.autoplay_button_loop.get_active(), int(self.autoplay_spin_time.get_value() * 1000)) self.start_looping() pympress-1.8.5/pympress/document.py000066400000000000000000001376201453663732000175030ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # document.py # # Copyright 2009, 2010 Thomas Jost # Copyright 2015 Cimbali # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ :mod:`pympress.document` -- document handling --------------------------------------------- This module contains several classes that are used for managing documents (only PDF documents are supported at the moment, but other formats may be added in the future). An important point is that this module is *completely* independent from the GUI: there should not be any GUI-related code here, except for page rendering (and only rendering itself: the preparation of the target surface must be done elsewhere). """ import logging logger = logging.getLogger(__name__) import math import enum import pathlib import tempfile import mimetypes import webbrowser import collections from urllib.request import url2pathname from urllib.parse import urlsplit import gi gi.require_version('Poppler', '0.18') from gi.repository import Poppler from pympress.util import fileopen def get_extension(mime_type): """ Returns a valid filename extension (recognized by python) for a given mime type. Args: mime_type (`str`): The mime type for which to find an extension Returns: `str`: A file extension used for the given mimetype """ if not mimetypes.inited: mimetypes.init() for ext in mimetypes.types_map: if mimetypes.types_map[ext] == mime_type: return ext class PdfPage(enum.IntEnum): """ Represents the part of a PDF page that we want to draw. """ #: No notes on PDF page, only falsy value NONE = 0 #: Full PDF page (without notes) FULL = 1 #: Bottom half of PDF page BOTTOM = 2 #: Top half of PDF page TOP = 3 #: Right half of PDF page RIGHT = 4 #: Left half of PDF page LEFT = 5 #: Full page + draw another page for notes, which is after the slides AFTER = 6 #: Complementary of AFTER: for a notes page, the slide page is BEFORE by half a document BEFORE = 7 #: Slides on even pages (0-indexed), notes on uneven pages ODD = 8 #: Complementary of ODD EVEN = 9 #: An arbitrary mapping of notes pages to slide pages MAP = 10 #: Reverse the arbitrary mapping MAP RMAP = 11 def complement(val): """ Return the enum value for the other part of the page. """ return PdfPage(val ^ 1) def scale(val): """ Return the enum value that does only scaling not shifting. """ return PdfPage(val | 1) def direction(val): """ Returns whether the pdf page/notes mode is horizontal or vertical. Returns: `str`: a string representing the direction that can be used as the key in the config section """ if val == PdfPage.LEFT or val == PdfPage.RIGHT: return 'horizontal' elif val == PdfPage.TOP or val == PdfPage.BOTTOM: return 'vertical' elif val == PdfPage.AFTER or val == PdfPage.BEFORE: return 'page number' elif val == PdfPage.EVEN or val == PdfPage.ODD: return 'page parity' elif val == PdfPage.MAP or val == PdfPage.RMAP: return 'page mapping' else: return None def from_screen(val, x, y, x2 = None, y2 = None): """ Transform visible part of the page coordinates to full page coordinates. Pass 2 floats to transform coordinates, 4 to transform margins, i.e. the second pair of coordinates is taken from the opposite corner. Args: x (`float`): x coordinate on the screen, on a scale 0..1 y (`float`): y coordinate on the screen, on a scale 0..1 x2 (`float`): second x coordinate on the screen, from the other side, on a scale 0..1 y2 (`float`): second y coordinate on the screen, from the other side, on a scale 0..1 """ if val == PdfPage.RIGHT: page = ((1 + x) / 2., y) elif val == PdfPage.LEFT: page = (x / 2., y) elif val == PdfPage.BOTTOM: page = (x, (1 + y) / 2.) elif val == PdfPage.TOP: page = (x, y / 2.) else: page = (x, y) if x2 is None or y2 is None: return page else: return page + val.complement().from_screen(x2, y2) def to_screen(val, x, y, x2 = None, y2 = None): """ Transform full page coordinates to visible part coordinates. Pass 2 floats to transform coordinates, 4 to transform margins, i.e. the second pair of coordinates is taken from the opposite corner. Args: x (`float`): x coordinate on the page, on a scale 0..1 y (`float`): y coordinate on the page, on a scale 0..1 x2 (`float`): second x coordinate on the page, from the other side, on a scale 0..1 y2 (`float`): second y coordinate on the page, from the other side, on a scale 0..1 """ if val == PdfPage.RIGHT: screen = (x * 2 - 1, y) elif val == PdfPage.LEFT: screen = (x * 2, y) elif val == PdfPage.BOTTOM: screen = (x, y * 2 - 1) elif val == PdfPage.TOP: screen = (x, y * 2) else: screen = (x, y) if x2 is None or y2 is None: return screen else: return screen + val.complement().to_screen(x2, y2) class Link(object): """ This class encapsulates one hyperlink of the document. Args: x1 (`float`): first x coordinate of the link rectangle y1 (`float`): first y coordinate of the link rectangle x2 (`float`): second x coordinate of the link rectangle y2 (`float`): second y coordinate of the link rectangle action (`function`): action to perform when the link is clicked """ #: `float`, first x coordinate of the link rectangle x1 = None #: `float`, first y coordinate of the link rectangle y1 = None #: `float`, second x coordinate of the link rectangle x2 = None #: `float`, second y coordinate of the link rectangle y2 = None #: `function`, action to be perform to follow this link follow = lambda *args, **kwargs: logger.error(_("no action defined for this link!")) def __init__(self, x1, y1, x2, y2, action): self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2 self.follow = action def is_over(self, x, y): """ Tell if the input coordinates are on the link rectangle. Args: x (`float`): input x coordinate y (`float`): input y coordinate Returns: `bool`: `True` if the input coordinates are within the link rectangle, `False` otherwise """ return ((self.x1 <= x) and (x <= self.x2) and (self.y1 <= y) and (y <= self.y2)) @staticmethod def build_closure(fun, *args, **kwargs): r""" Return a lambda that calls fun(\*args, \**kwargs), with the current value of args and kwargs. By creating the lambda in a new scope, we bind the arguments. Args: fun (`function`): The function to be called args (`tuple`): non-keyworded variable-length argument list to pass to fun() kwargs (`dict`): keyworded variable-length argument dict to pass to fun() """ return lambda *a, **k: fun(*(tuple(args) + tuple(a)), **dict(kwargs, **k)) #: A class that holds all the properties for media files Media = collections.namedtuple('Media', ['relative_margins', 'filename', 'autoplay', 'repeat', 'poster', 'show_controls', 'type', 'start_pos', 'duration'], defaults=[False, False, False, False, '', 0., 0.]) class Page(object): """ Class representing a single page. It provides several methods used by the GUI for preparing windows for displaying pages, managing hyperlinks, etc. Args: doc (:class:`~Poppler.Page`): the poppler object around the page number (`int`): number of the page to fetch in the document parent (:class:`~pympress.document.Document`): the parent Document class """ #: Page handled by this class (instance of :class:`~Poppler.Page`) page = None #: `int`, number of the current page (starting from 0) page_nb = -1 #: `str` representing the page label page_label = None #: All the links in the page, as a `list` of :class:`~pympress.document.Link` instances links = [] #: All the media in the page, as a `list` of :class:`~pympress.document.Media` medias = [] #: `float`, page width pw = 0. #: `float`, page height ph = 0. #: All text annotations annotations = [] #: Instance of :class:`~pympress.document.Document` that contains this page. parent = None def __init__(self, page, number, parent): self.page = page self.page_nb = number self.parent = parent self.links = [] self.medias = [] self.annotations = [] if self.page is None: return # Get page label self.page_label = self.page.get_label() # Read page size self.pw, self.ph = self.page.get_size() # Read links on the page for link in self.page.get_link_mapping(): action = self.get_link_action(link.action.type, link.action) my_link = Link(link.area.x1, link.area.y1, link.area.x2, link.area.y2, action) self.links.append(my_link) # Read annotations, in particular those that indicate media for annotation in self.page.get_annot_mapping(): annot_type = annotation.annot.get_annot_type() if annot_type == Poppler.AnnotType.LINK: # just an Annot, not subclassed -- probably redundant with links continue elif annot_type == Poppler.AnnotType.MOVIE: movie = annotation.annot.get_movie() filepath = self.parent.get_full_path(movie.get_filename()) if not filepath: logger.error(_("Pympress can not find file ") + movie.get_filename()) continue relative_margins = Poppler.Rectangle() relative_margins.x1 = annotation.area.x1 / self.pw # left relative_margins.x2 = 1.0 - annotation.area.x2 / self.pw # right relative_margins.y1 = annotation.area.y1 / self.ph # bottom relative_margins.y2 = 1.0 - annotation.area.y2 / self.ph # top movie_options = {'show_controls': movie.show_controls(), 'poster': movie.need_poster()} try: movie_options['repeat'] = movie.get_play_mode() == Poppler.MoviePlayMode.REPEAT movie_options['start_pos'] = movie.get_start() / 1e9 movie_options['duration'] = movie.get_duration() / 1e9 # NB: autoplay not part of Poppler’s MovieActivationParameters struct except AttributeError: pass # Missing functions in pre-21.04 Poppler versions media = Media(relative_margins, filepath, **movie_options) self.medias.append(media) action = Link.build_closure(self.parent.play_media, hash(media)) elif annot_type == Poppler.AnnotType.SCREEN: action_obj = annotation.annot.get_action() if not action_obj: continue action = self.get_annot_action(action_obj.any.type, action_obj, annotation.area) if not action: continue elif annot_type == Poppler.AnnotType.FILE_ATTACHMENT: attachment = annotation.annot.get_attachment() filename = pathlib.Path(attachment.name) with tempfile.NamedTemporaryFile('wb', suffix=filename.suffix, prefix=filename.stem, delete=False) as f: # now the file name is shotgunned filename = pathlib.Path(f.name) self.parent.remove_on_exit(filename) if not attachment.save(str(filename)): logger.error(_("Pympress can not extract attached file")) continue action = Link.build_closure(fileopen, filename) elif annot_type in {Poppler.AnnotType.TEXT, Poppler.AnnotType.POPUP, Poppler.AnnotType.FREE_TEXT}: # text-only annotations, hide them from screen and show them in annotations popup content = annotation.annot.get_contents() if content: self.annotations.append(annotation.annot) self.page.remove_annot(annotation.annot) continue elif annot_type in {Poppler.AnnotType.STRIKE_OUT, Poppler.AnnotType.HIGHLIGHT, Poppler.AnnotType.UNDERLINE, Poppler.AnnotType.SQUIGGLY, Poppler.AnnotType.POLYGON, Poppler.AnnotType.POLY_LINE, Poppler.AnnotType.SQUARE, Poppler.AnnotType.CIRCLE, Poppler.AnnotType.CARET, Poppler.AnnotType.LINE, Poppler.AnnotType.STAMP, Poppler.AnnotType.INK}: # Poppler already renders annotation of these types, nothing more can be done # even though the rendering isn't always perfect. continue else: logger.warning(_("Pympress can not interpret annotation of type:") + " {} ".format(annot_type)) continue my_annotation = Link(annotation.area.x1, annotation.area.y1, annotation.area.x2, annotation.area.y2, action) self.links.append(my_annotation) def get_link_action(self, link_type, action): """ Get the function to be called when the link is followed. Args: link_type (:class:`~Poppler.ActionType`): The type of action to be performed action (:class:`~Poppler.Action`): The atcion to be performed Returns: `function`: The function to be called to follow the link """ # Poppler.ActionType.RENDITION should only appear in annotations, right? Otherwise how do we know # where to render it? Any documentation on which action types are admissible in links vs in annots # is very welcome. For now, link is fallback to annot so contains all action types. if link_type == Poppler.ActionType.NONE: return lambda: None elif link_type == Poppler.ActionType.GOTO_DEST: dest_type = action.goto_dest.dest.type if dest_type == Poppler.DestType.NAMED: dest = self.parent.doc.find_dest(action.goto_dest.dest.named_dest) if dest: return Link.build_closure(self.parent.goto_page, dest.page_num - 1) else: warning = _('Unrecognized named destination: ') + str(action.goto_dest.dest.named_dest) elif dest_type != Poppler.DestType.UNKNOWN: return Link.build_closure(self.parent.goto_page, action.goto_dest.dest.page_num - 1) elif link_type == Poppler.ActionType.NAMED: dest_name = action.named.named_dest dest = self.parent.doc.find_dest(dest_name) if dest: return Link.build_closure(self.parent.goto_page, dest.page_num) elif dest_name == "GoBack": return self.parent.goto_prev_hist elif dest_name == "GoForward": return self.parent.goto_next_hist elif dest_name == "FirstPage": return Link.build_closure(self.parent.goto_page, 0) elif dest_name == "PrevPage": return Link.build_closure(self.parent.goto_page, self.page_nb - 1) elif dest_name == "NextPage": return Link.build_closure(self.parent.goto_page, self.page_nb + 1) elif dest_name == "LastPage": return Link.build_closure(self.parent.goto_page, self.parent.pages_number() - 1) elif dest_name == "GoToPage": # Same as the 'G' action which allows one to pick a page to jump to return Link.build_closure(self.parent.start_editing_page_number, ) elif dest_name == "Find": # TODO popup a text box and search results with Page.find_text # http://lazka.github.io/pgi-docs/Poppler-0.18/classes/Page.html#Poppler.Page.find_text warning = _("Pympress does not yet support link type \"{}\" to \"{}\"").format(link_type, dest_name) else: # TODO find out other possible named actions? warning = _("Pympress does not recognize link type \"{}\" to \"{}\"").format(link_type, dest_name) elif link_type == Poppler.ActionType.LAUNCH: launch = action.launch if launch.params: logger.warning("ignoring params: " + str(launch.params)) filepath = self.parent.get_full_path(launch.file_name) if not filepath: logger.error("can not find file " + launch.file_name) return lambda: None else: return Link.build_closure(fileopen, filepath) elif link_type == Poppler.ActionType.URI: return Link.build_closure(webbrowser.open_new_tab, action.uri.uri) elif link_type == Poppler.ActionType.RENDITION: # Poppler 0.22 warning = _("Pympress does not yet support link type \"{}\"").format(link_type) elif link_type == Poppler.ActionType.MOVIE: # Poppler 0.20 warning = _("Pympress does not yet support link type \"{}\"").format(link_type) elif link_type == Poppler.ActionType.GOTO_REMOTE: warning = _("Pympress does not yet support link type \"{}\"").format(link_type) elif link_type == Poppler.ActionType.OCG_STATE: warning = _("Pympress does not yet support link type \"{}\"").format(link_type) elif link_type == Poppler.ActionType.JAVASCRIPT: warning = _("Pympress does not yet support link type \"{}\"").format(link_type) elif link_type == Poppler.ActionType.UNKNOWN: warning = _("Pympress does not yet support link type \"{}\"").format(link_type) else: warning = _("Pympress does not recognize link type \"{}\"").format(link_type) logger.info(warning) return Link.build_closure(logger.warning, _('Unsupported link clicked. ') + warning) def get_annot_action(self, link_type, action, rect): """ Get the function to be called when the link is followed. Args: link_type (:class:`~Poppler.ActionType`): The link type action (:class:`~Poppler.Action`): The action to be performed when the link is clicked rect (:class:`~Poppler.Rectangle`): The region of the page where the link is Returns: `function`: The function to be called to follow the link """ if link_type == Poppler.ActionType.RENDITION: media = action.rendition.media if media.is_embedded(): ext = get_extension(media.get_mime_type()) with tempfile.NamedTemporaryFile('wb', suffix=ext, prefix='pdf_embed_', delete=False) as f: # now the file name is shotgunned filename = pathlib.Path(f.name) self.parent.remove_on_exit(filename) if not media.save(str(filename)): logger.error(_("Pympress can not extract embedded media")) return None else: filename = self.parent.get_full_path(media.get_filename()) if not filename: logger.error(_("Pympress can not find file ") + media.get_filename()) return None relative_margins = Poppler.Rectangle() relative_margins.x1 = rect.x1 / self.pw # left relative_margins.x2 = 1.0 - rect.x2 / self.pw # right relative_margins.y1 = rect.y1 / self.ph # bottom relative_margins.y2 = 1.0 - rect.y2 / self.ph # top media_options = {'type': media.get_mime_type()} try: media_options['autoplay'] = media.get_auto_play() media_options['show_controls'] = media.get_show_controls() media_options['repeat'] = media.get_repeat_count() - 1 # NB: no poster in Poppler’s MediaParameters except AttributeError: pass media = Media(relative_margins, filename, **media_options) self.medias.append(media) return Link.build_closure(self.parent.play_media, hash(media)) else: return self.get_link_action(link_type, action) def number(self): """ Get the page number. """ return self.page_nb def label(self): """ Get the page label. """ return self.page_label def get_link_at(self, x, y, dtype=PdfPage.FULL): """ Get the :class:`~pympress.document.Link` corresponding to the given position. Returns `None` if there is no link at this position. Args: x (`float`): horizontal coordinate y (`float`): vertical coordinate dtype (:class:`~pympress.document.PdfPage`): the type of document to consider Returns: :class:`~pympress.document.Link`: the link at the given coordinates if one exists, `None` otherwise """ x, y = dtype.from_screen(x, y) xx = self.pw * x yy = self.ph * (1. - y) for link in self.links: if link.is_over(xx, yy): return link return None def get_size(self, dtype=PdfPage.FULL): """ Get the page size. Args: dtype (:class:`~pympress.document.PdfPage`): the type of document to consider Returns: `(float, float)`: page size """ return dtype.scale().from_screen(self.pw, self.ph) def get_aspect_ratio(self, dtype=PdfPage.FULL): """ Get the page aspect ratio. Args: dtype (:class:`~pympress.document.PdfPage`): the type of document to consider Returns: `float`: page aspect ratio """ w, h = self.get_size(dtype) return w / h def get_annotations(self): """ Get the list of text annotations on this page. Returns: `list` of `str`: annotations on this page """ return self.annotations def new_annotation(self, pos, rect=None, value=''): """ Add an annotation to this page Args: pos (`int`): The position in the list of annotations in which to insert this annotation rect (:class:`~Poppler.Rectangle`): A rectangle for the position of this annotation value (`str`): The contents of the annotation """ if self.parent.doc is None: return if pos < 0: pos = 0 if pos > len(self.annotations): pos = len(self.annotations) if rect is None: rect = Poppler.Rectangle() rect.x1 = self.pw - 20 rect.x2 = rect.x1 + 20 rect.y2 = self.ph - len(self.annotations) * 20 rect.y1 = rect.y2 - 20 new_annot = Poppler.AnnotText.new(self.parent.doc, rect) new_annot.set_icon(Poppler.ANNOT_TEXT_ICON_NOTE) new_annot.set_contents(value) self.annotations.insert(pos, new_annot) self.parent.made_changes() def set_annotation(self, pos, value): """ Update an annotation on this page Args: pos (`int`): The number of the annotation value (`str`): The new contents of the annotation """ try: rect = self.annotations[pos].get_rectangle() except IndexError: # Often because no document is loaded logger.error(_("Pympress can not edit PDF annotation {}").format(pos)) else: self.remove_annotation(pos) self.new_annotation(pos, rect, value) def remove_annotation(self, pos): """ Remove an annotation from this page Args: pos (`int`): The number of the annotation """ self.parent.made_changes() del self.annotations[pos] def get_media(self): """ Get the list of medias this page might want to play. Returns: `list`: medias in this page """ return self.medias def render_cairo(self, cr, ww, wh, dtype=PdfPage.FULL): """ Render the page on a Cairo surface. Args: cr (:class:`~Gdk.CairoContext`): target surface ww (`int`): target width in pixels wh (`int`): target height in pixels dtype (:class:`~pympress.document.PdfPage`): the type of document that should be rendered """ pw, ph = self.get_size(dtype) cr.set_source_rgb(1, 1, 1) # Scale scale = min(ww / pw, wh / ph) cr.scale(scale, scale) cr.rectangle(0, 0, pw, ph) cr.fill() # For "regular" pages, there is no problem: just render them. # For other pages (i.e. half of a page), the widget already has correct # dimensions so we don't need to deal with that. But for right and bottom # halfs we must translate the output in order to only show the correct half. if dtype == PdfPage.RIGHT: cr.translate(-pw, 0) elif dtype == PdfPage.BOTTOM: cr.translate(0, -ph) self.page.render(cr) def can_render(self): """ Informs that rendering *is* necessary (avoids checking the type). Returns: `bool`: `True`, do rendering """ return True class Document(object): """ This is the main document handling class. The page numbering starts as 0 and is aware of notes (i.e. number of pages may change to account for note pages). The document page numbers are the same as in Poppler, and also start at 0 but do not depend on notes. Args: builder (:class:`pympress.builder.Builder`): A builder to load callbacks pop_doc (:class:`~pympress.Poppler.Document`): Instance of the Poppler document that this class will wrap uri (`str`): URI of the PDF file to open page (`int`): page number to which the file should be opened """ #: Current PDF document (:class:`~Poppler.Document` instance) doc = None #: `str` full path to pdf uri = None #: :class:`~pathlib.Path` to pdf if uri is a file: URI path = None #: Number of pages in the document nb_pages = -1 #: Pages cache (`dict` of :class:`~pympress.document.Page`). This makes #: navigation in the document faster by avoiding calls to Poppler when loading #: a page that has already been loaded. pages_cache = {} #: `set` of :class:`~pathlib.Path` representing the temporary files which need to be removed temp_files = set() #: History of pages we have visited, using note-aware page numbers history = [] #: Our position in the history hist_pos = -1 #: `list` of slide page labels, indexed on note-aware page numbers page_labels = [] #: `list` of all the page labels, indexed on document page numbers doc_page_labels = [] #: `list` of (slide's document page number, notes' document page number) tuples, or `None` if there are no notes notes_mapping = None #: `bool` indicating whether there were modifications to the document changes = False #: callback, to be connected to :func:`~pympress.extras.Media.play` play_media = lambda *args: None #: callback, to be connected to :func:`~pympress.editable_label.PageNumber.start_editing` start_editing_page_number = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.goto_page` navigate = lambda *args: None def __init__(self, builder, pop_doc, uri): if builder is not None: # Connect callbacks self.play_media = builder.get_callback_handler('medias.play') self.start_editing_page_number = builder.get_callback_handler('page_number.start_editing') self.goto_page = builder.get_callback_handler('goto_page') self.goto_next_hist = builder.get_callback_handler('doc_hist_next') self.goto_prev_hist = builder.get_callback_handler('doc_hist_prev') # Setup PDF file self.uri = uri self.doc = pop_doc self.changes = False if uri is not None: uri_parts = urlsplit(uri, scheme='file') self.path = pathlib.Path(url2pathname(uri_parts.path)) if uri_parts.scheme == 'file': self.path = pathlib.Path.cwd().joinpath(self.path.name) else: self.path = None # Pages numbers and labels self.nb_pages = 0 if pop_doc is None else self.doc.get_n_pages() self.doc_page_labels = [self.doc.get_page(n).get_label() for n in range(self.nb_pages)] self.page_labels = self.doc_page_labels # Pages cache self.pages_cache = {} def get_structure(self, index_iter = None): """ Gets the structure of the document from its index. Recursive, pass the iterator. Args: index_iter (:class:`~Poppler.IndexIter` or `None`): the iterator for the child index to explore. Returns: `list`: A list of tuples (depth, page number, title) """ try: if index_iter is None: index_iter = Poppler.IndexIter(self.doc) except TypeError: return {} if index_iter is None: return {} index = {} while True: action = index_iter.get_action() title = '' page = None try: if action.type == Poppler.ActionType.GOTO_DEST: title = action.goto_dest.title if action.goto_dest.dest.type == Poppler.DestType.NAMED: page = self.doc.find_dest(action.goto_dest.dest.named_dest).page_num - 1 elif action.goto_dest.dest.type == Poppler.DestType.UNKNOWN: raise AssertionError('Unknown type of destination') else: page = action.goto_dest.dest.page_num - 1 else: raise AssertionError('Unexpected type of action') except Exception: logger.error(_('Unexpected action in index "{}"').format(action.type)) page = None new_entry = {'title': title} child = index_iter.get_child() if child: new_entry['children'] = self.get_structure(child) if page is None: page = min(new_entry['children']) # there should not be synonymous sections, correct the page here to a better guess if page in index: lower_bound = max(index.keys()) find = index[lower_bound] while 'children' in find: lower_bound = max(find['children'].keys()) find = find['children'][lower_bound] try: page = min(number for number, label in enumerate(self.page_labels) if label == self.page_labels[page] and number > lower_bound) except ValueError: # empty iterator page = lower_bound + 1 if page is not None: index[page] = new_entry if not index_iter.next(): break return index @staticmethod def create(builder, uri): """ Initializes a Document by passing it a :class:`~Poppler.Document`. Args: builder (:class:`pympress.builder.Builder`): A builder to load callbacks uri (`str`): URI to the PDF file to open page (`int`): page number to which the file should be opened Returns: :class:`~pympress.document.Document`: The initialized document """ if uri is None: doc = EmptyDocument() else: poppler_doc = Poppler.Document.new_from_file(uri, None) doc = Document(builder, poppler_doc, uri) return doc def made_changes(self): """ Notify the document that some changes were made (e.g. annotations edited) """ self.changes = True def has_changes(self): """ Return whether that some changes were made (e.g. annotations edited) """ return self.changes def save_changes(self, dest_uri=None): """ Save the changes Args: dest_uri (`str` or `None`): The URI where to save the file, or None to save in-place """ if self.doc is None: return for page in self.pages_cache.values(): for annot in page.get_annotations(): page.page.add_annot(annot) if dest_uri is not None and dest_uri != self.uri: if self.doc.save(dest_uri): self.changes = False else: # We can’t overwrite the current file directly, so create a temporary file and then overwrite with tempfile.NamedTemporaryFile('wb', suffix=self.path.suffix, prefix=self.path.stem, dir=self.path.parent, delete=False) as f: temp_path = pathlib.Path(f.name) if self.doc.save(temp_path.as_uri()): self.changes = False temp_path.replace(self.path) for page in self.pages_cache.values(): for annot in page.get_annotations(): page.page.remove_annot(annot) def guess_notes(self, horizontal, vertical, current_page=0): """ Get our best guess for the document mode. Args: horizontal (`str`): A string representing the preference for horizontal slides vertical (`str`): A string representing the preference for vertical slides Returns: :class:`~pympress.document.PdfPage`: the notes mode """ if any(label.startswith('notes:') for label in self.page_labels): return PdfPage.MAP page = self.page(current_page) if page is None or not page.can_render(): return PdfPage.NONE ar = page.get_aspect_ratio() # Check whether we have N slides with one aspect ratio then N slides with a different aspect ratio # that is the sign if Libreoffice notes pages if self.nb_pages and self.nb_pages % 2 == 0: half_doc = self.nb_pages // 2 ar_slides = self.page(0).get_aspect_ratio() ar_notes = self.page(half_doc).get_aspect_ratio() if ar_slides != ar_notes and \ all(self.page(p).get_aspect_ratio() == ar_slides for p in range(1, half_doc)) and \ all(self.page(half_doc + p).get_aspect_ratio() == ar_notes for p in range(1, half_doc)): return PdfPage.AFTER # "Regular" slides will have an aspect ratio of 4/3, 16/9, 16/10... i.e. in the range [1..2] # So if the aspect ratio is >= 2, we can assume it is a document with notes on the side. if ar >= 2: try: return PdfPage[horizontal.upper()] except KeyError: return PdfPage.RIGHT # Make exception for classic american letter format and ISO (A4, B5, etc.) if abs(ar - 8.5 / 11) < 1e-3 or abs(ar - 1 / math.sqrt(2)) < 1e-3: return PdfPage.NONE # If the aspect ratio is < 1, we can assume it is a document with notes above or below. if ar < 1: try: return PdfPage[vertical.upper()] except KeyError: return PdfPage.BOTTOM return PdfPage.NONE def set_notes_pos(self, notes_direction): """ Set whether where the notes pages are relative to normal pages Valid values are returned by :meth:`~pympress.document.PdfPage.direction` - page number (aka Libreoffice notes mode) - page parity (can not be detected automatically, where every other page contains notes) - page mapping (where labels of notes pages are corresponding slide labels prefixed with “notes:”) Args: notes_direction (`str`): Where the notes pages are """ if notes_direction == 'page number': self.notes_mapping = [(n, n + self.nb_pages // 2) for n in range(self.nb_pages // 2)] elif notes_direction == 'page parity': self.notes_mapping = [(n, n + 1) for n in range(0, self.nb_pages, 2)] elif notes_direction == 'page mapping': notes_mapping = collections.OrderedDict() for n, (label, prev_label) in enumerate(zip(self.doc_page_labels, [None, *self.doc_page_labels[:-1]])): # Here the condition (could be adjusted) is 2 successive pages labeled