metaclean 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,45 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The "policy" module: which tools to run for which file, and what counts as
4
- # privacy-relevant if it survives a clean.
5
- #
6
- # Keeping this logic in its own file means the runner doesn't need to know
7
- # about formats — it just asks Strategy.tools_for(path) and runs whatever
8
- # comes back.
9
-
10
3
  module Metaclean
11
4
  module Strategy
12
- # Group-name PREFIXES treated as privacy-bearing. Matching whole families by
13
- # prefix keeps the residual check fail-closed instead of an exact allowlist
14
- # that silently misses variants:
15
- # GPS* — GPS plus any sub-group
16
- # XMP-* — every XMP namespace (XMP-exif GPS, XMP-mwg-rs face/person
17
- # names, XMP-xmpMM DocumentID, XMP-iptcExt, …)
18
- # MakerNotes*, IPTC*
19
- # IFD1 — the embedded thumbnail IFD; a surviving thumbnail can carry
20
- # the original's full EXIF+GPS
21
- # Over-flagging here is deliberate: for a privacy tool a false "still
22
- # present" is far cheaper than a false "Cleaned". (ICC colour-profile groups
23
- # are intentionally NOT flagged — a colour profile isn't PII; any genuinely
24
- # identifying field such as Copyright is still caught by PRIVACY_TAGS below.)
25
5
  PRIVACY_GROUP_PREFIXES = %w[GPS XMP- MakerNotes IPTC IFD1].freeze
26
6
 
27
- # Formats ExifTool can't WRITE, so it leaves document-internal metadata only
28
- # mat2's rebuild removes (and can't re-read to verify). If mat2 won't run for
29
- # one of these, the runner warns coverage is reduced rather than reporting a
30
- # confident "Cleaned". (PDF is NOT here: ExifTool writes PDF metadata and qpdf
31
- # rebuilds the file, so PDF is fully handled and verifiable without mat2.)
32
- MAT2_ESSENTIAL = %w[docx xlsx pptx odt ods odp odg odf epub].freeze
33
-
34
- # Specific tag NAMES (regardless of group) we never want to leak.
35
- # If exiftool reports e.g. "EXIF:Artist" we still flag it because of the
36
- # tag-name match, not the group. exiftool's `-all=` normally strips these,
37
- # so this list is a fail-closed BACKSTOP: if any survive a strip we'd rather
38
- # over-warn than report a confident "Cleaned".
7
+ MAT2_ESSENTIAL = %w[
8
+ docx xlsx pptx odt ods odp odg odf odc odi epub
9
+ zip tar torrent html htm xhtml xht ncx
10
+ ].freeze
11
+
39
12
  PRIVACY_TAGS = %w[
40
13
  Artist Author Creator Copyright Rights
41
14
  By-line By-lineTitle Credit Source Contact OwnerName
42
15
  CameraOwnerName SerialNumber InternalSerialNumber LensSerialNumber
16
+ CameraSerialNumber UniqueCameraModel LocalizedCameraModel
17
+ RawDataUniqueID OriginalRawFileName OriginalRawFileData
18
+ OriginalRawFileDigest RawImageDigest NewRawImageDigest
19
+ CameraCalibrationSignature ProfileCalibrationSignature
20
+ ProfileName ProfileCopyright AsShotProfileName
43
21
  Software HostComputer ProcessingSoftware
44
22
  ImageDescription UserComment
45
23
  LastModifiedBy LastSavedBy LastAuthor
@@ -49,62 +27,49 @@ module Metaclean
49
27
  XPAuthor XPComment XPSubject XPKeywords XPTitle Comment
50
28
  ].freeze
51
29
 
