From e0c6872cf40896c7be36b11dcc744620f10adf1d Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Mon, 2 Sep 2019 13:46:59 +0900 Subject: Initial commit --- support/epspdf/epspdf.tlu | 2691 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2691 insertions(+) create mode 100755 support/epspdf/epspdf.tlu (limited to 'support/epspdf/epspdf.tlu') diff --git a/support/epspdf/epspdf.tlu b/support/epspdf/epspdf.tlu new file mode 100755 index 0000000000..0007ade074 --- /dev/null +++ b/support/epspdf/epspdf.tlu @@ -0,0 +1,2691 @@ +#!/usr/bin/env texlua + +kpse.set_program_name('texlua') + +--[[ +epspdf conversion utility + +0.6.0: first texlua version +0.6.1: allow TeX installation on path with spaces +0.6.2: compatibility fix for luatex 0.81 +0.6.3: compatibility fixes for luatex 0.9x +0.6.4: adaptations for newer versions of LuaTeX and ghostscript; + some refactoring; better handling of some corner cases + +Copyright (C) 2006-2019 Siep Kroonenberg + +siepo at bitmuis nl + +This program is free software, licensed under the GNU GPL, >=2.0. +This software comes with absolutely NO WARRANTY. Use at your own risk! + +Note. +TeX code for cropping pdfs adapted from Heiko Oberdiek's pdfcrop utility + +Program structure + +- early initialization +- functions for: + - error handling + - file- and path utilities + - other general utilities + - infrastructure: logging and temporary files + - reading and writing settings + - gui function for communicating with frontend + - boundingboxes + - manipulating [e]ps- and pdf files +- the PsPdf object: + - creator functions + - boundingbox handling + - one-step conversion methods +- any_to_any function. + this function checks options, the one-step converters check for success. +- main initialization section: + - collecting system information + - infrastructure: setting up logging and temp directory + - settings: + - defining settings-, descriptions-, options- and auxiliary tables + - read settings + - defining commandline options and help function + - parsing commandline and performing non-conversion options +- calling any_to_any +- finishing up + +all calls to external programs work on temporary files with a simple +generated filename. The current directory is a newly-created +temporary directory. So no need to quote names of input- and output +filenames. + +POSSIBLE EXTENSIONS + +- duplicating epstopdf options +- custom options for gs and pdftops + +--]] + +-- some general utilities and globals --------------------------- + +-- early initializations + +eol = false +path_sep = false +if os.type=='unix' then + eol='\n' + path_sep = ':' +else + eol='\r\n' + path_sep = ';' +end + +infile = false +outfile = false + +bufsize=16000 -- for reading and writing files + +-- these `declarations' are not really needed; +-- they are here mainly for my own peace of mind + +from_gui = false -- whether epspdf is run from the epspsdtk gui + +cwd = '' + +-- Windows: miktex, TL or neither. +-- Actual values determined during system-dependent initialization +is_miktex = false +is_tl_w32 = false + +-- Luatex 1.09 and later replace epdf with pdfe + +-- some global file- and directory names +gs_prog = false +pdftops = false +epsdir = false +rcfile = false +logfile = false +tempdir = false +tempfiles = {} +-- childpath = false +-- os.getenv('path') returns the parents path, +-- so we need to keep track ourselves of the child path + +options = false -- actual conversion options +settings = false -- persistent settings; may be stored in config file +descriptions = false -- help strings for settings +gs_options = false +pdf_options = false +pdf_tail_options = false +ps_options = false +gray_options = false + +-- logging ------------------------ + +-- we open and close the logfile anew for each write. +-- failure to open does not constitute an error. + +function print_log(s) + local f = io.open(logfile, 'a') + if f then + f:write(s,eol) + f:close() + end + if from_gui then + print(s) -- intercepted by the gui + end +end + +function write_log(s) + print_log(string.format('%s %s', + os.date('%Y/%m/%d %H:%M:%S', os.time()), s)) +end + +function log_cmd(cmd) + write_log('[' .. table.concat(cmd, '] [') .. ']') +end + +-- error- and debug ------------------------- + +-- Simple-minded error handling. At most, we call a function which +-- tries to write the error message to log before re-raising the error. + +-- When run from the Tcl/Tk gui, this gui will capture error messages. + +function errror(mess) + if infile and outfile then + mess = 'Failure to convert '..infile..' to '..outfile..':\n'..mess + end + if logfile then pcall(write_log, mess) end + -- ignore result of pcall: we can do nothing about failure + error(mess, 2) +end + +function warn(mess) + if logfile then write_log(mess) end + print(mess) +end + +-- function dbg(mess) +-- if options.debug then +-- warn(mess) +-- end +-- end + +-- file- and path utilities ---------------- + +-- function ep_shortname(path) +-- if os.type=='unix' then +-- return path +-- else +-- -- shortname appears not to work under miktex +-- -- so return original path as a fallback +-- local sp = lfs.shortname(path) +-- return sp or path +-- end +-- end + +-- prepend or append dir to path if necessary +function maybe_add_path(dir, append) + local dircmp = path_sep .. dir .. path_sep + local pathcmp = path_sep .. kpse.var_value('PATH') .. path_sep + -- case folding + if os.name=='windows' or os.name=='cygwin' or os.name=='macosx' then + dircmp = string.lower(dir) + pathcmp = string.lower(pathcmp) + end + -- slash flipping + if os.type=='windows' then + pathcmp = (string.gsub(pathcmp, '/', '\\')) + dircmp = (string.gsub(dircmp, '/', '\\')) + end + if not string.find(pathcmp, dircmp, 1, true) then + if not append then -- prepend + os.setenv('PATH', dir..path_sep..kpse.var_value('PATH')) + else -- append + os.setenv('PATH', kpse.var_value('PATH')..path_sep..dir) + end + end +end + +function fw(path) + if os.type=='windows' then + return string.gsub(path, '\\', '/') + else + return path + end +end + +function absolute_path(path) + + --[[ Return absolute normalized version of path, interpreted + from the directory from where the program was called. + + We use the fact that lfs.currentdir() always returns an absolute and + normalized path. So we go to the parent directory of path, ask for + the current directory and then combine the current directory with + the base filename. + + On windows, texlua has no trouble cd-ing into a UNC path. + + The function returns nil if there is no valid parent path. + This might be an issue if path is a directory, + but we shall apply this function only on files. + It is ok if path itself does not exist. --]] + + path = fw(path) + + local present_dir = fw(lfs.currentdir()) + + lfs.chdir(cwd) + + local parentdir + local filename + + if string.match(path, '/') then + parentdir, filename = string.match(path,'^(.*)/([^/]*)$') + if parentdir=='' then + parentdir = '/' + -- on unix, this is an absolute path. on windows, it is not + if os.type=='windows' then + lfs.chdir('/') + parentdir = fw(lfs.currentdir()) + end + elseif os.type=='windows' and string.match(parentdir,'^[a-zA-Z]:$') then + parentdir = string.sub(parentdir,1,2)..'/' + else + if not lfs.chdir(parentdir) then + parentdir = nil + else + parentdir = fw(lfs.currentdir()) + end + end + elseif os.type=='windows' and string.match(path,'^[a-zA-Z]:') then + -- windows: d:file + parentdir = string.sub(path,1,2) + if not lfs.chdir(parentdir) then + parentdir = nil + else + parentdir = fw(lfs.currentdir()) + filename = string.sub(path,3) + end + else + parentdir = fw(lfs.currentdir()) + filename = path + end + lfs.chdir(present_dir) + if not parentdir then + return nil + elseif string.sub(parentdir,-1)=='/' then + return parentdir..filename, parentdir + else + return parentdir..'/'..filename, parentdir + end +end -- absolute_path + +-- check whether prog is on the searchpath. +-- we need it only under unix, +-- so we save ourselves the trouble of accommodating windows. +-- we return the original string, although we only need a yes or no answer + +function find_on_path (prog) + if os.type == 'unix' then + for d in string.gmatch(os.getenv('PATH'), '[^:]+') do + if lfs.isfile(d..'/'..prog) then + return prog + end + end + else + for d in string.gmatch(os.getenv('PATH'), '[^;]+') do + if lfs.isfile(d..'\\'..prog) then + return prog + end + end + end + return false +end -- find_on_path + +function dir_writable(d) + -- because directory attributes do not tell the whole story, + -- we actually try to create a file in the directory. + if not lfs.isdir(d) then + return false + end + -- try to create a new file, write to it and delete it afterwards + for i=1,1000 do + local s = d .. '/' .. tostring(i) + if not lfs.isfile(s) then + local fh = io.open(s, "w") + if fh then + fh:write('test') + fh:close() + if lfs.isfile(s) then + if lfs.attributes(s, 'size') > 0 then + os.remove(s) + return true + else + os.remove(s) + return false -- open and write resulted in empty file + end -- lfs.attributes + else + return false -- open and write did not result in a file + end -- lfs.isfile + end -- fh + return false -- filename available; could not open for write + end -- not lfs.isfile + end -- for + return false +end + +function system_tempdir () + local d + if os.type=='windows' then + d = os.getenv('TEMP') + if not d then + d = os.getenv('TMP') + end + else + d = os.getenv('TMPDIR') + if not d then + d = '/tmp' + end + end + -- cygwin: $TEMP=/tmp, root '/' being root of cygwin installation + if d and not dir_writable(d) then + d = false + end + return d +end + +-- other general utilities --------------------------- + +-- check whether el occurs in array lst +function in_list (el, lst) + if not lst then return false end + for _,p in ipairs(lst) do + if el == p then + return true + end + end + return false +end -- in_list + +-- remove leading and trailing, but not embedded spaces +function strip_outer_spaces(s) + s = string.gsub(s, '%s*$', '') + s = string.gsub(s, '^%s*', '') + return s +end -- strip_outer_spaces + +function join(t, sep, lastsep) + -- there is a table function concat which does this, + -- but without optional different lastsep + if t==nil or #t<1 then return '' end -- or should we return nil? + local s = t[1] + for i=2,#t do -- ok if #t<2 + if i==#t and lastsep then + s = s .. lastsep .. t[i] + else + s = s .. sep .. t[i] + end + end + return s +end -- join + +-- combine several tables into one. +-- the parameter is a table of tables. +function tab_combine (t) + local res = {} + for _,tt in ipairs(t) do + for __, ttt in ipairs(tt) do + table.insert(res, ttt) + end + end + return res +end -- tab_combine + +-- files ---------------------------------------------------- +-- Copy a file in chunks, with optional length and offset. +-- Since files may be very large, we copy them piecemeal. +-- An initial chunk of size bufsize should be plenty to include +-- any interesting header information. + +function slice_file(source, dest, len, offset, mode) + -- The final three parameters can be independently left out by + -- specifying false as value + -- Assume caller ensured parameters of correct type. + -- We do not allow negative offsets. + + local copy2self = false + if source==dest then copy2self = true end + if os.type=='unix' and + lfs.attributes(source,'ino')==lfs.attributes(dest,'ino') then + copy2self = true + end + if copy2self and not len and not offset and mode~='ab' then + return -- nothing to do + elseif copy2self then + errror('slice_file invoked with identical source and destination '.. + source..' and non-simple copy') + end + -- in practice any_to_any already checks for this. + -- in addition, the main program makes a backup if infile==outfile + + local sz = lfs.attributes(source, 'size') + if not offset then + offset = 0 + elseif offset>sz then + offset = sz + end + if not len or len>sz-offset then + len = sz - offset + end + if not mode then mode = 'wb' end + local buffer='' + local s=io.open(source, 'rb') + s:seek('set', offset) + local copied = 0 + local d=io.open(dest, mode) + if not d then errror('slice_file: failed to copy to '..dest) end + local slen = len + while slen>0 do + if slen>=bufsize then + buffer = s:read(bufsize) + slen = slen - bufsize + else + buffer = s:read(slen) + slen = 0 + end + if not d:write(buffer) then + errror('slice_file: failed to copy to '..dest) + end + end + s:close() + d:close() +end -- slice_file + +function move_or_copy(source, dest) + if lfs.isfile(dest) and lfs.attributes(dest, 'size')>0 then + warn('Removing old '..dest) + os.remove(dest) -- in case of failure, go ahead anyway + end + -- Windows: try first renaming in-place before moving to the right directory + if os.type == 'windows' then + if os.rename(source, source..'.renamed') then + source = source..'.renamed' + end + end + if not os.rename(source, dest) then + slice_file(source, dest) -- bails out on failure + local ok, err_mess = os.remove(source) + if not ok then + warn('Failed to remove old ' .. source .. ': ' .. err_mess) + end + end +end + +-- temporary files ---------------------------------------- + +-- tempdir = false -- will be created later and chdir-ed into +-- tempfiles initialized early to empty table + +-- We just name our temporary files nn. with successive nn. +-- We cannot exclude that another process uses our tempdir +-- so we have to first check for each new file whether it already exists. +-- Epspdf does all the real work from the temp directory. + +function mktemp(ext) + local froot, fname, f, g + for i=0,99 do + froot = string.format('%02d.', i) + fname = froot..ext + if ext~='tex' then + if not lfs.isfile(fname) then + f = io.open(fname, 'wb') + if not f then + errror('Cannot create temporary file '..fname) + end + f:close() + table.insert(tempfiles, fname) + return froot..ext -- no need to record pdf name + end + else + -- tex; we also need a pdf + if not lfs.isfile(fname) and not lfs.isfile(froot..'pdf') then + local f = io.open(fname, 'wb') + if not f then + errror('Cannot create temporary file '..fname) + end + f:close() + table.insert(tempfiles, fname) + fname = froot..'pdf' + g = io.open(fname, 'wb') + if not g then + errror('Cannot create temporary file '..fname) + end + g:close() + table.insert(tempfiles, fname) + table.insert(tempfiles, froot..'log') + return froot..ext -- no need to record pdf name + end + end -- if + end -- for + errror('Cannot create temporary file in '..tempdir) +end + +function waitasec() + -- stupid windows file locking; assume vista or later + if os.type=='windows' then + os.execute('timeout /t 1 /nobreak >nul') + -- else do nothing + end + -- error checking pointless +end + +function cleantemp() + lfs.chdir(tempdir) + if os.type=='windows' then waitasec() end + for _,f in ipairs(tempfiles) do + if lfs.isfile(f) then + local success, mess = os.remove(f) + if not success then write_log(mess) end + end + end + local empty = true + for f in lfs.dir('.') do + if f ~= '.' and f ~= '..' then + empty = false + write_log('Temp dir '..tempdir..' contains '..f..' therefore not removed') + break + end + end + if os.type=='windows' then waitasec() end + lfs.chdir('..') + if empty then + local res, mess + res, mess = lfs.rmdir(tempdir) + if not res then + write_log('Failed to remove empty '..tempdir..'\n'..mess) + end + end +end + +-- gs_epsdevice ----------------------- + +function gs_epsdevice() + local gh = io.popen(gs_prog..' -help') + local s = gh:read("*a") + gh:close() + if string.find(s,'eps2write') then + return 'eps2write' + elseif string.find(s,'epswrite') then + return 'epswrite' + else + return false + end +end + +-- settings ----------------------- + +function write_settings (file) + local f + if file then + f = io.open(file, 'wb') + if not f then + return + end + else -- stdout to be captured by epspdftk + f = io.output() + end + for k, v in pairs(settings) do + if descriptions[k] and file then + f:write(eol, '# ', descriptions[k], eol) + end + f:write(k, ' = ', tostring(v), eol) + end + if file then + f:close() + end +end + +function read_settings(file) + -- read and interpret rcfile + -- we shall ignore illegal entries. + local contents + local f + if file then + f = io.open(rcfile, 'rb') + if not f then + return + end + else + f = io.input() + end + contents = f:read(10000) + if file then + f:close() + end + if not contents or contents=='' then + return + end + -- remove initial \r and \n characters + contents = string.gsub(contents, '^[\r\n]*', ''); + -- gmatch chops contents into series of non-line-ending characters + -- possibly followed by line-ending characters i.e. in lines + local k, v, vl, vnum + for l in string.gmatch(contents, '[^\r\n]+[\r\n]*') do + l = string.match(l,'[^\r\n]*') + if not string.match(l, '^#') then + k, v = string.match(l, '^%s*([^%s]+)%s*=%s*(.*)$') + if v then v = string.gsub(v,'%s*$', '') end + -- now handle k and v + if k == 'pdf_target' then + -- ignore unless valid option + if in_list(v, pdf_targets) then + settings[k] = v + end + elseif k == 'pdf_version' then + -- ignore unless valid option + if in_list(v, pdf_versions) then + settings[k] = v + end + elseif k == 'ignore_pdftops' then + vl = string.lower(string.sub(v,1,1)) + if v == 0 or vl == 'n' or vl == 'f' then + settings.use_pdftops = true + elseif v == 1 or vl == 'y' or vl == 't' then + settings.use_pdftops = false + end + elseif k == 'use_pdftops' then + vl = string.lower(string.sub(v,1,1)) + if v == '0' or vl == 'n' or vl == 'f' then + settings.use_pdftops = false + elseif v == '1' or vl == 'y' or vl == 't' then + settings.use_pdftops = true + end + -- final three settings not used by epspdf itself but + -- passed along to epspdftk + elseif k == 'ps_viewer' then + settings.ps_viewer = v + elseif k == 'pdf_viewer' then + settings.pdf_viewer = v + elseif k == 'default_dir' then + settings.default_dir = v + end -- test for k + end -- not matching ^# + end -- for +end -- read settings + +function version_added() + for _, v in pairs(pdf_options) do + if string.find(v, '-dCompatibilityLevel') then + return true + end + end + return false +end + +function maybe_add_version_parameter() + if options.type=='pdf' and not options.bbox and settings.pdf_version and + settings.pdf_version~='default' and not version_added() then + table.insert(pdf_options, '-dCompatibilityLevel#'..settings.pdf_version) + end +end + +-- gui: reading and writing settings ----------- + +function gui(action) + -- use stdin for reading settings from gui, and stdout for writing + if action=='config_w' then + -- called at start of epspdftk + write_settings() -- to pipe epspdf => epspdftk + os.exit() + elseif action=='config_r' then + read_settings() -- from 'pipe' epspdftk => epspdf + write_settings(rcfile) + os.exit() + else + from_gui = true + end +end + +-- boundingboxes --------------------------------------------------- + +-- [HR]Bb.coords names same as those of epdf PDFRectangle +-- but the new pdfe simply uses an array + +Bb = {} +Bb.coords = {'x1', 'y1', 'x2', 'y2'} + +function Bb:from_rect(r) + -- also handle the case that r is a 4-element array: + if not r.x1 then r.x1 = r[1] end + if not r.y1 then r.y1 = r[2] end + if not r.x2 then r.x2 = r[3] end + if not r.y2 then r.y2 = r[4] end + + for _,k in ipairs(self.coords) do + if not r[k] or type(r[k])~='number' then + errror('from_rect called with illegal parameters') + end + -- sanity check on size + -- FIXME: this limit is far too high + if r[k]+.5==r[k] or r[k]-.5==r[k] then + errror('Bb:from_rect: ' .. r[k] ..' greater than maxint') + end + local b = {} + local eps = 0.000001 + b.x1, b.x2 = math.floor(math.min(r.x1, r.x2) + eps), + math.ceil(math.max(r.x1, r.x2) - eps) + b.y1, b.y2 = math.floor(math.min(r.y1, r.y2) + eps), + math.ceil(math.max(r.y1, r.y2) - eps) + if b.x1==b.x2 or b.y1==b.y2 then + errror('from_rect: width or height is zero') + end + setmetatable(b, {__index=self}) + return b + end +end + +Bb.bb_pat = '^%s*%%%%BoundingBox:' +Bb.bb_end = '^%s*%%%%BoundingBox:%s*%(%s*atend%s*%)' + +function Bb:from_comment(s) + local p = self.bb_pat..'%s*([-+%d]+)'..string.rep('%s+([-+%d]+)',3) + local b = {} + b.x1, b.y1, b.x2, b.y2 = string.match(s, p) + if not b.y2 then + errror('Bb.from_comment: illegal boundingbox string ' .. s) + end + for _,k in ipairs(self.coords) do + b[k] = tonumber(b[k]) + end + return Bb:from_rect(b) +end + +function Bb:nonnegative () + return self.x1>=0 and self.y1>=0 +end + +function Bb:comment() + -- if options.debug then print(debug.traceback()) end + return string.format('%%%%BoundingBox: %d %d %d %d', + self.x1, self.y1, self.x2, self.y2) +end + +-- hires boundingboxes --------------------------------------------- + +HRBb = {} + +setmetatable(HRBb, {__index=Bb}) + +function HRBb:from_rect(r) + -- also handle the case that r is a 4-element array: + if not r.x1 then r.x1 = r[1] end + if not r.y1 then r.y1 = r[2] end + if not r.x2 then r.x2 = r[3] end + if not r.y2 then r.y2 = r[4] end + + for _,k in ipairs(self.coords) do + if not r[k] or type(r[k])~='number' then + errror('from_rect called with illegal parameters') + end + -- sanity check on size + if r[k]+.5==r[k] or r[k]-.5==r[k] then + errror('HRBb:from_rect: ' .. b[k] ..' greater than maxint') + end + local b = {} + b.x1, b.x2 = math.min(r.x1, r.x2), math.max(r.x1, r.x2) + b.y1, b.y2 = math.min(r.y1, r.y2), math.max(r.y1, r.y2) + if b.x1==b.x2 or b.y1==b.y2 then + errror('from_rect: width or height is zero') + end + setmetatable(b, {__index=self}) + return b + end +end + +HRBb.bb_pat = '^%s*%%%%HiResBoundingBox:' +HRBb.bb_end = '^%s*%%%%HiResBoundingBox:%s*%(%s*atend%s*%)%s*$' + +function HRBb:from_comment(s) + local p = self.bb_pat..'%s*([-+.%deE]+)'..string.rep('%s+([-+.%deE]+)',3) + local b = {} + b.x1, b.y1, b.x2, b.y2 = string.match(s, p) + if not b.y2 then + errror('HRBb.from_comment: illegal boundingbox string ' .. s) + end + for _,k in ipairs(self.coords) do + b[k] = tonumber(b[k]) + end + return HRBb:from_rect(b) +end + +function HRBb:nonnegative () + return self.x1>=0 and self.y1>=0 +end + +function HRBb:comment() + return string.format('%%%%HiResBoundingBox: %f %f %f %f', + self.x1, self.y1, self.x2, self.y2) +end + +--[[ + +-- no longer used: gs handles this +-- call this one also via pcall +function HRBb:wrapper() + + -- local fn = mktemp('ps') + -- local f = io.open(fn, 'wb') + -- f.write(string.format('<< /PageSize [%f %f] >> setpagedevice\n', + -- self.x2 - self.x1, self.y2 - self.y1)) + -- f.write(string.format('gsave\n%f %f translate\n', -self.x1, -self.y1)) + -- f:close() + -- return fn + + return string.format( + '<< /PageSize [%f %f] >> setpagedevice gsave %f %f translate', + self.x2 - self.x1, self.y2 - self.y1, -self.x1, -self.y1) +end + +--]] + +-- manipulating eps/ps/pdf files ----------------------------------- + +function identify() + local f = io.open(infile, 'rb') + if not f then + errror('Failure to open '..infile..' for identification') + end + local filestart= f:read(23) + f:close() + if not filestart or filestart=='' then + return false + elseif string.match(filestart,'^\197\208\211\198') then -- c5 d0 d3 c6 + return 'epsPreview' + elseif string.match(filestart,'^%%!PS%-Adobe%-%d%.%d EPSF%-%d%.%d') then + return 'eps' + elseif string.match(filestart,'^%%!PS%-Adobe%-%d%.%d') then + for _, p in ipairs({'.eps', '.epi', '.epsi', '.epsf'}) do + if string.sub(string.lower(infile), -1-string.len(p),-1) == p then + return 'eps' + else + return 'ps' + end + end + return 'ps' + elseif string.match(filestart, '^%%PDF') then + return 'pdf' + else + return false + end +end -- identify + +function pdf_props(path) + local pdfdoc, pgs, maver, miver + if pdfe then + pdfdoc = pdfe.open(path) + if pdfdoc then + pgs = pdfe.getnofpages(pdfdoc) + maver, miver = pdfe.getversion(pdfdoc) + end + if not (pdfdoc and pgs and maver and miver) then + errror('pdfe failed to get information about '..path) + end + pdfe.close(pdfdoc) + else + local cat + pdfdoc = epdf.open(path) + if pdfdoc then + cat = pdfdoc:getCatalog() + if cat then + pgs = cat:getNumPages() + end + maver = pdfdoc:getPDFMajorVersion() + miver = pdfdoc:getPDFMinorVersion() + end + if not (pdfdoc and pgs and maver and miver) then + errror('epdf failed to get information about '..path) + end + -- epdf.close(pdfdoc) + end + -- if os.type=='windows' then waitasec() end + if maver > 1 then + print(path..' has pdf major version \n'..tostring(maver).. + ' which is unsupported;\n'.. + 'Continuing with fingers crossed...') + end + return pgs, miver, maver +end -- pdf_props + +function info() + local intype = identify() + if not intype then + print(infile..' has an unsupported filetype.') + elseif intype~='pdf' then + print(infile..' has type '..intype..'.') + else + local pgs, miver, maver = pdf_props(infile) + print(infile..' has type pdf, version '..tostring(maver).. + '.'..tostring(miver)..' and has '..tostring(pgs)..' pages.') + end + os.exit() +end + +-- PsPdf object ------------------------------------------------- + +PsPdf = {} + +-- creators + +function PsPdf:new(ext) + local psp = {} + setmetatable(psp, {__index = self}) + -- assign temp file + psp.path = mktemp(string.lower(ext)) + if string.lower(ext)=='pdf' then + psp.type = 'pdf' + elseif string.lower(ext)=='eps' then + psp.type = 'eps' + elseif string.lower(ext)=='ps' then + psp.type = 'ps' + else + psp.type = false + end + if psp.type=='eps' then + psp.pages = 1 + end + psp.bb = false + psp.hrbb = false + return psp +end -- PsPdf:new + +function PsPdf:from_path(path) + local psp = {} + setmetatable(psp, {__index = self}) + psp.path = path + if lfs.isfile(path) then + -- turn existing file into PsPdf object. + psp.type = identify(psp.path) + if psp.type=='pdf' then + psp.pages, psp.miver, psp.maver = pdf_props(psp.path) + end + else + errror('PsPdf:from_path called with non-existant file '..path) + end + if psp.type=='eps' then + psp.pages = 1 + end + psp.bb = false + psp.hrbb = false + -- only calculate when needed + return psp +end -- PsPdf:from_path + +-- do we need to downgrade the pdf to a lower version? +-- consider [e]ps lower than any pdf version +function PsPdf:to_downgrade() + if self.type~='pdf' then + return false + elseif options.type~='pdf' then + return true + elseif settings.pdf_version=='default' then + return false + elseif settings.pdf_version~='default' and + self.maver+0.1*self.miver-0.001 > tonumber(settings.pdf_version) then + -- -0.001: exact binary representation of pdf_version not guaranteed + return true + else + return false + end +end + +--[[ getting boundingbox property from file itself -------------- + +get_bb_simple: use only for eps PsPdf objects we generated +ourselves, so we can assume that the bbox comments are in the header +and the hires bb lies within the lores bb. +Of course the file itself is not rewritten. + +--]] + +function PsPdf:get_bb_simple() + if self.type~='eps' then + errror('get_bb_simple called with non-eps file '..self.path) + end + self.bb = false + self.hrbb = false + local slurp = false + local f = io.open(self.path, 'rb') + if f then + slurp = f:read(bufsize) + f:close() + end + lines = {} + for l in string.gmatch(slurp, '[^\n\r]+') do + if string.match(l, Bb.bb_pat) then + self.bb = Bb:from_comment(l) + elseif string.match(l, HRBb.bb_pat) then + self.hrbb = HRBb:from_comment(l) + elseif self.bb then + break -- stop looking; we expect hrbb next to bb + end + if self.bb and self.hrbb then break end + end + if not self.bb then + errror('No valid boundingbox for generated file' .. self.path) + end + return self -- no real need for a return value +end + +function PsPdf:bb_from_gs() + + if self.type=='ps' then + errror('bb_from_gs called with ps file '..self.path) + -- not needed for generic PostScript, + -- page selection only works with pdf files, so we save ourselves + -- the trouble of picking the right bbox from a list + end + if self.type=='eps' and not self.bb:nonnegative() then + errror('bb_from_gs called on ' .. self.path .. + ' which has some negative boundingbox coordinates') + -- any_to_any should guard against such an invocation + end + + -- Since Ghostscript writes the boundingbox comments to stderr, + -- we need a shell to intercept this output: + + local bb_file = mktemp('dsc') + -- a somewhat low resolution parameter may help gs + -- deal with eps files with large coordinates + -- but this may impact the accuracy of the HRBb + local cmdline + if self.type=='eps' and (self.bb.x2 > 850 or self.bb.y2 > 850) then + cmdline = gs_prog .. ' -r300 ' .. table.concat(gs_options,' ') + else + cmdline = gs_prog .. ' ' .. table.concat(gs_options,' ') + end + if self.type=='pdf' then + pg = options.page or 1 + cmdline = cmdline .. ' -dFirstPage#' .. tostring(pg) .. + ' -dLastPage#' .. tostring(pg) + end + cmdline = cmdline .. ' -sDEVICE#bbox ' .. self.path .. ' 2>'..bb_file + + -- execute shell command + + local r, cmd + write_log('os.execute: '..cmdline) + r = os.execute(cmdline) + if not r then + errror('Cannot get fixed boundingbox for '..self.path) + end + + -- read new bbox from ghostscript output + -- can we really count on the plain bb coming first? + -- OTOH, I would rather not introduce unnecessary complexity + -- still, it may be better to match each line with [HR]Bb_pat + + local bb = false + local hrbb = false + local fin = io.open(bb_file, 'r') + if fin then + for i=1,10 do -- actually, 2 should suffice + local l = fin:read("*line") + if not l then break end + if string.match(l, Bb.bb_pat) then + bb = Bb:from_comment(l) + end + if string.match(l, HRBb.bb_pat) then + hrbb = HRBb:from_comment(l) + end + end + fin:close() + end + if not bb or not hrbb then + errror('Cannot get fixed boundingbox for '..self.path) + end + return bb, hrbb +end + +-- eps_clean: remove some problem features from eps (new file & object) + +function PsPdf:eps_clean() + + -- return a PsPdf object referring to a new file + -- without a preview header and with boundingbox(es) in the header. + -- return a new file even if no changes were needed. + + local function bytes2num (s, i) + -- convert substring s[i..i+3] to a number. + -- by working byte for byte we avoid endian issues + local n = string.byte(s, i+3) + for j=2,0,-1 do n = 256*n + string.byte(s, i+j) end + return n + -- somehow the explicit expression below didn't work + -- return ((256 * (256 * (256 * string.byte(s,i+3)) + string.byte(s,i+2)) + -- + string.byte(s,i+1)) + string.byte(s,i)) + end + + if self.type~='eps' and self.type~='epsPreview' then + errror('epsclean called with non-eps file ' .. self.path) + end + local offset, ps_length = false, false + local fin, fout + if self.type=='eps' then + offset = 0 + ps_length = lfs.attributes(self.path, 'size') + else + -- read TOC; see Adobe EPS specification + -- interpret byte for byte, in case the platform is not little-endian + fin = io.open(self.path, 'rb') + if fin then + local toc = fin:read(12) + fin:close() + if toc and string.len(toc)==12 then + offset = bytes2num(toc, 5) + ps_length = bytes2num(toc, 9) + end + end + if not offset then + errror('Could not read preview header of ' .. self.path) + end + end + + -- create the PsPdf object which is to be returned + + local psp + psp = PsPdf:new('eps') + + -- read an initial and if necessary a final chunk of the file + -- to find boundingbox comments. + + local atend = false + local hr_atend = false + local slurp -- the read buffer + local l -- contains current scanned line; split off from slurp + -- pre_lines: scanned header lines; alternately lines and eols + local pre_lines = {} + -- new_offset: offset plus combined length of scanned header lines + local new_offset = offset + -- post_lines: scanned trailer lines + local post_lines = {} + -- middle_length: ps_length minus scanned header- and and maybe trailer parts + -- this is the length of file that will be copied wholesale. + local middle_length + local i, i_bb, i_hrbb + local j, j_bb, j_hrbb, j_end + -- j_end: index of final scanned trailer line + -- no i_end necessary: for header lines we can use #pre_lines. + + fin = io.open(self.path, 'rb') + if not fin then errror('Cannot read '..self.path) end + fin:seek('set', offset) + + -- remaining, unscanned length of input buffer slurp + local unscanned = math.min(ps_length,bufsize) + slurp = fin:read(unscanned) + + -- unnecessary: + psp.bb = nil + psp.hrbb = nil + + i, i_bb, i_hrbb = 0, false, false + while unscanned>0 do + i = i+1 + if string.find(slurp,'[\n\r]')==1 then + l,slurp = string.match(slurp, '^([\n\r]+)(.*)$') + else + l,slurp = string.match(slurp, '^([^\n\r]+)(.*)$') + if string.match(l, Bb.bb_end) then + atend = true + i_bb = i + elseif string.match(l, Bb.bb_pat) then + psp.bb = Bb:from_comment(l) + -- from_comment errors out on failure; no need to check return value + i_bb = i + elseif string.match(l, HRBb.bb_end) then + hr_atend = true + i_hrbb = i + elseif string.match(l, HRBb.bb_pat) then + psp.hrbb = HRBb:from_comment(l) + i_hrbb = i + end -- bbox line + end -- eol/non-eol + pre_lines[i] = l + unscanned = unscanned - string.len(l) + if (i_bb and (i_hrbb or (i_bb<(i-1)))) or unscanned<=0 then + -- condition i_bbbufsize then + fin:seek('set',offset+ps_length-bufsize) + unscanned = bufsize + slurp = fin:read(unscanned) + else + -- use what is left from old slurp + unscanned = string.len(slurp) + end + j = 1 -- count down from 0 + j_bb, j_hrbb, j_end = false, false, false + while unscanned>0 do + j = j - 1 + if string.find(slurp,'[\n\r]', string.len(slurp)) then + slurp,l = string.match(slurp, '^(.-)([\n\r]+)$') + -- '-': non-greedy matching + else + slurp,l = string.match(slurp, '^(.-)([^\n\r]+)$') + if string.match(l, Bb.bb_pat) then + psp.bb = Bb:from_comment(l) + j_bb = j + elseif string.match(l, HRBb.bb_pat) then + psp.hrbb = HRBb:from_comment(l) + j_hrbb = j + end -- bbox line + end -- eol/non-eol + post_lines[j] = l + unscanned = unscanned - string.len(l) + if (psp.bb and + (psp.hrbb or not hr_atend or j_bb>(j+1))) or unscanned<=0 then + -- stop looking + j_end = j + break + end -- deciding whether to stop + end -- while + middle_length = middle_length - + string.len(table.concat(post_lines, '', j_end, 0)) + end --if atend + fin:close() + -- fix boundingbox lines + if atend and j_bb then + -- pre_lines[i_bb] = post_lines[j_bb] + pre_lines[i_bb] = psp.bb:comment() -- WHY DOESNT THIS WORK ???? + post_lines[j_bb] = '' + post_lines[j_bb+1] = '' + end + if hr_atend and j_hrbb then + -- pre_lines[i_hrbb] = post_lines[j_hrbb] + pre_lines[i_hrbb] = psp.hrbb:comment() + post_lines[j_hrbb] = '' + post_lines[j_hrbb+1] = '' + end + -- create cleaned eps file + fout = io.open(psp.path, 'wb') + if not fout then errror('Cannot create new file '..psp.path) end + fout:write(table.concat(pre_lines)) + fout:close() + slice_file(self.path, psp.path, middle_length, new_offset, 'ab') + fout = io.open(psp.path, 'ab') + fout:write(table.concat(post_lines, '', j_end, 0)) + fout:close() + return psp +end -- eps_clean + +-- tight boundingbox (new file & object) + +function PsPdf:eps_crop() + + -- conversion is not done by an external program, although + -- we invoke Ghostscript with a bbox device for a tight boundingbox. + -- We use both the regular and the hires boundingbox from gs. + -- The eps should already have been cleaned up by eps_clean, + -- and the current boundingbox should not contain negative coordinates, + -- otherwise the bbox output device may give incorrect results. + -- Only the boundingbox in the eps is rewritten. + + -- create the PsPdf object which is to be returned + + local psp = PsPdf:new('eps') + + -- read new bbox from ghostscript output + + psp.bb, psp.hrbb = self:bb_from_gs() + + -- rewrite header with new boundingboxes + + local slurp -- the read buffer + local l -- contains current scanned line; split off from slurp + -- pre_lines: scanned header lines; alternately lines and eols + local pre_lines = {} + -- offset: combined length of scanned header lines + local offset = 0 + local ps_length = lfs.attributes(self.path, 'size') + local i, i_bb, i_hrbb + + fin = io.open(self.path, 'rb') + if not fin then errror('Cannot read '..self.path) end + + -- remaining, unscanned length of input buffer slurp + local unscanned = math.min(ps_length,bufsize) + slurp = fin:read(unscanned) + i, i_bb, i_hrbb = 0, false, false + while unscanned>0 do + i = i+1 + if string.find(slurp,'[\n\r]')==1 then + l,slurp = string.match(slurp, '^([\n\r]+)(.*)$') + else + l,slurp = string.match(slurp, '^([^\n\r]+)(.*)$') + if string.match(l, Bb.bb_pat) then + i_bb = i + elseif string.match(l, HRBb.bb_pat) then + i_hrbb = i + end -- bbox line + end -- eol/non-eol + pre_lines[i] = l + unscanned = unscanned - string.len(l) + if (i_bb and (i_hrbb or (i_bb<(i-1)))) or unscanned<=0 then + break + end + end -- while + fin:close() + offset = string.len(table.concat(pre_lines)) + + if i_hrbb then + pre_lines[i_bb] = psp.bb:comment() + pre_lines[i_hrbb] = psp.hrbb:comment() + else + -- jam both bbox comments into one slot, with an intervening eol. + -- for the sake of conformity, we copy an existing eol. + pre_lines[i_bb] = psp.bb:comment() .. pre_lines[i_bb-1] .. + psp.hrbb:comment() + end + + -- write a new eps file + + fout = io.open(psp.path, 'wb') + if not fout then errror('Cannot write new file '.. psp.path) end + fout:write(table.concat(pre_lines)) + fout:close() + slice_file(self.path, psp.path, + lfs.attributes(self.path,'size') - offset, offset, 'ab') + options.bbox = false + return psp +end -- eps_crop + +--[[ + +most of these conversions involve a single invocation of gs, pdftops or texlua + +Each conversion fullfills all options that it can: gray, bbox and +page. gray when converting to pdf, bbox when converting from eps or +from pdf to pdf and page when converting from pdf. It then sets the +fullfilled option(s) to false. + +We make sure to do the tight boundingbox before a file format downgrade: +rasterization of the page or graphic frustrates boundingbox calculation. + +We like to preserve fonts as fonts. gs does this when generating +pdf, but may fail for fonts such as cid and large truetype when +generating PostScript. In such cases, pdftops may succeed. However, +it seems that if the page contains an element that does not cleanly +convert, pdftops simply rasterizes the entire page, and that this +choice is made per page. + +--]] + +-- Converting from pdf to pdf using luatex; no grayscaling here + +function PsPdf:getpgbox() + if options.page and options.page > self.pages then + errror('PsPdf:getpgbox called with non-existent page '.. options.page) + end + local pg = options.page or 1 + local bb, hrbb, pgbox, pdfdoc, ppage + if pdfe then + pdfdoc = pdfe.open(self.path) + if pdfdoc then + ppage = pdfe.getpage(pdfdoc, pg) + if not ppage then errror('did not get page') end + if ppage then + pgbox = pdfe.getbox(ppage, 'MediaBox') + if not pgbox then + pgbox = pdfe.getbox(ppage, 'CropBox') + if not pgbox then + pgbox = pdfe.getbox(ppage, 'TrimBox') + if not pgbox then + errror('No box acquired') + end + end + end + end + end + pdfe.close(pdfdoc) + else + local cat + pdfdoc = epdf.open(self.path) + if pdfdoc then + cat = pdfdoc:getCatalog() + if cat then + ppage = cat:getPage(pg) + if ppage then + pgbox = ppage:getMediaBox() + if not pgbox then + pgbox = ppage:getCropBox() + if not pgbox then + pgbox = ppage:getTrimBox() + end + end + end + end + end + end + -- normalization and further checks, including for non-nil, + -- by HRBb:from_rect, which errors out on failures + if pgbox then + pgbox = HRBb:from_rect(pgbox) + -- this also converts numeric array indices to x1 ... y2 if necessary + else + errror('Cannot get page box from '..self.path..' page '..pg) + end + return pgbox +end -- PsPdf:getpgbox + +function PsPdf:pdf_crop() + + -- options to be fulfilled: page, boundingbox if possible + -- embeds the pdf with boundingbox parameters into a new (lua)tex document + if options.page and options.page > self.pages then + errror('PsPdf:pdf_crop called with non-existent page '.. options.page) + end + local bb, hrbb, pgbox + pgbox = self:getpgbox() + if options.bbox and pgbox:nonnegative() then + bb, hrbb = self:bb_from_gs() + options.bbox = false + else + hrbb = pgbox + end + + -- luatex is on searchpath + local luatex_prog = 'luatex' + + -- write TeX file which includes cropped pdf page + -- adapted from Heiko Oberdiek's pdfcrop utility. + -- the table `pieces' will contain the component strings for the tex source + -- first, for texlua <= 0.81 + local pieces = {} + if status.luatex_version <= 80 then + pieces[1] = '\\pdfoutput=1\n' + else + pieces[1] = [[ +\edef\pdfminorversion {\pdfvariable minorversion} +\edef\pdfcompresslevel {\pdfvariable compresslevel} +\edef\pdfobjcompresslevel {\pdfvariable objcompresslevel} +\edef\pdfdecimaldigits {\pdfvariable decimaldigits} +\edef\pdfhorigin {\pdfvariable horigin} +\edef\pdfvorigin {\pdfvariable vorigin} +\let\pdfpagewidth\pagewidth +\let\pdfpageheight\pageheight +\let\pdfximage\saveimageresource +\let\pdfrefximage\useimageresource +\let\pdflastximage\lastsavedimageresourceindex + +\outputmode=1 +]] + end + if self.maver > 1 then + pieces[2] = '\\pdfminorversion=9\n' + else + pieces[2] = '\\pdfminorversion=' .. self.miver .. '\n' + end + if self.maver > 1 or self.miver > 4 then + pieces[3] = [[ +\pdfcompresslevel=9 +\pdfobjcompresslevel=2 +\pdfdecimaldigits=4 +]] + else + pieces[3] = [[ +\pdfcompresslevel=9 +\pdfobjcompresslevel=0 +\pdfdecimaldigits=4 +]] + end + pieces[4] = [[ +\def\page #1 [#2 #3 #4 #5]{% + \count0=#1\relax + \setbox0=\hbox{% + \pdfximage page #1 mediabox{]] + pieces[5] = self.path + pieces[6] = [[}% + \pdfrefximage\pdflastximage + }% + \pdfhorigin=#2bp\relax + \pdfvorigin=#3bp\relax + \pagewidth=#4bp\relax + \pageheight=#5bp\relax + \ht0=\pageheight + \shipout\box0\relax +} +]] + pieces[7] = string.format([[ +\page %d [%f %f %f %f] +\csname @@end\endcsname +\end +]], + options.page or 1, -hrbb.x1, hrbb.y1, hrbb.x2-hrbb.x1, hrbb.y2-hrbb.y1) + + local textemp = mktemp('tex') -- this also took care of pdf: + local pdftemp = string.gsub(textemp, 'tex$', 'pdf') + -- if os.type=='windows' then waitasec() end + local f = io.open(textemp, 'w') + -- if os.type=='windows' then waitasec() end + f:write(table.concat(pieces, '')) + f:close() + local cmd, res, psp + -- if os.type=='unix' then + cmd = {luatex_prog, '--safer', '--no-shell-escape', textemp} + log_cmd(cmd) + res = os.spawn(cmd) + -- else + -- cmd = luatex_prog..' --safer --no-shell-escape '..textemp + -- log_cmd({cmd}) + -- -- os.execute('timeout /t 1 /nobreak >nul') + -- res = os.execute(cmd) + -- end + if res and res==0 and lfs.attributes(pdftemp, 'size')>0 then + psp = PsPdf:from_path(pdftemp) + options.bbox = false + options.page = false + return psp + else + errror('pdf_crop failed on '..self.path) + end +end + +function PsPdf:eps_to_pdf() + + -- option to be fulfilled: gray + -- set target and maybe pdf version if applicable + if self.type~='eps' then + errror('PsPdf:eps_to_pdf called for non-eps file '.. self.path) + end + local cmd + maybe_add_version_parameter() + cmd = tab_combine({{gs_prog}, gs_options, pdf_options}) + if options.gray then + cmd = tab_combine({cmd, gray_options}) + end + table.insert(cmd, '-dEPSCrop') -- uses existing hires bb + local psp = PsPdf:new('pdf') + table.insert(cmd, '-sOutputFile#'..psp.path) + cmd = tab_combine({cmd, pdf_tail_options}) + table.insert(cmd, self.path) + log_cmd(cmd) + local res = os.spawn(cmd) + if res and res==0 and lfs.attributes(psp.path, 'size')>0 then + psp.pages, psp.miver, psp.maver = pdf_props(psp.path) + options.gray = false + return psp + else + errror('eps_to_pdf failed on '..self.path) + end +end -- eps_to_pdf + +-- Converting from pdf to pdf with grayscaling and/or page selection +-- or just eliminating negative boundingbox + +function PsPdf:pdf_to_pdf() + + -- option to be fulfilled: gray, optionally page + -- side effect: makes bbox non-negative. + -- do not call this just for page selection because + -- pdf_crop can do this in a less invasive manner + if self.type~='pdf' then + errror('PsPdf:pdf_to_pdf called for non-pdf file '.. self.path) + end + local cmd + if options.page and options.page > self.pages then + errror('PsPdf:pdf_to_pdf called with non-existent page '.. options.page) + end + cmd = tab_combine({{gs_prog}, gs_options, pdf_options}) + if options.gray then + cmd = tab_combine({cmd, gray_options}) + end + if options.page then + table.insert(cmd, '-dFirstPage#'..tostring(options.page)) + table.insert(cmd, '-dLastPage#'..tostring(options.page)) + end + maybe_add_version_parameter() + local psp = PsPdf:new('pdf') + table.insert(cmd, '-sOutputFile#'..psp.path) + cmd = tab_combine({cmd, pdf_tail_options}) + table.insert(cmd, self.path) + log_cmd(cmd) + local res = os.spawn(cmd) + if res and res==0 and lfs.attributes(psp.path, 'size')>0 then + psp.pages, psp.miver, psp.maver = pdf_props(psp.path) + options.gray = false + options.page = false + return psp + else + errror('pdf_to_pdf failed on '..self.path) + end +end -- pdf_to_pdf + +function PsPdf:pdf_to_eps() + + -- options to be fulfilled: page + local psp = PsPdf:new('eps') + local cmd, res + -- any_to_any already checked the validity and relevance of options.page + if pdftops then + if options.page then + cmd = tab_combine({{pdftops}, ps_options, + {'-f', options.page, '-l', options.page, + '-eps', self.path, psp.path}}) + else + cmd = tab_combine({{pdftops}, ps_options, + {'-eps', self.path, psp.path}}) + end + log_cmd(cmd) + if os.type=='windows' then + -- suppress console output of 'No display font for...' messages, + -- which are usually harmless and for which I know no easy fix + -- pdftops -q does not do the trick on Windows, + -- and redirection to logfile gives access denied under miktex + -- res = os.spawn({'cmd', '/c', table.concat(cmd, ' ')..' 2>>'..log_bsl}) + res = os.execute(table.concat(cmd, ' ')..' 2>nul') + else + res = os.spawn(cmd) + end + if res and res==0 and lfs.attributes(psp.path, 'size')>0 then + psp.pages = 1 + options.page = false + else + errror('pdf_to_eps failed on '..self.path) + end + -- fix for incorrect DSC header produced by some versions of pdftops: + -- if necessary, change line `% Produced by ...' into `%%Produced by ...' + -- this is usually the second line. + -- otherwise the DSC header would be terminated before the bbox comment. + -- this problem exists with pdftops from TL2011/w32. + local slurp -- input buffer + local fin = io.open(psp.path, 'rb') + if not fin then errror('Cannot read '..psp.path) end + + -- remaining, unscanned length of input buffer slurp + local unscanned = math.min(lfs.attributes(psp.path, 'size'),bufsize) + slurp = fin:read(unscanned) + local i, i_bb = 0, false + local needs_fixing = false + local pre_lines = {} + local offset = 0 + while unscanned>0 do + i = i+1 + if string.find(slurp,'[\n\r]')==1 then + l,slurp = string.match(slurp, '^([\n\r]+)(.*)$') + else + l,slurp = string.match(slurp, '^([^\n\r]+)(.*)$') + if string.match(l, Bb.bb_pat) then + -- bbox line + i_bb = i + elseif string.match(l, '^%%%s') then -- `%' is escape char: doubled + -- %X with X printable would be ok + needs_fixing = true + -- fix rightaway + l = string.gsub(l, '^%%%s', '%%%%') -- same length + end + end -- eol/non-eol + pre_lines[i] = l + unscanned = unscanned - string.len(l) + offset = offset + string.len(l) + if i_bb then break end + end -- while + fin:close() + if needs_fixing then + -- write a new eps file + local newfile = mktemp('eps') + fout = io.open(newfile, 'wb') + if not fout then errror('Cannot write new file '.. newfile) end + fout:write(table.concat(pre_lines)) + fout:close() + slice_file(psp.path, newfile, + lfs.attributes(psp.path,'size') - offset, offset, 'ab') + psp.path = newfile + end -- needs_fixing + else -- use ghostscript + local epsdev = gs_epsdevice() + if not epsdev then + errror('Conversion to eps not supported by this ghostscript') + end + cmd = tab_combine({{gs_prog}, gs_options, + {'-sDEVICE#'..epsdev, '-dHaveTrueTypes=true', '-dLanguageLevel#3'}}) + if options.gray then + cmd = tab_combine({cmd, gray_options}) + end + if options.page then + table.insert(cmd, '-dFirstPage='..options.page) + table.insert(cmd, '-dLastPage='..options.page) + end + table.insert(cmd, '-sOutputFile='..psp.path) + table.insert(cmd, self.path) + log_cmd(cmd) + res = os.spawn(cmd) + if res and res==0 and lfs.attributes(psp.path, 'size')>0 then + psp.pages = 1 + options.page = false + options.gray = false + else + errror('pdf_to_eps failed on '..self.path) + end + end -- use ghostscript + psp:get_bb_simple() + return psp + +end -- pdf_to_eps + +function PsPdf:ps_to_pdf() + + -- options to be fulfilled: gray + if self.type~='ps' then + errror('PsPdf:ps_to_pdf called for non-ps file '.. self.path) + end + local cmd + cmd = tab_combine({{gs_prog}, gs_options, pdf_options}) + if options.gray then + cmd = tab_combine({cmd, gray_options}) + end + local psp = PsPdf:new('pdf') + table.insert(cmd, '-sOutputFile#'..psp.path) + cmd = tab_combine({cmd, pdf_tail_options}) + table.insert(cmd, self.path) + log_cmd(cmd) + local res = os.spawn(cmd) + if res and res==0 and lfs.attributes(psp.path, 'size')>0 then + psp.pages, psp.miver, psp.maver = pdf_props(psp.path) + options.gray = false + return psp + else + errror('ps_to_pdf failed on '..self.path) + end + +end -- PsPdf:ps_to_pdf + +function PsPdf:pdf_to_ps() + + -- options to be fulfilled: page and, if not using pdftops, also gray + local psp = PsPdf:new('ps') + -- options.page checked by any_to_any + psp.pages = self.pages + local cmd, res + if pdftops then + cmd = tab_combine({{pdftops}, ps_options}) + if options.page then + cmd = tab_combine({cmd, {'-f', options.page, '-l', options.page}}) + end + cmd = tab_combine({cmd, {'-paper', 'match', self.path, psp.path}}) + else -- use ghostscript + cmd = tab_combine({{gs_prog}, gs_options, + {'-sDEVICE#ps2write', '-dHaveTrueTypes=true', '-dLanguageLevel#3'}}) + if options.gray then + cmd = tab_combine({cmd, gray_options}) + end + if options.page then + cmd = tab_combine({cmd, + {'-dFirstPage#'..options.page, '-dLastPage#'..options.page}}) + end + table.insert(cmd, '-sOutputFile#'..psp.path) + table.insert(cmd, self.path) + end + log_cmd(cmd) + if os.type=='windows' and pdftops then + -- suppress console output of 'No display font for...' messages, + -- which are usually harmless and for which I know no easy fix + -- pdftops -q does not do the trick on Windows, + -- and redirection to logfile gives access denied under miktex + -- res = os.spawn({'cmd', '/c', table.concat(cmd, ' ')..' 2>>'..log_bsl}) + res = os.execute(table.concat(cmd, ' ')..' 2>nul') + else + res = os.spawn(cmd) + end + if res and res==0 and lfs.attributes(psp.path, 'size')>0 then + options.page = false + -- gs will have grayscaled if requested but pdftops will not + if not pdftops then options.gray = false end + return psp + else + errror('pdf_to_ps failed on '..self.path) + end +end -- PsPdf:pdf_to_ps + +function PsPdf:ps_to_ps() + -- we do not accept a page option since we do not know + -- which pages are available. + -- if no gray option then there is no point in invoking this function. + local psp = PsPdf:new('ps') + local cmd = tab_combine({{gs_prog}, gs_options, + {'-sDEVICE#ps2write', '-dHaveTrueTypes=true', '-dLanguageLevel#3'}}) + if options.gray then + cmd = tab_combine({cmd, gray_options}) + end + table.insert(cmd, '-sOutputFile#'..psp.path) + table.insert(cmd, self.path) + log_cmd(cmd) + local res = os.spawn(cmd) + if res and res==0 and lfs.attributes(psp.path, 'size')>0 then + options.gray = false + return psp + else + errror('ps_to_ps failed on '..self.path) + end +end -- PsPdf:ps_to_ps + +function any_to_any() + + local psp = PsPdf:from_path(infile) + + -- sanitize some options + + if options.type=='ps' then + options.bbox = false + end + + if (options.bbox or options.type=='eps') and not options.page then + options.page = 1 + end + + if psp.type=='pdf' then + if options.page then + local pgs = pdf_props(infile) + if pgs> setdistillerparams', '-f'} + end + end + + --[[ Actual conversions + each single-step conversion takes care of options it can handle + and sets those options to false. + Cropping a pdf is best be done before converting to postscript or + a low (<1.4) pdf version. + + all invocations of external programs work on temporary files + in the then-current temporary directory, with a simple generated + filename. So no need to quote names of input- or output filenames. + --]] + + local newfile + + if psp.type=='eps' or psp.type=='epsPreview' then + -- As a side effect of eps_clean, the modified or unmodified source file + -- is copied to our temp subdirectory. + -- We always create a new file. + psp = psp:eps_clean() + if options.bbox and psp.bb:nonnegative() then + psp = psp:eps_crop() + end + if options.type=='eps' then + if options.gray or options.bbox then + -- bbox: eps_crop was apparently not applicable: pdf roundtrip + psp = psp:eps_to_pdf() + if options.bbox then + psp = psp:pdf_crop() + end + psp = psp:pdf_to_eps() + end + elseif options.type=='pdf' then + psp = psp:eps_to_pdf() + if options.bbox then + psp = psp:pdf_crop() + end + if psp:to_downgrade() then + maybe_add_version_parameter() + psp = psp:pdf_to_pdf() + end + elseif options.type=='ps' then + -- often, the eps file is fine as a postscript file. + -- however, it may lack a showpage operator, or have a weird + -- boundingbox. converting back-and-forth to pdf solves both problems. + -- eps_to_pdf will take care of grayscaling + -- a tight boundingbox option is not supported for ps output. + psp = psp:eps_to_pdf():pdf_to_ps() + end + slice_file(psp.path, outfile) + return true + + elseif psp.type=='ps' then + + -- preliminary: + -- copy infile to a file in the temp directory, needed for gs -dSAFER + newfile = mktemp(psp.type) + slice_file(psp.path, newfile) + psp.path = newfile + + if options.type=='ps' and not options.page then + psp = psp:ps_to_ps() + slice_file(psp.path, outfile) + return true + end + + -- remaining options require initial conversion to pdf + psp = psp:ps_to_pdf() + -- AFAIK, all high-level ps constructs are covered by any pdf version + -- so this option should not cause unnecessary loss of structure + maybe_add_version_parameter() + + if options.page and options.page>1 then + -- could not check page option before + local pgs = pdf_props(psp.path) + if pgs ps + slice_file(psp.path, outfile) + return true + + elseif psp.type=='pdf' then + + if options.type=='pdf' and settings.pdf_target=='default' and + not options.gray and not options.bbox and not options.page and + not psp:to_downgrade() then + if infile~=outfile then + slice_file(infile, outfile) + end + return true + end + + -- preliminary: + -- copy infile to a file in the temp directory, needed for gs -dSAFER + newfile = mktemp(psp.type) + slice_file(psp.path, newfile) + psp = PsPdf:from_path(newfile) + local pgbox + + -- actual conversion + if options.type=='eps' then + if options.bbox then + pgbox = psp:getpgbox() -- page n. available from options table + if not pgbox:nonnegative() or options.gray then + -- fix in extra pdf-to-pdf step + psp = psp:pdf_to_pdf() + end + -- we want to calculate a tight boundingbox before conversion to eps, + -- because this conversion may cause rasterization and baffle + -- gs' boundingbox calculations + psp = psp:pdf_crop() + elseif options.gray and pdftops then + psp = psp:pdf_to_pdf() + end + psp = psp:pdf_to_eps() + elseif options.type=='pdf' then + -- pdf_crop can take care of bbox and page, + -- but not of gray and not of some pdf options + if options.bbox then + pgbox = psp:getpgbox() + if not pgbox:nonnegative() then + -- pdf_to_pdf fixes negative bbox parameters + -- and also takes care of page and gray + psp = psp:pdf_to_pdf() + end + psp = psp:pdf_crop() + end + need_gs = false + if psp:to_downgrade() then + need_gs = true + maybe_add_version_parameter() + end + if settings.pdf_target~='default' then + need_gs = true + end + if options.gray then + need_gs = true + end + if (not need_gs) and options.page then + psp = psp:pdf_crop() + -- less invasive that page selection by gs + end + if need_gs then + psp = psp:pdf_to_pdf() -- will handle page selection too + end + elseif options.type=='ps' then + if options.gray and pdftops then + psp = psp:pdf_to_pdf():pdf_to_ps() + else + psp = psp:pdf_to_ps() + end + end -- pdf => ps + slice_file(psp.path, outfile) + return true + end +end -- any_to_any + +-- system-dependent initialization ----------------------------------- + +-- current directory, at program start +cwd = lfs.currentdir() +if os.type == 'windows' then cwd = string.gsub(cwd, '\\', '/') end + +-- child searchpath initially set to parent searchpath +-- childpath = os.getenv('PATH') + +-- prepend (lua)tex directory to searchpath, if not already there +maybe_add_path(os.selfdir, false) + +-- Windows: miktex, TL or neither. is_miktex, is_tl_w32 +-- no support yet for separate ghostscript +is_miktex = false +is_tl_w32 = false + +if os.type == 'windows' then + if string.find (string.lower(kpse.version()), 'miktex') then + is_miktex = true + else + local rt = string.gsub(os.selfdir,'[\\/][^\\/]+[\\/][^\\/]+$', '') + if not rt then + errror('Unrecognized TeX directory structure', 0) + elseif lfs.isfile(rt..'/release-texlive.txt') then + --[[ + -- TL version is easy to determine but is not needed + local fin = io:open(rt..'release-texlive.txt', 'r') + if fin then + local l = fin:read('*line') + tl_ver = string.match(l, 'version%s+(%d+)$') + if tl_ver then tl_ver = tonumber(tl_ver) end + end -- if fin + --]] + is_tl_w32 = true + else + errror('Not MikTeX and no file ' .. rt .. + '/release-texlive.txt; TeX installation not supported.', 0) + end -- if isfile + end -- if not miktex +end -- if windows + +-- without Ghostscript we are dead in the water. +gs_prog = false +do + local rt='' + if os.type == 'unix' then + if find_on_path('gs') then + gs_prog = 'gs' + else + error('No ghostscript on searchpath!', 0) + end + elseif is_miktex then + gs_prog = 'mgs.exe' + -- neither MiKTeX's nor TL's ghostscript need GS_LIB to be set + elseif is_tl_w32 then + -- windows/TeX Live + -- grandparent of texlua.exe directory .. ... + -- rt = string.gsub(os.selfdir,'[\\/][^\\/]+[\\/][^\\/]+$', '') + -- ..'/tlpkg/tlgs' + -- maybe_add_path(rt..'/bin', false) + -- -- gs_prog = 'gswin32c.exe' + -- ---[[ problems with (at least) grayscaling + gs_prog = 'rungs.exe' + --]] + else + errror('Only TeX Live and MikTeX supported!', 0) + end +end -- do + +-- directory for configuration and log +epsdir = '' +if os.type == 'windows' then + epsdir = fw(os.getenv('APPDATA')) .. '/epspdf' +else + epsdir = os.getenv('HOME')..'/.epspdf' +end +rcfile = epsdir .. '/config' +logfile = epsdir .. '/epspdf.log' + +-- create epsdir if necessary +if lfs.isfile(epsdir) then + error('Cannot continue; epspdf directory ' .. epsdir .. ' is a file') +elseif not lfs.isdir(epsdir) then + if not lfs.mkdir(epsdir) then + error('Failed to create epspdf directory ' .. epsdir) + end +end + +-- start logging --------------------------------- + +-- log rotate if logfile too big +if lfs.attributes(logfile) and lfs.attributes(logfile).size > 100000 then + local oldlog = logfile .. '.old' + if lfs.attributes(oldlog) then + if os.remove(oldlog) then os.rename(logfile,oldlog) end + elseif lfs.attributes(logfile) then do + -- separate epsdir runs with empty lines + print_log('\n\nNew run') + end end -- do elseif +end -- if lfs...logfile + +write_log('epspdf '..table.concat(arg, ' ')) + +--[[ + +settings, initial values + +priority, from low to high: + +- built-in defaults +- settings read from and written to the configuration file +- command-line options, defined in the opts table + +The options- and settings tables are initialized from built-in +defaults. We go for false rather than undefined, because this +results in an actual settings- or options entry. + +Command-line options are copied to either options or settings. +We ignore illegal settings in the config file. + +--]] + +pdf_targets = {'screen', 'ebook', 'printer', 'prepress', 'default'} +pdf_versions = {'1.2', '1.3', '1.4', '1.5', '1.6', '1.7', 'default'} +-- ghostscript will substitute higher versions with +-- the highest-supported version + +settings = {} +descriptions = {} + +settings.pdf_target = 'default' +descriptions.pdf_target = 'One of ' .. join(pdf_targets, ', ', ' or ') + +settings.pdf_version = 'default' +descriptions.pdf_version = 'One of ' .. join(pdf_versions, ', ', ' or ') + +settings.use_pdftops = true +descriptions.use_pdftops = 'Use pdftops if available' + +-- epspdf stores ps- and pdf viewer settings on behalf of the gui interface +-- but does not use them itself. +-- They will not be used at all under macos or windows. + +settings.ps_viewer = false +descriptions.ps_viewer = + 'Epspdftk: viewer for PostScript files; not used on Windows or OS X' + +settings.pdf_viewer = false +descriptions.pdf_viewer = + 'Epspdftk: viewer for pdf files; not used on Windows or Mac OS' + +-- default_dir, which is used on all platforms, is only for the gui. + +if os.type == 'windows' then + settings.default_dir = + string.gsub(os.getenv('USERPROFILE'), '\\', '/') +else + settings.default_dir = os.getenv('HOME') +end +descriptions.default_dir = + 'Epspdftk: initial directory; ignored by epspdf itself' + +-- options ------------------------------------- + +-- besides settings, which can be saved, we also use options which are not. +-- these are mostly conversion options. + +options = {} +options.page = false +options.gray = false +options.bbox = false +options.debug = false +options.type = false -- implied via output filename on command line + +-- command-line fragments for conversions -------------------- + +-- We could make these `class attributes' for PsPdf but to what purpose? +-- For Windows shell commands, we need to substitute `#' for `=' +-- when invoking Ghostscript. For simplicity, we do this across the board. +-- -P- : do not look first in current directory + +gs_options = {'-q', '-dNOPAUSE', '-dBATCH', '-P-', '-dSAFER'} + +-- may add custom options later + +pdf_options = {'-sDEVICE#pdfwrite'} -- '-dUseCIEColor' causes serious slowdown +-- options for final conversion to pdf; +-- will be completed after reading settings and options +pdf_tail_options = {'-c', '.setpdfwrite', '-f'} +gray_options = {'-dProcessColorModel#/DeviceGray', + '-sColorConversionStrategy#Gray'} + +pdftops = false +-- gets a value only if we are going to use pdftops + +ps_options = {'-level3'} + +-- `main program' inside scope-creating block ---------------------- + +do -- main program + + local in_dir = false -- directory of infile + local out_dir = false -- directory of outfile + + do -- Handle settings and command-line inside nested scope -------------- + + read_settings(rcfile) + + local opts = {} + + opts.page = { + type = 'string', val = nil, + forms = {'-p', '--page', '--pagenumber'}, + placeholder = 'PNUM', + negforms = nil, + help = 'Page number; must be a positive integer' + } + + opts.gray = { + type = 'boolean', val = nil, + forms = {'-g', '--grey', '--gray', '-G', '--GREY', '--GRAY'}, + negforms = nil, + help = 'Convert to grayscale' + } + + opts.bbox = { + type = 'boolean', val = nil, + forms = {'-b', '--bbox', '--BoundingBox'}, + negforms = nil, + help = 'Compute tight boundingbox' + } + + ---[[ ignored; included for backward compatibility + opts.use_hires_bb = { + type = 'boolean', val = nil, + forms = {'-r', '--hires'}, + negforms = {'-n', '--no-hires'}, + } + + opts.custom = { + type = 'string', val = nil, + forms = {'-C', '--custom', '-P', '--psoptions'}, + negforms = nil + } + --]] + + opts.pdf_target = { + type = 'string', val = nil, + forms = {'-T', '--target'}, + placeholder = 'TARGET', + negforms = nil, + help = descriptions.pdf_target + } + opts.pdf_version = { + type = 'string', val = nil, + forms = {'-N', '--pdfversion'}, + placeholder = 'VERSION', + negforms = nil, + help = descriptions.pdf_version + } + + opts.use_pdftops = { + type = 'boolean', val = nil, + forms = {'-U'}, + negforms = {'-I'}, + help = descriptions.use_pdftops + } + + opts.info = { + type = 'boolean', val = nil, + forms = {'-i', '--info'}, + negforms = nil, + help = 'Info: display detected filetype and exit' + } + + opts.help = { + type = 'boolean', val = nil, + forms = {'-h', '--help'}, + negforms = nil, + help = 'Display this help message and exit' + } + + opts.version = { + type = 'boolean', val = nil, + forms = {'-v', '--version'}, + negforms = nil, + help = 'Display version info and exit' + } + + opts.save = { + type = 'boolean', val = nil, + forms = {'-s', '--save'}, + negforms = nil, + help = 'Save some settings to configuration file' + } + + opts.debug = { + type = 'boolean', val = nil, + forms = {'-d'}, + negforms = nil, + help = 'Debug: do not remove temp files' + } + + opts.gui = { + type = 'string', val = nil, + forms = {'--gui'}, + negforms = nil, + help = nil -- reserved for use by epspdftk + } + + -- a couple of functions only available during command-line parsing + + local function show_version () + print('Epspdf version 0.6.4\nCopyright (c) 2006-2019 Siep Kroonenberg') + end + + local function help (mess) -- requires opts array + if mess then + print(mess..eol) + end + show_version() + -- below, string.gsub unindents its long-string parameter. + -- string.format removes the second return value of string.gsub. + print( -- string.format('%s', + string.gsub([[ + + Convert between [e]ps and pdf formats + Usage: epspdf[.tlu] [options] infile [outfile] + Default for outfile is file.pdf if infile is file.eps or file.ps + Default for outfile is file.eps if infile is file.pdf + ]], '([\r\n]+) ', '%1')) --) + + -- need to enforce an ordering, otherwise we could have used pairs(opts) + -- omitted below: no-op options + -- one line where possible + local indent_n = 12 + local intent_sp = string.rep(' ', indent_n) + local indent_fmt = '%-' .. tostring(indent_n) .. 's' + for _, o in ipairs({'page', 'gray', 'bbox', 'pdf_target', 'pdf_version', + 'use_pdftops', 'save', 'info', 'debug', 'version', 'help'}) do + local v = opts[o] + if v and v.help then + local synt = join(v.forms, ', ') + if v.type ~= 'boolean' then synt = synt .. ' ' .. v.placeholder end + if string.len(synt)#arg then + help('Missing parameter to '..kk) + end + o.val = strip_outer_spaces(arg[i]) + end -- testing for o.type or vv + break -- for + end -- if in_list + end -- for + if not parsed then help('illegal parameter '..kk) end + i = i + 1 + end -- while + + -- check and interpret opts. + -- Copy to either settings or to options table. + -- at syntax error, abort via help function. + + -- page + + if opts.page.val then + local pnum = tonumber(opts.page.val) + if pnum<=0 or math.floor(pnum) ~= pnum then + help(opts.page.val..' not a positive integer') + else + options.page = pnum + end + end + + -- grayscaling + + if opts.gray.val then + options.gray = true + else + options.gray = false + end + + -- boundingbox + + if opts.bbox.val then + options.bbox = true + else + options.bbox = false + end + + --[[ + -- using hires boundingbox + + if opts.use_hires_bb.val~=nil then + settings.use_hires_bb = opts.use_hires_bb.val + end + --]] + + -- using pdftops + + if opts.use_pdftops.val~=nil then + settings.use_pdftops = opts.use_pdftops.val + end + + -- pdf target use + + if opts.pdf_target.val~=nil then + if in_list(opts.pdf_target.val, pdf_targets) then + settings.pdf_target = opts.pdf_target.val + else + help('Illegal value '..opts.pdf_target.val..' for pdf_target') + end + end + + -- pdf version + + if opts.pdf_version.val~=nil then + if in_list(opts.pdf_version.val, pdf_versions) then + settings.pdf_version = opts.pdf_version.val + else + help('Illegal value '..opts.pdf_version.val..' for pdf_version') + end + end + + -- pdftops should be on the path + if settings.use_pdftops then + if os.type=='windows' then + pdftops = find_on_path('pdftops.exe') + else + pdftops = find_on_path('pdftops') + end + end + + -- other options + + if opts.save.val then + write_settings(rcfile) + end + + if opts.debug.val then + options.debug = true + end + + if opts.help.val then + help() + end + + -- opts.info.val: do later; need to get infile first + + if opts.version.val then + show_version() + os.exit() + end + + if opts.gui.val then + gui(opts.gui.val) + end + + -- now we need 1 or 2 filenames, unless the user really only + -- wanted to save options without further action. + + if i>#arg then + if opts.save.val then os.exit() else help('No filenames') end + end + + infile = arg[i] + outfile = false + if i<#arg then + outfile = arg[i+1] + end + if (#arg>i and opts.info.val) or (#arg>i+1) then + help('Surplus non-option parameters') + end + + if not outfile and not opts.info.val then + -- derive outfile from infile: [e]ps => pdf, pdf => eps + if intype=='pdf' then + outfile = string.gsub(infile,'%.[^%.]*$','eps') + else + outfile = string.gsub(infile,'%.[^%.]*$','.pdf') + end + end + + -- one final quick option + if opts.info.val then + info() + end + + end -- do (decoding command-line) + + --[[ + + Once it becomes clear that real work needs to be done, + we shall create a temp directory. + + because of gs -dSAFER restrictions, infile must be in (a + subdirectory of) the directory of the output file, e.g. in the + temp directory. So we copy infile to the temp directory. + + --]] + + if not lfs.isfile(infile) or lfs.attributes(infile, 'size')==0 then + errror(infile..' does not exist or is empty.') + end + infile, in_dir = absolute_path(infile) + outfile, out_dir = absolute_path(outfile) + if not out_dir then + errror('Invalid output directory for '.. outfile) + end + + -- directory for temporary files + + -- previously, we used a subdirectory of the target directory. + -- however, since under windows cleanup may fail, we now try to use + -- a directory under a dedicated temp directory, which has a better chance + -- of getting cleaned up by the system. + + lfs.chdir(system_tempdir() or out_dir) + -- no check for failure; we create a subdirectory in + -- whatever is the current directory + tempdir = os.tmpdir() + if not tempdir then + errror('Cannot create directory for temporary files') + end + lfs.chdir(tempdir) + + -- determine filetype from first few bytes of file + intype = identify(infile) + + -- remaining cases: want a real conversion + if not intype then + errror(infile..' has an unsupported filetype') + end + + -- valid output filetype? + options.type = string.match(outfile, '.*%.([^%.]+)$') + if not options.type or (options.type~='ps' and + options.type~='eps' and options.type~='pdf') then + errror('Output file '..outfile.. + ' should have extension .eps, .ps or .pdf') + end + + if outfile==infile then + slice_file(infile, infile..'.backup') + -- copy, not move, since outfile may be infile if there was nothing to do + end + + result = any_to_any(infile, outfile) + if not lfs.isfile(outfile) or lfs.attributes(outfile, 'size')==0 then + errror('Failed to generate '..outfile) + end + + if not options.debug then + cleantemp() + end +end -- cgit v1.2.3