lpdf-xmp.lmt /size: 15 Kb    last modification: 2021-10-28 13:51
1if not modules then modules = { } end modules ['lpdf-xmp'] = {
2    version   = 1.001,
3    comment   = "companion to lpdf-ini.mkiv",
4    author    = "Hans Hagen, PRAGMA-ADE, Hasselt NL",
5    copyright = "PRAGMA ADE / ConTeXt Development Team",
6    license   = "see context related readme files",
7    comment   = "with help from Peter Rolf",
8}
9
10local tostring, type = tostring, type
11local format, gsub, match = string.format, string.gsub, string.match
12local concat = table.concat
13local settings_to_array = utilities.parsers.settings_to_array
14local utfchar = utf.char
15local xmlfillin = xml.fillin
16local md5HEX = md5.HEX
17local osdate, ostime, ostimezone, osuuid = os.date, os.time, os.timezone, os.uuid
18
19local trace_xmp  = false  trackers.register("backend.xmp",  function(v) trace_xmp  = v end)
20local trace_info = false  trackers.register("backend.info", function(v) trace_info = v end)
21
22local report_xmp  = logs.reporter("backend","xmp")
23local report_info = logs.reporter("backend","info")
24
25local backends             = backends
26local pdfbackend           = backends.registered.pdf
27local codeinjections       = pdfbackend.codeinjections
28
29local lpdf                 = lpdf
30local pdfdictionary        = lpdf.dictionary
31local pdfconstant          = lpdf.constant
32local pdfunicode           = lpdf.unicode
33local pdfstring            = lpdf.string
34local pdfreference         = lpdf.reference
35local pdfflushstreamobject = lpdf.flushstreamobject
36
37-- The XMP packet wrapper is kind of fixed, see page 10 of XMPSpecificationsPart1.pdf from
38-- XMP-Toolkit-SDK-CC201607.zip. So we hardcode the id.
39
40local xpacket = format ( [[
41<?xpacket begin="%s" id="W5M0MpCehiHzreSzNTczkc9d"?>
42
43%%s
44
45<?xpacket end="w"?>]], utfchar(0xFEFF) )
46
47local unknown = { false, false }
48local mapping = table.setmetatableindex ( {
49    -- user defined keys (pdfx:)
50    ["ConTeXt.Jobname"]      = { "context", "rdf:Description/pdfx:ConTeXt.Jobname" },
51    ["ConTeXt.Time"]         = { "date",    "rdf:Description/pdfx:ConTeXt.Time" },
52    ["ConTeXt.Url"]          = { "context", "rdf:Description/pdfx:ConTeXt.Url" },
53    ["ConTeXt.Support"]      = { "context", "rdf:Description/pdfx:ConTeXt.Support" },
54    ["ConTeXt.Version"]      = { "context", "rdf:Description/pdfx:ConTeXt.Version" },
55    ["ConTeXt.LMTX"]         = { "context", "rdf:Description/pdfx:ConTeXt.LMTX" },
56    ["TeX.Support"]          = { "metadata","rdf:Description/pdfx:TeX.Support" },
57    ["LuaTeX.Version"]       = { "metadata","rdf:Description/pdfx:LuaTeX.Version" },
58    ["LuaTeX.Functionality"] = { "metadata","rdf:Description/pdfx:LuaTeX.Functionality" },
59    ["LuaTeX.LuaVersion"]    = { "metadata","rdf:Description/pdfx:LuaTeX.LuaVersion" },
60    ["LuaTeX.Platform"]      = { "metadata","rdf:Description/pdfx:LuaTeX.Platform" },
61    ["ID"]                   = { "id",      "rdf:Description/pdfx:ID" },                         -- has date
62    -- Adobe PDF schema
63    ["Keywords"]             = { "metadata","rdf:Description/pdf:Keywords" },
64    ["Producer"]             = { "metadata","rdf:Description/pdf:Producer" },
65 -- ["Trapped"]              = { "pdf",     "rdf:Description/pdf:Trapped" },                     -- '/False' in /Info, but 'False' in XMP
66    -- Dublin Core schema
67    ["Author"]               = { "metadata","rdf:Description/dc:creator/rdf:Seq/rdf:li" },
68    ["Format"]               = { "metadata","rdf:Description/dc:format" },                       -- optional, but nice to have
69    ["Subject"]              = { "metadata","rdf:Description/dc:description/rdf:Alt/rdf:li" },
70    ["Title"]                = { "metadata","rdf:Description/dc:title/rdf:Alt/rdf:li" },
71    -- XMP Basic schema
72    ["CreateDate"]           = { "date",    "rdf:Description/xmp:CreateDate" },
73    ["CreationDate"]         = { "date",    "rdf:Description/xmp:CreationDate" },                -- dummy
74    ["Creator"]              = { "metadata","rdf:Description/xmp:CreatorTool" },
75    ["MetadataDate"]         = { "date",    "rdf:Description/xmp:MetadataDate" },
76    ["ModDate"]              = { "date",    "rdf:Description/xmp:ModDate" },                     -- dummy
77    ["ModifyDate"]           = { "date",    "rdf:Description/xmp:ModifyDate" },
78    -- XMP Media Management schema
79    ["DocumentID"]           = { "id",      "rdf:Description/xmpMM:DocumentID" },                -- uuid
80    ["InstanceID"]           = { "id",      "rdf:Description/xmpMM:InstanceID" },                -- uuid
81    ["RenditionClass"]       = { "pdf",     "rdf:Description/xmpMM:RenditionClass" },            -- PDF/X-4
82    ["VersionID"]            = { "pdf",     "rdf:Description/xmpMM:VersionID" },                 -- PDF/X-4
83    -- additional entries
84    -- PDF/X
85    ["GTS_PDFXVersion"]      = { "pdf",     "rdf:Description/pdfxid:GTS_PDFXVersion" },
86    -- optional entries
87    -- all what is visible in the 'document properties --> additional metadata' window
88    -- XMP Rights Management schema (optional)
89    ["Marked"]               = { "pdf",      "rdf:Description/xmpRights:Marked" },
90 -- ["Owner"]                = { "metadata", "rdf:Description/xmpRights:Owner/rdf:Bag/rdf:li" }, -- maybe useful (not visible)
91 -- ["UsageTerms"]           = { "metadata", "rdf:Description/xmpRights:UsageTerms" },           -- maybe useful (not visible)
92    ["WebStatement"]         = { "metadata", "rdf:Description/xmpRights:WebStatement" },
93    -- Photoshop PDF schema (optional)
94    ["AuthorsPosition"]      = { "metadata", "rdf:Description/photoshop:AuthorsPosition" },
95    ["Copyright"]            = { "metadata", "rdf:Description/photoshop:Copyright" },
96    ["CaptionWriter"]        = { "metadata", "rdf:Description/photoshop:CaptionWriter" },
97}, function() return unknown end )
98
99
100local metadata         = nil
101local trailerid        = true
102local creationdate     = false
103local modificationdate = false
104
105local function pdftimestamp(str)
106    local t = type(str)
107    if t == "string" then
108        local Y, M, D, h, m, s, Zs, Zh, Zm = match(str,"^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)([%+%-])(%d%d):(%d%d)$")
109        return Y and format("D:%s%s%s%s%s%s%s%s'%s'",Y,M,D,h,m,s,Zs,Zh,Zm)
110    else
111        return osdate("D:%Y%m%d%H%M%S",t == "number" and str or ostime()) -- maybe "!D..." : universal time
112    end
113end
114
115local function pdfgetmetadata()
116    if not metadata then
117        local contextversion      = environment.version
118        local luatexversion       = format("%1.2f",LUATEXVERSION)
119        local luatexfunctionality = tostring(LUATEXFUNCTIONALITY)
120        local jobname             = environment.jobname or tex.jobname or "unknown"
121        local documentid          = trailerid and ("uuid:" .. osuuid()) or "no unique document id here"
122        local instanceid          = trailerid and ("uuid:" .. osuuid()) or "no unique instance id here"
123        metadata = creationdate and {
124            producer            = format("LuaMetaTeX-%s",luatexversion),
125            creator             = format("LuaMetaTeX %s %s + ConTeXt LMTX %s",luatexversion,luatexfunctionality,contextversion),
126            luatexversion       = luatexversion,
127            contextversion      = contextversion,
128            luatexfunctionality = luatexfunctionality,
129            luaversion          = tostring(LUAVERSION),
130            platform            = os.platform,
131            creationdate        = creationdate,
132            modificationdate    = modificationdate,
133            id                  = format("%s | %s",jobname,creationdate),
134            documentid          = documentid,
135            instanceid          = instanceid,
136            jobname             = jobname,
137        } or {
138            producer            = "LuaMetaTeX",
139            creator             = "LuaMetaTeX + ConTeXt LMTX",
140            id                  = jobname,
141            documentid          = documentid,
142            instanceid          = instanceid,
143            jobname             = jobname,
144        }
145     -- inspect(metadata)
146    end
147    return metadata
148end
149
150local function pdfsetmetadate(n,both)
151    if n then
152        n = converters.totime(n)
153        if n then
154            creationdate = osdate("%Y-%m-%dT%H:%M:%S",ostime(n)) .. ostimezone()
155            if both then
156                modificationdate = creationdate
157            end
158        end
159    end
160    return creationdate
161end
162
163lpdf.pdftimestamp = pdftimestamp
164
165function lpdf.gettrailerid()
166    if trailerid == true then
167        return md5.HEX(osuuid())
168    elseif type(trailerid) == "string" then
169        return md5.HEX(trailerid)
170    else
171        return false
172    end
173end
174
175-- string: use that, true: uuid, false: nothing
176
177directives.register("backend.trailerid", function(v)
178    trailerid = type(v) and v or toboolean(v)
179end)
180
181-- year-mm-dd : use that for creation and modification
182
183local function setdates(v)
184    local t = type(v)
185    if t == "number" or t == "string" then
186        local d = converters.totime(v)
187        if d then
188            report_info("forced date/time information %a will be used",pdfsetmetadate(d,true))
189            return
190        end
191    end
192    if toboolean(v) then
193        creationdate     = osdate("%Y-%m-%dT%H:%M:%S") .. ostimezone()
194        modificationdate = creationdate
195    else
196        creationdate     = false
197        modificationdate = false
198    end
199end
200
201setdates(true)
202
203directives.register("backend.date", setdates)
204
205-- maybe some day we will load the xmp file at runtime
206
207local xmp, xmpfile, xmpname = nil, nil, "lpdf-pdx.xml"
208
209local function setxmpfile(name)
210    if xmp then
211        report_xmp("discarding loaded file %a",xmpfile)
212        xmp = nil
213    end
214    xmpfile = name ~= "" and name
215end
216
217codeinjections.setxmpfile = setxmpfile
218
219interfaces.implement {
220    name      = "setxmpfile",
221    arguments = "string",
222    actions   = setxmpfile
223}
224
225local function valid_xmp()
226    if not xmp then
227     -- local xmpfile = xmpfile or resolvers.findfile(xmpname) or ""
228        if xmpfile and xmpfile ~= "" then
229            xmpfile = resolvers.findfile(xmpfile) or ""
230        end
231        if not xmpfile or xmpfile == "" then
232            xmpfile = resolvers.findfile(xmpname) or ""
233        end
234        if xmpfile ~= "" then
235            report_xmp("using file %a",xmpfile)
236        end
237        local xmpdata = xmpfile ~= "" and io.loaddata(xmpfile) or ""
238        xmp = xml.convert(xmpdata)
239    end
240    return xmp
241end
242
243function lpdf.addxmpinfo(tag,value,check)
244    local pattern = mapping[tag][2]
245    if type(pattern) == "string" then
246        xmlfillin(xmp or valid_xmp(),pattern,value,check)
247    end
248end
249
250-- redefined
251
252local pdfaddtoinfo  = lpdf.addtoinfo
253local pdfaddxmpinfo = lpdf.addxmpinfo
254
255function lpdf.addtoinfo(tag,pdfvalue,strvalue)
256    local pattern = mapping[tag][2]
257    if pattern then
258        pdfaddtoinfo(tag,pdfvalue)
259    end
260    if type(pattern) == "string" then
261        local value = strvalue or gsub(tostring(pdfvalue),"^%((.*)%)$","%1") -- hack
262        if trace_info then
263            report_info("set %a to %a",tag,value)
264        end
265        xmlfillin(xmp or valid_xmp(),pattern,value,check)
266    end
267end
268
269local pdfaddtoinfo = lpdf.addtoinfo -- used later
270
271-- for the do-it-yourselvers
272
273function lpdf.insertxmpinfo(pattern,whatever,prepend)
274    xml.insert(xmp or valid_xmp(),pattern,whatever,prepend)
275end
276
277function lpdf.injectxmpinfo(pattern,whatever,prepend)
278    xml.inject(xmp or valid_xmp(),pattern,whatever,prepend)
279end
280
281-- flushing
282
283local add_xmp_blob   = true
284local indentity_done = false  -- using "setupidentity = function() end" fails as the meaning is frozen in register
285
286local function setupidentity()
287    if not done then
288        --
289        local identity = interactions.general.getidentity()
290        local title    = identity.title
291        local subtitle = identity.subtitle
292        local author   = identity.author
293        local date     = identity.date
294        local keywords = identity.keywords
295        --
296        if date and date ~= "" then
297            pdfsetmetadate(date)
298        end
299        if keywords then
300            keywords = concat(settings_to_array(keywords), " ")
301        end
302        --
303        local metadata       = pdfgetmetadata()
304        local creator        = metadata.creator
305        local contextversion = metadata.contextversion
306        local id             = metadata.id
307        local jobname        = metadata.jobname
308        local creator        = metadata.creator
309        local creation       = metadata.creationdate
310        local modification   = metadata.modificationdate
311        --
312        if creator then
313            pdfaddtoinfo("Creator",pdfunicode(creator),creator)
314        end
315        if creation then
316            pdfaddtoinfo("CreationDate",pdfstring(pdftimestamp(creation)),creation)
317        end
318        if modification then
319            pdfaddtoinfo("ModDate",pdfstring(pdftimestamp(modification)),modification)
320        end
321        if id then
322            pdfaddtoinfo("ID",pdfstring(id),id) -- needed for pdf/x
323        end
324        --
325        if title ~= "" then
326            pdfaddtoinfo("Title",pdfunicode(title),title)
327        end
328        if subtitle ~= "" then
329            pdfaddtoinfo("Subject",pdfunicode(subtitle),subtitle)
330        end
331        if author ~= "" then
332            pdfaddtoinfo("Author",pdfunicode(author),author) -- '/Author' in /Info, 'Creator' in XMP
333        end
334        if keywords and keywords ~= "" then
335            pdfaddtoinfo("Keywords",pdfunicode(keywords),keywords)
336        end
337        --
338        if contextversion then
339            pdfaddtoinfo("ConTeXt.Version",contextversion)
340        end
341        if creation then
342            pdfaddtoinfo("ConTeXt.Time",creation)
343        end
344        if jobname then
345            pdfaddtoinfo("ConTeXt.Jobname",jobname)
346        end
347        --
348        pdfaddtoinfo("ConTeXt.Url","www.pragma-ade.com")
349        pdfaddtoinfo("ConTeXt.Support","contextgarden.net")
350        pdfaddtoinfo("TeX.Support","tug.org")
351        --
352        done = true
353    else
354        -- no need for a message
355    end
356end
357
358local function flushxmpinfo()
359    commands.pushrandomseed()
360    commands.setrandomseed(ostime())
361
362    local metadata   = pdfgetmetadata()
363    local time       = metadata.time
364    local producer   = metadata.producer
365    local creator    = metadata.creator
366    local documentid = metadata.documentid
367    local instanceid = metadata.instanceid
368
369    pdfaddtoinfo("Producer",producer)
370    pdfaddtoinfo("Creator",creator)
371    pdfaddtoinfo("CreationDate",time)
372    pdfaddtoinfo("ModDate",time)
373
374    if add_xmp_blob then
375
376        pdfaddxmpinfo("DocumentID",documentid)
377        pdfaddxmpinfo("InstanceID",instanceid)
378        pdfaddxmpinfo("Producer",producer)
379        pdfaddxmpinfo("CreatorTool",creator)
380        pdfaddxmpinfo("CreateDate",time)
381        pdfaddxmpinfo("ModifyDate",time)
382        pdfaddxmpinfo("MetadataDate",time)
383        pdfaddxmpinfo("LuaTeX.Version",metadata.luatexversion)
384        pdfaddxmpinfo("LuaTeX.Functionality",metadata.luatexfunctionality)
385        pdfaddxmpinfo("LuaTeX.LuaVersion",metadata.luaversion)
386        pdfaddxmpinfo("LuaTeX.Platform",metadata.platform)
387
388        local blob = xml.tostring(xml.first(xmp or valid_xmp(),"/x:xmpmeta"))
389        local md = pdfdictionary {
390            Subtype = pdfconstant("XML"),
391            Type    = pdfconstant("Metadata"),
392        }
393        if trace_xmp then
394            report_xmp("data flushed, see log file")
395            logs.pushtarget("logfile")
396            report_xmp("start xmp blob")
397            logs.newline()
398            logs.writer(blob)
399            logs.newline()
400            report_xmp("stop xmp blob")
401            logs.poptarget()
402        end
403        blob = format(xpacket,blob)
404        if not verbose and lpdf.compresslevel() > 0 then
405            blob = gsub(blob,">%s+<","><")
406        end
407        local r = pdfflushstreamobject(blob,md,false) -- uncompressed
408        lpdf.addtocatalog("Metadata",pdfreference(r))
409
410    end
411
412    commands.poprandomseed() -- hack
413end
414
415lpdf.registerpagefinalizer(setupidentity,"identity")
416lpdf.registerdocumentfinalizer(flushxmpinfo,1,"metadata")
417
418directives.register("backend.xmp",        function(v) add_xmp_blob = v end)
419directives.register("backend.verbosexmp", function(v) verbose = v end)
420