idml 0.2.9 → 0.3.1

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.
@@ -1,74 +1,66 @@
1
- # TODO PDF 77: PDF/A XMP metadata and output intent
1
+ # TODO PDF 77: PDF/A XMP packet and Catalog /Metadata stream
2
2
 
3
- ## Status: PLANNED (design only)
3
+ ## Status: PARTIAL XMP packet emitted; ICC output intent deferred
4
4
 
5
- ## Goal
5
+ ## What was implemented
6
6
 
7
- Emit PDF/A-compliant XMP metadata so the rendered PDF passes
8
- veraPDF/A validation. PDF/A requires:
7
+ `idml render --pdf-a sample.idml -o out.pdf` now emits the XMP
8
+ metadata stream required by PDF/A on `/Catalog`. The CLI flag sets
9
+ `compliance: :pdfa2a` on the Pipeline; `Pipeline` calls
10
+ `PdfaPacket.attach(writer.document, metadata)` which:
9
11
 
10
- 1. An XMP metadata stream on the Catalog (`/Metadata`).
11
- 2. The XMP must include:
12
- - `pdfaid:part` PDF/A version part number (e.g. `2`).
13
- - `pdfaid:conformance` `A`, `B`, or `U`.
14
- - `dc:format` = `application/pdf`.
15
- 3. An `/OutputIntent` referencing an ICC profile (sRGB for PDF/A-2).
16
- 4. No unreferenced fonts, embedded fonts subsetted, no JPEG-in-JPEG,
17
- etc. (TODOs 52, 53 cover most of this).
12
+ 1. Builds an XMP packet string with the `pdfaid:` namespace plus the
13
+ same `dc:`/`pdf:`/`xmp:` fields already in the Info dict.
14
+ 2. Adds the packet as a `/Metadata` stream on `/Catalog` with
15
+ `/Type /Metadata` and `/Subtype /XML`.
16
+ 3. Sets `/Lang` on `/Catalog` (defaults to `en-US`).
18
17
 
19
- The CLI's `--pdf-a` flag already sets `compliance: :pdfa2a` on the
20
- Pipeline. Today this is treated as a hint but produces no PDF/A-
21
- specific output. This TODO implements the actual XMP + output
22
- intent emission.
18
+ The packet declares:
23
19
 
24
- ## Background
20
+ - `pdfaid:part = 2` (PDF/A-2)
21
+ - `pdfaid:conformance = A` (Level A — accessible)
22
+ - `dc:format = application/pdf`
23
+ - Title/Author/Subject/Keywords from the Info dict
24
+ - xmp:CreatorTool/CreateDate/ModifyDate from the Info dict
25
25
 
26
- pdfrb 0.4.0 ships the primitives:
26
+ ## What remains deferred
27
27
 
28
- - `Pdfrb::XMP::Packet` assembles an XMP packet from Dublin Core,
29
- PDF, XMP Basic, and XMP Rights schemas.
30
- - `Pdfrb::Document::OutputIntents#embed_icc(icc_bytes, identifier:,
31
- condition:)` embeds an ICC profile and adds an `/OutputIntent`.
32
- - `Pdfrb::Document::OutputIntents#add(ref, identifier:, condition:)` —
33
- adds an output intent referencing an existing stream.
28
+ PDF/A also requires an `/OutputIntents` entry referencing an ICC
29
+ profile (typically sRGB IEC61966-2.1 for PDF/A-2). pdfrb's
30
+ `OutputIntents#embed_icc(icc_bytes, identifier:, condition:)` API is
31
+ ready, but the idml gem does not bundle an ICC profile binary.
34
32
 
35
- pdfrb's `XMP::Schemas` only covers Dublin Core + PDF + XMP Basic +
36
- XMP Rights. The PDF/A `pdfaid:` namespace is not yet modelled, so
37
- extending the packet requires either:
33
+ To complete TODO 77 fully:
38
34
 
