idml 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/TODO.pdf/75-pdf-metadata-from-xmp.md +52 -90
- data/TODO.pdf/76-tagged-pdf-structure.md +63 -87
- data/TODO.pdf/77-pdfa-xmp-output-intent.md +51 -59
- data/TODO.pdf/78-hyperlinks.md +79 -0
- data/TODO.pdf/79-bookmarks-outline.md +72 -0
- data/lib/idml/parts/xmp.rb +305 -0
- data/lib/idml/parts.rb +4 -0
- data/lib/idml/render/image_collector.rb +107 -0
- data/lib/idml/render/page_item_renderer.rb +18 -1
- data/lib/idml/render/pdfa_packet.rb +135 -0
- data/lib/idml/render/pdfrb_writer.rb +2 -1
- data/lib/idml/render/pipeline.rb +69 -90
- data/lib/idml/render/render_context.rb +2 -0
- data/lib/idml/render/spread_renderer.rb +6 -2
- data/lib/idml/render/structure_mapper.rb +54 -0
- data/lib/idml/render/structure_tracker.rb +55 -0
- data/lib/idml/render.rb +4 -0
- data/lib/idml/version.rb +1 -1
- metadata +8 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Idml
|
|
6
|
+
module Parts
|
|
7
|
+
# XMP (Extensible Metadata Platform) parser for IDML's
|
|
8
|
+
# `META-INF/metadata.xml`. The packet is RDF/XML with multiple
|
|
9
|
+
# namespaces (`dc:`, `xmp:`, `pdf:`, `xmpMM:`, etc.).
|
|
10
|
+
#
|
|
11
|
+
# Per Lutaml's namespace API, each namespaced element is a
|
|
12
|
+
# separate `Serializable` subclass that carries its own
|
|
13
|
+
# `namespace`. The parent `<rdf:Description>` composes the
|
|
14
|
+
# children via `map_element`, with the namespace binding handled
|
|
15
|
+
# on each child class.
|
|
16
|
+
module Xmp
|
|
17
|
+
class RdfNamespace < Lutaml::Xml::Namespace
|
|
18
|
+
uri "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
19
|
+
prefix_default "rdf"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class DcNamespace < Lutaml::Xml::Namespace
|
|
23
|
+
uri "http://purl.org/dc/elements/1.1/"
|
|
24
|
+
prefix_default "dc"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class XmpNamespace < Lutaml::Xml::Namespace
|
|
28
|
+
uri "http://ns.adobe.com/xap/1.0/"
|
|
29
|
+
prefix_default "xmp"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class XmetaNamespace < Lutaml::Xml::Namespace
|
|
33
|
+
uri "adobe:ns:meta/"
|
|
34
|
+
prefix_default "x"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Single direct-value element. Subclasses set the element name
|
|
38
|
+
# and namespace.
|
|
39
|
+
class Leaf < Lutaml::Model::Serializable
|
|
40
|
+
attribute :value, :string
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Format < Leaf
|
|
44
|
+
xml do
|
|
45
|
+
root "format"
|
|
46
|
+
namespace DcNamespace
|
|
47
|
+
map_content to: :value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class CreatorTool < Leaf
|
|
52
|
+
xml do
|
|
53
|
+
root "CreatorTool"
|
|
54
|
+
namespace XmpNamespace
|
|
55
|
+
map_content to: :value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class CreateDate < Leaf
|
|
60
|
+
xml do
|
|
61
|
+
root "CreateDate"
|
|
62
|
+
namespace XmpNamespace
|
|
63
|
+
map_content to: :value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class ModifyDate < Leaf
|
|
68
|
+
xml do
|
|
69
|
+
root "ModifyDate"
|
|
70
|
+
namespace XmpNamespace
|
|
71
|
+
map_content to: :value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class MetadataDate < Leaf
|
|
76
|
+
xml do
|
|
77
|
+
root "MetadataDate"
|
|
78
|
+
namespace XmpNamespace
|
|
79
|
+
map_content to: :value
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# rdf:li element inside rdf:Alt / rdf:Seq / rdf:Bag containers.
|
|
84
|
+
class ListItem < Lutaml::Model::Serializable
|
|
85
|
+
attribute :lang, :string
|
|
86
|
+
attribute :value, :string
|
|
87
|
+
|
|
88
|
+
xml do
|
|
89
|
+
root "li"
|
|
90
|
+
namespace RdfNamespace
|
|
91
|
+
map_attribute "lang", to: :lang
|
|
92
|
+
map_content to: :value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# rdf:Alt — alternative-language values. Used for dc:title and
|
|
97
|
+
# dc:description.
|
|
98
|
+
class Alt < Lutaml::Model::Serializable
|
|
99
|
+
attribute :items, ListItem, collection: true
|
|
100
|
+
|
|
101
|
+
xml do
|
|
102
|
+
root "Alt"
|
|
103
|
+
namespace RdfNamespace
|
|
104
|
+
map_element "li", to: :items
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def default_value
|
|
108
|
+
x_default = items.find { |i| i.lang == "x-default" }
|
|
109
|
+
(x_default || items.first)&.value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# rdf:Seq — ordered sequence. Used for dc:creator.
|
|
114
|
+
class Seq < Lutaml::Model::Serializable
|
|
115
|
+
attribute :items, ListItem, collection: true
|
|
116
|
+
|
|
117
|
+
xml do
|
|
118
|
+
root "Seq"
|
|
119
|
+
namespace RdfNamespace
|
|
120
|
+
map_element "li", to: :items
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def values
|
|
124
|
+
items.filter_map(&:value)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# rdf:Bag — unordered set. Used for dc:subject.
|
|
129
|
+
class Bag < Lutaml::Model::Serializable
|
|
130
|
+
attribute :items, ListItem, collection: true
|
|
131
|
+
|
|
132
|
+
xml do
|
|
133
|
+
root "Bag"
|
|
134
|
+
namespace RdfNamespace
|
|
135
|
+
map_element "li", to: :items
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def values
|
|
139
|
+
items.filter_map(&:value)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Container-wrapped dc elements.
|
|
144
|
+
class Title < Lutaml::Model::Serializable
|
|
145
|
+
attribute :alt, Alt
|
|
146
|
+
|
|
147
|
+
xml do
|
|
148
|
+
root "title"
|
|
149
|
+
namespace DcNamespace
|
|
150
|
+
map_element "Alt", to: :alt
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def value
|
|
154
|
+
alt&.default_value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
class DcDescription < Lutaml::Model::Serializable
|
|
159
|
+
attribute :alt, Alt
|
|
160
|
+
|
|
161
|
+
xml do
|
|
162
|
+
root "description"
|
|
163
|
+
namespace DcNamespace
|
|
164
|
+
map_element "Alt", to: :alt
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def value
|
|
168
|
+
alt&.default_value
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class Creator < Lutaml::Model::Serializable
|
|
173
|
+
attribute :seq, Seq
|
|
174
|
+
|
|
175
|
+
xml do
|
|
176
|
+
root "creator"
|
|
177
|
+
namespace DcNamespace
|
|
178
|
+
map_element "Seq", to: :seq
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def values
|
|
182
|
+
seq&.values || []
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class Subject < Lutaml::Model::Serializable
|
|
187
|
+
attribute :bag, Bag
|
|
188
|
+
|
|
189
|
+
xml do
|
|
190
|
+
root "subject"
|
|
191
|
+
namespace DcNamespace
|
|
192
|
+
map_element "Bag", to: :bag
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def values
|
|
196
|
+
bag&.values || []
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Single `<rdf:Description>` element from an XMP packet. Combines
|
|
202
|
+
# children from multiple namespaces (`dc:`, `xmp:`) by composing
|
|
203
|
+
# nested `Serializable` models — each child carries its own
|
|
204
|
+
# `namespace` declaration per Lutaml's namespace API.
|
|
205
|
+
class XmpDescription < Lutaml::Model::Serializable
|
|
206
|
+
attribute :format, Xmp::Format
|
|
207
|
+
attribute :title, Xmp::Title
|
|
208
|
+
attribute :creator, Xmp::Creator
|
|
209
|
+
attribute :description, Xmp::DcDescription
|
|
210
|
+
attribute :subject, Xmp::Subject
|
|
211
|
+
attribute :creator_tool, Xmp::CreatorTool
|
|
212
|
+
attribute :create_date, Xmp::CreateDate
|
|
213
|
+
attribute :modify_date, Xmp::ModifyDate
|
|
214
|
+
attribute :metadata_date, Xmp::MetadataDate
|
|
215
|
+
|
|
216
|
+
xml do
|
|
217
|
+
root "Description"
|
|
218
|
+
namespace Xmp::RdfNamespace
|
|
219
|
+
map_element "format", to: :format
|
|
220
|
+
map_element "title", to: :title
|
|
221
|
+
map_element "creator", to: :creator
|
|
222
|
+
map_element "description", to: :description
|
|
223
|
+
map_element "subject", to: :subject
|
|
224
|
+
map_element "CreatorTool", to: :creator_tool
|
|
225
|
+
map_element "CreateDate", to: :create_date
|
|
226
|
+
map_element "ModifyDate", to: :modify_date
|
|
227
|
+
map_element "MetadataDate", to: :metadata_date
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def author
|
|
231
|
+
creator&.values&.first
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def keywords
|
|
235
|
+
subject&.values&.join(", ")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# `<rdf:RDF>` — collection of `<rdf:Description>` siblings inside
|
|
240
|
+
# an XMP packet. Per IDML's META-INF/metadata.xml, there is
|
|
241
|
+
# typically one Description per schema group.
|
|
242
|
+
class XmpRdf < Lutaml::Model::Serializable
|
|
243
|
+
attribute :descriptions, XmpDescription, collection: true
|
|
244
|
+
|
|
245
|
+
xml do
|
|
246
|
+
root "RDF"
|
|
247
|
+
namespace Xmp::RdfNamespace
|
|
248
|
+
map_element "Description", to: :descriptions
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Merged view across all Descriptions. Each attribute returns
|
|
252
|
+
# the first non-nil value across the descriptions.
|
|
253
|
+
def first_description
|
|
254
|
+
descriptions.first
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def title
|
|
258
|
+
descriptions.map(&:title).find(&:itself)&.value
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def author
|
|
262
|
+
descriptions.filter_map(&:author).first
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def subject
|
|
266
|
+
descriptions.filter_map(&:subject).find(&:itself)&.values
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def keywords
|
|
270
|
+
subject&.join(", ")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def description
|
|
274
|
+
descriptions.map(&:description).find(&:itself)&.value
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def creator_tool
|
|
278
|
+
descriptions.filter_map(&:creator_tool).first&.value
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def create_date
|
|
282
|
+
descriptions.filter_map(&:create_date).first&.value
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def modify_date
|
|
286
|
+
descriptions.filter_map(&:modify_date).first&.value
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# `<x:xmpmeta>` — outer wrapper of an XMP packet.
|
|
291
|
+
class XmpMeta < Lutaml::Model::Serializable
|
|
292
|
+
attribute :rdf, XmpRdf
|
|
293
|
+
|
|
294
|
+
xml do
|
|
295
|
+
root "xmpmeta"
|
|
296
|
+
namespace Xmp::XmetaNamespace
|
|
297
|
+
map_element "RDF", to: :rdf
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def description
|
|
301
|
+
rdf&.first_description
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
data/lib/idml/parts.rb
CHANGED
|
@@ -15,6 +15,10 @@ module Idml
|
|
|
15
15
|
autoload :Preferences, "idml/parts/preferences"
|
|
16
16
|
autoload :Tags, "idml/parts/tags"
|
|
17
17
|
autoload :Mapping, "idml/parts/mapping"
|
|
18
|
+
autoload :Xmp, "idml/parts/xmp"
|
|
19
|
+
autoload :XmpDescription, "idml/parts/xmp"
|
|
20
|
+
autoload :XmpRdf, "idml/parts/xmp"
|
|
21
|
+
autoload :XmpMeta, "idml/parts/xmp"
|
|
18
22
|
|
|
19
23
|
@registry = {}
|
|
20
24
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Walks a Spread's page items, resolves image URIs against the
|
|
6
|
+
# package's base directory, deduplicates by URI, and emits
|
|
7
|
+
# placement refs (`{ name:, placement:, clip_box: }`) ready for
|
|
8
|
+
# `SpreadRenderer#render_images`.
|
|
9
|
+
#
|
|
10
|
+
# Extracted from `Pipeline` so the pipeline stays a sequence of
|
|
11
|
+
# high-level steps and the image-loading details live behind one
|
|
12
|
+
# object. Owns: file I/O, format detection, deduplication,
|
|
13
|
+
# placement math. Does not own: rendering (SpreadRenderer draws),
|
|
14
|
+
# layer filtering (caller passes a visible-only iterable).
|
|
15
|
+
class ImageCollector
|
|
16
|
+
DEFAULT_PAGE_HEIGHT = 792
|
|
17
|
+
|
|
18
|
+
def initialize(writer:, base_dir:, page_height: DEFAULT_PAGE_HEIGHT)
|
|
19
|
+
@writer = writer
|
|
20
|
+
@base_dir = base_dir
|
|
21
|
+
@page_height = page_height
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Enumerate every page item in `spread`, register its image
|
|
25
|
+
# children with the writer, and return the list of placement
|
|
26
|
+
# refs. Items whose `image` collection is empty or whose URIs
|
|
27
|
+
# cannot be resolved are skipped silently.
|
|
28
|
+
def collect(spread)
|
|
29
|
+
refs = []
|
|
30
|
+
spread.each_page_item do |item|
|
|
31
|
+
images_for(item).each do |image|
|
|
32
|
+
ref = register(image, item)
|
|
33
|
+
refs << ref if ref
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
refs
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def images_for(item)
|
|
42
|
+
case item
|
|
43
|
+
when Idml::Elements::Rectangle, Idml::Elements::Polygon
|
|
44
|
+
item.image || []
|
|
45
|
+
else
|
|
46
|
+
[]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def register(image, parent)
|
|
51
|
+
uri = image.resource_uri
|
|
52
|
+
return nil unless uri
|
|
53
|
+
|
|
54
|
+
existing = @writer.image_name_for(uri)
|
|
55
|
+
return reuse(existing, image, parent) if existing
|
|
56
|
+
|
|
57
|
+
load_new(image, parent, uri)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reuse(name, image, parent)
|
|
61
|
+
{ name: name, placement: placement_for(image, parent),
|
|
62
|
+
clip_box: clip_box_for(parent) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_new(image, parent, uri)
|
|
66
|
+
path = Image.resolve_path(uri, base_dir: @base_dir)
|
|
67
|
+
return nil unless File.exist?(path)
|
|
68
|
+
|
|
69
|
+
data = File.binread(path)
|
|
70
|
+
dims = dimensions_of(data)
|
|
71
|
+
return nil unless dims
|
|
72
|
+
|
|
73
|
+
name = @writer.add_image(data: data)
|
|
74
|
+
@writer.register_image_name(uri, name)
|
|
75
|
+
{ name: name, placement: placement_for(image, parent, dims[1]),
|
|
76
|
+
clip_box: clip_box_for(parent) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clip_box_for(parent)
|
|
80
|
+
return nil unless parent.geometric_bounds
|
|
81
|
+
|
|
82
|
+
Geometry.placement_rect(parent.geometric_bounds,
|
|
83
|
+
parent.item_transform, @page_height)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def dimensions_of(data)
|
|
87
|
+
format = Image.detect_format(data)
|
|
88
|
+
return nil unless format
|
|
89
|
+
|
|
90
|
+
format == :png ? Image.png_dimensions(data) : Image.jpeg_dimensions(data)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def placement_for(image, parent, pixel_height = 100)
|
|
94
|
+
Image.compute_placement(
|
|
95
|
+
image_transform: parse_transform(image.item_transform),
|
|
96
|
+
parent_transform: parse_transform(parent.item_transform),
|
|
97
|
+
pixel_height: pixel_height,
|
|
98
|
+
page_height: @page_height,
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_transform(raw)
|
|
103
|
+
Image.parse_transform(raw) || Image.identity
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -20,7 +20,11 @@ module Idml
|
|
|
20
20
|
renderer = renderer_for(context.item)
|
|
21
21
|
return nil unless renderer
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
if context.structure&.enabled?
|
|
24
|
+
wrap_tagged(canvas, context) { renderer.render(canvas, context) }
|
|
25
|
+
else
|
|
26
|
+
renderer.render(canvas, context)
|
|
27
|
+
end
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
def self.renderer_for(item)
|
|
@@ -29,6 +33,19 @@ module Idml
|
|
|
29
33
|
|
|
30
34
|
Render::Renderers.const_get(renderer_name)
|
|
31
35
|
end
|
|
36
|
+
|
|
37
|
+
def self.wrap_tagged(canvas, context, &)
|
|
38
|
+
type = StructureMapper.type_for(context.item)
|
|
39
|
+
return yield unless type
|
|
40
|
+
|
|
41
|
+
tracker = context.structure
|
|
42
|
+
page_index = context.page_index || 0
|
|
43
|
+
mcid = tracker.next_mcid(page_index)
|
|
44
|
+
alt = StructureMapper.alt_for(context.item)
|
|
45
|
+
tracker.add(type, page_index: page_index, mcid: mcid, alt: alt)
|
|
46
|
+
canvas.tagged(type, mcid: mcid, &)
|
|
47
|
+
end
|
|
48
|
+
private_class_method :wrap_tagged
|
|
32
49
|
end
|
|
33
50
|
end
|
|
34
51
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Builds the PDF/A-2a XMP packet and attaches it to the PDF
|
|
6
|
+
# Catalog as the `/Metadata` stream. PDF/A requires:
|
|
7
|
+
#
|
|
8
|
+
# 1. An XMP metadata stream on `/Catalog` (not just `/Info`).
|
|
9
|
+
# 2. The packet must declare `pdfaid:part` and `pdfaid:conformance`.
|
|
10
|
+
# 3. `dc:format` must be `application/pdf`.
|
|
11
|
+
#
|
|
12
|
+
# ICC profile (output intent) embedding is handled separately by
|
|
13
|
+
# `OutputIntents#embed_icc` — see TODO 77 for the deferred plan
|
|
14
|
+
# to bundle sRGB and register the intent.
|
|
15
|
+
#
|
|
16
|
+
# The packet is built from the same fields already threaded
|
|
17
|
+
# through `PdfrbWriter#set_info`, so PDF/A output reuses the
|
|
18
|
+
# XMP-extracted values from the IDML packet (TODO 75).
|
|
19
|
+
module PdfaPacket
|
|
20
|
+
PDF_A_PART = 2
|
|
21
|
+
PDF_A_CONFORMANCE = "A"
|
|
22
|
+
|
|
23
|
+
# Returns the XMP packet bytes suitable for a `/Metadata` stream.
|
|
24
|
+
# @param metadata [Hash{Symbol => String}] the Info-dict fields
|
|
25
|
+
# already assembled by the pipeline.
|
|
26
|
+
def self.build(metadata)
|
|
27
|
+
body = rdf_description(metadata)
|
|
28
|
+
"#{XMP_BEGIN}#{body}#{XMP_END}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Attaches the built packet to the document's Catalog as the
|
|
32
|
+
# `/Metadata` stream and sets `/Lang`. Idempotent — replaces
|
|
33
|
+
# any existing `/Metadata`.
|
|
34
|
+
def self.attach(document, metadata)
|
|
35
|
+
xmp = build(metadata)
|
|
36
|
+
stream = document.add(
|
|
37
|
+
{ Type: :Metadata, Subtype: :XML, Length: xmp.bytesize },
|
|
38
|
+
type: Pdfrb::Model::Cos::Stream,
|
|
39
|
+
)
|
|
40
|
+
stream.stream = xmp
|
|
41
|
+
document.catalog.value[:Metadata] =
|
|
42
|
+
Pdfrb::Model::Reference.new(stream.oid, stream.gen)
|
|
43
|
+
document.catalog.value[:Lang] ||= "en-US"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
XMP_BEGIN = "<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n"
|
|
47
|
+
XMP_END = "<?xpacket end=\"w\"?>\n"
|
|
48
|
+
private_constant :XMP_BEGIN, :XMP_END
|
|
49
|
+
|
|
50
|
+
def self.rdf_description(metadata)
|
|
51
|
+
lines = [
|
|
52
|
+
'<x:xmpmeta xmlns:x="adobe:ns:meta/">',
|
|
53
|
+
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">',
|
|
54
|
+
'<rdf:Description rdf:about=""',
|
|
55
|
+
' xmlns:dc="http://purl.org/dc/elements/1.1/"',
|
|
56
|
+
' xmlns:pdf="http://ns.adobe.com/pdf/1.3/"',
|
|
57
|
+
' xmlns:xmp="http://ns.adobe.com/xap/1.0/"',
|
|
58
|
+
' xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">',
|
|
59
|
+
]
|
|
60
|
+
lines += pdfaid_lines
|
|
61
|
+
lines += dc_lines(metadata)
|
|
62
|
+
lines += pdf_lines(metadata)
|
|
63
|
+
lines += xmp_lines(metadata)
|
|
64
|
+
lines << "</rdf:Description>"
|
|
65
|
+
lines << "</rdf:RDF>"
|
|
66
|
+
lines << "</x:xmpmeta>"
|
|
67
|
+
"#{lines.join("\n")}\n"
|
|
68
|
+
end
|
|
69
|
+
private_class_method :rdf_description
|
|
70
|
+
|
|
71
|
+
def self.pdfaid_lines
|
|
72
|
+
[
|
|
73
|
+
" <pdfaid:part>#{PDF_A_PART}</pdfaid:part>",
|
|
74
|
+
" <pdfaid:conformance>#{PDF_A_CONFORMANCE}</pdfaid:conformance>",
|
|
75
|
+
]
|
|
76
|
+
end
|
|
77
|
+
private_class_method :pdfaid_lines
|
|
78
|
+
|
|
79
|
+
def self.dc_lines(metadata)
|
|
80
|
+
lines = []
|
|
81
|
+
if metadata[:Title]
|
|
82
|
+
lines << ' <dc:title><rdf:Alt><rdf:li xml:lang="x-default">' \
|
|
83
|
+
"#{escape(metadata[:Title])}</rdf:li></rdf:Alt></dc:title>"
|
|
84
|
+
end
|
|
85
|
+
if metadata[:Author]
|
|
86
|
+
lines << " <dc:creator><rdf:Seq>" \
|
|
87
|
+
"<rdf:li>#{escape(metadata[:Author])}</rdf:li>" \
|
|
88
|
+
"</rdf:Seq></dc:creator>"
|
|
89
|
+
end
|
|
90
|
+
if metadata[:Subject]
|
|
91
|
+
lines << ' <dc:description><rdf:Alt><rdf:li xml:lang="x-default">' \
|
|
92
|
+
"#{escape(metadata[:Subject])}</rdf:li></rdf:Alt></dc:description>"
|
|
93
|
+
end
|
|
94
|
+
lines
|
|
95
|
+
end
|
|
96
|
+
private_class_method :dc_lines
|
|
97
|
+
|
|
98
|
+
def self.pdf_lines(metadata)
|
|
99
|
+
return [] unless metadata[:Keywords]
|
|
100
|
+
|
|
101
|
+
[" <pdf:Keywords>#{escape(metadata[:Keywords])}</pdf:Keywords>"]
|
|
102
|
+
end
|
|
103
|
+
private_class_method :pdf_lines
|
|
104
|
+
|
|
105
|
+
def self.xmp_lines(metadata)
|
|
106
|
+
lines = []
|
|
107
|
+
lines << " <dc:format>application/pdf</dc:format>"
|
|
108
|
+
if metadata[:Creator]
|
|
109
|
+
lines << " <xmp:CreatorTool>#{escape(metadata[:Creator])}</xmp:CreatorTool>"
|
|
110
|
+
end
|
|
111
|
+
lines << xmp_date_line("CreateDate", metadata[:CreationDate])
|
|
112
|
+
lines << xmp_date_line("ModifyDate", metadata[:ModDate])
|
|
113
|
+
lines << xmp_date_line("MetadataDate", metadata[:CreationDate])
|
|
114
|
+
lines.compact
|
|
115
|
+
end
|
|
116
|
+
private_class_method :xmp_lines
|
|
117
|
+
|
|
118
|
+
def self.xmp_date_line(name, value)
|
|
119
|
+
return nil unless value
|
|
120
|
+
|
|
121
|
+
" <xmp:#{name}>#{escape(value)}</xmp:#{name}>"
|
|
122
|
+
end
|
|
123
|
+
private_class_method :xmp_date_line
|
|
124
|
+
|
|
125
|
+
def self.escape(text)
|
|
126
|
+
text.to_s
|
|
127
|
+
.gsub("&", "&")
|
|
128
|
+
.gsub("<", "<")
|
|
129
|
+
.gsub(">", ">")
|
|
130
|
+
.gsub('"', """)
|
|
131
|
+
end
|
|
132
|
+
private_class_method :escape
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -78,8 +78,9 @@ module Idml
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def add_structure_element(type, page_index:, mcid:, text: nil, alt: nil)
|
|
81
|
+
page = @document.pages[page_index]
|
|
81
82
|
@document.structure.add_element(type, text: text, alt: alt,
|
|
82
|
-
page:
|
|
83
|
+
page: page, mcid: mcid)
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
def build_structure
|