// moonmeta 的命令行前端。
//
// 这里刻意只做三件事:读文件、调库、把结果打成人能读的样子。
// "删什么"的判断全在库里——CLI 一旦也有一份策略,库和命令行的说法就会分叉,
// 而脱敏工具最怕的就是同一份文件两种口径。
//
// 一条硬规矩:写操作不覆盖输入文件。输出默认落在
// `<原名>.redacted.<后缀>`;想就地改必须明说 `--force`。
///|
enum Action {
Read
Audit
Redact
Strip
}
///|
struct Request {
action : Action
path : String
out : String?
policy : String
force : Bool
keep_bytes : Bool
json : Bool
}
///|
/// 决定"走哪条写路"要用的三件事,打包传:旗标、容器、以及这份文件里有没有
/// 一条原地模式不碰的目录链。第三件必须一起传:只报 `dropped_thumbnail: false`
/// 说不清"没有链"和"有链但这次不动它"。
struct WriteMode {
keep_bytes : Bool
kind : @lib.Kind
chain : Bool
}
///|
fn write_mode(req : Request, kind : @lib.Kind, chain : Bool) -> WriteMode {
{ keep_bytes: req.keep_bytes, kind, chain, }
}
///|
fn main {
let code = run(strip_program_name(@env.args()))
if code != 0 {
@sys.exit(code)
}
}
///|
fn strip_program_name(argv : Array[String]) -> Array[String] {
match argv {
[_, .. rest] => rest.to_owned()
[] => []
}
}
///|
/// 命令行的全部逻辑收在这里:只返回退出码,不自己退出。
/// 这样以后要给 `run` 加测试,不用先把进程杀掉。
fn run(argv : Array[String]) -> Int {
let cmd = command()
let parsed = cmd.parse(argv~, env=Map([])) catch {
_ => {
println("参数解析失败,见上方用法")
return 2
}
}
match to_request(parsed) {
None => {
println(cmd.render_help())
2
}
Some(req) => execute(req)
}
}
///|
fn command() -> @argparse.Command {
@argparse.Command(
"moonmeta",
about="读 / 审 / 删 JPEG·PNG·TIFF 里的 EXIF 元数据",
version="moonmeta 0.1.0",
subcommand_required=true,
subcommands=[
@argparse.Command(
"read",
about="列出容器结构与每一条元数据,不改动文件",
positionals=[
@argparse.PositionArg("file", about="要看的图片文件"),
],
flags=[json_flag()],
),
@argparse.Command(
"audit",
about="只报告按策略会被删掉的条目,不改动文件",
positionals=[
@argparse.PositionArg("file", about="要审计的图片文件"),
],
options=[policy_option()],
flags=[json_flag()],
),
@argparse.Command(
"redact",
about="按策略逐条删除,结果写到新文件",
positionals=[
@argparse.PositionArg("file", about="要脱敏的图片文件"),
],
options=[policy_option(), output_option()],
flags=[force_flag(), keep_bytes_flag()],
),
@argparse.Command(
"strip",
about="整段摘掉元数据,结果写到新文件",
positionals=[@argparse.PositionArg("file", about="要清空的文件")],
options=[output_option()],
flags=[force_flag(), keep_bytes_flag()],
),
],
)
}
///|
fn policy_option() -> @argparse.OptionArg {
@argparse.OptionArg(
"policy",
about="privacy(默认,保留时间戳)或 strict(连时间一起清)",
)
}
///|
fn output_option() -> @argparse.OptionArg {
@argparse.OptionArg(
"output",
short='o',
about="输出路径;不给就在原名上加 .redacted / .stripped",
)
}
///|
fn force_flag() -> @argparse.FlagArg {
@argparse.FlagArg(
"force",
about="允许把结果写回输入文件,默认不允许",
)
}
///|
/// 裸 TIFF 的两条写路:重建整份文件(默认),或者一个字节都不搬、
/// 只把目录表原地收缩。后者能处理真实照片(IFD 后面就是像素),
/// 代价是缩略图链与像素都不碰,所以删不完的东西也就不假装删完了。
fn keep_bytes_flag() -> @argparse.FlagArg {
@argparse.FlagArg(
"keep-bytes",
about="裸 TIFF 改为原位收缩目录:文件长度不变,像素与缩略图一个字节不动",
)
}
///|
/// 给人看的措辞以后还会改,脚本不该依赖它。`--json` 输出稳定、纯 ASCII,
/// 供 CI 与外部工具(如 Pillow 对拍)机读。
fn json_flag() -> @argparse.FlagArg {
@argparse.FlagArg(
"json",
about="以单行 JSON 输出,供脚本与对拍使用",
)
}
///|
fn to_request(matches : @argparse.Matches) -> Request? {
match matches.subcommand {
None => None
Some((name, sub)) => {
let action = match name {
"read" => Read
"audit" => Audit
"redact" => Redact
"strip" => Strip
_ => return None
}
match one(sub, "file") {
None => None
Some(path) =>
Some({
action,
path,
out: one(sub, "output"),
policy: or_else(one(sub, "policy"), "privacy"),
force: sub.flags.get("force") == Some(true),
keep_bytes: sub.flags.get("keep-bytes") == Some(true),
json: sub.flags.get("json") == Some(true),
})
}
}
}
}
///|
fn one(m : @argparse.Matches, key : String) -> String? {
match m.values.get(key) {
Some(v) if v.length() > 0 => Some(v[0])
_ => None
}
}
///|
fn or_else(o : String?, d : String) -> String {
match o {
Some(s) => s
None => d
}
}
///|
fn execute(req : Request) -> Int {
let data = @fs.read_file_to_bytes(req.path) catch {
e => {
println("\{req.path}: 读取失败:\{io_text(e)}")
return 1
}
}
let p = match policy_of(req.policy) {
None => {
println("未知策略 \{req.policy}:可用的是 privacy 或 strict")
return 2
}
Some(p) => p
}
let decoded = @lib.decode_any(data) catch {
e => {
println("\{req.path}: \{e}")
return 1
}
}
dispatch(req, data, decoded.0, decoded.1, p)
}
///|
/// 打错字的策略不能悄悄退回默认值:`--policy strick` 若按 privacy 执行,
/// 用户会以为时间戳也清了。
fn policy_of(name : String) -> @lib.Policy? {
match name {
"privacy" => Some(@lib.privacy_policy())
"strict" => Some(@lib.strict_policy())
_ => None
}
}
///|
fn io_text(e : @fs.IOError) -> String {
@debug.to_string(e)
}
///|
/// 容器层面"逐条清单装不下"的那几件事,一次走查取回。
///
/// `xmp`/`iptc` 是成包的副本,策略覆盖载体时会被整包摘除;
/// `unread` 是我们既读不了也不会删的段/块。三种状态各有各的一句话,
/// 都不能从 `findings` 的长度推出来——那正是"报告上一切正常、实际什么都没脱"
/// 那种事故的形状。
struct Notes {
xmp : Bool
iptc : Bool
unread : Array[String]
}
///|
fn empty_notes() -> Notes {
{ xmp: false, iptc: false, unread: [], }
}
///|
/// 容器里另有一包 XMP / IPTC 吗?还有哪些段/块是我们没解析的?
fn notes_of(data : Bytes, kind : @lib.Kind) -> Notes {
match kind {
@lib.Jpeg =>
try @lib.jpeg_inspect(data) catch {
// 走查失败的文件在调用方那一侧本来就要报错,这里不额外发明第二套说法。
_ => empty_notes()
} noraise {
m =>
{
xmp: m.has_xmp,
iptc: m.has_iptc,
unread: m.unread.map(s => "\{s.name()}@\{s.offset}"),
}
}
@lib.Png =>
try @lib.png_inspect(data) catch {
_ => empty_notes()
} noraise {
m =>
{
xmp: m.has_xmp,
// PNG 侧没有"成包的 IPTC"可摘:那套住在 Raw profile 文本块里,
// 本库不解析它,它会以 `tEXt@偏移` 的形式出现在 unread 里。
iptc: false,
unread: m.unread.map(c => "\{c.kind}@\{c.offset}"),
}
}
// 裸 TIFF 的 XMP / IPTC 都是 IFD0 里的一条(0x02bc / 0x83bb),
// 逐条清单本来就看得见它们,所以这里不再重复交代一遍。
@lib.Tiff => empty_notes()
}
}
///|
fn dispatch(
req : Request,
data : Bytes,
kind : @lib.Kind,
block : @lib.TiffBlock?,
p : @lib.Policy,
) -> Int {
let notes = notes_of(data, kind)
let mode = write_mode(req, kind, has_chain(block))
match req.action {
Read =>
if req.json {
println(read_json(req.path, kind, block, notes))
0
} else {
show_read(req.path, kind, block, notes)
}
Audit =>
if req.json {
println(audit_json(req.path, req.policy, block, p, notes))
0
} else {
show_audit(req.path, req.policy, block, p, notes)
}
Redact => redact_file(req, data, mode, p, notes)
Strip => strip_file(req, data, mode)
}
}
///|
/// `read --json` 的形状(键序固定,便于逐字节比对):
/// {"file":..,"container":..,"xmp":false,"iptc":false,"unread":[],"exif":false}
/// 有 EXIF 时再给 byte_order / directories / has_ifd1 / gps。
/// `xmp`/`iptc`/`unread` 与 text 输出里那几句"另含…包""未解析段…"是同一件事:
/// 机器读者不该比人拿到的更少。
/// 坐标以字符串给出:`Double` 可能打印成非有限值,那会把整行 JSON 变成非法的。
fn read_json(
path : String,
kind : @lib.Kind,
block : @lib.TiffBlock?,
notes : Notes,
) -> String {
let head = "{\"file\":" +
json_str(path) +
",\"container\":" +
json_str(kind_name(kind)) +
",\"xmp\":" +
bool_json(notes.xmp) +
",\"iptc\":" +
bool_json(notes.iptc) +
",\"unread\":" +
arr_json(notes.unread)
match block {
None => head + ",\"exif\":false}"
Some(b) =>
head +
",\"exif\":true,\"byte_order\":" +
json_str(if b.little { "II" } else { "MM" }) +
",\"directories\":[" +
dir_json(@lib.Ifd0, b.ifd0) +
"," +
dir_json(@lib.Exif, b.exif) +
"," +
dir_json(@lib.Gps, b.gps) +
"," +
dir_json(@lib.Interop, b.interop) +
"],\"has_ifd1\":" +
bool_json(b.has_ifd1) +
",\"gps\":" +
gps_json(b) +
"}"
}
}
///|
fn kind_name(kind : @lib.Kind) -> String {
match kind {
Jpeg => "jpeg"
Png => "png"
Tiff => "tiff"
}
}
///|
fn gps_json(b : @lib.TiffBlock) -> String {
match b.gps_lat_lon() {
None => "null"
Some((lat, lon)) => json_str(lat.to_string() + "," + lon.to_string())
}
}
///|
fn dir_json(dir : @lib.Dir, entries : Array[@lib.IfdEntry]) -> String {
let parts : Array[String] = []
for e in entries {
parts.push(entry_json(dir, e))
}
"{\"dir\":" +
json_str(dir.label()) +
",\"entries\":[" +
join_comma(parts) +
"]}"
}
///|
/// 名字要按**这条条目所在的目录**查:编号 2 在 IFD0 是 `BitsPerSample`,
/// 在 GPS 目录是 `GPSLatitude`。写死一个目录会成片报错名字。
fn entry_json(dir : @lib.Dir, e : @lib.IfdEntry) -> String {
"{\"tag\":" +
e.tag.to_string() +
",\"name\":" +
json_str(@lib.tag_name(dir, e.tag)) +
",\"type\":" +
e.type_code.to_string() +
",\"value\":" +
json_str(e.value.to_string()) +
"}"
}
///|
fn join_comma(parts : Array[String]) -> String {
let mut s = ""
let n = parts.length()
for i in 0.. String {
if b {
"true"
} else {
"false"
}
}
///|
/// 字符串数组转 JSON 数组。每个元素都过 `json_str`:段名里也可能有非 ASCII,
/// 直接拼会把一整行 JSON 变成非法的。
fn arr_json(items : Array[String]) -> String {
"[" + join_comma(items.map(s => json_str(s))) + "]"
}
///|
fn audit_json(
path : String,
name : String,
block : @lib.TiffBlock?,
p : @lib.Policy,
notes : Notes,
) -> String {
let found : Array[@lib.Removed] = match block {
None => []
Some(b) => b.findings(p)
}
let parts : Array[String] = []
for r in found {
parts.push(
"{\"dir\":" +
json_str(r.dir.label()) +
",\"tag\":" +
r.tag.to_string() +
",\"name\":" +
json_str(r.name) +
",\"category\":" +
json_str(r.category.label()) +
"}",
)
}
// "xmp"/"iptc"/"unread" 是给机器读的那句"这里还有 findings 装不下的东西"。
// 少了它们,findings 为空就分不清三种状态:"没有敏感东西"、
// "敏感东西在策略管不到的包里"、"在我们不读也不删的那些段里"。
"{\"file\":" +
json_str(path) +
",\"policy\":" +
json_str(name) +
",\"xmp\":" +
bool_json(notes.xmp) +
",\"iptc\":" +
bool_json(notes.iptc) +
",\"unread\":" +
arr_json(notes.unread) +
",\"exif\":" +
bool_json(block != None) +
",\"findings\":[" +
join_comma(parts) +
"]}"
}
///|
/// 整串转义成纯 ASCII:非 ASCII 一律走 `\uXXXX`(超出 BMP 的拆成代理对)。
/// 中文文件名、ISO-8859-1 的 Make/Model 都不会把非法字节漏进输出。
fn json_str(s : String) -> String {
let mut out = "\""
for c in s.to_array() {
let code = c.to_int()
if c == '"' {
out = out + "\\\""
} else if c == '\\' {
out = out + "\\\\"
} else if code == 8 {
out = out + "\\b"
} else if code == 12 {
out = out + "\\f"
} else if code == 10 {
out = out + "\\n"
} else if code == 13 {
out = out + "\\r"
} else if code == 9 {
out = out + "\\t"
} else if code < 32 {
out = out + "\\u00" + hex_nibbles(code, 2)
} else if code < 128 {
out = out + c.to_string()
} else if code <= 0xFFFF {
out = out + "\\u" + hex_nibbles(code, 4)
} else {
let v = code - 0x10000
out = out +
"\\u" +
hex_nibbles(0xD800 + (v >> 10), 4) +
"\\u" +
hex_nibbles(0xDC00 + (v & 0x3FF), 4)
}
}
out + "\""
}
///|
fn hex_nibbles(v : Int, count : Int) -> String {
let mut s = ""
let mut i = count - 1
while i >= 0 {
s = s + nibble((v >> (4 * i)) & 0xf)
i = i - 1
}
s
}
///|
fn show_read(
path : String,
kind : @lib.Kind,
block : @lib.TiffBlock?,
notes : Notes,
) -> Int {
println("\{path}:\{kind.to_string()}")
match block {
None => {
println(" 这个文件里没有 EXIF。")
print_if(notes.xmp, read_xmp_note(false))
print_if(notes.iptc, read_iptc_note(false))
print_unread(notes)
0
}
Some(b) => {
dump_dir(@lib.Ifd0, b.ifd0)
dump_dir(@lib.Exif, b.exif)
dump_dir(@lib.Gps, b.gps)
dump_dir(@lib.Interop, b.interop)
match b.gps_lat_lon() {
Some((lat, lon)) =>
println(" 折算坐标:\{lat.to_string()}, \{lon.to_string()}")
None => ()
}
print_if(b.has_gps(), " GPS 目录存在。")
print_if(
b.has_ifd1,
" 另有缩略图目录(IFD1):任何写操作都会整段丢掉它",
)
print_line(
" 相机:\{or_else(b.make(), "?")} \{or_else(b.model(), "?")}",
)
print_if(notes.xmp, read_xmp_note(true))
print_if(notes.iptc, read_iptc_note(true))
print_unread(notes)
0
}
}
}
///|
/// `read` 只摊开 EXIF 目录。包着的那一份它看不见,所以必须说出来——
/// "这个文件里没有 EXIF"和"这个文件没有元数据"是两回事。
fn read_xmp_note(has_exif : Bool) -> String {
if has_exif {
" 另含 XMP 包:read 不摊开它,逐条清单里也永远不会出现它。"
} else {
" 但容器里另有一包 XMP:read 只摊开 EXIF 目录,包里的东西用 audit 看。"
}
}
///|
/// IPTC/Photoshop 包的同构那一句。
///
/// 不能省也不能和 XMP 混成一句:两者在写侧是同一条待遇(策略覆盖载体时都整包摘),
/// 但读侧是两个不同的包,只报一个就等于另一半没说。
fn read_iptc_note(has_exif : Bool) -> String {
if has_exif {
" 另含 IPTC/Photoshop 包:read 不摊开它,逐条清单里也永远不会出现它。"
} else {
" 但容器里另有一包 IPTC/Photoshop:read 只摊开 EXIF 目录,包里的东西用 audit 看。"
}
}
///|
/// 我们没解析、也不会删的那些段/块,逐件点名。
///
/// 这一句是"未解析"三个字的兑现:不点名,用户看到"删除 0 条"就以为整个文件干净了。
fn print_unread(notes : Notes) -> Unit {
if !notes.unread.is_empty() {
println(
" 未解析段 \{notes.unread.length()} 段(本库不读也不删):\{join_comma(notes.unread)}",
)
}
}
///|
fn print_if(cond : Bool, line : String) -> Unit {
if cond {
println(line)
}
}
///|
fn print_line(line : String) -> Unit {
println(line)
}
///|
fn dump_dir(dir : @lib.Dir, entries : Array[@lib.IfdEntry]) -> Unit {
println(" \{dir.label()} 目录(\{entries.length()} 条)")
for e in entries {
println(
" \{hex_tag(e.tag)} \{@lib.tag_name(dir, e.tag)} = \{e.value.to_string()}",
)
}
}
///|
fn show_audit(
path : String,
name : String,
block : @lib.TiffBlock?,
p : @lib.Policy,
notes : Notes,
) -> Int {
match block {
None => {
println("\{path}: 没有 EXIF,无需脱敏。")
print_if(notes.xmp, carrier_note(p))
print_if(notes.iptc, iptc_carrier_note(p))
print_unread(notes)
0
}
Some(b) => {
let found = b.findings(p)
println("\{path}: 策略 \{name} 命中 \{found.length()} 条。")
for r in found {
println(
" \{r.dir.label()}/\{r.name} (\{hex_tag(r.tag)}) → \{r.category.label()}",
)
}
print_if(notes.xmp, carrier_note(p))
print_if(notes.iptc, iptc_carrier_note(p))
print_unread(notes)
0
}
}
}
///|
/// XMP 包那一句交代,只在真的带包时打印(调用方用 print_if 把关)。
///
/// 这句话不能省:XMP 不是一条 IFD 条目,逐条清单里永远不会出现它。
fn carrier_note(p : @lib.Policy) -> String {
if p.covers(@lib.Carrier) {
"另含 XMP 包:逐条清单里看不到它;策略覆盖载体,会整包摘除。"
} else {
"警告:另含 XMP 包,当前策略不碰它。里面常是同一批事实的第二份副本——拍摄时间、机型、署名。"
}
}
///|
/// IPTC/Photoshop 包的同构那一句:写侧与 XMP 同一条待遇,所以两种状态都要说。
fn iptc_carrier_note(p : @lib.Policy) -> String {
if p.covers(@lib.Carrier) {
"另含 IPTC/Photoshop 包:逐条清单里看不到它;策略覆盖载体,会整包摘除。"
} else {
"警告:另含 IPTC/Photoshop 包,当前策略不碰它。署名、版权与联系人常就在这包里。"
}
}
///|
fn redact_file(
req : Request,
data : Bytes,
mode : WriteMode,
p : @lib.Policy,
notes : Notes,
) -> Int {
let (bytes, report) = redact_by(data, mode, p) catch {
e => {
println("\{req.path}: \{e}")
return 1
}
}
for r in report.removed {
println(" - \{r.dir.label()}/\{r.name} (\{r.category.label()})")
}
let n = report.removed.length()
println(
"删除 \{n} 条\{thumbnail_note(report.dropped_thumbnail)}\{xmp_note(notes.xmp, report.dropped_xmp)}\{iptc_note(notes.iptc, report.dropped_iptc)}。",
)
print_unread(notes)
write_result(req, bytes)
}
///|
/// 走哪条写路。
///
/// `--keep-bytes` 只有裸 TIFF 需要:JPEG / PNG 本来就是"只换装元数据的那一段",
/// 其余字节原样搬回去。在那两种容器上这个旗标什么都不改,所以要说一句
/// "这里不适用",而不是安静地按原路走——安静地接受一个被忽略的旗标,
/// 等于教会用户相信它生效了。
fn redact_by(
data : Bytes,
mode : WriteMode,
p : @lib.Policy,
) -> (Bytes, @lib.Redaction) raise @lib.MetaError {
if !choose_inplace(mode) {
@lib.redact_any(data, p)
} else {
// 先落笔再声明:那句"长度不变"是做完的凭证。原位模式在半路拒绝时
// (语料里真有一例:某个条目的类型码这一版不解码)如果话已经先说出去,
// 用户拿到的就是"长度不变"+"我没做"两句同时成立的话。
let done = @lib.tiff_redact_inplace(data, p)
println(inplace_said(data.length(), mode.chain))
done
}
}
///|
/// 同上的 strip 版。那句承诺同样只能在落笔成功之后说。
fn strip_by(
data : Bytes,
mode : WriteMode,
) -> (Bytes, @lib.Strip) raise @lib.MetaError {
if !choose_inplace(mode) {
@lib.strip_any(data)
} else {
let done = @lib.tiff_strip_inplace(data)
println(inplace_said(data.length(), mode.chain))
done
}
}
///|
fn choose_inplace(mode : WriteMode) -> Bool {
if !mode.keep_bytes {
false
} else if mode.kind == @lib.Tiff {
true
} else {
println(
"这个容器是 \{mode.kind},本来就不搬字节:--keep-bytes 在这里不改变任何行为。",
)
false
}
}
///|
/// 原位模式做完了要当场声明自己的边界:长度没变是它的承诺,
/// 像素与缩略图链没动是它的代价。不说这一句,用户会以为
/// `--keep-bytes` 出来的文件和默认那条路等价。
///
/// "这一条链还在"必须由文件本身的结构说出来:`dropped_thumbnail` 在原地
/// 模式下永远是 false,它的意思是"这一次什么都没丢",不是"这文件里没有"。
/// 把两者混为一谈,就是让用户以为缩略图里的第二份坐标也没了。
fn inplace_said(len : Int, chain : Bool) -> String {
if chain {
"原位模式:文件长度不变(\{len} 字节)。这份文件还有一条缩略图目录链,原位模式不碰它——里面可能另有一份坐标。要连它一起清只能走不带 --keep-bytes 的那条路,而那条路要求整份文件能被逐字节重建;重建不了的裸 TIFF 会在落笔前拒绝并说出会丢多少字节,那种文件两条路都清不到链里这一份。"
} else {
"原位模式:文件长度不变(\{len} 字节),像素与缩略图链一个字节未动(这个文件没有缩略图目录链)。"
}
}
///|
fn has_chain(block : @lib.TiffBlock?) -> Bool {
match block {
Some(b) => b.has_ifd1
None => false
}
}
///|
/// XMP 包同样要单独交代:它不在逐条清单里,不写这一句就等于删了一整包没人知道。
///
/// 三个状态都要有落点:"本来没有"什么都不说,"有但这份策略不删"必须警告。
/// 把后者按前者处理,就是让用户以为已经删干净。
fn xmp_note(present : Bool, dropped : Bool) -> String {
if dropped {
",并整包摘除 XMP"
} else if present {
";警告:XMP 包还在,当前策略不碰它"
} else {
""
}
}
///|
/// IPTC/Photoshop 包的同一段话。形状与 `xmp_note` 一模一样,但不共用函数:
/// 两句话要各自被测试逐字钉住,合并成一句参数的版本会让其中一格失去落点。
fn iptc_note(present : Bool, dropped : Bool) -> String {
if dropped {
",并整包摘除 IPTC/Photoshop 包"
} else if present {
";警告:IPTC/Photoshop 包还在,当前策略不碰它"
} else {
""
}
}
///|
/// 缩略图必须单独交代:它常自带一份 GPS,"删了主坐标却留着缩略图"
/// 是最常见的假脱敏。
fn thumbnail_note(dropped : Bool) -> String {
if dropped {
",并丢掉可能自带坐标的缩略图"
} else {
""
}
}
///|
fn strip_file(req : Request, data : Bytes, mode : WriteMode) -> Int {
let (bytes, had) = strip_by(data, mode) catch {
e => {
println("\{req.path}: \{e}")
return 1
}
}
println(
if inplace_mode(mode) {
inplace_strip_said(had, bytes)
} else {
strip_said(had)
},
)
write_result(req, bytes)
}
///|
/// 走没走原位那条路。与 `choose_inplace` 同一判据,但不印任何东西:
/// 这句话要说两遍,第二遍不该把"这里不适用"再播报一次。
fn inplace_mode(mode : WriteMode) -> Bool {
mode.keep_bytes && mode.kind == @lib.Tiff
}
///|
/// 原位 strip 说的是另一句话,因为它的承诺不一样。
///
/// 裸 TIFF 的图像说明(宽高、压缩、条带位置……)和元数据住在同一张 IFD0
/// 表里:把它们一起删,产物就不再是一张图——门禁在 104 份陌生 TIFF 里量到
/// 81 份因此解不出像素。所以这条路不许说"整段摘除",它只能说"删掉所有不
/// 参与解码的条目",并且把留下的那些数出来。数字从产物里读回来,
/// 不是从计划里抄:抄来的数只说明我们打算留什么。
fn inplace_strip_said(had : @lib.Strip, out : Bytes) -> String {
let kept = kept_image_entries(out)
let head = if had.had_exif || had.dropped_xmp || had.dropped_iptc {
"原位摘除非解码条目:" + join_comma(strip_parts(had)) + "。"
} else {
"这个文件的 IFD0 里没有不参与解码的条目,一个字节都没改。"
}
if kept > 0 {
"\{head}IFD0 里留着 \{kept} 条图像解码必需的字段(宽高/压缩/条带位置…):裸 TIFF 的图像说明与元数据在同一张表里,'整段摘除'在这里做不到。"
} else {
head
}
}
///|
/// 产物里 IFD0 还剩几条条目。子目录指针不算:`decode_tiff` 把它们当成结构
/// 摘出去了,剩下能数到的就真是"这张图怎么解出来"那部分。
fn kept_image_entries(out : Bytes) -> Int {
let parsed = try @lib.decode_any(out) catch {
_ => None
} noraise {
(_, b) => b
}
match parsed {
Some(b) => b.ifd0.length()
None => 0
}
}
///|
/// 摘除之后怎么说。对拍脚本要拿这句话跟文件本身的事实比对,
/// 所以四个状态各有一句、且都不许多说。
fn strip_said(had : @lib.Strip) -> String {
// "本来就没有"和"已清除"是两回事;说错这一句,整个工具就没人敢信了。
// 三件事分开说:EXIF 摘干净而 XMP 或 IPTC 留着,都不叫摘干净。
if had.had_exif || had.dropped_xmp || had.dropped_iptc {
"元数据已整段摘除:" + join_comma(strip_parts(had)) + "。"
} else {
"这个文件本来就没有 EXIF,也没有 XMP 包与 IPTC 包。"
}
}
///|
fn strip_parts(had : @lib.Strip) -> Array[String] {
let parts : Array[String] = []
if had.had_exif {
parts.push("EXIF")
}
if had.dropped_xmp {
parts.push("XMP 包")
}
if had.dropped_iptc {
parts.push("IPTC/Photoshop 包")
}
parts
}
///|
fn write_result(req : Request, bytes : Bytes) -> Int {
match output_path(req) {
None => {
println(
"输出路径与输入相同;确实要就地修改请加 --force",
)
2
}
Some(path) =>
if path == req.path && !req.force {
println(
"拒绝覆盖输入文件 \{req.path};就地修改请加 --force",
)
2
} else {
let _ = @fs.write_bytes_to_file(path, bytes) catch {
e => {
println("写入 \{path} 失败:\{io_text(e)}")
return 1
}
}
println("已写出 \{path}")
0
}
}
}
///|
/// 默认输出路径:在扩展名前加 `.redacted` / `.stripped`,这样绝不撞输入文件。
fn output_path(req : Request) -> String? {
match req.out {
Some(p) => Some(p)
None => {
let suffix = match req.action {
Redact => ".redacted"
Strip => ".stripped"
_ => ""
}
if suffix == "" {
None
} else {
Some(with_suffix(req.path, suffix))
}
}
}
}
///|
fn with_suffix(path : String, suffix : String) -> String {
match split_ext(path) {
None => path + suffix
Some((stem, ext)) => stem + suffix + ext
}
}
///|
/// 在最后一个 `.` 处切开。点号必须落在最后一个分隔符之后,否则那是
/// `.hidden`、`..` 这类没有扩展名的路径,直接往后加后缀。
fn split_ext(path : String) -> (String, String)? {
let chars = path.to_array()
let sep = last_separator(chars)
match last_index(chars, '.') {
None => None
Some(dot) =>
if dot == 0 || (sep != None && dot < sep.unwrap()) {
None
} else {
Some((take(chars, 0, dot), take(chars, dot, chars.length())))
}
}
}
///|
fn last_separator(chars : Array[Char]) -> Int? {
let mut found : Int? = None
let mut i = 0
while i < chars.length() {
if chars[i] == '/' || chars[i] == '\\' {
found = Some(i)
}
i = i + 1
}
found
}
///|
fn last_index(chars : Array[Char], c : Char) -> Int? {
let mut i = chars.length() - 1
while i >= 0 {
if chars[i] == c {
return Some(i)
}
i = i - 1
}
None
}
///|
fn take(chars : Array[Char], from : Int, to : Int) -> String {
let mut s = ""
let mut i = from
while i < to {
s = s + chars[i].to_string()
i = i + 1
}
s
}
///|
fn hex_tag(tag : Int) -> String {
"0x" +
nibble((tag >> 12) & 0xf) +
nibble((tag >> 8) & 0xf) +
nibble((tag >> 4) & 0xf) +
nibble(tag & 0xf)
}
///|
fn nibble(v : Int) -> String {
let digits = "0123456789abcdef"
digits.to_array()[v & 0xf].to_string()
}