Read, write and redact EXIF metadata in JPEG, PNG and TIFF files, in pure MoonBit.
$ moon run cmd/main -- read <文件> # 列出容器结构与每一条元数据
$ moon run cmd/main -- audit <文件> # 只报告按策略会删掉哪些,不动文件
$ moon run cmd/main -- redact <文件> # 按策略逐条删除,写到新文件
$ moon run cmd/main -- strip <文件> # 整段摘掉元数据,写到新文件$ python ci/make_fixture.py$ moon run cmd/main -- read .scratch/fix/cross.jpg
.scratch/fix/cross.jpg:jpeg
IFD0 目录(5 条)
0x010f Make = "NIKON CORPORATION"
0x0110 Model = "NIKON D850"
0x0131 Software = "MoonMeta CrossCheck 1.0"
0x013b Artist = "Zhang San"
0x8298 Copyright = "(C) 2026 Someone"
Exif 目录(7 条)
0x9003 DateTimeOriginal = "2026:09:24 15:51:00"
...
0xa431 BodySerialNumber = "BODY-0001234567"
GPS 目录(5 条)
0x0002 GPSLatitude = Rationals(3 value(s))
...
折算坐标:31.00375, 121.00241666666666
GPS 目录存在。
相机:NIKON CORPORATION NIKON D850$ moon run cmd/main -- audit .scratch/fix/cross.jpg --policy strict
.scratch/fix/cross.jpg: 策略 strict 命中 12 条。
IFD0/Artist (0x013b) → identity
Exif/DateTimeOriginal (0x9003) → timestamps
...
GPS/GPSLongitude (0x0004) → location$ moon run cmd/main -- read .scratch/fix/xmp_only.jpg
.scratch/fix/xmp_only.jpg:jpeg
这个文件里没有 EXIF。
但容器里另有一包 XMP:read 只摊开 EXIF 目录,包里的东西用 audit 看。$ moon run cmd/main -- read .scratch/fix/cross_xmp.jpg
.scratch/fix/cross_xmp.jpg:jpeg
IFD0 目录(5 条)
...
相机:NIKON CORPORATION NIKON D850
另含 XMP 包:read 不摊开它,逐条清单里也永远不会出现它。$ moon run cmd/main -- audit .scratch/fix/xmp_only.jpg --policy strict
.scratch/fix/xmp_only.jpg: 没有 EXIF,无需脱敏。
另含 XMP 包:逐条清单里看不到它;策略覆盖载体,会整包摘除。$ moon run cmd/main -- redact .scratch/fix/cross_xmp.jpg --policy strict
- IFD0/Artist (identity)
...
- GPS/GPSLongitude (location)
删除 12 条,并整包摘除 XMP。
已写出 .scratch/fix/cross_xmp.redacted.jpg$ moon run cmd/main -- read .scratch/fix/xmp_only.jpg --json
{"file":".scratch/fix/xmp_only.jpg","container":"jpeg","xmp":true,"iptc":false,"unread":[],"exif":false}$ moon run cmd/main -- audit .scratch/fix/cross_xmp.jpg --policy strict --json
{"file":".scratch/fix/cross_xmp.jpg","policy":"strict","xmp":true,"iptc":false,"unread":[],"exif":true,"findings":[{"dir":"IFD0","tag":315,"name":"Artist","category":"identity"},{"dir":"Exif","tag":36867,"name":"DateTimeOriginal","category":"timestamps"},{"dir":"Exif","tag":36868,"name":"DateTimeDigitized","category":"timestamps"},{"dir":"Exif","tag":36881,"name":"OffsetTimeOriginal","category":"timestamps"},{"dir":"Exif","tag":37500,"name":"MakerNote","category":"maker_note"},{"dir":"Exif","tag":42033,"name":"BodySerialNumber","category":"device_id"},{"dir":"Exif","tag":42037,"name":"LensSerialNumber","category":"device_id"},{"dir":"GPS","tag":0,"name":"GPSVersionID","category":"location"},{"dir":"GPS","tag":1,"name":"GPSLatitudeRef","category":"location"},{"dir":"GPS","tag":2,"name":"GPSLatitude","category":"location"},{"dir":"GPS","tag":3,"name":"GPSLongitudeRef","category":"location"},{"dir":"GPS","tag":4,"name":"GPSLongitude","category":"location"}]}$ moon run cmd/main -- audit .scratch/exif-samples/jpg/orientation/landscape_1.jpg --policy strict
.scratch/exif-samples/jpg/orientation/landscape_1.jpg: 策略 strict 命中 0 条。
另含 XMP 包:逐条清单里看不到它;策略覆盖载体,会整包摘除。
另含 IPTC/Photoshop 包:逐条清单里看不到它;策略覆盖载体,会整包摘除。
未解析段 1 段(本库不读也不删):APP12@102$ moon run cmd/main -- strip .scratch/exif-samples/jpg/orientation/landscape_1.jpg -o .scratch/d9e_doc/l1.jpg
元数据已整段摘除:EXIF,XMP 包,IPTC/Photoshop 包。
已写出 .scratch/d9e_doc/l1.jpg
$ moon run cmd/main -- read .scratch/d9e_doc/l1.jpg --json
{"file":".scratch/d9e_doc/l1.jpg","container":"jpeg","xmp":false,"iptc":false,"unread":["APP12@2"],"exif":false}$ moon run cmd/main -- redact .scratch/fix/cross.png -o .scratch/fix/cross.png
- IFD0/Artist (identity)
...
删除 9 条。
拒绝覆盖输入文件 .scratch/fix/cross.png;就地修改请加 --force$ moon run cmd/main -- audit .scratch/fix/cross.png --policy strick
未知策略 strick:可用的是 privacy 或 strict$ moon run cmd/main -- strip .scratch/fix/cross.stripped.jpg
这个文件本来就没有 EXIF,也没有 XMP 包。
已写出 .scratch/fix/cross.stripped.stripped.jpg$ moon run cmd/main -- redact .scratch/fix/bare_gps.tiff --keep-bytes --policy strict -o out.tif
原位模式:文件长度不变(1530 字节),像素与缩略图链一个字节未动(这个文件没有缩略图目录链)。
- IFD0/Artist (identity)
- GPS/GPSLongitude (location)
...
删除 7 条。
已写出 out.tif$ moon run cmd/main -- strip .scratch/fix/bare_gps.tiff --keep-bytes -o out.tif
原位模式:文件长度不变(1530 字节),像素与缩略图链一个字节未动(这个文件没有缩略图目录链)。
原位摘除非解码条目:EXIF。IFD0 里留着 10 条图像解码必需的字段(宽高/压缩/条带位置…):裸 TIFF 的图像说明与元数据在同一张表里,'整段摘除'在这里做不到。
已写出 out.tif
$ moon run cmd/main -- strip .scratch/fix/cross.tiff --keep-bytes -o out.tif
原位模式:文件长度不变(1292 字节),像素与缩略图链一个字节未动(这个文件没有缩略图目录链)。
这个文件的 IFD0 里没有不参与解码的条目,一个字节都没改。IFD0 里留着 10 条图像解码必需的字段(宽高/压缩/条带位置…):裸 TIFF 的图像说明与元数据在同一张表里,'整段摘除'在这里做不到。
已写出 out.tif$ moon run cmd/main -- redact .scratch/fix/cross.jpg --keep-bytes --policy strict -o out.jpg
这个容器是 jpeg,本来就不搬字节:--keep-bytes 在这里不改变任何行为。
- IFD0/Artist (identity)
...
删除 12 条。
已写出 out.jpg///|
fn audit_and_clean(data : Bytes) -> Bytes raise {
let (_kind, block) = @moonmeta.decode_any(data)
let findings = match block {
Some(b) => b.findings(@moonmeta.strict_policy())
None => []
}
for r in findings {
println("\{r.dir.label()}/\{r.name} → \{r.category.label()}")
}
let (clean, _report) = @moonmeta.redact_any(data, @moonmeta.strict_policy())
clean
}Exif/DateTimeOriginal → timestamps
GPS/GPSLatitudeRef → location
GPS/GPSLatitude → location
GPS/GPSLongitudeRef → location
GPS/GPSLongitude → location| 函数 | 作用 |
|---|---|
| sniff(data) | 只看魔数判容器,认不出来返回 None(不猜) |
| decode_any(data) | → (Kind, TiffBlock?),容器自适应 |
| TiffBlock::findings(policy) | 只读审计,返回将被删除的条目清单 |
| redact_any(data, policy) | 逐条删除 + 报告,容器自适应 |
| strip_any(data) | 整段摘除,并报告原本有没有 |
| decode_tiff / encode_tiff | 裸 TIFF 块的读与回写 |
| tiff_redact_inplace(data, policy) | 就地脱敏:只改目录表那一段,长度不变 |
| tiff_strip_inplace(data) | 就地摘除非解码条目,并说明"整段"为何做不到 |
| jpeg_* / png_* | 容器层的段表 / 块表走查与原位替换 |
| jpeg_inspect / png_inspect | 段表 / 块表,含 has_xmp 与每包的偏移区间 |
| jpeg_drop_xmp / png_drop_xmp | 整包摘除,返回 (新文件, 真摘了没有) |
| Policy::covers(category) | 这个策略管不管这一类;报告的话术由它决定,不另写一份 |
| describe_container(data) | 一行容器概览,给报告用 |
| 类别 | 覆盖什么 | privacy | strict |
|---|---|---|---|
| Location | GPS 目录里的全部条目(含版本号)+ 坐标本身 | ✔ | ✔ |
| Identity | Artist、ImageUniqueID | ✔ | ✔ |
| DeviceId | 机身 / 镜头序列号 | ✔ | ✔ |
| MakerNote | 厂商私有块(内容不受规范约束,坐标常藏在里面) | ✔ | ✔ |
| Comments | UserComment | ✔ | ✔ |
| Timestamps | DateTime 及各 OffsetTime* / SubsecTime* / DateTime* | ✘ | ✔ |
| Carrier | 容器里成包的第二份副本:JPEG 的 XMP APP1 与 APP13(Photoshop 3.0 资源包,IPTC-IIM 住在里面)、PNG 里关键字为 XML:com.adobe.xmp 的 tEXt/iTXt,以及 TIFF 里装整包的 0x02bc / 0x02bd / 0x83bb | ✔ | ✔ |
moon test # wasm(moon.mod 的 preferred_target)
moon test --target js
moon test --target wasm-gc$ moon run cmd/main -- audit 一张照片.jpg --policy strict$ moon work init
$ moon work use 本仓库的路径 你的模块的路径///|
import {
"shencangsheng513/moonmeta",
}pub suberror MetaError {
TooShort(Int, Int, Int)
BadTiffHeader(Int)
BadByteOrder(Int)
IfdOffsetOutOfRange(Int64)
EntryTooLarge(Int, Int, Int, Int)
ValueOffsetOutOfRange(Int, Int64)
UnsupportedType(Int, Int)
SubIfdOutOfRange(Int, Int64)
UnencodableText(Int, Int)
TypeMismatch(Int, Int)
ValueOutOfRange(Int, Int64)
NotJpeg(Int)
SegmentTooLarge(Int, Int, Int)
TruncatedAt(Int)
SegmentOverflow(Int, Int, Int)
ExtraMetadata(Int)
NotPng(Int)
BadChecksum(Int, Int64, Int64)
TiffNotByteExact(Int64, Int, Int)
TiffInplaceShared(Int, Int, Int, Int, String)
TiffInplaceCycle(Int)
TiffInplaceOpaque(Int, Int)
UnknownFormat(Int)
} derive(Debug)pub(all) struct JpegMap {
segments : Array[JpegSegment]
exif : (Int, Int)?
has_xmp : Bool
xmp : Array[(Int, Int)]
iptc : Array[(Int, Int)]
has_iptc : Bool
unread : Array[JpegSegment]
} derive(Eq, Debug)fn crc32(data : Bytes) -> Int64fn type_size(code : Int) -> Int?Install
Download zipRead, write and redact EXIF metadata in JPEG, PNG and TIFF files, in pure MoonBit.