Preview Image Editing — Design Spec
Milestone: preview-image-editing (GitHub #315) Manual: TBD — the user manual needs an “Editing an image” section: the Edits facet, the reversible operation chain, compare modes (single/side-by-side/wipe), copying edits between images, and that Revert to Original always asks first because every step is already saved.
Design-led spec (Testing Constitution). The creative director owns the intent; tests enforce it; code makes them pass. One line per behavior, each cited by its pinning test. Status: DRAFT — PASS 1 of 2 (built behaviors only, grounded in disk 2026-09-18; the fold-in of legacy milestone #168’s sixteen issues is PASS 2 — a fold plan follows this spec’s own behaviors, no issues moved yet). Tags: [OK] behaves this way today · [PARTIAL] implemented, not fully verified/tested · [BROKEN] regression, code contradicts the line · [GAP] intended, never built.
Intent (the design)
Image editing is reached from preview-surface.md’s own territory — the Preview pane’s
“Edits” facet — but is substantial enough to be its own spec, the same relationship
reading-markup-annotations.md has to reader-view.md: preview-surface.md owns mounting
and dispatching the canvas Preview shows; this spec owns what happens once the user starts
editing what’s on it. Views/Preview/ImageEditor/ is an 11-file, self-contained subsystem
(2,402 lines: the editor view, its toolbar, popovers, a step-chain panel, a marquee-select
overlay, a live-edit preview, and a clipboard for copying edits between images).
[[never rewrite source]], verified precisely from the code, not assumed: editing is
non-destructive by construction, not by convention. An edit is one operation appended to an
ImageEditChain — its own database row (document_id + an ordered operations: list[dict])
— never a mutation of the document’s own file bytes. Rendering a preview
(GET /api/images/{id}/preview?apply_edits=true|false) loads the ORIGINAL source image fresh
from disk every time (_load_source_image(source_path, …)), applies the chain’s operations to
an in-memory copy, and writes only the RENDERED RESULT to a disposable cache directory
(storage/preview-cache/{document_id}/page-{page}/{hash}.bin, keyed on document id + page +
apply_edits + the chain’s own updated_at, capped at four cached revisions per page). The
source file at source_path is read, never written, anywhere in this path. apply_edits=false
is how the “original” side of a compare mode is produced — no separate original copy is kept,
because the source itself always IS the original.
Ratified rulings honored here, not restated: the three-surfaces rule (Preview/Reader/
Inspector never merge) from preview-surface.md; preview-surface.md’s own mounting of
DocumentCanvas, which this spec’s editor sits beside, not inside — EditorView’s own
previewRoute(for:isEditing:) chooses between DocumentCanvas’s plain storage-display canvas
and this spec’s full ImageEditorView, both driven by the SAME toggle
(inspectorSelectedTab == .edits), so the two specs share one on/off switch without either
one owning the other’s surface.
Surfaces: ImageEditorView (++Canvas/+Clipboard/+Popovers/+Toolbar/+Types),
ImageEditorModel, ImageEditChainPanel (++Steps), ImageMarqueeOverlay,
LiveEditPreview, ImageEditClipboard, ImageEditingService (client),
fichero_server.models.ImageEditChain, api/routes/ingest/image_editing.py.
Behaviors (each → one pinning test)
A. Non-destructive storage — the source is never rewritten
preview.edit.chain-is-a-separate-row-never-the-source— [OK] an edit is one operation appended toImageEditChain(document_id+ orderedoperations), its own database row — never a write to the document’s source file. Verified at HEAD:_render_preview(image_editing.py:613-641) calls_load_source_image(source_path, …)to read the ORIGINAL every time, applieschain.operationsto an in-memory copy viaapply_operation, and writes only the rendered bytes to a disposable preview-cache path — no code path in this function (or_append_operation) openssource_pathfor writing. Pinned:test_routes_image_editing.py::TestImageEditChainRoutes::test_valid_chain_round_trips_without_transient_path.preview.edit.preview-toggles-original-vs-edited-with-no-second-copy— [OK] theapply_editsquery parameter onGET /api/images/{id}/previewis the entire mechanism for showing “original” versus “edited” —apply_edits=falseskips the chain entirely and renders straight from the source, so there is no separately-stored “original” copy to drift from the real source. Pinned:test_routes_image_editing.py::TestImagePreviewRoute::test_preview_returns_original_without_edits,::test_preview_resolves_library_relative_source_path.preview.edit.operations-validated-at-the-boundary— [OK] an invalid operation (an unknown op name, an out-of-range rotate angle, a negative crop dimension) is refused with a 422 at the PUT boundary, never silently accepted and failing later at render time. Pinned:test_routes_image_editing.py::TestImageEditChainRoutes::test_put_rejects_invalid_operations(parametrized over three invalid shapes).preview.edit.crop-and-split-are-reversible— [OK] cropping or splitting an image is a chain operation like any other, not a destructive file replacement — reversible the same way every other step is (delete the step, or revert the whole chain). Pinned:test_routes_image_editing.py::TestReversibleImageCrop,::TestReversibleImageSplit.preview.edit.quarter-turns-are-lossless— [OK] a 90/180/270-degree rotation is a lossless quarter-turn operation, not a lossy re-encode-and-rotate. Pinned:test_image_edit_chains.py::TestQuarterTurnsAreLossless.
B. Mounting — reached through the Preview surface’s Edits facet
preview.edit.mounted-from-the-edits-facet— [PARTIAL] (→ #4872)EditorView .previewRoute(for:isEditing:)mounts the fullImageEditorView(its.imageEditorroute case) whenisEditingis true (inspectorSelectedTab == .edits) AND the document is editable (fileType == .imageordocType == .page) — for a plain image/page, a folder’s image, or a PDF page alike (EditorView.swift:175-238). The SAMEisEditing/inspectorSelectedTab == .editstoggle also drivesDocumentCanvas’sisEditingbinding when the doc ISN’T routed to the full editor, greying its edit-affordance control instead — one toggle, two surfaces, perpreview-surface.md’s ownDocumentCanvasbehaviors. Not verified as built: no dedicated mount/dispatch test assertspreviewRoutepicks.imageEditorunder these exact conditions; grounded in reading the exhaustive route function directly.
C. Editor mechanics — the step chain, caches, and copying edits
preview.edit.re-editing-a-step-rewrites-it-in-place— [OK] re-opening an already-applied step’s controls and adjusting them rewrites that SAME step in the chain — it does not append a second, competing step for the same operation. Pinned:ImageEditStepEditingTests.updateOperationKeepsPosition(filefichero/Tests/Unit/general/Views/Preview/ImageEditStepEditingTests.swift, suiteImageEditStepEditingTests).preview.edit.delete-step-and-revert-are-reachable-and-confirmed— [OK] deleting a single step, and reverting the whole chain to the original, are both reachable from the steps panel, and reverting always asks for confirmation first — every step is already committed/saved, so reverting always discards real, saved work, never just an in-progress draft. Pinned:ImageEditStepEditingTests(suiteImageEditStepEditingTests, case “delete-a-step and revert-to-original stay reachable and confirmed”).preview.edit.rendition-caches-invalidate-on-every-edit— [OK] a successful edit drops BOTH derived-pixel caches (not just the storage-service one), including on the way OUT of the editor, so leaving edit mode can never show stale pixels; a batch-applied edit invalidates the cache per DOCUMENT touched, not only for whichever one is on screen. Pinned:ImageEditRenditionRefreshTests(filefichero/Tests/Unit/general/Views/Preview/ImageEditRenditionRefreshTests.swift, suiteImageEditRenditionRefreshTests; all three cases).preview.edit.chain-sync-avoids-redundant-refetch— [OK] the editor’s chain-sync mechanism re-fetches and re-renders exactly once per real change (a bumped storage epoch), never on an unchanged epoch, never for an edit the model made itself echoing back through the sync path, and does nothing for a model with no document loaded. The Inspector’s Edits facet and the editor canvas both watch the same storage epoch, and the facet draws only the step LIST, never the pixels (that stays the canvas’s job). Pinned:ImageEditChainSyncTests(filefichero/Tests/Unit/general/Views/Preview/ImageEditChainSyncTests.swift, suiteImageEditChainSyncTests; all seven cases).preview.edit.clipboard-copies-and-pastes-a-chain— [OK] an edit chain can be copied from one image and pasted onto another (or across a multi-selection) — copying strips the per-document bookkeeping so the copy travels cleanly, pasting REPLACES the target’s chain rather than appending to it, copying an intentionally-empty chain is still a real copy (not a silent no-op), and pasting across a multi-selection asks for confirmation first (since it may replace saved chains on files not currently on screen) while pasting onto a single image does not. Pinned:ImageEditClipboardTests(filefichero/Tests/Unit/general/Views/Preview/ImageEditClipboardTests.swift, suiteImageEditClipboardTests; all four cases).
D. Compare modes — true but untested
preview.edit.compare-modes-single-side-by-side-wipe— [PARTIAL] (→ #4872) the editor offers three ways to compare original and edited:.single(one canvas, toggled),.wipe(a slider split), and.sideBySide(ImageEditorView+Types.swift:3-7,CompareMode). Single mode reuses the sharedDocumentCanvaszoom; side-by-side and wipe are plain images with their own independent zoom control, since a second zoom pill overDocumentCanvaswould fight with its own. Grounded in reading the enum and its call sites directly; no test found exercising any of the three modes.
PASS 2 — the fold (legacy milestone #168, 16 issues, every body read fresh)
E. New behaviors (fits, cited from HEAD, moved onto #315)
preview.edit.pdf-edit-toggle-is-dead— [BROKEN] (#2261) the edit toggle should work on a top-level PDF document, not only on one of its child pages. Verified BROKEN at HEAD:EditorView.previewRoute(EditorView.swift:190-198) checksdoc.docType == .pageand routes THAT topagePreviewRoute, which does checkisEditing— but a plain PDF file (doc.fileType == .pdf, not a page) returns.storageDisplayunconditionally, with noisEditingcheck at all. The per-page raster edit chain (ImageEditorView) already works; a PDF parent simply never routes to it.preview.edit.continuous-scroll-through-pages— [GAP] (#1933) the image editor should scroll continuously through a document’s pages/images, the way the PDF viewer’s continuous mode does, rather than one page at a time. Not verified as built — the editor’s canvas (ImageEditorView+Canvas.swift) shows the single active document, stepped via prev/next, with no continuous-scroll mode found.preview.edit.toolbar-icon-alignment— [GAP] (#1556) the edit button in the image mini-toolbar should share the same baseline/vertical centering and sizing as its neighbouring controls (zoom, fit). Not verified either way — a small, cosmetic claim not worth a source-scan proof either way.preview.edit.native-vision-remove-background— [GAP] (#1540) Remove Background should offer a macOS-native option (Vision’s subject-lift, the same engine Preview/Quick Look use) alongside the existing methods, since the current default’s output quality is poor. Verified at HEAD: the server’s_remove_background(media/image_ops.py:162-178) supports exactly three methods —rembg,opencv,threshold— no Vision-based method exists; the client’sremoveBackground(method:)defaults to"opencv"and offers no alternative in its UI. Not built.preview.edit.segments-panel-and-recombine— [GAP] (#504) splitting a document should surface a segments panel (browse the resulting pages/segments as separate items, run a workflow on one independently) and a recombine action restoring the parent — with no duplicate files created on disk. Verified PARTIALLY at HEAD: the underlying reversible split/uncrop chain operation is built and tested (seepreview.edit.crop-and-split-are- reversibleabove), but no segments-BROWSING panel or recombine UI was found anywhere underViews/Preview/ImageEditor/. The chain mechanism exists; the panel this issue actually asks for does not.preview.edit.epic-tracks-already-built-and-remaining-pieces— [GAP] (#1385, the founding EPIC: “AI-enhanced, non-destructive image editing — port legacy ML tools; apply to images AND PDF pages”) — its five stated principles are a mix of DONE and open, stated plainly rather than left as one undifferentiated ask: non-destructive storage is built (see section A above); an A/B compare UI is built (CompareMode: single/wipe/side-by-side,preview.edit.compare-modes-single-side-by-side-wipe); a macOS-native option for at least one tool (Remove Background) is NOT built (preview.edit.native-vision-remove-backgroundabove); “apply to any image OR to a page/range of pages of a PDF” is contradicted bypreview.edit.pdf-edit-toggle-is-deadabove — a PDF parent’s edit toggle doesn’t even route to the editor; EXIF-awareness across transforms was not independently checked this pass. Kept as one line naming the whole, with each concrete piece cited to its own specific behavior rather than duplicated here.
F. Redirected to an existing spec
- #4418 (“show recognised text regions on images AND PDFs”) →
segment-representations.mdassegment.overlay.recognized-text-regions— a region/anchor with its own provenance (PDF text layer, model, or human correction) is that spec’s own subject, not an editing operation. The bidirectional region↔text cursor it asks for is explicitly the SAME seamreader-overlay-frame-identity.mdalready owns, cited there rather than re-specified here.
G. Verify-close — evidence posted, left OPEN, not closed here
Given the test coverage Pass 1 found, several older issues describe symptoms already fixed by a DIFFERENT mechanism than the one they proposed — evidence stated as what it is (the code read, not a rendered screen):
- #502 (“Wire: Image Editing v1 — Crop + Rotate”) — every item in its own test checklist
is built and tested: crop/rotate live-update, Original toggle confirms the source is
unchanged, undo reverts, the chain persists across relaunch. Section A’s behaviors above
(
preview.edit.chain-is-a-separate-row-never-the-source,.crop-and-split-are-reversible,.quarter-turns-are-lossless) are this issue’s own acceptance criteria, verified from the code. - #503 (“Wire: Image Editing v2 — Enhance + Remove BG”) — enhance sliders
(brightness/contrast/sharpen state in
ImageEditorView) and a Remove Background action (ImageEditorModel.removeBackground(method:)) both exist and are reachable from the toolbar and the step panel. Not independently checked this pass: the specific “Auto-enhance” one-click button and whether export specifically re-applies the chain. - #1161 (“Document editing tools: deskew, color-correct, split, crop, rotate”) — every
named operation exists server-side:
detect_deskew_angle/auto_deskew,brightness/contrast(media/image_ops.py), crop, rotate, and split (Section A above); batch-apply across a multi-selection is built and tested (preview.edit.clipboard-copies-and-pastes-a-chain’s “pasting across a selection” case). - #1558 (“side-by-side / wipe compare forces images to square”) — verified FIXED at HEAD:
ImageEditorView+Canvas.swift’s wipe mode computesImageFit.fittedRect(imagePixelSize: in:)for its frame — a real aspect-ratio-preserving fit, not a hardcoded square — and both wipe layers carry.aspectRatio(contentMode: .fit). - #1590 (“edited image doesn’t update the image viewer / library thumbnail”) — the exact
complaint this issue names (“rotate an image and the library row and preview strip kept
showing the old one”) is fixed, but by a DIFFERENT, later mechanism than the issue’s own
proposed notification:
ImageEditorView’sonEditAppliedandfinishEditing()both callstorageService.invalidateImageCache(for:)ANDrenditionService?.invalidate(documentId:)— the exact two cachesImageEditRenditionRefreshTests’s own doc comment names as the ones that have to drop together. Still an explicitly open question, not fixed and not reopened as broken — this issue’s own body flags it, not this pass: workflows (Catalogue/Transcribe/OCR) resolve input viastorage.resolve_source(doc), which returns the ORIGINAL file with no knowledge of the edit chain, so a workflow run after an edit still processes the unedited original. Not independently re-checked this pass; the issue itself called this “flagged for decision,” not fixed. - #3213 (“Quartz evaluation — recommend hybrid, not a rewrite”) — its core recommendation
is built:
LiveEditPreview.swiftmaps the pending op toCIFilter.colorControls(),.sharpenLuminance(), and.straighten()for live, local preview while dragging, exactly the “client-side live preview with Core Image for the pending op only” the issue recommends — the server-authoritative chain remains the committed source of truth, unchanged. Not independently checked: the issue’s own proposed drift test (server render and CI preview agree within tolerance on a fixture set) and latency budget test. - #3756 (“wire reversible image editing into SwiftUI”) — crop, split, batch-apply, and
undo are all wired and tested (Section A and C above); “uncrop”/”unsplit” specifically were
not found as separately named operations — they read as the same generic
delete-a-step/revert-to-original mechanism
preview.edit.delete-step-and-revert-are- reachable-and-confirmedalready covers, not a gap of their own.
H. Maintainer triage — no home found
- #1174 (“Lightroom-style non-destructive Document Inspector for stage variants”) — a
stage/variant NAVIGATOR inside the Document Inspector, not this spec’s editing-operations
surface — no spec read this session owns the Document Inspector’s general tab/facet
structure (
kg-entity-inspector.mdis scoped to the entity focus pane specifically). - #1176 (“Non-destructive parametric image pipeline + transient cache”) — a BACKEND
workflow-tools pipeline for paleography preprocessing (
prepare_images/rotate/split/segment/enhance/convert_to_svgas workflow tools, with recipe caching/eviction), its own text says “backend-first… designed for SwiftUI inspector integration LATER” — a different pipeline from this spec’s interactiveImageEditChain, not this surface’s question to answer.
Milestone
Legacy milestone #168 does not reach zero this pass: of 16 open issues, 6 fit and move onto
315, 1 redirects to segment-representations.md, 7 are verify-close (evidence posted,
left OPEN for the maintainer, none closed here), and 2 go to maintainer triage — 9 remain on