Skip to content

Commit f3bf00b

Browse files
authored
Merge pull request #772 from rpatters1/rpatters1/extract-enigmaxml-from-musx
Zip utilities
2 parents 387edb6 + c67b887 commit f3bf00b

File tree

1 file changed

+225
-0
lines changed

1 file changed

+225
-0
lines changed

src/library/ziputils.lua

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
--[[
2+
$module ziputils
3+
4+
Functions for unzipping files. (Future may include zipping as well.)
5+
6+
Dependencies:
7+
8+
- Windows users must have `7z` installed. You can download it [here](https://www.7-zip.org/).
9+
- MacOS users must have `unzip` and `gunzip`, but these are usually installed with the OS.
10+
11+
Pay careful attention to the comments about how strings are encoded. They are either encoded
12+
**platform** or **utf8**. On macOS, platform encoding is always utf8, but on Windows it can
13+
be any number of encodings depending on the locale settings and version of Windows. You can use
14+
`luaosutils.text` to convert them back and forth. Both `luaosutils.process.execute`
15+
requires platform encoding as do `lfs` and all built-in Lua `io` functions.
16+
17+
Note that many functions require later versions of RGP Lua that include `luaosutils`
18+
and/or `lfs`. But the these dependencies are embedded in each function so that any version
19+
of Lua for Finale can at least load the library.
20+
]] --
21+
local ziputils = {}
22+
23+
local utils = require("library.utils")
24+
25+
-- This variable allows us to check if we are supported when we load and the functions
26+
-- can throw out based on it.
27+
local not_supported_message
28+
if finenv.MajorVersion <= 0 and finenv.MinorVersion < 68 then
29+
not_supported_message = "ziputils requires at least RGP Lua v0.68."
30+
elseif finenv.TrustedMode == finenv.TrustedModeType.UNTRUSTED then
31+
not_supported_message = "ziputils must run in Trusted mode."
32+
elseif not finaleplugin.ExecuteExternalCode then
33+
not_supported_message = "ziputils.extract_enigmaxml must have finaleplugin.ExecuteExternalCode set to true."
34+
end
35+
36+
--[[
37+
% calc_rmdir_command
38+
39+
Returns the platform-dependent command to remove a directory. It can be passed
40+
to `luaosutils.process.execute`.
41+
42+
**WARNING** The command, if executed, permanently deletes the contents of the directory.
43+
You would normally call this on the temporary directory name from `calc_temp_output_path`.
44+
But it works on any directory.
45+
46+
@ path_to_remove (string) platform-encoded path of directory to remove.
47+
: (string) platform-encoded command string to execute.
48+
]]
49+
function ziputils.calc_rmdir_command(path_to_remove)
50+
return (finenv.UI():IsOnMac() and "rm -r " or "cmd /c rmdir /s /q ") .. path_to_remove
51+
end
52+
53+
--[[
54+
% calc_delete_file_command
55+
56+
Returns the platform-dependent command to delete a file. It can be passed
57+
to `luaosutils.process.execute`.
58+
59+
**WARNING** The command, if executed, permanently deletes the file.
60+
You would normally call this on the temporary directory name from `calc_temp_output_path`.
61+
But it works on any directory.
62+
63+
@ path_to_remove (string) platform-encoded path of directory to remove.
64+
: (string) platform-encoded command string to execute.
65+
]]
66+
function ziputils.calc_delete_file_command(path_to_remove)
67+
return (finenv.UI():IsOnMac() and "rm " or "cmd /c del ") .. path_to_remove
68+
end
69+
70+
71+
--[[
72+
% calc_temp_output_path
73+
74+
Returns a path that can be used as a temporary target for unzipping. The caller may create it
75+
either as a file or a directory, because it is guaranteed not to exist when it is returned and it does
76+
not have a terminating path delimiter. Also returns a platform-dependent unzip command that can be
77+
passed to `luaosutils.process.execute` to unzip the input archive into the temporary name as a directory.
78+
The command may not be compatible with `os.execute`.
79+
80+
This function requires `luaosutils`.
81+
82+
@ [archive_path] (string) platform-encoded filepath to the zip archive that is included in the zip command.
83+
: (string) platform-encoded temporary path generated by the system.
84+
: (string) platform-encoded unzip command that can be used to unzip a multifile archived directory structure into the temporary path.
85+
]]
86+
function ziputils.calc_temp_output_path(archive_path)
87+
if not_supported_message then
88+
error(not_supported_message, 2)
89+
end
90+
91+
archive_path = archive_path or ""
92+
93+
local process = require("luaosutils").process
94+
95+
local output_dir = os.tmpname()
96+
local rmcommand = ziputils.calc_delete_file_command(output_dir)
97+
process.execute(rmcommand)
98+
99+
local zipcommand
100+
if finenv.UI():IsOnMac() then
101+
zipcommand = "unzip \"" .. archive_path .. "\" -d " .. output_dir
102+
else
103+
zipcommand = "cmd /c 7z x -o" .. output_dir .. " \"" .. archive_path .. "\""
104+
end
105+
return output_dir, zipcommand
106+
end
107+
108+
--[[
109+
% calc_gunzip_command
110+
111+
Returns the platform-dependent command to gunzip a file. It can be passed
112+
to `luaosutils.process.execute`, which will then return the text directly.
113+
114+
115+
@ archive_path (string) platform-encoded path of source gzip archive.
116+
: (string) platform-encoded command string to execute.
117+
]]
118+
function ziputils.calc_gunzip_command(archive_path)
119+
if finenv.UI():IsOnMac() then
120+
return "gunzip -c " .. archive_path
121+
else
122+
return "7z e -so " .. archive_path
123+
end
124+
end
125+
126+
--[[
127+
% calc_is_gzip
128+
129+
Detects if an input buffer is a gzip archive. Sometimes, Finale gzips the internal EnigmaXML document.
130+
131+
@ buffer (string) binary data to check if it is a gzip archive
132+
: (boolean) true if the buffer is a gzip archive
133+
]]
134+
function ziputils.calc_is_gzip(buffer)
135+
local byte1, byte2, byte3, byte4 = string.byte(buffer, 1, 4)
136+
return byte1 == 0x1F and byte2 == 0x8B and byte3 == 0x08 and byte4 == 0x00
137+
end
138+
139+
-- symmetrical encryption/decryption function for EnigmaXML
140+
local function crypt_enigmaxml_buffer(buffer)
141+
local INITIAL_STATE <const> = 0x28006D45 -- this value was determined empirically
142+
local state = INITIAL_STATE
143+
local result = {}
144+
145+
for i = 1, #buffer do
146+
-- BSD rand()
147+
if (i - 1) % 0x20000 == 0 then
148+
state = INITIAL_STATE
149+
end
150+
state = (state * 0x41c64e6d + 0x3039) & 0xFFFFFFFF -- Simulate 32-bit overflow
151+
local upper = state >> 16
152+
local c = upper + math.floor(upper / 255)
153+
154+
local byte = string.byte(buffer, i)
155+
byte = byte ~ (c & 0xFF) -- XOR operation on the byte
156+
157+
table.insert(result, string.char(byte))
158+
end
159+
160+
return table.concat(result)
161+
end
162+
163+
--[[
164+
%extract_enigmaxml
165+
166+
EnigmaXML is the underlying file format of a Finale `.musx` file. It is undocumented
167+
by MakeMusic and must be extracted from the `.musx` file. There is an effort to document
168+
it underway at the [EnigmaXML Documentation](https://github.com/finale-lua/ziputils-documentation)
169+
repository.
170+
171+
This function extracts the EnigmaXML buffer from a `.musx` file. Note that it does not work with Finale's
172+
older `.mus` format.
173+
174+
@ filepath (string) utf8-encoded file path to a `.musx` file.
175+
: (string) utf8-encoded buffer of xml data containing the EnigmaXml extracted from the `.musx`.
176+
]]
177+
function ziputils.extract_enigmaxml(filepath)
178+
if not_supported_message then
179+
error(not_supported_message, 2)
180+
end
181+
local _, _, extension = utils.split_file_path(filepath)
182+
if extension ~= ".musx" then
183+
error(filepath .. " is not a .musx file.", 2)
184+
end
185+
186+
local text = require("luaosutils").text
187+
local process = require("luaosutils").process
188+
189+
local os_filepath = text.convert_encoding(filepath, text.get_utf8_codepage(), text.get_default_codepage())
190+
local output_dir, zipcommand = ziputils.calc_temp_output_path(os_filepath)
191+
if not process.execute(zipcommand) then
192+
error(zipcommand .. " failed")
193+
end
194+
195+
local file <close> = io.open(output_dir .. "/score.dat", "rb")
196+
if not file then
197+
error("unable to read " .. output_dir .. "/score.dat")
198+
end
199+
local buffer = file:read("*all")
200+
file:close()
201+
202+
local delcommand = ziputils.calc_rmdir_command(output_dir)
203+
process.execute(delcommand)
204+
205+
buffer = crypt_enigmaxml_buffer(buffer)
206+
if ziputils.calc_is_gzip(buffer) then
207+
local gzip_path = ziputils.calc_temp_output_path()
208+
local gzip_file <close> = io.open(gzip_path, "wb")
209+
if not gzip_file then
210+
error("unable to create " .. gzip_file)
211+
end
212+
gzip_file:write(buffer)
213+
gzip_file:close()
214+
local gunzip_command = ziputils.calc_gunzip_command(gzip_path)
215+
buffer = process.execute(gunzip_command)
216+
process.execute(ziputils.calc_delete_file_command(gzip_path))
217+
if not buffer or buffer == "" then
218+
error(gunzip_command .. "failed")
219+
end
220+
end
221+
222+
return buffer
223+
end
224+
225+
return ziputils

0 commit comments

Comments
 (0)