No description
  • Go 70.4%
  • Svelte 9.1%
  • JavaScript 8.4%
  • Python 7%
  • CSS 4.4%
  • Other 0.7%
Find a file
Steffen Rörtgen 5e59c73ab8
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m6s
ci: retry after runner disk cleanup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 08:38:47 +02:00
.forgejo/workflows ci: build and push image to forgejo registry (amb-relay convention) 2026-07-14 11:50:41 +02:00
cmd/metacleaner feat(cli): -pages/-remove-pages on apply, strip, oer 2026-07-16 14:16:39 +02:00
docs/superpowers docs(plan): PDF page extraction implementation plan 2026-07-16 13:45:32 +02:00
internal fix: final-review findings — UI copy, thumbs semaphore + 500s, qpdf timeout, linearized assertion 2026-07-16 15:07:36 +02:00
scripts test: multi-page fixture with per-page recoverability markers 2026-07-16 13:54:51 +02:00
web fix: final-review findings — UI copy, thumbs semaphore + 500s, qpdf timeout, linearized assertion 2026-07-16 15:07:36 +02:00
.dockerignore feat: dockerfile for homelab deployment; document web service 2026-07-10 12:24:56 +02:00
.gitignore feat: web frontend scaffold with tested ops model and upload screen 2026-07-10 12:01:19 +02:00
CLAUDE.md docs: page extraction — README features, CLAUDE.md commands 2026-07-16 14:54:52 +02:00
Dockerfile feat(web): server-rendered page thumbnails via pdftoppm 2026-07-16 14:28:54 +02:00
flake.lock chore: scaffold nix dev shell, go module, tool smoke test 2026-07-09 11:58:23 +02:00
flake.nix feat(web): server-rendered page thumbnails via pdftoppm 2026-07-16 14:28:54 +02:00
go.mod chore: scaffold nix dev shell, go module, tool smoke test 2026-07-09 11:58:23 +02:00
go.work chore: scaffold nix dev shell, go module, tool smoke test 2026-07-09 11:58:23 +02:00
LICENSE chore: dedicate to the public domain (Unlicense) 2026-07-15 09:51:18 +02:00
README.md docs: page extraction — README features, CLAUDE.md commands 2026-07-16 14:54:52 +02:00
SPEC.md docs: add spec, CLAUDE.md, and phase-1 plan 2026-07-09 11:55:15 +02:00

metacleaner

A local, offline metadata inspector and cleaner for PDFs and images.

Open a file, see all the metadata it carries — grouped by where it actually lives — delete, edit, or add fields, and write a cleaned copy. metacleaner then re-reads the output file and shows you the real on-disk result, never a predicted diff. No file ever leaves your machine, there is no telemetry, and everything works offline.

It exists for two symmetric jobs:

  • Stripping tool provenance. Design tools sign your exports: a PDF exported from Canva names Canva in the DocInfo dictionary and in the XMP stream, and carries design IDs in the keywords. metacleaner strip removes tool provenance from every store at once.
  • Writing clean provenance. For Open Educational Resources: metacleaner oer embeds author, title, license, source URL, and language into the file — and can emit a JSON sidecar for AMB ingestion.

It can also extract or remove PDF pages — pick pages visually in the web editor or via -pages "1-3,7" / -remove-pages "4,9" on the CLI; page selection always fully rewrites the output so removed pages are unrecoverable.

Supported formats: PDF (.pdf); images .jpg/.jpeg, .png, .tif/.tiff, .webp (.heic/.heif is wired but untested). Office/OpenDocument support is planned.

Contents

What makes it careful

Metadata cleaning has classic failure modes; metacleaner is built around avoiding them:

  1. It clears every store. PDF metadata lives in two independent places — the DocInfo dictionary and the XMP stream — and the same fact (e.g. producing software) usually appears in both. Images similarly spread facts across EXIF, IPTC, and XMP. metacleaner shows all stores, links duplicated facts, and cleans them together.
  2. It never fingerprints itself. Many tools stamp their own name into the files they "clean" (e.g. pdf:Producer = pikepdf x.y.z). metacleaner writes no trace of itself, and its acceptance tests verify that no self-trace ends up in the output.
  3. It doesn't silently bump dates. Rewriting a file normally updates xmp:MetadataDate, revealing the file was reprocessed. Dates are preserved by default (-no-preserve-dates to opt out).
  4. It flattens the output. Incremental PDF edits keep old revisions recoverable inside the file — "removed" data that isn't gone. By default metacleaner fully rewrites the output (-no-flatten to opt out) and then runs a byte-pattern leak scan for every removed value.
  5. It never touches the original. Output is always a copy, by default <name>-clean.<ext>.
  6. It shows the truth. After writing, the output file is re-read and the actual on-disk metadata is displayed — not a prediction.

