embed_xmp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.
@@ -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)
@@ -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: []