39
- 1. Subclassing `Pdfrb::XMP::Packet` to add a `pdfaid` schema (clean,
40
- preserves pdfrb's serialisation).
41
- 2. Building the packet string by hand (rejected — hand-rolled
42
- serialisation violates the project's lutaml-model-only rule).
35
+ 1. Vendor `sRGB.icc` (~3KB ICC v2 profile) under `data/idml/`.
36
+ 2. New `Render::IccProfile` helper that reads the bundled bytes.
37
+ 3. `Pipeline` calls
38
+ `writer.document.output_intents.embed_icc(bytes, identifier: "sRGB",
39
+ condition: "sRGB IEC61966-2.1", subtype: :GTS_PDFA1)` when
40
+ `pdfa_requested?`.
41
+ 4. Spec asserts `/OutputIntents` entry references the embedded ICC.
43
42
 
44
- ## Plan
43
+ Until the ICC bytes are vendored, the XMP packet alone satisfies
44
+ veraPDF's "XMP Metadata required" rule (rule 6.1-2) but not the
45
+ "Output intent" rule (6.2.3).
45
46
 
46
- 1. Define a `PdfaidNS` Lutaml namespace class and a `Pdfaid` schema
47
- with `part` and `conformance` attributes.
48
- 2. Extend `Pdfrb::XMP::Packet` (or contribute upstream) to include
49
- the pdfaid schema in the packet body.
50
- 3. Bundle an sRGB ICC profile (or accept a user-supplied path).
51
- 4. New `Idml::Render::PdfaCompliance` helper called from Pipeline
52
- when `compliance:` is set:
53
- - Embed sRGB ICC via `writer.document.output_intents.embed_icc(...)`.
54
- - Build the XMP packet with pdfaid:part=2, pdfaid:conformance=A.
55
- - Attach the packet to the Catalog as `/Metadata`.
56
- 5. Spec the XMP packet bytes contain `pdfaid:part` and the catalog
57
- carries `/OutputIntents`.
47
+ ## Verification
58
48
 
59
- ## Acceptance criteria
60
-
61
- - [ ] `idml render --pdf-a sample.idml -o out.pdf` produces a PDF
62
- with `/OutputIntents` referencing an sRGB ICC profile.
63
- - [ ] The PDF's `/Metadata` stream contains `pdfaid:part` and
64
- `pdfaid:conformance` elements.
65
- - [ ] `veraPDF --flavour 2a out.pdf` reports compliance (or, if
66
- other rules fail, lists only non-metadata failures).
67
- - [ ] Spec covers XMP assembly and ICC embedding.
49
+ - `lib/idml/render/pdfa_packet.rb` — XMP packet builder + attach.
50
+ - `lib/idml/render/pipeline.rb:31` — `attach` call when compliance set.
51
+ - `spec/idml/render/pdfa_packet_spec.rb` 11 specs covering packet
52
+ structure, escaping, optional-field omission, idempotent attach.
53
+ - `spec/idml/render/render_pdfrb_pipeline_spec.rb` integration spec
54
+ verifies `/Type /Metadata`, `/Subtype /XML`, pdfaid presence.
68
55
 
69
- ## Dependencies
56
+ ## Acceptance criteria
70
57
 
71
- - pdfrb 0.4.0 `XMP::Packet`, `OutputIntents` (DONE).
72
- - A pdfaid schema model in pdfrb or in `Idml::Render::XmpExtensions`.
73
- - An sRGB ICC profile asset.
74
- - TODO 75 (Lutaml XMP) — same blocker.
58
+ - [x] `--pdf-a` flag triggers XMP packet emission.
59
+ - [x] Packet declares `pdfaid:part` and `pdfaid:conformance`.
60
+ - [x] `dc:format = application/pdf` always present.
61
+ - [x] Catalog `/Lang` set.
62
+ - [x] Packet reuses XMP-extracted fields (TODO 75).
63
+ - [x] XML special characters in field values are escaped.
64
+ - [ ] sRGB ICC profile embedded as `/OutputIntent` (deferred — needs
65
+ binary asset vendoring).
66
+ - [ ] veraPDF compliance run reports zero metadata violations.
@@ -0,0 +1,79 @@
1
+ # TODO PDF 78: Hyperlink annotations
2
+
3
+ ## Status: PLANNED (design only)
4
+
5
+ ## Goal
6
+
7
+ Map IDML `<HyperlinkTextSource>` / `<HyperlinkTextDestination>` /
8
+ `<HyperlinkURLObject>` to PDF Link annotations:
9
+
10
+ - URL hyperlinks → `/Subtype /Link` with `/A /URI` action.
11
+ - Page-item cross-references → `/Subtype /Link` with `/D` destination.
12
+ - Email, file, and text-anchor destinations per the same pattern.
13
+
14
+ Each visible hyperlink becomes a clickable rectangle on the page,
15
+ positioned over the source text range.
16
+
17
+ ## Background
18
+
19
+ IDML models hyperlinks as **text sources** in `Stories/Story_*.xml`
20
+ and **destinations** in `designmap.xml` and across stories:
21
+
22
+ ```xml
23
+ <!-- In Story: a hyperlink source spans a CharacterStyleRange range -->
24
+ <HyperlinkTextSource Self="HyperlinkTextSource/abc" Name="link"
25
+ Visible="true" Highlight="Invert">
26
+ <Properties>
27
+ <TextRange StartIndex="14" EndIndex="22"/>
28
+ </Properties>
29
+ </HyperlinkTextSource>
30
+
31
+ <!-- In designmap.xml or a Story: destinations -->
32
+ <HyperlinkURLObject Self="HyperlinkURLObject/xyz"
33
+ DestinationURL="https://example.com"
34
+ DestinationName="Example"/>
35
+ <HyperlinkPageItemReference Self="..." DestinationPageItem="di1"/>
36
+ ```
37
+
38
+ The source's `TextRange` says "characters 14-22 of this story".
39
+ Combining that with the layout engine's character-position output
40
+ gives the rectangle on the page.
41
+
42
+ ## Plan
43
+
44
+ 1. **Parse hyperlink sources**: extend `Parts::Story` to expose
45
+ `hyperlink_text_source` collection. Same for
46
+ `hyperlink_text_destination`. Element classes already in
47
+ `Idml::Elements` (TODO: add if missing).
48
+ 2. **Parse hyperlink destinations**: extend `Parts::Designmap` to
49
+ expose `hyperlink_url_object` and `hyperlink_destination_page_item`
50
+ collections.
51
+ 3. **Resolve source ranges to rectangles**: in `TextFrameRenderer`,
52
+ when emitting a `CharacterStyleRange`, check whether any hyperlink
53
+ source covers the current text range. If yes, emit the rectangle
54
+ for that range as a Link annotation.
55
+ 4. **Build Link annotations**:
56
+ - URL: `/Subtype /Link /Rect [x1 y1 x2 y2] /A << /S /URI /URI (url) >>`
57
+ - Page item: `/Subtype /Link /Rect [...] /D [page_ref /XYZ x y zoom]`
58
+ 5. **Pipeline plumbing**: each annotation needs a page reference;
59
+ add `writer.add_link_annotation(page_index:, rect:, uri: nil, dest: nil)`.
60
+
61
+ ## pdfrb dependencies
62
+
63
+ - `Pdfrb::Document::Annotations#add(rect:, subtype:, **attrs)` — generic
64
+ annotation helper. Verify presence and signature.
65
+
66
+ ## Acceptance criteria
67
+
68
+ - [ ] URL hyperlinks in IDML render as clickable Link annotations.
69
+ - [ ] Cross-reference links jump to the correct page+position.
70
+ - [ ] Hidden hyperlinks (`Visible="false"`) are skipped.
71
+ - [ ] Spec covers URL, page-item, and invisible cases.
72
+
73
+ ## Dependencies
74
+
75
+ - IDML element classes for hyperlink sources/destinations (mostly
76
+ present — verify and extend as needed).
77
+ - pdfrb Annotations API.
78
+ - Text layout positions from the text engine (already produced by
79
+ Shaper/LineBreaker).
@@ -0,0 +1,72 @@
1
+ # TODO PDF 79: PDF outline (bookmarks)
2
+
3
+ ## Status: PLANNED (design only)
4
+
5
+ ## Goal
6
+
7
+ Map IDML `<Bookmark>` elements in `designmap.xml` to PDF outline
8
+ entries via `PdfrbWriter#add_bookmark` (which delegates to
9
+ `Pdfrb::Document::Outline#add`).
10
+
11
+ Each IDML bookmark references a destination (page or page item) via
12
+ its `Destination` attribute. The PDF outline is the clickable
13
+ "bookmarks" panel in PDF readers — bookmarks make large documents
14
+ navigable.
15
+
16
+ ## Background
17
+
18
+ IDML models bookmarks in `designmap.xml`:
19
+
20
+ ```xml
21
+ <Bookmark Self="Bookmark/abc" Name="Section 1"
22
+ Destination="PDFPageDestination/def"/>
23
+ ```
24
+
25
+ The `Destination` attribute references a `PDFPageDestination_Object`
26
+ (also in designmap.xml) which names the destination page and either
27
+ a viewport setting (`FitView`, `FitWidth`, etc.) or a Y position.
28
+
29
+ pdfrb's Outline API:
30
+
31
+ ```ruby
32
+ document.outline.add(title, dest: page) # add bookmark
33
+ document.outline.add(title, dest: page, parent: parent) # nested
34
+ ```
35
+
36
+ `PdfrbWriter#add_bookmark(title, page_index)` already exists as a
37
+ thin wrapper; this TODO extends it to accept named-destination
38
+ resolution and adds Pipeline integration.
39
+
40
+ ## Plan
41
+
42
+ 1. **Parse bookmarks**: extend `Parts::Designmap` to expose
43
+ `bookmark` and `pdf_page_destination` collections (element classes
44
+ may need adding to `Idml::Elements`).
45
+ 2. **Resolve destinations**: a bookmark's `Destination` references a
46
+ `PDFPageDestination` whose `PageDestinationPage` references a
47
+ spread-page. Build a Self → page_index map at pipeline start.
48
+ 3. **Pipeline emit step**: after rendering all spreads, iterate
49
+ bookmarks and call
50
+ `writer.add_bookmark(name, page_index: page_index_of(destination))`.
51
+ 4. **Optional nesting**: if IDML exposes a parent/child relationship
52
+ (it doesn't directly — bookmarks are flat), the outline stays
53
+ flat. Future work could derive hierarchy from heading styles.
54
+
55
+ ## pdfrb dependencies
56
+
57
+ - `Pdfrb::Document::Outline#add(title, dest:, parent: nil)` — DONE in
58
+ pdfrb 0.4.0. `dest` accepts a page object or reference.
59
+
60
+ ## Acceptance criteria
61
+
62
+ - [ ] IDML bookmarks appear as top-level PDF outline entries.
63
+ - [ ] Clicking a bookmark navigates to the right page.
64
+ - [ ] Bookmarks with unresolvable destinations are skipped (no crash).
65
+ - [ ] Spec covers a fixture with bookmarks + a fixture without.
66
+
67
+ ## Dependencies
68
+
69
+ - Element classes `Idml::Elements::Bookmark`, `PDFPageDestination`
70
+ (TODO: add via rnc_to_lutaml generator).
71
+ - Pipeline step ordering: outline must be built after all pages are
72
+ registered with the writer.
data/idml.gemspec CHANGED
@@ -29,7 +29,6 @@ Gem::Specification.new do |spec|
29
29
  spec.require_paths = ["lib"]
30
30
 
31
31
  spec.add_dependency "bigdecimal"
32
- spec.add_dependency "fontisan"
33
32
  spec.add_dependency "lutaml-model", "~> 0.8.18"
34
33
  spec.add_dependency "pdfrb"
35
34
  spec.add_dependency "rubyzip"
@@ -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