Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2673044
test(desktop): preserve Open With intake order
Aaqibhafeezkhan Sep 15, 2026
4f62632
Merge pull request #113 from Aaqibhafeezkhan/fix/110-os-open-intake
McanKul Sep 15, 2026
e034c77
test(desktop): fix OS open queue test visibility
McanKul Sep 15, 2026
b30ee97
fix(security): validate job IDs before filesystem use
McanKul Oct 4, 2026
a52da45
Merge pull request #115 from McanKul/fix/validate-job-ids
McanKul Oct 4, 2026
b78322b
ci: require PDF engines for Rust tests
McanKul Oct 4, 2026
eb95779
Merge pull request #116 from McanKul/ci/require-pdf-engines
McanKul Oct 4, 2026
015a902
fix(pdf): bind source locators to one snapshot
McanKul Oct 4, 2026
5e2f1e5
Merge pull request #117 from McanKul/fix/33-source-snapshot-identity
McanKul Oct 4, 2026
3d8a15b
fix(pdf): fail closed on unsafe content streams (#118)
McanKul Oct 4, 2026
28493aa
fix(pdf): preflight source objects before parsing (#119)
McanKul Oct 4, 2026
3e80df5
fix(pdf): bound cross-reference parsing (#120)
McanKul Oct 4, 2026
79ed37a
fix(pdf): preflight content before parsing (#121)
McanKul Oct 4, 2026
2cd4d99
fix(pdf): reject unsafe font encodings (#122)
McanKul Oct 4, 2026
1c9dc6f
fix(pdf): classify unsafe text states (#123)
McanKul Oct 4, 2026
7f50ff0
docs(editor): record source editing decision (#124)
McanKul Oct 4, 2026
100a4e8
Merge remote-tracking branch 'origin/main' into release/v0.4.0
McanKul Oct 4, 2026
203269a
chore(release): prepare OffPDF v0.4.0
McanKul Oct 4, 2026
3bc2704
Merge pull request #125 from McanKul/release/v0.4.0
McanKul Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/release-notes/v0.4.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
OffPDF v0.4.0 completes the direct-editing foundation while keeping unsupported PDF structures fail-closed.

Highlights:

- Source-content inspection uses one bounded input snapshot with deterministic locators.
- Oversized, malformed, compressed, or ambiguous content is rejected explicitly.
- Font encodings and unsafe PDF text-state operators are checked before a source edit can be considered.
- Desktop job identifiers are validated before filesystem use.
- Operating-system file intake keeps the original file order.
- CI requires the same PDF engines exercised by production paths.

This release does not claim Word-like editing for arbitrary PDFs. Existing source text and image replacement remains limited to separately proven structures; overlays continue to be treated as new content.

Known issue:

- Some real-device HEIC/HEIF files may still crash on Windows; investigation continues in issue #14.

Thanks to @Aaqibhafeezkhan for contributing to this release.

Packages:

- Signed and notarized DMG for Apple Silicon Macs
- Windows x64 NSIS installer — unsigned while Authenticode signing is pending. Verify the accompanying SHA-256 file before use.

See [CHANGELOG.md](https://github.com/McanKul/offpdf/blob/v0.4.0/CHANGELOG.md) for full details.
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,16 @@ jobs:
librsvg2-dev \
libwebkit2gtk-4.1-dev \
patchelf \
poppler-utils \
qpdf \
wget

- name: Show PDF engine versions
run: |
qpdf --version | head -n 1
pdftotext -v 2>&1 | head -n 1
pdftoppm -v 2>&1 | head -n 1

- name: Use stable Rust
run: rustup default stable

Expand All @@ -79,4 +86,18 @@ jobs:
run: cargo check --manifest-path src-tauri/Cargo.toml

- name: Test Rust backend
run: cargo test --manifest-path src-tauri/Cargo.toml --lib
shell: bash
env:
# Edit text tests fail instead of skipping when qpdf or Poppler is missing;
# the grep below also catches the older tests' `skip:` lines.
OFFPDF_REQUIRE_ENGINES: "1"
run: |
set -o pipefail
cargo test --manifest-path src-tauri/Cargo.toml --lib -- --nocapture 2>&1 | tee rust-test.log
skips=$(grep -c 'skip:' rust-test.log || true)
echo "skip lines: $skips"
if [ "$skips" -ne 0 ]; then
grep 'skip:' rust-test.log
echo "::error::Rust tests skipped because a PDF engine was missing"
exit 1
fi
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable project changes should be documented here.

## Unreleased

## 0.4.0 - 2026-10-05

### Added

- Completed the v0.4 direct-editing foundation with a documented compatibility matrix and an explicit boundary between safe overlays and narrowly proven source-content edits.
- Added deterministic, read-only source locators for supported whole-string text and uniquely referenced image structures without exposing a general save capability.

### Fixed

- Source-content inspection now reads from one bounded snapshot and fails closed on oversized, malformed, compressed, or ambiguous PDF structures.
- Cross-reference tables, indirect objects, decoded content streams, font encodings, and unsafe text-state operators are bounded or rejected with explicit reasons.
- Desktop job identifiers are validated before they can reach temporary filesystem paths.
- Files opened through the operating system retain their original intake order.

### CI

- Rust CI now installs and requires the PDF engines used by production paths instead of silently skipping their tests.

### Contributors

- Thank you to @Aaqibhafeezkhan for contributing to this release.

### Known issues

- Some real-device HEIC/HEIF files may still crash on Windows; investigation continues in issue #14.

## 0.3.2 - 2026-09-11

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ be filled when several files are combined; and Edit PDF does not rewrite text
or images already embedded in a source page. New text and images are added as
overlays instead.

The investigation behind that boundary is recorded in the
[source-content editing decision](./docs/source-editing-decision.md).

## Downloads

| Platform | Package | Status |
Expand Down
112 changes: 112 additions & 0 deletions docs/source-editing-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Existing source-content editing decision

Status: concluded on 2026-10-04 for #11 and #33.

## Decision

OffPDF should not present existing PDF text or images as generally editable.
The read-only classifier is useful as an evidence gate, but a `supported` result
does not authorize a Save action. It is not connected to a Tauri command, React,
or a mutation path.

The only candidates demonstrated by the committed corpus are:

| Fixture | Candidate operation | Boundary |
| --- | --- | --- |
| `text-tj` | Replace the complete `Tj` string | Same simple Helvetica font; no substring edit or reflow |
| `text-tj-kerned` | Replace the complete `TJ` array | Preserve explicit positioning; no paragraph reflow |
| `image-unique` | Replace one Image XObject stream | The object must not be shared, nested, inline, or masked |

These remain research candidates. They need a separate mutation prototype and a
publish gate that proves the source operator or image object was replaced. The
existing overlay validation gate cannot prove that: a white rectangle plus new
text can leave the old searchable content in the PDF.

## Compatibility matrix

| Structure | Classifier result | Reason or note |
| --- | --- | --- |
| Simple `Tj` text | Supported candidate | Whole operand only; demonstrated with Helvetica |
| Kerned `TJ` text | Supported candidate | Whole array only; spacing is included in bounds |
| CID text with ToUnicode | Unsupported | `AMBIGUOUS_UNICODE`; decoding does not prove that replacement text can be encoded |
| CID text without ToUnicode | Unsupported | `NO_TOUNICODE` |
| Subset or custom-encoded simple font | Unsupported | `SUBSET_FONT` or `CUSTOM_ENCODING` |
| Type 3 or vertical text | Unsupported | `TYPE3` or `VERTICAL` |
| Raised text or non-default rendering mode | Unsupported | `TEXT_RISE` or `TEXT_RENDER_MODE` |
| Rotated, skewed, clipped, patterned, or nested text | Unsupported | Explicit geometry/content reason |
| Unique Image XObject | Supported candidate | One independently owned image object |
| Reused, nested, inline, or masked image | Unsupported | `SHARED_XOBJECT`, `NESTED_FORM`, `INLINE_IMAGE`, or `MASKED_IMAGE` |
| Rotated page, offset crop origin, or non-default UserUnit | Unsupported when paint is present | `GEOMETRY` |
| Encrypted, signed, stale, oversized, or malformed source | Rejected | Recoverable file/content error; no silent fallback |

The corpus contains 18 synthetic, license-safe PDFs (14,624 bytes total). It covers
the structures above but is not representative of every Office, browser,
scanner, or CAD producer. Passing it is evidence for the bounded subset, not a
claim that arbitrary PDFs are editable.

## Bounds and failure behavior

The classifier reads and hashes one bounded source snapshot. Raw cross-reference
data, object values, object streams, decoded content, operands, operations,
recursive containers, graphics-state depth, Form recursion, and occurrence count
are capped before their respective parser or traversal can grow without bound.
Unsupported filters and unprovable inline-image boundaries fail closed. Malformed
input returns an application error instead of falling back to compressed bytes
or a visual overlay.

The implementation was built in small reviews: #97 and #117–#123. The fixture
corpus is #32; output validation is #34.

## Performance and packaging evidence

Measurement environment: macOS arm64 (Darwin 25.4.0), Rust 1.96.0, one test
thread, debug test harness, 2026-10-04.

| Measurement | Result |
| --- | --- |
| Classifier and preflight tests | 68 passed |
| Test-reported execution time | 0.13 s |
| Wall time measured with `/usr/bin/time -l` | 0.14 s |
| Peak resident set size | 21,381,120 bytes (about 20.4 MiB) |
| Full Rust library regression suite | 325 passed |
| New native sidecar payload | 0 bytes |
| Fixture bytes included in Tauri resources | 0 bytes |

This is a regression-harness measurement over a small synthetic corpus, not a
production throughput or worst-case memory benchmark. The exact signed-installer
delta was not isolated: lopdf 0.34 was already a direct OffPDF dependency before
the classifier, and the fixture corpus is not bundled. No new native runtime or
sidecar was added. A release-size comparison should be repeated only if this
currently unexposed classifier is wired into the product.

The full Rust suite passes locally on macOS and in the Ubuntu GitHub workflow.
The classifier has not received a dedicated Windows execution run. Because the
current path is Rust code linked into the existing application, it adds no
separate Windows or macOS signing target.

## Engine choice

| Concern | Bounded lopdf prototype | PDFium sidecar |
| --- | --- | --- |
| Current implementation | Complete for the committed classifier corpus | Not implemented |
| Native code boundary | None added | Native library and process boundary required |
| Crash isolation | Runs in-process; bounded safe-Rust preflight reduces parser exposure | Must run out of process before accepting untrusted PDFs |
| Package and signing work | No new native artifact | Per-platform binary packaging and signing; not measured |
| Evidence gained for this spike | Deterministic reasons and locators across the corpus | No additional evidence yet |

Stay on lopdf 0.34 for the read-only classifier. A PDFium prototype is not
justified by the current corpus and was deliberately not built. If a future
fixture cannot be classified safely with lopdf and materially blocks a supported
subset, evaluate PDFium in a separate issue and require an out-of-process
protocol before loading untrusted documents.

## Product boundary

- Keep overlay text and images described as newly added content, never as a
replacement for existing source content.
- Use secure redaction when the goal is to remove existing content.
- Show a specific unsupported reason; do not flatten, cover, or silently fall
back.
- Do not expose the classifier as an editing permission until a narrow mutation
issue proves replacement, output validation, stale-source handling, and
sibling-destination publishing for its exact subset.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "offpdf",
"private": true,
"version": "0.3.2",
"version": "0.4.0",
"type": "module",
"description": "Free, open-source, offline-first desktop PDF utility.",
"license": "MIT",
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 3 additions & 4 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "offpdf"
version = "0.3.2"
version = "0.4.0"
description = "Free, open-source, offline-first desktop PDF utility."
authors = ["OffPDF contributors"]
edition = "2021"
Expand All @@ -27,6 +27,8 @@ fs2 = "0.4"

# Read/write PDF page boxes (used for cropping).
lopdf = "0.34"
flate2 = "1"
sha2 = "0.10"

# Glyph metrics for the bundled editor font (Noto Sans).
ttf-parser = "0.25"
Expand All @@ -44,9 +46,6 @@ image = { version = "0.25", default-features = false, features = [
# app so users do not need to install a system codec or upload their photos.
heif-rs = { path = "vendor/heif-rs" }

[dev-dependencies]
flate2 = "1"

[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"

Expand Down
56 changes: 56 additions & 0 deletions src-tauri/src/commands/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
use crate::error::AppError;
use crate::models::JobRegistry;

/// Longest job id the frontend sends (`crypto.randomUUID()` is 36 characters).
const JOB_ID_MAX: usize = 128;

/// The webview's job id names a work folder (`<temp>/work/<job_id>`) that the job deletes when it
/// ends: only `[A-Za-z0-9_-]{1,128}` is accepted, so it can never step out of the temp root.
pub(crate) fn check_job_id(job_id: &str) -> Result<(), AppError> {
let ok = !job_id.is_empty()
&& job_id.len() <= JOB_ID_MAX
&& job_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
if ok {
return Ok(());
}
Err(AppError::new(
"INVALID_JOB",
"Invalid job",
"OffPDF received a job id it does not accept.",
)
.with_details(format!("job id of {} bytes", job_id.len())))
}

/// Cancel a running job by id. Killing the child (if any) is handled by the
/// `JobHandle`; the worker thread reaps it and returns `AppError::cancelled`.
#[tauri::command]
Expand All @@ -15,3 +37,37 @@ pub async fn cancel_job(
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::check_job_id;

/// review-T5: a job id reaches `remove_dir_all(<temp>/work/<job_id>)`.
#[test]
fn job_ids_cannot_leave_the_work_folder() {
for ok in [
"3f2b8c1e-0d4a-4c6e-9a51-7b2f0e9d1c34",
"job-1700000000000-1a2b",
"a_b",
] {
assert!(check_job_id(ok).is_ok(), "{ok}");
}
let long = "a".repeat(129);
for bad in [
"",
"..",
"../x",
"a/b",
"a\\b",
"C:x",
"a b",
long.as_str(),
"x\0",
] {
let e = check_job_id(bad)
.err()
.unwrap_or_else(|| panic!("{bad:?} accepted"));
assert_eq!(e.code, "INVALID_JOB");
}
}
}
Loading
Loading