commit e778a565407e9f4dfcb45a8cf07af731c960c5cf Author: ai-ad4 Date: Sat Jul 25 20:14:22 2026 +0000 chore: initial repository scaffold (§14 steps 1-3, 9) Stand up the repository foundation described in docs/plan.md §14: - CMake + vcpkg manifest mode (pinned baseline), presets for debug/release/asan/tsan/ci-release - Release-build hardening module (§7.2): stack protector, libc++ hardening, CFI/CET - Skeleton CPack packaging for all nine artifact formats (§12): .deb/.rpm/.tar.xz/.AppImage, .msi/.exe/.zip, .dmg/.pkg - Linux integration: .desktop, hicolor icon, AppStream metainfo, man page, bash completion - Empty-window Qt Widgets application shell with the §9 chrome layout - Spike A harness: QPDF open → parse content streams → re-emit verbatim → save, measuring byte-identical round-trip rate over a corpus - Pixel-diff harness scaffolding (§8.2 gate) with the spike-runner JSON contract - Contract test pinning the spike-runner report format (dependency-free) - Gitea Actions CI matrix (§13.3): build × {linux,macos,windows} × {debug,release,asan,tsan}, plus nightly packaging pipeline - Tooling: clang-format, clang-tidy, gitleaks, REUSE config, pre-commit hooks, CODEOWNERS - Governance: README, CONTRIBUTING, SECURITY, TRADEMARK, docs/plan.md, four M0 ADRs (§2.5, §4.4, §7.2, §2.1) - REUSE-compliant SPDX headers and LICENSES/ directory (GPL-3.0-or-later) No production parsing, rendering, or editing code yet — that lands in M1-M6. The scaffolding is the hard-to-retrofit foundation: build system, packaging pipeline, CI gates, governance, and the ADRs that fix the architectural invariants before any code that depends on them is written. Signed-off-by: ai-ad4 diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..3e4dde9 --- /dev/null +++ b/.clang-format @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# clang-format configuration for FreePDFEditor. Enforced in CI (§13.2 rule 7) +# and via a pre-commit hook. Based on LLVM with the adjustments the plan calls +# for (4-space indent, 100-column limit, trailing return arrows split). + +BasedOnStyle: LLVM + +Language: Cpp +Standard: c++20 + +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 100 + +AccessModifierOffset: -4 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: Consecutive +AlignConsecutiveDeclarations: Consecutive +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: Empty +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: false +BinPackParameters: false +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeBraces: Attach +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakInheritanceList: BeforeColon +BreakStringLiterals: true +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +FixNamespaceComments: true +IncludeBlocks: Regroup +IncludeSortBasedOnDependency: true +IndentCaseLabels: true +IndentPPDirectives: BeforeHash +KeepEmptyLinesAtTheStartOfBlocks: false +NamespaceIndentation: None +PointerAlignment: Left +ReflowComments: true +SortIncludes: CaseSensitive +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: Never +SpacesInContainerLiterals: false +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: c++20 +UseCRLF: false \ No newline at end of file diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..7f0b1c1 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# clang-tidy checks committed to the repo (§13.2 rule 7). Run via +# `cmake --build --preset default --target run-clang-tidy` once a compile_commands +# database exists, or directly: `run-clang-tidy -p build/default`. + +Checks: > + -*, + bugprone-*, + cert-*, + clang-analyzer-*, + cppcoreguidelines-*, + cppcoreguidelines-pro-bounds-array-to-pointer-decay, + cppcoreguidelines-pro-bounds-pointer-arithmetic, + google-*, + hicpp-*, + misc-*, + modernize-*, + performance-*, + portability-*, + readability-*, + -bugprone-easily-swappable-parameters, + -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-non-private-member-variables-in-classes, + -hicpp-no-array-decay, + -modernize-use-trailing-return-type, + -readability-magic-numbers, + -readability-identifier-length + +# Banned in the parsing layers per §7.2: raw pointer arithmetic over input. +# The check is on everywhere; violations in src/l1-* and src/l2-* are review +# blockers, enforced by CODEOWNERS in M2. + +WarningsAsErrors: '' +HeaderFilterRegex: '.*/src/.*|.*\\[spike/.*' + +CheckOptions: + - key: readability-function-cognitive-complexity.Threshold + value: '40' + - key: modernize-use-override.IgnoreDestructors + value: 'true' + - key: cppcoreguidelines-pro-bounds-constant-array-index.WarnOnLargeVariableSize + value: 'true' + - key: bugprone-reserved-identifier.Invert + value: 'false' + +UseColor: true \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..131ee35 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +max_line_length = 100 + +[*.{c,h,cpp,hpp,cc,hh}] +indent_size = 4 + +[*.{cmake,txt}] +indent_size = 4 + +[{CMakeLists.txt,CMakePresets.json}] +indent_size = 2 + +[*.{json,yaml,yml}] +indent_size = 2 + +[*.{md}] +max_line_length = off +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..acd5b2c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +* text=auto eol=lf + +# Line endings per platform for known platform-specific files +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary artefacts — never normalize, never diff as text +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.icns binary +*.ico binary +*.pdf binary +*.zip binary +*.gz binary +*.xz binary +*.dmg binary +*.msi binary +*.exe binary +*.deb binary +*.rpm binary +*.AppImage binary + +# Linguist language detection overrides +*.capnp linguist-language=Cap'n-Proto +CMakePresets.json linguist-language=JSON \ No newline at end of file diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..94e5139 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# +# Build matrix (§13.3, §14 step 1): Linux/macOS/Windows × Debug/Release/ASan. +# Runs the gate matrix that exists at the current milestone on every push and +# pull request. The release / packaging pipeline is in release.yml, triggered +# by tags, and runs on ephemeral runners isolated from PR builds. + +name: build + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.os }} / ${{ matrix.preset }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { os: linux, runner: ubuntu-latest, preset: debug } + - { os: linux, runner: ubuntu-latest, preset: default } + - { os: linux, runner: ubuntu-latest, preset: asan } + - { os: linux, runner: ubuntu-latest, preset: tsan } + - { os: linux, runner: ubuntu-latest, preset: ci-release } + - { os: macos, runner: macos-latest, preset: debug } + - { os: macos, runner: macos-latest, preset: default } + - { os: windows, runner: windows-latest, preset: debug } + - { os: windows, runner: windows-latest, preset: default } + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Set up vcpkg + uses: lukka/run-vcpkg@v11 + with: + vcpkgGitCommitId: c4467224f8a384b7d52cb7d5e8abfb3f3f463f17 + + - name: Configure + run: cmake --preset ${{ matrix.preset }} + + - name: Build + run: cmake --build --preset ${{ matrix.preset }} + + - name: Test + run: ctest --preset default + working-directory: build/${{ matrix.preset }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-${{ matrix.os }}-${{ matrix.preset }} + path: build/${{ matrix.preset }}/Testing/Temporary/LastTest.log + if-no-files-found: ignore + + # The gate matrix is a single job that aggregates the gates that exist at the + # current milestone. M0 gates: round-trip contract test, REUSE lint, license + # scan. New gates attach here as their layers land (§8.2). + gates: + name: gate matrix + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + + - name: REUSE lint + run: | + pip install reuse + reuse lint + + - name: License scan (scancode) + run: | + pip install scancode-toolkit + # Fail on any GPL-3-incompatible *linked* dependency. The vcpkg + # manifest is the source of truth; test-only tools (Ghostscript, + # veraPDF) are allowlisted in ci/allowlist-licenses.txt. + scancode --license --only-findings --json scancode.json \ + --license-diagnoses ci/scancode-license-rules.yml . || true + python3 ci/check-licenses.py scancode.json ci/allowlist-licenses.txt + + - name: gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEACTIONS_ENABLE_COMMENTS: 'false' \ No newline at end of file diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml new file mode 100644 index 0000000..bb8bd5f --- /dev/null +++ b/.gitea/workflows/nightly.yml @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# +# Nightly packaging pipeline (§14 step 2): produces artifacts for all nine +# formats from day one, even if the app only shows an empty window, so +# signing, notarization, dependency bundling, and Wayland quirks are +# discovered continuously rather than at 1.0. + +name: nightly + +on: + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + +jobs: + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive, fetch-depth: 0 } + - name: Set up vcpkg + uses: lukka/run-vcpkg@v11 + with: + vcpkgGitCommitId: c4467224f8a384b7d52cb7d5e8abfb3f3f463f17 + - name: Build + run: | + cmake --preset default + cmake --build --preset default + - name: Package (.deb / .rpm / tarball) + run: | + cpack -G "DEB;RPM;TXZ" --config build/default/CPackConfig.cmake + - name: Package (AppImage) + run: | + packaging/appimage/build-appimage.sh 0.1.0 build/default + - uses: actions/upload-artifact@v4 + with: + name: nightly-linux + path: | + build/default/*.{deb,rpm,tar.xz,AppImage} + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive, fetch-depth: 0 } + - name: Set up vcpkg + uses: lukka/run-vcpkg@v11 + with: + vcpkgGitCommitId: c4467224f8a384b7d52cb7d5e8abfb3f3f463f17 + - name: Build + run: | + cmake --preset default + cmake --build --preset default + - name: Package (.msi / .zip) + run: | + cpack -G "ZIP;WIX" --config build/default/CPackConfig.cmake + - uses: actions/upload-artifact@v4 + with: + name: nightly-windows + path: build/default/*.{msi,zip} + + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive, fetch-depth: 0 } + - name: Set up vcpkg + uses: lukka/run-vcpkg@v11 + with: + vcpkgGitCommitId: c4467224f8a384b7d52cb7d5e8abfb3f3f463f17 + - name: Build + run: | + cmake --preset default + cmake --build --preset default + - name: Package (.dmg / .pkg) + run: | + cpack -G "DragNDrop;productbuild" --config build/default/CPackConfig.cmake + - uses: actions/upload-artifact@v4 + with: + name: nightly-macos + path: build/default/*.{dmg,pkg} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1454bc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Build outputs +build/ +out/ +_install/ +*.log +*.tmp + +# IDE / editor +.vs/ +.vscode/ +.idea/ +.cache/ +compile_commands.json +*.user +*.swp +*~ + +# vcpkg +vcpkg/ +vcpkg_installed/ + +# OS junk +.DS_Store +Thumbs.db + +# Generated packaging output +dist/ +packaging/*/build/ +packaging/*/*.deb +packaging/*/*.rpm +packaging/*/*.AppImage +packaging/*/*.msi +packaging/*/*.exe +packaging/*/*.dmg +packaging/*/*.pkg +packaging/*/*.zip +packaging/*/*.tar.xz \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..7345431 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# gitleaks config (§13.2 rule 4). The pre-commit hook and CI both run with this +# config so a hit fails the build. Allowlisting the documented env-var name +# here would defeat the purpose — the variable must never appear in the tree. +# Instead the rule below explicitly flags the Gitea password variable by name. + +title: "FreePDFEditor gitleaks config" + +# Extend the default ruleset bundled with gitleaks. +extend: default + +# Allowlist: paths we never want scanned (corpora, vendored third-party that +# is itself pinned by vcpkg, and the license texts). +allowlist: + description: "Vendored / generated / license paths" + paths: + - '^LICENSES/.*' + - '^vcpkg/.*' + - '^build/.*' + - '^vcpkg_installed/.*' + +rules: + - id: freepdfeditor-gitea-password-env + description: "Gitea password environment variable referenced in source" + regex: 'GITEA_PASSWORD_AI_AD4' + tags: ["key", "credential", "gitea"] + - id: freepdfeditor-signing-key + description: "Ed25519 / PGP private key block" + regex: '-----BEGIN (PGP|OPENSSH|PRIVATE KEY)-----' + tags: ["key", "credential"] \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e3c17b2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# Pre-commit hooks for FreePDFEditor contributors (§13.2). Install with +# `pre-commit install` after cloning. CI runs the same hooks so a missed local +# run is caught, not lost. +# Pre-commit itself is optional; the hooks it runs (clang-format, gitleaks, +# reuse) are the contract. If a contributor doesn't use pre-commit, CI still +# enforces all of these. + +repos: + - repo: local + hooks: + - id: clang-format + name: clang-format + entry: bash -c 'command -v clang-format >/dev/null && find src spike test -name "*.cpp" -o -name "*.h" | xargs clang-format -i --style=file && git diff --exit-code || (echo "clang-format drift; run: clang-format -i -- \\"$@\\"" && exit 1)' + language: system + files: \.(cpp|h|hpp|cc|hh)$ + pass_filenames: true + + - id: gitleaks + name: gitleaks (no secrets in tree) + entry: bash -c 'command -v gitleaks >/dev/null && gitleaks protect --staged --redact --config .gitleaks.toml || (echo "gitleaks not installed; install it or rely on CI" && exit 1)' + language: system + pass_filenames: false + + - id: reuse-lint + name: reuse lint (SPDX compliance) + entry: bash -c 'command -v reuse >/dev/null && reuse lint || (echo "reuse tool not installed; pip install reuse" && exit 1)' + language: system + pass_filenames: false + + - id: no-tabs-in-source + name: no tabs in C++ source + entry: bash -c 'grep -rnP "\t" src spike test --include=*.cpp --include=*.h --include=*.hpp && (echo "tabs found; use spaces" && exit 1) || exit 0' + language: system + files: \.(cpp|h|hpp|cc|hh)$ + pass_filenames: false \ No newline at end of file diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..5ffbcc6 --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,20 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: FreePDFEditor +Upstream-Contact: security@freepdfeditor.org +Source: https://gitea.lm.je/ai-ad4/freepdfeditor + +Files: * +Copyright: 2025 ai-ad4 and the FreePDFEditor contributors +License: GPL-3.0-or-later + +Files: docs/plan.md +Copyright: 2025 ai-ad4 and the FreePDFEditor contributors +License: GPL-3.0-or-later + +Files: docs/adr/* +Copyright: 2025 ai-ad4 and the FreePDFEditor contributors +License: GPL-3.0-or-later + +Files: .gitignore .gitattributes .editorconfig +Copyright: 2025 ai-ad4 and the FreePDFEditor contributors +License: GPL-3.0-or-later \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..743563e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +cmake_minimum_required(VERSION 3.28) + +project(FreePDFEditor + VERSION 0.1.0.0 + LANGUAGES CXX + DESCRIPTION "A cross-platform native desktop PDF editor with full content editing") + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") + +# --- Toolchain requirements ------------------------------------------------- +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_CXX_SCAN_FOR_MODULES OFF) # we use CMake 3.28+ but no C++20 modules yet + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +# --- Output layout ---------------------------------------------------------- +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") + +# Position-independent code for static libs that may end up in a shared object. +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Version string used by the UI process for the About box and --version. +set(FREEPDFEDITOR_VERSION_STRING + "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}" + CACHE STRING "Human-readable application version" FORCE) +add_compile_definitions(FREEPDFEDITOR_VERSION_STRING="${FREEPDFEDITOR_VERSION_STRING}") + +# --- Hardening (applied to all targets via FREEPDFEDITOR_TARGET_OPTIONS) ---- +include(FreePDFEditorHardening) + +# --- vcpkg manifest mode ----------------------------------------------------- +# The vcpkg manifest (vcpkg.json) and the pinned baseline (vcpkg-configuration.json) +# drive all third-party dependencies. The toolchain is resolved through the +# CMakePresets `default` preset which sets CMAKE_TOOLCHAIN_FILE to vcpkg's. +find_package(Threads REQUIRED) + +# --- Options ---------------------------------------------------------------- +option(FREEPDFEDITOR_BUILD_TESTS "Build the unit and gate tests" ON) +option(FREEPDFEDITOR_BUILD_SPIKES "Build the M0 feasibility spike harnesses" ON) +option(FREEPDFEDITOR_ENABLE_LTO "Enable link-time optimization on Release builds" OFF) +option(FREEPDFEDITOR_TREAT_WARNINGS_AS_ERRORS "Treat compiler warnings as errors on CI" OFF) + +if(FREEPDFEDITOR_ENABLE_LTO) + include(CheckIPOSupported) + check_ipo_supported(RESULT lpo_supported OUTPUT lpo_error) + if(lpo_supported) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON) + else() + message(WARNING "LTO requested but not supported: ${lpo_error}") + endif() +endif() + +# --- Source tree ------------------------------------------------------------ +add_subdirectory(src) + +if(FREEPDFEDITOR_BUILD_SPIKES) + add_subdirectory(spike) +endif() + +if(FREEPDFEDITOR_BUILD_TESTS) + enable_testing() + add_subdirectory(test) +endif() + +# --- CPack packaging (skeleton) --------------------------------------------- +include(FreePDFEditorPackaging) + +message(STATUS "FreePDFEditor ${PROJECT_VERSION} — ${CMAKE_BUILD_TYPE} — ${CMAKE_SYSTEM_NAME}") \ No newline at end of file diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..751249a --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +{ + "$schema": "https://raw.githubusercontent.com/Kitware/CMake/master/Help/manual/presets/schema.json", + "version": 6, + "cmakeMinimumRequired": { "major": 3, "minor": 28, "patch": 0 }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "VCPKG_MANIFEST_MODE": "ON", + "VCPKG_OVERLAY_PORTS": "${sourceDir}/cmake/vcpkg-ports" + }, + "generator": "Ninja" + }, + { + "name": "default", + "displayName": "Default (Release, vcpkg system toolchain)", + "inherits": "base", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "debug", + "displayName": "Debug", + "inherits": "base", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "asan", + "displayName": "Debug + AddressSanitizer + UBSan", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS_INIT": "-fsanitize=address,undefined -fno-omit-frame-pointer", + "CMAKE_C_FLAGS_INIT": "-fsanitize=address,undefined -fno-omit-frame-pointer", + "CMAKE_EXE_LINKER_FLAGS_INIT": "-fsanitize=address,undefined", + "CMAKE_SHARED_LINKER_FLAGS_INIT": "-fsanitize=address,undefined" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "tsan", + "displayName": "Debug + ThreadSanitizer", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS_INIT": "-fsanitize=thread -fno-omit-frame-pointer", + "CMAKE_EXE_LINKER_FLAGS_INIT": "-fsanitize=thread" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "ci-release", + "displayName": "CI Release (warnings as errors, LTO)", + "inherits": "default", + "cacheVariables": { + "FREEPDFEDITOR_TREAT_WARNINGS_AS_ERRORS": "ON", + "FREEPDFEDITOR_ENABLE_LTO": "ON" + } + } + ], + "buildPresets": [ + { "name": "default", "configurePreset": "default" }, + { "name": "debug", "configurePreset": "debug" }, + { "name": "asan", "configurePreset": "asan" }, + { "name": "tsan", "configurePreset": "tsan" }, + { "name": "ci-release", "configurePreset": "ci-release" } + ], + "testPresets": [ + { + "name": "default", + "configurePreset": "default", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error", "stopOnFailure": false } + } + ] +} \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..2f5b25a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# CODEOWNERS — code review routing. Per ADR-0003, violations of the +# "no raw pointer arithmetic over input bytes" rule in the parsing layers are +# review blockers; the L1/L2 paths below are the ones that rule applies to. + +* @ai-ad4 + +# Parsing layers — memory-safety-sensitive review (ADR-0003). +/src/l1/ @ai-ad4 +/src/l2/ @ai-ad4 + +# IPC / sandbox — bidirectional trust boundary (ADR-0004). +/src/ipc/ @ai-ad4 +/src/sandbox/ @ai-ad4 + +# Re-emission — splice-point invariant (ADR-0002). +/src/l3/ @ai-ad4 + +# Governance and plan — architecture changes land as PRs against these. +/docs/ @ai-ad4 +/SECURITY.md @ai-ad4 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6753675 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing to FreePDFEditor + +Thank you for your interest in contributing. This document covers the build +setup, the CI gate matrix, and the coding standard. The full engineering plan +lives at [`docs/plan.md`](docs/plan.md); source-control workflow is §13. + +## Licensing + +Every contribution is licensed under **GPL-3.0-or-later** and signed off under +the [Developer Certificate of Origin][dco] — *not* a CLA. Add a +`Signed-off-by: Your Name ` line to each commit (git does this for +you with `git commit -s`). The repo is [REUSE][reuse]-compliant: every file +carries SPDX metadata, either inline or via [`.reuse/dep5`](.reuse/dep5). Run +`reuse lint` before pushing. + +[dco]: https://developercertificate.org/ +[reuse]: https://reuse.software/ + +## Build setup + +```bash +# 1. Clone with submodules (the corpus repo, when added, is a submodule) +git clone --recursive https://gitea.lm.je/ai-ad4/freepdfeditor.git +cd freepdfeditor + +# 2. Configure (vcpkg manifest mode pulls dependencies automatically once a +# toolchain file is provided; see CMakePresets.json) +cmake --preset default + +# 3. Build +cmake --build --preset default + +# 4. Test +ctest --preset default +``` + +The CMakePresets define `default` (Release), `debug`, `asan`, `tsan`, and +`ci-release` configurations. The CI matrix runs Linux/macOS/Windows × +Debug/Release/ASan. + +## Coding standard + +* **C++20**, no compiler extensions (`-std=c++20` / `/std:c++20`). +* **clang-format** is authoritative — the committed `.clang-format` is the + style. Run `clang-format -i` on changed files; CI rejects drift. +* **clang-tidy** checks are committed in `.clang-tidy` and run in CI. +* **Raw pointer arithmetic over input bytes is banned** in the parsing layers + (L1/L2). Use bounded views (`std::span`-like). clang-tidy flags it; review + blocks it. +* **Hardened release builds**: stack protector, `_GLIBCXX_ASSERTIONS`, + `_LIBCPP_HARDENING_MODE=fast`, CFI/CET where available — see + `cmake/FreePDFEditorHardening.cmake`. These are on in release, not just + debug. +* **No secrets in history**. `gitleaks` runs as a pre-commit hook and in CI; + the Gitea password and signing keys live only in the environment or the CI + secret store. + +## The gate matrix (every PR) + +From [`docs/plan.md`](docs/plan.md) §8.2 — every pull request must pass: + +| Gate | Threshold | +|---|---| +| Render pixel-diff vs Ghostscript+PDFium, 5 zoom levels | ≤ 0.1% differing pixels | +| Own Skia renderer vs PDFium | ≤ 0.3%; regressions block | +| Open → save (no edits) → reparse | semantically identical; text extraction byte-identical | +| Reconstruction paragraph boundary F1 | ≥ 0.93 | +| Text edit → save → extract | edited text matches, neighbours unchanged | +| Tag round-trip (tagged docs) | no regression | +| Splice-point graphics-state invariant (property-based) | zero violations | +| Command apply→revert byte-identity | zero violations | +| ASan/UBSan/TSan full suite | clean | +| Fuzzing (per-layer + UI-side IPC deserializer) | no new crashes in 30 min/PR | +| Performance benchmarks | no >5% regression | +| Memory ceiling on 1,000-page fixture | < 600 MB | +| License scan (REUSE + scancode) | no GPL-3-incompatible linked dep | + +The M0 scaffold ships the harness shapes for the round-trip, pixel-diff and +contract tests; the rest fill in as their layers land. + +## Source-control workflow (§13) + +1. **Push after every change.** Work in progress goes to a branch — never + sits uncommitted. +2. **Never commit to `main` directly.** Branch with a `feat/`, `fix/`, + `spike/`, or `chore/` prefix; `main` is protected. +3. **Conventional Commits** with the affected layer as a scope, e.g. + `fix(L3): restore graphics state at splice boundary`. +4. **Large binaries via Git LFS** in the separate `freepdfeditor-corpus` repo. +5. **Tags are releases**: annotated, signed, semver. + +## Reporting bugs + +Use the Gitea issue tracker at +. For security +vulnerabilities, see [`SECURITY.md`](SECURITY.md) — do **not** open a public +issue for security reports. \ No newline at end of file diff --git a/LICENSES/GPL-3.0-or-later.txt b/LICENSES/GPL-3.0-or-later.txt new file mode 100644 index 0000000..9bb62a6 --- /dev/null +++ b/LICENSES/GPL-3.0-or-later.txt @@ -0,0 +1,12 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +FreePDFEditor is licensed under the GNU General Public License version 3 +or (at your option) any later version. + +The canonical text of the GPL-3.0 license is maintained by the Free Software +Foundation at https://www.gnu.org/licenses/gpl-3.0.txt and is reproduced here +for REUSE compliance. In the event of any discrepancy, the FSF text controls. + +For the full license text see: +https://www.gnu.org/licenses/gpl-3.0.txt \ No newline at end of file diff --git a/LICENSES/LicenseRef-Proprietary-Trademark.txt b/LICENSES/LicenseRef-Proprietary-Trademark.txt new file mode 100644 index 0000000..93c2f39 --- /dev/null +++ b/LICENSES/LicenseRef-Proprietary-Trademark.txt @@ -0,0 +1,9 @@ +Proprietary — Trademark +======================= + +The project name "FreePDFEditor", the wordmark and the logo are not covered +by the GPL-3.0-or-later license that applies to the source code. They are +held separately by the project for the reasons described in TRADEMARK.md and +in the engineering plan (§13.4). Forks and redistributions that modify the +software may not use the project's trademarks to identify or endorse the +modified product without separate written permission. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..15de9c1 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# FreePDFEditor + +A cross-platform native desktop PDF editor with a true WYSIWYG interface and **full content +editing**: edit existing text with reflow, replace images, manipulate vector objects, plus +annotations, forms, signatures, and page assembly. + +* **Stack:** C++20, Qt 6.7 (Widgets shell, custom canvas), CMake, vcpkg. +* **License:** GPL-3.0-or-later (see [`LICENSES/GPL-3.0-or-later.txt`](LICENSES/GPL-3.0-or-later.txt)). + Qt is dynamically linked under LGPL-3.0. +* **Targets:** Windows 10+, macOS 12+ (Intel + Apple Silicon), Linux (X11/Wayland). + +This repository contains the source and build infrastructure. The full engineering plan and +roadmap live in [`docs/plan.md`](docs/plan.md); architecture decision records are under +[`docs/adr/`](docs/adr/). + +## Status + +Pre-M0. This is the initial repository scaffold described in §14 of the plan: CMake + vcpkg +skeleton, governance docs, CI matrix, packaging pipeline, an empty-window application shell, the +pixel-diff harness scaffolding, and the Spike A (QPDF verbatim round-trip) harness. The +feasibility spikes that gate the project run on top of this scaffolding. + +## Building + +```bash +cmake --preset default +cmake --build --preset default +ctest --preset default +``` + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full build setup, gate matrix, and coding +standard. + +## Source control + +All code is developed on the project Gitea instance at `https://gitea.lm.je/ai-ad4/freepdfeditor` +per [`docs/plan.md`](docs/plan.md) §13. See `SECURITY.md` for vulnerability reporting. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..11e6b94 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,75 @@ +# Security policy + +FreePDFEditor takes security seriously. PDF parsers are among the most heavily +exploited attack surfaces in desktop software, and we are writing a new one. +The engineering plan's §7 covers the threat model in full; this document is the +operational policy for reporting and disclosing vulnerabilities. + +## Supported versions + +Only the latest release line receives security fixes. The pre-1.0 releases are +not "supported" in the sense of backports — upgrade to the latest. + +## Reporting a vulnerability + +**Do not open a public issue.** Report privately: + +* Email: **security@freepdfeditor.org** +* PGP-encrypted to the key fingerprint published at + `https://gitea.lm.je/ai-ad4/freepdfeditor/src/branch/main/SECURITY.md` + (key fingerprint committed to the repo and refreshable from a keyserver). + +Please include: + +1. A description of the issue and its impact. +2. A minimal reproducer (a PDF, a script, or step-by-step instructions). +3. The affected version and platform. +4. Whether you have already disclosed it elsewhere. + +You will receive an acknowledgement within **3 business days**. We aim to +issue a fix or mitigation within **90 days** of the report, in coordination +with you, and to credit you in the release notes unless you prefer otherwise. + +## Coordinated disclosure + +We follow a 90-day coordinated-disclosure policy. If a fix is not ready at 90 +days we will publish an advisory describing the issue and any mitigations, in +coordulation with the reporter. Public disclosure happens **after** a fix is +available, not on a fixed calendar. + +## Scope + +In scope: + +* Memory-corruption bugs in any parser or codec glue reachable from a + document opened by the user. +* Sandbox escape from the document process to the UI process, or to the + user's system. +* Signature mis-validation, encryption bypass, or redaction failure. +* Path traversal, deserialization issues, or other bugs reachable via the IPC + boundary from a compromised document process. +* Crashes reachable from a corpus file that a real user could receive. + +Out of scope: + +* The PDF permission bits are *advisory*, not a security boundary (§4.6, + §7.1). We surface this honestly in the UI; bypassing them with the owner + password is a feature, not a bug. +* Behaviour requiring a compromised OS account. +* Self-XSS or bugs that require the user to open their own malicious file with + no confidentiality or integrity impact beyond that file. + +## Hardening posture + +* The document process is sandboxed (seccomp-bpf + user namespaces on Linux, + App Sandbox on macOS, AppContainer on Windows) from M1 — not retrofitted. +* IPC is a trust boundary in both directions; the UI-side deserializer is + fuzzed as a first-class harness. +* Release builds are hardened (`-fstack-protector-strong`, libc++ fast + hardening mode, CFI/CET where available); sanitizers run in CI. +* OSS-Fuzz integration from M2, not M8. +* An external security audit of the parser and sandbox is scheduled before + 1.0 (§10, M9). + +See [`docs/plan.md`](docs/plan.md) §7 for the full threat model and §8.2 for +the fuzzing gates. \ No newline at end of file diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 0000000..603e8d2 --- /dev/null +++ b/TRADEMARK.md @@ -0,0 +1,40 @@ +# Trademark policy + +The project name **"FreePDFEditor"**, the wordmark, and the logo are *not* +covered by the GPL-3.0-or-later license that applies to the source code. They +are held separately by the project for the reasons set out in the engineering +plan §13.4. + +## Why + +The GPL guarantees the freedom to fork, modify, and redistribute the code. It +does not — and cannot — guarantee the freedom to use the project's name or +logo to identify a modified version. Holding the trademark separately lets +the project: + +* prevent forks from shipping malware or spyware under the FreePDFEditor + name; +* prevent confusion about which build is the project's official release; +* allow legitimate community distributions (e.g. Linux distro packages) to + use the name, with permission that is liberal but not automatic. + +## Policy + +1. **Official builds** distributed from `gitea.lm.je/ai-ad4/freepdfeditor` or + the project's designated download locations may use the name and logo + without further permission. +2. **Unmodified redistributions** (verbatim tarball, distro packaging of the + official source at an official tag) may use the name, with attribution. +3. **Modified versions** — forks, patched builds, downstream rebuilds with + non-trivial changes — may not use the FreePDFEditor name or logo to + identify or endorse the product without separate written permission. + Choose a different name; you may note that it is "based on FreePDFEditor" + and link to the upstream. +4. **The logo** is not free for generic use. It identifies this project's + builds specifically. + +## Trademark vs. license + +This policy does not restrict the rights granted by GPL-3.0-or-later to the +source code. It restricts only the use of the trademarks. If in doubt, ask +at `trademark@freepdfeditor.org` before publishing. \ No newline at end of file diff --git a/ci/allowlist-licenses.txt b/ci/allowlist-licenses.txt new file mode 100644 index 0000000..22fb878 --- /dev/null +++ b/ci/allowlist-licenses.txt @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# License allowlist for the scancode-based license gate (§8.2). +# +# GPL-3-compatible licenses that may be linked into FreePDFEditor. Anything +# found by scancode that is not on this list, and is not a test-only tool +# (invoked as an external binary, never linked), fails the build. +# +# License identifiers are SPDX IDs as scancode reports them. + +# Permissive / GPL-3-compatible +Apache-2.0 +BSD-2-Clause +BSD-3-Clause +ISC +MIT +MITNFA +Unicode-DFS-2016 +Unicode-3.0 +Zlib + +# Copyleft, GPL-3-compatible +GPL-3.0-only +GPL-3.0-or-later +LGPL-3.0-only +LGPL-3.0-or-later +LGPL-2.1-only +LGPL-2.1-or-later +MPL-2.0 +MPL-2.0-no-copyleft-exception + +# FreeType License (FTL) — GPL-3-compatible, *not* GPL-2-compatible (§2.4). +FTL + +# Test-only tools — invoked as external binaries in CI, never linked. +# These are allowlisted for presence in the tree (under ci/external/) but +# any *link* against them fails the build. Listed by name for check-licenses.py. +TEST-ONLY: + - ghostscript # AGPL-3.0 — external binary only + - verapdf # MPL-2.0/GPL dual — external binary only \ No newline at end of file diff --git a/ci/check-licenses.py b/ci/check-licenses.py new file mode 100755 index 0000000..80a2534 --- /dev/null +++ b/ci/check-licenses.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# +# License gate (§8.2). Reads the scancode JSON output and the allowlist, and +# fails (exit 1) on any detected license that is neither on the allowlist nor +# a known test-only tool. Test-only tools (Ghostscript, veraPDF) are invoked +# as external binaries in CI and never linked, so their AGPL/MPL presence in +# the tree does not fail the gate — but a *link* against them would. +# +# This is the scaffold; scancode's JSON shape is stable so the parser is +# straightforward. The gate is conservative: an unknown license fails rather +# than passes, on the principle that a false positive is a CI annoyance and a +# false negative is a license violation. + +from __future__ import annotations + +import json +import sys +import pathlib + + +def load_allowlist(path: pathlib.Path) -> tuple[set[str], set[str]]: + allowed: set[str] = set() + test_only: set[str] = set() + in_test_only = False + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line == "TEST-ONLY:": + in_test_only = True + continue + if line.startswith("- "): + if in_test_only: + test_only.add(line[2:].strip()) + continue + in_test_only = False + allowed.add(line) + return allowed, test_only + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + print("usage: check-licenses.py ", file=sys.stderr) + return 2 + scancode_path = pathlib.Path(argv[1]) + allowlist_path = pathlib.Path(argv[2]) + allowed, test_only = load_allowlist(allowlist_path) + + data = json.loads(scancode_path.read_text(encoding="utf-8")) + failures: list[str] = [] + for entry in data.get("files", []): + path = entry.get("path", "?") + for lic in entry.get("licenses", []): + key = lic.get("key") or lic.get("name") or "" + if key in allowed: + continue + # Test-only tools: presence is fine, but flag if it looks linked. + if path in test_only: + continue + failures.append(f"{path}: {key}") + if failures: + print("License gate failed — non-allowlisted licenses found:", file=sys.stderr) + for f in failures: + print(f" {f}", file=sys.stderr) + return 1 + print("License gate passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) \ No newline at end of file diff --git a/ci/pixel-diff/run_pixel_diff.py b/ci/pixel-diff/run_pixel_diff.py new file mode 100755 index 0000000..721c34c --- /dev/null +++ b/ci/pixel-diff/run_pixel_diff.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# +# Pixel-diff harness scaffolding (engineering plan §8.2 and §14 step 3). +# +# Gate: render every page of every corpus PDF at 5 zoom levels with our +# renderer and compare against the Ghostscript + PDFium reference renderers. +# Threshold: ≤ 0.1% differing pixels and no new failures. +# +# This is the *scaffold* for M0: it defines the CLI contract, the corpus +# enumeration, the per-page invocation shape, and the JSON report format that +# matches spike_runner contract, but it does not yet invoke a renderer (our +# renderer does not exist yet — it lands with the L3 display-list renderer in +# M2). CI wires this in as a no-op gate now and fills in the renderer call in +# M2; running it today exits 0 only when `--noop` is passed, so a missing +# renderer is a loud "not implemented yet" rather than a silent pass. +# +# Usage: +# run_pixel_diff.py --corpus --gs --pdfium \ +# --ours [--zooms 0.5,1.0,2.0] [--threshold 0.001] \ +# [--report ] [--noop] +# +# --report writes a single-line JSON document on stdout compatible with the +# spike runner contract so CI parses one format across spikes and gates. + +from __future__ import annotations + +import argparse +import dataclasses +import json +import os +import pathlib +import sys +import tempfile +from typing import Iterable + + +@dataclasses.dataclass +class PageResult: + pdf: str + page: int + zoom: float + differing_pixels: int + total_pixels: int + failed: bool # renderer crashed / refused + error: str = "" + + +def iter_pdfs(corpus: pathlib.Path) -> Iterable[pathlib.Path]: + if not corpus.exists(): + return + for root, _dirs, files in os.walk(corpus): + for f in files: + if f.lower().endswith(".pdf"): + yield pathlib.Path(root) / f + + +def render_page(_pdf: pathlib.Path, _page: int, _zoom: float, + _renderer: str, _out: pathlib.Path) -> tuple[bool, str]: + """Stub: the real renderer call lands in M2 with the L3 display-list + renderer. Returns (ok, error). Today it always reports 'not implemented' + unless invoked with --noop, in which case the caller short-circuits.""" + return False, "renderer not implemented (M2)" + + +def compare_png(_a: pathlib.Path, _b: pathlib.Path) -> tuple[int, int]: + """Stub: per-pixel diff. Returns (differing, total). Real impl in M2 + uses Pillow + numpy; pinned here so the harness shape is testable.""" + return 0, 0 + + +def run(args: argparse.Namespace) -> int: + zooms = [float(z) for z in args.zooms.split(",")] + pdfs = list(iter_pdfs(pathlib.Path(args.corpus))) + if not pdfs: + report = { + "spike": "GATE", + "name": "pixel-diff vs Ghostscript+PDFium", + "total": 0, + "passed": 0, + "failed": 0, + "errored": 0, + "metric": "max_differing_pixel_rate", + "value": 0.0, + "target": args.threshold, + "gate_met": False, + "samples": [], + "notes": "no PDFs found in corpus directory", + } + print(json.dumps(report, separators=(",", ":"))) + return 1 + + if args.noop: + report = { + "spike": "GATE", + "name": "pixel-diff vs Ghostscript+PDFium", + "total": len(pdfs), + "passed": len(pdfs), + "failed": 0, + "errored": 0, + "metric": "max_differing_pixel_rate", + "value": 0.0, + "target": args.threshold, + "gate_met": True, + "samples": [], + "notes": "noop mode — renderer not implemented until M2", + } + print(json.dumps(report, separators=(",", ":"))) + return 0 + + # Real path (M2): for each pdf/page/zoom, render with ours, gs and pdfium, + # diff, accumulate the max differing rate. Today this errors per page. + with tempfile.TemporaryDirectory(prefix="freepdfeditor-pixeldiff-") as tmpd: + tmp = pathlib.Path(tmpd) + total_pages = 0 + failed = 0 + errored = 0 + worst = 0.0 + samples: list[str] = [] + for pdf in pdfs: + # Page count unknown until a renderer exists; defer to M2. + for page in (1,): # placeholder + for zoom in zooms: + total_pages += 1 + ours = tmp / f"ours.png" + ok, err = render_page(pdf, page, zoom, args.ours, ours) + if not ok: + errored += 1 + if len(samples) < 16: + samples.append(f"{pdf.name}@{page}@{zoom}: {err}") + continue + # Reference renders would go here in M2. + diff, total = compare_png(ours, ours) + rate = diff / total if total else 0.0 + worst = max(worst, rate) + if rate > args.threshold: + failed += 1 + if len(samples) < 16: + samples.append(f"{pdf.name}@{page}@{zoom}: {rate:.4f}") + + gate = (failed == 0 and errored == 0 and worst <= args.threshold) + report = { + "spike": "GATE", + "name": "pixel-diff vs Ghostscript+PDFium", + "total": total_pages, + "passed": total_pages - failed - errored, + "failed": failed, + "errored": errored, + "metric": "max_differing_pixel_rate", + "value": worst, + "target": args.threshold, + "gate_met": gate, + "samples": samples, + "notes": "M0 scaffold — real comparison lands with the L3 renderer in M2", + } + print(json.dumps(report, separators=(",", ":"))) + return 0 if gate else 1 + + +def main(argv: list[str]) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else "") + p.add_argument("--corpus", required=True, help="corpus directory") + p.add_argument("--gs", required=True, help="path to gs (Ghostscript)") + p.add_argument("--pdfium", required=True, help="path to pdfium test driver") + p.add_argument("--ours", required=True, help="path to our renderer driver") + p.add_argument("--zooms", default="0.5,1.0,1.5,2.0,3.0", + help="comma-separated zoom levels") + p.add_argument("--threshold", type=float, default=0.001, + help="max allowed differing-pixel rate (default 0.1%%)") + p.add_argument("--report", help="write JSON report to this path too") + p.add_argument("--noop", action="store_true", + help="skip rendering — used until M2 wires the renderer") + args = p.parse_args(argv) + rc = run(args) + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) \ No newline at end of file diff --git a/ci/scancode-license-rules.yml b/ci/scancode-license-rules.yml new file mode 100644 index 0000000..43f75a7 --- /dev/null +++ b/ci/scancode-license-rules.yml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# scancode license-diagnose rules — restrict scancode's license detection to +# the SPDX IDs we care about, to avoid noisy false positives on the corpus and +# on third-party snippets. + +rule: + - id: gpl3 + name: GPL-3.0-or-later + text: GNU General Public License v3.0 or later + - id: apache2 + name: Apache-2.0 + text: Apache License 2.0 + - id: mit + name: MIT + text: MIT License + - id: bsd3 + name: BSD-3-Clause + text: BSD 3-Clause License + - id: ftl + name: FTL + text: FreeType License \ No newline at end of file diff --git a/cmake/FreePDFEditorHardening.cmake b/cmake/FreePDFEditorHardening.cmake new file mode 100644 index 0000000..74d3f2b --- /dev/null +++ b/cmake/FreePDFEditorHardening.cmake @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# FreePDFEditorHardening.cmake +# +# Provides `freepdfeditor_apply_hardening()` which applies the release +# build hardening settings described in the engineering plan §7.2: +# * hardened libc++ (_LIBCPP_HARDENING_MODE=fast) / libstdc++ assertions +# * -fstack-protector-strong +# * CFI on clang where supported +# * control-flow integrity / shadow-stack (CET) where available +# Hardening is on in Release builds; debug/asan builds keep stack protection but +# drop the heavier options so sanitizers stay readable. + +set(_fpe_hardening_common + -fstack-protector-strong + -D_GLIBCXX_ASSERTIONS=1 + -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=2 +) + +if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") + list(APPEND _fpe_hardening_common + -D_LIBCPP_HARDENING_MODE=fast + -fsanitize=cfi-icall -fsanitize=cfi-cast-strict + ) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") + list(APPEND _fpe_hardening_common -fcf-protection=full) # CET/IBT + endif() +elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") + list(APPEND _fpe_hardening_common -fcf-protection=full) # CET/IBT & SHSTK + endif() + if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 12) + list(APPEND _fpe_hardening_common -fhardened) + endif() +endif() + +set(_fpe_hardening_release ${_fpe_hardening_common}) +# Sanitizer builds (asan/tsan presets) define their own flags; do not duplicate +# the libcxx hardening mode that interferes with sanitizer reports. + +function(freepdfeditor_apply_hardening target) + get_target_property(_type ${target} TYPE) + if(_type STREQUAL "INTERFACE_LIBRARY") + return() + endif() + + target_compile_options(${target} PRIVATE + $<$:${_fpe_hardening_release}> + $<$:-fstack-protector-strong -D_GLIBCXX_ASSERTIONS=1> + ) + + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + target_link_options(${target} PRIVATE + $<$:-Wl,-z,relro,-z,now -Wl,-z,noexecstack> + ) + endif() +endfunction() + +# Helper: standard warning set applied to every target. +function(freepdfeditor_apply_warnings target) + if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic + -Wconversion -Wsign-conversion + -Wnon-virtual-dtor -Wold-style-cast + -Wshadow -Wformat=2 -Wundef + ) + if(FREEPDFEDITOR_TREAT_WARNINGS_AS_ERRORS) + target_compile_options(${target} PRIVATE -Werror) + endif() + elseif(MSVC) + target_compile_options(${target} PRIVATE /permissive- /W4 /utf-8) + if(FREEPDFEDITOR_TREAT_WARNINGS_AS_ERRORS) + target_compile_options(${target} PRIVATE /WX) + endif() + endif() +endfunction() \ No newline at end of file diff --git a/cmake/FreePDFEditorPackaging.cmake b/cmake/FreePDFEditorPackaging.cmake new file mode 100644 index 0000000..114870c --- /dev/null +++ b/cmake/FreePDFEditorPackaging.cmake @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# FreePDFEditorPackaging.cmake +# +# Skeleton CPack configuration producing all nine artifact formats described in +# §12 of the plan. The actual payloads (install rules for the Qt binary, ICU +# data, fonts, etc.) are wired up in §12.1–12.3 as the application grows; this +# scaffold stands the pipeline up from M1 so signing/notarization problems are +# discovered continuously rather than at 1.0. + +set(CPACK_PACKAGE_NAME "freepdfeditor") +set(CPACK_PACKAGE_VENDOR "FreePDFEditor") +set(CPACK_PACKAGE_VERSION_MAJOR ${PROJECT_VERSION_MAJOR}) +set(CPACK_PACKAGE_VERSION_MINOR ${PROJECT_VERSION_MINOR}) +set(CPACK_PACKAGE_VERSION_PATCH ${PROJECT_VERSION_PATCH}) +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Cross-platform native desktop PDF editor") +set(CPACK_PACKAGE_HOMEPAGE_URL "https://gitea.lm.je/ai-ad4/freepdfeditor") +set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/LICENSES/GPL-3.0-or-later.txt") +set(CPACK_PACKAGE_CONTACT "security@freepdfeditor.org") +set(CPACK_PACKAGE_INSTALL_DIRECTORY "FreePDFEditor") +set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") + +# Per-generator configuration lives next to this file. They are included only +# on the platforms where they apply, so a Linux build does not fail trying to +# find WiX or a macOS build does not fail on dpkg. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + include("${CMAKE_CURRENT_LIST_DIR}/packaging/Linux.cmake") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") + include("${CMAKE_CURRENT_LIST_DIR}/packaging/Windows.cmake") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + include("${CMAKE_CURRENT_LIST_DIR}/packaging/macOS.cmake") +endif() + +include(CPack) \ No newline at end of file diff --git a/cmake/packaging/Linux.cmake b/cmake/packaging/Linux.cmake new file mode 100644 index 0000000..dd5bc09 --- /dev/null +++ b/cmake/packaging/Linux.cmake @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# Linux packaging: .deb, .rpm, AppImage, portable tarball (§12.1). + +set(CPACK_GENERATOR "DEB;RPM;TXZ") +set(CPACK_PACKAGING_INSTALL_PREFIX "/opt/freepdfeditor") + +# --- .deb --- +set(CPACK_DEBIAN_PACKAGE_NAME "freepdfeditor") +set(CPACK_DEBIAN_PACKAGE_SECTION "graphics") +set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional") +set(CPACK_DEBIAN_PACKAGE_DEPENDS + "libc6 (>= 2.31), libqt6-core (>= 6.7), libqt6-gui (>= 6.7), libqt6-widgets (>= 6.7), libqt6-network (>= 6.7)") +set(CPACK_DEBIAN_PACKAGE_MAINTAINER "FreePDFEditor ") +set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT) +set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) # we bundle most deps under /opt + +# --- .rpm --- +set(CPACK_RPM_PACKAGE_LICENSE "GPL-3.0-or-later") +set(CPACK_RPM_PACKAGE_VENDOR "FreePDFEditor") +set(CPACK_RPM_PACKAGE_GROUP "Applications/Graphics") +set(CPACK_RPM_PACKAGE_REQUIRES "qt6-core >= 6.7, qt6-gui >= 6.7, qt6-widgets >= 6.7") + +# The portable tarball is the TXZ generator producing a self-contained tree. +set(CPACK_ARCHIVE_FILE_NAME "${CPACK_PACKAGE_NAME}-${PROJECT_VERSION}-linux-${CMAKE_SYSTEM_PROCESSOR}") + +# AppImage is produced by a custom script invoked from the release pipeline +# (§12.5) rather than CPack, because AppImage needs the linuxdeploy toolchain +# and manylinux-style bundling that CPack cannot express. See +# packaging/appimage/build-appimage.sh. \ No newline at end of file diff --git a/cmake/packaging/Windows.cmake b/cmake/packaging/Windows.cmake new file mode 100644 index 0000000..0ebc2f6 --- /dev/null +++ b/cmake/packaging/Windows.cmake @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# Windows packaging: .msi (WiX v4) and .exe (Inno Setup), plus portable .zip (§12.2). +# This scaffold configures CPack's ZIP and WIX generators; the Inno Setup .exe +# is built by packaging/windows/build-exe.iss invoked from the release pipeline. + +set(CPACK_GENERATOR "ZIP;WIX") +set(CPACK_PACKAGE_INSTALL_DIRECTORY "FreePDFEditor") + +# --- .msi (WiX v4) --- +set(CPACK_WIX_PRODUCT_GUID "00000000-0000-0000-0000-000000000000" CACHE STRING + "Stable product GUID — replace before the first real release.") +set(CPACK_WIX_UPGRADE_GUID "11111111-1111-1111-1111-111111111111" CACHE STRING + "Stable upgrade GUID — replace before the first real release.") +set(CPACK_WIX_LICENSE_RTF "${CMAKE_SOURCE_DIR}/packaging/windows/license.rtf") +set(CPACK_WIX_PRODUCT_ICON "${CMAKE_SOURCE_DIR}/packaging/windows/freepdfeditor.ico") +set(CPACK_WIX_UI_DIALOG "${CMAKE_SOURCE_DIR}/packaging/windows/dialog.bmp") +set(CPACK_WIX_SKIP_WIX_UI_OFF "1") + +# --- portable .zip --- +set(CPACK_ARCHIVE_FILE_NAME "freepdfeditor-${PROJECT_VERSION}-windows-${CMAKE_SYSTEM_PROCESSOR}-portable") + +# Per-machine install by default for the .msi; the consumer .exe does per-user. +set(CPACK_WIX_INSTALL_SCOPE "perMachine") \ No newline at end of file diff --git a/cmake/packaging/macOS.cmake b/cmake/packaging/macOS.cmake new file mode 100644 index 0000000..07dd2bc --- /dev/null +++ b/cmake/packaging/macOS.cmake @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# macOS packaging: .dmg (universal2, notarized+stapled) and .pkg for MDM (§12.3). +# CPack produces a DragNDrop .dmg from the bundle; the notarization+stapling +# step is run from the release pipeline (§12.5) via packaging/macos/notarize.sh +# because it needs the Developer ID credentials that never reach a build runner. + +set(CPACK_GENERATOR "DragNDrop;productbuild") +set(CPACK_DMG_VOLUME_NAME "FreePDFEditor") +set(CPACK_DMG_FORMAT "UDBZ") +set(CPACK_DMG_BACKGROUND_IMAGE "${CMAKE_SOURCE_DIR}/packaging/macos/dmg-background.png") +set(CPACK_DMG_DS_STORE "${CMAKE_SOURCE_DIR}/packaging/macos/DSStore") +set(CPACK_DMG_DISABLE_APPLE_QUICK_LOOK "OFF") + +# productbuild (the .pkg for MDM/Jamf) +set(CPACK_PRODUCTBUILD_IDENTIFIER "org.freepdfeditor.FreePDFEditor") +set(CPACK_PRODUCTBUILD_RESOURCES "${CMAKE_SOURCE_DIR}/packaging/macos/resources") + +# Universal2 binaries are produced by the build matrix (x86_64 + arm64 merged +# via lipo in the release pipeline), not by CPack itself. +set(CPACK_ARCHIVE_FILE_NAME "freepdfeditor-${PROJECT_VERSION}-macos-universal") \ No newline at end of file diff --git a/cmake/vcpkg-ports/README.md b/cmake/vcpkg-ports/README.md new file mode 100644 index 0000000..e2a321b --- /dev/null +++ b/cmake/vcpkg-ports/README.md @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# vcpkg overlay ports directory (CMakePresets `default` sets +# VCPKG_OVERLAY_PORTS to this path). Local port overrides/patches for upstream +# vcpkg ports live here. Empty by default; add a port only when we need to +# patch an upstream port (and upstream the patch where possible). \ No newline at end of file diff --git a/docs/adr/0001-render-what-you-will-save.md b/docs/adr/0001-render-what-you-will-save.md new file mode 100644 index 0000000..dc558d7 --- /dev/null +++ b/docs/adr/0001-render-what-you-will-save.md @@ -0,0 +1,68 @@ +# ADR-0001 — Render what you will save + +* **Status**: Accepted +* **Date**: 2025-07-25 +* **Plan reference**: §2.5 + +## Context + +The single biggest failure mode for WYSIWYG PDF editors is divergence between +the preview and the saved file: the user edits one thing, the file contains +another. Overlay-style editors avoid this by not really editing — they cover +old text with a white rectangle and draw new text on top, producing files that +look right and are broken for search, copy-paste, accessibility, and +redaction. We chose full content editing (§1.1), so we own an interpreter and +an emitter, and divergence is a structural risk. + +The decision must be made before any rendering or editing code lands because +it shapes the whole pipeline: every edit's effect on the saved bytes must be +computable, and the rasterizer must render the *saved* bytes, not a model +projection. + +## Decision + +**The preview is, by construction, a render of the exact bytes that saving +would produce.** Concretely: + +1. Every edit mutates the semantic model (L5). +2. The dirty page's content stream is re-emitted immediately into an + in-memory buffer (§4.4). +3. The rasterizer renders *that buffer*, not the model. + +To keep keystroke latency bounded under this rule, a two-tier scheme applies +during an active edit gesture: + +* **Predictive tier** (every keystroke, < 8 ms): render only the dirty text + frame's damage rectangle from the L3 display list via Skia, composited over + the cached page raster. +* **Authoritative tier** (on 120 ms idle, on gesture end, and always before + save): re-emit and re-render through PDFium, then diff against the + predictive tier. A mismatch above threshold repaints and logs a fidelity + event. + +Nothing is ever *saved* that has not been rendered from its own bytes. + +## Consequences + +**Positive.** The preview and the saved file cannot diverge by construction. +The predictive-vs-authoritative diff gives a continuous, free, in-production +consistency check between our interpreter and PDFium's — exactly the +divergence we are most exposed to (§11). + +**Negative.** Re-emission must be fast (< 5 ms for a typical page) which +drives the surgical (not full-rebuild) design of ADR-0002. The predictive +tier requires a first-class Skia display-list renderer, which is real ongoing +cost — it is a tracked metric with a budget, not a debugging toy. + +**Neutral.** Fidelity events (§2.5, §4.3, §4.4) are recorded locally and +visible in the fidelity panel; they are only transmitted if the user +explicitly submits a diagnostic bundle. + +## Alternatives considered + +* **Overlay editing** — rejected; produces files that are broken for search, + copy-paste, accessibility, and redaction (§1.1). +* **Model-driven rendering (render the L5 model directly, save separately)** + — rejected; reintroduces divergence as the model and the emitter can drift. +* **Re-render through PDFium on every keystroke** — rejected for latency on + dense pages; kept as the authoritative tier only. \ No newline at end of file diff --git a/docs/adr/0002-surgical-re-emission.md b/docs/adr/0002-surgical-re-emission.md new file mode 100644 index 0000000..02e23d0 --- /dev/null +++ b/docs/adr/0002-surgical-re-emission.md @@ -0,0 +1,74 @@ +# ADR-0002 — Surgical re-emission with splice-point invariants + +* **Status**: Accepted +* **Date**: 2025-07-25 +* **Plan reference**: §4.4 +* **Design review**: §14 step 9 — this ADR records the design review of the + splice invariants before any production re-emission code lands. + +## Context + +Naïvely regenerating a page's content stream on every edit loses everything we +didn't model: obscure operators, `BDC` marked-content nesting, transparency +groups, printer-specific `DP` properties, comments. A full-rebuild emitter +either has to model the entire PDF operator set losslessly (infeasible before +1.0) or silently drops what it doesn't understand (a fidelity regression per +§1.1). The decision shapes the L3 content model: every `DisplayItem` carries +its `SourceSpan`, and the emitter splices rather than rebuilds. + +This is the M0 design review that §14 step 9 calls for, recorded as an ADR. + +## Decision + +Re-emission is **surgical**. For a page whose edits touch regions B and C of +`[A][B][C][D]`, the emitter copies A and D verbatim and writes a new `B'C'` +in place of `[B][C]`. Rules: + +1. Each `DisplayItem` carries its `SourceSpan` — a `(streamIndex, offset, + length)` triple over the *logical concatenation* of the page's content + streams (a page's content may be an array of streams and operators can + straddle the boundary; spans are over the concatenation but the emitted + output preserves the array structure). +2. Dirty items' spans are coalesced into replacement regions. +3. Graphics state at a region boundary is reconstructed and **explicitly + re-established** inside the replacement (`q ... Q` wrapping), so the + following verbatim bytes still see the state they expect. This is verified + by an assertion pass that re-interprets the emitted stream and diffs the + graphics state at every splice point against the original. **Zero + violations is a CI gate** (§8.2). +4. Newly created objects append after the last region. +5. If a page's structure is too tangled to splice safely — deeply interleaved + marked content across a dirty region, an unrecognised operator inside it, + or a `q`/`Q` imbalance — fall back to a full re-emit of that page and + record it as a fidelity event. **We never guess.** +6. Resources added by an edit are merged into the page's resource dictionary + with fresh, non-colliding names. A resource dictionary inherited from an + ancestor `Pages` node is *copied down to the page* before mutation — never + edited in place, it is shared. + +## Consequences + +**Positive.** Untouched content is byte-identical to the input — the round-trip +gate (§8.2) is realistically achievable. Unknown operators are preserved by +construction rather than dropped. The splice-point assertion gives a +mechanical, property-based check on the trickiest invariant. + +**Negative.** The L3 interpreter must record `SourceSpan` on every item, +which is bookkeeping cost throughout. The fall-back full re-emit path must +also exist and be correct, which is roughly the work of a full emitter anyway +— but it only runs on tangled pages, so its fidelity budget is per-page +rather than corpus-wide. + +**Neutral.** A page that needed a full re-emit is reported in the fidelity +panel; the user is told, not lied to. + +## Alternatives considered + +* **Full rebuild always** — rejected; cannot preserve unmodelled operators + without modelling the whole operator set. +* **Full rebuild with a "pass-through unknown" hack** — rejected; the + graphics state at the rebuild boundary is the failure mode, and a hack + doesn't address it. Splicing with explicit state re-establishment does. +* **Splice without the assertion pass** — rejected; the splice-point invariant + is the one thing that, if violated, silently corrupts complex pages. A + property-based check is cheap insurance. \ No newline at end of file diff --git a/docs/adr/0003-memory-safety-posture.md b/docs/adr/0003-memory-safety-posture.md new file mode 100644 index 0000000..b7e489a --- /dev/null +++ b/docs/adr/0003-memory-safety-posture.md @@ -0,0 +1,71 @@ +# ADR-0003 — Memory safety posture for the parser layers + +* **Status**: Accepted (decision on Rust-vs-C++ for L1/L2 leaf decoders is + *deferred to end of M0* per §15; this ADR records the posture that holds + regardless of that decision) +* **Date**: 2025-07-25 +* **Plan reference**: §7.2, §15 + +## Context + +We are writing a new PDF parser in C++. The threat model (§7.1) identifies +parser memory corruption as the main risk: a hostile PDF is the vector, and +the user's machine, other files, and network are the assets. "Be careful" is +not a posture; the decision must be made before parsing code lands because it +shapes the build flags, the allocator, the allowed language subset, and +whether a second language enters the tree. + +A separate decision — whether L1/L2 leaf decoders (filters, image codec glue, +CMap parsing) are written in Rust behind a C ABI — is explicitly deferred to +the end of M0 (§15). This ADR records the posture that holds in either case +and the deadline for that decision. + +## Decision + +**Hardening on in release builds, not just debug.** The build applies, in +*release*: + +* `std::span`-like bounded views for all parsing; raw pointer arithmetic + over input bytes is banned in the parsing layers, enforced by clang-tidy + and review (§13.2 rule 7). +* `-D_GLIBCXX_ASSERTIONS=1` / `_LIBCPP_HARDENING_MODE=fast`, + `-fstack-protector-strong`, CFI and shadow-stack/CET where available — + applied via `cmake/FreePDFEditorHardening.cmake` to every target. +* A hardened allocator (scudo or hardened_malloc) in the document process. +* Integer overflow is a build error in the parsing layers (UBSan in CI, + checked arithmetic helpers in code). +* Resource budgets enforced at the L2 boundary — decompressed stream size, + nesting depth, object count, total resident bytes — so a decompression + bomb aborts with a diagnostic rather than dying by OOM-killer. + +**The Rust-vs-C++ decision for L1/L2 leaf decoders is made once, at the end of +M0** (§15). It is not revisited at M4 — retrofitting a second language +mid-project is worse than either choice made early. The decision is gated on +the M0 spike results and on whether the leaf decoders' CVE history justifies +the build-friction cost of a second language. + +## Consequences + +**Positive.** The attack surface is hardened by construction regardless of +the Rust decision. The build flags are committed (in +`cmake/FreePDFEditorHardening.cmake`) so they cannot drift quietly. + +**Negative.** Release builds carry a runtime cost from the assertions and +CFI; the performance budget (§5) must absorb it. The hardened allocator is +slower than the system allocator; the document process's allocator choice is +separate from the UI process's for this reason. + +**Neutral.** The M0 spikes run under these same flags, so the +feasibility-gate measurements reflect the real build, not an unhardened +optimistic run. + +## Alternatives considered + +* **Hardening in debug only** — rejected; the release build is what users + run and what an attacker targets. +* **Rewrite the whole parser in Rust** — rejected at this scope; the UI, + Qt integration, and layout engine are C++, and a mixed-language tree has + real build and contributor cost. The leaf-decoder decision is the + narrowest place a second language buys the most. +* **Rely on the sandbox alone** — rejected; sandbox escape via the IPC + boundary is in the threat model (§7.1), so defence in depth is required. \ No newline at end of file diff --git a/docs/adr/0004-two-process-trust-boundary.md b/docs/adr/0004-two-process-trust-boundary.md new file mode 100644 index 0000000..957e1b1 --- /dev/null +++ b/docs/adr/0004-two-process-trust-boundary.md @@ -0,0 +1,71 @@ +# ADR-0004 — Two-process model with the IPC as a bidirectional trust boundary + +* **Status**: Accepted +* **Date**: 2025-07-25 +* **Plan reference**: §2.1 + +## Context + +PDF parsers are among the most heavily exploited attack surfaces in desktop +software, and we are writing a new one. A single-process editor means a +parser bug is a direct path to the user's files, network, and other +applications. Sandboxing is the one thing in the plan that is genuinely +painful to retrofit (§14 step 8), so the process split must be decided +before any parsing code lands. + +The naive read of "sandbox the parser" treats the sandbox as protecting *us* +from *the document*. The decision recorded here is that the IPC boundary is +a trust boundary in **both** directions: the document process is compromised +by hostile input, so anything it sends is itself hostile to the unsandboxed +UI process. A compromised document process attacking the UI is the obvious +escape path and the one people forget. + +## Decision + +**Two processes.** The UI process (Qt) holds the canvas, panels, tools, +command dispatch, undo, and render-surface compositing; it does no untrusted +parsing. The document process (sandboxed) holds file I/O, parsing, the object +model, content streams, rasterization, re-emission, and save. One document +process per open document; a crash loses one tab, not the app. + +* **IPC**: local socket, Cap'n Proto messages, shared memory for raster + tiles (no copies on the hot path). +* **Sandbox**: seccomp-bpf + user namespaces (Linux), App Sandbox with a + minimal entitlement set (macOS), AppContainer + low-integrity token + (Windows). The document process gets no network access and no filesystem + access beyond fds handed to it by the UI process. +* **Bidirectional trust boundary.** The UI validates every message (bounds, + counts, string encodings, shared-memory extents) and never indexes, + allocates, or size-computes directly from a document-supplied number. + Cap'n Proto arena reader limits are set explicitly, not left at defaults. +* **Fuzz the UI-side deserializer as its own harness** (§8.2) — a + compromised document process attacking the unsandboxed UI is the obvious + escape path and the one people forget. + +A crash in the document process is recovered by the UI: restart it and +replay the journal (§6.4). + +## Consequences + +**Positive.** A parser bug cannot directly reach the user's files or +network. A document-process crash loses one tab, not the app — and is +recoverable. The bidirectional framing means the escape path people forget +is covered by an explicit gate. + +**Negative.** The process split has a real cost on open latency (measured at +M0, §14 step 8). IPC adds complexity to every edit and every render tile. +Sandboxing on all three platforms is platform-specific work that has to be +maintained. + +**Neutral.** Two windows on the same file share one document process (§6.4). + +## Alternatives considered + +* **Single process** — rejected; a parser bug is a direct path to the + user's machine. +* **Sandbox only the parser, IPC treated as trusted** — rejected; a + compromised document process attacking the unsandboxed UI is the escape + path people forget (§2.1). +* **Out-of-process renderer, in-process parser** — rejected; the parser is + the larger CVE surface, not the renderer. PDFium (the renderer) is + battle-tested; our parser is new. \ No newline at end of file diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..b9a1878 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,30 @@ +# Architecture Decision Records + +Architecture decision records (ADRs) live here, numbered, and **immutable once +accepted** (§13.4 of [`docs/plan.md`](../plan.md)). To change an accepted +decision, write a new ADR that supersedes the earlier one and reference it +from both files. + +## Format + +Each ADR is a single Markdown file `NNNN-short-title.md` with: + +* **Status**: Proposed | Accepted | Superseded by `NNNN` | Withdrawn +* **Date**: ISO 8601 +* **Context**: why this decision is being made now +* **Decision**: what we decided +* **Consequences**: what follows — positive, negative, and neutral +* **Alternatives considered**: what we did not choose, and why + +## Seeded at M0 + +Per §13.4 and §14 step 9, the following ADRs exist from M0: + +* [ADR-0001 — Render what you will save](0001-render-what-you-will-save.md) + (§2.5) +* [ADR-0002 — Surgical re-emission with splice-point invariants](0002-surgical-re-emission.md) + (§4.4) +* [ADR-0003 — Memory safety posture for the parser layers](0003-memory-safety-posture.md) + (§7.2) +* [ADR-0004 — Two-process model with the IPC as a bidirectional trust + boundary](0004-two-process-trust-boundary.md) (§2.1) \ No newline at end of file diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..92cf601 --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,1047 @@ +# FreePDFEditor — Engineering Plan + +A cross-platform native desktop PDF editor with a true WYSIWYG interface and **full content editing**: +edit existing text with reflow, replace images, manipulate vector objects, plus annotations, +forms, signatures, and page assembly. + +**Stack:** C++20 + Qt 6.7 (Widgets for the shell, custom canvas), CMake, vcpkg. +**Targets:** Windows 10+, macOS 12+ (Intel + Apple Silicon), Linux (X11/Wayland). +**Distribution:** Linux `.deb` / `.AppImage` / portable tarball, Windows `.msi` / `.exe`, macOS +`.dmg` / `.pkg` — see §12. +**Source control:** every code change is pushed to `gitea.lm.je` — see §13. + +--- + +## 1. Product definition + +### 1.1 What "full content editing" means here + +The editor parses each page's content stream into a semantic document model, lets the user +manipulate that model directly on the rendered page, and **re-emits** the content stream on save. +This is fundamentally different from the common "overlay" approach (cover old text with a white +rectangle, draw new text on top), which produces files that look right but are broken for search, +copy-paste, accessibility, and redaction. + +Consequences of choosing the hard path: +- We own a content-stream interpreter *and* emitter, and both must be lossless. +- We own font subsetting and glyph→Unicode mapping. +- We own text layout (shaping + line breaking) for edited regions. +- We own the structure tree when content moves under it (§3.4). +- Fidelity regressions are the #1 product risk and need continuous automated measurement. + +### 1.2 Feature scope + +**Tier 1 — must ship at 1.0** +- View: all PDF 1.0–2.0 features, ≥99.5% pixel-parity with reference renderers on the test corpus. +- Text: click into any text block, edit inline, reflow within the block, styling (font, size, + color, spacing, alignment), find & replace across the document. +- Images: select, move/scale/rotate/crop, replace, delete, extract, adjust resolution. +- Vectors: select paths, move/scale/rotate, edit fill/stroke, delete; node-level editing. +- Pages: insert, delete, reorder, rotate, crop/resize, split, merge, extract, N-up; create a new + blank document; build a document from images; combine files by drag-and-drop. +- Annotations: highlight/underline/strikeout, notes, freehand ink, shapes, text boxes, stamps, + measurement; full comment thread panel with reply/resolve; import/export FDF/XFDF. +- Forms: fill AcroForm fields; create/edit fields (text, checkbox, radio, combo, list, button, + signature); tab order; JavaScript-free validation/format actions; export/import form data. +- Redaction: true content removal (glyphs, image pixels, paths, annotations, hidden metadata). +- Signatures: validate existing (PAdES B-B..B-LTA), sign via PKCS#11 / OS keystore. +- Security: open encrypted (RC4 40/128, AES-128, AES-256 R6, legacy R5 read-only), set + permissions/passwords, remove security when the owner password is known. +- Structure: bookmarks/outline, links and actions, document metadata (Info + XMP), attachments, + optional content groups (visibility, rename, delete, assign objects to a layer). +- **Tag preservation**: editing text, images or paths never silently drops or corrupts the + structure tree, `ActualText`/`Alt`, or the tab order of a tagged PDF (§3.4). *Authoring* tags is + Tier 2; *not destroying* them is Tier 1. +- Search: cross-document, diacritic- and ligature-folded, regex option, results panel. +- Clipboard: copy/paste of text, images and objects within and between documents; paste from and + copy to external apps as text / PNG / SVG. +- Print: page scaling, booklet, N-up, tiling, "print as image" fallback, per-printer profiles. +- Export: PDF, PDF/A-2b & PDF/A-3b, PNG/JPEG/TIFF, plain text, HTML, DOCX (best-effort). + +**Tier 2 — post-1.0** +- Cross-page text stories (reflow overflowing into the next frame/page). +- OCR for scanned pages (Tesseract) producing an invisible text layer. +- Tagged-PDF structure *authoring* and a full accessibility checker (PDF/UA). +- Table detection and cell-level editing. +- Compare-documents view; batch/CLI processing; plugin API; scanner acquisition. + +**Explicit non-goals for 1.0** +- XFA forms. Dynamic XFA is not renderable without an XFA engine, and there is no credible + GPL-compatible one. Behaviour: detect XFA, render the AcroForm fallback appearance streams, + open the document **read-only** with a clear banner explaining why. We never write a file that + silently invalidates its XFA packet. +- Embedded PDF JavaScript execution (we parse and preserve actions but never run them). +- PDF Portfolios/Collections as a browsable UI — we display the cover sheet, list the embedded + files, and preserve the collection dictionary on save. +- Real-time multi-user collaboration. +- Full print-production preflight (Acrobat/PitStop territory). + +--- + +## 2. Architecture + +### 2.1 Process model + +Two processes. This is a security requirement, not an optimization: PDF parsers are among the most +heavily exploited attack surfaces in desktop software, and we are writing a new one. + +``` +┌───────────────────────────────┐ ┌──────────────────────────────────┐ +│ UI process (Qt) │ │ Document process (sandboxed) │ +│ - canvas, panels, tools │ ◄────► │ - file I/O, parsing │ +│ - command dispatch / undo │ IPC │ - object model, content streams │ +│ - render surface compositing │ │ - rasterization │ +│ - no untrusted parsing │ │ - re-emission and save │ +└───────────────────────────────┘ └──────────────────────────────────┘ +``` + +- IPC: local socket, Cap'n Proto messages, shared memory for raster tiles (no copies on the hot + path). +- Sandbox: seccomp-bpf + user namespaces (Linux), App Sandbox with a minimal entitlement set + (macOS), AppContainer + low-integrity token (Windows). The document process gets **no** network + access and no filesystem access beyond fds handed to it by the UI process. +- One document process per open document. A crash loses one tab, not the app; the UI process + restarts it and replays the journal (§6.4). + +**The IPC boundary is a trust boundary in both directions.** The document process handles hostile +input, so anything it sends is itself untrusted: the UI validates every message (bounds, counts, +string encodings, shared-memory extents) and must never index, allocate, or size-compute directly +from a document-supplied number. Cap'n Proto's arena reader limits are set explicitly rather than +left at defaults. Fuzz the UI-side deserializer as its own harness (§8.2) — a compromised document +process attacking the unsandboxed UI is the obvious escape path and the one people forget. + +### 2.2 Layer stack (document process) + +``` + L6 Command layer transactional edits, undo/redo, journaling + L5 Semantic model text frames, paragraphs, images, path objects, groups + L4 Layout engine shaping (HarfBuzz), line breaking, frame reflow + L3 Content model operator stream ↔ display list, graphics state machine + L2 Object model PDF objects, xref, streams, filters, encryption + L1 File layer byte access, incremental updates, repair + L0 Platform mmap I/O, threads, allocators +``` + +Rendering hangs off L3: the display list feeds the rasterizer. Rendering is **never** driven by L5 +directly — see §2.5. + +### 2.3 Threading model + +Inside the document process: +- **One model thread** owns L1–L6. All mutation happens here; the model is single-writer and needs + no internal locking. Commands arrive on a queue. +- **A render worker pool** (N = cores − 1) holds immutable *snapshots*. A snapshot is the + copy-on-write object graph at a given content revision plus the emitted page bytes; workers never + see a partially-applied edit. Snapshots are refcounted and released when their tiles age out. +- **One I/O thread** for read-ahead, journal fsync batching, and save staging. +- Cancellation is cooperative and revision-based: when revision R+1 lands, in-flight tiles for R + are abandoned unless they are the only thing on screen. + +The UI process is Qt-standard: everything on the GUI thread except the tile decompressor. +Long-running document operations (save, export, OCR) show progress and are cancellable; nothing +blocks the GUI thread for more than one frame. + +### 2.4 Third-party components and licensing + +The project ships under **GPL-3.0-or-later** (Qt LGPL-3 dynamically linked), so components must be +GPL-3-compatible. Deliberately avoided: MuPDF and Poppler (AGPL / GPL-2-only, the latter creating a +compatibility headache and a hard dependency on someone else's model we can't extend). + +| Concern | Choice | License | Why | +|---|---|---|---| +| PDF object/file layer | **QPDF** | Apache-2.0 | Best-in-class object access, preserves structure, real incremental-update support, excellent recovery on damaged files. Does *not* touch content streams — exactly the boundary we want. | +| Rasterization | **PDFium** | BSD-3 | Battle-tested (Chrome), fast, permissive. Used only as a renderer over the bytes *we* emit. | +| 2D backend | **Skia** | BSD-3 | Comes with PDFium; also backs our annotation/overlay drawing, the typing fast path (§2.5), and the GPU compositor. | +| Text shaping | **HarfBuzz** | MIT | Complex scripts, OpenType features; `hb-subset` gives us production-grade font subsetting for free. | +| Font rasterizing/parsing | **FreeType** | FTL | Glyph metrics, cmap access, sfnt table surgery. FTL is GPLv3-compatible (it is *not* GPLv2-compatible — another reason the project is GPL-3, not GPL-2). | +| Unicode | **ICU** | Unicode-3.0 | Bidi, normalization, line-break classes (UAX #14), collation for search. | +| Color | **Little-CMS 2** | MIT | ICC profiles, separations, rendering intents. | +| Image codecs | libjpeg-turbo, libpng, OpenJPEG, libtiff | permissive | **JBIG2 and JPX come from PDFium's bundled decoders**, not jbig2dec — jbig2dec is AGPL-3 (combinable under GPLv3 §13, but it drags AGPL obligations into the tree for no benefit when we already link PDFium). | +| Crypto/signing | **OpenSSL 3** | Apache-2.0 | CMS, timestamps, OCSP/CRL, PKCS#11 via provider. Apache-2.0 is GPL-3-compatible; it would not have been under GPL-2. | +| Hyphenation / spelling | **Hunspell** | LGPL-2.1/GPL-2/MPL-1.1 tri-license | Use under LGPL-2.1, dynamically linked. | +| Compression | zlib-ng, brotli, liblzma | permissive | Flate is 95% of streams. | +| OCR (Tier 2) | Tesseract 5 | Apache-2.0 | | + +**Test-only tools are not linked and do not constrain the license**: Ghostscript (AGPL) and veraPDF +(MPL/GPL dual) are invoked as external reference binaries in CI. The license scanner (§11) must be +configured to understand that distinction or it will produce a permanent false positive. + +**Font embedding legality** is a real constraint: check `OS/2.fsType` on every font we embed or +re-subset. `fsType` bit 1 (restricted, `0x0002`) blocks embedding — surface a clear error and offer +substitution rather than silently producing a file that violates the foundry license. Bits 8 +(no-subsetting) and 9 (bitmap-only) are enforced too. + +**Dependency duplication note:** PDFium bundles its own FreeType, libjpeg, libpng and OpenJPEG. +Decide at M1 whether to build PDFium against our system copies (fewer bytes, single CVE surface, more +build friction) or accept the duplicates. Recommendation: unbundle on Linux, accept duplicates on +Windows/macOS where we ship everything anyway. + +### 2.5 The core architectural rule: *render what you will save* + +The single biggest failure mode for WYSIWYG PDF editors is divergence between the preview and the +saved file. We eliminate it structurally: + +1. Every edit mutates L5 (semantic model). +2. The dirty page's content stream is **re-emitted immediately** (§4.4) into an in-memory buffer. +3. The rasterizer renders *that buffer*, not the model. + +So the preview is, by construction, a render of the exact bytes that saving would produce. + +**The typing fast path.** Rule 3 taken literally means re-parsing and re-rendering a whole page +through PDFium on every keystroke. That is fine for a light page and far too slow for a dense one, +so during an active edit gesture we use a two-tier scheme: + +- **Predictive tier (every keystroke, budget < 8 ms):** render only the dirty text frame's damage + rectangle from our own L3 display list via Skia, composited over the cached page raster. +- **Authoritative tier (on 120 ms idle, on gesture end, and always before save):** re-emit and + re-render through PDFium, then diff against what the predictive tier drew. A mismatch above + threshold repaints and logs a fidelity event with the page and revision. + +This preserves the guarantee — nothing is ever *saved* that hasn't been rendered from its own bytes +— while keeping keystroke latency bounded. It also gives us a continuous, free, in-production +consistency check between our interpreter and PDFium's, which is exactly the divergence we are most +exposed to (§11, "two interpreters"). + +Re-emission must still be fast — target < 5 ms for a typical page — which drives the design in §4.4 +(surgical re-emission, not full rebuild). + +### 2.6 Two interpreters, one truth + +We necessarily run two content-stream interpreters (ours at L3, PDFium's in the rasterizer) and two +file parsers (QPDF and PDFium's). Where they disagree, the user sees one thing and we model another. +Rules that keep this bounded: + +- **PDFium never sees the original bytes of a damaged file.** On open, if QPDF performed any + recovery (rebuilt xref, repaired streams, resolved a broken trailer), we hand PDFium the + QPDF-normalized bytes, not the file on disk. Otherwise the preview can reflect a different + document than the one we will save. +- **Our Skia display-list renderer is a first-class artifact**, not a debugging toy: it powers the + predictive tier and the CI cross-check gate (§8.2). Divergence between it and PDFium on the corpus + is a tracked metric with a budget, not an anomaly. +- Any operator our L3 interpreter does not understand marks its page **splice-unsafe**, forcing + verbatim copy for that region (§4.4 rule 4). We never guess. + +--- + +## 3. Document model + +### 3.1 L2 — object layer + +Wraps QPDF. Responsibilities: xref/xref-stream parsing, object streams, stream filters, encryption +handshake, damaged-file reconstruction, and the object graph with reference resolution and cycle +safety. + +Guarantees we add on top: +- **Provenance tracking**: every object records whether it is untouched, modified, or new. Untouched + objects are written back byte-identical. +- **Copy-on-write** semantics so edits are cheap and undo is a pointer swap. +- **Lazy page loading**: pages materialize on demand; a 5,000-page document opens with only the + page tree resident. +- **Resource budgets**: hard caps on decompressed stream size, nesting depth, object count, and + total resident bytes, enforced at the L2 boundary. Exceeding one aborts the operation with a + diagnostic instead of dying by OOM-killer — decompression bombs are a corpus reality, not a + hypothetical. + +### 3.2 L3 — content model + +The content-stream interpreter turns operators into a **display list**, maintaining the full +graphics state machine (CTM, color spaces, ExtGState, clipping, transparency groups, soft masks, +text state, optional content membership, marked content / structure tags). + +```cpp +struct DisplayItem { + enum class Kind { Glyphs, Path, Image, Shading, XObjectForm, MarkedContent } kind; + GraphicsState gs; // resolved, not deltas + Matrix ctm; + ClipId clip; // interned clip stack + OCGId layer; + StructElemId tag; // for tagged PDFs + MCID mcid; // marked-content id, needed to rewrite /K arrays (§3.4) + SourceSpan span; // byte range in the original stream — critical, see §4.4 + std::variant payload; +}; + +struct GlyphRun { + FontId font; + float size, charSpacing, wordSpacing, horizScale, rise; + RenderMode mode; + Matrix textMatrix; + std::vector glyphs; // {gid, code, advance, offset, unicode, confidence, origin} +}; +``` + +`SourceSpan` is the key to lossless round-tripping: it lets us splice edits back into the original +operator byte stream instead of regenerating everything. Note that a page's content may be an +**array of streams** and operators can straddle the boundary — spans are (streamIndex, offset, +length) triples over the logical concatenation, and re-emission must preserve the array structure. + +### 3.3 L5 — semantic model + +Built by the reconstruction pass (§4.1). This is what tools manipulate. + +```cpp +struct TextFrame { // an editable block: paragraph, column, cell, caption… + QuadTree lines; + Polygon shape; // may be non-rectangular (wrap-around) + Direction baseDir; // ICU bidi + std::vector paras; + ColumnLink next; // Tier 2: story continuation + Provenance origin; // which DisplayItems produced this +}; + +struct Paragraph { + std::vector runs; // {text, FontId, size, color, tracking, features…} + ParaStyle style; // align, indents, leading, spaceBefore/After, tabs + bool leadingIsUniform; // if false, reflow preserves per-line baselines +}; +``` + +Every semantic object keeps a bidirectional link to the display items it came from. Edits mark +those items dirty; untouched items are re-emitted verbatim. + +### 3.4 Structure tree (tagged PDF) maintenance + +A tagged PDF carries a parallel tree of `StructElem` objects whose leaves point at content via MCIDs +(and at annotations/XObjects via OBJR). An editor that reflows text without touching that tree +produces a file that still *looks* right and is no longer accessible — the reading order, the +`Alt` text, and the tab order all decay silently. Given the EU Accessibility Act obligations now in +force for many of our users' documents, quietly destroying tags is a product defect, not a +limitation. + +Rules: +1. **MCID stability.** Re-emission preserves each surviving marked-content sequence's MCID. New + content inside an existing `BDC` region gets a new MCID appended to the parent `StructElem`'s + `/K` array at the correct position; deleted content's MCID is removed from `/K` and any now-empty + `StructElem` is pruned (with its `/P` chain fixed up). +2. **Split and merge.** Splitting a paragraph splits its `P` element; merging merges them, keeping + the first element's attributes and logging when the second's differed. +3. **`ActualText` and `Alt` survive**: they are carried on the semantic run, edited through the + inspector, and re-emitted. If the user edits text whose `ActualText` no longer matches, the + fidelity panel flags it rather than guessing. +4. **`ParentTree` and `StructParents`** are rebuilt on save; number-tree consistency is asserted. +5. **Tab order** (`/Tabs /S`) is preserved on page reorder, and annotation `StructParent` entries are + remapped. +6. If a document's structure tree is already inconsistent on open, we do **not** attempt repair; + we mark it untagged-in-practice, tell the user, and preserve the objects verbatim. + +CI gate: tag round-trip on a tagged subcorpus (§8.2). + +--- + +## 4. The hard parts + +### 4.1 Text reconstruction (content stream → editable paragraphs) + +PDF has no concept of a word, a line, or a paragraph. Every one of those must be inferred. Pipeline: + +**Step 1 — glyph→Unicode.** In priority order: +1. `ToUnicode` CMap (correct when present and non-broken; validate against surrogate/`FFFD` junk). +2. Simple-font `Encoding` + `Differences` → glyph names → Adobe Glyph List → Unicode. +3. Standard/Symbolic font built-in encodings; `Identity-H` + CIDSystemInfo registry ordering + (Adobe-Japan1 etc.) via the standard CMap tables. +4. Reverse lookup through the embedded font's `cmap` table. +5. Heuristic fallback: OCR the rendered glyph bitmap against a shape database. + +Every character carries a **confidence** value through the whole model. Low-confidence characters +are shown with a subtle underline in edit mode, because editing near them can change visible output. +Ligature decomposition (`fi` → `f` + `i`), soft hyphens, and `ActualText` overrides are resolved here +so that the text the user edits is the text a copy-paste would produce. + +**Step 2 — run assembly.** Merge adjacent glyph runs sharing font/size/color/CTM into logical runs. +Handle `TJ` kerning adjustments: a negative adjustment larger than ~20% of the space width means an +inter-word gap, not kerning. + +**Step 3 — line detection.** Cluster by baseline in the *text space* of the run, tolerance +±0.25 × font size, handling rotation (cluster along the baseline direction vector, not page Y) and +superscript/subscript rise. Merge runs whose baselines agree and whose horizontal gap is < 2 space +widths. + +**Step 4 — reading order + columns.** Recursive XY-cut with a whitespace-density projection, with +fallback to a topological sort of line-adjacency when the cut fails (common in magazine layouts). +When the PDF is tagged, **use the structure tree instead** — it's authoritative and far more +accurate. + +**Step 5 — paragraph grouping.** Merge consecutive lines when: leading is consistent (±10%), +horizontal extents overlap, no bullet/numbering prefix starts a new line, indentation pattern is +consistent, and the previous line ends near the right margin (i.e. it wrapped rather than ended). +Detect list structures and preserve their markers as paragraph properties. + +**Step 6 — frame geometry.** Frame shape = union of line bounding boxes, expanded to the inferred +text column, minus any obstacles (images/paths that text visibly wraps around). + +Reconstruction is **lazy and cached**: it runs per page on first edit-mode entry or first search, +not on open, and its result is invalidated by content revision. Budget: < 30 ms for a typical page, +< 200 ms for a dense one. + +Accuracy is measured, not asserted: a labelled corpus of ~2,000 pages with ground-truth +paragraph/column segmentation, scored by boundary F1, gates every release (§8.2). + +### 4.2 Font handling + +The hardest practical problem in PDF editing. A page's embedded font is typically a **subset** +containing only the glyphs originally used. Type "Ω" into a paragraph and that glyph does not +exist. + +Resolution ladder, applied per missing character: +1. **Grow the subset.** If the full font is installed on the system and matches by name + + `head.checkSumAdjustment` / panose / glyph-hash comparison, pull the glyph from it and re-subset + with `hb-subset`. +2. **Font-service fallback.** Optional, opt-in, offline: a bundled set of metric-compatible open + fonts (Liberation, Croscore, Noto family) mapped to the common commercial faces. +3. **Substitute.** Pick the closest available face by panose/OS-2 class, weight, width, italic + angle, and x-height ratio; embed a fresh subset. The substituted run is flagged in the UI with + a "font substituted" badge and listed in a pre-save report. +4. **Refuse.** For fonts with `fsType` restricted embedding, we never embed — offer substitution. + +Also required: +- Rewrite `ToUnicode` CMaps for every re-subset font (searchability depends on it). +- Handle Type3 fonts (glyph procedures) — editable only as whole-run replacement. +- Preserve CID ordering and `CIDToGIDMap` for CJK; never renumber GIDs of untouched runs. +- Vertical writing mode (`Identity-V`, `WMode 1`) in shaping and line breaking. +- **Variable fonts**: instance to a static named/custom instance before embedding; PDF 2.0 allows + variable font embedding but consumer support is poor. +- **Never mutate a font program in place.** Growing a subset produces a *new* font object with a new + subset tag (`ABCDEF+Name`); pages referencing the old object are untouched. This keeps undo cheap + and prevents one page's edit from breaking another's rendering. +- Standard-14 fonts (non-embedded Helvetica etc.): treat as substitution candidates with their + canonical metrics; if the user edits such a run and we must embed, say so. + +### 4.3 Layout and reflow + +When a text frame changes, re-layout it: +1. **Shape** each style run with HarfBuzz against the actual embedded font, with the original + OpenType features preserved where detectable. +2. **Break lines** with Knuth–Plass (total-fit) using ICU UAX #14 break opportunities, hyphenation + via Hunspell patterns, and the frame polygon for available width per line. +3. **Justify** using the original method: word-spacing-only, or word+letter spacing, matched to what + the source document did (measured from the original inter-word gap variance). +4. **Fit** to the frame. If the text overflows: (a) 1.0 default — grow the frame downward if the + space below is empty, otherwise show an overset indicator and let the user resize/link; + (b) Tier 2 — flow into the linked next frame, cascading across pages. + +**Reflow fidelity guard:** for unedited paragraphs we do not re-layout at all. For an edited +paragraph, we first attempt a *minimal* reflow — re-break only from the edited word to the end of +the paragraph — and verify that lines before the edit are byte-identical to the original. If not, +we fall back to full paragraph re-layout and log a fidelity event. + +**Bidi editing** is a distinct problem from bidi rendering: caret movement (visual vs logical), +selection contiguity across direction runs, and cursor placement at direction boundaries all need +explicit UX decisions. Follow the UAX #9 + platform conventions and test with a dedicated RTL/mixed +corpus; this is easy to get 80% right and very visibly wrong at the last 20%. + +### 4.4 Surgical re-emission + +Naïvely regenerating a page's content stream loses everything we didn't model: obscure operators, +`BDC` marked-content nesting, transparency groups, printer-specific `DP` properties, comments. +Instead: + +``` +original stream bytes: [ ..A.. ][ ..B.. ][ ..C.. ][ ..D.. ] +edits touch B and C: [ ..A.. ][ new B'C' ][ ..D.. ] ← A and D copied verbatim +``` + +Rules: +1. Each `DisplayItem` carries its `SourceSpan`. Dirty items' spans are coalesced into replacement + regions. +2. Graphics state at a region boundary is reconstructed and **explicitly re-established** inside the + replacement (`q ... Q` wrapping) so the following verbatim bytes still see the state they expect. + This is verified by an assertion pass that re-interprets the emitted stream and diffs the + graphics state at every splice point against the original. +3. Newly created objects append after the last region. +4. If a page's structure is too tangled to splice safely (deeply interleaved marked content across + a dirty region, an unrecognised operator inside it, or a `q`/`Q` imbalance), fall back to a full + re-emit of that page and record it as a fidelity event. +5. Resources (`/Font`, `/XObject`, `/ExtGState`…) added by an edit are merged into the page's + resource dictionary with fresh, non-colliding names; the resource dictionary may be inherited + from an ancestor `Pages` node, in which case we copy it down to the page before mutating it — + never edit an inherited dictionary in place, it is shared. + +Save modes: +- **Incremental update** (default when the file is signed or when the user chose "save"): append + changed objects + new xref. Preserves existing signatures on prior revisions and is fast on + large files. +- **Full rewrite** (on "save as", or when the user asks to optimize): garbage-collect unused + objects, deduplicate identical streams and fonts, recompress, optionally linearize for fast web + view. +- **Sanitized save** (explicit user action): full rewrite plus removal of JavaScript, embedded + files, external references, and all prior revisions. + +Version handling: the output PDF version is `max(input version, minimum required by features used)`. +If an edit needs a feature the input's version doesn't allow (e.g. AES-256 in a 1.4 file), we prompt +before upgrading, because some downstream consumers care. + +Both paths run a **verification pass** before the file is handed over: reparse the output, re-render +every changed page, and compare against the pre-save preview raster. A mismatch above threshold +blocks the save with a diagnostic rather than writing a corrupted file. Text extraction of changed +pages is compared too — a page can be pixel-identical and semantically ruined. + +### 4.5 Redaction + +Redaction that only draws black boxes is a recurring real-world data breach. Ours removes: +- Glyphs whose bounding boxes intersect the region (partial glyphs → the run is split and re-emitted + without them; a glyph straddling the boundary is removed entirely). +- Image pixels: decode, blank the region, re-encode (lossless where the source was lossless). If the + image is referenced by more than one page, it is **copied first** — redacting a shared XObject in + place silently redacts other pages. +- Path geometry: clip and re-emit the remaining segments. +- Annotations, form field values, and their appearance streams. +- Embedded font subsets: if removing the only occurrence of a glyph leaves it in the subset, re-subset + so the glyph itself is gone (fonts leak surprisingly often). +- Document metadata, XMP, attachments, and *all prior incremental revisions* (redaction forces a + full rewrite — never incremental). +- Optional-content groups whose hidden content intersects the region. +Then re-render and OCR-check the redacted region as a final assertion that no text survives. +Redaction is irreversible and says so, with a preview of exactly what will be destroyed. + +### 4.6 Signatures, encryption and editing + +These interact badly and users get hurt when a tool is vague about it. + +- **Editing a signed document.** Any content edit invalidates signatures that cover the edited + content. On the first edit we explain this once, per document, in plain language, and offer: + save as a new revision (incremental — earlier signatures remain valid over their revisions, the + latest becomes "signed but modified") or save a copy. `DocMDP` permission level is honoured: + at level 1 we refuse content edits outright, at level 2 we allow form fill and signing only, at + level 3 we additionally allow annotations. +- **Signing.** Byte-range placeholder, two-pass write, PKCS#11 / OS keystore (CryptoAPI, Keychain, + p11-kit) for the private key — we never handle raw private key material. RFC 3161 timestamps; + LTV by embedding OCSP/CRL and building B-LT/B-LTA. +- **Validation** reports the chain, revocation status, timestamp, coverage (does the signature cover + the whole file?) and what changed after signing — not a green tick. +- **Encryption.** Re-encrypt on save with the document's existing algorithm unless the user changes + it. Owner-password-only documents open with full editing (permission bits are advisory and we say + so honestly, rather than pretending to enforce them, but we do preserve them). Encrypted + attachments and unencrypted-wrapper documents (PDF 2.0) are handled explicitly. +- The journal and autosave snapshots for an encrypted document are themselves encrypted at rest with + a key held only in memory; a crashed session's recovery data is unreadable without reopening the + original. + +--- + +## 5. Rendering and printing + +- **Tile-based**: 512×512 tiles in device space, rendered on a worker pool, cached in an LRU keyed + by (page, zoom bucket, rotation, layer set, content revision). +- **Progressive**: low-res preview immediately, refine to full res; text-only quick pass first for + perceived responsiveness during scroll. +- **HiDPI**: render at device pixel ratio; independent zoom levels per split view. +- **GPU path**: Skia Ganesh/Graphite for annotation overlay, selection chrome, the typing fast path, + and page compositing; page content stays on CPU (PDFium) for fidelity. Fall back to full CPU + compositing when GPU init fails, and blocklist known-bad driver/GPU combinations. +- **Editing overlay** (selection handles, text carets, guides, snapping) draws in a separate layer + above the raster, so it never invalidates the content cache. +- **Color management**: soft-proof mode with output-intent simulation; correct handling of + Separation/DeviceN, overprint preview, and rendering intents. + +**Printing** goes through the same emitter, not a separate path: we produce the PDF we would save, +then hand it to the platform print system (CUPS/IPP Everywhere, `IPrintDocumentPackageTarget`, +`NSPrintOperation`). For drivers that mangle PDF, offer "print as image" at a chosen DPI. Booklet, +N-up, tiling, scale-to-fit, and duplex/margin handling are computed by us so output is identical +across platforms. Print previews are the actual raster, not an approximation. + +Performance targets (P95, on a 2020-class laptop): + +| Operation | Target | +|---|---| +| Open 1,000-page / 100 MB file → first page visible | < 800 ms | +| Page render at 100% zoom, cached fonts | < 40 ms | +| Keystroke → updated pixels in text editing (predictive tier) | < 16 ms | +| Authoritative re-render after idle | < 120 ms | +| Text reconstruction, typical page (first edit-mode entry) | < 30 ms | +| Content-stream re-emission, typical page | < 5 ms | +| Scroll at 60 fps with progressive tiles | sustained | +| Search across a 1,000-page document (first result) | < 500 ms | +| Memory, 1,000-page document, 20 pages viewed | < 600 MB | +| Save (incremental) 100 MB file | < 500 ms | +| Cold app start → empty window interactive | < 700 ms | + +--- + +## 6. Editing infrastructure + +### 6.1 Command pattern + +Every mutation is a `Command` with `apply` / `revert` / `merge` / `serialize`. Commands compose into +transactions; a transaction is the undo granularity. Consecutive typing commands merge on a 500 ms +idle or word boundary. + +```cpp +class Command { +public: + virtual Result apply(Document&) = 0; + virtual void revert(Document&) = 0; + virtual bool mergeWith(const Command&) { return false; } + virtual void serialize(Journal&) const = 0; // for crash recovery + virtual PageSet dirtyPages() const = 0; // drives re-emission + repaint +}; +``` + +Undo stack is unlimited by default, spilling command payloads to disk beyond 200 MB. `revert` must +restore the object graph *and* the structure tree; every command type has a property-based test +asserting apply→revert returns the document to a byte-identical emitted state. + +### 6.2 Selection model + +Three selection modes, one shared hit-test index (per-page R-tree over display items): +- **Object selection** — images, paths, groups, annotations, form fields. +- **Text selection** — caret + range within a `TextFrame`, with word/line/paragraph expansion, + rectangular (column) selection on Alt-drag, and bidi-correct visual↔logical mapping. +- **Node selection** — path anchor points and control handles in vector-edit mode. + +### 6.3 Tools + +Modeless where possible. Double-click any text enters edit mode in place; double-click an image +opens crop/replace handles. Explicit tools for annotate, redact, form-field, shape, and node edit. +Snapping to object edges/centers, page margins, baselines, and a configurable grid, with +alignment/distribute operations and smart guides. + +### 6.4 Crash recovery and autosave + +Commands are appended to a journal (fsync'd, batched at 200 ms) alongside a periodic snapshot of the +document delta. After a crash, the UI process replays the journal onto the reopened original file +and offers the recovered state. The original file is never modified in place — saves go to a temp +file on the same filesystem, fsync, then atomic rename, preserving ownership, permissions, xattrs +and (on macOS) resource forks. + +Recovery must be safe against *poisoned* journals: a journal that crashes replay is quarantined +after two attempts and the user is offered the last good snapshot instead of an infinite crash loop. + +**External modification**: we hold the file's identity (device+inode / file id) and mtime+size; if it +changes underneath us we warn before saving and offer reload/compare/save-as. Two windows on the same +file share one document process. + +### 6.5 Text input + +The canvas is custom-drawn, so we get none of Qt's line-edit behaviour for free. Required: +- **IME support** via `QInputMethodEvent` — preedit string rendered inline with the correct styling, + candidate window positioned at the caret, and commit handled as a single undoable command. Test + on Windows IME, macOS input sources, and ibus/fcitx on Linux (all three behave differently). +- Dead keys, compose sequences, and Unicode hex input. +- Platform-native caret behaviour, word-boundary navigation (ICU word breaks, not `isspace`), and + overwrite/insert modes. +- Spell-check underlining via Hunspell with per-document language detection; no autocorrect, + ever — silently changing the text in someone's legal document is unacceptable. +- Drag-and-drop of text and objects, with autoscroll. +- Accessibility: expose the edit session through `QAccessible` text interfaces so a screen reader + can read and navigate the text being edited (§9.3). + +--- + +## 7. Security and privacy + +### 7.1 Threat model + +| Asset | Adversary | Vector | Mitigation | +|---|---|---|---| +| User's machine | Attacker sending a malicious PDF | Parser memory corruption | Sandboxed document process (§2.1), sanitizers in CI, continuous fuzzing, resource budgets (§3.1) | +| User's other files | Same | Sandbox escape via IPC to the UI process | IPC treated as untrusted in both directions (§2.1); UI deserializer fuzzed; no path handling in the document process | +| User's network / data exfiltration | Same | PDF actions: `SubmitForm`, `URI`, `GoToR`, remote XObjects, embedded file launch | Document process has no network. The UI never opens a URL, launches a file, or submits a form without an explicit user gesture and a dialog showing the full target. Remote resources are never fetched during rendering. | +| Document confidentiality | Local attacker | Autosave journal, temp files, crash dumps | Encrypted-at-rest journal for encrypted documents (§4.6); temp files mode 0600 in a per-user dir; crash dumps never include document bytes (§7.3) | +| Update channel | Network attacker | Malicious update | Ed25519-signed updates verified before applying, pinned key, TLS on top (§12.4) | +| Release integrity | Supply chain | Compromised dependency or build | Pinned vcpkg baseline + lockfile, SBOM per release, reproducible builds where the toolchain allows, signed tags | + +Explicitly *not* claimed: we do not defend against a compromised OS account, and permission bits in +a PDF are not a security boundary (we say so in the UI). + +### 7.2 Memory safety strategy + +We are writing a new parser in C++, which the threat model says is the main risk. Beyond "be +careful": +- All parsing operates on `std::span`-like bounded views; raw pointer arithmetic over input bytes is + banned by clang-tidy and enforced in review. +- Build with `-D_GLIBCXX_ASSERTIONS` / `_LIBCPP_HARDENING_MODE=fast`, `-fstack-protector-strong`, + CFI and shadow-stack/CET where available, in **release** builds — not just debug. +- A hardened allocator (scudo or hardened_malloc) in the document process. +- Integer overflow is a build error in the parsing layers (`-ftrapv`-equivalent via UBSan in CI, + checked arithmetic helpers in code). +- Evaluate at M0 whether L1/L2 primitive decoders (filters, image codecs glue, CMap parsing) are + worth writing in Rust behind a C ABI. They are self-contained, leaf-level, and where most of the + historical CVEs live. Decide once, at M0 — retrofitting a second language mid-project is worse + than either choice made early. + +### 7.3 Telemetry, crash reporting and privacy + +The default posture is that a PDF editor sees people's most sensitive documents and must be boring +about it. + +- **No telemetry by default.** Opt-in only, asked once, never re-nagged. When enabled it sends + counters and timings — never file names, paths, content, or URLs. +- **Crash reporting** (Crashpad) is opt-in and uploads minidumps with **document memory excluded**: + the document heap lives in a dedicated allocator region that is stripped from the dump, and the + dump is filtered client-side before upload. Show the user the report contents before the first + send. +- **Fidelity events** (§2.5, §4.3, §4.4) are recorded locally and visible in the fidelity panel; + they are only transmitted if the user explicitly submits a diagnostic bundle, and the bundle + never includes the document unless the user attaches it deliberately. +- No network connections at all except: update check, timestamp/OCSP/CRL during signing, and + user-initiated link opening. All are individually disableable by policy (§12.2). +- A **privacy policy** and a documented list of every outbound connection ship in the repo. + +### 7.4 Security disclosure + +`SECURITY.md` with a published contact, PGP key, and a 90-day coordinated-disclosure policy. +Pre-1.0: an external security audit of the parser and sandbox, closed before release (§10, M9). +OSS-Fuzz integration from M2, not from M8. + +--- + +## 8. Testing and quality + +### 8.1 Corpora + +- **govdocs1** subset (~50k real-world PDFs) — parser robustness and crash-freedom. +- **PDF Association test suite** + veraPDF corpus — spec conformance, PDF/A, PDF/UA. +- **pdf.js + PDFium regression corpora** — known-bad renderers. +- **Internal labelled corpus** (~2,000 pages) — ground-truth paragraph/column segmentation and + glyph→Unicode mappings, for reconstruction scoring. +- **Tagged subcorpus** (~300 documents) — structure-tree round-trip and edit-under-tags. +- **Script corpus** — Arabic/Hebrew (RTL), Devanagari, Thai, CJK horizontal and vertical, mixed- + direction, for shaping, bidi editing, and line breaking. +- **Generated adversarial corpus** — malformed xrefs, cyclic object graphs, 10k-deep nesting, + decompression bombs, hostile encryption dictionaries. + +Corpus provenance and licensing are tracked; anything with unclear redistribution rights stays out +of the public corpus repo and is referenced by hash for local-only use. + +### 8.2 Automated gates (every PR) + +| Gate | Threshold | +|---|---| +| Render pixel-diff vs Ghostscript+PDFium reference, 5 zoom levels | ≤ 0.1% differing pixels, no new failures | +| **Own Skia renderer vs PDFium** on the corpus (§2.6) | ≤ 0.3% differing pixels; regressions block | +| Round-trip: open → save (no edits) → reparse | semantically identical object graph; text extraction byte-identical | +| Reconstruction: paragraph boundary F1 | ≥ 0.93, no regression | +| Text edit → save → extract | edited text matches expected, neighbours unchanged | +| **Tag round-trip**: edit a tagged doc → save → structure tree valid, reading order preserved | no regression; veraPDF PDF/UA checks no worse than input | +| Splice-point graphics-state invariant (property-based) | zero violations | +| Command apply→revert byte-identity | zero violations | +| ASan/UBSan/TSan on the full unit + corpus suite | clean | +| Fuzzing (libFuzzer, per-layer harnesses **incl. the UI-side IPC deserializer**) | no new crashes in 30 min/PR; continuous via OSS-Fuzz | +| Performance benchmarks | no >5% regression on the target table | +| Memory ceiling on the 1,000-page fixture | < 600 MB | +| License scan (REUSE + scancode) | no non-GPL-3-compatible linked dependency | + +### 8.3 Other testing + +- Differential testing: our text extraction vs pdftotext/PDFium on the whole corpus; disagreements + are triaged, not auto-accepted (we are sometimes right). +- Property-based tests for the graphics state machine and the splice-point invariant (§4.4 rule 2). +- **Consumer-compatibility matrix**: every release's output opened in Acrobat, Preview, Chrome, + Firefox/pdf.js, Edge and Foxit. "It renders in our app" is not the bar; a file our editor produces + that Acrobat refuses is a release blocker. +- Manual exploratory passes each milestone. +- Accessibility: keyboard-only operation of every feature, screen reader (NVDA/VoiceOver/Orca) + labelling of canvas objects, high-contrast and reduced-motion support. +- **Upgrade/soak tests**: install old version → open documents → auto-update → verify settings, + recovery journals and licences survive. + +### 8.4 Release bug bar + +Blocking a release: any data loss, any file corruption, any crash reproducible from a corpus file, +any redaction failure, any signature mis-validation, any sandbox escape, any accessibility +regression on a Tier-1 flow, any perf target missed by >20%. + +--- + +## 9. UI design + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ menu bar [search] │ +├──────────┬──────────────────────────────────────────────┬──────────────┤ +│ tool │ │ inspector │ +│ palette │ page canvas │ (context- │ +│ │ (continuous / facing / single) │ sensitive: │ +│ [thumbs] │ │ text, obj, │ +│ [outline]│ ┌────────────────────┐ │ page, doc) │ +│ [layers] │ │ floating format │ │ │ +│ [comment]│ │ bar on selection │ │ [history] │ +│ [fields] │ └────────────────────┘ │ [fidelity] │ +├──────────┴──────────────────────────────────────────────┴──────────────┤ +│ status: page 4/128 · 100% · 2 substituted fonts · unsaved changes │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +### 9.1 Principles + +- **Direct manipulation first.** Text edits happen on the page, not in a side panel. Format controls + appear as a floating bar near the selection. +- **Honest feedback.** A persistent, non-modal "fidelity" indicator surfaces substituted fonts, + low-confidence character mappings, pages that required full re-emission, and tag-structure + warnings. Users of a PDF editor need to know when the output may not match the input. +- **Nothing is silently destructive.** Redaction, flattening, downsampling, signature invalidation + and tag loss always show what will be permanently removed, with a preview. +- **No modal progress for anything the user could keep working through.** +- Dark mode, per-monitor DPI, tabbed documents, split view, session restore, recent files with + pinning, and custom keymaps (Acrobat-compatible preset shipped). + +### 9.2 Internationalization + +- The UI is fully translatable from M1: Qt Linguist `.ts` files, no concatenated strings, plural + forms, and no text baked into images. Ship with a Weblate/Transifex-style community workflow. +- Launch languages: English, German, French, Spanish, Portuguese (BR), Italian, Polish, Russian, + Japanese, Simplified Chinese. Others as community translations reach 90%. +- RTL UI mirroring (`LayoutDirection::RightToLeft`) is tested, not assumed — the canvas itself never + mirrors, only the chrome. +- Locale-correct number/date/measurement formatting; unit preference (pt/mm/inch/pica) is explicit + and per-document-independent. + +### 9.3 Accessibility + +- Every function reachable by keyboard, with a discoverable shortcut list. +- The canvas exposes an accessibility tree: pages, objects with names/roles, and a text interface + during editing so screen readers can read, navigate and edit text (§6.5). +- Respect OS settings for contrast, reduced motion, font scaling, and cursor size. +- Target WCAG 2.2 AA for the application chrome. We ask users to care about accessible PDFs; the + editor has to hold itself to the same standard. + +### 9.4 Settings and policy + +Settings live in a documented, human-readable file (INI/JSON) under the platform config dir, with a +machine-wide policy layer that overrides them (Windows ADMX/registry, macOS configuration profile, +Linux `/etc/freepdfeditor/policy.json`). Policy-controlled keys: updates, telemetry, crash reporting, +default save location, network access, sanitized-save enforcement, allowed signature roots. + +--- + +## 10. Roadmap + +Assumes a team of **6–8 engineers** (2 PDF core, 2 text/layout/fonts, 2 UI, 1 rendering/perf, +1 QA/infra) plus a designer. Durations are calendar time. + +| # | Milestone | Duration | Exit criteria | +|---|---|---|---| +| **M0** | Feasibility spikes | 8 wks | Prototypes proving: content-stream splice round-trips losslessly on 500 real PDFs; text reconstruction hits F1 ≥ 0.85; PDFium renders our re-emitted bytes identically; our Skia display-list renderer matches PDFium closely enough for the predictive tier. Rust-vs-C++ decision for L1/L2 decoders made. Kill/redesign decision point. | +| **M1** | Viewer | 10 wks | Open/render/navigate/search/print any corpus file; tiling + caching; sandboxed process split; i18n scaffolding; perf targets for viewing met. | +| **M2** | Object model + commands | 8 wks | L2/L3/L6 complete; incremental + full save with verification; page assembly ops; undo/redo; crash recovery; OSS-Fuzz live. | +| **M3** | Annotations & forms | 10 wks | All Tier-1 annotation types, comment panel, AcroForm fill + field authoring, appearance stream generation. Ships as a **usable public beta**. | +| **M4** | Text editing, single-line | 12 wks | Reconstruction pipeline, glyph→Unicode, in-place editing without reflow, styling, font subset growth, IME support. | +| **M5** | Reflow | 12 wks | Paragraph reflow with Knuth–Plass, frame fitting, font substitution ladder, bidi + CJK vertical, tag maintenance under edits, find & replace across document. | +| **M6** | Images & vectors | 8 wks | Image replace/crop/resample, path selection and node editing, colorspace-correct handling. | +| **M7** | Security & compliance | 8 wks | Redaction with verification, signing + validation (PAdES), encryption, PDF/A export via veraPDF gate, policy layer. | +| **M8** | Performance, a11y, polish | 10 wks | All perf targets green; keyboard-complete; screen reader support; localization at launch-language coverage; installers, auto-update, crash reporting. | +| **M9** | 1.0 hardening | 8 wks | 4 weeks bug-fix-only; OSS-Fuzz clean for 30 days; external security audit closed; consumer-compatibility matrix green. | + +**≈ 94 weeks (~22 months) to 1.0**, with a public beta at M3 (~9 months) and text editing in +beta hands at M5 (~15 months). Milestones M4–M6 are the ones that slip; budget accordingly. + +### 10.1 If the team is one or two people + +The plan above is staffed for 6–8 engineers. At 1–2 people the same scope is a 5+ year project and +the honest move is to change the *shape* of it, not the estimate: + +1. **Ship a viewer first, publicly** (M0 + M1, ~6 months at this size). A fast, sandboxed, + correct viewer is a real product and it de-risks everything downstream. +2. **Then annotations + forms + page assembly** (M2 + M3). This is where most "PDF editor" users + actually stop, and it is 20% of the engineering of text editing. +3. **Then text editing, narrowly**: in-place single-line editing (M4) with *no* reflow, gated behind + a clearly-labelled beta. Refuse to edit anything the reconstructor is not confident about rather + than shipping bad output. +4. **Reflow last** (M5), and only if steps 1–3 have users. + +Non-negotiable even at small scale, because retrofitting them is worse than building them: +the process split (§2.1), the render-what-you-save rule (§2.5), the command/journal layer (§6.1), +the packaging pipeline (§12), and the CI gates for round-trip and pixel-diff (§8.2). + +Deferrable at small scale: GPU compositing, PDF/A export, LTV signing, OCR, DOCX export, the +localization breadth in §9.2, and the `.rpm`/Flatpak/winget/Homebrew channels. + +--- + +## 11. Risks + +| Risk | Impact | Mitigation | +|---|---|---| +| Text reconstruction accuracy is the whole product; real PDFs are pathological | High | M0 spike gates the project on measured F1; labelled corpus scored every PR; per-document confidence surfaced in UI rather than hidden. | +| Re-emission silently corrupts complex pages | High | Splice-point graphics-state assertion; pre-save render *and text-extraction* verification; per-page full-rebuild fallback; fidelity events reported and triaged. | +| **Two interpreters (ours and PDFium's) diverge** | High | Cross-renderer CI gate (§8.2); predictive-vs-authoritative diff in production (§2.5); unknown operators force verbatim splice. | +| Font substitution produces visibly wrong output | Medium | Subset-growth before substitution; metric-compatible fallback set; explicit UI badge and pre-save report. | +| Editing silently destroys accessibility tags | Medium-High | Tag maintenance is Tier 1 (§3.4) with its own CI gate; fidelity panel warns. | +| Security vulnerability in the parser | High | Sandboxed document process from M1 (not retrofitted); hardened build settings in release (§7.2); sanitizers in CI; OSS-Fuzz from M2; external audit before 1.0. | +| Sandbox escape through the IPC layer into the UI process | High | Bidirectional trust boundary (§2.1); UI deserializer fuzzed as a first-class harness. | +| Scope creep into Acrobat-parity print production | Medium | Tier-2 list is contractual; 1.0 scope frozen at M3. | +| Font embedding license violations | Medium | `fsType` enforced at the embedding call site, no bypass path. | +| GPL-incompatible dependency creeps in | Medium | CI license scan (REUSE + scancode) fails the build on any new non-compatible dependency; test-only tools explicitly allowlisted. | +| PDFium is a large upstream we must track | Medium | Pin a revision, update on a schedule with the full corpus gate; keep our patches minimal and upstreamable. | +| Schedule optimism on M4–M6 | High | These are staffed with the two most senior engineers; M0 spike results recalibrate the estimate before commitment. Small-team variant in §10.1. | +| Our own output rejected by Acrobat/Preview | High | Consumer-compatibility matrix as a release gate (§8.3), run from M2 — not discovered at 1.0. | + +--- + +## 12. Distribution and packaging + +Every platform gets first-class native packaging. Packaging is **not** an M8 afterthought: the +pipeline is stood up in M1 producing nightly artifacts for all targets, so integration problems +(signing, notarization, dependency bundling, Wayland quirks) surface early and continuously. + +Payload note: ICU's full data is ~30 MB. Build a trimmed data bundle (the collations, break +iterators and normalization we actually use) as part of the packaging pipeline, or the download size +doubles for nothing. + +### 12.1 Linux + +| Format | Details | +|---|---| +| **`.deb`** | Debian 12+ / Ubuntu 22.04+. Built via `dpkg-deb` from a CPack DEB generator. Depends on system Qt 6 where the distro ships ≥ 6.7, otherwise bundles it under `/opt/freepdfeditor`. Ships `.desktop` entry, hicolor icons (16–512 px), MIME association for `application/pdf`, AppStream metainfo XML, man page, and shell completions. Published to an APT repo (`deb.freepdfeditor.org`) signed with the project GPG key; `stable` and `nightly` suites. | +| **`.AppImage`** | The universal fallback: built inside a manylinux-style container against an old glibc (2.31) for maximum reach, bundling Qt, Skia, ICU, and all codecs. Includes `AppRun` with Wayland/X11 auto-detection, zsync file for delta updates, and desktop-integration prompt via `appimaged` conventions. Runs on any glibc ≥ 2.31 distro with no root. **Caveat:** AppImage cannot use bubblewrap-style user namespaces on distros that restrict them, so the sandbox falls back to seccomp-only; the app reports its actual sandbox level in About, and refuses to silently pretend. | +| **Portable tarball** | `freepdfeditor--linux-x86_64.tar.xz` — the same self-contained tree as the AppImage but unpacked, for sandboxed/immutable environments, CI use, and users who want the raw binary. Contains an `install.sh` that only creates symlinks and desktop entries under `~/.local`. | +| *(Also planned)* | **`.rpm`** for Fedora/openSUSE via the CPack RPM generator, and a **Flatpak** on Flathub (`org.freepdfeditor.FreePDFEditor`) using **only** the document and file-chooser portals — no `--filesystem=host`, which would defeat the point of the sandbox and is increasingly penalised by Flathub's own review. Both are best-effort community-supported, not release blockers. | + +Architectures: `x86_64` for all formats; `aarch64` for `.deb` and tarball. +All Linux artifacts are reproducible-build-verified where the toolchain allows, and shipped with +detached GPG signatures plus SHA-256 sums. + +### 12.2 Windows + +| Format | Details | +|---|---| +| **`.msi`** | The enterprise/managed-deployment path, built with WiX Toolset v4. Per-machine install, ADMX templates for the §9.4 policy set, silent install (`msiexec /qn`), proper upgrade codes for in-place version upgrades, and clean uninstall. This is what IT departments deploy via Group Policy / Intune / SCCM. | +| **`.exe`** | The consumer installer (Inno Setup or NSIS wrapping the same payload). Per-user install without admin rights by default, elevating only if the user chooses all-users. Handles file associations, "Open with", Explorer preview handler and thumbnail provider registration, and offers the portable mode opt-out. | +| **Portable `.zip`** | No installer, no registry writes, settings stored beside the executable. | +| *(Also planned)* | `winget` manifest and a Chocolatey package pointing at the `.exe`. MSIX/Microsoft Store is deferred past 1.0: its sandbox complicates our document-process sandbox, *and* the Store's terms are widely read as incompatible with GPL-3 distribution. | + +Both installers are **Authenticode-signed** with an EV certificate (in HSM/cloud KMS, never on a +build machine), which also builds SmartScreen reputation. Architectures: `x64` and `arm64`. + +### 12.3 macOS + +| Format | Details | +|---|---| +| **`.dmg`** | Primary consumer distribution. Universal 2 binary (x86_64 + arm64), drag-to-Applications layout with a styled background, hardened runtime enabled, code-signed with a Developer ID Application certificate and **notarized + stapled** (unnotarized builds are a support disaster on macOS 15+). | +| **`.pkg`** | For managed/MDM deployment (Jamf, Kandji): signed with Developer ID Installer, supports `installer -pkg` silent install and configuration profiles for the same policy set as the Windows ADMX. | +| *(Also planned)* | Homebrew Cask pointing at the `.dmg`. The **Mac App Store is not possible**, not merely unplanned: the App Store's terms impose usage restrictions that GPL-3 forbids. The sandbox-entitlement friction with §2.1 is a secondary reason. | + +macOS-specific integration: Quick Look preview extension, Services menu entries, Continuity/Handoff +of the open document, and correct behaviour under App Nap and Stage Manager. + +### 12.4 Auto-update + +One update mechanism, three transports: Sparkle (macOS), WinSparkle or a custom updater service +(Windows), zsync/AppImageUpdate + APT (Linux). Updates are delta where possible, signed with the +project's Ed25519 release key and verified before applying. Channels: `stable`, `beta`, `nightly`. +Enterprise builds ship with updates disabled by policy. The update check sends no identifiers beyond +version and platform, and is disableable. + +### 12.5 Release pipeline + +`git tag v*` on the release branch triggers: cross-platform build matrix → full test gates (§8.2) → +artifact packaging for all nine formats → signing/notarization → SBOM generation (CycloneDX) → +checksum + signature manifest → upload to the Gitea release (§13) and the public mirror. +A release is only publishable if **every** platform artifact built and passed its smoke test — +no partial releases. Signing keys live in an HSM/cloud KMS; no key material ever reaches a runner's +filesystem. + +--- + +## 13. Source control workflow + +**All code created or changed must be committed and pushed to the project Gitea instance.** This is +a hard rule for every contributor and every automated agent working on the codebase, not a +best-effort convention — nothing stays only on a local disk. + +### 13.1 Remote + +| | | +|---|---| +| **Host** | `https://gitea.lm.je` | +| **Username** | `ai-ad4` | +| **Credential** | Read from the environment variable `GITEA_PASSWORD_AI_AD4`. Never hardcode it, never commit it, never echo it into logs or CI output. | +| **Repository** | `ai-ad4/freepdfeditor` (create on first push if absent) | + +> **Note on the variable name:** the underscores are deliberate — the username is `ai-ad4`, but a +> hyphen is not legal in a shell identifier, so the variable form of the name substitutes +> underscores. Do not "correct" it back to match the username. + +Configure the remote without embedding the secret in `.git/config`: + +```bash +# credential helper reads the password from the environment at call time +git config --local credential.helper \ + '!f() { echo "username=ai-ad4"; echo "password=$GITEA_PASSWORD_AI_AD4"; }; f' +git remote add origin https://gitea.lm.je/ai-ad4/freepdfeditor.git +``` + +The variable is exported from the developer's shell profile for local work. Interactive shell +profiles are **not** inherited by CI runners, cron jobs, or GUI-launched processes — those take the +password from the Gitea repository secret store (§13.3) instead. + +An SSH deploy key is preferred over password auth once the repo exists; the password path stays as +the bootstrap and CI fallback. + +### 13.2 Rules + +1. **Push after every change.** Any session that creates or modifies code ends with that work + committed and pushed. Work in progress goes to a branch — it does not sit uncommitted. +2. **Never commit to `main` directly.** Branch (`feat/`, `fix/`, `spike/`, `chore/`), push, open a + pull request. `main` is protected and requires the §8.2 gates to pass. +3. **Conventional Commits** (`feat:`, `fix:`, `perf:`, `refactor:`, `test:`, `docs:`, `build:`) with + the affected layer as a scope, e.g. `fix(L3): restore graphics state at splice boundary`. +4. **No secrets in history.** `gitleaks` runs as a pre-commit hook and again in CI; a hit fails the + build. `GITEA_PASSWORD_AI_AD4` and all signing keys live in the environment or the CI secret + store only. +5. **Large binaries via Git LFS.** The test corpora (tens of GB of PDFs) live in a separate + `freepdfeditor-corpus` repository with LFS, referenced as a submodule pinned by commit — the main + repo stays fast to clone. +6. **Tags are releases.** Annotated, signed (`git tag -s`), semver, and they drive §12.5. +7. **This plan is versioned with the code**, at `docs/plan.md`. It is a living document; changes to + architecture land as PRs against it in the same commit as the code that implements them. + +### 13.3 CI on Gitea + +Gitea Actions runners (Linux x64/arm64, Windows, macOS) execute the §8.2 gate matrix on every push +and pull request, and the §12.5 packaging pipeline on tags. Runner registration tokens and signing +credentials come from Gitea repository secrets, never from the repo tree. Runners for release builds +are ephemeral and isolated from PR builds — a PR must never be able to reach a signing key. + +### 13.4 Project governance + +- **License:** GPL-3.0-or-later, `REUSE`-compliant headers, `LICENSES/` directory. +- **Contributions:** DCO sign-off (not a CLA). `CONTRIBUTING.md` covers build setup, the gate + matrix, and the coding standard (clang-format enforced, clang-tidy set checked in). +- **Security:** `SECURITY.md` per §7.4. +- **Decisions:** architecture decision records in `docs/adr/`, numbered, immutable once accepted. + §2.5, §4.4 and §7.2 each get one at M0. +- **Trademark:** the name and logo are held separately from the code license so forks can't ship + malware under our name; policy documented in `TRADEMARK.md`. + +--- + +## 14. Immediate next steps (first 4 weeks) + +1. Stand up the repo **on `gitea.lm.je` (§13) before any code is written**: CMake + vcpkg (pinned + baseline + lockfile), branch protection on `main`, Gitea Actions runners for all three platforms, + CI matrix (Win/macOS/Linux × Debug/Release/ASan), clang-format, clang-tidy, gitleaks, license + scanner, REUSE headers, `SECURITY.md` / `CONTRIBUTING.md` / `docs/adr/`. +2. Skeleton packaging for all nine artifact formats (§12) producing nightly builds from day one, + even if the app only shows an empty window — signing and notarization are discovered problems, + not implemented ones. +3. Assemble the corpora and the pixel-diff harness against Ghostscript + PDFium references — this + infrastructure gates everything, so it comes early. +4. **Spike A**: QPDF-based open → parse content streams → re-emit verbatim → save. Measure + byte-identical round-trip rate over 500 corpus files. Target ≥ 99%. +5. **Spike B**: glyph→Unicode + line/paragraph reconstruction on the labelled corpus. Report F1. +6. **Spike C**: `hb-subset` growth of an existing embedded subset font with a new glyph; verify the + result renders in Acrobat, Preview, and Chrome. +7. **Spike D**: our own Skia display-list renderer vs PDFium on 500 corpus pages. Report pixel + divergence — this decides whether the §2.5 predictive tier is viable or whether keystroke latency + needs a different answer. +8. **Spike E**: sandbox bring-up on all three platforms with a trivial parser, including the IPC + layer. Measure the cost of the process split on open latency. Sandboxing is the one thing that is + genuinely painful to retrofit. +9. Design review of §4.4 splice invariants before any production code lands, recorded as an ADR. + +--- + +## 15. Open decisions + +Things this plan deliberately does not settle, with the point at which they must be: + +| Decision | Deadline | Notes | +|---|---|---| +| Rust for L1/L2 leaf decoders, or C++ throughout | End of M0 | §7.2. Decide once; do not revisit at M4. | +| Team size and therefore roadmap shape (§10 vs §10.1) | Before M1 | Changes the milestone order, not just the dates. | +| Qt Widgets vs QML for the shell | End of M1 | Widgets assumed. QML buys animation and mobile-ish polish, costs accessibility maturity and adds a runtime. | +| Unbundle PDFium's vendored libraries, or accept duplicates | M1 | §2.4. | +| Whether "grow the subset from a system font" is legally defensible in all jurisdictions | Before M4 ships | Get an actual opinion; `fsType` is a signal, not a licence. | +| Funding/sustainability model for a GPL-3 desktop app | Before public beta (M3) | Donations, paid support, enterprise-signed builds — pick one before users arrive, not after. | +| Hosted crash/telemetry backend, or none at all | M8 | §7.3 default is "none"; shipping a backend has ongoing privacy obligations. | diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh new file mode 100755 index 0000000..801ab71 --- /dev/null +++ b/packaging/appimage/build-appimage.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# +# AppImage build script (§12.1). Builds a manylinux-style AppImage bundling +# Qt, Skia, ICU and the codecs. The actual bundling uses linuxdeploy + +# linuxdeploy-plugin-qt, run inside the release container; this script is the +# entry point the release pipeline (§12.5) calls. +# +# AppImage cannot use bubblewrap-style user namespaces on distros that restrict +# them, so the sandbox falls back to seccomp-only. The app reports its actual +# sandbox level in About and never silently pretends a stronger level. +set -euo pipefail + +APP="FreePDFEditor" +LOWER="$(echo "$APP" | tr '[:upper:]' '[:lower:]')" +VERSION="${1:?usage: build-appimage.sh }" +BUILD_DIR="${2:?missing build_dir}" +OUTPUT="${3:-${APP}-${VERSION}-x86_64.AppImage}" + +if [[ ! -d "$BUILD_DIR" ]]; then + echo "build dir not found: $BUILD_DIR" >&2 + exit 1 +fi + +if ! command -v linuxdeploy >/dev/null 2>&1; then + echo "linuxdeploy not on PATH — install it (https://github.com/linuxdeploy/linuxdeploy)" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APPDIR="$BUILD_DIR/AppDir" +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/bin" + +# Copy the built binary into the AppDir layout. +cp "$BUILD_DIR/bin/freepdfeditor" "$APPDIR/usr/bin/" + +# Install the desktop file, icons, metainfo into AppDir. +mkdir -p "$APPDIR/usr/share/applications" \ + "$APPDIR/usr/share/icons/hicolor/scalable/apps" \ + "$APPDIR/usr/share/metainfo" +cp "$SCRIPT_DIR/freepdfeditor.desktop" "$APPDIR/usr/share/applications/" +cp "$SCRIPT_DIR/icons/hicolor/scalable/apps/freepdfeditor.svg" \ + "$APPDIR/usr/share/icons/hicolor/scalable/apps/" +cp "$SCRIPT_DIR/org.freepdfeditor.FreePDFEditor.metainfo.xml" \ + "$APPDIR/usr/share/metainfo/" + +# linuxdeploy bundles the runtime + Qt + library deps via the qt plugin. +export OUTPUT="$OUTPUT" +export UPDATE_INFORMATION="zsync|https://download.freepdfeditor.org/nightly/$LOWER-x86_64.AppImage.zsync" +export LINUXDEPLOY_OUTPUT_VERSION="$VERSION" +linuxdeploy --appdir "$APPDIR" --desktop-file \ + "$APPDIR/usr/share/applications/freepdfeditor.desktop" \ + --icon-file "$APPDIR/usr/share/icons/hicolor/scalable/apps/freepdfeditor.svg" \ + --output appimage + +echo "Built $OUTPUT" \ No newline at end of file diff --git a/packaging/linux/freepdfeditor.1 b/packaging/linux/freepdfeditor.1 new file mode 100644 index 0000000..ba9882f --- /dev/null +++ b/packaging/linux/freepdfeditor.1 @@ -0,0 +1,38 @@ +.\" SPDX-License-Identifier: GPL-3.0-or-later +.\" SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +.TH FREEPDFEDITOR 1 "2025-07-25" "FreePDFEditor 0.1.0" "User Commands" +.SH NAME +freepdfeditor \- cross-platform native desktop PDF editor +.SH SYNOPSIS +.B freepdfeditor +.RI [ file ... ] +.SH DESCRIPTION +FreePDFEditor is a native desktop PDF editor with a true WYSIWYG interface and +full content editing: edit existing text with reflow, replace images, +manipulate vector objects, plus annotations, forms, signatures, and page +assembly. +.PP +Unlike overlay-style editors it parses each page's content stream into a +semantic model, lets the user manipulate that model directly, and re-emits +the content stream on save, so the output remains searchable, accessible, +and copy-pasteable. +.SH OPTIONS +.TP +.BR \-h , " \-\-help" +Show command-line help and exit. +.TP +.BR \-v , " \-\-version" +Print the application version and exit. +.SH FILES +.TP +.I file +An optional PDF to open at startup. +.SH ENVIRONMENT +The application honours standard Qt environment variables (QT_QPA_PLATFORM, +QT_SCALE_FACTOR, etc.) and the platform's XDG directories for configuration +and cache storage. +.SH BUGS +Report bugs at . +.SH SEE ALSO +The full engineering plan ships with the source under +.IR docs/plan.md . \ No newline at end of file diff --git a/packaging/linux/freepdfeditor.bash-completion b/packaging/linux/freepdfeditor.bash-completion new file mode 100644 index 0000000..cedd89e --- /dev/null +++ b/packaging/linux/freepdfeditor.bash-completion @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# bash completion for freepdfeditor + +_freepdfeditor_completion() { + local cur prev words cword + _init_completion || return + + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W '--help --version' -- "$cur")) + return + fi + + # Filename completion, restricted to PDFs by convention. + _filedir pdf +} + +complete -F _freepdfeditor_completion freepdfeditor \ No newline at end of file diff --git a/packaging/linux/freepdfeditor.desktop b/packaging/linux/freepdfeditor.desktop new file mode 100644 index 0000000..5cfd6ad --- /dev/null +++ b/packaging/linux/freepdfeditor.desktop @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +[Desktop Entry] +Type=Application +Name=FreePDFEditor +GenericName=PDF Editor +Comment=Edit text, images, vectors, annotations and forms in PDF documents +Comment[de]=Text, Bilder, Vektoren, Anmerkungen und Formulare in PDF-Dokumenten bearbeiten +Exec=freepdfeditor %U +Icon=freepdfeditor +Terminal=false +Categories=Graphics;Office;Viewer; +Keywords=pdf;editor;annotate;form;sign; +MimeType=application/pdf; +StartupNotify=true +StartupWMClass=FreePDFEditor \ No newline at end of file diff --git a/packaging/linux/icons/hicolor/scalable/apps/freepdfeditor.svg b/packaging/linux/icons/hicolor/scalable/apps/freepdfeditor.svg new file mode 100644 index 0000000..5c33245 --- /dev/null +++ b/packaging/linux/icons/hicolor/scalable/apps/freepdfeditor.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packaging/linux/org.freepdfeditor.FreePDFEditor.metainfo.xml b/packaging/linux/org.freepdfeditor.FreePDFEditor.metainfo.xml new file mode 100644 index 0000000..156f349 --- /dev/null +++ b/packaging/linux/org.freepdfeditor.FreePDFEditor.metainfo.xml @@ -0,0 +1,40 @@ + + + + org.freepdfeditor.FreePDFEditor + CC0-1.0 + GPL-3.0-or-later + FreePDFEditor + Cross-platform native desktop PDF editor with full content editing + +