52
- # File extensions where mat2 is meaningfully stricter than ExifTool and
53
- # should run first. For other formats, ExifTool is the broader expert.
54
- # (mkv/webm are NOT here — see FFMPEG_FORMATS; no mat2/ExifTool path writes
55
- # Matroska.)
56
30
  MAT2_PREFERRED = %w[
57
- docx xlsx pptx odt ods odp odg odf epub png svg
31
+ docx xlsx pptx odt ods odp odg odf odc odi epub png svg svgz
58
32
  mp4 avi
59
33
  ].freeze
60
34
 
61
- # Matroska containers. ExifTool is read-only for them and mat2 has no
62
- # Matroska parser, so neither can strip mkv/webm. ffmpeg is the only tool in
63
- # the set that can — it remuxes the container dropping all metadata while
64
- # copying every stream verbatim (lossless, no re-encode).
35
+ RESIDUAL_IGNORED_GROUPS = %w[System ExifTool Composite].freeze
36
+
37
+ ICC_PRIVACY_TAGS = %w[
38
+ ProfileDescription ProfileDescriptionML ProfileCopyright ProfileCreator
39
+ ProfileCMMType DeviceManufacturer DeviceModel DeviceMfgDesc DeviceModelDesc
40
+ ].freeze
41
+
42
+ ICC_STANDARD_DESCRIPTIONS = [
43
+ 'srgb iec61966-2.1', 'srgb', 'srgb iec61966-2-1 black scaled',
44
+ 'srgb iec61966-2-1 no black scaling', 'display p3', 'dci-p3', 'p3-d65 (display)',
45
+ 'adobe rgb (1998)', 'prophoto rgb', 'romm rgb: iso 22028-2:2013',
46
+ 'apple rgb', 'colormatch rgb', 'camera rgb profile',
47
+ 'apple wide color sharing profile', 'linear rgb profile',
48
+ 'generic rgb profile', 'generic gray profile', 'generic gray gamma 2.2 profile',
49
+ 'generic lab profile', 'generic cmyk profile', 'generic xyz profile',
50
+ 'gray gamma 2.2', 'dot gain 20%',
51
+ 'rec. itu-r bt.709-5', 'hdtv (rec. 709)', 'sdtv (rec. 601)'
52
+ ].freeze
53
+
65
54
  FFMPEG_FORMATS = %w[mkv webm].freeze
66
55
 
67
- # Raster formats mat2 cannot strip without DAMAGING the file: it rebuilds via
68
- # Pillow, which recompresses JPEG/WebP (visible quality loss — a clean
69
- # wallpaper drops ~65% in size with no metadata to remove) and downconverts
70
- # TIFF (16-bit → 8-bit). ExifTool strips all of these completely and IN PLACE
71
- # (pixels byte-identical), so ExifTool owns them and mat2 is skipped —
72
- # cleaning metadata must never silently damage the file.
73
- MAT2_DEGRADES = %w[jpg jpeg webp tif tiff].freeze
56
+ MAT2_DEGRADES = %w[jpg jpe jpeg webp tif tiff heic].freeze
74
57
 
75
58
  module_function
76
59
 
77
- # Returns an ordered list of tool symbols (e.g. `[:mat2, :exiftool, :qpdf]`)
78
- # to run on `path`. The runner executes them in order; if one fails or
79
- # is skipped, the next still runs. The tools are always used together
80
- # for maximum coverage — there is no per-tool opt-out; a tool that isn't
81
- # installed is simply left out (the `.available?`/`.supports?` checks).
82
60
  def tools_for(path)
83
61
  ext = Metaclean.ext_of(path)
84
62
  tools = []
85
63
 
86
64
  if ext == 'pdf'
87
- # mat2 cleans PDFs by RASTERIZING every page (text → images): it destroys
88
- # the text layer and balloons the file (~35×). So PDFs skip mat2 and use:
89
- # exiftool → strips the Info dictionary + XMP (Author, Title, Producer…)
90
- # qpdf → rebuilds the file, dropping unreferenced objects / old revisions
91
- # Both are lossless and leave the text intact. (PDF JS/macros are out of
92
- # scope — see README.)
93
65
  tools << :exiftool