Honest limitations: metadata removal makes origin less obvious, not unprovable — fonts, content-stream naming, and compression fingerprints can still identify the source tool. Camera maker notes (MakerNotes) are shown read-only in the Other store and are not removed by strip; on real camera photos, serial numbers or GPS data may live there. The leak scan is a heuristic, not proof of unrecoverability. Removing pages can leave bookmarks or links that pointed at those pages dangling — viewers tolerate this, but the reference is not cleaned up.

Installation

metacleaner shells out to external tools: ExifTool, qpdf, and Python 3 with pikepdf (for PDF writing). They must be on PATH. The web service additionally needs pdftoppm from poppler-utils to render the page thumbnails used by the page picker; the CLI does not need it.

The dev shell provides Go and all external tools:

nix develop -c go build ./cmd/metacleaner
./metacleaner inspect file.pdf        # run inside `nix develop` too

Manual

Install exiftool, qpdf, and python3 + pikepdf with your package manager, then:

go build ./cmd/metacleaner

Docker (web service)

docker build -t metacleaner .
docker run --rm -p 8080:8080 metacleaner

The image bundles all dependencies and the built web UI, and serves on port 8080.

CLI reference

metacleaner <command> [flags] <file>

Commands that write output share three flags:

Flag Default Meaning
-out PATH <name>-clean.<ext> output path; the original is never modified
-no-flatten off (flatten on) skip the full rewrite — old revisions stay recoverable!
-no-preserve-dates off (dates preserved) let metadata dates update
-compress off|balanced|strong off recompress embedded images (PDF only, see below)
-pages "1-3,7" (none) keep only these pages (PDF only, see below)
-remove-pages "4,9" (none) drop these pages (PDF only, see below)

-compress shrinks large embedded images in PDFs: balanced downsamples to max 2000 px and re-encodes as JPEG quality 80, strong to max 1200 px / quality 60. Images are reached wherever they live, including nested inside Form XObjects (how Canva and similar tools wrap page content). Soft-masked images are re-encoded with their alpha mask preserved untouched. Images with a color-key mask, fax/JPEG2000 encodings, or a stencil mask are left untouched, and a stream is only replaced when the re-encoding actually comes out smaller. Compression is lossy and opt-in — it defaults to off.

-pages/-remove-pages extract or remove PDF pages (1-based, comma- and range-separated, e.g. "1-3,7"); they are mutually exclusive. Page selection always forces a full rewrite — even overriding -no-flatten — so removed pages, and anything that only lived on them, are unrecoverable. Bookmarks or links that pointed at a removed page may dangle; viewers tolerate this.

inspect — show all metadata

metacleaner inspect [-json] <file>

-json emits machine-readable output. Human output groups fields by store; ! marks fields flagged sensitive, and the right column is the field ID used in operations files:

$ metacleaner inspect fixture.pdf
[DocInfo]
! /Author           Jane Doe                   pdf.docinfo./Author
  /CreationDate     2024:01:15 10:00:00+00:00  pdf.docinfo./CreationDate
! /Creator          Canva                      pdf.docinfo./Creator
! /Keywords         DAHKMBpJu1c,BAFap7-f260,0  pdf.docinfo./Keywords
! /Producer         Canva                      pdf.docinfo./Producer
  /Title            Fixture Document           pdf.docinfo./Title
[XMP]
! dc:creator        Jane Doe                   xmp.dc:creator
  dc:title          Fixture Document           xmp.dc:title
! pdf:Keywords      DAHKMBpJu1c,BAFap7-f260,0  xmp.pdf:Keywords
! pdf:Producer      Canva                      xmp.pdf:Producer
! xmp:CreatorTool   Canva                      xmp.xmp:CreatorTool
  xmp:MetadataDate  2024:01:15 10:00:00+00:00  xmp.xmp:MetadataDate

strip — remove tool provenance

metacleaner strip [-out PATH] [-no-flatten] [-no-preserve-dates] [-compress off|balanced|strong] [-pages "1-3,7" | -remove-pages "4,9"] <file>

Removes Creator/Producer/CreatorTool/Keywords and similar provenance fields from all stores, writes the cleaned copy, re-reads it, and leak-scans the output bytes for every removed value:

$ metacleaner strip fixture.pdf
planned operations (6):
  delete pdf.docinfo./Creator
  delete pdf.docinfo./Keywords
  delete pdf.docinfo./Producer
  delete xmp.pdf:Keywords
  delete xmp.pdf:Producer
  delete xmp.xmp:CreatorTool

written: fixture-clean.pdf (original untouched)
result after re-reading the output:
[DocInfo]
! /Author           Jane Doe                   pdf.docinfo./Author
  ...