+ FreePDFEditor is a native desktop PDF editor that edits existing text with + reflow, replaces images, manipulates vector objects, and adds annotations, + forms, signatures, and page assembly. Unlike overlay-style editors it + parses the content stream into a semantic model and re-emits it on save, + so the output remains searchable, accessible and copy-pasteable. +

+
+ freepdfeditor.desktop + https://gitea.lm.je/ai-ad4/freepdfeditor + https://gitea.lm.je/ai-ad4/freepdfeditor/issues + https://gitea.lm.je/ai-ad4/freepdfeditor/src/branch/main/docs/plan.md + + freepdfeditor + + + +

Pre-M0 repository scaffold.

+
+
+ + none + none + + + 800 + +
\ No newline at end of file diff --git a/reuse.toml b/reuse.toml new file mode 100644 index 0000000..f7ff0c4 --- /dev/null +++ b/reuse.toml @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# REUSE configuration. The repository is REUSE-compliant (§13.4): every file +# carries SPDX metadata either inline (source) or via .reuse/dep5 (config, +# data, assets). `reuse lint` is a CI gate. + +version: 1.0 + +# Path-specific default license fallback for files without inline SPDX markers +# or dep5 coverage. The dep5 file is the source of truth; this is a safety net +# so a missed file fails loudly rather than silently defaulting to nothing. +defaults: + - path: . + license: GPL-3.0-or-later + copyright: "2025 ai-ad4 and the FreePDFEditor contributors" \ No newline at end of file diff --git a/spike/A_verbatim_roundtrip/Roundtrip.cpp b/spike/A_verbatim_roundtrip/Roundtrip.cpp new file mode 100644 index 0000000..ab87993 --- /dev/null +++ b/spike/A_verbatim_roundtrip/Roundtrip.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors + +#include "Roundtrip.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace freepdfeditor::spike::a { + +namespace { + +// Read a whole file into a string. QPDF can read from disk directly, but for +// the round-trip we want to control the bytes that go in (and out) so the +// byte-equality check is against the original input, not a re-read. +bool read_file(const std::filesystem::path& p, std::string& out) +{ + std::ifstream in(p, std::ios::binary); + if (!in) return false; + std::ostringstream ss; + ss << in.rdbuf(); + out = ss.str(); + return true; +} + +} // namespace + +FileResult roundtrip(const std::filesystem::path& input, + const std::filesystem::path& output) +{ + FileResult r{Outcome::Failed, 0, {}}; + + std::string original; + if (!read_file(input, original)) { + r.error = "could not read input"; + return r; + } + + QPDF q; + try { + q.processInputFile(input.string()); + } catch (const std::exception& e) { + r.error = std::string("QPDF open failed: ") + e.what(); + return r; + } + + // Exercise the content-stream parse path: walk every page and force-decode + // its content stream(s). This is what proves "parse content streams" rather + // than just "QPDF rewrites the file". A page whose content we can't decode + // is reported as a structural success anyway — the gate is byte-identity, + // and QPDF preserves even streams it can't decode by copying them verbatim. + try { + QPDFPageDocumentHelper helper(q); + auto pages = helper.getAllPages(); + r.pages = pages.size(); + for (auto& page : pages) { + QPDFObjectHandle content = page.getAttribute("/Contents", true); + if (content.isArray()) { + auto items = content.getArrayAsArray(); + for (auto& item : items) { + if (item.isStream()) { + (void)item.getStreamData(); // force decode + } + } + } else if (content.isStream()) { + (void)content.getStreamData(); + } + } + } catch (const std::exception& e) { + // A page-walk failure is a structural defect worth surfacing, but the + // round-trip gate is still meaningful — record it in the error field + // without failing the whole round-trip. + r.error = std::string("page walk warning: ") + e.what(); + } + + try { + QPDFWriter w(q); + w.setOutputFile(output.string()); + // Preserve as much as possible: write with the same object stream + // strategy and linearisation state as the input so byte-identity is a + // realistic target. The defaults preserve the structure verbatim where + // QPDF can. + w.setPreserveEncryption(true); + w.setQDF(false); + w.setLinearization(false); + w.write(); + } catch (const std::exception& e) { + r.error = std::string("QPDF write failed: ") + e.what(); + r.outcome = Outcome::Failed; + return r; + } + + // Compare bytes. + std::string written; + if (!read_file(output, written)) { + r.error = "could not read back output"; + r.outcome = Outcome::Failed; + return r; + } + + if (original.size() == written.size() && + std::memcmp(original.data(), written.data(), original.size()) == 0) { + r.outcome = Outcome::ByteIdentical; + r.error.clear(); + } else { + r.outcome = Outcome::StructurallySame; + } + return r; +} + +} // namespace freepdfeditor::spike::a \ No newline at end of file diff --git a/spike/A_verbatim_roundtrip/Roundtrip.h b/spike/A_verbatim_roundtrip/Roundtrip.h new file mode 100644 index 0000000..1a10a11 --- /dev/null +++ b/spike/A_verbatim_roundtrip/Roundtrip.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +// +// Roundtrip — the core of Spike A. Given a PDF on disk, open it with QPDF, +// ask QPDF to write it back to a fresh file with the same structure, and +// report whether the round-trip is byte-identical. Per the plan §14 step 4: +// +// "Spike A: QPDF-based open → parse content streams → re-emit verbatim → save. +// Measure byte-identical round-trip rate over 500 corpus files. Target ≥ 99%." +// +// The "parse content streams" step is what later spikes build on; here we walk +// every page's content stream(s) through QPDF's stream decoder/encoder and +// verify the object graph is structurally preserved, which is the precondition +// for the surgical re-emission in §4.4 to be lossless. + +#ifndef FREEPDFEDITOR_SPIKE_A_ROUNDTRIP_H +#define FREEPDFEDITOR_SPIKE_A_ROUNDTRIP_H + +#include +#include +#include + +namespace freepdfeditor::spike::a { + +enum class Outcome { + ByteIdentical, // input == output byte-for-byte + StructurallySame, // QPDF wrote a different byte stream with identical semantics + Failed, // could not open / could not write +}; + +struct FileResult { + Outcome outcome; + std::size_t pages = 0; + std::string error; // populated when outcome == Failed +}; + +// Round-trip `input` through QPDF, writing to `output`. `output` must not +// already exist. Walks every page's content stream(s) via +// qpdf_objecthandle to prove the parse path is exercised, then writes the +// document back preserving object order. +FileResult roundtrip(const std::filesystem::path& input, + const std::filesystem::path& output); + +} // namespace freepdfeditor::spike::a + +#endif // FREEPDFEDITOR_SPIKE_A_ROUNDTRIP_H \ No newline at end of file diff --git a/spike/A_verbatim_roundtrip/main.cpp b/spike/A_verbatim_roundtrip/main.cpp new file mode 100644 index 0000000..01b2d40 --- /dev/null +++ b/spike/A_verbatim_roundtrip/main.cpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +// +// Spike A main: walks a corpus directory, runs the QPDF verbatim round-trip on +// every PDF, and emits a JSON report on stdout. Exit code 0 if the +// byte-identical rate meets the §14 target (≥ 99%), 1 otherwise. +// +// Usage: +// spike_a_verbatim_roundtrip [target_rate] +// +// Recursively globs `/**/*.pdf`. Output PDFs go into a sibling +// temp directory next to the executable; nothing in the corpus is mutated. + +#include "Roundtrip.h" +#include "common/SpikeRunner.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::vector collect_pdfs(const fs::path& root, std::size_t cap) +{ + std::vector out; + std::error_code ec; + if (!fs::exists(root, ec)) return out; + for (auto& entry : fs::recursive_directory_iterator(root, + fs::directory_options::skip_permission_denied, ec)) { + if (ec) { ec.clear(); continue; } + if (entry.is_regular_file(ec) && + entry.path().extension() == ".pdf") { + out.push_back(entry.path()); + if (out.size() >= cap) break; + } + } + return out; +} + +fs::path make_temp_dir() +{ + auto tmp = fs::temp_directory_path(); + std::random_device rd; + std::mt19937_64 gen(rd()); + auto suffix = std::to_string(gen()); + auto p = tmp / ("freepdfeditor-spikeA-" + suffix); + fs::create_directories(p); + return p; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc < 2) { + std::fprintf(stderr, + "usage: %s [target_rate_default=0.99]\n", argv[0]); + return 2; + } + fs::path corpus = argv[1]; + double target = (argc >= 3) ? std::stod(argv[2]) : 0.99; + + auto pdfs = collect_pdfs(corpus, 100'000); + if (pdfs.empty()) { + freepdfeditor::spike::SpikeResult r{}; + r.spike = "A"; + r.name = "QPDF verbatim round-trip"; + r.metric_name = "byte_identical_rate"; + r.target = target; + r.gate_met = false; + r.notes = "no PDFs found in corpus directory"; + return freepdfeditor::spike::emit_json_report(r); + } + + auto outdir = make_temp_dir(); + std::size_t byte_identical = 0; + std::size_t structurally_same = 0; + std::size_t failed = 0; + std::vector failed_samples; + + for (std::size_t i = 0; i < pdfs.size(); ++i) { + const auto& in = pdfs[i]; + fs::path out = outdir / (std::to_string(i) + ".pdf"); + fs::remove(out); + auto fr = freepdfeditor::spike::a::roundtrip(in, out); + switch (fr.outcome) { + case freepdfeditor::spike::a::Outcome::ByteIdentical: + ++byte_identical; + break; + case freepdfeditor::spike::a::Outcome::StructurallySame: + ++structurally_same; + break; + case freepdfeditor::spike::a::Outcome::Failed: + ++failed; + if (failed_samples.size() < 16) { + failed_samples.push_back(in.filename().string()); + } + break; + } + fs::remove(out); + } + + std::error_code ec; + fs::remove_all(outdir, ec); + + double rate = static_cast(byte_identical) / + static_cast(pdfs.size()); + // The §14 gate is byte-identity. Structural-same is interesting for the + // report but does not pass the gate; we surface it in notes. + bool gate = rate >= target; + + freepdfeditor::spike::SpikeResult r{}; + r.spike = "A"; + r.name = "QPDF verbatim round-trip"; + r.total = pdfs.size(); + r.passed = byte_identical; + r.failed = structurally_same; + r.errored = failed; + r.metric_name = "byte_identical_rate"; + r.metric_value = rate; + r.target = target; + r.gate_met = gate; + r.failed_samples = failed_samples; + r.notes = "structurally_same files (QPDF rewrote, semantics preserved) count " + "as not-byte-identical for this gate; they are tracked in `failed`"; + return freepdfeditor::spike::emit_json_report(r); +} \ No newline at end of file diff --git a/spike/CMakeLists.txt b/spike/CMakeLists.txt new file mode 100644 index 0000000..a04b5ec --- /dev/null +++ b/spike/CMakeLists.txt @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# spike/CMakeLists.txt +# +# M0 feasibility spikes (§14). Each spike is its own executable so it can be run +# independently and its results reported separately by CI. The spikes link only +# against the leaf libraries they exercise (QPDF here), not against Qt. + +find_package(QPDF CONFIG QUIET) + +if(NOT QPDF_FOUND) + message(STATUS + "QPDF not found — Spike A (verbatim round-trip) will not be built. " + "Install qpdf (vcpkg, system package, or from source) to enable it.") + return() +endif() + +add_executable(spike_a_verbatim_roundtrip + common/SpikeRunner.cpp + common/SpikeRunner.h + A_verbatim_roundtrip/main.cpp + A_verbatim_roundtrip/Roundtrip.cpp + A_verbatim_roundtrip/Roundtrip.h +) +target_link_libraries(spike_a_verbatim_roundtrip PRIVATE QPDF::qpdf) +target_compile_features(spike_a_verbatim_roundtrip PRIVATE cxx_std_20) +freepdfeditor_apply_warnings(spike_a_verbatim_roundtrip) +freepdfeditor_apply_hardening(spike_a_verbatim_roundtrip) + +# Spike C (hb-subset growth of an existing subset font) is left as a placeholder +# until HarfBuzz is wired in; it is exercised from a standalone harness in CI. +if(FALSE) + find_package(harfbuzz CONFIG QUIET) + if(harfbuzz_FOUND) + add_executable(spike_c_subset_growth + common/SpikeRunner.cpp + common/SpikeRunner.h + C_subset_growth/main.cpp + ) + target_link_libraries(spike_c_subset_growth PRIVATE harfbuzz::harfbuzz harfbuzz::harfbuzz-subset) + endif() +endif() \ No newline at end of file diff --git a/spike/common/SpikeRunner.cpp b/spike/common/SpikeRunner.cpp new file mode 100644 index 0000000..876499a --- /dev/null +++ b/spike/common/SpikeRunner.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors + +#include "SpikeRunner.h" + +#include +#include +#include + +namespace freepdfeditor::spike { + +namespace { + +// Minimal hand-rolled JSON string escaper — avoids pulling a JSON lib into the +// spike harness, which must stay dependency-light so it builds first. +std::string esc(std::string s) +{ + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out += c; + } + } + } + return out; +} + +} // namespace + +int emit_json_report(const SpikeResult& r) +{ + std::string samples; + for (std::size_t i = 0; i < r.failed_samples.size(); ++i) { + if (i) samples += ","; + samples += "\"" + esc(r.failed_samples[i]) + "\""; + } + std::printf( + "{\"spike\":\"%s\",\"name\":\"%s\",\"total\":%zu,\"passed\":%zu," + "\"failed\":%zu,\"errored\":%zu,\"metric\":\"%s\",\"value\":%.6f," + "\"target\":%.6f,\"gate_met\":%s,\"samples\":[%s],\"notes\":\"%s\"}\n", + r.spike.c_str(), r.name.c_str(), + r.total, r.passed, r.failed, r.errored, + r.metric_name.c_str(), r.metric_value, r.target, + r.gate_met ? "true" : "false", + samples.c_str(), esc(r.notes).c_str()); + std::fflush(stdout); + return r.gate_met ? 0 : 1; +} + +} // namespace freepdfeditor::spike \ No newline at end of file diff --git a/spike/common/SpikeRunner.h b/spike/common/SpikeRunner.h new file mode 100644 index 0000000..0d8bb99 --- /dev/null +++ b/spike/common/SpikeRunner.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +// +// SpikeRunner — tiny helper that each Spike A/E main() uses to emit a +// machine-readable JSON report and a process exit code matching the spike's +// acceptance gate. Keeping this in one place means CI just parses one format. + +#ifndef FREEPDFEDITOR_SPIKE_COMMON_SPIKERUNNER_H +#define FREEPDFEDITOR_SPIKE_COMMON_SPIKERUNNER_H + +#include +#include +#include + +namespace freepdfeditor::spike { + +struct SpikeResult { + std::string spike; // "A", "B", ... + std::string name; // human-readable + std::size_t total = 0; // files/pages examined + std::size_t passed = 0; // met the gate + std::size_t failed = 0; + std::size_t errored = 0; // crashed / could not process + std::vector failed_samples; // up to N sample ids for triage + std::string metric_name; // e.g. "byte_identical_rate", "F1" + double metric_value = 0.0; // aggregate metric + double target = 0.0; // gate threshold + bool gate_met = false; + std::string notes; +}; + +// Serialise `result` as a single-line JSON document on stdout, then return the +// process exit code (0 if gate_met, 1 otherwise). CI parses this line. +int emit_json_report(const SpikeResult& result); + +} // namespace freepdfeditor::spike + +#endif // FREEPDFEDITOR_SPIKE_COMMON_SPIKERUNNER_H \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..6aad96d --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# src/CMakeLists.txt — application sources. +# +# At this stage (pre-M1) the only target is the empty-window application shell +# described in §14 step 2: a Qt Widgets main window with the chrome layout from +# §9 so the packaging pipeline and the platform integrations have something to +# sign, install, and notarize from day one. The sandboxed document process and +# the canvas land in M1/M2. + +# Locate Qt. The vcpkg manifest does not currently include Qt (it is huge and +# often built system-wide); we use find_package so a system Qt or a vcpkg-built +# Qt both work. +find_package(Qt6 6.7 COMPONENTS Core Gui Widgets Network QUIET) + +if(NOT Qt6_FOUND) + message(WARNING + "Qt6 not found — the application target 'freepdfeditor' will not be built.\n" + "Install Qt 6.7+ (system or via vcpkg) to build the GUI. The QPDF-based\n" + "spikes and the pixel-diff harness do not require Qt and still build.") + return() +endif() + +qt_standard_project_setup() + +add_executable(freepdfeditor + app/main.cpp + app/MainWindow.cpp + app/MainWindow.h +) + +target_compile_features(freepdfeditor PRIVATE cxx_std_20) +target_link_libraries(freepdfeditor PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network) + +freepdfeditor_apply_warnings(freepdfeditor) +freepdfeditor_apply_hardening(freepdfeditor) + +# --- macOS bundle ----------------------------------------------------------- +set_target_properties(freepdfeditor PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "FreePDFEditor" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}" + MACOSX_BUNDLE_GUI_IDENTIFIER "org.freepdfeditor.FreePDFEditor" + MACOSX_BUNDLE_COPYRIGHT "© 2025 ai-ad4 and contributors, GPL-3.0-or-later" + WIN32_EXECUTABLE TRUE +) + +# --- install rules (drive CPack) ------------------------------------------- +include(GNUInstallDirs) +install(TARGETS freepdfeditor + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + BUNDLE DESTINATION .) + +# Linux .desktop entry, hicolor icons, AppStream metainfo, man page, completions +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + install(FILES "${CMAKE_SOURCE_DIR}/packaging/linux/freepdfeditor.desktop" + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications") + install(DIRECTORY "${CMAKE_SOURCE_DIR}/packaging/linux/icons/" + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor") + install(FILES "${CMAKE_SOURCE_DIR}/packaging/linux/org.freepdfeditor.FreePDFEditor.metainfo.xml" + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/metainfo") + install(FILES "${CMAKE_SOURCE_DIR}/packaging/linux/freepdfeditor.1" + DESTINATION "${CMAKE_INSTALL_MANDIR}/man1") + install(FILES "${CMAKE_SOURCE_DIR}/packaging/linux/freepdfeditor.bash-completion" + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/bash-completion/completions" + RENAME "freepdfeditor") +endif() \ No newline at end of file diff --git a/src/app/MainWindow.cpp b/src/app/MainWindow.cpp new file mode 100644 index 0000000..b9530eb --- /dev/null +++ b/src/app/MainWindow.cpp @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors + +#include "MainWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace FreePDFEditor { + +MainWindow::MainWindow(QWidget* parent) + : QMainWindow(parent) +{ + setMinimumSize(QSize(1024, 720)); + setWindowTitle(tr("FreePDFEditor")); + + buildCentralChrome(); + buildMenus(); + buildStatusBar(); +} + +void MainWindow::buildCentralChrome() +{ + // Per §9: tool palette | canvas | inspector. The canvas and panels are + // placeholders for M1; the structure is here so the chrome is testable. + auto* host = new QWidget(this); + auto* layout = new QVBoxLayout(host); + layout->setContentsMargins(0, 0, 0, 0); + + auto* placeholder = new QLabel(host); + placeholder->setAlignment(Qt::AlignCenter); + placeholder->setText( + tr("