94
66
  tools << :qpdf if Qpdf.available?
95
67
  elsif FFMPEG_FORMATS.include?(ext)
96
- # Matroska (mkv/webm): ffmpeg is the ONLY tool that can clean these.
97
- # ExifTool still re-reads the result afterwards, so the residual check
98
- # (the false-clean backstop) is not blind.
99
68
  tools << :ffmpeg if Ffmpeg.available?
100
69
  elsif MAT2_PREFERRED.include?(ext) && Mat2.available?
101
- # Office docs, modern image/video containers — mat2 leads.
102
70
  tools << :mat2
103
71
  tools << :exiftool
104
72
  else
105
- # Everything else (JPEG, MP3, RAW, …) — ExifTool has the broadest coverage.
106
- # mat2 still adds coverage for many, but NOT for rasters it would damage
107
- # (MAT2_DEGRADES) — there ExifTool's in-place strip is complete and lossless.
108
73
  tools << :exiftool
109
74
  tools << :mat2 if Mat2.supports?(path) && !MAT2_DEGRADES.include?(ext)
110
75
  end
@@ -112,62 +77,72 @@ module Metaclean
112
77
  tools
113
78
  end
114
79
 
115
- # Looks at metadata read AFTER cleaning and returns the entries that
116
- # still look privacy-relevant. The runner uses this for the "still
117
- # present" warning at the end of each file.
118
- #
119
- # Why both group-match and tag-match? Tag names can appear under
120
- # different groups depending on the format (e.g. "Author" in PDF vs
121
- # "Artist" in EXIF). Combining the two keeps coverage broad without
122
- # having to enumerate every {group, tag} pair.
123
- def privacy_residual(meta)
80
+ def privacy_residual(meta, allow_icc_metadata: false)
81
+ standard_icc = standard_icc_profile?(meta)
124
82
  meta.select do |k, v|
125
- # Skip SourceFile and the System/File/etc. groups — not user metadata.
126
- next false unless Display.embedded_key?(k)
83
+ key = k.to_s
84
+ next false if key == 'SourceFile'
127
85
 
128
- # ExifTool keys look like "GPS:GPSLatitude". Split on the first ":";
129
- # no "Group:" prefix means the whole key is the tag name.
130
- group, tag = k.to_s.split(':', 2)
86
+ group, tag = key.split(':', 2)
131
87
  name = tag.nil? ? group.to_s : tag
88
+ next false if RESIDUAL_IGNORED_GROUPS.include?(group.to_s)
89
+
90
+ icc_privacy = group.to_s.start_with?('ICC') && ICC_PRIVACY_TAGS.include?(name)
91
+ if group.to_s.start_with?('ICC')
92
+ next false if allow_icc_metadata || standard_icc
93
+ next false unless icc_privacy
94
+ end
132
95
 
133
- # A zeroed/empty value is not a leak for un-removable container atoms like
134
- # QuickTime:CreateDate (deletable only by zeroing, "0000:00:00 …") — without
135
- # this every video would fail the gate on an already-zeroed date. GPS is the
136
- # exception: 0,0 is a REAL location (Null Island) and a coordinate ExifTool
137
- # reports as 0 (or null) must still be caught, so the blank exemption NEVER
138
- # applies to GPS-family entries — the whole point of the fail-closed backstop.
139
96
  gps = group.to_s.start_with?('GPS') || name.start_with?('GPS')
140
- next false if !gps && blank_value?(v)
97
+ next false if !gps && exempt_blank?(name, v)
141
98
 
142
- privacy_group?(group) || privacy_tag?(name)
99
+ icc_privacy || privacy_group?(group) || privacy_tag?(name)
143
100
  end
144
101
  end
145
102
 