leak scan: no leaks found — byte-pattern scan (heuristic, not proof of unrecoverability)

Note that /Author survives: strip targets tool provenance, not authorship. Delete authors explicitly via apply if you want them gone.

Combine with page selection to drop pages in the same pass, e.g. to strip provenance and keep only the first three pages:

metacleaner strip -pages "1-3" doc.pdf

apply — apply an operations file

metacleaner apply -ops ops.json [-out PATH] [-no-flatten] [-no-preserve-dates] [-compress off|balanced|strong] [-pages "1-3,7" | -remove-pages "4,9"] <file>

-ops is a JSON array of operations; it is required unless -compress or -pages/-remove-pages is set, in which case apply may run a compress-only and/or pages-only pass with no metadata changes. Three op types:

[
  {"type": "delete", "fieldId": "pdf.docinfo./Author"},
  {"type": "set", "fieldId": "xmp.dc:title", "value": "New Title"},
  {"type": "add", "store": "XMP", "key": "dc:rights", "value": "CC BY-SA 4.0"}
]

Field IDs come from inspect (rightmost column, or -json). Stores for add: DocInfo, XMP, EXIF, IPTC, PNG (Other is read-only).

oer — write OER provenance

metacleaner oer [-creator NAME] [-title TITLE] [-license LICENSE] [-source URL] \
                [-language CODE] [-sidecar] [-out PATH] \
                [-no-flatten] [-no-preserve-dates] [-compress off|balanced|strong] \
                [-pages "1-3,7" | -remove-pages "4,9"] <file>
Flag Meaning
-creator author name
-title resource title
-license license, e.g. "CC BY-SA 4.0"
-source canonical URL
-language language code, e.g. de
-sidecar also write <out>.oer.json for AMB ingestion

Each metadata flag is optional, but at least one must be set — with no operations to apply, the command errors out.

metacleaner oer -creator "Jane Doe" -title "Bruchrechnung Arbeitsblatt" \
  -license "CC BY-SA 4.0" -source "https://example.org/ab-bruch" \
  -language de -sidecar arbeitsblatt.pdf

serve — run the web service

metacleaner serve [-addr 127.0.0.1:8080] [-ui-dir web/dist] \
                  [-max-upload-mb 200] [-session-ttl 15m] [-max-sessions 100]
Flag Default Meaning
-addr 127.0.0.1:8080 listen address
-ui-dir web/dist directory with the built web UI
-max-upload-mb 200 maximum upload size in MB
-session-ttl 15m session lifetime (sliding)
-max-sessions 100 maximum concurrent sessions

Build the UI first (npm --prefix web install && npm --prefix web run build); without it the server still runs the API and shows a hint page.

Web service & HTTP API

The web UI (upload → edit → verified result) is a thin client over a JSON API you can use directly, e.g. with curl. Design notes live in docs/superpowers/specs/2026-07-10-web-service-design.md.

Session model / privacy: an upload creates a server-side session (files under a private temp dir, metadata in memory). Sessions expire after a sliding TTL (default 15 minutes) or on shutdown, and everything is deleted — zero retention. Request logs never contain filenames, field values, or file bytes. There is no authentication; run it on localhost or behind your own proxy. JSON endpoints accept bodies up to 10 MB; uploads up to -max-upload-mb.

Errors are JSON: {"error": "message"} with status 400 (bad input), 404 (unknown/expired session, or nothing to download yet), 413 (upload too large), 500 (internal failure), or 503 (too many active sessions).

Method & path Purpose
POST /api/files upload; create session; return inspected metadata
GET /api/files/{id}/ops/strip strip-provenance ops for this file
POST /api/oer-ops build OER provenance ops
POST /api/files/{id}/apply apply ops; verify; return before/after
GET /api/files/{id}/download download the cleaned copy
GET /api/files/{id}/sidecar download the OER sidecar
GET /api/files/{id}/thumbs/{n} PNG thumbnail of page n (PDF only)
GET /healthz liveness (ok)

POST /api/files

Multipart form upload, field name file:

curl -F file=@doc.pdf http://127.0.0.1:8080/api/files
{
  "sessionId": "abc123",
  "filename": "doc.pdf",
  "fields": [
    {
      "id": "pdf.docinfo./Producer",
      "store": "DocInfo",
      "namespace": "docinfo",
      "key": "/Producer",
      "label": "/Producer",
      "value": "Canva",
      "editable": true,
      "removable": true,
      "sensitive": true,
      "linkedIds": ["xmp.pdf:Producer"]
    }
  ],
  "pageCount": 4
}

