modpdf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. modpdf-0.1.0/.github/workflows/release.yml +135 -0
  2. modpdf-0.1.0/.github/workflows/security.yml +62 -0
  3. modpdf-0.1.0/.github/workflows/test.yml +62 -0
  4. modpdf-0.1.0/.gitignore +42 -0
  5. modpdf-0.1.0/.pre-commit-config.yaml +29 -0
  6. modpdf-0.1.0/CHANGELOG.md +155 -0
  7. modpdf-0.1.0/CONTRIBUTING.md +92 -0
  8. modpdf-0.1.0/LICENSE +202 -0
  9. modpdf-0.1.0/NOTICE +29 -0
  10. modpdf-0.1.0/PKG-INFO +292 -0
  11. modpdf-0.1.0/README.md +264 -0
  12. modpdf-0.1.0/SECURITY.md +47 -0
  13. modpdf-0.1.0/THREAT_MODEL.md +94 -0
  14. modpdf-0.1.0/docs/adr/0001-python-over-go-and-rust.md +67 -0
  15. modpdf-0.1.0/docs/adr/0002-pypdfium2-over-pymupdf.md +50 -0
  16. modpdf-0.1.0/docs/adr/0003-pikepdf-over-ghostscript.md +55 -0
  17. modpdf-0.1.0/docs/adr/0004-compression-quality-gate.md +62 -0
  18. modpdf-0.1.0/docs/adr/0005-no-network-at-runtime.md +49 -0
  19. modpdf-0.1.0/docs/adr/0006-one-lock-for-pdfium.md +61 -0
  20. modpdf-0.1.0/docs/compression.md +113 -0
  21. modpdf-0.1.0/docs/images/app-dark-compress.png +0 -0
  22. modpdf-0.1.0/docs/images/app-light.png +0 -0
  23. modpdf-0.1.0/packaging/macos/README.md +43 -0
  24. modpdf-0.1.0/packaging/macos/build.sh +37 -0
  25. modpdf-0.1.0/packaging/macos/make_icon.py +86 -0
  26. modpdf-0.1.0/packaging/macos/modpdf.spec +90 -0
  27. modpdf-0.1.0/pyproject.toml +120 -0
  28. modpdf-0.1.0/src/modpdf/__init__.py +17 -0
  29. modpdf-0.1.0/src/modpdf/cli.py +567 -0
  30. modpdf-0.1.0/src/modpdf/document.py +159 -0
  31. modpdf-0.1.0/src/modpdf/gui/__init__.py +21 -0
  32. modpdf-0.1.0/src/modpdf/gui/app.py +57 -0
  33. modpdf-0.1.0/src/modpdf/gui/grid.py +180 -0
  34. modpdf-0.1.0/src/modpdf/gui/icons.py +172 -0
  35. modpdf-0.1.0/src/modpdf/gui/session.py +160 -0
  36. modpdf-0.1.0/src/modpdf/gui/settings.py +79 -0
  37. modpdf-0.1.0/src/modpdf/gui/theme.py +237 -0
  38. modpdf-0.1.0/src/modpdf/gui/thumbnails.py +123 -0
  39. modpdf-0.1.0/src/modpdf/gui/widgets.py +401 -0
  40. modpdf-0.1.0/src/modpdf/gui/window.py +1572 -0
  41. modpdf-0.1.0/src/modpdf/gui/workers.py +124 -0
  42. modpdf-0.1.0/src/modpdf/inspection.py +335 -0
  43. modpdf-0.1.0/src/modpdf/ops/__init__.py +0 -0
  44. modpdf-0.1.0/src/modpdf/ops/compress.py +1113 -0
  45. modpdf-0.1.0/src/modpdf/ops/merge.py +45 -0
  46. modpdf-0.1.0/src/modpdf/ops/outline.py +200 -0
  47. modpdf-0.1.0/src/modpdf/ops/sanitize.py +213 -0
  48. modpdf-0.1.0/src/modpdf/ops/select.py +47 -0
  49. modpdf-0.1.0/src/modpdf/ops/split.py +64 -0
  50. modpdf-0.1.0/src/modpdf/pagespec.py +105 -0
  51. modpdf-0.1.0/src/modpdf/pdfium_lock.py +27 -0
  52. modpdf-0.1.0/src/modpdf/security/__init__.py +0 -0
  53. modpdf-0.1.0/src/modpdf/security/fs.py +284 -0
  54. modpdf-0.1.0/src/modpdf/security/limits.py +76 -0
  55. modpdf-0.1.0/src/modpdf/security/netguard.py +121 -0
  56. modpdf-0.1.0/src/modpdf/security/secrets.py +71 -0
  57. modpdf-0.1.0/src/modpdf/tasks.py +246 -0
  58. modpdf-0.1.0/src/modpdf/verify.py +248 -0
  59. modpdf-0.1.0/tests/__init__.py +0 -0
  60. modpdf-0.1.0/tests/conftest.py +474 -0
  61. modpdf-0.1.0/tests/gui/__init__.py +0 -0
  62. modpdf-0.1.0/tests/gui/conftest.py +77 -0
  63. modpdf-0.1.0/tests/gui/test_add_to_workspace.py +216 -0
  64. modpdf-0.1.0/tests/gui/test_compress_panel.py +254 -0
  65. modpdf-0.1.0/tests/gui/test_reorder.py +441 -0
  66. modpdf-0.1.0/tests/gui/test_session.py +88 -0
  67. modpdf-0.1.0/tests/gui/test_settings.py +527 -0
  68. modpdf-0.1.0/tests/gui/test_split_ranges.py +195 -0
  69. modpdf-0.1.0/tests/gui/test_thumbnails.py +142 -0
  70. modpdf-0.1.0/tests/gui/test_window.py +147 -0
  71. modpdf-0.1.0/tests/gui/test_workers.py +127 -0
  72. modpdf-0.1.0/tests/integration/__init__.py +0 -0
  73. modpdf-0.1.0/tests/integration/test_commands.py +346 -0
  74. modpdf-0.1.0/tests/property/__init__.py +0 -0
  75. modpdf-0.1.0/tests/property/test_roundtrip.py +107 -0
  76. modpdf-0.1.0/tests/security/__init__.py +0 -0
  77. modpdf-0.1.0/tests/security/test_hostile_input.py +254 -0
  78. modpdf-0.1.0/tests/security/test_no_network.py +157 -0
  79. modpdf-0.1.0/tests/security/test_secrets.py +155 -0
  80. modpdf-0.1.0/tests/security/test_synced_folders.py +138 -0
  81. modpdf-0.1.0/tests/unit/__init__.py +0 -0
  82. modpdf-0.1.0/tests/unit/test_compress.py +497 -0
  83. modpdf-0.1.0/tests/unit/test_fs.py +125 -0
  84. modpdf-0.1.0/tests/unit/test_inspection.py +164 -0
  85. modpdf-0.1.0/tests/unit/test_outline.py +159 -0
  86. modpdf-0.1.0/tests/unit/test_pagespec.py +110 -0
  87. modpdf-0.1.0/tests/unit/test_pdfium_lock.py +66 -0
  88. modpdf-0.1.0/tests/unit/test_sanitize.py +157 -0
  89. modpdf-0.1.0/tests/unit/test_tasks.py +95 -0
  90. modpdf-0.1.0/tests/unit/test_verify.py +173 -0
  91. modpdf-0.1.0/uv.lock +1820 -0