146
- # True when a value carries no information: empty, or only zeros plus date/time
147
- # punctuation and the "Z" (UTC) marker — e.g. "0000:00:00 00:00:00", or the ASF
148
- # variant "0000:00:00 00:00:00Z" that mat2 writes into WMV's mandatory date
149
- # field. Only the digit 0 is stripped (never 1-9), so a real value like
150
- # "59.9139", "Jane Doe", or a real "2024:..." date keeps other characters and
151
- # is NOT blank. (GPS is exempt from this check entirely — see privacy_residual.)
103
+ def standard_icc_profile?(meta)
104
+ meta.any? do |k, v|
105
+ key = k.to_s
106
+ next false unless key.start_with?('ICC') && key.split(':', 2).last == 'ProfileDescription'
107
+
108
+ ICC_STANDARD_DESCRIPTIONS.include?(normalize_icc_value(v))
109
+ end
110
+ end
111
+
112
+ def normalize_icc_value(value)
113
+ value.to_s.strip.downcase.gsub(/\s+/, ' ')
114
+ end
115
+
116
+ def exempt_blank?(name, value)
117
+ return true if value.to_s.strip.empty?
118
+
119
+ date_like_tag?(name) && blank_value?(value)
120
+ end
121
+
122
+ def date_like_tag?(name)
123
+ n = name.to_s
124
+ n.end_with?('Date') || n.start_with?('DateTime')
125
+ end
126
+
152
127
  def blank_value?(value)
153
128
  s = value.to_s
154
129
  s.strip.empty? || s.gsub(/[Z0\s:.+-]/, '').empty?
155
130
  end
156
131
 
157
- # A group is privacy-bearing if it matches one of the family prefixes
158
- # (GPS, XMP-, MakerNotes, IPTC, IFD1).
159
132
  def privacy_group?(group)
160
133
  PRIVACY_GROUP_PREFIXES.any? { |p| group.to_s.start_with?(p) }
161
134
  end
162
135
 
163
- # A tag is privacy-bearing if it's in the exact list OR is any GPS* tag
164
- # (GPSLatitude/GPSLongitude/GPSPosition/… regardless of group).
165
136
  def privacy_tag?(tag)
166
137
  t = tag.to_s
167
- PRIVACY_TAGS.include?(t) || t.start_with?('GPS')
138
+ return true if PRIVACY_TAGS.include?(t) || t.start_with?('GPS')
139
+ return true if t.match?(/SerialNumber\z/i)
140
+ return true if t.match?(/\AOriginalRawFile(?:Name|Data|Digest)\z/i)
141
+ return true if t.match?(/\A(?:RawDataUniqueID|RawImageDigest|NewRawImageDigest)\z/i)
142
+
143
+ false
168
144
  end
169
145
 
170
- # Does this path need mat2 for adequate coverage? (See MAT2_ESSENTIAL.)
171
146
  def mat2_essential?(path)
172
147
  MAT2_ESSENTIAL.include?(Metaclean.ext_of(path))
173
148
  end
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Single source of truth for the program's version.
4
- # Both the gemspec and `metaclean --version` read from here, so we only have
5
- # one place to bump.
6
-
7
3
  module Metaclean
8
- VERSION = '4.1.0'
4
+ VERSION = '4.2.0'
9
5
  end
data/lib/metaclean.rb CHANGED
@@ -1,90 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Library entry point. require order matters: dependencies before dependents.
4
-
5
3
  require 'open3'
6
4
 
7
5
  require 'metaclean/version'
8
6
  require 'metaclean/display'
7
+ require 'metaclean/file_ops'
9
8
  require 'metaclean/exiftool'
10
9
  require 'metaclean/mat2'
11
10
  require 'metaclean/qpdf'
12
11
  require 'metaclean/ffmpeg'
13
12
  require 'metaclean/strategy'
13
+ require 'metaclean/discovery'
14
+ require 'metaclean/committer'
14
15
  require 'metaclean/runner'
15
16
  require 'metaclean/cli'
16
17
 
17
18
  module Metaclean
18
19
  class Error < StandardError; end
19
20
 
