diff options
Diffstat (limited to 'macros/luatex/generic/minim-pdf/minim-pdf.lua')
-rw-r--r-- | macros/luatex/generic/minim-pdf/minim-pdf.lua | 1114 |
1 files changed, 1114 insertions, 0 deletions
diff --git a/macros/luatex/generic/minim-pdf/minim-pdf.lua b/macros/luatex/generic/minim-pdf/minim-pdf.lua new file mode 100644 index 0000000000..ffe845061c --- /dev/null +++ b/macros/luatex/generic/minim-pdf/minim-pdf.lua @@ -0,0 +1,1114 @@ + +local M = { } +local alloc = require('minim-alloc') +local cb = require('minim-callbacks') +alloc.remember('minim-pdf') + +-- 1 helper functions + +local function insert_formatted(t, ...) + table.insert(t, string.format(...)) +end + +local function add_to_catalog(...) + pdf.setcatalog((pdf.getcatalog() or '') .. string.format(...) .. ' ') +end + +local function add_to_names(...) + pdf.setnames((pdf.getnames() or '') .. string.format(...) .. ' ') +end + +-- in-depth node list traversal +-- returns current and parent node +-- only dives into hbox and vbox nodes +local function full_traverse(head) + return function(stack, last) + local next = last.next + if not next then + next, stack.box = table.unpack(table.remove(stack)) + else + while next.list do + table.insert(stack, { next, stack.box }) + next, stack.box = next.list, next + end + end + return next, stack.box + end, { { } }, { next = head } +end + +-- re-encode to utf-16 and surround by <> +local function pdf_hex_string(text) + local str = { [1] = '<feff' } + for i in text:utfvalues() do + if i < 0xFFFF then + insert_formatted(str, '%04x', i) + else + i = i - 0x10000 + m = math.floor(i/0x400) + 0xD800 + n = ( i % 0x400 ) + 0xDC00 + insert_formatted(str, '%04x%04x' ,m, n) + end + end + table.insert(str, '>') + return table.concat(str,'') +end + +-- try and produce a () string first, then fall back to <> +function M.pdf_string(text) + local str = { '(' } + for i in text:utfvalues() do + if i > 0x7E then + return pdf_hex_string(text) + elseif i < 0x20 then + insert_formatted(str, '\\%03o', i) + elseif i == 0x28 or i == 0x29 or i == 0x5c then + insert_formatted(str, '\\%c', i) + else + insert_formatted(str, '%c', i) + end + end + table.insert(str, ')') + return table.concat(str,'') + end + +-- make available as \pdfstring{...} +alloc.luadef('pdfstring', function() M.pdf_string(token.scan_string()) end) + +-- try and produce a date of the format (D:YYYYMMDD) +function M.pdf_date(text) + local y, m, d = string.match(text, '^([12][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)$') + if not y then d, m, y = string.match(text, '^([0-9][0-9]?)-([0-9][0-9]?)-([12][0-9][0-9][0-9])$') end + if y then + return string.format('(D:%4d%02d%02d)', y, m, d) + else + return string.format('(D:%s)', text) + end +end + +function M.options_scanner() + return { scanners = { }, + add = function(this, name, scanner) + this.scanners[name] = scanner + return this + end, + keyword = function(this, name) + return this:add(name, function() return true end) + end, + int = function(this, name) + return this:add(name, token.scan_int) + end, + glue = function(this, name) + return this:add(name, token.scan_glue) + end, + dimen = function(this, name) + return this:add(name, token.scan_dimen) + end, + string = function(this, name) + return this:add(name, token.scan_string) + end, + argument = function(this, name) + return this:add(name, token.scan_argument) + end, + word = function(this, name) + return this:add(name, token.scan_word) + end, + list = function(this, name) + return this:add(name, token.scan_list) + end, + scan = function(this, defaults) + local res = defaults or { } + repeat + local matched = false + for name, scanner in pairs(this.scanners) do + if token.scan_keyword(name) then + matched, this.scanners[name] = true, nil + res[name] = scanner() + end + end + until not matched + return res + end + } +end + +local function pdf_err(n, msg) + local m = node.new(8,16) + m.mode, m.token = 2, '%% Warning: '..msg + node.insert_after(n, n, m) +end + +-- 1 infrastructure and bookkeeping + +local function tagging_enabled() + return tex.count['writedocumentstructure'] > 0 +end + +local function spaces_enabled() + return tex.count['writehyphensandspaces'] > 0 +end + +-- we start with the document at the top level +local structure = { { + index = 1, + struct = 'Document', + children = { }, +} } + +-- the parent tree will be filled automatically and separately +local parent_tree = { } -- list of list of structure elements + +-- Our four helper attributes +local current_struct = alloc.new_attribute('tagging:current:se') +local current_order = alloc.new_attribute('tagging:element:order') +local current_status = alloc.new_attribute('tagging:current:status') +local current_lang = alloc.new_attribute('tagging:current:language') + +local function current_structure_element() + return structure[tex.attribute[current_struct]] +end + +-- Marker whatsits +local marker_whatsit = alloc.new_whatsit('tagged pdf marker') + +local function make_whatsit(t, se, order) + local n = node.new(8, 8); -- user_defined + n.user_id, n.type = marker_whatsit, 108 + n[current_struct] = se or tex.attribute['tagging:current:se'] + n[current_order] = order or tex.attribute['tagging:element:order'] + n.value = t + return n +end + +alloc.luadef('tagging:mci:content', function() + node.write(make_whatsit{ what = 'content' }) +end, 'protected') +alloc.luadef('tagging:mci:markstart', function() + node.write(make_whatsit{ what = 'mci_start' }) +end, 'protected') +alloc.luadef('tagging:mci:markstop', function() + node.write(make_whatsit{ what = 'mci_stop' }) +end, 'protected') +alloc.luadef('tagging:art:markstart', function() + node.write(make_whatsit{ what = 'art_start', it = token.scan_string() }) +end, 'protected') +alloc.luadef('tagging:art:markstop', function() + node.write(make_whatsit{ what = 'art_stop' }) +end, 'protected') + +-- 1 structure type table and type aliases + +local structure_types = alloc.saved_table('structure types', { + -- grouping types that may carry a bookmark + Document = { type = 'section' }, + Part = { type = 'section' }, + Art = { type = 'section' }, + Sect = { type = 'section' }, + Div = { type = 'group' }, + -- grouping elements + BlockQuote = { type = 'group' }, + Caption = { type = 'group' }, + TOC = { type = 'group', contains = { TOC=1, TOCI=1, Caption=1 } }, + TOCI = { contains = { Lbl=1, Reference=1, P=1, NonStruct=1, TOC=1 }, contained = { TOC=1 } }, + Index = { type = 'group' }, + NonStruct = { type = 'none' }, -- meaningless grouping + -- illustration elements + Figure = { type = 'inline', needsalt=1 }, + Formula = { type = 'inline', needsalt=1 }, + Form = { type = 'inline', needsalt=1 }, + -- block level structure elements (BLSE) + P = { type = 'block' }, + H = { type = 'block' }, + H1 = { type = 'block' }, -- for nonhierarchical tagging (do not use?) + H2 = { type = 'block' }, -- __________ „ __________ + H3 = { type = 'block' }, -- __________ „ __________ + H4 = { type = 'block' }, -- __________ „ __________ + H5 = { type = 'block' }, -- __________ „ __________ + H6 = { type = 'block' }, -- __________ „ __________ + L = { type = 'block', contains = { LI=1, Caption=1 } }, + LI = { contains = { Lbl=1, LBody=1 }, contained = { L=1 } }, + LBody = { type = 'contain', contained = { LI=1 } }, + Lbl = { type = 'block', contained = { Note=1, Bibentry=1, LI=1, TOCI=1 } }, + -- tables (neither BLSE nor ILSE) + Table = { type = 'block', contains = { TR=1, THead=1, TBody=1, TFoot=1, Caption=1 } }, + THead = { contains = { TR=1 }, contained = { Table=1 } }, + TBody = { contains = { TR=1 }, contained = { Table=1 } }, + TFoot = { contains = { TR=1 }, contained = { Table=1 } }, + TR = { contains = { TH=1, TD=1 }, contained = { THead=1, TBody=1, TFoot=1 } }, + TH = { type = 'contain', contained = { TR=1 } }, -- header cell + TD = { type = 'contain', contained = { TR=1 } }, -- data cell + -- inline structure elements ILSE + Span = { type = 'inline' }, + Quote = { type = 'inline' }, + Note = { type = 'inline' }, + Reference = { type = 'inline' }, + BibEntry = { type = 'inline' }, + Code = { type = 'inline' }, + Link = { type = 'inline' }, -- contains link objects + Annot = { type = 'inline' }, -- contains other annotations + -- ruby/warichu + Ruby = { type = 'inline', contains = { RB=1, RT=1, RP=1 } }, + RB = { contains = { }, contained = { Ruby=1 } }, + RT = { contains = { }, contained = { Ruby=1 } }, + RP = { contains = { }, contained = { Ruby=1 } }, + Warichu = { type = 'inline', contains = { WT=1, WP=1 } }, + WT = { contains = { }, contained = { Warichu=1 } }, + WP = { contains = { }, contained = { Warichu=1 } }, +}) + +local structure_type_compatibility = { + none = { none=1, section=1, group=1, block=1, inline=1, contain=1 }, + section = { none=1, section=1, group=1, block=1 }, + group = { none=1, group=1, block=1 }, + block = { none=1, inline=1 }, + inline = { none=1, inline=1 }, + contain = { none=1, inline=1, block=1, group=1 }, +} + +local function check_structure_compatibility(parent, child, childtype) + local p, c = structure_types[parent], structure_types[child] + childtype = childtype or c.type + if p.contains then + return p.contains[child] + elseif c.contained then + return c.contained[parent] + elseif childtype == 'section' and parent == child then + return false + else + return structure_type_compatibility[p.type][childtype] + end +end + +local function determine_parent_node(current, se) + local c = current + if tex.count['strictstructuretagging'] > 0 then + c = check_structure_compatibility(c.struct, se.struct, c.type) and c + else + while c and not check_structure_compatibility(c.struct, se.struct, c.type) do + c = c.parent + end + end + if not c then + alloc.err('Structure type mismatch: %s in %s', se.struct, current.struct) + end + return c or current +end + +function M.add_structure_alias(stype, alias, settings) + local t, attr = { }, { } + local info = structure_types[stype] or + alloc.err('Unknown structure type ‘%s’', stype) and {} + for k, v in pairs(info) do t[k] = v end + for k,v in pairs(t.attributes or {}) do attr[k] = v end -- copy to new table + t.attributes = nil + for k, v in pairs(settings or {}) do t[k] = v end -- may re-set attributes + for k,v in pairs(t.attributes or {}) do attr[k] = v end + if #attr > 0 then t.attributes = attr end + for _, s in pairs(structure_types) do + if s.contains and s.contains[stype] then s.contains[alias] = 1 end + if s.contained and s.contained[stype] then s.contained[alias] = 1 end + end + t.aliasof, t.inuse = t.aliasof or stype, false + structure_types[alias] = t +end + +alloc.luadef('struct:addalias', function() + local settings = load(token.scan_string())() + local stype = token.scan_string() + local alias = token.scan_string() + M.add_structure_alias(stype, alias, settings) +end, 'protected') + + +-- 1 writing the document structure + +local function stable_sort_on_order_field(unsorted) + -- n.b. ‘unsorted’ is likely to closely resemble two concatenated + -- monotonously increasing lists. + local sorted, oldsorted = { unsorted[1] }, nil + local i, next, c = 2, unsorted[2], nil + while next do + oldsorted, sorted = sorted, { } + for _, e in ipairs(oldsorted) do + c = e.order + while next and next.order < c do + table.insert(sorted, next); + i = i+1; next = unsorted[i] + end + table.insert(sorted, e); + end + while next and c <= next.order do + c = next.order + table.insert(sorted, next); + i = i+1; next = unsorted[i] + end + end + return sorted +end + +local function format_K_array(se) + local res = { '[' } + for _, k in ipairs(stable_sort_on_order_field(se.children)) do + se.mainpage = se.mainpage or k.pageobj + if k.mcid then + if se.mainpage == k.pageobj then + insert_formatted(res, '%d', k.mcid) + else + insert_formatted(res, '<< /Type/MCR /Pg %d 0 R /MCID %d >>', k.pageobj, k.mcid) + end + elseif k.children then + insert_formatted(res, '%s 0 R', k.objnum) + else + if se.mainpage == k.pageobj then + insert_formatted(res, '<< /Type/OBJR /Obj %d 0 R >>', k.objnum) + else + insert_formatted(res, '<< /Type/OBJR /Obj %d 0 R /Pg %s 0 R >>', k.objnum, k.pageobj) + end + end + end + table.insert(res, ']') + return table.concat(res, ' ') +end + +local function make_rolemap() + local aliases = { } + for k, v in pairs(structure_types) do + if v.aliasof and v.inuse and v.aliasof ~= k then + insert_formatted(aliases, '/%s/%s', k, v.aliasof) + end + end + if #aliases > 0 then + return '\n/RoleMap << ' .. table.concat(aliases,' ') .. ' >>' + end + return '' +end + +local function write_structure() + if #structure == 1 then return end + -- reserve object numbers, prepare for writing + local root_obj, parent_tree_obj = pdf.reserveobj(), pdf.reserveobj() + structure[1].parent = { objnum = root_obj } + for _, se in ipairs(structure) do + if not se.hidden then se.objnum = pdf.reserveobj() end + end + -- update the document catalog + add_to_catalog('/MarkInfo << /Marked true >>') + add_to_catalog('/StructTreeRoot %s 0 R', root_obj) + -- write the structure tree root + pdf.immediateobj(root_obj, string.format( + '<< /Type/StructTreeRoot /K %d 0 R /ParentTree %d 0 R%s >>', + structure[1].objnum, parent_tree_obj, make_rolemap())) + -- write structure elements + for _, se in ipairs(structure) do + if not se.hidden then + local res = { '<<' } + insert_formatted(res, '/Type/StructElem /S/%s /P %d 0 R', se.struct, se.parent.objnum) + if se.lang and se.lang ~= se.parent.lang then insert_formatted(res, '/Lang (%s)', se.lang) end + if se.alt then insert_formatted(res, '/Alt %s', M.pdf_string(se.alt)) end + if se.actual then insert_formatted(res, '/ActualText %s', M.pdf_string(se.actual)) end + if #se.children > 0 then insert_formatted(res, '\n/K %s', format_K_array(se)) end + if se.mainpage then insert_formatted(res, '/Pg %d 0 R', se.mainpage) end + if se.attributes then + table.insert(res, '/A <<') + for k,v in pairs(se.attributes) do insert_formatted(res, '/%s %s', k, v) end + table.insert(res, '>>') + end + if se.files then + table.insert(res, '/AF [') + for _, fs in ipairs(se.files) do + insert_formatted(res, '%d 0 R', fs) + end + table.insert(res, ']') + end + table.insert(res, '>>') + pdf.immediateobj(se.objnum, table.concat(res, ' ')) + end + end + -- write the parent tree + local res = { '<< /Nums [' } + for i, parents in ipairs(parent_tree) do + -- i, parents = StructParents index + 1, list of structure elems + if type(parents) == 'number' then + table.insert(res, string.format('%d %d 0 R', i-1, structure[parents].objnum)) + else + local entry = { } + insert_formatted(entry, '%d [', i-1) + for _, p in ipairs(parents) do + insert_formatted(entry, '%d 0 R', p.objnum) + end + table.insert(entry, ']') + table.insert(res, table.concat(entry, ' ')) + end + end + table.insert(res, '] >>') + pdf.immediateobj(parent_tree_obj, table.concat(res, '\n')) +end + +-- 1 languages + +-- name|code ↦ nr & nr ↦ code +local language_codes = alloc.saved_table('language codes') + +function M.set_language_code(name, code) + if not language_codes[code] then + table.insert(language_codes, code) + language_codes[code] = #language_codes + end + language_codes[name] = language_codes[code] +end + +if not language_codes.sumerian then + local defaults = require('minim-languagecodes') + for name, code in pairs(defaults) do + M.set_language_code(name, code) + end +end + +-- \setlanguagecode dutch nl_BE +alloc.luadef('setlanguagecode', function() + M.set_language_code(token.scan_string(), token.scan_string()) +end, 'protected') + +function M.get_language_code(name_or_code) + local nr = language_codes[name_or_code] + return nr and language_codes[nr] or name_or_code +end + +function M.get_current_language_code() + return language_codes[tex.attribute['tagging:current:language']] +end + +function M.set_document_language(code) + structure[1].lang = code +end + +alloc.luadef('setdocumentlanguage', function() + M.set_document_language(M.get_language_code(token.scan_string())) +end, 'protected') + +-- \uselanguage patch; provide default document language +-- and associate names with numbers +alloc.luadef('minim:apply:language:code', function() + local name = token.scan_string() + local nr = language_codes[name] or alloc.err('No language code known for ‘%s’', name) and 1 + if not structure[1].lang then M.set_document_language(language_codes[nr]) end + tex.sprint(nr) +end) + +local function write_language() + add_to_catalog('/Lang (%s)', structure[1].lang or 'und') +end + +-- 1 marking structure elements + +local function get_ancestor_of_type(se, stype) + while se and se.struct ~= stype do + if se.level == -1000 then return end + se = se.parent + end + return se and se.parent +end + +function M.close_structure_node(stype, raiseerror) + local strict = tex.count['strictstructuretagging'] > 0 + local current, open = current_structure_element(), false + if strict then + open = current.struct == stype and current.parent + else + open = get_ancestor_of_type(current, stype) + end + if open then + tex.setattribute(current_struct, open.index) + elseif strict then + alloc.err('Cannot close structure element %s: %s still open', stype, current.struct) + elseif raiseerror then + alloc.err('Cannot close structure element %s: no open tag found', stype) + end +end + +alloc.luadef('tagging:stopelement', function() + M.close_structure_node(token.scan_string(), true) +end, 'protected') + +alloc.luadef('tagging:ensurestopelement', function() + M.close_structure_node(token.scan_string(), false) +end, 'protected') + +function M.open_structure_node(n) + local info = structure_types[n.struct] + if not info then + alloc.err('Unknown structure type %s replaced by NonStruct', n.struct) + n.struct, info = 'NonStruct', structure_types.NonStruct + end + info.inuse = true + if info.attributes then + n.attributes = n.attributes or { } + for k,v in pairs(info.attributes) do + n.attributes[k] = n.attributes[k] or v + end + end + if n.block then + n.type = 'block' + n.attributes = n.attributes or { } + n.attributes.Placement = '/Block' + elseif n.inline then + n.type = 'inline' + n.attributes = n.attributes or { } + n.attributes.Placement = '/Inline' + end + n.index = #structure + 1 + n.children = { } + -- order and parent can be forced (needed for asynchronous elements) + n.order = n.order or tex.getattribute(current_order) + n.parent = (n.parent and structure[n.parent]) + or determine_parent_node(current_structure_element(), n) + table.insert(structure, n) + if n.lang then + n.lang = get_language_code(n.lang) + else + local lang = tex.getattribute(current_lang) + n.lang = language_codes[lang] + end + if not n.hidden then table.insert(n.parent.children, n) end + if not n.async then tex.setattribute(current_struct, #structure) end +end + +alloc.luadef('tagging:startelement', function() + local s = M.options_scanner() + :string('type') -- 'section', 'group', 'block' etc. + :argument('alt') + :argument('actual') + :string('lang') + :keyword('block') + :keyword('inline') + :scan() + s.struct = token.scan_string() + M.open_structure_node(s) +end, 'protected') + +alloc.luadef('tagging:alt', function() + current_structure_element().alt = token.scan_string() +end) + +alloc.luadef('tagging:actual', function() + current_structure_element().actual = token.scan_string() +end) + + +-- 1 marking content items + +-- All content items should be explicitly opened and closed, per page, by the +-- late_lua nodes constructed beneath. After the page is finished, the MCIDs +-- will be stored in the parent tree entry associated to the page. + +-- For the insertion of these late_lua nodes, see the next section; this one +-- only deals with the bookkeeping mechanics. + +-- Note that we do not nest artifacts within marked content items. This would +-- be allowed by the spec, but confuses some pdf readers (e.g. poppler). + +local mcid_list, pageobj + +local function clear_page_tagging_parameters() + mcid_list, pageobj = { }, nil + _open_mci_node_ = function (se_num, order) + pageobj = pageobj or pdf.getpageref(status.total_pages + 1) + local se = structure[se_num] + pdf.print(string.format('/%s <</MCID %d>> BDC ', se.struct, #mcid_list)) + table.insert(se.children, { mcid = #mcid_list, order = order, pageobj = pageobj }) + table.insert(mcid_list, se) + -- unhide hidden parents (done here to preserve the correct order) + if se.hidden then + table.insert(se.parent.children, se) + se.hidden = false + end + end +end +clear_page_tagging_parameters() + +local function new_open_mci_node(se, order) + local n = node.new(8,7) -- late_lua + n.token = string.format('_open_mci_node_(%d, %d)', se, order) + return n +end + +local function new_open_art_node(atype) + local n = node.new(8,16) -- pdf_literal + n.mode, n.token = 1, string.format('/Artifact << /Type/%s >> BDC', atype) + return n +end + +local function new_emc_node() + local n = node.new(8,16) -- pdf_literal + n.mode, n.token = 1, 'EMC' + return n +end + +cb.register('finish_pdfpage', function(shippingout) + if shippingout then + local pageattr = string.gsub(pdf.getpageattributes() or '', ' */StructParents %d+', '' ) + if #mcid_list > 0 then + pageattr = pageattr..string.format('/StructParents %d', #parent_tree) + table.insert(parent_tree, mcid_list) + end + pdf.setpageattributes(pageattr) + clear_page_tagging_parameters() + end +end) + +-- 1 content item boundaries and linking + +function M.mark_content_items(box) + local se, order, open + local start_node, end_node, parent_node + local pageobj = pdf.getpageref(status.total_pages + 1) + -- inserting mci markers + local insert_tags = function(end_parent) + if not open then return end + parent_node.list = node.insert_before(parent_node.list, start_node, open) + node.insert_after(end_parent.list, end_node, new_emc_node()) + start_node, end_node, parent_node, open = nil, nil, nil, nil + end + local start_content = function(n, b) + se, order = n[current_struct], n[current_order] + start_node, end_node, parent_node = n, n, b + end + -- traversing all nodes + for n, b in full_traverse(box) do + local marker = n.id == 8 and n.subtype == 8 + and n.user_id == marker_whatsit and n.value + if marker and marker.what == 'art_start' then + -- first we start with marking artifacts + insert_tags(b); + start_content(n, b) + open = new_open_art_node(marker.it) + elseif marker and marker.what == 'art_stop' then + end_node = n + insert_tags(b); + elseif n[current_status] then + -- inside artifact, do nothing + elseif n.id == 8 and n.subtype == 19 then -- pdf_start_link + -- then attach links to Link elements + local _se, _order = n[current_struct], n[current_order] + local link = structure[_se] + if link.struct == 'Link' then + table.insert(link.children, { objnum = n.objnum, order = _order, pageobj = pageobj }) + n.link_attr = string.format('%s /StructParent %d', n.link_attr, #parent_tree) + table.insert(parent_tree, _se) + else + alloc.err('Link found outside Link structure element (see pdf)') + pdf_err(n, 'unmarked link') + end + elseif marker and marker.what == 'mci_start' then + -- explicit start and stop nodes + insert_tags(b); + start_content(n, b) + open = new_open_mci_node(se, order) + elseif marker and marker.what == 'mci_stop' then + end_node = end_node and n; insert_tags(b) + elseif n.id == 2 or n.id == 29 -- rule, glyph or content marker + or marker and marker.what == 'content' then + -- now see if we need to intervene between content nodes + local _se, _order = n[current_struct], n[current_order] + if n.id == 2 and (n.width == 0 or n.height == 0 and n.depth == 0) then + -- ignore invisible rules + elseif not _se or not _order then + -- unmarkable content should not be possible (but is) + alloc.err('Unmarkable content on page %d (see pdf)', status.total_pages + 1) + pdf_err(n, 'possible unmarked content') + elseif se ~= _se or order ~= _order or not end_node then + -- new content item on changing attributes + insert_tags(b); + start_content(n, b) + open = new_open_mci_node(se, order) + else + -- nothing changed: continue current mci + end_node = n + end + elseif n.id == 12 and n.subtype > 2 and n.subtype < 8 then + -- parskip or displayskip always closes mci + insert_tags(b) + end + end + -- close the last mci + if start_node and start_node.user_id and start_node.value + and start_node.value.what == 'art_start' then + alloc.err('Page %d ends with a broken artifact', status.total_pages+1) + end + insert_tags(box) +end + +cb.register('pre_shipout', function(nr) + if tagging_enabled() then + M.mark_content_items(tex.box[nr]) + end +end) + +-- 1 destinations + +local dest_count = alloc.new_count('link dest count') +local function new_dest_name() + local dest_nr = tex.count[dest_count] + 1 + tex.setcount('global', dest_count, dest_nr) + return 'hyper:dest:'..dest_nr +end + +local function last_dest_name() + return 'hyper:dest:'..tex.count[dest_count] +end + +local function write_new_dest() + local name = new_dest_name() + tex.sprint(string.format('\\nameddestination{%s}', name)) + return name +end + +-- make available as \newdestinationname, \lastdestinationname +alloc.luadef('newdestinationname', function() tex.sprint(new_dest_name()) end) +alloc.luadef('lastdestinationname', function() tex.sprint(last_dest_name()) end) + +-- 1 bookmarks + +-- start with a dummy top-level bookmark +local bookmarks = { { count = 0 } } +structure[1].bookmark = bookmarks[1] + +local function write_bookmarks() + if #bookmarks == 1 then return end + -- reserve objects in a row + for i=1, #bookmarks do + bookmarks[i].outline_obj = pdf.reserveobj() + end + -- write outlines object + add_to_catalog('/Outlines %s 0 R', bookmarks[1].outline_obj) + pdf.immediateobj(bookmarks[1].outline_obj, string.format( + ' <</Type /Outlines /First %s 0 R /Last %s 0 R /Count %s >> ', + bookmarks[2].outline_obj, bookmarks[1].last.outline_obj, #bookmarks - 1)) + -- write bookmark objects + for i=2, #bookmarks do + local bm, res = bookmarks[i], { } + insert_formatted(res, '<< /Title %s\n/Parent %s 0 R /Dest (%s)', M.pdf_string(bm.title), + bm.parent and bm.parent.outline_obj or bookmarks[1].outline_obj, bm.dest) + if bm.next then insert_formatted(res, '/Next %s 0 R', bm.next.outline_obj) end + if bm.prev then insert_formatted(res, '/Prev %s 0 R', bm.prev.outline_obj) end + if bm.struct and bm.struct.objnum then + insert_formatted(res, '/SE %s 0 R', bm.struct.objnum) + end + if bm.count > 0 then + insert_formatted(res, '/First %s 0 R /Last %s 0 R /Count %s%s', + bm.first.outline_obj, bm.last.outline_obj, bm.open and '' or '-', bm.count) + end + pdf.immediateobj(bm.outline_obj, table.concat(res,'\n')..' >>') + end +end + +function M.add_bookmark(bm) + local se = current_structure_element() + bm.parent, bm.count = false, 0 + bm.dest = bm.dest or write_new_dest() + se.bookmark, bm.struct = bm, se + -- find and update the parent bookmarks + local p = se.parent + while p do + local pbm = p.bookmark + if pbm then + if not bm.parent then + if pbm.last then pbm.last.next = bm end + bm.parent, bm.prev = pbm, pbm.last + pbm.first, pbm.last = pbm.first or bm, bm + end + -- update the count until the first closed bookmark + pbm.count = pbm.count + 1 + p = pbm.open and pbm.parent + else + p = p.parent + end + end + table.insert(bookmarks, bm) +end + +alloc.luadef('outline', function() + local s = M.options_scanner() + :keyword('open') + :keyword('closed') -- default; ignored + :string('dest') + :scan() + s.title = token.scan_string() + M.add_bookmark(s) +end, 'protected') + + +-- 1 associated files + +local attached_files = { } +local function write_fileattachments() + if #attached_files > 0 then + table.sort(attached_files, function(a,b) return a.name < b.name end) + local afs = { '/AF [' } + local nms = { '<< /Names [' } + for _, t in ipairs(attached_files) do + insert_formatted(afs, '%d 0 R', t.objnum) + insert_formatted(nms, '%s %d 0 R', t.name, t.objnum) + end + table.insert(afs, ']') + table.insert(nms, '] >>') + add_to_catalog(table.concat(afs, ' ')) + local efs = pdf.immediateobj(table.concat(nms, ' ')) + add_to_names('/EmbeddedFiles %d 0 R', efs) + end +end + +function M.embed_file(t) + -- default moddate + if not t.moddate then + tex.runtoks(function() tex.print('\\csname minim:makedefaultmoddate\\endcsname') end) + local moddate = tex.toks['embeddedfiles:moddate'] + if moddate ~= '' then t.moddate = moddate end + end + -- embed the file + local attr = { '/Type/EmbeddedFile' } + if t.mimetype then insert_formatted(attr, '/Subtype/%s', string.gsub(t.mimetype, '/', '#2F')) end + if t.moddate then insert_formatted(attr, '/Params << /ModDate %s >>', M.pdf_date(t.moddate)) end + local ef = pdf.obj { + immediate = true, + compresslevel = t.uncompressed and 0 or nil, + type = 'stream', + file = t.file and kpse.find_file(t.file), + string = t.string, + attr = table.concat(attr, ' ') } + -- write the filespec + local res = { '<< /Type/Filespec' } + insert_formatted(res, '/F %s /UF %s /EF << /F %d 0 R /UF %d 0 R >>', t.name, t.name, ef, ef) + if t.desc then insert_formatted(res, '/Desc %s', M.pdf_string(t.desc)) end + if t.relation then insert_formatted(res, '/AFRelationship /%s', t.relation) end + table.insert(res, '>>') + local fs = pdf.immediateobj(table.concat(res, ' ')) + -- (globally) attach the file + return fs, ef +end + +alloc.luadef('embedfile', function() + local t = M.options_scanner() + :string('desc') + :string('file') + :string('string') + :string('mimetype') + :string('moddate') + :string('relation') + :string('name') + :keyword('global') + :keyword('uncompressed') + :scan() + if not t.name then + t.name = t.file or aloc.err('No name specified for embedded file stream') + end + t.name = M.pdf_string(t.name or '(unnamed)') + local fs, ef = M.embed_file(t, t.global) + -- where to attach? + if t.global then + table.insert(attached_files, { name = t.name, objnum = fs } ) + elseif tagging_enabled() then + local se = current_structure_element() + se.files = se.files or { } + table.insert(se.files, fs) + else + alloc.err('In untagged pdf, \\embedfile must be used with ‘global’ keyword') + table.insert(attached_files, { name = t.name, objnum = fs } ) + end +end, 'protected') + + +-- 1 hyphenation + +function M.mark_discretionaries(head, gc) + if not spaces_enabled() then return end + for disc in node.traverse_id(7, head) do + if disc.subtype ~= 2 then -- ‘automatic’ (exclude explicit hyphens) + local pre, post, replace = disc.pre, disc.post, disc.replace + local se, order = disc[current_struct], disc[current_order] + -- get the replacement text + local actual = { } + for c in node.traverse_id(29, replace) do + table.insert(actual, c.char) + end + -- special case: a single hyphen + if #actual == 0 and pre.char and pre.char == 0x2D then + actual = '' -- soft hyphen U+00AD + elseif #actual > 0 then + actual = string.utfcharacter(table.unpack(actual)) + else + goto continue + end + -- reserve a structure element (but hide it) + M.open_structure_node { hidden = true, async = true, + struct = 'Span', parent = se, order = order, actual = actual } + -- apply the new se to pre and post + for n, _ in full_traverse(pre) do n[current_struct] = #structure end + for n, _ in full_traverse(past) do n[current_struct] = #structure end + end + ::continue:: + end + return true +end + +cb.register('pre_linebreak_filter', M.mark_discretionaries) + +-- 1 spaces + +local space_attr = alloc.new_attribute('space insertion marker') + +function M.mark_spaces(head, gc) + if not spaces_enabled() then return end + for g in node.traverse_id(12, head) do -- glue + if g.prev and g.subtype == 13 or g.subtype == 14 then -- (x)spaceskip + local p = g.prev + p[space_attr] = p.id == 29 and p.font + or g.next and g.next.id == 29 and g.next.font + or font.current() + end + end + return true +end + +local function create_space(n) + local s = node.new(29) -- glyph + s.char, s.font, s.attr = 0x20, n[space_attr], n.attr + local b = node.hpack(s) + b.width, b.height, b.depth = 0, 0, 0 + return b +end + +local function insert_spaces(head) + for n in node.traverse(head) do + if n[space_attr] then + node.insert_after(head, n, create_space(n)) + end + end +end + +function M.insert_spaces(head, gc) + if not spaces_enabled() then return end + for line in node.traverse_id(0, head) do + insert_spaces(line.head) + end + return true +end + +function M.mark_and_insert_spaces(head, gc, ...) + if not spaces_enabled() then return end + M.mark_spaces(head, gc) + insert_spaces(head) + return true +end + +cb.register('pre_linebreak_filter', M.mark_spaces) +cb.register('post_linebreak_filter', M.insert_spaces) +cb.register('hpack_filter', M.mark_and_insert_spaces) + +-- 1 page labels + +local pagelabels = { } + +function M.page_labels(abs_nr, nr, style, prefix) + table.insert(pagelabels, { start = abs_nr, st = nr, s = style, p = prefix }) +end + +local pagelabel_styles = { + Decimal = 'D', + Roman = 'R', + roman = 'r', + Alphabetic = 'A', + alphabetic = 'a', + none = false } + +alloc.luadef('setpagelabels', function() + local prefix = token.scan_keyword('prefix') and token.scan_string() + local style = token.scan_word() + local nr = token.scan_int() + local st = pagelabel_styles[style] + if st == nil then alloc.err('Unknown page label style »%s«', style) end + M.page_labels(status.total_pages, nr, st, prefix) +end, 'protected') + +local function write_pagelabels() + if #pagelabels == 0 then return end + if pagelabels[1].start ~= 0 then + table.insert(pagelabels, 1, { start = 0, st = 1, s = 'D' }) + end + local res = { '/PageLabels << /Nums [' } + for _, l in ipairs(pagelabels) do + local s, p = l.s and '/S/'..l.s, l.p and ' /P '..M.pdf_string(l.p) + table.insert(res, string.format('%d << /St %d %s%s >>', l.start, l.st, s or '', p or '')) + end + table.insert(res, ']') + add_to_catalog(table.concat(res, ' ')) +end + +-- 1 output intents + +local output_intents = { } + +function M.add_output_intent(t) + table.insert(output_intents, t) +end + +local function write_output_intents() + if #output_intents == 0 then return end + local res = { '[' } + for _, oi in ipairs(output_intents) do + local p = pdf.obj { type = 'stream', + file = kpse.find_file(oi.path), + compresslevel = 9, + attr = string.format('/N %d', oi.N), + immediate = true } + insert_formatted(res, '<< /Type/OutputIntent /DestOutputProfile %d 0 R', p) + insert_formatted(res, '/S/%s /OutputConditionIdentifier (%s)', oi.subtype, oi.id) + if oi.info then insert_formatted(res, '/Info (%s)', oi.info) end + if oi.condition then insert_formatted(res, '/OutputCondition (%s)', oi.condition) end + if oi.registry then insert_formatted(res, '/RegistryName (%s)', oi.registry) end + table.insert(res, '>>') + end + table.insert(res, ']') + local objnum = pdf.immediateobj(table.concat(res, ' ')) + add_to_catalog('/OutputIntents %d 0 R', objnum) +end + +function M.add_default_rgb_output_intent(t) + t = t or {} + M.add_output_intent { + subtype = t.subtype or 'GTS_PDFA1', + info = t.info or 'IEC 61966-2.1 Default RGB colour space - sRGB', + condition = t.condition, + registry = 'http://www.iev.ch', + id = 'IEC sRGB', + N = 3, path = 'sRGB.icc' } +end + +function M.add_default_cmyk_output_intents(t) + t = t or {} + M.add_output_intent { + subtype = t.subtype or 'GTS_PDFA1', + info = t.info or 'FOGRA39L Coated', + condition = t.condition or 'FOGRA39L Coated', + registry = 'http://www.fogra.org', + id = 'Offset printing, according to ISO 12647-2:2004/Amd 1, OFCOM, paper type 1 or 2 = coated art, 115 g/m2, tone value increase curves A (CMY) and B (K)', + N = 4, path = 'FOGRA39L_coated.icc' } +end + +alloc.luadef('minim:default:rgb:profile', function() M.add_default_rgb_output_intent() end) +alloc.luadef('minim:default:cmyk:profile', function() M.add_default_cmyk_output_intent() end) + +-- + +cb.register ('finish_pdffile', function() + if tagging_enabled() then + write_language() + write_structure() + end + write_bookmarks() + write_pagelabels() + write_output_intents() + write_fileattachments() +end) + +return M + |