@@ -0,0 +1,135 @@
1
+ name: release
2
+
3
+ # Pushing a tag like v0.1.0 publishes that version to PyPI, then creates a
4
+ # GitHub Release with the same files, an SBOM, and an unsigned macOS app.
5
+ #
6
+ # Running it by hand (Actions → release → Run workflow) is a dry run: it
7
+ # builds and publishes to TestPyPI instead, and creates no GitHub Release.
8
+ #
9
+ # Publishing uses PyPI's Trusted Publishing: PyPI trusts this workflow, in this
10
+ # repository, running in the `pypi` environment, and nothing else. There is no
11
+ # API token stored anywhere to be stolen.
12
+ on:
13
+ push:
14
+ tags: ["v*"]
15
+ workflow_dispatch:
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ build:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v5
25
+ - uses: astral-sh/setup-uv@v10.1.0
26
+
27
+ # A tag that doesn't match pyproject.toml would publish one version
28
+ # under another's name. Stop before anything is built.
29
+ - name: Tag matches the project version
30
+ if: github.event_name == 'push'
31
+ run: |
32
+ version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
33
+ if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then
34
+ echo "tag $GITHUB_REF_NAME does not match pyproject.toml version $version" >&2
35
+ exit 1
36
+ fi
37
+
38
+ - name: Build the sdist and wheel
39
+ run: uv build
40
+
41
+ # The SBOM lists every runtime dependency, desktop extra included, at the
42
+ # exact versions uv.lock pins.
43
+ - name: SBOM
44
+ run: |
45
+ uv export --locked --no-dev --extra gui --no-emit-project --no-hashes \
46
+ --output-file requirements-runtime.txt
47
+ uvx --from cyclonedx-bom==7.4.0 cyclonedx-py requirements \
48
+ --pyproject pyproject.toml --of JSON --output-reproducible \
49
+ -o sbom.cdx.json requirements-runtime.txt
50
+
51
+ - uses: actions/upload-artifact@v7
52
+ with:
53
+ name: dist
54
+ path: dist/
55
+ - uses: actions/upload-artifact@v7
56
+ with:
57
+ name: sbom
58
+ path: sbom.cdx.json
59
+
60
+ publish:
61
+ needs: build
62
+ runs-on: ubuntu-latest
63
+ environment:
64
+ name: pypi
65
+ url: https://pypi.org/p/modpdf
66
+ permissions:
67
+ id-token: write # what Trusted Publishing uses instead of a token
68
+ steps:
69
+ - uses: actions/download-artifact@v8
70
+ with:
71
+ name: dist
72
+ path: dist/
73
+ - uses: pypa/gh-action-pypi-publish@v1.14.2
74
+ with:
75
+ repository-url: ${{ github.event_name == 'workflow_dispatch' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }}
76
+ # A second dry run of the same version would otherwise fail on
77
+ # TestPyPI; a real release must never silently skip.
78
+ skip-existing: ${{ github.event_name == 'workflow_dispatch' }}
79
+
80
+ github-release:
81
+ needs: publish
82
+ if: github.event_name == 'push'
83
+ runs-on: ubuntu-latest
84
+ permissions:
85
+ contents: write
86
+ steps:
87
+ - uses: actions/checkout@v5
88
+ - uses: actions/download-artifact@v8
89
+ with:
90
+ name: dist
91
+ path: dist/
92
+ - uses: actions/download-artifact@v8
93
+ with:
94
+ name: sbom
95
+
96
+ # The release notes are this version's own section of CHANGELOG.md.
97
+ - name: Create the release
98
+ env:
99
+ GH_TOKEN: ${{ github.token }}
100
+ run: |
101
+ version="${GITHUB_REF_NAME#v}"
102
+ awk -v v="$version" '
103
+ $0 ~ "^## \\[" v "\\]" { found = 1; next }
104
+ found && /^## \[/ { exit }
105
+ found { print }
106
+ ' CHANGELOG.md > notes.md
107
+ if [ ! -s notes.md ]; then
108
+ echo "CHANGELOG.md has no section for $version" >&2
109
+ exit 1
110
+ fi
111
+ gh release create "$GITHUB_REF_NAME" dist/* sbom.cdx.json \
112
+ --title "ModPDF $version" --notes-file notes.md
113
+
114
+ # Separate from the release above, so a problem building the app can't stop
115
+ # the PyPI release or the GitHub Release from happening.
116
+ macos-app:
117
+ needs: github-release
118
+ runs-on: macos-latest
119
+ permissions:
120
+ contents: write
121
+ steps:
122
+ - uses: actions/checkout@v5
123
+ - uses: astral-sh/setup-uv@v10.1.0
124
+ - name: Install
125
+ run: |
126
+ uv sync --locked --all-groups --python 3.13
127
+ uv pip install pyinstaller
128
+ - name: Build ModPDF.app
129
+ run: packaging/macos/build.sh
130
+ - name: Attach it to the release
131
+ env:
132
+ GH_TOKEN: ${{ github.token }}
133
+ run: |
134
+ ditto -c -k --keepParent dist/ModPDF.app "ModPDF-${GITHUB_REF_NAME}-macos-arm64.zip"
135
+ gh release upload "$GITHUB_REF_NAME" "ModPDF-${GITHUB_REF_NAME}-macos-arm64.zip"
@@ -0,0 +1,62 @@
1
+ name: security
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ schedule:
8
+ # Dependencies pick up CVEs while nobody is pushing. Check weekly.
9
+ - cron: "17 6 * * 1"
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ audit:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v5
19
+ - uses: astral-sh/setup-uv@v10.1.0
20
+
21
+ - name: Install
22
+ run: uv sync --locked --all-groups
23
+
24
+ # Audit the lockfile rather than the environment. modpdf itself is not on
25
+ # PyPI, and auditing the installed set makes --strict fail on it -- as an
26
+ # unauditable dependency, or as a skipped one under --skip-editable. The
27
+ # export is every locked version and nothing else, which is what we
28
+ # actually want checked.
29
+ - name: Known vulnerabilities in dependencies
30
+ run: |
31
+ uv export --locked --all-groups --no-emit-project --no-hashes \
32
+ --output-file requirements-audit.txt
33
+ uv run pip-audit --strict --desc --no-deps -r requirements-audit.txt
34
+
35
+ - name: Static analysis of our own code
36
+ run: uv run bandit -r src/ -c pyproject.toml
37
+
38
+ # An independent check on the no-network promise. netguard blocks sockets
39
+ # inside the process; this blocks them outside it. If a dependency starts
40
+ # phoning home, one of the two will catch it.
41
+ offline:
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v5
45
+
46
+ # PySide6 links against these at import time, even for the "offscreen"
47
+ # platform the GUI tests run under — there is no display on this runner.
48
+ - name: Qt runtime libraries
49
+ run: |
50
+ sudo apt-get update
51
+ sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3
52
+
53
+ - uses: astral-sh/setup-uv@v10.1.0
54
+
55
+ - name: Install
56
+ run: uv sync --locked --all-groups
57
+
58
+ # Run the suite inside a network namespace with no interfaces but
59
+ # loopback. Firewalling the whole runner would also cut its own link to
60
+ # GitHub, so the job could never report what it found.
61
+ - name: Tests must pass with no network at all
62
+ run: sudo unshare --net .venv/bin/python -m pytest -v
@@ -0,0 +1,62 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ # Every OS on the newest Python, plus the oldest supported versions on Linux
17
+ # only. The older versions differ in the language, not the platform, so one
18
+ # OS is enough to catch them, and it keeps pull request feedback quick.
19
+ test:
20
+ runs-on: ${{ matrix.os }}
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: [ubuntu-latest, macos-latest, windows-latest]
25
+ python: ["3.13"]
26
+ include:
27
+ - os: ubuntu-latest
28
+ python: "3.11"
29
+ - os: ubuntu-latest
30
+ python: "3.12"
31
+
32
+ steps:
33
+ - uses: actions/checkout@v5
34
+
35
+ # PySide6 links against these at import time, even for the "offscreen"
36
+ # platform the GUI tests run under — there is no display on this
37
+ # runner. Not needed on macOS or Windows: those wheels carry what they
38
+ # need.
39
+ - name: Qt runtime libraries (Linux only)
40
+ if: runner.os == 'Linux'
41
+ run: |
42
+ sudo apt-get update
43
+ sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3
44
+
45
+ - uses: astral-sh/setup-uv@v10.1.0
46
+ with:
47
+ enable-cache: true
48
+
49
+ - name: Install
50
+ run: uv sync --locked --all-groups --python ${{ matrix.python }}
51
+
52
+ - name: Lint
53
+ run: uv run ruff check --output-format=github .
54
+
55
+ - name: Format check
56
+ run: uv run ruff format --check .
57
+
58
+ - name: Types
59
+ run: uv run mypy
60
+
61
+ - name: Tests
62
+ run: uv run pytest -v --cov --cov-report=term-missing
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+
9
+ # Tooling
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .coverage
14
+ htmlcov/
15
+ .hypothesis/
16
+
17
+ # Test corpus downloaded by scripts/fetch_corpus.py — never committed
18
+ tests/fixtures/corpus/
19
+
20
+ # Never commit real documents. This is a privacy tool; treat it like one.
21
+ *.pdf
22
+ !tests/fixtures/generated/*.pdf
23
+
24
+ # OS
25
+ .DS_Store
26
+
27
+ # Written by the audit job from uv.lock; never committed.
28
+ requirements-audit.txt
29
+
30
+ # Local design-tool exports (mockups, canvas layout). Not shipped code;
31
+ # screenshots of the real, running app go in README.md instead.
32
+ /design/
33
+
34
+ # Local-only folders: documents to try the app on, and the helper that
35
+ # regenerates the README's screenshots. Neither belongs in the repository.
36
+ /PDFs/
37
+ /scripts/make_readme_screenshots.py
38
+
39
+ # Generated by packaging/macos/make_icon.py from mark_pixmap() — a build
40
+ # artifact, not source, the same as dist/ and build/ above.
41
+ packaging/macos/AppIcon.icns
42
+ packaging/macos/AppIcon.iconset/
@@ -0,0 +1,29 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: check-merge-conflict
6
+ - id: check-toml
7
+ - id: check-yaml
8
+ - id: end-of-file-fixer
9
+ - id: trailing-whitespace
10
+ # This is a privacy tool. A stray real document in a commit would be
11
+ # embarrassing at best, so keep the size ceiling low.
12
+ - id: check-added-large-files
13
+ args: ["--maxkb=512"]
14
+
15
+ - repo: https://github.com/astral-sh/ruff-pre-commit
16
+ rev: v0.16.7 # keep in step with ruff's version in uv.lock, or the two disagree
17
+ hooks:
18
+ - id: ruff-check
19
+ args: [--fix]
20
+ - id: ruff-format
21
+
22
+ # The same scan the security workflow runs on src/. Here so a finding shows up
23
+ # before a push instead of after it, as a bare `assert` once did.
24
+ - repo: https://github.com/PyCQA/bandit
25
+ rev: 1.9.4 # keep in step with bandit's version in uv.lock
26
+ hooks:
27
+ - id: bandit
28
+ args: ["-c", "pyproject.toml", "-q"]
29
+ files: ^src/
@@ -0,0 +1,155 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ uses [semantic versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-09-25
8
+
9
+ ### Added
10
+
11
+ - `split`, `merge` and `reorder`. Page numbers are 1-based and inclusive, with
12
+ `-1` for the last page and `12-` for "to the end".
13
+ - Bookmarks are rebuilt against the new page order instead of being dropped.
14
+ Entries orphaned by a removed parent heading are promoted rather than deleted,
15
+ and named destinations are resolved from both `/Dests` and `/Names`.
16
+ - `inspect`, which reports what a PDF contains besides its pages: JavaScript,
17
+ automatic actions, embedded attachments, XFA forms, outbound links, metadata,
18
+ and earlier revisions still recoverable inside the file. `--json` included.
19
+ - `sanitize`, which removes all of the above by rebuilding the document from its
20
+ pages, so payloads are absent from the output rather than merely unreferenced.
21
+ Plain links are kept unless `--strip-links` is given.
22
+ - An enforced no-network guarantee. The process replaces its own socket, DNS and
23
+ TLS entry points with ones that refuse, and the test suite asserts both that
24
+ network calls fail and that PDF work still succeeds while they do.
25
+ - Warnings when output is written into an iCloud Drive, Dropbox, Google Drive or
26
+ OneDrive folder, because a local-only tool writing into a sync folder is not
27
+ local in any way the user cares about.
28
+ - Reporting when QPDF had to repair a damaged file, so a silently reconstructed
29
+ document is not mistaken for an intact one.
30
+ - Encrypted documents, via `--password-stdin` or `MODPDF_PASSWORD`. There is
31
+ deliberately no flag that takes a password as its value.
32
+ - File-size and page-count limits on every input, with clear refusals.
33
+ - Atomic, mode-0600 output writing; split directories at mode 0700.
34
+ - `compress`, with a quality gate (`modpdf/verify.py`) that renders every page
35
+ before and after and compares them, falling back to a lossless result — and
36
+ saying so — the moment any page looks different enough to matter.
37
+ - Oversized images are downsampled and re-encoded with the codec their actual
38
+ pixel values call for: CCITT Group 4 for anything overwhelmingly near-black
39
+ or near-white, whatever colour space it happens to be stored in, JPEG for
40
+ genuine photographs. CMYK images, indexed images and anything carrying a
41
+ transparency mask are left untouched rather than risked.
42
+ - `--lossless` and three tuned `--level` presets (`low`, `balanced`, `high`),
43
+ rather than a raw DPI number to guess at. Only `high` is allowed to accept
44
+ a more visibly different result in exchange for a smaller file; `low` and
45
+ `balanced` both still promise no visible loss.
46
+ - `high` re-encodes every eligible image even when it was not oversized.
47
+ Otherwise a document whose images were already reasonably sized had
48
+ nothing left for "maximum compression" to do beyond what `balanced`
49
+ already did, and the report says so when that happens.
50
+ - `balanced` and `high` can also rasterize a page whose own vector content —
51
+ not an image at all, a complex diagram exported as drawing commands — is
52
+ heavy enough to be worth it, each at its own resolution and JPEG quality,
53
+ since no image setting touches vector art. Every character of text on that
54
+ page, including text that is itself part of the diagram, stays exactly as
55
+ it was and stays selectable. `low` never does this. `balanced`'s flattened
56
+ result still has to clear the same strict quality gate as everything else
57
+ it does, so this did not loosen its no-visible-loss promise; only `high`
58
+ widens what the gate will accept.
59
+ - `high`'s own image resolution and JPEG quality are kept deliberately close
60
+ to lossless — an early, more aggressive version made a flattened diagram's
61
+ own text hard to read. One consequence worth knowing: on an ordinary photo
62
+ or scan with no vector page to flatten, `high` has little left to trade,
63
+ and `balanced` can end up producing the smaller file.
64
+ - A desktop application (`modpdf-gui`, optional extra `modpdf[gui]`, PySide6):
65
+ page thumbnails with selection, drag-to-reorder (tracked by the mouse
66
+ directly rather than through Qt's own drag-and-drop, which does not
67
+ reliably initiate a session at all), delete, duplicate, extract, split by
68
+ hand-built page ranges, compress, sanitize and merge-by-dropping. Edits are held in memory as an ordering of the source
69
+ pages, so nothing is written until you save and Revert is free. Thumbnails
70
+ are never written to disk and no recent-files list is kept.
71
+ - The desktop app's Open button now builds a workspace out of more than one
72
+ file: with a document already open, choosing (or dropping) another one adds
73
+ its pages after the first's instead of replacing it, honouring whatever
74
+ reordering or deletion was already pending. No second button — Open is
75
+ overloaded rather than duplicated, since "bring this file into the
76
+ workspace" is one action either way. Backed by `tasks.append_document` and
77
+ a private, per-window `0700` scratch directory
78
+ (`security/fs.private_scratch_dir`) that is removed when the window closes.
79
+
80
+ - A Settings dialog in the desktop app, behind a gear in the header: thumbnail
81
+ size (small, medium or large), the compression level the Compress panel
82
+ starts on, and light or dark. Every choice applies immediately. Switching
83
+ theme rebuilds the window in place and keeps the open document, page
84
+ selection, active panel, split ranges and compress choice as they were.
85
+ The three values are stored in the operating system's own per-user
86
+ settings location (`modpdf/gui/settings.py`). They are the only thing the
87
+ app remembers between runs, and none of them is a path or anything else
88
+ about a document.
89
+ - `packaging/macos/build.sh`, which builds an unsigned `ModPDF.app` with
90
+ PyInstaller. Its icon is generated from the same drawn mark the window
91
+ uses, so there is still no image asset in the repository. Signing and
92
+ notarization are not done yet; see `packaging/macos/README.md`.
93
+
94
+ - A release workflow. Pushing a version tag publishes to PyPI with Trusted
95
+ Publishing (no API token exists to leak), then creates a GitHub Release
96
+ with the package files, a CycloneDX SBOM of every runtime dependency, and
97
+ an unsigned macOS app. Running it by hand publishes to TestPyPI as a dry
98
+ run.
99
+ - `bandit` now runs in pre-commit, on the same files the security workflow
100
+ scans.
101
+ - ADRs for pikepdf over Ghostscript, the compression quality gate, blocking
102
+ the network at runtime, and the PDFium lock. `docs/compression.md` holds the
103
+ compression details that used to fill most of the README.
104
+
105
+ ### Changed
106
+
107
+ - The README was rewritten to be shorter, with screenshots at the top.
108
+ - The desktop app's layout was redesigned: a new header and toolbar, a
109
+ drop-zone empty state, a document details card, and a set of line icons
110
+ drawn in code (`modpdf/gui/icons.py`) rather than loaded from files or an
111
+ icon font.
112
+
113
+ - Whole operations moved into `modpdf/tasks.py`, which the command line and the
114
+ desktop app both call. Neither interface implements a PDF operation of its own,
115
+ and neither can reach the filesystem without the security layer.
116
+ - `Pdf.save` options can now be threaded through `document.save_pdf`, so a
117
+ caller that computed a file's size with particular save options (compression
118
+ does, for its structural pass) writes the file with the same ones — the
119
+ number in a report always matches the number on disk.
120
+
121
+ ### Fixed
122
+
123
+ - `inspect` always reported "0 images". Its scan skipped every object that
124
+ wasn't a plain dictionary, and an image is always a stream. Soft masks are
125
+ not counted as images of their own, so a transparent picture counts once.
126
+ - In dark mode, radio buttons were invisible until checked: the platform style
127
+ drew their rings in light-theme grey. They're now drawn by the app's own
128
+ stylesheet in both themes.
129
+ - Hints under the Compress panel's options could lose their second line. The
130
+ indent was stylesheet padding, which QLabel's word wrap doesn't measure, and
131
+ a panel taller than the window was squashed to fit. Hints now use a real
132
+ margin, and every inspector panel scrolls when it doesn't fit.
133
+
134
+ - The desktop app rendered every page thumbnail on its own window thread, so
135
+ opening a large document froze the window until the last page was done.
136
+ The renderer had been moved to a worker thread and then called directly —
137
+ and a direct call runs on the caller's thread regardless. Requests now go
138
+ out as queued signals. Each carries a generation number, so a thumbnail
139
+ still arriving for a document or size that has since been replaced is
140
+ dropped instead of landing on the wrong tile.
141
+ - PDFium is not thread-safe, even across unrelated documents, and the desktop
142
+ app could enter it from two threads at once: the thumbnail renderer, and a
143
+ compression running in the background whose quality gate renders every
144
+ page. That can crash the whole process. Every PDFium call now holds one
145
+ process-wide lock (`modpdf/pdfium_lock.py`). The command line is
146
+ single-threaded, so the lock costs it nothing.
147
+
148
+ - A desktop-app background job started from inside another job's own
149
+ completion callback — exactly what adding a second file does, by opening
150
+ the combined result it just wrote — could vanish silently: nothing kept the
151
+ first job's Python object alive between it emitting its result and the main
152
+ thread actually receiving it, so it was sometimes garbage collected in
153
+ between and the window was left saying "Adding…" forever, with no error.
154
+ `workers.run` now holds a reference to every job until its result has
155
+ actually been delivered.
@@ -0,0 +1,92 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
6
+
7
+ ```
8
+ git clone https://github.com/BryanIGD/ModPDF.git
9
+ cd ModPDF
10
+ uv sync --all-groups
11
+ uv run pytest
12
+ ```
13
+
14
+ Before opening a pull request, all of these should pass — they're exactly
15
+ what CI runs:
16
+
17
+ ```
18
+ uv run ruff check .
19
+ uv run ruff format --check .
20
+ uv run mypy
21
+ uv run pytest -v --cov
22
+ uv run bandit -r src/ -c pyproject.toml
23
+ ```
24
+
25
+ ## YAGNI is enforced, not aspirational
26
+
27
+ This is a security-focused tool, and bloat in a security tool is worse than
28
+ bloat elsewhere: every abstraction is more code someone has to read before
29
+ they can trust it. Concretely:
30
+
31
+ - **Build only what a shipped command needs.** No feature flags, config
32
+ options, or extension points without a caller today.
33
+ - **No abstraction before a second implementation exists.** We call pikepdf
34
+ directly; there is no `PdfEngine` interface, because there is no second
35
+ engine. If one ever appears, extract the interface then, informed by real
36
+ requirements instead of a guess.
37
+ - **Every dependency must be load-bearing.** Adding one means updating the
38
+ comment in `pyproject.toml` that names the count, adding an inline comment
39
+ saying what it's for, and — if it changes the shape of a real decision, not
40
+ just a version bump — a note in `docs/adr/`.
41
+ - **Flat over deep.** One module per concept. Split a file when it's
42
+ genuinely hard to read, not in anticipation of it becoming so.
43
+
44
+ If a change adds a layer "for future flexibility," it needs a real, current
45
+ caller in the same pull request, or the flexibility waits until something
46
+ actually needs it.
47
+
48
+ ## How this project writes documentation
49
+
50
+ The README and this file are written the way a person explains something to
51
+ another person, not the way software project marketing usually reads. In
52
+ practice:
53
+
54
+ - No emoji in headings, no "blazing fast," no badge walls.
55
+ - Examples show real output from the actual commands, not invented sample
56
+ text — the point of `tests/conftest.py`'s generated fixtures is that
57
+ examples can be captured and stay true.
58
+ - A limitations section should be specific and slightly unflattering. "This
59
+ won't shrink a text-only PDF much, here's why" is worth more than silence
60
+ on the subject.
61
+ - If a sentence could be deleted without losing information, delete it.
62
+
63
+ ## Tests
64
+
65
+ Every PDF the test suite touches is generated at test time by
66
+ `tests/conftest.py`; no real document is ever committed, and pages carry a
67
+ marker so a test can assert that page 3 really is the page that started as
68
+ page 3 after a split or reorder — not just that the page *count* looks right.
69
+
70
+ - An operation change needs a test asserting page **identity**, not only page
71
+ count.
72
+ - A change to anything in `modpdf/security/` needs a test in
73
+ `tests/security/`, and if it touches the network guard specifically,
74
+ confirm the change still passes with the network blocked.
75
+ - A GUI change that reaches `modpdf.tasks` needs the window-level test to
76
+ call the task function directly (as the existing GUI tests do) rather than
77
+ only exercising it through a live `QThreadPool` job — except when the
78
+ threading itself is what's being tested, as in `tests/gui/test_workers.py`.
79
+
80
+ ## Structure
81
+
82
+ The command line and the desktop app are two interfaces over one set of
83
+ functions in `modpdf/tasks.py`. Neither implements a PDF operation of its
84
+ own, and neither can reach the filesystem without going through
85
+ `modpdf/security/`. A change that adds behavior only one interface can use is
86
+ a sign it belongs in the wrong layer.
87
+
88
+ ## Commit messages and pull requests
89
+
90
+ Say what changed and why, the way the rest of this codebase's comments do.
91
+ "Fix bug" is not that; "the page-count check ran before the password prompt,
92
+ so an encrypted file always failed" is.