From a77f7d3afb1f5e076c5190ab9c84372e72dd67ba Mon Sep 17 00:00:00 2001 From: Karl Berry Date: Tue, 28 May 2019 21:25:42 +0000 Subject: lyluatex (28may19) git-svn-id: svn://tug.org/texlive/trunk@51252 c570f23f-e606-0410-a88d-b1316a301751 --- .../texmf-dist/scripts/lyluatex/lyluatex-lib.lua | 163 +++++ .../scripts/lyluatex/lyluatex-options.lua | 266 +++++++++ Master/texmf-dist/scripts/lyluatex/lyluatex.lua | 662 +++++++++------------ 3 files changed, 725 insertions(+), 366 deletions(-) create mode 100755 Master/texmf-dist/scripts/lyluatex/lyluatex-lib.lua create mode 100755 Master/texmf-dist/scripts/lyluatex/lyluatex-options.lua (limited to 'Master/texmf-dist/scripts/lyluatex') diff --git a/Master/texmf-dist/scripts/lyluatex/lyluatex-lib.lua b/Master/texmf-dist/scripts/lyluatex/lyluatex-lib.lua new file mode 100755 index 00000000000..ebe6745eb59 --- /dev/null +++ b/Master/texmf-dist/scripts/lyluatex/lyluatex-lib.lua @@ -0,0 +1,163 @@ +-- luacheck: ignore ly log self luatexbase internalversion font fonts tex token kpse status +local err, warn, info, log = luatexbase.provides_module({ + name = "lyluatex-lib", + version = '1.0f', --LYLUATEX_VERSION + date = "2019/05/27", --LYLUATEX_DATE + description = "Module lyluatex-lib.", + author = "The Gregorio Project − (see Contributors.md)", + copyright = "2015-2019 - jperon and others", + license = "MIT", +}) + +local lib = {} +lib.TEX_UNITS = {'bp', 'cc', 'cm', 'dd', 'in', 'mm', 'pc', 'pt', 'sp', 'em', +'ex'} + +------------------------- +-- General tool functions + +function lib.basename(str) +--[[ + Given the full path to a file, return only the file name (without path). + If there is no slash, return the full name. +--]] + return str:gsub(".*/(.*)", "%1") or str +end + + +function lib.contains(table_var, value) +--[[ + Returns the key if the given table contains the given value, or nil. + A value of 'false' (string) is considered equal to false (Boolean). +--]] + for k, v in pairs(table_var) do + if v == value then return k + elseif v == 'false' and value == false then return k + end + end +end + + +function lib.contains_key(table_var, key) +-- Returs true if the given key is present in the table, nil otherwise. + for k in pairs(table_var) do + if k == key then return true end + end +end + + +function lib.convert_unit(value) +--[[ + Convert a LaTeX unit, if possible. + TODO: Understand what this *really* does, what is accepted and returned. +--]] + if not value then return 0 + elseif value == '' then return false + elseif value:match('\\') then + local n, u = value:match('^%d*%.?%d*'), value:match('%a+') + if n == '' then n = 1 end + return tonumber(n) * tex.dimen[u] / tex.sp("1pt") + else return ('%f'):format(tonumber(value) or tex.sp(value) / tex.sp("1pt")) + end +end + + +function lib.dirname(str) +--[[ + Given the full path to a file, return only the path (without file name), + including the last slash. If there is no slash, return an empty string. +--]] + return str:gsub("(.*/).*", "%1") or '' +end + + +local fontdata = fonts.hashes.identifiers +function lib.fontinfo(id) +--[[ + Return a LuaTeX font object based on the given ID +--]] + return fontdata[id] or font.fonts[id] +end + + +function lib.max(a, b) + a, b = tonumber(a), tonumber(b) + if a > b then return a else return b end +end + + +function lib.min(a, b) + a, b = tonumber(a), tonumber(b) + if a < b then return a else return b end +end + + +function lib.mkdirs(str) + local path + if str:sub(1, 1) == '/' then path = '' else path = '.' end + for dir in str:gmatch('([^%/]+)') do + path = path .. '/' .. dir + lfs.mkdir(path) + end +end + + +function lib.orderedpairs(t) + local key + local i = 0 + local orderedIndex = {} + for k in pairs(t) do table.insert(orderedIndex, k) end + table.sort(orderedIndex) + return function () + i = i + 1 + key = orderedIndex[i] + if key then return key, t[key] end + end +end + + +function lib.readlinematching(s, f) + if f then + local result = '' + while result and not result:find(s) do result = f:read() end + f:close() + return result + end +end + + +function lib.splitext(str, ext) +--[[ + If 'ext' is supplied return str stripped of the given extension, + otherwise return the base and extension (if any) +--]] + return ext and (str:match('(.*)%.'..ext..'$') or str) + or (str:match('(.*)%.(%w*)$') or str) +end + + +------------------------------------ +-- Engine, version, TeX distribution + +local tex_engine = {} +setmetatable(tex_engine, tex_engine) + +function tex_engine:__call() +--[[ + Defines the properties extracted from the first line of jobname.log. +--]] + local f = io.open(tex.jobname..'.log') + if not f then return end + self.engine, self.engine_version, self.dist, self.dist_version, self.format, self.format_version = + f:read():match( + 'This is ([^,]*), Version ([^%(]*) %(([^%)]*) ([^%)]*)%)%s+%(format=([^%)]*) ([^)]*)%)' + ) + f:close() + return self +end + +function tex_engine:__index(k) return rawget(self(), k) end + + +lib.tex_engine = tex_engine +return lib diff --git a/Master/texmf-dist/scripts/lyluatex/lyluatex-options.lua b/Master/texmf-dist/scripts/lyluatex/lyluatex-options.lua new file mode 100755 index 00000000000..62ec72a3c3c --- /dev/null +++ b/Master/texmf-dist/scripts/lyluatex/lyluatex-options.lua @@ -0,0 +1,266 @@ +-- luacheck: ignore ly warn info log self luatexbase internalversion font fonts tex token kpse status +local err, warn, info, log = luatexbase.provides_module({ + name = "lyluatex-options", + version = '1.0f', --LYLUATEX_VERSION + date = "2019/05/27", --LYLUATEX_DATE + description = "Module lyluatex-options.", + author = "The Gregorio Project − (see Contributors.md)", + copyright = "2015-2019 - jperon and others", + license = "MIT", +}) + +--[[ + This module provides functionality to handle package options and make them + configurable in a fine-grained fashion as + - package options + - local options (for individual instances of commands/environments) + - changed “from here on” within a document. + +-- ]] + +local lib = require(kpse.find_file("lyluatex-lib.lua") or "lyluatex-lib.lua") +local optlib = {} -- namespace for the returned table +local Opts = {options = {}} -- Options class +Opts.__index = function (self, k) return self.options[k] or rawget(Opts, k) end +setmetatable(Opts, Opts) + + +function Opts:new(opt_prefix, declarations) +--[[ + Declare package options along with their default and + accepted values. To *some* extent also provide type checking. + - opt_prefix: the prefix/name by which the lyluatex-options module is + referenced in the parent LaTeX document (preamble or package). + This is required to write the code calling optlib.set_option into + the option declaration. + - declarations: a definition table stored in the calling module (see below) + Each entry in the 'declarations' table represents one package option, with each + value being an array (table with integer indexes instead of keys). For + details please refer to the manual. +--]] + local o = setmetatable( + { + declarations = declarations, + options = {} + }, + self + ) + local exopt = '' + for k, v in pairs(declarations) do + o.options[k] = v[1] or '' + tex.sprint(string.format([[ +\DeclareOptionX{%s}{\directlua{ + %s:set_option('%s', '\luatexluaescapestring{#1}') +}}%% +]], + k, opt_prefix, k + )) + exopt = exopt..k..'='..(v[1] or '')..',' + end + tex.sprint([[\ExecuteOptionsX{]]..exopt..[[}%%]], [[\ProcessOptionsX]]) + return o +end + +function Opts:check_local_options(opts, ignore_declarations) +--[[ + Parse the given options (options passed to a command/environment), + sanitize them against the global package options and return a table + with the local options that should then supersede the global options. + If ignore_declaration is given any non-false value the sanitization + step is skipped (i.e. local options are only parsed and duplicates + rejected). +--]] + local options = {} + local next_opt = opts:gmatch('([^,]+)') -- iterator over options + for opt in next_opt do + local k, v = opt:match('([^=]+)=?(.*)') + if k then + if v and v:sub(1, 1) == '{' then -- handle keys with {multiple, values} + while select(2, v:gsub('{', '')) ~= select(2, v:gsub('}', '')) do v = v..','..next_opt() end + v = v:sub(2, -2) -- remove { } + end + if not ignore_declarations then + k, v = self:sanitize_option(k:gsub('^%s', ''), v:gsub('^%s', '')) + end + if k then + if options[k] then err('Option %s is set two times for the same score.', k) + else options[k] = v + end + end + end + end + return options +end + +function Opts:is_neg(k) +--[[ + Type check for a 'negative' option. This is an existing option + name prefixed with 'no' (e.g. 'noalign') +--]] + local _, i = k:find('^no') + return i and lib.contains_key(self.declarations, k:sub(i + 1)) +end + +function Opts:sanitize_option(k, v) +--[[ + Check and (if necessary) adjust the value of a given option. + Reject undefined options + Check 'negative' options + Handle boolean options (empty strings or 'false'), set them to real booleans +--]] + local declarations = self.declarations + if k == '' or k == 'noarg' then return end + if not lib.contains_key(declarations, k) then err('Unknown option: '..k) end + -- aliases + if declarations[k] and declarations[k][2] == optlib.is_alias then + if declarations[k][1] == v then return + else k = declarations[k] end + end + -- boolean + if v == 'false' then v = false end + -- negation (for example, noindent is the negation of indent) + if self:is_neg(k) then + if v ~= nil and v ~= 'default' then + k = k:gsub('^no(.*)', '%1') + v = not v + else return + end + end + return k, v +end + +function Opts:set_option(k, v) +--[[ + Set an option for the given prefix to be in effect from this point on. + Raises an error if the option is not declared or does not meet the + declared expectations. (TODO: The latter has to be integrated by extracting + optlib.validate_option from optlib.validate_options and call it in + sanitize_option). +--]] + k, v = self:sanitize_option(k, v) + if k then + self.options[k] = v + self:validate_option(k) + end +end + +function Opts:validate_option(key, options_obj) +--[[ + Validate an (already sanitized) option against its expected values. + With options_obj a local options table can be provided, + otherwise the global options stored in OPTIONS are checked. +--]] + local package_opts = self.declarations + local options = options_obj or self.options + local unexpected + if options[key] == 'default' then + -- Replace 'default' with an actual value + options[key] = package_opts[key][1] + unexpected = options[key] == nil + end + if not lib.contains(package_opts[key], options[key]) and package_opts[key][2] then + -- option value is not in the array of accepted values + if type(package_opts[key][2]) == 'function' then package_opts[key][2](key, options[key]) + else unexpected = true + end + end + if unexpected then + err([[ + Unexpected value "%s" for option %s: + authorized values are "%s" + ]], + options[key], key, table.concat(package_opts[key], ', ') + ) + end +end + +function Opts:validate_options(options_obj) +--[[ + Validate the given set of options against the option declaration + table for the given prefix. + With options_obj a local options table can be provided, + otherwise the global options stored in OPTIONS are checked. +--]] + for k, _ in lib.orderedpairs(self.declarations) do + self:validate_option(k, options_obj) + end +end + + +function optlib.is_alias() +--[[ + This function doesn't do anything, but if an option is defined + as an alias, its second parameter will be this function, so the + test declarations[k][2] == optlib.is_alias in Opts:sanitize_options + will return true. +--]] +end + + +function optlib.is_dim(k, v) +--[[ + Type checking for options that accept a LaTeX dimension. + This can be + - a number (integer or float) + - a number with unit + - a (multiplied) TeX length + (see error message in code for examples) +--]] + if v == '' or v == false or tonumber(v) then return true end + local n, sl, u = v:match('^%d*%.?%d*'), v:match('\\'), v:match('%a+') + -- a value of number - backslash - length is a dimension + -- invalid input will be prevented in by the LaTeX parser already + if n and sl and u then return true end + if n and lib.contains(lib.TEX_UNITS, u) then return true end + err([[ +Unexpected value "%s" for dimension %s: +should be either a number (for example "12"), +a number with unit, without space ("12pt"), +or a (multiplied) TeX length (".8\linewidth") +]], + v, k + ) +end + + +function optlib.is_neg(k, _) +--[[ + Type check for a 'negative' option. At this stage, + we only check that it begins with 'no'. +--]] + return k:find('^no') +end + + +function optlib.is_num(_, v) +--[[ + Type check for number options +--]] + return v == '' or tonumber(v) +end + + +function optlib.is_str(_, v) +--[[ + Type check for string options +--]] + return type(v) == 'string' +end + + +function optlib.merge_options(base_opt, super_opt) +--[[ + Merge two tables. + Create a new table as a copy of base_opt, then merge with + super_opt. Entries in super_opt supersede (i.e. overwrite) + colliding entries in base_opt. +--]] + local result = {} + for k, v in pairs(base_opt) do result[k] = v end + for k, v in pairs(super_opt) do result[k] = v end + return result +end + + +optlib.Opts = Opts +return optlib diff --git a/Master/texmf-dist/scripts/lyluatex/lyluatex.lua b/Master/texmf-dist/scripts/lyluatex/lyluatex.lua index fd54cd22c87..1023e20ef9a 100755 --- a/Master/texmf-dist/scripts/lyluatex/lyluatex.lua +++ b/Master/texmf-dist/scripts/lyluatex/lyluatex.lua @@ -1,24 +1,29 @@ --- luacheck: ignore ly log self luatexbase internalversion font fonts tex token kpse status +-- luacheck: ignore ly log self luatexbase internalversion font fonts tex token kpse status ly_opts local err, warn, info, log = luatexbase.provides_module({ name = "lyluatex", - version = '1.0b', --LYLUATEX_VERSION - date = "2018/03/12", --LYLUATEX_DATE + version = '1.0f', --LYLUATEX_VERSION + date = "2019/05/27", --LYLUATEX_DATE description = "Module lyluatex.", author = "The Gregorio Project − (see Contributors.md)", - copyright = "2015-2018 - jperon and others", + copyright = "2015-2019 - jperon and others", license = "MIT", }) +local lib = require(kpse.find_file("lyluatex-lib.lua") or "lyluatex-lib.lua") +local ly_opts = ly_opts -- global ly_opts has been defined before in lyluatex.sty + local md5 = require 'md5' local lfs = require 'lfs' local latex = {} -local ly = {} -local obj = {} -local Score = {} +local ly = { + err = err, + varwidth_available = kpse.find_file('varwidth.sty') +} +local Score = ly_opts.options +Score.__index = Score local FILELIST -local OPTIONS = {} local DIM_OPTIONS = { 'extra-bottom-margin', 'extra-top-margin', @@ -38,6 +43,8 @@ local DIM_OPTIONS = { local HASHIGNORE = { 'autoindent', 'cleantmp', + 'do-not-print', + 'force-compilation', 'hpadding', 'max-left-protrusion', 'max-right-protrusion', @@ -56,7 +63,6 @@ local MXML_OPTIONS = { 'verbose', } local TEXINFO_OPTIONS = {'doctitle', 'nogettext', 'texidoc'} -local TEX_UNITS = {'bp', 'cc', 'cm', 'dd', 'in', 'mm', 'pc', 'pt', 'sp', 'em', 'ex'} local LY_HEAD = [[ %%File header \version "<<>>" @@ -81,13 +87,15 @@ local LY_HEAD = [[ } \layout{ <<>> + <<>> } +<<
>> %%Follows original score ]] ---[[ ========================== Helper functions ========================== ]] +--[[ ========================== Helper functions ========================== --]] -- dirty fix as info doesn't work as expected local oldinfo = info function info(...) @@ -100,55 +108,33 @@ local function debug(...) end -local function contains(table_var, value) - for _, v in pairs(table_var) do - if v == value then return true - elseif v == 'false' and value == false then return true - end - end -end - - -local function contains_key(table_var, key) - for k in pairs(table_var) do - if k == key then return true end - end -end - - -local function convert_unit(value) - if not value then return 0 - elseif value == '' then return false - elseif value:match('\\') then - local n, u = value:match('^%d*%.?%d*'), value:match('%a+') - if n == '' then n = 1 end - return tonumber(n) * tex.dimen[u] / tex.sp("1pt") - else return ('%f'):format(tonumber(value) or tex.sp(value) / tex.sp("1pt")) - end -end - - -local function dirname(str) return str:gsub("(.*/)(.*)", "%1") or '' end - - local function extract_includepaths(includepaths) includepaths = includepaths:explode(',') local cfd = Score.currfiledir:gsub('^$', './') table.insert(includepaths, 1, cfd) for i, path in ipairs(includepaths) do -- delete initial space (in case someone puts a space after the comma) - includepaths[i] = path:gsub('^ ', ''):gsub('^~', os.getenv("HOME")) + includepaths[i] = path:gsub('^ ', ''):gsub('^~', os.getenv("HOME")):gsub('^%.%.', './..') end return includepaths end -local fontdata = fonts.hashes.identifiers -local function fontinfo(id) return fontdata[id] or font.fonts[id] end +local function font_default_staffsize() + return lib.fontinfo(font.current()).size/39321.6 +end -local function font_default_staffsize() - return fontinfo(font.current()).size/39321.6 +local function includes_parse(list) + local includes = '' + if list then + list = list:explode(',') + for _, included_file in ipairs(list) do + warn(included_file) + includes = includes .. '\\include "'..included_file..'.ly"\n' + end + end + return includes end @@ -165,70 +151,12 @@ local function locate(file, includepaths, ext) end -local function max(a, b) - a, b = tonumber(a), tonumber(b) - if a > b then return a else return b end -end - - -local function min(a, b) - a, b = tonumber(a), tonumber(b) - if a < b then return a else return b end -end - - -local function mkdirs(str) - local path = '.' - for dir in str:gmatch('([^%/]+)') do - path = path .. '/' .. dir - lfs.mkdir(path) - end -end - - -local function orderedpairs(t) - local key - local i = 0 - local orderedIndex = {} - for k in pairs(t) do table.insert(orderedIndex, k) end - table.sort(orderedIndex) - return function () - i = i + 1 - key = orderedIndex[i] - if key then return key, t[key] end - end -end - - -local function process_options(k, v) - if k == '' or k == 'noarg' then return end - if not contains_key(OPTIONS, k) then err('Unknown option: '..k) end - -- aliases - if OPTIONS[k] and OPTIONS[k][2] == ly.is_alias then - if OPTIONS[k][1] == v then return - else k = OPTIONS[k][1] - end - end - -- boolean - if v == 'false' then v = false end - -- negation (for example, noindent is the negation of indent) - if ly.is_neg(k) then - if v ~= nil and v ~= 'default' then - k = k:gsub('^no(.*)', '%1') - v = not v - else return - end - end - return k, v -end - - local function range_parse(range, nsystems) local num = tonumber(range) if num then return {num} end -- if nsystems is set, we have insert=systems if nsystems ~= 0 and range:sub(-1) == '-' then range = range..nsystems end - if not range:match('^%d+%s*-%s*%d*$') then + if not (range == '' or range:match('^%d+%s*-%s*%d*$')) then warn([[ Invalid value '%s' for item in list of page ranges. Possible entries: @@ -252,16 +180,6 @@ This item will be skipped! end -local function readlinematching(s, f) - if f then - local result = '' - while result and not result:find(s) do result = f:read() end - f:close() - return result - end -end - - local function set_lyscore(score) ly.score = score ly.score.nsystems = ly.score:count_systems() @@ -277,23 +195,34 @@ local function set_lyscore(score) end -local function splitext(str, ext) return str:match('(.*)%.'..ext..'$') or str end - - ---[[ ================ Bounding box calculations =========================== ]] +--[[ ================ Bounding box calculations =========================== --]] local bbox = {} function bbox.get(filename, line_width) return bbox.read(filename) or bbox.parse(filename, line_width) end +function bbox.calc(x_1, x_2, y_1, y_2, line_width) + local bb = { + ['protrusion'] = -lib.convert_unit(("%fbp"):format(x_1)), + ['r_protrusion'] = lib.convert_unit(("%fbp"):format(x_2)) - line_width, + ['width'] = lib.convert_unit(("%fbp"):format(x_2)) + } + --FIX #192: height is only calculated if really needed, to prevent errors with huge scores. + function bb.__index(_, k) + if k == 'height' then return lib.convert_unit(("%fbp"):format(y_2)) - lib.convert_unit(("%fbp"):format(y_1)) end + end + setmetatable(bb, bb) + return bb +end + function bbox.parse(filename, line_width) -- get BoundingBox from EPS file - local bbline = readlinematching('^%%%%BoundingBox', io.open(filename..'.eps', 'r')) + local bbline = lib.readlinematching('^%%%%BoundingBox', io.open(filename..'.eps', 'r')) if not bbline then return end local x_1, y_1, x_2, y_2 = bbline:match('(%--%d+)%s(%--%d+)%s(%--%d+)%s(%--%d+)') -- try to get HiResBoundingBox from PDF (if 'gs' works) - bbline = readlinematching( + bbline = lib.readlinematching( '^%%%%HiResBoundingBox', io.popen('gs -sDEVICE=bbox -q -dBATCH -dNOPAUSE '..filename..'.pdf 2>&1', 'r') ) @@ -308,20 +237,24 @@ function bbox.parse(filename, line_width) x_1, y_1, x_2, y_2 = pbb() + x_1, pbb() + y_1, pbb() + x_1, pbb() + y_1 else warn([[gs couldn't be launched; there could be rounding errors.]]) end - local bb = { - ['protrusion'] = -convert_unit(("%fbp"):format(x_1)), - ['r_protrusion'] = convert_unit(("%fbp"):format(x_2)) - line_width, - ['width'] = convert_unit(("%fbp"):format(x_2)), - ['height'] = convert_unit(("%fbp"):format(y_2)) - convert_unit(("%fbp"):format(y_1)) - } - obj.serialize(bb, filename..'.bbox') - return bb + local f = io.open(filename .. '.bbox', 'w') + f:write( + string.format("return %s, %s, %s, %s, %s", x_1, y_1, x_2, y_2, line_width) + ) + f:close() + return bbox.calc(x_1, x_2, y_1, y_2, line_width) end -function bbox.read(f) return obj.deserialize(f..'.bbox') end +function bbox.read(f) + f = f .. '.bbox' + if lfs.isfile(f) then + local x_1, y_1, x_2, y_2, line_width = dofile(f) + return bbox.calc(x_1, x_2, y_1, y_2, line_width) + end +end ---[[ =============== Functions that output LaTeX code ===================== ]] +--[[ =============== Functions that output LaTeX code ===================== --]] function latex.filename(printfilename, insert, input_file) if printfilename and input_file then @@ -347,8 +280,8 @@ end function latex.includeinline(pdfname, height, valign, hpadding, voffset) local v_base if valign == 'bottom' then v_base = 0 - elseif valign == 'top' then v_base = convert_unit('1em') - height - else v_base = (convert_unit('1em') - height) / 2 + elseif valign == 'top' then v_base = lib.convert_unit('1em') - height + else v_base = (lib.convert_unit('1em') - height) / 2 end tex.sprint( string.format( @@ -407,8 +340,8 @@ function latex.verbatim(verbatim, ly_code, intertext, version) '.*%%%s*begin verbatim', ''):gsub( '%%%s*end verbatim.*', '') --[[ We unfortunately need an external file, - as verbatim environments are quite special. ]] - local fname = ly.get_option('tmpdir')..'/verb.tex' + as verbatim environments are quite special. --]] + local fname = ly_opts.tmpdir..'/verb.tex' local f = io.open(fname, 'w') f:write( ly.verbenv[1]..'\n'.. @@ -422,36 +355,12 @@ function latex.verbatim(verbatim, ly_code, intertext, version) end ---[[ =============== Functions that operate on objects ==================== ]] - -function obj.deserialize(f) if lfs.isfile(f) then return dofile(f) end end - -function obj.serialize(o, f) - f = io.open(f, 'w') - if not f then return end - f:write('local o = '..obj.str(o)..'\nreturn o') - f:close() -end - -function obj.str(o) - if type(o) == "number" then return o - elseif type(o) == "string" then return string.format("%q", o) - elseif type(o) == "table" then - local r = '{\n' - for k, v in pairs(o) do r = r.." ["..obj.str(k).."] = "..obj.str(v)..",\n" end - return r.."}\n" - else error("cannot convert " .. type(o).." to string") - end -end - - ---[[ =============================== Classes =============================== ]] +--[[ =============================== Classes =============================== --]] -- Score class function Score:new(ly_code, options, input_file) local o = options or {} setmetatable(o, self) - self.__index = self o.output_names = {} o.input_file = input_file o.ly_code = ly_code @@ -475,6 +384,10 @@ end function Score:calc_properties() self:calc_staff_properties() + -- add includes to lilypond code + self.ly_code = includes_parse(self.include_before_body) + .. self.ly_code + .. includes_parse(self.include_after_body) -- fragment and relative if self.relative and not self.fragment then -- local option takes precedence over global option @@ -506,7 +419,7 @@ function Score:calc_properties() end -- dimensions that can be given by LaTeX for _, dimension in pairs(DIM_OPTIONS) do - self[dimension] = convert_unit(self[dimension]) + self[dimension] = lib.convert_unit(self[dimension]) end self['max-left-protrusion'] = self['max-left-protrusion'] or self['max-protrusion'] self['max-right-protrusion'] = self['max-right-protrusion'] or self['max-protrusion'] @@ -533,21 +446,30 @@ end function Score:calc_range() local nsystems = self:count_systems(true) - if self['print-only'] == '' then - if nsystems == 0 then self['print-only'] = '1-' - else self['print-only'] = '1-'..nsystems + local printonly, donotprint = self['print-only'], self['do-not-print'] + if printonly == '' then printonly = '1-' end + local result = tonumber(printonly) and {tonumber(printonly)} or {} + if not result[1] then + for _, r in pairs(printonly:explode(',')) do + local range = range_parse(r:gsub('^%s', ''):gsub('%s$', ''), nsystems) + if range then + for _, v in pairs(range) do table.insert(result, v) end + end end end - local result = {} - if tonumber(self['print-only']) then result = {self['print-only']} - else - for _, r in pairs(self['print-only']:explode(',')) do + local rm_result = tonumber(donotprint) and {tonumber(donotprint)} or {} + if not rm_result[1] then + for _, r in pairs(donotprint:explode(',')) do local range = range_parse(r:gsub('^%s', ''):gsub('%s$', ''), nsystems) if range then - for _, v in pairs(range) do table.insert(result, v) end + for _, v in pairs(range) do table.insert(rm_result, v) end end end end + for _, v in pairs(rm_result) do + local k = lib.contains(result, v) + if k then table.remove(result, k) end + end return result end @@ -651,9 +573,9 @@ function Score:check_indent(lp) if lp.shorten > 0 then if not self.indent or self.indent == 0 then self.indent = lp.overflow_left - lp.shorten = max(lp.shorten - lp.overflow_left, 0) + lp.shorten = lib.max(lp.shorten - lp.overflow_left, 0) else - self.indent = max(self.indent - lp.overflow_left, 0) + self.indent = lib.max(self.indent - lp.overflow_left, 0) end lp.changed_indent = true end @@ -700,26 +622,7 @@ function Score:check_indent(lp) end function Score:check_properties() - local unexpected = false - for k, _ in orderedpairs(OPTIONS) do - if self[k] == 'default' then - self[k] = OPTIONS[k][1] or nil - unexpected = not self[k] - end - if not contains(OPTIONS[k], self[k]) and OPTIONS[k][2] then - if type(OPTIONS[k][2]) == 'function' then OPTIONS[k][2](k, self[k]) - else unexpected = true - end - end - if unexpected then - err([[ -Unexpected value "%s" for option %s: -authorized values are "%s" -]], - self[k], k, table.concat(OPTIONS[k], ', ') - ) - end - end + ly_opts:validate_options(self) for _, k in pairs(TEXINFO_OPTIONS) do if self[k] then info([[Option %s is specific to Texinfo: ignoring it.]], k) end end @@ -750,20 +653,20 @@ function Score:check_protrusion(bbox_func) -- line_props lp local lp = {} -- Determine offset due to left protrusion - lp.overflow_left = max(bb.protrusion - math.floor(self['max-left-protrusion']), 0) + lp.overflow_left = lib.max(bb.protrusion - math.floor(self['max-left-protrusion']), 0) self.protrusion_left = lp.overflow_left - bb.protrusion -- Determine further line properties - lp.stave_extent = lp.overflow_left + min(self['line-width'], bb.width) + lp.stave_extent = lp.overflow_left + lib.min(self['line-width'], bb.width) lp.available = self.original_lw + self['max-right-protrusion'] lp.total_extent = lp.stave_extent + bb.r_protrusion -- Check if stafflines protrude into the right margin after offsetting -- Note: we can't *reliably* determine this with ragged one-system scores, -- possibly resulting in unnecessarily short lines when right protrusion is -- present - lp.stave_overflow_right = max(lp.stave_extent - self.original_lw, 0) + lp.stave_overflow_right = lib.max(lp.stave_extent - self.original_lw, 0) -- Check if image as a whole protrudes over max-right-protrusion - lp.overflow_right = max(lp.total_extent - lp.available, 0) - lp.shorten = max(lp.stave_overflow_right, lp.overflow_right) + lp.overflow_right = lib.max(lp.total_extent - lp.available, 0) + lp.shorten = lib.max(lp.stave_overflow_right, lp.overflow_right) lp.changed_indent = false self:check_indent(lp, bb) if lp.shorten > 0 or lp.changed_indent then @@ -789,6 +692,7 @@ end function Score:content() local n = '' + local ly_code = self.ly_code if self.relative then self.fragment = 'true' -- in case it would serve later if self.relative < 0 then @@ -796,9 +700,9 @@ function Score:content() elseif self.relative > 0 then for _ = 1, self.relative do n = n.."'" end end - return string.format([[\relative c%s {%s}]], n, self.ly_code) - elseif self.fragment then return [[{]]..self.ly_code..[[}]] - else return self.ly_code + return string.format([[\relative c%s {%s}]], n, ly_code) + elseif self.fragment then return [[{]]..ly_code..[[}]] + else return ly_code end end @@ -832,9 +736,13 @@ function Score:flatten_content(ly_code) including referenced files (if they can be opened. Other files (from LilyPond's include path) are considered irrelevant for the purpose of a hashsum.) --]] + + -- Replace percent signs with another character that doesn't + -- meddle with Lua's gsub escape character. + ly_code = ly_code:gsub('%%', '#') local f - local includepaths = self.includepaths - if self.input_file then includepaths = self.includepaths..','..dirname(self.input_file) end + local includepaths = self.includepaths..','..self.tmpdir + if self.input_file then includepaths = self.includepaths..','..lib.dirname(self.input_file) end for iline in ly_code:gmatch('\\include%s*"[^"]*"') do f = io.open(locate(iline:match('\\include%s*"([^"]*)"'), includepaths, '.ly') or '') if f then @@ -845,46 +753,75 @@ function Score:flatten_content(ly_code) return ly_code end +function Score:footer() + return includes_parse(self.include_footer) +end + function Score:header() local header = LY_HEAD for element in LY_HEAD:gmatch('<<<(%w+)>>>') do header = header:gsub('<<<'..element..'>>>', self['ly_'..element](self) or '') end + local wh_dest = self['write-headers'] + if wh_dest then + if self.input_file then + local _, ext = lib.splitext(wh_dest) + local header_file = ext and wh_dest + or wh_dest..'/'..lib.splitext(lib.basename(self.input_file), 'ly').."-lyluatex-headers.ily" + lib.mkdirs(lib.dirname(header_file)) + local f = io.open(header_file, 'w') + f:write(header + :gsub([[%\include "lilypond%-book%-preamble.ly"]], '') + :gsub([[%#%(define inside%-lyluatex %#t%)]], '') + :gsub('\n+', '\n') + ) + f:close() + else + warn([[Ignoring 'write-headers' for non-file score.]]) + end + end return header end function Score:is_compiled() + if self['force-compilation'] then return false end return lfs.isfile(self.output..'.pdf') or self:count_systems(true) ~= 0 end function Score:is_odd_page() return tex.count['c@page'] % 2 == 1 end -function Score:lilypond_cmd(ly_code) +function Score:lilypond_cmd() local input, mode = '-s -', 'w' if self.debug then local f = io.open(self.output..'.ly', 'w') - f:write(ly_code) + f:write(self.complete_ly_code) f:close() input = self.output..".ly 2>&1" mode = 'r' end - local cmd = self.program.." ".. + local cmd = '"'..self.program..'" '.. "-dno-point-and-click ".. "-djob-count=2 ".. "-dno-delete-intermediate-files " + if self['optimize-pdf'] and self:lilypond_has_TeXGS() then cmd = cmd.."-O TeX-GS " end if self.input_file then - cmd = cmd..'-I "'..dirname(self.input_file):gsub('%./', lfs.currentdir()..'/')..'" ' + cmd = cmd..'-I "'..lib.dirname(self.input_file):gsub('^%./', lfs.currentdir()..'/')..'" ' end for _, dir in ipairs(extract_includepaths(self.includepaths)) do cmd = cmd..'-I "'..dir:gsub('^%./', lfs.currentdir()..'/')..'" ' end cmd = cmd..'-o "'..self.output..'" '..input + if lib.tex_engine.dist == 'MiKTeX' then cmd = '"'..cmd..'"' end debug("Command:\n"..cmd) return cmd, mode end +function Score:lilypond_has_TeXGS() + return lib.readlinematching('TeX%-GS', io.popen('"'..self.program..'" --help', 'r')) +end + function Score:lilypond_version(number) - local result = readlinematching('GNU LilyPond', io.popen(self.program..' --version', 'r')) + local result = lib.readlinematching('GNU LilyPond', io.popen('"'..self.program..'" --version', 'r')) if result then if number then return result:match('%d+%.%d+%.?%d*') else @@ -893,18 +830,22 @@ function Score:lilypond_version(number) self.output, self.program ) debug(result) + return true end - else - err([[ -LilyPond could not be started. -Please check that LuaLaTeX is started with the ---shell-escape option, and that 'program' -points to a valid LilyPond executable. -]] - ) end end +function Score:ly_fixbadlycroppedstaffgroupbrackets() + return self.fix_badly_cropped_staffgroup_brackets and [[\context { + \Score + \override SystemStartBracket.after-line-breaking = + #(lambda (grob) + (let ((Y-off (ly:grob-property grob 'Y-extent))) + (ly:grob-set-property! grob 'Y-extent + (cons (- (car Y-off) 1.7) (+ (cdr Y-off) 1.7))))) + }]] +end + function Score:ly_fonts() if self['pass-fonts'] then return string.format([[ @@ -921,6 +862,10 @@ function Score:ly_fonts() end end +function Score:ly_header() + return includes_parse(self.include_header) +end + function Score:ly_indent() if not (self.indent == false and self.insert == 'fullpage') then return [[indent = ]]..(self.indent or 0)..[[\pt]] @@ -936,43 +881,45 @@ function Score:ly_linewidth() return self['line-width'] end function Score:ly_staffsize() return self.staffsize end function Score:ly_margins() + local horizontal_margins = + self.twoside and string.format([[ + inner-margin = %s\pt]], self:tex_margin_inner()) + or string.format([[ + left-margin = %s\pt]], self:tex_margin_left()) + local tex_top = self['extra-top-margin'] + self:tex_margin_top() local tex_bottom = self['extra-bottom-margin'] + self:tex_margin_bottom() if self.fullpagealign == 'crop' then return string.format([[ -top-margin = %s\pt -bottom-margin = %s\pt -inner-margin = %s\pt -left-margin = %s\pt -]], - tex_top, tex_bottom, self:tex_margin_inner(), self:tex_margin_left() + top-margin = %s\pt + bottom-margin = %s\pt + %s]], + tex_top, tex_bottom, horizontal_margins ) elseif self.fullpagealign == 'staffline' then local top_distance = 4 * tex_top / self.staffsize + 2 local bottom_distance = 4 * tex_bottom / self.staffsize + 2 return string.format([[ -top-margin = 0\pt -bottom-margin = 0\pt -inner-margin = %s\pt -left-margin = %s\pt -top-system-spacing = + top-margin = 0\pt + bottom-margin = 0\pt + %s + top-system-spacing = #'((basic-distance . %s) (minimum-distance . %s) (padding . 0) (stretchability . 0)) -top-markup-spacing = + top-markup-spacing = #'((basic-distance . %s) (minimum-distance . %s) (padding . 0) (stretchability . 0)) -last-bottom-spacing = + last-bottom-spacing = #'((basic-distance . %s) (minimum-distance . %s) (padding . 0) (stretchability . 0)) ]], - self:tex_margin_inner(), - self:tex_margin_left(), + horizontal_margins, top_distance, top_distance, top_distance, @@ -992,19 +939,33 @@ Given: %s end function Score:ly_paper() + local system_count = + self['system-count'] == 0 and '' + or 'system-count = '..self['system-count']..'\n ' + local papersize = '#(set-paper-size "'..(self.papersize or 'lyluatexfmt')..'")' if self.insert == 'fullpage' then - local ppn = 'f' - if self['print-page-number'] then ppn = 't' end + local first_page_number = self['first-page-number'] or tex.count['c@page'] + local pfpn = self['print-first-page-number'] and 't' or 'f' + local ppn = self['print-page-number'] and 't' or 'f' return string.format([[ -%s -print-page-number = ##%s -print-first-page-number = ##t -first-page-number = %s +%s%s + print-page-number = ##%s + print-first-page-number = ##%s + first-page-number = %s %s]], - papersize, ppn, tex.count['c@page'], self:ly_margins() + system_count, papersize, ppn, pfpn, + first_page_number, self:ly_margins() ) - elseif self.papersize then return papersize + else + if self.papersize then + papersize = papersize..[[ +]] + else + papersize = '' + end + + return string.format([[%s%s]], papersize, system_count) end end @@ -1019,7 +980,7 @@ function Score:ly_preamble() end function Score:ly_raggedright() - if not self['ragged-right'] == 'default' then + if self['ragged-right'] ~= 'default' then if self['ragged-right'] then return 'ragged-right = ##t' else return 'ragged-right = ##f' end @@ -1041,6 +1002,20 @@ function Score:ly_version() return self['ly-version'] end function Score:optimize_pdf() if not self['optimize-pdf'] then return end + if self:lilypond_has_TeXGS() and not ly.final_optimization_message then + ly.final_optimization_message = true + luatexbase.add_to_callback( + 'stop_run', + function() + info( + [[Optimization enabled: remember to run + 'gs -q -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -sOutputFile=%s %s'.]], + tex.jobname..'-final.pdf', tex.jobname..'.pdf' + ) + end, + 'lyluatex optimize-pdf' + ) + end local pdf2ps, ps2pdf, path for file in lfs.dir(self.tmpdir) do path = self.tmpdir..'/'..file @@ -1069,8 +1044,8 @@ end function Score:output_filename() local properties = '' - for k, _ in orderedpairs(OPTIONS) do - if (not contains(HASHIGNORE, k)) and self[k] and type(self[k]) ~= 'function' then + for k, _ in lib.orderedpairs(ly_opts.declarations) do + if (not lib.contains(HASHIGNORE, k)) and self[k] and type(self[k]) ~= 'function' then properties = properties..'\n'..k..'\t'..self[k] end end @@ -1086,12 +1061,34 @@ end function Score:process() self:check_properties() self:calc_properties() - -- with bbox.read check_ptrotrusion will only execute with + if not self:lilypond_version() then + local warning = [[ +LilyPond could not be started. +Please check that LuaLaTeX is started with the +--shell-escape option, and that 'program' +points to a valid LilyPond executable. +]] + if self.showfailed then + warn(warning) + tex.sprint(string.format([[ +\begin{quote} +\minibox[frame]{LilyPond could not be started.} +\end{quote} + +]])) + return + else + err(warning) + end + end + -- with bbox.read check_protrusion will only execute with -- a prior compilation, otherwise it will be ignored local do_compile = not self:check_protrusion(bbox.read) - if do_compile then + if self['force-compilation'] or do_compile then repeat - self:run_lilypond(self:header()..self:content()) + self.complete_ly_code = self:header()..self:content()..self:footer() + self:run_lilypond() + self['force-compilation'] = false if self:is_compiled() then table.insert(self.output_names, self.output) else self:clean_failed_compilation() @@ -1102,36 +1099,47 @@ function Score:process() else table.insert(self.output_names, self.output) end set_lyscore(self) + if self:count_systems() == 0 then + warn([[ +The score doesn't contain any music: +this will probably cause bad output.]] + ) + end if not self['raw-pdf'] then self:write_latex(do_compile) end self:write_to_filelist() if not self.debug then self:delete_intermediate_files() end end -function Score:run_lilypond(ly_code) +function Score:run_lily_proc(p) + if self.debug then + local f = io.open(self.output..".log", 'w') + f:write(p:read('*a')) + f:close() + else p:write(self.complete_ly_code) + end + return p:close() + end + +function Score:run_lilypond() if self:is_compiled() then return end - mkdirs(dirname(self.output)) - self:lilypond_version() - local p = io.popen(self:lilypond_cmd(ly_code)) - if self.debug then - local f = io.open(self.output..".log", 'w') - f:write(p:read('*a')) - f:close() - else p:write(ly_code) + lib.mkdirs(lib.dirname(self.output)) + if not self:run_lily_proc(io.popen(self:lilypond_cmd(self.complete_ly_code))) and not self.debug then + self.debug = true + self.lilypond_error = not self:run_lily_proc(io.popen(self:lilypond_cmd(self.complete_ly_code))) end - self.lilypond_error = not p:close() end function Score:tex_margin_bottom() self._tex_margin_bottom = self._tex_margin_bottom or - convert_unit(tex.dimen.paperheight..'sp') + lib.convert_unit(tex.dimen.paperheight..'sp') - self:tex_margin_top() - - convert_unit(tex.dimen.textheight..'sp') + - lib.convert_unit(tex.dimen.textheight..'sp') return self._tex_margin_bottom end function Score:tex_margin_inner() self._tex_margin_inner = self._tex_margin_inner or - convert_unit(( + lib.convert_unit(( tex.sp('1in') + tex.dimen.oddsidemargin + tex.dimen.hoffset )..'sp') return self._tex_margin_inner @@ -1139,26 +1147,26 @@ end function Score:tex_margin_outer() self._tex_margin_outer = self._tex_margin_outer or - convert_unit((tex.dimen.paperwidth - tex.dimen.textwidth)..'sp') + lib.convert_unit((tex.dimen.paperwidth - tex.dimen.textwidth)..'sp') - self:tex_margin_inner() return self._tex_margin_outer end function Score:tex_margin_left() - if self:is_odd_page() then return self:tex_margin_inner() + if self:is_odd_page() or not self.twopage then return self:tex_margin_inner() else return self:tex_margin_outer() end end function Score:tex_margin_right() - if self:is_odd_page() then return self:tex_margin_outer() + if self:is_odd_page() or not self.twopage then return self:tex_margin_outer() else return self:tex_margin_inner() end end function Score:tex_margin_top() self._tex_margin_top = self._tex_margin_top or - convert_unit(( + lib.convert_unit(( tex.sp('1in') + tex.dimen.voffset + tex.dimen.topmargin + tex.dimen.headheight + tex.dimen.headsep )..'sp') @@ -1186,9 +1194,12 @@ Score with more than one system included inline. This will probably cause bad output.]] ) end - latex.includeinline( - self.output, self:bbox(1).height, self.valign, self.hpadding, self.voffset - ) + local bb = self:bbox(1) + if bb then + latex.includeinline( + self.output, bb.height, self.valign, self.hpadding, self.voffset + ) + end end end @@ -1202,9 +1213,9 @@ function Score:write_to_filelist() end ---[[ ========================== Public functions ========================== ]] +--[[ ========================== Public functions ========================== --]] + -ly.score_content = {} function ly.buffenv_begin() function ly.buffenv(line) @@ -1259,47 +1270,18 @@ Transcript written on %s.log. end -function ly.declare_package_options(options) - OPTIONS = options - local exopt = '' - for k, v in pairs(options) do - tex.sprint(string.format([[ -\DeclareOptionX{%s}{\directlua{ - ly.set_property('%s', '\luatexluaescapestring{#1}') -}}%% -]], - k, k - )) - exopt = exopt..k..'='..(v[1] or '')..',' - end - tex.sprint([[\ExecuteOptionsX{]]..exopt..[[}%%]], [[\ProcessOptionsX]]) - mkdirs(options.tmpdir[1]) - FILELIST = options.tmpdir[1]..'/'..splitext(status.log_name, 'log')..'.list' +function ly.make_list_file() + local tmpdir = ly_opts.tmpdir + lib.mkdirs(tmpdir) + FILELIST = tmpdir..'/'..lib.splitext(status.log_name, 'log')..'.list' os.remove(FILELIST) end - -function ly.env_begin(opts) - ly.state = 'env' - ly.env_no_args = opts == 'noarg' - if ly.env_no_args then tex.sprint(40, [[\ly@compilely]]) - else tex.sprint(40, [[\ly@bufferenv]]) - end -end - - -function ly.env_end() - if ly.env_no_args then tex.sprint(40, [[\endly@compilely]]) - else tex.sprint(40, [[\endly@bufferenv]]) - end -end - - function ly.file(input_file, options) --[[ Here, we only take in account global option includepaths, - as it really doesn't mean anything as a local option. ]] + as it really doesn't mean anything as a local option. --]] local file = locate(input_file, Score.includepaths, '.ly') - options = ly.set_local_options(options) + options = ly_opts:check_local_options(options) if not file then err("File %s doesn't exist.", input_file) end local i = io.open(file, 'r') ly.score = Score:new(i:read('*a'), options, file) @@ -1309,9 +1291,9 @@ end function ly.file_musicxml(input_file, options) --[[ Here, we only take in account global option includepaths, - as it really doesn't mean anything as a local option. ]] + as it really doesn't mean anything as a local option. --]] local file = locate(input_file, Score.includepaths, '.xml') - options = ly.set_local_options(options) + options = ly_opts:check_local_options(options) if not file then err("File %s doesn't exist.", input_file) end local xmlopts = '' for _, opt in pairs(MXML_OPTIONS) do @@ -1321,16 +1303,17 @@ function ly.file_musicxml(input_file, options) xmlopts = xmlopts..' '..options[opt] end end - elseif ly.get_option(opt) then xmlopts = xmlopts..' --'..opt + elseif ly_opts[opt] then xmlopts = xmlopts..' --'..opt end end - local i = io.popen(ly.get_option('xml2ly')..' --out=-'..xmlopts..' "'..file..'"', 'r') + local i = io.popen(ly_opts.xml2ly..' --out=-'..xmlopts..' "'..file..'"', 'r') if not i then err([[ -LilyPond could not be started. +%s could not be started. Please check that LuaLaTeX is started with the --shell-escape option. -]] +]], + ly_opts.xml2ly ) end ly.score = Score:new(i:read('*a'), options, file) @@ -1339,7 +1322,7 @@ end function ly.fragment(ly_code, options) - options = ly.set_local_options(options) + options = ly_opts:check_local_options(options) if type(ly_code) == 'string' then ly_code = ly_code:gsub('\\par ', '\n'):gsub('\\([^%s]*) %-([^%s])', '\\%1-%2') else ly_code = table.concat(ly_code, '\n') @@ -1349,43 +1332,10 @@ end function ly.get_font_family(font_id) - return fontinfo(font_id).shared.rawdata.metadata['familyname'] + return lib.fontinfo(font_id).shared.rawdata.metadata['familyname'] end -function ly.get_option(opt) return Score[opt] end - - -function ly.is_alias() end - - -function ly.is_dim(k, v) - if v == '' or v == false or tonumber(v) then return true end - local n, sl, u = v:match('^%d*%.?%d*'), v:match('\\'), v:match('%a+') - -- a value of number - backslash - length is a dimension - -- invalid input will be prevented in by the LaTeX parser already - if n and sl and u then return true end - if n and contains(TEX_UNITS, u) then return true end - err([[ -Unexpected value "%s" for dimension %s: -should be either a number (for example "12"), -a number with unit, without space ("12pt"), -or a (multiplied) TeX length (".8\linewidth") -]], - v, k - ) -end - - -function ly.is_neg(k, _) - local _, i = k:find('^no') - return i and contains_key(OPTIONS, k:sub(i + 1)) -end - - -function ly.is_num(_, v) return v == '' or tonumber(v) end - - function ly.newpage_if_fullpage() if ly.score.insert == 'fullpage' then tex.sprint([[\newpage]]) end end @@ -1408,32 +1358,12 @@ end if ly.score.ttfamily == '' then ly.score.ttfamily = ly.get_font_family(tt) end end -function ly.set_local_options(opts) - local options = {} - local next_opt = opts:gmatch('([^,]+)') -- iterator over options - for opt in next_opt do - local k, v = opt:match('([^=]+)=?(.*)') - if k then - if v and v:sub(1, 1) == '{' then -- handle keys with {multiple, values} - while v:sub(-1) ~= '}' do v = v..','..next_opt() end - v = v:sub(2, -2) -- remove { } - end - k, v = process_options(k:gsub('^%s', ''), v:gsub('^%s', '')) - if k then - if options[k] then err('Option %s is set two times for the same score.', k) - else options[k] = v - end - end - end - end - return options -end - -function ly.set_property(k, v) - k, v = process_options(k, v) - if k then Score[k] = v end +function ly.write_to_file(file, content) + local f = io.open(Score.tmpdir..'/'..file, 'w') + if not f then err('Unable to write to file %s', file) end + f:write(content) + f:close() end - return ly -- cgit v1.2.3