20
- # Raised by ensure_tools! when any of the four required external tools is not
21
- # on PATH. metaclean runs ExifTool, mat2, qpdf and ffmpeg together and refuses
22
- # to run without all of them.
23
21
  class ToolsMissing < Error; end
24
22
 
25
- # A path beginning with "-" is misread as an *option* by the tools we shell
26
- # out to — e.g. exiftool's `-config FILE` loads and runs arbitrary Perl.
27
- # Open3 argument arrays bypass the shell, but NOT the invoked tool's own
28
- # option parser. Prefixing a leading-dash relative path with "./" makes it
29
- # unambiguously a filename to every tool. Absolute paths and normal names
30
- # pass through untouched. Used at every shell-out boundary.
31
23
  def self.safe_path(path)
32
24
  s = path.to_s
33
25
  s.start_with?('-') ? File.join('.', s) : s
34
26
  end
35
27
 
36
- # External tools can hang, or run away producing endless output, on a corrupt
37
- # or hostile file. Every OPERATIONAL shell-out (read/strip/rebuild) goes through
38
- # this instead of Open3.capture3 so one bad file is bounded on BOTH axes — by
39
- # wall-clock (COMMAND_TIMEOUT) and by captured bytes (MAX_OUTPUT_BYTES) — rather
40
- # than hanging or exhausting memory and taking the whole batch with it. The
41
- # quick availability probes (`-ver`/`--version`) stay on plain capture3: fixed
42
- # args, no file input, nothing to hang on.
43
- COMMAND_TIMEOUT = 120 # seconds
44
- # Per stream (stdout AND stderr). Far above any legitimate output from the
45
- # tools' invocations here (metadata JSON / `-q` strips / `-v error` muxes), so
46
- # tripping it means a runaway, not a real result.
28
+ COMMAND_TIMEOUT = 120
47
29
  MAX_OUTPUT_BYTES = 64 * 1024 * 1024
30
+ PROBE_TIMEOUT = 10
31
+ PROBE_MAX_OUTPUT_BYTES = 1024 * 1024
48
32
  READ_CHUNK = 64 * 1024
49
33
 
50
- # Drop-in replacement for Open3.capture3 that returns the same [out, err,
51
- # status] triple but kills the command (and anything it spawned) if it runs
52
- # past `timeout` OR floods more than `max_output` bytes on either stream.
53
- def self.capture3(*cmd, timeout: COMMAND_TIMEOUT, max_output: MAX_OUTPUT_BYTES)
34
+ def self.command_timeout
35
+ override = ENV['METACLEAN_TIMEOUT'].to_i
36
+ override.positive? ? override : COMMAND_TIMEOUT
37
+ end
38
+
39
+ def self.capture3(*cmd, timeout: command_timeout, max_output: MAX_OUTPUT_BYTES)
54
40
  Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
55
- stdin.close
56
- # Drain both pipes concurrently: a tool that fills one pipe buffer would
57
- # otherwise block forever before exiting, and `join` below would never see
58
- # it finish even though it isn't actually hung.
59
- out_t = read_capped(stdout, max_output, wait_thr)
60
- err_t = read_capped(stderr, max_output, wait_thr)
61
-
62
- if wait_thr.join(timeout).nil?
41
+ out_t = err_t = deadline = nil
42
+ begin
43
+ stdin.close
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
45
+ out_t = read_capped(stdout, max_output, wait_thr)
46
+ err_t = read_capped(stderr, max_output, wait_thr)
47
+
48
+ if wait_thr.join(timeout).nil?
49
+ kill_group(wait_thr)
50
+ drain_readers(out_t, err_t, deadline)
51
+ raise Error, "#{cmd.first} timed out after #{timeout}s"
52
+ end
53
+
54
+ unless drain_readers(out_t, err_t, deadline)
55
+ kill_group(wait_thr)
56
+ drain_readers(out_t, err_t, deadline)
57
+ raise Error, "#{cmd.first} timed out after #{timeout}s"
58
+ end
59
+
60
+ out, out_over = out_t.value
61
+ err, err_over = err_t.value
62
+ raise Error, "#{cmd.first} exceeded the #{max_output}-byte output limit" if out_over || err_over
63
+
64
+ [out, err, wait_thr.value]
65
+ rescue Interrupt
63
66
  kill_group(wait_thr)
