# compare-pdf Implementation Plan

## Context

`compare-pdf` is at v1.1.8 with 7 open GitHub issues, no CI, and no activity since 2022. The `compare-pdf-v2-plan.docx` (authored by Marc Dacanay, April 2026) proposes a structured v2 roadmap. This plan validates that document against the actual codebase, corrects its errors, and produces a concrete, sequenced task list.

**What was already done before this plan (via recent merged PRs):**
- `canvas` upgraded to v3 (PR #50) — fixed #35, #42 (Node v19+/Apple Silicon install failures)
- Issue #27 (single-page diff crash) already fixed in `functions/compareImages.js:100` (PR #28)
- `diffColor`, `diffColorAlt`, `outputPngDifferences` config options added (PR #41) with partial type support
- `STANDARD_FONT_DATA_URL` path partially fixed (PR #44) — `CMAP_URL` still fragile
- Prettier already set up (PR #43) — `"format"` script + `prettier` devDep
- All 48 tests pass on both engines

**GitHub issues that can be closed now:** #27, #35, #42

---

## Corrections to the docx Plan

| Claim in docx | Reality |
|---|---|
| R3: Issue #27 "waiting since Oct 2022, one-line fix" | **Already fixed** in `compareImages.js:100`. Issues #27, #35, #42 can all be closed. |
| R2: `{ imageMagick: '7+' }` is not a valid gm API | **Is valid.** `gm@1.25.1` handles `case '7+': bin = 'magick'` at `node_modules/gm/lib/command.js:215`. |
| Bump gm to v1.25.0+ | Already at 1.25.1. No bump needed. |
| R4: `compare()` return type is broken | `types/comparePdf.d.ts:107` already declares `Promise<Results>`. Actual type bugs are different — see Task 2.3. |
| Proposed defaults: density 100→150, quality 70→90 | **Do not change.** Changing density silently invalidates every user's stored baseline PNGs. Keep defaults; document as options. |
| R4: Full TypeScript source rewrite in v2.0.0 | **Defer indefinitely.** Declaration file bugs are fixable in the `.d.ts` alone. Full rewrite requires a build step and risks regressions. |
| canvas as optionalDependency — CMAP path fragility | `native.js:6` uses `../../node_modules/pdfjs-dist/cmaps/` and line 8 uses `./node_modules/pdfjs-dist/standard_fonts/`. Both need `require.resolve` fix (Task 2.1). |

## What Recent PRs Already Delivered

| PR | What changed | Plan impact |
|---|---|---|
| #28 | Fixed issue #27 (single-page diff crash) | R3 already done — removed from task list |
| #39 | Fixed TS typings for buffer methods and `compare()` | `diffColor`/`diffColorAlt` types already in `.d.ts` |
| #41 | Added `diffColor`, `diffColorAlt`, `outputPngDifferences` to config + compareImages | Bug exposed: `compareImages.js:28` reads `config.outputPngDifferences` (wrong level — should be `config.settings.outputPngDifferences`). Also `outputPngDifferences` missing from `.d.ts`. Both fixed in Task 2.3. |
| #43 | Added Prettier (`"format"` script, `prettier` devDep) | Task 1.1 covers ESLint only — do NOT re-add Prettier |
| #44 | Partially fixed `STANDARD_FONT_DATA_URL` (changed `../../` → `./`) | Task 2.1 still needed — `CMAP_URL` unchanged and `./` is still fragile under hoisting |
| #50 | Upgraded canvas to v3 | Resolves install failures on Node 19+/Apple Silicon. Issues #35, #42 can be closed. |

---

## Commit Convention

- **One commit per completed task.** Every commit must be lint-clean and test-green before it lands.
- The **pre-commit hook** (set up in Phase 1) enforces this: `npm run lint && npm test` must exit 0.
- Update `CLAUDE.md` to reflect this convention.

---

## Phase 1 — Build Pipeline *(set up safety net first)*

> Establish linting, pre-commit protection, and CI before touching any functional code. All subsequent changes land under this protection.

### Task 1.1 — Add ESLint
**Modified files:** `package.json`
**New file:** `.eslintrc.json`

> Prettier is already set up (PR #43 — `"format": "prettier . --write"` script, `prettier` devDep). This task adds ESLint only.

1. Add `eslint` and `eslint-plugin-node` as devDependencies
2. Create `.eslintrc.json`:
   ```json
   {
     "env": { "node": true, "es2020": true },
     "plugins": ["node"],
     "extends": ["eslint:recommended", "plugin:node/recommended"],
     "rules": {
       "no-unused-vars": "error",
       "no-var": "error",
       "prefer-const": "error",
       "node/no-unsupported-features/es-syntax": "off"
     }
   }
   ```
3. Add `"lint": "eslint functions/ test/"` to `package.json` scripts

Note: Fix any existing lint violations in `functions/` and `test/` as part of this task so the baseline is clean before the hook is wired up.

> **Commit after this task:** `chore: add eslint with node plugin`

### Task 1.2 — Add pre-commit hook
**New file:** `.git/hooks/pre-commit`

Plain shell script (no husky dependency — single-maintainer library):
```sh
#!/bin/sh
set -e
echo "Running lint..."
npm run lint
echo "Running tests..."
npm test
```
Make executable: `chmod +x .git/hooks/pre-commit`

From this point on, every commit is automatically gated on green lint and tests.

> **Commit after this task:** `chore: add pre-commit lint and test gate`

### Task 1.3 — Add GitHub Actions CI
**New file:** `.github/workflows/test.yml`

Matrix: `ubuntu-latest` and `macos-latest` (defer Windows — 32-bit GhostScript install is unreliable in Actions).

```yaml
name: Test
on: [push, pull_request]
jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - name: Install system deps (Ubuntu)
        if: runner.os == 'Linux'
        run: sudo apt-get install -y graphicsmagick imagemagick ghostscript
      - name: Install system deps (macOS)
        if: runner.os == 'macOS'
        run: brew install graphicsmagick imagemagick ghostscript
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run test-ts
```

> **Commit after this task:** `ci: add GitHub Actions matrix for ubuntu and macos`

### Task 1.4 — Update CLAUDE.md with development conventions
**File:** `CLAUDE.md`

Add a **Development Conventions** section:
- Commit convention: one task per commit, lint-clean and test-green
- Pre-commit hook enforces `npm run lint && npm test`
- How to run lint (`npm run lint`), JS tests (`npm test`), TS tests (`npm run test-ts`)

> **Commit after this task:** `docs: update CLAUDE.md with commit and quality conventions`

---

## Phase 2 — Foundation Fixes (bugs, compatibility, types)

> Code changes now land under the protection of the pre-commit hook and CI.

### Task 2.1 — Fix CMAP/font paths in native engine
**File:** `functions/engines/native.js:6-8`

Current state (after PR #44):
- Line 6: `CMAP_URL = "../../node_modules/pdfjs-dist/cmaps/"` — still fragile, unchanged
- Line 8: `STANDARD_FONT_DATA_URL = "./node_modules/pdfjs-dist/standard_fonts/"` — partially fixed but still a relative path

Replace both with dynamic resolution:
```js
const PDFJS_ROOT = require('path').dirname(require.resolve('pdfjs-dist/package.json'));
const CMAP_URL = PDFJS_ROOT + '/cmaps/';
const STANDARD_FONT_DATA_URL = PDFJS_ROOT + '/standard_fonts/';
```
Fixes breakage when the package is installed inside a project with hoisted `node_modules`.

> **Commit after this task:** `fix: resolve pdfjs-dist CMAP paths dynamically`

### Task 2.2 — Fix ImageMagick 7+ support in graphicsMagick engine
**File:** `functions/engines/graphicsMagick.js:1,14,27,37`
**File:** `functions/config.js`
**File:** `types/comparePdf.d.ts`

1. Add `imageMagickVersion?: '6' | '7+'` to `ComparePdfConfig.settings` in `types/comparePdf.d.ts`
2. Add `imageMagickVersion: '7+'` as the new default in `functions/config.js`
3. In `graphicsMagick.js`, initialize gm from config on each call:
   ```js
   const getGm = (config) => require('gm').subClass({
     imageMagick: config.settings.imageMagickVersion || '7+'
   });
   ```
4. Remove the per-call `.command(process.platform === 'win32' ? 'magick' : 'convert')` overrides — `'7+'` mode sets `bin = 'magick'` internally, correct for both Unix and Windows IM7.

Resolves GitHub issue #29.

> **Commit after this task:** `fix: support ImageMagick 7+ via imageMagickVersion config`

### Task 2.3 — Fix type errors and missing declarations
**Files:** `types/comparePdf.d.ts`, `functions/compareImages.js`

**Type declaration fixes (`types/comparePdf.d.ts`):**
- Line 62: `numDiffPixels: string` → `numDiffPixels: number` (pixelmatch returns `number`)
- Line 68: `message: string` → `message?: string` (absent on `status: 'passed'`)
- Line 14: `imageEngine: string` → `imageEngine: 'graphicsMagick' | 'native'`
- Add `imageMagickVersion?: '6' | '7+'` to settings interface (for Task 2.2)
- Add `outputPngDifferences?: boolean` to settings interface (added in PR #41 but missing from types)

**Bug fix (`functions/compareImages.js:28`):**
PR #41 added `outputPngDifferences` support but reads from the wrong config level:
```js
// Current (wrong):
if (config.outputPngDifferences) {
// Fix:
if (config.settings.outputPngDifferences !== false) {
```
The `!== false` pattern preserves backward compatibility — existing configs without this key default to outputting diffs (existing behaviour).

Closes issue #40.

> **Commit after this task:** `fix: correct TypeScript declaration types`

### Task 2.4 — Enrich the result object
**Files:** `functions/compareImages.js`, `types/comparePdf.d.ts`

Non-breaking additions — existing fields are preserved:

1. `comparePngs` returns `totalPixels: width * height` alongside existing fields
2. `comparePdfByImage` accumulates `perPageResults[]` for every page, computes `totalDiffPercent` and `changedPages[]`
3. Both `passed` and `failed` result paths include the new fields:
   ```js
   { status: 'passed', perPageResults, totalDiffPercent: 0, changedPages: [] }
   { status: 'failed', message, details: failedResults, perPageResults, totalDiffPercent, changedPages }
   ```
4. Update `types/comparePdf.d.ts`: `Details` gets `totalPixels: number`; `Results` gets `perPageResults?: Details[]`, `totalDiffPercent?: number`, `changedPages?: number[]`

After this task, bump version `1.1.8` → `1.2.0` in `package.json` and document the new result fields in `README.md`.

> **Commit after this task:** `feat: enrich result object with perPageResults and diffPercent`

---

## Phase 3 — v1.3.0 (New comparison modes)

### Task 3.1 — Add `byText` comparison mode
**New file:** `functions/compareText.js`
**Modified files:** `functions/comparePdf.js:145-153`, `types/comparePdf.d.ts`

`pdfjs-dist` is already a dependency with `page.getTextContent()`. No new packages required.

1. Extract text per page: `pdfjsLib.getDocument(...)` → loop pages → `page.getTextContent()` → join `item.str`
2. Diff actual vs baseline text line-by-line per page
3. Return `{ status, message, details: [{ pageIndex, actualText, baselineText, diffLines }] }` on failure
4. Reuse `PDFJS_ROOT`-based CMAP path from Task 2.1
5. Add `case 'byText':` in `comparePdf.js` switch
6. Add `TextPageDetail` interface and `'byText'` to comparisonType union in `types/comparePdf.d.ts`
7. Add test PDFs for text comparison and test cases to `comparePdfTests.js`

Stretch goal: if `getTextContent()` returns empty items (scanned PDF), warn and fall back to `byImage`.

> **Commit after this task:** `feat: add byText comparison mode using pdfjs-dist text extraction`

### Task 3.2 — Add `shiftTolerance` config option
**Files:** `functions/compareImages.js`, `functions/config.js`, `types/comparePdf.d.ts`

Absorbs minor positional drift (anti-aliasing, subpixel rendering differences across environments).

**Performance:** Smart per-pixel neighborhood check — not repeated full pixelmatch runs:
- Run pixelmatch once. If it passes → zero overhead added.
- Only on failure: for each differing pixel, check if a matching neighbor exists within radius N in the other image. If found, the pixel is forgiven.
- O(diffPixels × N²) lookups. At N=4 with 500 drift pixels: ~40k lookups (microseconds). On passing tests: zero extra work. On genuine failures: no change in result.

Implementation:
1. Add `shiftTolerance: 0` to `functions/config.js` (default off)
2. In `comparePngs`: after pixelmatch, if `numDiffPixels > tolerance && shiftTolerance > 0`, scan the pixelmatch diff buffer for differing pixels, check radius-N neighborhood in baseline PNG data, reduce effective diff count for forgiven pixels
3. Add `shiftTolerance?: number` to `ComparePdfConfig.settings` in the declaration file

> **Commit after this task:** `feat: add shiftTolerance for rendering drift tolerance`

---

## Phase 4 — v1.4.0 (AI mode — defer until Phase 3 stable)

### Task 4.1 — Add `byAI` comparison mode
**New file:** `functions/compareAI.js`
**Modified files:** `functions/comparePdf.js`

Design constraints:
- Disabled unless `config.settings.ai.enabled = true` (throw clear error otherwise)
- Support two backends via `config.settings.ai.engine`: `'anthropic'` | `'ollama'`
- **Do not hard-depend on any AI SDK** — lazy-require with install hints, or accept a user-provided adapter to avoid SDK version coupling
- Convert pages to base64 PNG via native engine internals, send to vision model
- Prompt model for structured JSON: `{ verdict: 'same' | 'different', reason: string, pageIndex: number }`
- Return standard `{ status, message, details }` shape

Document that `byAI` tests must not run in standard CI (cost/latency/nondeterminism).

> **Commit after this task:** `feat: add byAI comparison mode with configurable vision endpoint`

---

## What to Skip

| Item | Reason |
|---|---|
| Full TypeScript source rewrite | Requires build step, restructures package, risks regressions. Declaration bugs fixable in `.d.ts` alone. Revisit later. |
| Change default density/quality | Silent breaking change for stored baselines. Document as options, don't change defaults. |
| Removing `gm` entirely | IM7 fix (Task 2.2) is sufficient. `gm@1.25.1` is stable despite npm sunset warning. |
| Windows CI | 32-bit GhostScript install unreliable in Actions. Document manual Windows testing. |
| Merging community fork wholesale | Use as reference for type improvements only; don't merge the full TS rewrite. |

---

## Critical Files

| File | Phase |
|---|---|
| `.eslintrc.json` | 1.1 (new) |
| `.git/hooks/pre-commit` | 1.2 (new) |
| `.github/workflows/test.yml` | 1.3 (new) |
| `CLAUDE.md` | 1.4 |
| `functions/engines/native.js` | 2.1 |
| `functions/engines/graphicsMagick.js` | 2.2 |
| `types/comparePdf.d.ts` | 2.3, 2.4, 3.1, 3.2 |
| `functions/config.js` | 2.2, 3.2 |
| `functions/compareImages.js` | 2.3, 2.4, 3.2 |
| `functions/comparePdf.js` | 3.1, 4.1 |
| `package.json` | 1.1, 2.4 |
| `README.md` | 2.4 |
| `functions/compareText.js` | 3.1 (new) |
| `functions/compareAI.js` | 4.1 (new) |

## Release Strategy

| Phase | Release |
|---|---|
| Phase 1 | No release — infrastructure only |
| Phase 2 | **v1.2.0** (bug fixes + enriched result object) |
| Phase 3 | **v1.3.0** (new comparison modes) |
| Phase 4 | **v1.4.0** (AI mode) |

## Verification

Every commit is gated by the pre-commit hook (`npm run lint && npm test`), and every release requires CI green on ubuntu and macos runners before tagging. New comparison modes (Phases 3–4) require dedicated test cases in `test/comparePdfTests.js` and `test/comparePdfTests.ts` before shipping.
