diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..151ace4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,81 @@ +name: Bug report +description: Something in the app behaved wrongly. For a bad crop use the detection template instead. +title: "[bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + ⚠️ **Never attach a card scan.** Paste text, or a screenshot with any + card artwork cropped out. + + - type: textarea + id: what_happened + attributes: + label: What happened + description: What you did, what you expected, and what you got instead. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: > + If you can reproduce it with `bash Scripts/bundle-app.sh --synthetic`, + say so — that means anyone can fix it without a scanner, which makes it + far more likely to get fixed quickly. + value: | + 1. + 2. + 3. + validations: + required: true + + - type: dropdown + id: how_run + attributes: + label: How were you running ScanDeck? + options: + - "bash Scripts/bundle-app.sh --run (real scanner)" + - "bash Scripts/bundle-app.sh --synthetic (no scanner)" + - "swift run ScanDeck" + - "swift run scandeck-m0 (command line)" + validations: + required: true + + - type: markdown + attributes: + value: | + If you chose `swift run ScanDeck` and the symptom is that **typing does + nothing**: that is a known trap rather than a bug. A bare SwiftPM + executable registers as background-only and receives no keyboard events. + Use `bash Scripts/bundle-app.sh --run` instead and check with + `lsappinfo list | grep -A4 ScanDeck` that you get `type="Foreground"`. + + - type: input + id: versions + attributes: + label: macOS version, and scanner if one was attached + placeholder: "macOS 26.5.2, fi-8170" + validations: + required: true + + - type: textarea + id: diagnostics + attributes: + label: Diagnostics report + description: > + Settings → Copy Diagnostics Report. Content-free by construction — no + path, file name or batch name can reach it. + render: shell + validations: + required: false + + - type: checkboxes + id: safety + attributes: + label: Before you submit + options: + - label: I have not attached any card scan or personal data + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d384785 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: How changes land, and what to expect back + url: https://github.com/eliseorobles/ScanDeck/blob/main/CONTRIBUTING.md + about: Branch, PR, review. Also which changes want an issue opened first, and whether you need a scanner (usually not). + - name: Will my scanner work? + url: https://github.com/eliseorobles/ScanDeck/blob/main/README.md#about-scanner-support + about: Only the fi-8170 today. If you have something else, open a hardware datapoint — that is how the list grows. diff --git a/.github/ISSUE_TEMPLATE/detection-report.yml b/.github/ISSUE_TEMPLATE/detection-report.yml new file mode 100644 index 0000000..3f7aec3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/detection-report.yml @@ -0,0 +1,100 @@ +name: ScanDeck measured a card wrong +description: A crop clipped the card, kept too much, or the two sides disagreed when they should not have. +title: "[detection] " +labels: ["detection", "image-pipeline"] +body: + - type: markdown + attributes: + value: | + This is the bug class the project cares most about, because it is the one + that can publish a wrong image that still looks plausible. + + ⚠️ **Do not attach the scan.** Card images are your property. Everything + below is numbers and text, which is all we need to reproduce the + reasoning — and if it turns out we need pixels, we will talk about how to + share them privately rather than in a public issue. + + - type: dropdown + id: symptom + attributes: + label: What went wrong? + options: + - The crop cut into the card + - The crop kept far too much background + - The two sides of one card measured very differently + - Complete Batch was blocked and I think it should not have been + - Complete Batch was allowed and I think it should not have been + - The card published visibly crooked + - Something else + validations: + required: true + + - type: textarea + id: what_the_app_said + attributes: + label: What did ScanDeck say? + description: > + The margin badge on the row, the text of any banner, and the completion + blocker if there was one. Copy the wording exactly — the copy is part of + the product and getting it wrong is itself a bug. + validations: + required: true + + - type: input + id: footprints + attributes: + label: The two measured footprints, if the card row showed them + placeholder: "front 2104 x 2942 px, back 2098 x 2951 px" + validations: + required: false + + - type: dropdown + id: media + attributes: + label: What was in the feeder? + options: + - Raw (bare) card + - Card in a toploader + - Card in a semi-rigid (note - not a supported format) + - Card in a penny sleeve only (note - not a supported format) + - Something else + validations: + required: true + + - type: input + id: scanner + attributes: + label: Scanner and macOS version + placeholder: "fi-8170, macOS 26.5.2" + validations: + required: true + + - type: textarea + id: diagnostics + attributes: + label: Diagnostics report + description: > + Settings → Copy Diagnostics Report. It is content-free by construction — + it has no field a path, file name or batch name could go in, and there is + a test that proves it. + render: shell + validations: + required: false + + - type: markdown + attributes: + value: | + **If you keep the masters**, you can turn this into a permanent + regression yourself without sending anyone an image: + + ```sh + SCANDECK_CORPUS_DIR= SCANDECK_CORPUS_REPORT=/tmp/r.csv \ + swift test -c release --filter CardImageCropperCorpusDiagnostics + ``` + + That writes one row per side with both threshold reads, the dominant + component, the floor-supported bounds and the raw occupancy outside them + — which is the difference between "one side over-detected" and "the other + under-detected", opposite fixes on identical-looking evidence. Pasting + the CSV rows for the bad pair is the single most useful thing you can + attach. diff --git a/.github/ISSUE_TEMPLATE/hardware-datapoint.yml b/.github/ISSUE_TEMPLATE/hardware-datapoint.yml new file mode 100644 index 0000000..0dcecae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hardware-datapoint.yml @@ -0,0 +1,95 @@ +name: Hardware datapoint +description: Report what ScanDeck saw on a scanner other than the fi-8170. This is the most valuable report the project can get. +title: "[hardware] " +labels: ["hardware-datapoint", "scanner-support"] +body: + - type: markdown + attributes: + value: | + Thank you — this is genuinely the highest-value contribution available. + + ScanDeck supports exactly one scanner today, and widening that is capped + by nobody having capability data for anything else. Your report is the + seed of the supported-scanner matrix. + + **You do not need the scan to have worked.** A device that fails is just + as useful as one that succeeds, as long as we can see *which* capability + was missing. + + ⚠️ **Never attach a card scan.** Card images are your property and this + repository has never contained one. Paste text output only. + + - type: input + id: model + attributes: + label: Scanner make and model + placeholder: "Ricoh fi-8040 / Brother ADS-4300N / Epson DS-C490" + validations: + required: true + + - type: input + id: macos + attributes: + label: macOS version + placeholder: "26.5.2 (25F84)" + validations: + required: true + + - type: dropdown + id: connection + attributes: + label: How is it connected? + options: + - USB + - Network (Wi-Fi or Ethernet) + - Both available, reporting USB + - Both available, reporting network + validations: + required: true + + - type: textarea + id: list + attributes: + label: Output of `swift run scandeck-m0 --list` + description: This works on any scanner and is not gated. + render: shell + validations: + required: true + + - type: textarea + id: inspect + attributes: + label: Output of `swift run scandeck-m0 --inspect` + description: > + Paste the whole thing, including any error. If it refuses your device, + that refusal IS the datapoint — paste it verbatim. Note that inspection + is currently gated to the fi-8170; if it refuses on those grounds, say + so and the `--list` output above is still useful on its own. + render: shell + validations: + required: false + + - type: checkboxes + id: capabilities + attributes: + label: Which of these does the scanner have? + description: > + These four are what the pipeline actually depends on. Tick what you know; + leave the rest blank rather than guessing. The vendor's spec sheet is a + fine source. + options: + - label: Duplex ADF (images both sides in one pass) + - label: 600 dpi + - label: 24-bit colour + - label: An overscan or margin mode that captures background *around* the detected media + - label: Automatic media-size detection + + - type: textarea + id: notes + attributes: + label: Anything else + description: > + Vendor driver name and version, whether Apple Image Capture sees it, or + anything that surprised you. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/spec-hole.yml b/.github/ISSUE_TEMPLATE/spec-hole.yml new file mode 100644 index 0000000..19efa95 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/spec-hole.yml @@ -0,0 +1,40 @@ +name: Spec hole or design disagreement +description: SPEC.md says something wrong, missing, over-engineered, or that you think is a mistake. +title: "[spec] " +labels: ["spec"] +body: + - type: markdown + attributes: + value: | + This is a real contribution and it is explicitly invited. SPEC.md is the + contract, and it has been changed before because someone argued it was + wrong — three of the seven invariants carry amendments for exactly that + reason. + + You do not need code, a scanner, or a patch. An argument is enough. + + - type: textarea + id: what + attributes: + label: What does the spec say, and where? + description: Quote it, with the section number. + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What is wrong with it? + description: > + Wrong, missing, over-engineered, internally inconsistent, or contradicted + by the code — say which, and why it matters to someone using the app. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What should it say instead? + description: Optional. "I think this is wrong but I don't know the fix" is a legitimate issue. + validations: + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ca16008 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +# ScanDeck compiles only against the macOS 26 SDK — `Package.swift` pins +# `.macOS(.v26)` and swift-tools-version 6.2 — so every job that touches Swift +# has to run on a `macos-26` runner. That image went generally available on +# 2026-02-26, and because this repository is public it costs nothing: standard +# GitHub-hosted runners are free and unmetered for public repositories. +# +# Nothing here needs an fi-8170. The suites that do need real hardware, a real +# corpus, or the private centering tool are all gated — on a `SCANDECK_*` +# environment variable, or on a file `.gitignore` deliberately keeps out of +# this repository — so they skip and the rest runs. + +on: + push: + branches: [main] + pull_request: + +# A second push to the same branch makes the first run's answer worthless, and +# a stale green is worse than no answer. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-test: + name: Build and test (macOS 26) + runs-on: macos-26 + timeout-minutes: 30 + + env: + # Pinned rather than left to the image default. The `macos-26` image + # currently defaults to Xcode 26.5, which ships Swift 6.3; this pin is + # Xcode 26.3 (build 17C529, Swift 6.2.4), the exact toolchain the 251-test + # suite has been verified green against. Strict concurrency is on across + # the whole package and a compiler bump is precisely where new + # diagnostics land, so moving this number is a deliberate change with its + # own review — not something a runner-image refresh gets to do overnight. + XCODE_APP: /Applications/Xcode_26.3.app + + steps: + - name: Check out + uses: actions/checkout@v7 + + # Fail loudly and legibly if the image has retired this Xcode, rather + # than silently compiling against whatever the default happens to be. + - name: Select Xcode + run: | + if [ ! -d "$XCODE_APP" ]; then + echo "::error::$XCODE_APP is not installed on this runner image." + echo "Xcodes present on the runner:" + ls -d /Applications/Xcode*.app + exit 1 + fi + sudo xcode-select -s "$XCODE_APP" + + - name: Record the toolchain + run: | + sw_vers + xcodebuild -version + swift --version + + # ScanDeck has zero external package dependencies, so this cache buys + # incremental compilation and nothing else — there are no checkouts to + # restore. Keep it keyed tightly enough that a restored `.build` is only + # ever a speed-up: the Xcode version is in the key because a toolchain + # change invalidates every module inside, and the source hash means the + # exact key only hits on a re-run of the same commit. Ordinary runs land + # on the prefix via restore-keys and rebuild what changed. It is roughly + # 335 MB, well inside the 10 GB repository cache budget. + - name: Cache SwiftPM build products + uses: actions/cache@v6 + with: + path: .build + key: >- + ${{ runner.os }}-${{ runner.arch }}-spm-xcode26.3-${{ + hashFiles('Package.swift', 'Sources/**/*.swift', 'Tests/**/*.swift') + }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-spm-xcode26.3- + + - name: Build + run: swift build + + # Release, not debug, and it is not a preference: `CardImageCropper` + # analyses full-resolution 600 dpi masters, which costs ~47 s per master + # in debug against ~0.25 s in release. + - name: Test + run: swift test -c release + + repository-hygiene: + # Free, ~15 s, and needs no macOS. This is the remote half of guards that + # currently exist only on the operator's machine: `.gitignore` plus the + # `.githooks/pre-commit` hook, which a fresh clone does not have until + # someone remembers `git config core.hooksPath .githooks`. + name: Repository hygiene + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out + uses: actions/checkout@v7 + + - name: Nothing private reached the public remote + run: | + status=0 + fail() { echo "::error::$1"; status=1; } + + # The centering tool is private. This repository has a live public + # remote, so a stray `git add -f` is one push from publishing it. + tracked_centering="$(git ls-files centering)" + if [ -n "$tracked_centering" ]; then + fail "The private centering tool is tracked: $(echo "$tracked_centering" | tr '\n' ' ')" + fi + + # Card scans are the operator's images. The corpus lives in + # Application Support and must never be committed. + tracked_images="$(git ls-files -- '*.jpg' '*.jpeg' '*.png' '*.tiff' '*.tif' '*.heic')" + if [ -n "$tracked_images" ]; then + fail "Card images are tracked: $(echo "$tracked_images" | tr '\n' ' ')" + fi + + # Internal working documents: the ticket queue carries real consignor + # and batch names, and the PaperStream research is a derivative of one + # copyrighted source. + tracked_private="$(git ls-files -- CLAUDE.md PENDINGTICKETS.md 'PaperStream_for_Mac_UI_Spec*' docs/research/paperstream-capture-inventory.md)" + if [ -n "$tracked_private" ]; then + fail "Private working documents are tracked: $(echo "$tracked_private" | tr '\n' ' ')" + fi + + # "PaperStream" is PFU Limited's trademark. Nominative use in prose is + # fine and appears throughout the docs; it must never become part of a + # shipped product name, target name, or bundle identifier. + if grep -i -q 'paperstream' Package.swift Resources/ScanDeck-Info.plist; then + fail "PaperStream appears in Package.swift or the app's Info.plist." + fi + + if [ "$status" -eq 0 ]; then + echo "Repository hygiene checks passed." + fi + exit "$status" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a035e69 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,64 @@ +# Code of Conduct + +## The short version + +Be kind, be concrete. Assume the person on the other end is trying to help. + +Disagreement about the code is welcome and expected — this project changes its +mind when the evidence says to, and "I think this constant is wrong, here is a +corpus run" is the most valuable thing you can bring. Disagreement about a +person is not. + +## The longer version + +We pledge to make participation a harassment-free experience for everyone, +regardless of age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, education, +socio-economic status, nationality, personal appearance, race, religion, or +sexual identity and orientation. + +Examples of behaviour that contributes to a positive environment: + +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility, apologising to those affected by our mistakes, and + learning from the experience +- Focusing on what is best for the project and the people using it + +Examples of unacceptable behaviour: + +- Sexualised language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Scope + +This applies in all project spaces — issues, pull requests, discussions, commit +messages — and when someone is representing the project in public. + +## Reporting + +Report unacceptable behaviour to **eliseo@eliseorobles.me**. Reports are +reviewed and investigated promptly and fairly, and the reporter's privacy and +security are respected. + +If the report concerns the maintainer, say so directly; you are also free to +report to GitHub through their abuse-reporting flow, which is outside this +project's control. + +## Enforcement + +The maintainer is responsible for clarifying and enforcing these standards and +will take appropriate and fair corrective action in response to any behaviour +judged inappropriate, threatening, offensive, or harmful — including editing or +removing comments, commits, code, issues and other contributions, and where +necessary blocking a person from the project. + +## Attribution + +Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b496f5c..f9758ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,66 @@ Say in the PR whether you ran against real hardware or not. "Tests pass, no scanner" is a completely fine and useful thing to write; claiming a hardware behaviour nobody observed is not. +## What you can expect back + +**A human reply within three days**, even when the reply is "not yet" or "not +this". If you have waited longer than that, the notification was missed rather +than ignored — say so on the thread and it will be picked up. + +This is a small project maintained by one person, so the honest version is: +review may be slower than you would like, but silence is never the answer and a +stale PR is a failure on this end, not yours. + +## Open an issue first for anything load-bearing + +Not bureaucracy — this codebase has unusually strong invariants, and the +expensive place to discover a scope mismatch is after you have written the code. + +**Ask first** if the change touches: + +- a `BatchSession` gate, or any safety rule +- `CardImageCropper` detection constants — these were measured against real + scanner artefacts and a plausible-sounding rewrite is how a defect gets back in +- an invariant in [SPEC.md](SPEC.md), or the v1 scope +- the `ImageCaptureCore` boundary + +**Just send it** for docs, operator copy, tests, accessibility, hardware +datapoints, or anything already described in an open issue. + +One concrete example of why: `AcceptedPairSizeMismatch` stores the two measured +footprints rather than a `Bool`, and that looks like something to simplify. It +is not — a `Bool` would carry a person's consent onto a re-measured pair they +never saw. Several things here are shaped that way, and the reasoning usually +lives in a doc comment right above the code. + +## Do you need an fi-8170? + +Almost certainly not. Every issue is labelled `needs-fi-8170` or +`no-hardware-needed`, and the second group is much larger: the whole reducer, +the SwiftUI shell, image encoding and DPI metadata, the crash-safety primitives, +error copy, accessibility, docs and CI. + +`swift test` runs 251 tests with no scanner attached, and +`bash Scripts/bundle-app.sh --synthetic` runs the real crop, margin verdicts, +deskew and atomic publication against a simulated page source — so you can +reproduce and fix most UI and pipeline behaviour without hardware. + +## Using AI assistance + +Allowed, and it does not need to be hidden — but note it in the PR so a reviewer +knows what kind of scrutiny to apply, and hold it to the same bar as anything +you wrote by hand. You are the author of what you submit. + +Two rules that bind harder here than in most projects, because this codebase's +safety story rests on things a model cannot check: + +- **An LLM cannot observe hardware.** Never let generated text become a claim + about what a scanner did. If a pass was not physically run, say so. +- **Detector constants need evidence, not rationale.** A change to + `CardImageCropper`'s thresholds must cite a corpus run. A confident-sounding + explanation for a new number is exactly the failure mode those constants were + measured to prevent. + ## The most useful contributions right now - **Exercise the suite.** Run `swift test` on Apple Silicon with Xcode 26 — diff --git a/README.md b/README.md index e0a3bd7..4934640 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # ScanDeck — free, open-source Mac scanning software for trading cards +[![CI](https://github.com/eliseorobles/ScanDeck/actions/workflows/ci.yml/badge.svg)](https://github.com/eliseorobles/ScanDeck/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +![Platform: macOS 26](https://img.shields.io/badge/platform-macOS%2026-lightgrey) +![Swift 6.2](https://img.shields.io/badge/Swift-6.2-orange) + **Batch-scan trading cards front and back on macOS, at 600 dpi, with a real scanner-background border — and get numbered `_F` / `_B` pairs a listing tool can read.** ScanDeck drives a duplex ADF document scanner, crops each side to @@ -222,9 +227,67 @@ The app scans real batches end to end on a physical fi-8170 — including a 186-card production run. Milestones M0–M2 are met. Still open: a job/profile editor (there is one built-in profile today), an app -icon, accessibility passes, CI, and a supported-scanner matrix. There is no CI -yet, so there is no build badge — `swift test` on your own machine is the honest -signal. +icon, accessibility passes, a downloadable release, and a supported-scanner +matrix. Every one of those has an [issue](https://github.com/eliseorobles/ScanDeck/issues). + +CI runs `swift build` and `swift test -c release` on every pull request, plus a +hygiene job that fails if a card scan or any private working file ever becomes +tracked. + +## Help make this the best scanning app on the Mac + +That is the actual goal, and it is reachable — but not by one person with one +scanner. + +ScanDeck already does one thing nothing else on the Mac does: it **measures the +border it is about to publish and refuses to publish a batch it cannot vouch +for.** Every published pixel is captured background. When a card's two sides +disagree by more than 140 px the batch stays blocked until a person overrules +that specific card, and the overrule is stored against the two measured +footprints so a re-scan invalidates it. That is not a slogan — it is a reducer, +and you can go read it. + +"Best open-source Mac scanning app" would mean that same guarantee holds **on a +scanner you already own, in an app you can download, driven from a keyboard.** +All three of those currently fail, and none of them fail for architectural +reasons: + +| The gap | Why it is not hard | Where | +|---|---|---| +| **One supported scanner** | The hardware seam is a single protocol. The allowlist is a two-entry `Set` | `ScannerInventory.swift:23` | +| **No downloadable app** | The bundle script already builds and signs it; it needs notarization and a release | `Scripts/bundle-app.sh` | +| **Mouse required** | Two review actions are hover-only, with no keyboard route | `BatchWorkspaceView.swift:741` | + +**Widening scanner support is the highest-value thing anyone can do here**, and +it caps everything else — more scanners means more users, and contributors come +from users. The correctness guarantee is exactly what makes the project worth +widening, so the work is to grow the device list *without* loosening it. The +test harness for that already exists: 251 tests run with no scanner, and +`--synthetic` exercises the real crop, verdict, deskew and publication path. + +### You do not need a scanner + +This is the most common reason people assume they can't help, and it is wrong. +Every issue is labelled `needs-fi-8170` or `no-hardware-needed`, and almost +everything is the second one. The reducer, the SwiftUI shell, the JPEG and DPI +metadata, the crash-safety primitives, the error copy, accessibility, docs and +CI are all reachable with nothing but a Mac. + +### Good places to start + +- **[Open issues](https://github.com/eliseorobles/ScanDeck/issues)** — every one + names the files to touch and the command that proves it worked. +- **Have a different duplex ADF scanner?** Run `swift run scandeck-m0 --list` + and `--inspect` and open a hardware datapoint. That is the seed of the + supported-scanner matrix and it costs you five minutes. +- **Found a card ScanDeck measured wrong?** That is the most valuable bug report + this project can get. Tell us what the two sides measured — but never attach + the scan. +- **Read [SPEC.md](SPEC.md) and poke holes in it.** It is the contract, and + disagreeing with it in an issue is a real contribution. + +You will get a reply. See [CONTRIBUTING.md](CONTRIBUTING.md) for what to expect +and how changes land. ## Contributing diff --git a/SPEC.md b/SPEC.md index 68f138e..7fadab2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -138,11 +138,12 @@ Every milestone is validated on Apple Silicon against the physical fi-8170 with ## 8. Open questions & research -Research is in `docs/research/` (all three tracks complete): +Research is in `docs/research/`: - `macos-scanning-apis.md` — the ImageCaptureCore facts §6 is built on (verified against the macOS 26 SDK and the installed PFU driver binary) -- `paperstream-capture-inventory.md` — full PaperStream Capture 6.0 feature inventory (from the official 644-page manual); the post-v1 backlog source: batch state machine + Batch Manager, three counter types with persistence modes, naming-token builder with live preview, marks/filters, separators, ad-hoc override, hotkeys/hardware-button job mapping - `competitor-gaps.md` — positioning: the real competitor is ExactScan Pro (~100€, own drivers), and the two structural gaps nothing on Mac has are the review-then-completion gate and first-class pair naming with transactional counters. VueScan's watermark-trial/renewal model is the goodwill killer to avoid. +A third track — a feature inventory of PaperStream Capture 6.0, distilled from the vendor's 644-page manual — is the source of the post-v1 backlog below (batch state machine, three counter types with persistence modes, naming-token builder with live preview, marks/filters, separators, ad-hoc override, hardware-button job mapping). It is **not published**: it is a dense page-cited abstract of one copyrighted document, and nothing in ScanDeck needs it published to be useful. The backlog items it produced are listed here in their own right. + A separate, broader UX study of a general-purpose PaperStream Capture remake (presets, OCR, searchable PDF, page-list review) exists as local research and is deliberately not published. **It is not this product's roadmap.** Settled 2026-07-28: ScanDeck stays a trading-card app and that document serves as a UX quality bar and post-v1 backlog only. Where the two disagree, this file wins. Consequently the domain models F/B *pairs*, not a general page list; adding a document profile later would be a reducer rewrite, accepted deliberately. Still open: diff --git a/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift b/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift index af50695..cb676b3 100644 --- a/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift +++ b/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift @@ -130,8 +130,23 @@ struct CardImageCropperDeskewTests { == result.analysis.requestedCropBounds.height ) - // And the pixels came through by copy, not by interpolation: the - // output must equal the source region exactly. + // And the pixels came through by copy, not by interpolation. + // + // The comparison has a tolerance, and the reason is the file format + // rather than the pipeline: the fixture is a lossless PNG but `crop` + // publishes JPEG, and JPEG at quality 1.0 is still a DCT round-trip + // through YCbCr. It lands a flat region within a level or two, not on + // it. Asserting exact equality passed on one machine and failed in CI + // reading 17 where 18 went in — a difference produced by the encoder, + // not by anything ScanDeck did. + // + // The tolerance is small on purpose. Interpolating a 0.1° rotation + // moves an edge pixel by whole levels of contrast, so ±2 still catches + // a resample while admitting the codec. The load-bearing proof that + // nothing was resampled is above this loop and is exact: `tiltDegrees` + // is nil, so `square` is never called, and the output dimensions equal + // the requested crop bounds rather than a rotated bounding box. + let jpegRoundTripTolerance = 2 let sourceImage = try cropper.decode(source) let outputImage = try cropper.decode(output) let bounds = result.analysis.requestedCropBounds @@ -141,9 +156,13 @@ struct CardImageCropperDeskewTests { let out = (y * outputImage.width + x) * 4 let into = ((y + bounds.y) * sourceImage.width + (x + bounds.x)) * 4 + let difference = abs( + Int(outputImage.pixels[out]) + - Int(sourceImage.pixels[into]) + ) #expect( - outputImage.pixels[out] == sourceImage.pixels[into], - "pixel (\(x), \(y)) was resampled" + difference <= jpegRoundTripTolerance, + "pixel (\(x), \(y)) differs by \(difference), more than the codec accounts for, so it was resampled" ) sampled += 1 }