- Go 70.4%
- Svelte 9.1%
- JavaScript 8.4%
- Python 7%
- CSS 4.4%
- Other 0.7%
|
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| cmd/metacleaner | ||
| docs/superpowers | ||
| internal | ||
| scripts | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.work | ||
| LICENSE | ||
| README.md | ||
| SPEC.md | ||
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 stripremoves tool provenance from every store at once. - Writing clean provenance. For Open Educational Resources:
metacleaner oerembeds 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:
- 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.
- 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. - 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-datesto opt out). - 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-flattento opt out) and then runs a byte-pattern leak scan for every removed value. - It never touches the original. Output is always a copy, by default
<name>-clean.<ext>. - 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.
Nix (recommended)
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'sselect_pages)cmd/metacleaner— the CLIinternal/web— HTTP server: sessions, JSON API, SPA serving, page thumbnails (thumbs.go, viapdftoppm)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).