linkedIds connects the same logical fact across stores. pageCount is the PDF's page count (0 for images and for PDFs qpdf can't parse); the editor uses it to decide whether to show the page picker.

GET /api/files/{id}/ops/strip

Returns the operations strip would run, for review or editing:

{"ops": [{"type": "delete", "fieldId": "pdf.docinfo./Producer"}]}

POST /api/oer-ops

Builds OER provenance ops. filename selects the right target fields for the file type (PDF vs image); it is not stored:

{
  "creator": "Jane Doe",
  "title": "Bruchrechnung Arbeitsblatt",
  "license": "CC BY-SA 4.0",
  "source": "https://example.org/ab-bruch",
  "language": "de",
  "filename": "doc.pdf"
}

Response: {"ops": [...]} as above.

POST /api/files/{id}/apply

Applies operations and re-reads the output. JSON booleans default to false — send flatten and preserveDates explicitly (the UI sends both true):

{
  "ops": [{"type": "delete", "fieldId": "pdf.docinfo./Producer"}],
  "flatten": true,
  "preserveDates": true,
  "compress": "balanced",
  "pages": "1-3,7",
  "pagesMode": "keep",
  "sidecar": {"creator": "Jane Doe", "title": "...", "license": "CC BY-SA 4.0", "source": "https://...", "language": "de"}
}

compress is optional ("off" / "balanced" / "strong", PDF only — see the CLI reference above); omit it or send "off" to skip recompression. pages is optional (1-based ranges, e.g. "1-3,7", PDF only); pagesMode is "keep" (default) or "remove". Page selection always forces a full rewrite regardless of flatten. ops may be empty when a compress preset and/or page selection is active (compress-only and/or pages-only apply); without either, an empty ops list is rejected. sidecar is optional; when present, an .oer.json sidecar is written and offered for download. Response:

{
  "before": [ /* MetaField[] as uploaded */ ],
  "after":  [ /* MetaField[] re-read from the cleaned file */ ],
  "leaks": [],
  "warnings": [],
  "downloadUrl": "/api/files/abc123/download",
  "sidecarUrl": "/api/files/abc123/sidecar",
  "sizeBefore": 182340,
  "sizeAfter": 94210,
  "compression": {"processed": 2, "skipped": 0, "bytesBefore": 150000, "bytesAfter": 62000},
  "pageStats": {"before": 4, "after": 3}
}

leaks lists removed values still found in the output bytes (should be empty); sidecarUrl appears only if a sidecar was requested. sizeBefore/sizeAfter are the on-disk file sizes and are always present; compression is present only when a compress preset ran, and reports how many embedded images were recompressed vs. left untouched; pageStats is present only when a page selection ran, and reports the page count before and after.

GET /api/files/{id}/download, GET /api/files/{id}/sidecar

Return the cleaned file / sidecar as attachments; 404 until apply has run (or if no sidecar was requested).

GET /api/files/{id}/thumbs/{n}

Returns a PNG thumbnail (long edge scaled to 240 px, rendered via pdftoppm) of 1-based page n of the uploaded PDF, for the editor's page picker; 404 for non-PDFs, an out-of-range page, or an unrenderable file. Thumbnails are rendered on first request and cached for the rest of the session — they live under the session's temp dir, so the zero-retention TTL cleanup covers them too.

Development

Everything runs inside the Nix dev shell:

nix develop -c go test ./...                                  # full test suite
nix develop -c go build ./cmd/metacleaner                     # build the CLI
nix develop -c go run ./cmd/metacleaner serve                 # web service on :8080
nix develop -c npm --prefix web install                       # frontend deps
npm --prefix web run build                                    # build SPA to web/dist
npm --prefix web test                                         # frontend tests (vitest)
nix develop -c python3 scripts/make_fixture.py /tmp/fx.pdf    # Canva-like test PDF
nix develop -c python3 scripts/make_fixture.py --pages 4 /tmp/fx.pdf  # multi-page fixture, each page marked PAGE-MARKER-n
nix develop -c python3 scripts/make_image_fixture.py jpeg /tmp/fx.jpg  # test image

Layout:

  • internal/meta — format-agnostic model (MetaField, Op, field IDs)
  • internal/engine — exiftool reader, pikepdf writer (PDF), exiftool writer (images), qpdf flatten, leak scan, presets, page keep/remove (pages.go, pdf_apply.py's select_pages)
  • cmd/metacleaner — the CLI
  • internal/web — HTTP server: sessions, JSON API, SPA serving, page thumbnails (thumbs.go, via pdftoppm)
  • web/ — Svelte + Vite SPA

SPEC.md is the engine requirements document; internal/engine/acceptance_test.go encodes its acceptance criteria.

License

Public domain — see LICENSE (Unlicense).