handle FTBFSing arches
[useful-scripts/useful-scripts.git] / RIFF / riffedit.py
1 #!/usr/bin/python3
2 # coding: UTF-8
3 #-
4 # Copyright © 2018, 2020 Thorsten Glaser <tg@mirbsd.de>
5 #
6 # Provided that these terms and disclaimer and all copyright notices
7 # are retained or reproduced in an accompanying document, permission
8 # is granted to deal in this work without restriction, including un‐
9 # limited rights to use, publicly perform, distribute, sell, modify,
10 # merge, give away, or sublicence.
11 #
12 # This work is provided “AS IS” and WITHOUT WARRANTY of any kind, to
13 # the utmost extent permitted by applicable law, neither express nor
14 # implied; without malicious intent or gross negligence. In no event
15 # may a licensor, author or contributor be held liable for indirect,
16 # direct, other damage, loss, or other issues arising in any way out
17 # of dealing in the work, even if advised of the possibility of such
18 # damage or existence of a defect, except proven that it results out
19 # of said person’s immediate fault when using the work as intended.
20 #-
21 # python3 riffedit.py -d src.sf2  # dump info only
22 # python3 riffedit.py -i src.sf2  # identify metadata (see below)
23 # python3 riffedit.py src.sf2 dst.sf2 { [-az] 'chnk' 'content' } ...
24 #  where -a means to align with NULs and -z to NUL-terminate
25 #  chnk means the RIFF chunk, LIST<chnk>/chnk is also supported
26 # Chunks currently need to exist in the input, insertion and deletion
27 # is missing for some later version to add.
28 # The comment field is limited to 65535 ASCII bytes, the others to 255.
29 #
30 # Metadata from a soundfont only includes chunks useful in copyright
31 # tracking. It outputs the INFO chunks, using input ordering, in the
32 # format “chunk_name \xFE chunk_body \xFF”, where both name and body
33 # (properly UTF-8 encoded) have all characters not valid for XML re‐
34 # moved or replaced with the OPTU-16 value or U+FFFD.
35 #
36 # You may also use this under the same terms as the Fluid (R3) soundfont.
37
38 from io import SEEK_SET, SEEK_CUR
39 import os
40 import struct
41 import sys
42
43 assert(sys.version_info[0] >= 3)
44
45 class RIFFChunk(object):
46     def __init__(self, parent):
47         self.parent = parent
48         self.file = parent
49         while isinstance(self.file, RIFFChunk):
50             self.file = self.file.parent
51
52         cn = self.file.read(4)
53         cs = self.file.read(4)
54         ct = None
55         cf = cn
56         if (len(cn) != 4) or (len(cs) != 4):
57             raise EOFError
58         co = self.file.tell()
59         try:
60             cs = struct.unpack_from('<L', cs)[0]
61         except struct.error:
62             raise EOFError
63         if cn in (b'RIFF', b'LIST'):
64             ct = self.file.read(4)
65             if len(ct) != 4:
66                 raise EOFError
67             cf = cn + b'<' + ct + b'>'
68
69         self.chunkname = cn
70         self.chunksize = cs
71         self.chunk_pad = cs & 1
72         self.container = ct
73         self.children = []
74         self.chunkfmt = cf
75         self.data_ofs = co
76         self.data_mem = None
77         self.justpast = self.data_ofs + self.chunksize + self.chunk_pad
78
79         if isinstance(self.parent, RIFFChunk) and \
80           self.justpast > self.parent.justpast:
81             raise IndexError('End of this %s chunk %d > end of parent %s chunk %d' % \
82               (self.chunkfmt, self.justpast, self.parent.chunkfmt, self.parent.justpast))
83
84         if self.container is not None:
85             while True:
86                 try:
87                     child = RIFFChunk(self)
88                 except EOFError:
89                     break
90                 self.children.append(child)
91                 if child.skip_past():
92                     break
93
94     def __str__(self):
95         s = '<RIFFChunk(%s)' % self.chunkfmt
96         if self.container is not None:
97             q = '['
98             for child in self.children:
99                 s += q + str(child)
100                 q = ', '
101             s += ']'
102         return s + '>'
103
104     def skip_past(self):
105         self.file.seek(self.justpast, SEEK_SET)
106         return isinstance(self.parent, RIFFChunk) and \
107           self.justpast == self.parent.justpast
108
109     def __getitem__(self, key):
110         if self.container is None:
111             raise IndexError('Chunk %s is not of a container type' % self.chunkname)
112         for child in self.children:
113             if child.chunkfmt == key:
114                 return child
115         raise IndexError('Chunk %s does not have a child %s' % (self.chunkname, key))
116
117     def print(self):
118         if self.container is not None:
119             raise IndexError('Chunk %s is of a container type' % self.chunkname)
120         if self.data_mem is not None:
121             return self.data_mem
122         self.file.seek(self.data_ofs, SEEK_SET)
123         s = self.file.read(self.chunksize)
124         if len(s) != self.chunksize:
125             raise IOError('Could not read %d data bytes (got %d)' % (self.chunksize, len(s)))
126         return s
127
128     def write(self, file):
129         if not isinstance(self.chunkname, bytes):
130             raise ValueError('Chunk name %s is not of type bytes' % self.chunkname)
131         if len(self.chunkname) != 4:
132             raise ValueError('Chunk name %s is not of length 4')
133         if file.write(self.chunkname + struct.pack('<L', self.chunksize)) != 8:
134             raise IOError('Could not write header bytes to destination file at chunk %s' % \
135               self.chunkfmt)
136         if self.container is not None:
137             cld = file.tell()
138             if not isinstance(self.container, bytes):
139                 raise ValueError('Container type %s is not of type bytes' % self.container)
140             if len(self.container) != 4:
141                 raise ValueError('Container type %s is not of length 4')
142             if file.write(self.container) != 4:
143                 raise IOError('Could not write container bytes to destination file at chunk %s' % \
144                   self.chunkfmt)
145             for child in self.children:
146                 child.write(file)
147             cld = file.tell() - cld
148             if cld != self.chunksize:
149                 raise ValueError('Children wrote %d bytes (expected %d) file at chunk %s' % \
150                   (cld, self.chunksize, self.chunkfmt))
151         else:
152             if self.data_mem is not None:
153                 if file.write(self.data_mem) != self.chunksize:
154                     raise IOError('Could not write %d data bytes to destination file at chunk %s' % \
155                       (self.chunksize, self.chunkfmt))
156             else:
157                 self.file.seek(self.data_ofs, SEEK_SET)
158                 total = self.chunksize
159                 while total > 0:
160                     n = 65536
161                     if n > total:
162                         n = total
163                     buf = self.file.read(n)
164                     n = len(buf)
165                     total -= n
166                     if file.write(buf) != n:
167                         raise IOError('Could not write %d data bytes to destination file at chunk %s' % \
168                           (n, self.chunkfmt))
169         if self.chunk_pad > 0:
170             file.write(b'\0')
171         if file.tell() & 1:
172             raise ValueError('Misaligned file after chunk %s' % self.chunkfmt)
173
174     def set_length(self, newlen):
175         old = self.chunksize + self.chunk_pad
176         self.chunksize = newlen
177         self.chunk_pad = self.chunksize & 1
178         new = self.chunksize + self.chunk_pad
179         if isinstance(self.parent, RIFFChunk):
180             self.parent.adjust_length(new - old)
181
182     def set_content(self, content, nul_pad=False):
183         if self.container is not None:
184             raise ValueError('Cannot set content of container type %s' % self.chunkfmt)
185         if isinstance(content, str):
186             content = content.encode('UTF-8')
187         if not isinstance(content, bytes):
188             raise ValueError('New content is not of type bytes')
189         if nul_pad and (len(content) & 1):
190             content += b'\0'
191         self.data_mem = content
192         self.set_length(len(content))
193
194     def adjust_length(self, delta):
195         self.set_length(self.chunksize + delta)
196
197 class RIFFFile(RIFFChunk):
198     def __init__(self, file):
199         self.file = file
200         self.container = True
201         self.children = []
202
203         child = None
204         while True:
205             try:
206                 child = RIFFChunk(f)
207             except EOFError:
208                 break
209             self.children.append(child)
210
211         if child is None:
212             raise IndexError('No RIFF chunks found')
213
214         self.justpast = child.justpast
215
216     def __str__(self):
217         s = '<RIFFFile'
218         q = '['
219         for child in self.children:
220             s += q + str(child)
221             q = ', '
222         return s + ']>'
223
224     def __getitem__(self, key):
225         return self.children[key]
226
227     def write(self, file):
228         for child in self.children:
229             child.write(file)
230
231 def dumpriff(container, level=0, isinfo=False):
232     indent = ('%s%ds' % ('%', 2*level)) % ''
233     print(indent + 'BEGIN level=%d' % level)
234     for chunk in container.children:
235         #print(indent + ' CHUNK %s of size %d, data at %d, next at %d' % (chunk.chunkfmt, chunk.chunksize, chunk.data_ofs, chunk.justpast))
236         if isinfo:
237             print(indent + ' CHUNK %s(%d): %s' % (chunk.chunkfmt, chunk.chunksize, chunk.print()))
238         else:
239             print(indent + ' CHUNK %s of size %d' % (chunk.chunkfmt, chunk.chunksize))
240         if chunk.container is not None:
241             dumpriff(chunk, level+1, chunk.chunkfmt == b'LIST<INFO>')
242     print(indent + 'END level=%d' % level)
243
244 if sys.argv[1] == '-i':
245     encode_table = {}
246     # bad characters in XML
247     for i in range(0, 32):
248         if i not in (0x09, 0x0A, 0x0D):
249             encode_table[i] = None
250     encode_table[0x7F] = 0xFFFD
251     for i in range(0x80, 0xA0):
252         encode_table[i] = 0xEF00 + i
253     for i in range(0xD800, 0xE000):
254         encode_table[i] = 0xFFFD
255     for i in range(0, 0x110000, 0x10000):
256         encode_table[i + 0xFFFE] = 0xFFFD
257         encode_table[i + 0xFFFF] = 0xFFFD
258     for i in range(0xFDD0, 0xFDF0):
259         encode_table[i] = 0xFFFD
260     # surrogateescape to OPTU-16
261     for i in range(128, 256):
262         encode_table[0xDC00 + i] = 0xEF00 + i
263     ident_encode_table = str.maketrans(encode_table)
264     del encode_table
265
266     def ident_encode(s):
267         return s.rstrip(b'\x00').\
268           decode(encoding='utf-8', errors='surrogateescape').\
269           translate(ident_encode_table).\
270           encode(encoding='utf-8', errors='replace')
271
272     if sys.argv[2] == '-':
273         f = sys.stdin.buffer
274     else:
275         f = open(sys.argv[2], 'rb')
276     riff = RIFFFile(f)
277     for chunk in riff[0][b'LIST<INFO>'].children:
278         if chunk.chunkname not in (b'ifil', b'isng', b'IPRD', b'ISFT'):
279             for x in (ident_encode(chunk.chunkname), b'\xFE',
280               ident_encode(chunk.print()), b'\xFF'):
281                 sys.stdout.buffer.write(x)
282     sys.exit(0)
283
284 print('START')
285 if sys.argv[1] == '-d':
286     with open(sys.argv[2], 'rb') as f:
287         riff = RIFFFile(f)
288         dumpriff(riff)
289 else:
290     with open(sys.argv[1], 'rb') as f, open(sys.argv[2], 'wb', buffering=65536) as dst:
291         riff = RIFFFile(f)
292         dumpriff(riff)
293         i = 3
294         _flags = { '-a': 1, '-z': 2, '-az': 3 }
295         while i < len(sys.argv):
296             flags = 0
297             if sys.argv[i] in _flags:
298                 flags = _flags[sys.argv[i]]
299                 i += 1
300                 if i >= len(sys.argv):
301                     break
302             chunks = sys.argv[i].split('/')
303             if chunks[0].isnumeric():
304                 chnk = riff
305             else:
306                 chnk = riff[0]
307             for cur in chunks:
308                 chnk = chnk[os.fsencode(cur)]
309             val = os.fsencode(sys.argv[i + 1])
310             if flags & 2:
311                 val += b'\0'
312             chnk.set_content(val, bool(flags & 1))
313             i += 2
314         print("=> after processing:")
315         dumpriff(riff)
316         riff.write(dst)
317 print('OUT')