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.
@@ -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
- renderer.render(canvas, context)
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("&", "&amp;")
128
+ .gsub("<", "&lt;")
129
+ .gsub(">", "&gt;")
130
+ .gsub('"', "&quot;")
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: page_index, mcid: mcid)
83
+ page: page, mcid: mcid)
83
84
  end
84
85
 
85
86
  def build_structure