local pdf = pdf local writer = require'luametalatex-nodewriter' local newpdf = require'luametalatex-pdf' local nametree = require'luametalatex-pdf-nametree' local pdfname, pfile local fontdirs = setmetatable({}, {__index=function(t, k)t[k] = pfile:getobj() return t[k] end}) local usedglyphs = {} local dests = {} local cur_page local whatsit_id = node.id'whatsit' local whatsits = node.whatsits() local colorstacks = {{ page = true, mode = "direct", default = "0 g 0 G", page_stack = {"0 g 0 G"}, }} token.scan_list = token.scan_box -- They are equal if no parameter is present local spacer_cmd = token.command_id'spacer' local function get_pfile() if not pfile then pdfname = tex.jobname .. '.pdf' pfile = newpdf.open(tex.jobname .. '.pdf') end return pfile end local outline local function get_outline() if not outline then outline = require'luametalatex-pdf-outline'() end return outline end local properties = node.direct.properties token.luacmd("shipout", function() local pfile = get_pfile() local voff = node.new'kern' voff.kern = tex.voffset + pdf.variable.vorigin voff.next = token.scan_list() voff.next.shift = tex.hoffset + pdf.variable.horigin local list = node.direct.tonode(node.direct.vpack(node.direct.todirect(voff))) list.height = tex.pageheight list.width = tex.pagewidth local page, parent = pfile:newpage() cur_page = page local out, resources, annots = writer(pfile, list, fontdirs, usedglyphs, colorstacks) cur_page = nil local content = pfile:stream(nil, '', out) pfile:indirect(page, string.format([[<>]], parent, content, -math.ceil(list.depth/65781.76), math.ceil(list.width/65781.76), math.ceil(list.height/65781.76), resources, annots)) node.flush_list(list) token.put_next(token.create'immediateassignment', token.create'global', token.create'deadcycles', token.create(0x30), token.create'relax') token.scan_token() end, 'force', 'protected') local infodir = "" local namesdir = "" local catalogdir = "" local creationdate = os.date("D:%Y%m%d%H%M%S%z"):gsub("+0000$", "Z"):gsub("%d%d$", "'%0") local function write_infodir(p) local additional = "" if not string.find(infodir, "/CreationDate", 1, false) then additional = string.format("/CreationDate(%s)", creationdate) end if not string.find(infodir, "/ModDate", 1, false) then additional = string.format("%s/ModDate(%s)", additional, creationdate) end if not string.find(infodir, "/Producer", 1, false) then additional = string.format("%s/Producer(LuaMetaLaTeX)", additional) end if not string.find(infodir, "/Creator", 1, false) then additional = string.format("%s/Creator(TeX)", additional) end if not string.find(infodir, "/PTEX.Fullbanner", 1, false) then additional = string.format("%s/PTEX.Fullbanner(%s)", additional, status.banner) end return p:indirect(nil, string.format("<<%s%s>>", infodir, additional)) end local pdf_escape = require'luametalatex-pdf-escape' local pdf_bytestring = pdf_escape.escape_bytes local pdf_text = pdf_escape.escape_text callback.register("stop_run", function() if not pfile then return end for fid, id in pairs(fontdirs) do local f = font.getfont(fid) local psname = f.psname or f.fullname local sorted = {} for k,v in pairs(usedglyphs[fid]) do sorted[#sorted+1] = v end table.sort(sorted, function(a,b) return a[1] < b[1] end) pfile:indirect(id, require'luametalatex-pdf-font'(pfile, f, sorted)) end pfile.root = pfile:getobj() pfile.version = string.format("%i.%i", pdf.variable.majorversion, pdf.variable.minorversion) local destnames = {} for k,obj in next, dests do if pfile:written(obj) then if type(k) == 'string' then destnames[k] = obj .. ' 0 R' end else texio.write_nl("Warning: Undefined destination %q", tostring(k)) end end if next(destnames) then namesdir = string.format("/Dests %i 0 R%s", nametree(destnames, pfile), namesdir or '') end if namesdir then catalogdir = string.format("/Names<<%s>>%s", namesdir, catalogdir) end local pages = #pfile.pages if outline then catalogdir = string.format("/Outlines %i 0 R%s", outline:write(pfile), catalogdir) end pfile:indirect(pfile.root, string.format([[<>]], pfile.version, pfile:writepages(), catalogdir)) pfile.info = write_infodir(pfile) local size = pfile:close() texio.write_nl("term", "(see the transcript file for additional information)") -- TODO: Additional logging, epecially targeting the log file texio.write_nl("term and log", string.format(" %d words of node memory still in use:", status.var_used)) local by_type, by_sub = {}, {} for n, id, sub in node.traverse(node.usedlist()) do if id == whatsit_id then by_sub[sub] = (by_sub[sub] or 0) + 1 else by_type[id] = (by_type[id] or 0) + 1 end end local nodestat = {} local types = node.types() for id, c in next, by_type do nodestat[#nodestat + 1] = string.format("%d %s", c, types[id]) end for id, c in next, by_sub do nodestat[#nodestat + 1] = string.format("%d %s", c, whatsits[id]) end texio.write_nl(" " .. table.concat(nodestat, ', ')) texio.write_nl(string.format("Output written on %s (%d pages, %d bytes).", pdfname, pages, size)) texio.write_nl(string.format("Transcript written on %s.\n", status.log_name)) end, "Finish PDF file") token.luacmd("pdfvariable", function() for n, t in pairs(pdf.variable_tokens) do if token.scan_keyword(n) then token.put_next(t) return end end -- The following error message gobbles the next word as a side effect. -- This is intentional to make error-recovery easier. --[[ error(string.format("Unknown PDF variable %s", token.scan_word())) ]] -- Delay the error to ensure luatex85.sty compatibility texio.write_nl(string.format("Unknown PDF variable %s", token.scan_word())) tex.sprint"\\unexpanded{\\undefinedpdfvariable}" end) local lastobj = -1 local lastannot = -1 function pdf.newcolorstack(default, mode, page) local idx = #colorstacks colorstacks[idx + 1] = { page = page, mode = mode or "origin", default = default, page_stack = {default}, } return idx end local function sp2bp(sp) return sp/65781.76 end local function projected(m, x, y, w) w = w or 1 return x*m[1] + y*m[3] + w*m[5], x*m[2] + y*m[4] + w*m[6] end local function get_action_attr(p, action, is_link) local action_type = action.action_type if action_type == 3 then return action.data end local action_attr = is_link and "/Subtype/Link/A<<" or "<<" local file = action.file if file then action_attr = action_attr .. '/F' .. pdf_bytestring(file) local newwindow = action.new_window if newwindow and newwindow > 0 then action_attr = action_attr .. '/NewWindow ' .. (newwindow == 1 and 'true' or 'false') end end if action_type == 2 then error[[FIXME: Threads are currently unsupported]] -- TODO elseif action_type == 0 then error[[FIXME]] elseif action_type == 1 then -- GoTo local id = action.id if file then assert(type(id) == "string") action_attr = action_attr .. "/S/GoToR/D" .. pdf_bytestring(id) .. ">>" else local dest = dests[id] if not dest then dest = pfile:getobj() dests[id] = dest end if type(id) == "string" then action_attr = action_attr .. "/S/GoTo/D" .. pdf_bytestring(id) .. ">>" else action_attr = string.format("%s/S/GoTo/D %i 0 R>>", action_attr, dest) end end end return action_attr end local function write_link(p, link) local quads = link.quads local minX, maxX, minY, maxY = math.huge, -math.huge, math.huge, -math.huge local attr = link.attr .. get_action_attr(p, link.action, true) assert(#quads%8==0) local quadStr = {} for i=1,#quads,8 do local x1, y1, x4, y4, x2, y2, x3, y3 = table.unpack(quads, i, i+7) x1, y1, x2, y2, x3, y3, x4, y4 = sp2bp(x1), sp2bp(y1), sp2bp(x2), sp2bp(y2), sp2bp(x3), sp2bp(y3), sp2bp(x4), sp2bp(y4) quadStr[i//8+1] = string.format("%f %f %f %f %f %f %f %f", x1, y1, x2, y2, x3, y3, x4, y4) minX = math.min(minX, x1, x2, x3, x4) minY = math.min(minY, y1, y2, y3, y4) maxX = math.max(maxX, x1, x2, x3, x4) maxY = math.max(maxY, y1, y2, y3, y4) end pfile:indirect(link.objnum, string.format("<>", minX-.2, minY-.2, maxX+.2, maxY+.2, table.concat(quadStr, ' '), attr)) for i=1,#quads do quads[i] = nil end link.objnum = nil end local function addlinkpoint(p, link, x, y, list, kind) local quads = link.quads local off = pdf.variable.linkmargin x = kind == 'start' and x-off or x+off if link.annots and link.annots ~= p.annots then -- We started on another page, let's finish that before starting the new page write_link(p, link) link.annots = nil end if not link.annots then link.annots = p.annots -- Just a marker to indicate the page link.objnum = link.objnum or p.file:getobj() p.annots[#p.annots+1] = link.objnum .. " 0 R" end local m = p.matrix local lx, ly = projected(m, x, y-off-(link.depth or node.direct.getdepth(list))) local ux, uy = projected(m, x, y+off+(link.height or node.direct.getheight(list))) local n = #quads quads[n+1], quads[n+2], quads[n+3], quads[n+4] = lx, ly, ux, uy if kind == 'final' or (link.force_separate and (n+4)%8 == 0) then write_link(p, link) link.annots = nil end end local function linkcontext_set(linkcontext, p, x, y, list, level, kind) for _,l in ipairs(linkcontext) do if l.level == level then addlinkpoint(p, l, x, y, list, level, kind) end end end function do_start_link(prop, p, n, x, y, outer, _, level) local links = p.linkcontext if not links then links = {set = linkcontext_set} p.linkcontext = links end local link = {quads = {}, attr = prop.link_attr, action = prop.action, level = level, force_separate = false} -- force_separate should become an option links[#links+1] = link addlinkpoint(p, link, x, y, outer, 'start') end function do_end_link(prop, p, n, x, y, outer, _, level) local links = p.linkcontext if not links then error"No link here to end" end local link = links[#links] links[#links] = nil if link.level ~= level then error"Wrong link level" end addlinkpoint(p, link, x, y, outer, 'final') end local do_setmatrix do local numberpattern = (lpeg.P'-'^-1 * lpeg.R'09'^0 * ('.' * lpeg.R'09'^0)^-1)/tonumber local matrixpattern = numberpattern * ' ' * numberpattern * ' ' * numberpattern * ' ' * numberpattern function do_setmatrix(prop, p, n, x, y, outer) local m = p.matrix local a, b, c, d = matrixpattern:match(prop.data) if not a then print(prop.data) error[[No valid matrix found]] end local e, f = (1-a)*x-c*y, (1-d)*y-b*x -- Emulate that the origin is at x, y for this transformation -- (We could also first translate by (-x, -y), then apply the matrix -- and translate back, but this is more direct) pdf.write_matrix(a, b, c, d, e, f, p) a, b = projected(m, a, b, 0) c, d = projected(m, c, d, 0) e, f = projected(m, e, f, 1) m[1], m[2], m[3], m[4], m[5], m[6] = a, b, c, d, e, f end end local function do_save(prop, p, n, x, y, outer) pdf.write('page', 'q', x, y, p) local lastmatrix = p.matrix p.matrix = {[0] = lastmatrix, table.unpack(lastmatrix)} end local function do_restore(prop, p, n, x, y, outer) -- TODO: Check x, y pdf.write('page', 'Q', x, y, p) p.matrix = p.matrix[0] end local function do_dest(prop, p, n, x, y) -- TODO: Apply matrix assert(cur_page, "Destinations can not appear outside of a page") local id = prop.dest_id local dest_type = prop.dest_type local data if dest_type == "xyz" then local zoom = prop.xyz_zoom if zoom then data = string.format("[%i 0 R/XYZ %.5f %.5f %.3f]", cur_page, sp2bp(x), sp2bp(y), prop.zoom/1000) else data = string.format("[%i 0 R/XYZ %.5f %.5f null]", cur_page, sp2bp(x), sp2bp(y)) end elseif dest_type == "fitr" then data = string.format("[%i 0 R/FitR %.5f %.5f %.5f %.5f]", cur_page, sp2bp(x), sp2bp(y + prop.depth), sp2bp(x + prop.width), sp2bp(y - prop.height)) elseif dest_type == "fit" then data = string.format("[%i 0 R/Fit]", cur_page) elseif dest_type == "fith" then data = string.format("[%i 0 R/FitH %.5f]", cur_page, sp2bp(y)) elseif dest_type == "fitv" then data = string.format("[%i 0 R/FitV %.5f]", cur_page, sp2bp(x)) elseif dest_type == "fitb" then data = string.format("[%i 0 R/FitB]", cur_page) elseif dest_type == "fitbh" then data = string.format("[%i 0 R/FitBH %.5f]", cur_page, sp2bp(y)) elseif dest_type == "fitbv" then data = string.format("[%i 0 R/FitBV %.5f]", cur_page, sp2bp(x)) end if pfile:written(dests[id]) then texio.write_nl(string.format("Duplicate destination %q", id)) else dests[id] = pfile:indirect(dests[id], data) end end local function do_refobj(prop, p, n, x, y) pfile:reference(prop.obj) end local function do_literal(prop, p, n, x, y) pdf.write(prop.mode, prop.data, x, y, p) end local function do_colorstack(prop, p, n, x, y) local colorstack = prop.colorstack local stack if p.is_page then stack = colorstack.page_stack elseif prop.last_form == resources then stack = colorstack.form_stack else stack = {prop.default} colorstack.form_stack = stack end if prop.action == "push" then stack[#stack+1] = prop.data elseif prop.action == "pop" then assert(#stack > 1) stack[#stack] = nil elseif prop.action == "set" then stack[#stack] = prop.data end pdf.write(colorstack.mode, stack[#stack], x, y, p) end local function write_colorstack() local idx = token.scan_int() local colorstack = colorstacks[idx + 1] if not colorstack then error[[Undefined colorstack]] end local action = token.scan_keyword'pop' and 'pop' or token.scan_keyword'set' and 'set' or token.scan_keyword'current' and 'current' or token.scan_keyword'push' and 'push' if not action then error[[Missing action specifier for colorstack command]] end local text if action == "push" or "set" then text = token.scan_string() -- text = token.to_string(token.scan_tokenlist()) -- Attention! This should never be executed in an expand-only context end local whatsit = node.new(whatsit_id, whatsits.pdf_colorstack) node.setproperty(whatsit, { handle = do_colorstack, colorstack = colorstack, action = action, data = text, }) node.write(whatsit) end local function scan_action() local action_type if token.scan_keyword'user' then return {action_type = 3, data = token.scan_string()} elseif token.scan_keyword'thread' then error[[FIXME: Unsupported]] -- TODO elseif token.scan_keyword'goto' then action_type = 1 else error[[Unsupported action]] end local action = { action_type = action_type, file = token.scan_keyword'file' and token.scan_string(), } if token.scan_keyword'page' then error[[TODO]] elseif token.scan_keyword'num' then if action.file and action_type == 3 then error[[num style GoTo actions must be internal]] end action.id = token.scan_int() if action.id <= 0 then error[[id must be positive]] end elseif token.scan_keyword'name' then action.id = token.scan_string() else error[[Unsupported id type]] end action.new_window = token.scan_keyword'newwindow' and 1 or token.scan_keyword'nonewwindow' and 2 if action.new_window and not action.file then error[[newwindow is only supported for external files]] end return action end local function scan_literal_mode() return token.scan_keyword"direct" and "direct" or token.scan_keyword"page" and "page" or token.scan_keyword"text" and "text" or token.scan_keyword"direct" and "direct" or token.scan_keyword"raw" and "raw" or "origin" end local function maybe_gobble_cmd(cmd) local t = token.scan_token() if t.command ~= cmd then token.put_next(t) end end token.luacmd("pdffeedback", function() if token.scan_keyword"colorstackinit" then local page = token.scan_keyword'page' or (token.scan_keyword'nopage' and false) -- If you want to pass "page" as mode local mode = scan_literal_mode() local default = token.scan_string() tex.sprint(tostring(pdf.newcolorstack(default, mode, page))) elseif token.scan_keyword"creationdate" then tex.sprint(creationdate) elseif token.scan_keyword"lastannot" then tex.sprint(tostring(lastannot)) elseif token.scan_keyword"lastobj" then tex.sprint(tostring(lastobj)) else -- The following error message gobbles the next word as a side effect. -- This is intentional to make error-recovery easier. error(string.format("Unknown PDF feedback %s", token.scan_word())) end end) token.luacmd("pdfextension", function(_, imm) if token.scan_keyword"colorstack" then write_colorstack() elseif token.scan_keyword"literal" then local mode = scan_literal_mode() local literal = token.scan_string() local whatsit = node.new(whatsit_id, whatsits.pdf_literal) node.setproperty(whatsit, { handle = do_literal, mode = mode, data = literal, }) node.write(whatsit) elseif token.scan_keyword"startlink" then local pfile = get_pfile() local whatsit = node.new(whatsit_id, whatsits.pdf_start_link) local attr = token.scan_keyword'attr' and token.scan_string() or '' local action = scan_action() local objnum = pfile:getobj() lastannot = num node.setproperty(whatsit, { handle = do_start_link, link_attr = attr, action = action, objnum = objnum, }) node.write(whatsit) elseif token.scan_keyword"endlink" then local whatsit = node.new(whatsit_id, whatsits.pdf_end_link) node.setproperty(whatsit, { handle = do_end_link, }) node.write(whatsit) elseif token.scan_keyword"save" then local whatsit = node.new(whatsit_id, whatsits.pdf_save) node.setproperty(whatsit, { handle = do_save, }) node.write(whatsit) elseif token.scan_keyword"setmatrix" then local matrix = token.scan_string() local whatsit = node.new(whatsit_id, whatsits.pdf_setmatrix) node.setproperty(whatsit, { handle = do_setmatrix, data = matrix, }) node.write(whatsit) elseif token.scan_keyword"restore" then local whatsit = node.new(whatsit_id, whatsits.pdf_restore) node.setproperty(whatsit, { handle = do_restore, }) node.write(whatsit) elseif token.scan_keyword"info" then infodir = infodir .. token.scan_string() elseif token.scan_keyword"catalog" then catalogdir = catalogdir .. ' ' .. token.scan_string() elseif token.scan_keyword"names" then namesdir = namesdir .. ' ' .. token.scan_string() elseif token.scan_keyword"obj" then local pfile = get_pfile() if token.scan_keyword"reserveobjnum" then lastobj = pfile:getobj() else local num = token.scan_keyword'useobjnum' and token.scan_int() or pfile:getobj() lastobj = num local attr = token.scan_keyword'stream' and (token.scan_keyword'attr' and token.scan_string() or '') local isfile = token.scan_keyword'file' local content = token.scan_string() if immediate then if attr then pfile:stream(num, attr, content, isfile) else pfile:indirect(num, content, isfile) end else if attr then pfile:delayedstream(num, attr, content, isfile) else pfile:delayed(num, attr, content, isfile) end end end elseif token.scan_keyword"refobj" then local num = token.scan_int() local whatsit = node.new(whatsit_id, whatsits.pdf_refobj) node.setproperty(whatsit, { obj = num, handle = do_refobj, }) node.write(whatsit) elseif token.scan_keyword"outline" then local pfile = get_pfile() local attr = token.scan_keyword'attr' and token.scan_string() or '' local action if token.scan_keyword"useobjnum" then action = token.scan_int() else local actionobj = scan_action() action = pfile:indirect(nil, get_action_attr(pfile, actionobj)) end local outline = get_outline() if token.scan_keyword'level' then local level = token.scan_int() local open = token.scan_keyword'open' local title = token.scan_string() outline:add(pdf_text(title), action, level, open, attr) else local count = token.scan_keyword'count' and token.scan_int() or 0 local title = token.scan_string() outline:add_legacy(pdf_text(title), action, count, attr) end elseif token.scan_keyword"dest" then local id if token.scan_keyword'num' then id = token.scan_int() if id <= 0 then error[[id must be positive]] end elseif token.scan_keyword'name' then id = token.scan_string() else error[[Unsupported id type]] end local whatsit = node.new(whatsit_id, whatsits.pdf_dest) local prop = { dest_id = id, handle = do_dest, } node.setproperty(whatsit, prop) if token.scan_keyword'xyz' then prop.dest_type = 'xyz' prop.xyz_zoom = token.scan_keyword'zoom' and token.scan_int() maybe_gobble_cmd(spacer_cmd) elseif token.scan_keyword'fitr' then prop.dest_type = 'fitr' maybe_gobble_cmd(spacer_cmd) while true do if token.scan_keyword'width' then prop.width = token.scan_dimen() elseif token.scan_keyword'height' then prop.height = token.scan_dimen() elseif token.scan_keyword'depth' then prop.depth = token.scan_dimen() else break end end elseif token.scan_keyword'fitbh' then prop.dest_type = 'fitbh' maybe_gobble_cmd(spacer_cmd) elseif token.scan_keyword'fitbv' then prop.dest_type = 'fitbv' maybe_gobble_cmd(spacer_cmd) elseif token.scan_keyword'fitb' then prop.dest_type = 'fitb' maybe_gobble_cmd(spacer_cmd) elseif token.scan_keyword'fith' then prop.dest_type = 'fith' maybe_gobble_cmd(spacer_cmd) elseif token.scan_keyword'fitv' then prop.dest_type = 'fitv' maybe_gobble_cmd(spacer_cmd) elseif token.scan_keyword'fit' then prop.dest_type = 'fit' maybe_gobble_cmd(spacer_cmd) else error[[Unsupported dest type]] end node.write(whatsit) else -- The following error message gobbles the next word as a side effect. -- This is intentional to make error-recovery easier. error(string.format("Unknown PDF extension %s", token.scan_word())) end end, "protected")