diff --git a/luametalatex-nodewriter.lua b/luametalatex-nodewriter.lua index 04fe7a8..147ba06 100644 --- a/luametalatex-nodewriter.lua +++ b/luametalatex-nodewriter.lua @@ -257,6 +257,10 @@ local user_rule = rulesubtypes.user local empty_rule = rulesubtypes.empty local outline_rule = rulesubtypes.outline local ship_img = require'luametalatex-pdf-image'.ship +-- print(require'inspect'(node.subtypes('glue'))) +-- print(require'inspect'(node.fields('glue'))) +-- print(require'inspect'(node.fields('rule'))) +-- print(require'inspect'(node.fields('whatsit'))) function nodehandler.rule(p, n, x, y, outer) if getwidth(n) == -1073741824 then setwidth(n, getwidth(outer)) end if getheight(n) == -1073741824 then setheight(n, getheight(outer)) end diff --git a/luametalatex-pdf-image-pdf.lua b/luametalatex-pdf-image-pdf.lua new file mode 100644 index 0000000..dcf57ac --- /dev/null +++ b/luametalatex-pdf-image-pdf.lua @@ -0,0 +1,106 @@ +local box_fallback = { + BleedBox = "CropBox", + TrimBox = "CropBox", + ArtBox = "CropBox", + CropBox = "MediaBox", +} + +local boxmap = { + media = "MediaBox", + crop = "CropBox", + bleed = "BleedBox", + trim = "TrimBox", + art = "ArtBox", +} + +-- FIXME: +local function to_sp(bp) return bp*65781.76//1 end +local function to_bp(sp) return sp/65781.76 end + +local function get_box(page, box) + box = boxmap[box] + while box do + local found = pdfe.getbox(page, box) + if found then + return {to_sp(found[1]), to_sp(found[2]), to_sp(found[3]), to_sp(found[4])} + end + box = box_fallback[box] + end +end + +local pdf_functions = {} + +local function open_pdfe(img) + local file = pdfe.open(img.filepath) + do + local userpassword = img.userpassword + local ownerpassword = img.ownerpassword + if userpassword or ownerpassword then + pdfe.unencrypt(file, userpassword, ownerpassword) + end + end + local status = pdfe.getstatus(file) + if status >= 0 then + return file + elseif status == -1 then + error[[PDF image is encrypted. Please provide the decryption key.]] + elseif status == -2 then + error[[PDF image could not be opened.]] + else + assert(false) + end +end +function pdf_functions.scan(img) + local file = open_pdfe(img) + img.pages = pdfe.getnofpages(file) + img.page = img.page or 1 + if img.page > img.pages then + error[[Not enough pages in PDF image]] + end + local page = pdfe.getpage(file, img.page) + local bbox = img.bbox or get_box(page, img.pagebox or 'crop') or {0, 0, 0, 0} + img.bbox = bbox + img.rotation = (360 - (page.Rotate or 0)) % 360 + assert(img.rotation % 90 == 0, "Invalid /Rotate") + img.rotation = img.rotation / 90 + if img.rotation < 0 then img.rotation = img.rotation + 4 end + img.xsize = bbox[3] - bbox[1] + img.ysize = bbox[4] - bbox[2] + img.xres, img.yres = nil, nil +end + +local pdfe_deepcopy = require'luametalatex-pdfe-deepcopy' +function pdf_functions.write(pfile, img) + local file = open_pdfe(img) + local page = pdfe.getpage(file, img.page) + local bbox = img.bbox + local dict = string.format("/Subtype/Form/BBox[%f %f %f %f]/Resources %s", bbox[1], bbox[2], bbox[3], bbox[4], pdfe_deepcopy(file, img.filepath, pfile, pdfe.getfromdictionary(page, 'Resources'))) + local content, raw = page.Contents + -- Three cases: Contents is a stream, so copy the stream (Remember to copy filter if necessary) + -- Contents is an array of streams, so append all the streams as a new stream + -- Contents is missing. Then create an empty stream. + local type = pdfe.type(content) + if type == 'pdfe.stream' then + raw = true + for i=1,#content do + local key, type, value, detail = pdfe.getfromstream(content, i) + dict = dict .. pdfe_deepcopy(file, img.filepath, pfile, 5, key) .. ' ' .. pdfe_deepcopy(file, img.filepath, pfile, type, value, detail) + end + content = content(false) + elseif type == 'pdfe.array' then + local array = content + content = '' + for i=1,#array do + content = content .. array[i](true) + end + else + content = '' + end + local attr = img.attr + if attr then + dict = dict .. attr + end + pfile:stream(img.objnum, dict, content, nil, raw) +end + +return pdf_functions diff --git a/luametalatex-pdf-image-png.lua b/luametalatex-pdf-image-png.lua new file mode 100644 index 0000000..b84a608 --- /dev/null +++ b/luametalatex-pdf-image-png.lua @@ -0,0 +1,285 @@ +local function ignore() end +local parse = setmetatable({ + -- IHDR = below, + -- PLTE = below, + -- IDAT = below, + -- IEND = below, + -- I'm not yet sure what to do about the following four color management chunks: + -- These two will probably be ignored (if you care about this stuff, you probably + -- prefer an ICC profile anyway. Also especially cHRM requires some weird computations.) + -- cHRM = TODO, -- ignore? + -- gAMA = TODO, -- ignore? + -- iCCP is implemented, but profiles are not cached, so it might include the + -- same profile many times + -- iCCP = below, + -- I would expect sRGB to be the most common, but it is a bit complicated because + -- PDF seems to require us to ship an actual ICC profile to support sRGB. Maybe later. + -- sRGB = TODO, + sBIT = ignore, + bKGD = ignore, -- Background color. Ignored since we support transparency + hIST = ignore, -- Color histogram + -- tRNS = below, + -- pHYs = below, -- resolution information + sPLT = ignore, -- Suggested palette but we support full truetype + tIME = ignore, -- The following only store metadata + iTXt = ignore, + tEXt = ignore, + zTXt = ignore, +}, { + __index = function(_, n) + print("Table " .. n .. " unsupported") -- FIXME: Handle extensions by detecing if they are critical etc. + return ignore + end + }) +function parse.IHDR(buf, i, after, ctxt) + if next(ctxt) then + error[[The header should come first]] + end + local compression, filter + ctxt.width, ctxt.height, + ctxt.bitdepth, ctxt.colortype, + compression, filter, + ctxt.interlace, i = string.unpack(">I4I4I1I1I1I1I1", buf, i) + if i ~= after then + return [[Invalid header size]] + end + if compression ~= 0 then + error [[Unsupported compression mode]] + end + if filter ~= 0 then + error [[Unsupported filter mode]] + end +end +function parse.PLTE(buf, i, after, ctxt) + if ctxt.PLTE then + error[[Multiple palettes detected]] + end + if (after-i)%3 ~= 0 then + error[[Invalid palette lenght]] + end + ctxt.PLTE_len = (after-i) // 3 + ctxt.PLTE = string.sub(buf, i, after-1) +end +function parse.tRNS(buf, i, after, ctxt) + if ctxt.colortype == 3 then + local count = assert(ctxt.PLTE_len) + local patt = lpeg.P(1) * lpeg.Cc'\xff' + for j=0,after-i-1 do + local off = i+j + patt = lpeg.P(string.char(j)) * lpeg.Cc(buf:sub(off, off)) + patt + end + ctxt.tRNS = lpeg.Cs(lpeg.Cg(patt)^0) + elseif ctxt.colortype == 0 then + local color + color, i = string.unpack(">I2", buf, i) + assert(i == after) + ctxt.tRNS = string.format('%i %i', color, color) + elseif ctxt.colortype == 2 then + local r, g, b + r, g, b, i = string.unpack(">I2I2I2", buf, i) + assert(i == after) + ctxt.tRNS = string.format('%i %i %i %i %i %i', r, r, g, g, b, b) + end +end +local meterperinch = 0.0254 +function parse.pHYs(buf, i, after, ctxt) + local xres, yres, unit + xres, yres, unit, i = string.unpack('>I4I4I1', buf, i) + if unit == 0 then + if xres > yres then + ctxt.xres, ctxt.yres = xres/yres, 0 + elseif xres < yres then + ctxt.xres, ctxt.yres = 0, yres/xres + end + elseif unit == 1 then + ctxt.xres, ctxt.yres = xres * meterperinch, yres * meterperinch + else + error[[Invalid unit]] + end + assert(i == after) +end +function parse.iCCP(buf, i, after, ctxt) + local j = buf:find('\0', i, true) + assert(j+1I4c4", buf, i) + if tp == limit then break end + parse[tp](buf, off, off + length, chunks) + i = off + length + 4 + end + return chunks, i +end +local function passes(buf, width, height, bitdepth, colortype) + local stride = (bitdepth == 16 and 2 or 1) * (1 + (colortype&3 == 2 and 2 or 0) + (colortype&4)/4) + local passes = { + {(width+7)//8, (height+7)//8}, + {(width+3)//8, (height+7)//8}, + {(width+3)//4, (height+3)//8}, + {(width+1)//4, (height+3)//4}, + {(width+1)//2, (height+1)//4}, + { width //2, (height+1)//2}, + { width , height //2}, + } + local off = 1 + local result + for i=1,#passes do + local xsize, ysize = passes[i][1], passes[i][2] + if xsize ~= 0 and ysize ~= 0 then + if bitdepth < 8 then + xsize = (xsize * bitdepth + 7) // 8 + end + local after = off + (xsize+1) * stride * ysize + local pass = pngdecode.applyfilter( + buf:sub(off, after-1), + xsize, + ysize, + stride) + if bitdepth < 8 then + pass = pngdecode.expand(pass, passes[i][1], ysize, bitdepth, xsize) + end + result = pngdecode.interlace(width, height, stride, i, pass, result) + off = after + end + end + assert(off == #buf+1) + return result +end + +local png_functions = {} + +function png_functions.scan(img) + local file = io.open(img.filepath) + if not file then + error[[PDF image could not be opened.]] + end + local buf = file:read'a' + file:close() + local t = run(buf, 1, #buf, 'IDAT') + img.pages = 1 + img.page = 1 + img.rotation = 0 + img.xsize, img.ysize = t.width, t.height + img.xres, img.yres = t.xres or 0, t.yres or 0 + img.colordepth = t.bitdepth +end + +local pdf_escape = require'luametalatex-pdf-escape'.escape_bytes + +local function rawimage(t, content) + content = xzip.decompress(content) + if t.interlace == 1 then + content = passes(content, t.width, t.height, t.bitdepth, t.colortype) + else + local xsize = t.width + if t.bitdepth < 8 then + xsize = (xsize * t.bitdepth + 7) // 8 + end + local colortype = t.colortype + content = pngdecode.applyfilter( + content, + xsize, + t.height, + (t.bitdepth == 16 and 2 or 1) * (1 + (colortype&3 == 2 and 2 or 0) + (colortype&4)/4)) + end + return content +end + +function png_functions.write(pfile, img) + local file = io.open(img.filepath) + if not file then + error[[PDF image could not be opened.]] + end + local buf = file:read'a' + file:close() + local t = run(buf, 1, #buf, 'IEND') + local colorspace + local colortype = t.colortype + if img.colorspace then + colorspace = string.format(' %i 0 R', img.colorspace) + elseif t.iCCP then + local icc_ref = pfile:stream(nil, '/N ' .. tostring(colortype & 2 == 2 and '3' or '1'), t.iCCP) + colorspace = string.format('[/ICCBased %i 0 R]', icc_ref) + elseif colortype & 2 == 2 then -- RGB + colorspace = '/DeviceRGB' + else -- Gray + colorspace = '/DeviceGray' + end + if colortype & 1 == 1 then -- Indexed + colorspace = string.format('[/Indexed%s %i%s]', colorspace, t.PLTE_len-1, pdf_escape(t.PLTE)) + end + local colordepth = t.interlace == 1 and 8 or img.colordepth + local dict = string.format("/Subtype/Image/Width %i/Height %i/BitsPerComponent %i/ColorSpace%s", img.xsize, img.ysize, colordepth, colorspace) + + local content = table.concat(t.IDAT) + local copy -- = true + if copy and (t.interlace == 1 or colortype & 4 == 4) then -- TODO: Add additional conditions + copy = false + end + + if copy then + -- In this case we never have to deal with an alpha component + dict = string.format( + '%s/Filter/FlateDecode/DecodeParms<>', + dict, colortype == 2 and 3 or 1, img.xsize, colordepth) + else + content = rawimage(t, content) + if colortype & 4 == 4 then -- Alpha channel present + local mask + content, mask = pngdecode.splitmask( + content, + img.xsize, + img.ysize, + 1 + (colortype&2), + colordepth//8) -- colordepth must be 8 or 16 if alpha is present + local mask_dict = string.format("/Subtype/Image/Width %i/Height %i/BitsPerComponent %i/ColorSpace/DeviceGray", img.xsize, img.ysize, colordepth) + local objnum = pfile:stream(nil, mask_dict, mask) + dict = string.format('%s/SMask %i 0 R', dict, objnum) + end + end + + if t.tRNS then + if colortype == 3 then + local unpacked = copy and rawimage(t, content) or content + if colordepth ~= 8 then + unpacked = pngdecode.expand(unpacked, img.xsize, img.ysize, colordepth, (img.xsize*colordepth+7)//8) + end + unpacked = t.tRNS:match(unpacked) + local mask_dict = string.format("/Subtype/Image/Width %i/Height %i/BitsPerComponent 8/ColorSpace/DeviceGray", img.xsize, img.ysize) + local objnum = pfile:stream(nil, mask_dict, unpacked) + dict = string.format('%s/SMask %i 0 R', dict, objnum) + else + dict = string.format('%s/Mask[%s]', dict, t.tRNS) + end + end + + local attr = img.attr + if attr then + dict = dict .. attr + end + pfile:stream(img.objnum, dict, content, nil, copy) +end + +return png_functions diff --git a/luametalatex-pdf-image.lua b/luametalatex-pdf-image.lua index 320b574..f5971ac 100644 --- a/luametalatex-pdf-image.lua +++ b/luametalatex-pdf-image.lua @@ -6,109 +6,21 @@ local setwhd = node.direct.setwhd local tonode = node.direct.tonode local nodewrite = node.write -local box_fallback = { - BleedBox = "CropBox", - TrimBox = "CropBox", - ArtBox = "CropBox", - CropBox = "MediaBox", -} - -local boxmap = { - media = "MediaBox", - crop = "CropBox", - bleed = "BleedBox", - trim = "TrimBox", - art = "ArtBox", +-- Mapping extensions to canonical type names if necessary +local imagetype_map = { + -- pdf1 = 'pdf', } +local imagetypes = setmetatable({}, {__index = function(t, k) + local remapped = imagetype_map[k] + local module = remapped and t[remapped] or require('luametalatex-pdf-image-' .. k) + t[k] = module + return module +end}) -- FIXME: local function to_sp(bp) return bp*65781.76//1 end local function to_bp(sp) return sp/65781.76 end -local function get_box(page, box) - box = boxmap[box] - while box do - local found = pdfe.getbox(page, box) - if found then - return {to_sp(found[1]), to_sp(found[2]), to_sp(found[3]), to_sp(found[4])} - end - box = box_fallback[box] - end -end - -local function open_pdfe(img) - local file = pdfe.open(img.filepath) - do - local userpassword = img.userpassword - local ownerpassword = img.ownerpassword - if userpassword or ownerpassword then - pdfe.unencrypt(file, userpassword, ownerpassword) - end - end - local status = pdfe.getstatus(file) - if status >= 0 then - return file - elseif status == -1 then - error[[PDF image is encrypted. Please provide the decryption key.]] - elseif status == -2 then - error[[PDF image could not be opened.]] - else - assert(false) - end -end -local function scan_pdf(img) - local file = open_pdfe(img) - img.imagetype = 'pdf' - img.pages = pdfe.getnofpages(file) - img.page = img.page or 1 - if img.page > img.pages then - error[[Not enough pages in PDF image]] - end - local page = pdfe.getpage(file, img.page) - local bbox = img.bbox or get_box(page, img.pagebox or 'crop') or {0, 0, 0, 0} - img.bbox = bbox - img.rotation = (360 - (page.Rotate or 0)) % 360 - assert(img.rotation % 90 == 0, "Invalid /Rotate") - img.rotation = img.rotation / 90 - if img.rotation < 0 then img.rotation = img.rotation + 4 end - img.xsize = bbox[3] - bbox[1] - img.ysize = bbox[4] - bbox[2] -end - -local pdfe_deepcopy = require'luametalatex-pdfe-deepcopy' -local function write_pdf(pfile, img) - local file = open_pdfe(img) - local page = pdfe.getpage(file, img.page) - local bbox = img.bbox - local dict = string.format("/Subtype/Form/BBox[%f %f %f %f]/Resources %s", bbox[1], bbox[2], bbox[3], bbox[4], pdfe_deepcopy(file, img.filepath, pfile, pdfe.getfromdictionary(page, 'Resources'))) - local content, raw = page.Contents - -- Three cases: Contents is a stream, so copy the stream (Remember to copy filter if necessary) - -- Contents is an array of streams, so append all the streams as a new stream - -- Contents is missing. Then create an empty stream. - local type = pdfe.type(content) - if type == 'pdfe.stream' then - raw = true - for i=1,#content do - local key, type, value, detail = pdfe.getfromstream(content, i) - dict = dict .. pdfe_deepcopy(file, img.filepath, pfile, 5, key) .. ' ' .. pdfe_deepcopy(file, img.filepath, pfile, type, value, detail) - end - content = content(false) - elseif type == 'pdfe.array' then - local array = content - content = '' - for i=1,#array do - content = content .. array[i](true) - end - else - content = '' - end - local attr = img.attr - if attr then - dict = dict .. attr - end - pfile:stream(img.objnum, dict, content, nil, raw) -end - local liberal_keys = {height = true, width = true, depth = true, transform = true} local real_images = {} local function relaxed_newindex(t, k, v) @@ -150,9 +62,13 @@ local function scan(img) real = real_images[img] if real.stream then error[[stream images are not yet supported]] end assert(real.filename) - if not real.filename:match'%.pdf$' then error[[Currently only PDF images are supported]] end + -- TODO: At some point we should just take the lowercased extension + local imagetype = real.filename:match'%.pdf$' and 'pdf' + or real.filename:match'%.png$' and 'png' + or error'Unsupported image format' real.filepath = assert(kpse.find_file(real.filename), "Image not found") - scan_pdf(real) + real.imagetype = imagetype + imagetypes[imagetype].scan(real) setmetatable(img, restricted_meta) end img.transform = img.transform or 0 @@ -163,13 +79,27 @@ local function scan(img) local flipped = (img.transform + real.rotation) % 2 == 1 if not (img.depth or img.height) then img.depth = 0 end if not img.width and not (img.height and img.depth) then + local xsize, ysize = real.xsize, real.ysize + if not real.bbox then + local xres, yres = img.xres, img.yres + -- TODO: \pdfvariable Parameters + if xres == 0 then + xres = 72 + yres = xres * ((not yres or yres == 0) and 1 or yres) + elseif yres == 0 then + yres = 72 + xres = yres * ((not xres or xres == 0) and 1 or xres) + end + local xscale, yscale = 4736286.72/xres, 4736286.72/yres + xsize, ysize = xsize*xscale//1, ysize*yscale//1 + end local total_y if flipped then - img.width = real.ysize - total_y = real.xsize + img.width = ysize + total_y = xsize else - img.width = real.xsize - total_y = real.ysize + img.width = xsize + total_y = ysize end if img.height then img.depth = total_y - img.height @@ -177,7 +107,7 @@ local function scan(img) img.height = total_y - img.depth end else - local ratio = flipped and real.xsize / real.ysize or real.ysize / real.xsize + local ratio = flipped and xsize / ysize or ysize / xsize if img.width then if img.depth then img.height = (ratio * img.width - img.depth) // 1 @@ -210,7 +140,7 @@ local function write_img(pfile, img) local objnum = reserve(pfile, img) if not img.written then img.written = true - write_pdf(pfile, img) + imagetypes[img.imagetype].write(pfile, img) end end local function do_img(data, p, n, x, y) @@ -222,7 +152,13 @@ local function do_img(data, p, n, x, y) height = height + depth local bbox = img.bbox local xsize, ysize = img.xsize, img.ysize - local a, b, c, d, e, f = 1, 0, 0, 1, -bbox[1], -bbox[2] + local a, b, c, d, e, f = 1, 0, 0, 1 + if bbox then + e, f = -bbox[1], -bbox[2] + else + e, f = 0, 0 + xsize, ysize = 65781.76, 65781.76 + end if mirror then a, e = -a, -e+xsize end