64
- out_t.join(2)
65
- err_t.join(2)
66
- raise Error, "#{cmd.first} timed out after #{timeout}s"
67
+ drain_readers(out_t, err_t, deadline) if out_t && err_t && deadline
68
+ raise
67
69
  end
70
+ end
71
+ end
68
72
 
69
- out, out_over = out_t.value
70
- err, err_over = err_t.value
71
- raise Error, "#{cmd.first} exceeded the #{max_output}-byte output limit" if out_over || err_over
72
-
73
- [out, err, wait_thr.value]
73
+ def self.drain_readers(out_t, err_t, deadline)
74
+ [out_t, err_t].all? do |t|
75
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+ remaining = 0.1 if remaining < 0.1
77
+ !t.join(remaining).nil?
74
78
  end
75
79
  end
76
80
 
77
- # Read an IO into a String in a thread, but stop accumulating once it passes
78
- # `limit` bytes — and kill the command then, so a flooding stream is cut off
79
- # promptly instead of waiting out the full timeout. After the cap is hit it
80
- # keeps draining (discarding) so the dying child isn't blocked on a full pipe.
81
- # Returns [string, overflowed?].
82
81
  def self.read_capped(io, limit, wait_thr)
83
82
  Thread.new do
84
83
  buf = +''
85
84
  over = false
86
85
  while (chunk = io.read(READ_CHUNK))
87
- next if over # past the cap: drain & discard so the child can exit
86
+ next if over
88
87
 
89
88
  buf << chunk
90
89
  next unless buf.bytesize > limit
@@ -97,51 +96,30 @@ module Metaclean
97
96
  end
98
97
  end
99
98
 
100
- # SIGTERM the child's whole process group — pgroup:true made the child the
101
- # group leader, so any helpers it forked are signalled too — escalating to
102
- # SIGKILL if it ignores TERM. A negative pid targets the group.
103
99
  def self.kill_group(wait_thr)
104
100
  Process.kill('-TERM', wait_thr.pid)
105
101
  Process.kill('-KILL', wait_thr.pid) unless wait_thr.join(2)
106
102
  rescue Errno::ESRCH, Errno::EPERM
107
- nil # already gone, or not permitted to signal it — nothing more to do
103
+ nil
108
104
  end
109
105
 
110
- # Lower-cased, dot-stripped extension used for FORMAT ROUTING decisions
111
- # (Strategy#tools_for, Strategy#mat2_essential?, Mat2.supports?). One
112
- # definition so every routing path normalizes the extension identically —
113
- # a future tweak (double extensions, locale-safe downcasing) lands once.
114
106
  def self.ext_of(path)
115
107
  File.extname(path.to_s).downcase.delete('.')
116
108
  end
117
109
 
118
- # Marker embedded in every staging-temp filename (Runner, Ffmpeg, Qpdf) and
119
- # matched by Runner#skip?, so a leftover temp from an interrupted run is
120
- # ignored on a later directory scan. One literal keeps the producers and the
121
- # matcher from drifting (qpdf previously embedded a divergent
122
- # ".metaclean.qpdf.tmp." that didn't contain this marker).
123
110
  TMP_MARKER = '.metaclean.tmp.'
124
111
 
125
- # Suffix of the default "<name>_clean.<ext>" outputs. Runner#build_clean_path
126
- # writes it; CLEAN_OUTPUT_RE derives the loop-prevention match from it so the
127
- # producer and Runner#skip? can't disagree.
128
112
  CLEAN_SUFFIX = '_clean'
