embed_xmp 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/COPYING +9 -0
- data/bin/embed_xmp +47 -0
- data/lib/embed_xmp.rb +11 -0
- data/lib/embed_xmp/image_file.rb +47 -0
- data/lib/embed_xmp/jfif.rb +49 -0
- data/lib/embed_xmp/jpeg.rb +82 -0
- data/lib/embed_xmp/png.rb +116 -0
- data/lib/embed_xmp/riff.rb +82 -0
- data/lib/embed_xmp/svg.rb +81 -0
- data/lib/embed_xmp/webp.rb +133 -0
- data/lib/embed_xmp/xmp.rb +55 -0
- metadata +83 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c43b6512c1799531bf8eff944a39df0a9104c37efde80baae9807877e3db7e17
|
4
|
+
data.tar.gz: f6ebaa9ac5f3865587a530db76d11a2cc5b5b381511711eccd10ac492f27fe1a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8b39a208756fc270088971aa8922f046ff0bf158dcbc7f8c45252fe9f62a13640437369d53b524236218c71ac3bbb84ce975338b56b7cbe344cc0bc9b33abfcf
|
7
|
+
data.tar.gz: 186975546220d9f5b565d1dd3db5ac64df4355a542bb0370a28cd1e3363db93dbed7657d42f4348e744fe0dd04ba86ac73d01e868d0f4b0014ac932b5e7441af
|
data/COPYING
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
Copyright © 2020 Daniel Aleksandersen. All rights reserved.
|
2
|
+
|
3
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
4
|
+
|
5
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
6
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
7
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
8
|
+
|
9
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/bin/embed_xmp
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
5
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
6
|
+
# License-Filename: COPYING
|
7
|
+
|
8
|
+
require 'embed_xmp'
|
9
|
+
|
10
|
+
def usage
|
11
|
+
puts "Usage: #{$PROGRAM_NAME} xmp_path image_path output_image_path"
|
12
|
+
puts
|
13
|
+
puts " XMP sidecars are checked to be well-formatted XML but aren't\n"
|
14
|
+
puts ' otherwise validated (whitespace is discarded!)'
|
15
|
+
exit 2
|
16
|
+
end
|
17
|
+
|
18
|
+
sidecar_file = ARGV[0]
|
19
|
+
|
20
|
+
input_file = ARGV[1]
|
21
|
+
output_file = ARGV[2]
|
22
|
+
|
23
|
+
usage if sidecar_file.nil? || input_file.nil? || output_file.nil?
|
24
|
+
|
25
|
+
raise 'XMPFileNotFound' unless File.exist?(sidecar_file)
|
26
|
+
raise 'InputImageNotFound' unless File.exist?(input_file)
|
27
|
+
|
28
|
+
unless %w[.xmp .xml].include?(File.extname(sidecar_file).downcase)
|
29
|
+
raise 'UnsupportedSidecarExtension'
|
30
|
+
end
|
31
|
+
|
32
|
+
in_ext = File.extname(input_file).downcase
|
33
|
+
|
34
|
+
if %w[.jpeg .jpg].include?(in_ext)
|
35
|
+
emb = EmbedXMP::JPEG.new(input_file)
|
36
|
+
elsif %w[.png .pnga].include?(in_ext)
|
37
|
+
emb = EmbedXMP::PNG.new(input_file)
|
38
|
+
elsif %w[.svg .xml].include?(in_ext)
|
39
|
+
emb = EmbedXMP::SVG.new(input_file)
|
40
|
+
elsif in_ext == '.webp'
|
41
|
+
emb = EmbedXMP::WebP.new(input_file)
|
42
|
+
else
|
43
|
+
raise 'UnsupportedImageExtension'
|
44
|
+
end
|
45
|
+
|
46
|
+
emb.join_sidecar(sidecar_file)
|
47
|
+
emb.write(output_file)
|
data/lib/embed_xmp.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'embed_xmp/xmp'
|
8
|
+
require 'embed_xmp/jpeg'
|
9
|
+
require 'embed_xmp/png'
|
10
|
+
require 'embed_xmp/webp'
|
11
|
+
require 'embed_xmp/svg'
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
class EmbedXMP
|
8
|
+
# Basic image file representation.
|
9
|
+
class ImageFile
|
10
|
+
@image_data
|
11
|
+
|
12
|
+
def initialize(input_image)
|
13
|
+
@image_data = read_io_or_string(input_image)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Read from a readable +IO+ object or a +String+ (file path or a thing).
|
17
|
+
def read_io_or_string(thing)
|
18
|
+
if thing.respond_to?(:encoding)
|
19
|
+
return IO.binread(thing) if File.exist?(thing)
|
20
|
+
|
21
|
+
return thing
|
22
|
+
elsif thing.respond_to?(:read)
|
23
|
+
return IO.binread(thing)
|
24
|
+
end
|
25
|
+
|
26
|
+
raise 'FileNotAnIOorString'
|
27
|
+
end
|
28
|
+
|
29
|
+
# Insert a +chunk+ of data at +file_offset+ from the start of +file_data+.
|
30
|
+
def insert_into_file(offset, data)
|
31
|
+
@image_data = @image_data[0..offset - 1] + data + @image_data[offset..-1]
|
32
|
+
end
|
33
|
+
|
34
|
+
# Write image to file (or return if argument is +nil+).
|
35
|
+
def write(output_file, data: nil)
|
36
|
+
data = @image_data if data.nil?
|
37
|
+
return data if output_file.nil?
|
38
|
+
|
39
|
+
written_bytes = 0
|
40
|
+
File.open(output_file, 'wb') do |file|
|
41
|
+
written_bytes = file.write(data)
|
42
|
+
end
|
43
|
+
|
44
|
+
written_bytes > 0 && written_bytes == data.b.length
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'embed_xmp/image_file'
|
8
|
+
|
9
|
+
class EmbedXMP
|
10
|
+
# JPEG File Interchange Format (container format for JPEG)
|
11
|
+
class JFIF < ImageFile
|
12
|
+
JFIF_SOI = "\xFF\xD8".b
|
13
|
+
JFIF_END = "\xFF\xD9".b
|
14
|
+
|
15
|
+
# Check if file has JFIF markers.
|
16
|
+
def check_file_markers
|
17
|
+
raise 'NoJPEGStartOfFile' if JFIF_SOI != @image_data[0..1]
|
18
|
+
raise 'NoJPEGEndOfFile' if JFIF_END != @image_data[-2..-1]
|
19
|
+
end
|
20
|
+
|
21
|
+
# Return segment at +offset+ from the beginning of the file.
|
22
|
+
def segment(offset)
|
23
|
+
marker = @image_data[offset, 2]
|
24
|
+
length = @image_data[offset + 2, 2].b.unpack1('n') + 2
|
25
|
+
|
26
|
+
raise 'SegmentExceedFileLength' if offset + length > @image_data.length
|
27
|
+
|
28
|
+
data = @image_data[offset + 2, length]
|
29
|
+
|
30
|
+
[marker, length, data]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Remove the chunk at +offset+ from the beginning of the file.
|
34
|
+
def remove_segment(offset)
|
35
|
+
_, length, = segment(offset)
|
36
|
+
|
37
|
+
@image_data.slice!(offset, length)
|
38
|
+
end
|
39
|
+
|
40
|
+
def new_segment(marker, data)
|
41
|
+
raise 'SegmentMarkerNotTwoBytes' if marker.length != 2
|
42
|
+
raise 'SegmentMarkerDoesNotBeginWithNullByte' if marker == '\b'.b
|
43
|
+
|
44
|
+
length = [2 + data.length].pack('n')
|
45
|
+
|
46
|
+
marker + length + data
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'embed_xmp/jfif'
|
8
|
+
|
9
|
+
class EmbedXMP
|
10
|
+
# JPEG images
|
11
|
+
class JPEG < JFIF
|
12
|
+
JPEG_AP1 = "\xFF\xE1".b
|
13
|
+
JPEG_IMG = "\xFF\xDA".b
|
14
|
+
JPEG_XMP = "http://ns.adobe.com/xap/1.0/\0".b
|
15
|
+
JPEG_EXF = "Exif\0\0".b
|
16
|
+
|
17
|
+
# Join an XMP sidecar file into the image file.
|
18
|
+
def join_sidecar(sidecar_file, xpacked: false)
|
19
|
+
check_file_markers
|
20
|
+
remove_xmp
|
21
|
+
|
22
|
+
sidecar = read_io_or_string(sidecar_file)
|
23
|
+
xmp_chunk = create_xmp_segment(sidecar, xpacked)
|
24
|
+
|
25
|
+
insert_into_file(find_xmp_insertion_offset, xmp_chunk)
|
26
|
+
end
|
27
|
+
|
28
|
+
def remove_xmp
|
29
|
+
offset = JFIF_SOI.length
|
30
|
+
while offset < @image_data.length
|
31
|
+
marker, length, = segment(offset)
|
32
|
+
|
33
|
+
break if [JPEG_IMG, JFIF_END, nil].include?(marker)
|
34
|
+
|
35
|
+
if segment_is_app1_xmp(offset, marker)
|
36
|
+
remove_segment(offset)
|
37
|
+
next
|
38
|
+
end
|
39
|
+
|
40
|
+
offset += length
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def find_xmp_insertion_offset
|
47
|
+
offset = JFIF_SOI.length
|
48
|
+
cursor = offset
|
49
|
+
while offset < @image_data.length
|
50
|
+
marker, length, = segment(offset)
|
51
|
+
|
52
|
+
break if [JPEG_IMG, JFIF_END, nil].include?(marker)
|
53
|
+
|
54
|
+
if segment_is_app1_exif(offset, marker)
|
55
|
+
cursor = offset + length
|
56
|
+
break
|
57
|
+
end
|
58
|
+
|
59
|
+
offset += length
|
60
|
+
end
|
61
|
+
|
62
|
+
cursor
|
63
|
+
end
|
64
|
+
|
65
|
+
def segment_is_app1_exif(off, seg)
|
66
|
+
JPEG_AP1 == seg && @image_data[off + 4, JPEG_EXF.length].b == JPEG_EXF
|
67
|
+
end
|
68
|
+
|
69
|
+
def segment_is_app1_xmp(off, seg)
|
70
|
+
JPEG_AP1 == seg && @image_data[off + 4, JPEG_XMP.length + 4].b == JPEG_XMP
|
71
|
+
end
|
72
|
+
|
73
|
+
def create_xmp_segment(xmp, xpacked)
|
74
|
+
data = JPEG_XMP +
|
75
|
+
EmbedXMP::XMP
|
76
|
+
.new(xmp, writable: true, xpacked: xpacked)
|
77
|
+
.to_s.gsub("\0", ' ')
|
78
|
+
|
79
|
+
new_segment(JPEG_AP1, data.b)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'digest/crc32'
|
8
|
+
|
9
|
+
require 'embed_xmp/image_file'
|
10
|
+
|
11
|
+
class EmbedXMP
|
12
|
+
# PNG images
|
13
|
+
class PNG < ImageFile
|
14
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
|
15
|
+
PNG_HEADER = 'IHDR'
|
16
|
+
PNG_IMAGE_END = 'IEND'
|
17
|
+
|
18
|
+
XMP_CHUNK_SIG = "iTXtXML:com.adobe.xmp\0\0\0\0\0".b.freeze
|
19
|
+
|
20
|
+
# Join an XMP sidecar file into a PNG image file.
|
21
|
+
def join_sidecar(sidecar_file, xpacked: false)
|
22
|
+
check_file_signatures
|
23
|
+
remove_xmp
|
24
|
+
|
25
|
+
sidecar = read_io_or_string(sidecar_file)
|
26
|
+
xmp_chunk = create_xmp_itxt(sidecar, xpacked)
|
27
|
+
|
28
|
+
insert_into_file(find_xmp_insertion_offset, xmp_chunk)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Quick and dirty test to see if +png_data+ is a PNG image file
|
32
|
+
def check_file_signatures
|
33
|
+
raise 'NoPNGSignature' if PNG_SIGNATURE != @image_data[0..7]
|
34
|
+
raise 'NoPNGEndOfFile' if PNG_IMAGE_END != @image_data[-8..-5]
|
35
|
+
end
|
36
|
+
|
37
|
+
def chunk(offset)
|
38
|
+
chunk_length = @image_data[offset, 4].b.unpack1('N') + 12
|
39
|
+
|
40
|
+
raise 'ChunkLongerThanFile' if offset + chunk_length > @image_data.length
|
41
|
+
|
42
|
+
chunk_id = @image_data[offset + 4, 4]
|
43
|
+
|
44
|
+
data = @image_data[offset + 8, chunk_length]
|
45
|
+
|
46
|
+
[chunk_id, chunk_length, data]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Return chunk at +offset+ from the beginning of the file.
|
50
|
+
def remove_chunk(offset)
|
51
|
+
_, chunk_length, = chunk(offset)
|
52
|
+
|
53
|
+
@image_data.slice!(offset, chunk_length)
|
54
|
+
end
|
55
|
+
|
56
|
+
# rubocop: disable Metrics/MethodLength
|
57
|
+
def remove_xmp
|
58
|
+
offset = PNG_SIGNATURE.length
|
59
|
+
while offset < @image_data.length
|
60
|
+
chunk_id, chunk_length, = chunk(offset)
|
61
|
+
|
62
|
+
break if [PNG_IMAGE_END, nil].include?(chunk_id)
|
63
|
+
|
64
|
+
if chunk_contains_xmp(offset)
|
65
|
+
remove_chunk(offset)
|
66
|
+
next
|
67
|
+
end
|
68
|
+
|
69
|
+
offset += chunk_length
|
70
|
+
end
|
71
|
+
end
|
72
|
+
# rubocop: enable Metrics/MethodLength
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def find_xmp_insertion_offset
|
77
|
+
offset = PNG_SIGNATURE.length
|
78
|
+
cursor = offset
|
79
|
+
while offset < @image_data.length
|
80
|
+
chunk_id, chunk_length, = chunk(offset)
|
81
|
+
|
82
|
+
break if [PNG_IMAGE_END, nil].include?(chunk_id)
|
83
|
+
|
84
|
+
if PNG_HEADER == chunk_id
|
85
|
+
cursor = offset + chunk_length
|
86
|
+
end
|
87
|
+
|
88
|
+
offset += chunk_length
|
89
|
+
end
|
90
|
+
|
91
|
+
cursor
|
92
|
+
end
|
93
|
+
|
94
|
+
def chunk_contains_xmp(offset)
|
95
|
+
XMP_CHUNK_SIG == @image_data[offset + 4, XMP_CHUNK_SIG.length + 3].b
|
96
|
+
end
|
97
|
+
|
98
|
+
def new_chunk(chunk_id, chunk_data)
|
99
|
+
length = [chunk_data.length].pack('N')
|
100
|
+
|
101
|
+
checksum = [Digest::CRC32.checksum(chunk_id + chunk_data)].pack('N')
|
102
|
+
|
103
|
+
length.b + chunk_id + chunk_data + checksum
|
104
|
+
end
|
105
|
+
|
106
|
+
def create_xmp_itxt(xmp_data, xpacked)
|
107
|
+
chunk_id = 'iTXt'
|
108
|
+
chunk_data = ("XML:com.adobe.xmp\0\0\0\0\0" +
|
109
|
+
EmbedXMP::XMP
|
110
|
+
.new(xmp_data, writable: false, xpacked: xpacked)
|
111
|
+
.to_s.gsub("\0", ' ')).b
|
112
|
+
|
113
|
+
new_chunk(chunk_id, chunk_data)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'embed_xmp/image_file'
|
8
|
+
|
9
|
+
class EmbedXMP
|
10
|
+
# Resource Interchange File Format (container format for WebP)
|
11
|
+
class RIFF < ImageFile
|
12
|
+
RIFF_HEAD = 'RIFF'
|
13
|
+
|
14
|
+
# Return the RIFF file header.
|
15
|
+
def file_header
|
16
|
+
riff_id, file_length, data = chunk(0)
|
17
|
+
form_type = data[0, 4]
|
18
|
+
real_file_length = @image_data.length
|
19
|
+
|
20
|
+
raise 'NoRIFFHeader' if RIFF_HEAD != riff_id
|
21
|
+
raise 'FileHeaderLongerThanFile' if real_file_length != file_length
|
22
|
+
|
23
|
+
[riff_id, file_length, form_type]
|
24
|
+
end
|
25
|
+
|
26
|
+
# Updates the file length value in the WebP file header.
|
27
|
+
def update_file_length_header
|
28
|
+
@image_data[4, 4] = [@image_data.length - 8].pack('V')
|
29
|
+
end
|
30
|
+
|
31
|
+
# rubocop: disable Metrics/AbcSize
|
32
|
+
# Return chunk at +offset+ from the beginning of the file.
|
33
|
+
def chunk(offset)
|
34
|
+
raise 'ChunksMustBeTwoBytesAligned' if offset.odd?
|
35
|
+
|
36
|
+
chunk_id = @image_data[offset, 4]
|
37
|
+
|
38
|
+
data_length = @image_data[offset + 4, 4].b.unpack1('V')
|
39
|
+
chunk_length = data_length + (data_length % 2) + 8
|
40
|
+
|
41
|
+
raise 'ChunkExceedsFileLength' if offset + chunk_length > @image_data.length
|
42
|
+
|
43
|
+
data = @image_data[offset + 8, data_length]
|
44
|
+
|
45
|
+
[chunk_id, chunk_length, data]
|
46
|
+
end
|
47
|
+
# rubocop: enable Metrics/AbcSize
|
48
|
+
|
49
|
+
# Remove the chunk at +offset+ from the beginning of the file.
|
50
|
+
def remove_chunk(offset)
|
51
|
+
_, chunk_length, = chunk(offset)
|
52
|
+
|
53
|
+
@image_data.slice!(offset, chunk_length)
|
54
|
+
|
55
|
+
update_file_length_header
|
56
|
+
end
|
57
|
+
|
58
|
+
# Replace the chunk at +offset+ from the beginning of the file
|
59
|
+
# with a new chunk
|
60
|
+
def replace_chunk(offset, chunk_id, data)
|
61
|
+
remove_chunk(offset)
|
62
|
+
|
63
|
+
chunk = new_chunk(chunk_id, data)
|
64
|
+
|
65
|
+
insert_into_file(offset, chunk)
|
66
|
+
|
67
|
+
update_file_length_header
|
68
|
+
end
|
69
|
+
|
70
|
+
# Create a new RIFF chunk with +data+ with pad byte when needed.
|
71
|
+
def new_chunk(chunk_id, data)
|
72
|
+
unless chunk_id.match?(/[a-zA-Z0-9 ]{4}/)
|
73
|
+
raise 'RIFFChunkIdentifierMustBeFourChar'
|
74
|
+
end
|
75
|
+
|
76
|
+
data_length = data.length
|
77
|
+
data += '\0'.b if data_length.odd?
|
78
|
+
|
79
|
+
chunk_id.b + [data_length].pack('V') + data
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'nokogiri'
|
8
|
+
|
9
|
+
require 'embed_xmp/image_file'
|
10
|
+
|
11
|
+
class EmbedXMP
|
12
|
+
# SVG images
|
13
|
+
class SVG < ImageFile
|
14
|
+
|
15
|
+
SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
16
|
+
|
17
|
+
def initialize(input_file)
|
18
|
+
file = read_io_or_string(input_file)
|
19
|
+
svg = Nokogiri::XML(file) do |conf|
|
20
|
+
conf.options = Nokogiri::XML::ParseOptions::NOBLANKS
|
21
|
+
end
|
22
|
+
|
23
|
+
raise 'NoSVGnamespace' if SVG_NAMESPACE != svg.root.namespace.href
|
24
|
+
|
25
|
+
@image_file = svg
|
26
|
+
end
|
27
|
+
|
28
|
+
# Join an XMP sidecar file into an SVG image file.
|
29
|
+
def join_sidecar(sidecar_file, xpacked: false)
|
30
|
+
remove_xmp
|
31
|
+
|
32
|
+
xmp_data = read_io_or_string(sidecar_file)
|
33
|
+
insert_xmp_metadata_into_svg(create_metadata(xmp_data, xpacked))
|
34
|
+
end
|
35
|
+
|
36
|
+
# Removes XMP metadata in /svg/metadata/rdf:RDF. Will only detect XMP data
|
37
|
+
# wrapped in an XPACKET processing instruction.
|
38
|
+
def remove_xmp
|
39
|
+
@image_file.root.xpath('//svg:metadata', 'svg' => 'http://www.w3.org/2000/svg')
|
40
|
+
.each do |svg_metadata|
|
41
|
+
xml_pi = svg_metadata.xpath('processing-instruction()')
|
42
|
+
|
43
|
+
if xml_pi.empty? ||
|
44
|
+
xml_pi.first.content != EmbedXMP::XMP::XPACKET_PDATA
|
45
|
+
next
|
46
|
+
end
|
47
|
+
|
48
|
+
svg_metadata.remove
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def write(output_file)
|
53
|
+
data = @image_file.to_xml(encoding: 'utf-8', indent: 2)
|
54
|
+
super(output_file, data: data)
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def insert_xmp_metadata_into_svg(xmp)
|
60
|
+
# insert <metadata> after <title> and <desc> or at top
|
61
|
+
insert_position = 0
|
62
|
+
2.times do
|
63
|
+
node_name = @image_file.root.children[insert_position].name
|
64
|
+
insert_position += 1 if %w[title desc].include?(node_name)
|
65
|
+
end
|
66
|
+
|
67
|
+
@image_file.root.children[insert_position].add_previous_sibling(xmp)
|
68
|
+
end
|
69
|
+
|
70
|
+
def create_metadata(xmp_data, xpacked)
|
71
|
+
xpacked_xmp = EmbedXMP::XMP
|
72
|
+
.new(xmp_data, writable: true, xpacked: xpacked).to_s
|
73
|
+
|
74
|
+
data = Nokogiri::XML("<metadata>\n#{xpacked_xmp}\n</metadata>") do |conf|
|
75
|
+
conf.options = Nokogiri::XML::ParseOptions::NOBLANKS
|
76
|
+
end
|
77
|
+
|
78
|
+
data.root
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'embed_xmp/riff'
|
8
|
+
|
9
|
+
class EmbedXMP
|
10
|
+
# WebP images
|
11
|
+
class WebP < RIFF
|
12
|
+
WEBP_HEAD = 'WEBP'
|
13
|
+
VP8X_HEAD = 'VP8X'
|
14
|
+
VP8F_HEAD = 'VP8 '
|
15
|
+
VP8L_HEAD = 'VP8L'
|
16
|
+
XMPS_HEAD = 'XMP '
|
17
|
+
|
18
|
+
VP8X_XMP_FLAG = 0b00000100
|
19
|
+
|
20
|
+
# Join an XMP sidecar into the image file.
|
21
|
+
def join_sidecar(sidecar_file, xpacked: false)
|
22
|
+
remove_xmp
|
23
|
+
|
24
|
+
xmp_data = read_io_or_string(sidecar_file)
|
25
|
+
upsert_xmp_chunk(create_xmp_chunk(xmp_data, xpacked))
|
26
|
+
end
|
27
|
+
|
28
|
+
def file_header
|
29
|
+
riff_id, file_length, form_type = super
|
30
|
+
|
31
|
+
raise 'NoWEBPHeader' if WEBP_HEAD != form_type
|
32
|
+
|
33
|
+
[riff_id, file_length, form_type]
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# rubocop: disable Metrics/MethodLength
|
39
|
+
def remove_xmp
|
40
|
+
offset = 12
|
41
|
+
while offset < @image_data.length
|
42
|
+
chunk_id, length, = chunk(offset)
|
43
|
+
|
44
|
+
break if chunk_id.nil?
|
45
|
+
|
46
|
+
if XMPS_HEAD == chunk_id
|
47
|
+
remove_chunk(offset)
|
48
|
+
toggle_xmp_feature(xmp: false)
|
49
|
+
break if offset + length >= @image_data.length
|
50
|
+
|
51
|
+
next
|
52
|
+
end
|
53
|
+
|
54
|
+
offset += length
|
55
|
+
end
|
56
|
+
end
|
57
|
+
# rubocop: enable Metrics/MethodLength
|
58
|
+
|
59
|
+
def create_xmp_chunk(xmp_data, xpacked)
|
60
|
+
data = EmbedXMP::XMP
|
61
|
+
.new(xmp_data, writable: true, xpacked: xpacked)
|
62
|
+
.to_s.gsub("\0", ' ').b
|
63
|
+
|
64
|
+
# pad with a potentially useful space char instead of NULL at end
|
65
|
+
if data.length.odd?
|
66
|
+
data[EmbedXMP::XMP::XPACKET_END_W] =
|
67
|
+
" #{EmbedXMP::XMP::XPACKET_END_W}"
|
68
|
+
end
|
69
|
+
|
70
|
+
new_chunk(XMPS_HEAD, data)
|
71
|
+
end
|
72
|
+
|
73
|
+
def upsert_xmp_chunk(xmp_chunk)
|
74
|
+
remove_xmp
|
75
|
+
@image_data += xmp_chunk
|
76
|
+
update_file_length_header
|
77
|
+
toggle_xmp_feature(xmp: true)
|
78
|
+
end
|
79
|
+
|
80
|
+
def dimensions_from_vp8(vp8_data)
|
81
|
+
width, height = vp8_data[6, 4]
|
82
|
+
.unpack('vv')
|
83
|
+
.map { |v| (v & 0b00111111_11111111) - 1 }
|
84
|
+
[width, height]
|
85
|
+
end
|
86
|
+
|
87
|
+
def dimensions_from_vp8_lossless(vp8_data)
|
88
|
+
b1, b2, b3, b4 = vp8_data[1, 4].unpack('c' * 4)
|
89
|
+
|
90
|
+
width = ((b2 & 0b0011_1111) << 8) | b1
|
91
|
+
height = ((b4 & 0b0011_1111) << 8) |
|
92
|
+
((b2 & 0b1100_0000) >> 6) | (b3 << 2)
|
93
|
+
[width, height]
|
94
|
+
end
|
95
|
+
|
96
|
+
def dimensions_from_image_chunk(chunk_id, vp8_data)
|
97
|
+
case chunk_id
|
98
|
+
when VP8F_HEAD
|
99
|
+
dimensions_from_vp8(vp8_data[0, 10])
|
100
|
+
when VP8L_HEAD
|
101
|
+
dimensions_from_vp8_lossless(vp8_data[0, 10])
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def upgrade_to_extended_format
|
106
|
+
chunk_id, _, data = chunk(12)
|
107
|
+
|
108
|
+
width, height = dimensions_from_image_chunk(chunk_id, data)
|
109
|
+
|
110
|
+
vp8x_data = [VP8X_XMP_FLAG, 0, 0, 0,
|
111
|
+
width, width >> 8, 0,
|
112
|
+
height, height >> 8, 0].pack('c' * 10)
|
113
|
+
|
114
|
+
chunk = new_chunk(VP8X_HEAD, vp8x_data)
|
115
|
+
|
116
|
+
insert_into_file(12, chunk)
|
117
|
+
end
|
118
|
+
|
119
|
+
def toggle_xmp_feature(xmp: true)
|
120
|
+
chunk_id, _, data = chunk(12)
|
121
|
+
|
122
|
+
if VP8X_HEAD != chunk_id
|
123
|
+
upgrade_to_extended_format
|
124
|
+
return toggle_xmp_feature(xmp: xmp)
|
125
|
+
end
|
126
|
+
|
127
|
+
data[0] = (data.unpack1('c').to_i | VP8X_XMP_FLAG).chr if xmp
|
128
|
+
data[0] = (data.unpack1('c').to_i & ~VP8X_XMP_FLAG).chr unless xmp
|
129
|
+
|
130
|
+
replace_chunk(12, chunk_id, data)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Daniel Aleksandersen <https://www.daniel.priv.no/>
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
5
|
+
# License-Filename: COPYING
|
6
|
+
|
7
|
+
require 'nokogiri'
|
8
|
+
|
9
|
+
require 'embed_xmp/image_file'
|
10
|
+
|
11
|
+
class EmbedXMP
|
12
|
+
# XMP sidecars
|
13
|
+
class XMP
|
14
|
+
XPACKET_PDATA = "begin=\"\uFEFF\" id=\"W5M0MpCehiHzreSzNTczkc9d\""
|
15
|
+
XPACKET_START = "<?xpacket #{XPACKET_PDATA}?>"
|
16
|
+
XPACKET_END_R = '<?xpacket end="r"?>'
|
17
|
+
XPACKET_END_W = '<?xpacket end="w"?>'
|
18
|
+
|
19
|
+
@xmp
|
20
|
+
|
21
|
+
def initialize(xmp_data_blob, writable: false, xpacked: false)
|
22
|
+
@xmp = xmp_str = xmp_xml_to_str(xmp_data_blob)
|
23
|
+
|
24
|
+
@xmp = xpack_sidecar(xmp_str, writable: writable) unless xpacked
|
25
|
+
end
|
26
|
+
|
27
|
+
# Return XMP sidecar as +String+.
|
28
|
+
def to_s
|
29
|
+
xmp_xml_to_str(@xmp)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Read XML-formatted XMP data into a format suitable for embedding.
|
33
|
+
def xmp_xml_to_str(xmp_data)
|
34
|
+
xmp = Nokogiri::XML(xmp_data) do |conf|
|
35
|
+
conf.options = Nokogiri::XML::ParseOptions::NOBLANKS
|
36
|
+
end
|
37
|
+
|
38
|
+
raise 'XMPIsMalformedXML' unless xmp.errors.empty?
|
39
|
+
|
40
|
+
xmp.to_xml(indent: 0,
|
41
|
+
encoding: 'utf-8',
|
42
|
+
save_with: Nokogiri::XML::Node::SaveOptions::NO_DECLARATION)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Wrap XMP sidecar data in in an magic XML processing instruction (xpacket).
|
46
|
+
# +writable+ is approperiate for file formats where the XMP data can be
|
47
|
+
# modified in place by staying within the confines of the XPACKET. (E.g.
|
48
|
+
# file formats with no chunk checksums.)
|
49
|
+
def xpack_sidecar(xmp_data, writable: false)
|
50
|
+
xpacket_end = writable ? XPACKET_END_W : XPACKET_END_R
|
51
|
+
|
52
|
+
"#{XPACKET_START}\n#{xmp_data}\n#{xpacket_end}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
metadata
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: embed_xmp
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Daniel Aleksandersen
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-04-27 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: digest-crc
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.5.1
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.5.1
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: nokogiri
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.10'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.10'
|
41
|
+
description: Embed XMP sidecar files into image files.
|
42
|
+
email: code@daniel.priv.no
|
43
|
+
executables:
|
44
|
+
- embed_xmp
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files:
|
47
|
+
- COPYING
|
48
|
+
files:
|
49
|
+
- COPYING
|
50
|
+
- bin/embed_xmp
|
51
|
+
- lib/embed_xmp.rb
|
52
|
+
- lib/embed_xmp/image_file.rb
|
53
|
+
- lib/embed_xmp/jfif.rb
|
54
|
+
- lib/embed_xmp/jpeg.rb
|
55
|
+
- lib/embed_xmp/png.rb
|
56
|
+
- lib/embed_xmp/riff.rb
|
57
|
+
- lib/embed_xmp/svg.rb
|
58
|
+
- lib/embed_xmp/webp.rb
|
59
|
+
- lib/embed_xmp/xmp.rb
|
60
|
+
homepage:
|
61
|
+
licenses:
|
62
|
+
- BSD-3-Clause
|
63
|
+
metadata: {}
|
64
|
+
post_install_message:
|
65
|
+
rdoc_options: []
|
66
|
+
require_paths:
|
67
|
+
- lib
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
69
|
+
requirements:
|
70
|
+
- - ">="
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: '0'
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
requirements: []
|
79
|
+
rubygems_version: 3.0.3
|
80
|
+
signing_key:
|
81
|
+
specification_version: 4
|
82
|
+
summary: XMP sidecar embedder.
|
83
|
+
test_files: []
|