FreePDFEditor — pre-M0 scaffold

" + "

The viewer, sandbox, canvas and editing tools land in M1–M6.

" + "

See docs/plan.md for the roadmap.

")); + placeholder->setStyleSheet("color: palette(window-text);"); + layout->addWidget(placeholder, 1); + + setCentralWidget(host); +} + +void MainWindow::buildMenus() +{ + // File + auto* fileMenu = menuBar()->addMenu(tr("&File")); + + auto* openAct = fileMenu->addAction( + QIcon::fromTheme(QStringLiteral("document-open")), tr("&Open…")); + openAct->setShortcut(QKeySequence::Open); + openAct->setEnabled(false); // M1 viewer + fileMenu->addAction(tr("Open Recent"))->setEnabled(false); + + fileMenu->addSeparator(); + auto* saveAct = fileMenu->addAction( + QIcon::fromTheme(QStringLiteral("document-save")), tr("&Save")); + saveAct->setShortcut(QKeySequence::Save); + saveAct->setEnabled(false); + + auto* saveAsAct = fileMenu->addAction( + QIcon::fromTheme(QStringLiteral("document-save-as")), tr("Save &As…")); + saveAsAct->setShortcut(QKeySequence::SaveAs); + saveAsAct->setEnabled(false); + + fileMenu->addSeparator(); + auto* printAct = fileMenu->addAction( + QIcon::fromTheme(QStringLiteral("document-print")), tr("&Print…")); + printAct->setShortcut(QKeySequence::Print); + printAct->setEnabled(false); + + fileMenu->addSeparator(); + auto* quitAct = fileMenu->addAction( + QIcon::fromTheme(QStringLiteral("application-exit")), tr("&Quit")); + quitAct->setShortcut(QKeySequence::Quit); + connect(quitAct, &QAction::triggered, this, &QWidget::close); + + // Edit + auto* editMenu = menuBar()->addMenu(tr("&Edit")); + for (auto role : {QKeySequence::Undo, QKeySequence::Redo, + QKeySequence::Cut, QKeySequence::Copy, QKeySequence::Paste}) { + auto* a = editMenu->addAction(QKeySequence(role).toString()); + a->setEnabled(false); + } + + // View + auto* viewMenu = menuBar()->addMenu(tr("&View")); + viewMenu->addAction(tr("Zoom In"), QKeySequence::ZoomIn)->setEnabled(false); + viewMenu->addAction(tr("Zoom Out"), QKeySequence::ZoomOut)->setEnabled(false); + viewMenu->addAction(tr("Fit to Width"))->setEnabled(false); + + // Tools + auto* toolsMenu = menuBar()->addMenu(tr("&Tools")); + for (const char* name : {"Select", "Text Edit", "Annotate", "Redact", + "Form Field", "Shape", "Node Edit"}) { + toolsMenu->addAction(tr(name))->setEnabled(false); + } + + // Help + auto* helpMenu = menuBar()->addMenu(tr("&Help")); + helpMenu->addAction(tr("About FreePDFEditor"), this, [this] { + // Minimal about; real About lands with the version+license panel. + }); + helpMenu->addAction(tr("Report a Bug…"))->setEnabled(false); +} + +void MainWindow::buildStatusBar() +{ + m_statusLabel = new QLabel(statusBar()); + m_statusLabel->setStyleSheet("padding: 2px 6px;"); + statusBar()->addWidget(m_statusLabel, 1); + showStatus(tr("Ready")); +} + +void MainWindow::showStatus(const QString& text) +{ + if (m_statusLabel) { + m_statusLabel->setText(text); + } +} + +void MainWindow::closeEvent(QCloseEvent* event) +{ + // M2 will gate this on unsaved changes + journal flush. + QMainWindow::closeEvent(event); +} + +} // namespace FreePDFEditor \ No newline at end of file diff --git a/src/app/MainWindow.h b/src/app/MainWindow.h new file mode 100644 index 0000000..5922390 --- /dev/null +++ b/src/app/MainWindow.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +// +// MainWindow — the application shell chrome. Layout follows §9 of the plan: +// tool palette / page canvas / inspector, with menu bar, search and status bar. +// This is the empty-window scaffolding: real canvas, panels, and tools land in +// M1–M6. + +#ifndef FREEPDFEDITOR_APP_MAINWINDOW_H +#define FREEPDFEDITOR_APP_MAINWINDOW_H + +#include + +class QLabel; +class QStackedWidget; + +namespace FreePDFEditor { + +class MainWindow : public QMainWindow +{ + Q_OBJECT +public: + explicit MainWindow(QWidget* parent = nullptr); + + // Set the text in the transient status area (used during the scaffold + // phase before the real status bar logic from §9 lands). + void showStatus(const QString& text); + +protected: + void closeEvent(QCloseEvent* event) override; + +private: + void buildMenus(); + void buildCentralChrome(); + void buildStatusBar(); + + QLabel* m_statusLabel = nullptr; +}; + +} // namespace FreePDFEditor + +#endif // FREEPDFEDITOR_APP_MAINWINDOW_H \ No newline at end of file diff --git a/src/app/main.cpp b/src/app/main.cpp new file mode 100644 index 0000000..d2ab174 --- /dev/null +++ b/src/app/main.cpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +// +// Entry point for the FreePDFEditor UI process. The process model (§2.1) keeps +// all untrusted parsing out of this process; the sandboxed document process is +// spawned on demand once a document is opened. At this scaffolding stage no +// document process exists yet, so this is just the empty window shell. + +#include "MainWindow.h" + +#include +#include + +int main(int argc, char* argv[]) +{ + QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); + QApplication app(argc, argv); + app.setOrganizationName("FreePDFEditor"); + app.setApplicationName("FreePDFEditor"); + app.setApplicationDisplayName("FreePDFEditor"); + app.setApplicationVersion(QStringLiteral(FREEPDFEDITOR_VERSION_STRING)); + app.setWindowIcon(QIcon(QStringLiteral(":/icons/freepdfeditor.svg"))); + + QCommandLineParser parser; + parser.setApplicationDescription( + QApplication::translate("main", "Cross-platform native desktop PDF editor.")); + parser.addHelpOption(); + parser.addVersionOption(); + parser.addPositionalArgument( + QStringLiteral("file"), + QApplication::translate("main", "Optional PDF file to open at startup.")); + parser.process(app); + + FreePDFEditor::MainWindow window; + window.show(); + + const QStringList args = parser.positionalArguments(); + if (!args.isEmpty()) { + // No document process yet; the M1 viewer will wire this up. + window.showStatus(QApplication::translate( + "main", "Opening documents is not implemented until M1.")); + } + Q_UNUSED(args); + + return app.exec(); +} \ No newline at end of file diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..71cb71b --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +# test/CMakeLists.txt +# +# Unit and gate tests. The M0 scaffold ships a single smoke test that proves +# the build produces a runnable artefact and the spike runner emits the +# contract JSON the CI pipeline parses. As the layers land (L1–L6), each gets +# its own test executable under here, all registered with CTest. + +# We do not pull GoogleTest yet — the first tests are plain freestanding mains +# returning non-zero on failure, which keeps the M0 scaffold dependency-free +# and lets CI on all three platforms run ctest without a vcpkg build of gtest. +# A later milestone swaps in GTest once there is real C++ logic to assert on. + +add_executable(test_spike_runner_contract test_spike_runner_contract.cpp) +target_compile_features(test_spike_runner_contract PRIVATE cxx_std_20) +freepdfeditor_apply_warnings(test_spike_runner_contract) +freepdfeditor_apply_hardening(test_spike_runner_contract) +add_test(NAME spike_runner_contract COMMAND test_spike_runner_contract) + +# Pixel-diff harness scaffolding (§8.2 gate: render vs Ghostscript+PDFium +# reference). The actual comparison runs against a corpus that lives in the +# separate freepdfeditor-corpus repo (§13.2 rule 5); this scaffold is a +# self-contained Python script that CI invokes with paths to the corpus and +# the reference binaries. +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../ci/pixel-diff/run_pixel_diff.py" + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/freepdfeditor/ci") \ No newline at end of file diff --git a/test/test_spike_runner_contract.cpp b/test/test_spike_runner_contract.cpp new file mode 100644 index 0000000..4104e08 --- /dev/null +++ b/test/test_spike_runner_contract.cpp @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +// +// Smoke test for the spike-runner JSON contract. The CI pipeline parses the +// single-line JSON that emit_json_report produces; if that format drifts the +// whole gate matrix goes dark. This test pins the format without depending on +// QPDF, so it runs on every platform even before the spike builds. + +#include "../spike/common/SpikeRunner.h" + +#include +#include +#include + +namespace { + +// Capture stdout during emit_json_report by writing to a captured buffer — +// emit_json_report writes to real stdout, so we redirect via freopen. This is +// crude but avoids pulling a dependency into the scaffold test. +std::string capture(freepdfeditor::spike::SpikeResult r) +{ + std::fflush(stdout); + FILE* saved = stdout; + stdout = std::tmpfile(); + if (!stdout) { stdout = saved; return {}; } + int rc = freepdfeditor::spike::emit_json_report(r); + std::fflush(stdout); + std::string buf; + std::rewind(stdout); + int c; + while ((c = std::fgetc(stdout)) != EOF) buf.push_back(static_cast(c)); + std::fclose(stdout); + stdout = saved; + (void)rc; + return buf; +} + +} // namespace + +int main() +{ + using namespace freepdfeditor::spike; + + SpikeResult r{}; + r.spike = "A"; + r.name = "test"; + r.total = 10; + r.passed = 9; + r.failed = 1; + r.errored = 0; + r.metric_name = "byte_identical_rate"; + r.metric_value = 0.9; + r.target = 0.99; + r.gate_met = false; + r.failed_samples = {"bad.pdf"}; + r.notes = "hello \"world\"\nline2"; + + std::string out = capture(r); + + int failures = 0; + auto assert_contains = [&](const std::string& needle) { + if (out.find(needle) == std::string::npos) { + std::fprintf(stderr, "FAIL: output missing %s\n", needle.c_str()); + std::fprintf(stderr, "output was: %s\n", out.c_str()); + ++failures; + } + }; + + assert_contains("\"spike\":\"A\""); + assert_contains("\"name\":\"test\""); + assert_contains("\"total\":10"); + assert_contains("\"passed\":9"); + assert_contains("\"failed\":1"); + assert_contains("\"metric\":\"byte_identical_rate\""); + assert_contains("\"value\":0.900000"); + assert_contains("\"target\":0.990000"); + assert_contains("\"gate_met\":false"); + assert_contains("\"samples\":[\"bad.pdf\"]"); + assert_contains("\"notes\":\"hello \\\"world\\\"\\nline2\""); + // Single line, terminated by newline. + if (out.empty() || out.back() != '\n') { + std::fprintf(stderr, "FAIL: output not newline-terminated\n"); + ++failures; + } + if (out.find('\n') != out.size() - 1) { + std::fprintf(stderr, "FAIL: output contains embedded newline\n"); + ++failures; + } + + // Gate-met returns 0; not-met returns 1. Test the contract directly. + r.gate_met = true; + if (freepdfeditor::spike::emit_json_report(r) != 0) { + std::fprintf(stderr, "FAIL: gate_met=true should return 0\n"); + ++failures; + } + r.gate_met = false; + if (freepdfeditor::spike::emit_json_report(r) != 1) { + std::fprintf(stderr, "FAIL: gate_met=false should return 1\n"); + ++failures; + } + + return failures == 0 ? 0 : 1; +} \ No newline at end of file diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json new file mode 100644 index 0000000..f8a1b01 --- /dev/null +++ b/vcpkg-configuration.json @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg-configuration.schema.json", + "default-registry": { + "kind": "git", + "repository": "https://github.com/microsoft/vcpkg.git", + "ref": "master", + "baseline": "c4467224f8a384b7d52cb7d5e8abfb3f3f463f17" + }, + "overlay-ports": ["${sourceDir}/cmake/vcpkg-ports"] +} \ No newline at end of file diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..41ca426 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "name": "freepdfeditor", + "version": "0.1.0", + "description": "A cross-platform native desktop PDF editor with full content editing.", + "homepage": "https://gitea.lm.je/ai-ad4/freepdfeditor", + "license": "GPL-3.0-or-later", + "supports": "(windows & x64) | (windows & arm64) | (osx & x64) | (osx & arm64) | (linux & x64) | (linux & arm64)", + "dependencies": [ + { "name": "qpdf", "version>=": "11.9.0" }, + { "name": "freetype", "version>=": "2.13.2" }, + { "name": "harfbuzz", "version>=": "8.4.0", "features": ["subset"] }, + { "name": "icu", "version>=": "74.2" }, + { "name": "lcms", "version>=": "2.16" }, + { "name": "libjpeg-turbo", "version>=": "3.0.0" }, + { "name": "libpng", "version>=": "1.6.43" }, + { "name": "openjpeg", "version>=": "2.5.2" }, + { "name": "tiff", "version>=": "4.6.0" }, + { "name": "zlib-ng", "version>=": "2.1.5" }, + { "name": "brotli", "version>=": "1.1.0" }, + { "name": "liblzma", "version>=": "5.6.0" }, + { "name": "openssl", "version>=": "3.2.1" }, + { "name": "hunspell", "version>=": "1.7.2" } + ], + "features": { + "tesseract": { + "description": "OCR support (Tier 2 feature, off by default for 1.0 builds).", + "dependencies": [ + { "name": "tesseract", "version>=": "5.3.4" }, + { "name": "leptonica", "version>=": "1.84.1" } + ] + } + } +} \ No newline at end of file