129
113
 
130
- # Matches our own "<name>_clean.<ext>" outputs (with optional "_N" collision
131
- # counter) so a recursive re-run doesn't re-clean them. Compiled once here,
132
- # in the module body that runs after the requires, so CLEAN_SUFFIX exists.
133
- CLEAN_OUTPUT_RE = /#{Regexp.escape(CLEAN_SUFFIX)}(_\d+)?\.[^.]+\z/
114
+ CLEAN_OUTPUT_RE = /#{Regexp.escape(CLEAN_SUFFIX)}(?:_\d+)?(?:\.[^.]+)?\z/
134
115
 
135
- # Preflight: all four tools must be installed. We run them together for full
136
- # coverage and to verify the strip, so a partial toolchain is not "good enough"
137
- # — bail with one clear message naming what's missing and how to install
138
- # everything. Called once by the CLI before any inspect/clean work.
139
- def self.ensure_tools!
116
+ def self.ensure_tools!(in_place: false)
140
117
  missing = []
141
118
  missing << 'exiftool' unless Exiftool.available?
142
119
  missing << 'mat2' unless Mat2.available?
143
120
  missing << 'qpdf' unless Qpdf.available?
144
121
  missing << 'ffmpeg' unless Ffmpeg.available?
122
+ missing << 'cp with metadata preservation' if in_place && !FileOps.metadata_copy_supported?
145
123
  return if missing.empty?
146
124
 
147
125
  raise ToolsMissing, <<~MSG
@@ -153,6 +131,8 @@ module Metaclean
153
131
  Fedora: sudo dnf install perl-Image-ExifTool mat2 qpdf ffmpeg
154
132
  Arch: sudo pacman -S perl-image-exiftool mat2 qpdf ffmpeg
155
133
  Windows: use WSL2 (https://learn.microsoft.com/windows/wsl/install) + the Debian/Ubuntu line
134
+
135
+ --in-place additionally requires the POSIX cp utility supplied by macOS or coreutils on Linux.
156
136
  MSG
157
137
  end
158
138
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metaclean
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 4.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laurent Zogaj
@@ -21,12 +21,16 @@ extra_rdoc_files: []
21
21
  files:
22
22
  - LICENSE
23
23
  - README.md
24
+ - SECURITY.md
24
25
  - bin/metaclean
25
26
  - lib/metaclean.rb
26
27
  - lib/metaclean/cli.rb
28
+ - lib/metaclean/committer.rb
29
+ - lib/metaclean/discovery.rb
27
30
  - lib/metaclean/display.rb
28
31
  - lib/metaclean/exiftool.rb
29
32
  - lib/metaclean/ffmpeg.rb
33
+ - lib/metaclean/file_ops.rb
30
34
  - lib/metaclean/mat2.rb
31
35
  - lib/metaclean/qpdf.rb
32
36
  - lib/metaclean/runner.rb
@@ -38,6 +42,7 @@ licenses:
38
42
  metadata:
39
43
  allowed_push_host: https://rubygems.org
40
44
  bug_tracker_uri: https://github.com/26zl/metaclean/issues
45
+ documentation_uri: https://github.com/26zl/metaclean#readme
41
46
  source_code_uri: https://github.com/26zl/metaclean
42
47
  rubygems_mfa_required: 'true'
43
48
  rdoc_options: []
@@ -58,6 +63,7 @@ requirements:
58
63
  - mat2 (https://github.com/jvoisin/mat2) on PATH — required
59
64
  - qpdf (https://qpdf.sourceforge.io) on PATH — required
60
65
  - ffmpeg (https://ffmpeg.org) on PATH — required
66
+ - POSIX cp with metadata-preservation support — required for --in-place
61
67
  rubygems_version: 3.6.9
62
68
  specification_version: 4
63
69
  summary: CLI that strips file metadata with ExifTool, mat2, qpdf and ffmpeg.