idml 0.3.1 → 0.4.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.
@@ -3,13 +3,21 @@
3
3
  module Idml
4
4
  module Elements
5
5
  # `<TableCell>` — a single cell within a table row. Carries the
6
- # cell's content type and dimensions.
6
+ # cell's content type, dimensions, and any inline text content
7
+ # (`<CharacterStyleRange>` children that carry the cell's text).
8
+ #
9
+ # IDML's actual schema (`Cell_Object` in
10
+ # `reference-docs/schemas/package/Stories/Story.rnc`) names this
11
+ # element `Cell` and includes many more attributes; this model
12
+ # captures the subset the renderer needs today.
7
13
  class TableCell < Lutaml::Model::Serializable
8
14
  attribute :self_attr, :string
9
15
  attribute :name, :string
10
16
  attribute :column, :integer
11
17
  attribute :row, :integer
12
18
  attribute :key_value, :string
19
+ attribute :character_style_range, Idml::Elements::CharacterStyleRange,
20
+ collection: true
13
21
 
14
22
  xml do
15
23
  root "TableCell"
@@ -18,6 +26,11 @@ module Idml
18
26
  map_attribute "Column", to: :column
19
27
  map_attribute "Row", to: :row
20
28
  map_attribute "KeyValue", to: :key_value
29
+ map_element "CharacterStyleRange", to: :character_style_range
30
+ end
31
+
32
+ def text_content
33
+ character_style_range.filter_map(&:text_content).join
21
34
  end
22
35
  end
23
36
  end
data/lib/idml/elements.rb CHANGED
@@ -15,6 +15,7 @@ module Idml
15
15
  autoload :BevelAndEmbossSetting, "idml/elements/bevel_and_emboss_setting"
16
16
  autoload :BlendingSetting, "idml/elements/blending_setting"
17
17
  autoload :ButtonPreference, "idml/elements/pref_button_preference"
18
+ autoload :Bookmark, "idml/elements/bookmark"
18
19
  autoload :CellStyle, "idml/elements/cell_style"
19
20
  autoload :CellStyleGroup, "idml/elements/cell_style_group"
20
21
  autoload :ChapterNumberPreference,
@@ -60,6 +61,13 @@ module Idml
60
61
  autoload :Group, "idml/elements/group"
61
62
  autoload :GuidePreference, "idml/elements/pref_guide_preference"
62
63
  autoload :HTMLExportPreference, "idml/elements/pref_html_export_preference"
64
+ autoload :Hyperlink, "idml/elements/hyperlink"
65
+ autoload :HyperlinkPageDestination,
66
+ "idml/elements/hyperlink_page_destination"
67
+ autoload :HyperlinkTextSource,
68
+ "idml/elements/hyperlink_text_source"
69
+ autoload :HyperlinkURLDestination,
70
+ "idml/elements/hyperlink_url_destination"
63
71
  autoload :Image, "idml/elements/image"
64
72
  autoload :IndexHeaderSetting, "idml/elements/pref_index_header_setting"
65
73
  autoload :IndexOptions, "idml/elements/pref_index_options"
@@ -47,6 +47,12 @@ module Idml
47
47
  attribute :active_process, :string
48
48
 
49
49
  attribute :layer, Idml::Elements::Layer, collection: true
50
+ attribute :bookmark, Idml::Elements::Bookmark, collection: true
51
+ attribute :hyperlink, Idml::Elements::Hyperlink, collection: true
52
+ attribute :hyperlink_page_destination,
53
+ Idml::Elements::HyperlinkPageDestination, collection: true
54
+ attribute :hyperlink_url_destination,
55
+ Idml::Elements::HyperlinkURLDestination, collection: true
50
56
 
51
57
  xml do
52
58
  root "Document"
@@ -85,6 +91,12 @@ module Idml
85
91
  map_attribute "PreferMathMLInEpubExport", to: :prefer_math_ml_in_epub_export
86
92
  map_attribute "ActiveProcess", to: :active_process
87
93
  map_element "Layer", to: :layer
94
+ map_element "Bookmark", to: :bookmark
95
+ map_element "Hyperlink", to: :hyperlink
96
+ map_element "HyperlinkPageDestination",
97
+ to: :hyperlink_page_destination
98
+ map_element "HyperlinkURLDestination",
99
+ to: :hyperlink_url_destination
88
100
  end
89
101
  end
90
102
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Resolves IDML bookmarks to (title, page_index) pairs ready for
6
+ # `PdfrbWriter#add_bookmark`. Uses the document's designmap to
7
+ # look up:
8
+ #
9
+ # Bookmark#destination (Self) →
10
+ # HyperlinkPageDestination#destination_page (Page Self) →
11
+ # PDF page index (via spread iteration)
12
+ #
13
+ # Bookmarks whose destination chain cannot be resolved are
14
+ # skipped silently.
15
+ class BookmarkResolver
16
+ def initialize(package)
17
+ @package = package
18
+ end
19
+
20
+ # Yields [title, page_index] pairs for each resolvable bookmark.
21
+ def each
22
+ return enum_for(:each) unless block_given?
23
+
24
+ bookmarks.each do |bookmark|
25
+ entry = resolve(bookmark)
26
+ yield entry if entry
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def bookmarks
33
+ designmap&.bookmark || []
34
+ end
35
+
36
+ def designmap
37
+ @package&.designmap
38
+ end
39
+
40
+ def resolve(bookmark)
41
+ destination = destination_by_self(bookmark.destination)
42
+ return nil unless destination&.destination_page
43
+
44
+ page_index = page_index_by_self(destination.destination_page)
45
+ return nil unless page_index
46
+
47
+ title = bookmark.name || destination.name || "Bookmark"
48
+ [title, page_index]
49
+ end
50
+
51
+ def destination_by_self(self_attr)
52
+ return nil unless self_attr
53
+
54
+ destinations.find { |d| d.self_attr == self_attr }
55
+ end
56
+
57
+ def destinations
58
+ designmap&.hyperlink_page_destination || []
59
+ end
60
+
61
+ def page_index_by_self(page_self)
62
+ page_self_table[page_self]
63
+ end
64
+
65
+ def page_self_table
66
+ @page_self_table ||= begin
67
+ table = {}
68
+ @package.spreads.each_with_index do |spread, spread_idx|
69
+ spread_pages = spread.spread.flat_map(&:page)
70
+ spread_pages.each_with_index do |page, page_idx|
71
+ table[page.self_attr] = cumulative_page_index(spread_idx, page_idx)
72
+ end
73
+ end
74
+ table
75
+ end
76
+ end
77
+
78
+ def cumulative_page_index(spread_idx, page_idx)
79
+ prior_pages = 0
80
+ @package.spreads.first(spread_idx).each do |spread|
81
+ prior_pages += spread.spread.flat_map(&:page).length
82
+ end
83
+ prior_pages + page_idx
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Emits PDF Link annotations for IDML hyperlinks. After a spread
6
+ # renders, walk each visible text frame, look up its story's
7
+ # hyperlink sources, resolve each to a URL via HyperlinkResolver,
8
+ # and emit a `/Subtype /Link` annotation over the frame's box.
9
+ #
10
+ # Limitation: this implementation is frame-level, not text-range
11
+ # level. The link covers the entire text frame rather than just
12
+ # the source's TextRange. Precise per-range rects require deeper
13
+ # integration with the text engine — see TODO 78.
14
+ class HyperlinkEmitter
15
+ def initialize(writer:, package:, page_height:, layer_filter: nil)
16
+ @writer = writer
17
+ @package = package
18
+ @resolver = HyperlinkResolver.new(package)
19
+ @page_height = page_height
20
+ @layer_filter = layer_filter
21
+ end
22
+
23
+ def emit_for(spread, page_index)
24
+ each_text_frame_on(spread) do |frame|
25
+ emit_for_frame(frame, page_index)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def each_text_frame_on(spread)
32
+ spread.each_page_item do |item|
33
+ next unless item.is_a?(Idml::Elements::TextFrame)
34
+ next if @layer_filter && !@layer_filter.visible?(item)
35
+
36
+ yield item
37
+ end
38
+ end
39
+
40
+ def emit_for_frame(frame, page_index)
41
+ urls = urls_for_frame(frame)
42
+ return if urls.empty?
43
+
44
+ box = Placement.box(frame, @page_height)
45
+ return unless box
46
+
47
+ urls.each do |url|
48
+ @writer.add_uri_link_annotation(
49
+ page_index: page_index,
50
+ rect: rect_for(box),
51
+ url: url,
52
+ )
53
+ end
54
+ end
55
+
56
+ def urls_for_frame(frame)
57
+ story = frame.parent_story ? @package.story_by_id(frame.parent_story) : nil
58
+ return [] unless story
59
+
60
+ sources = hyperlink_sources_in(story)
61
+ sources.filter_map { |source| @resolver.url_for_source(source.self_attr) }
62
+ end
63
+
64
+ def hyperlink_sources_in(story)
65
+ inner = story&.inner
66
+ return [] unless inner
67
+
68
+ inner.paragraph_style_range.flat_map do |psr|
69
+ psr.character_style_range.flat_map(&:hyperlink_text_source)
70
+ end.compact
71
+ end
72
+
73
+ def rect_for(box)
74
+ [box[:x], box[:y], box[:x] + box[:width], box[:y] + box[:height]]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Resolves IDML hyperlink definitions to URL destinations. Looks
6
+ # up `Hyperlink#source` → `Hyperlink#destination` →
7
+ # `HyperlinkURLDestination#destination_url` from the designmap.
8
+ #
9
+ # The renderer is responsible for mapping source Self IDs to
10
+ # page-item rectangles; this resolver only handles the
11
+ # destination lookup.
12
+ class HyperlinkResolver
13
+ def initialize(package)
14
+ @package = package
15
+ end
16
+
17
+ # Returns the URL for the given hyperlink-source Self, or nil.
18
+ def url_for_source(source_self)
19
+ return nil unless source_self
20
+
21
+ hyperlink = hyperlink_by_source(source_self)
22
+ return nil unless hyperlink
23
+
24
+ url_destination_by_self(hyperlink.destination)&.destination_url
25
+ end
26
+
27
+ # Yields [source_self, url] for every visible hyperlink whose
28
+ # destination chain resolves to a URL.
29
+ def each_visible
30
+ return enum_for(:each_visible) unless block_given?
31
+
32
+ hyperlinks.each do |hyperlink|
33
+ entry = visible_entry(hyperlink)
34
+ yield(*entry) if entry
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def visible_entry(hyperlink)
41
+ return nil if hyperlink.visible == false || hyperlink.hidden == true
42
+
43
+ url = url_destination_by_self(hyperlink.destination)&.destination_url
44
+ return nil unless url
45
+
46
+ [hyperlink.source, url]
47
+ end
48
+
49
+ def hyperlinks
50
+ @package&.designmap&.hyperlink || []
51
+ end
52
+
53
+ def hyperlink_by_source(source_self)
54
+ hyperlinks.find { |h| h.source == source_self }
55
+ end
56
+
57
+ def url_destination_by_self(self_attr)
58
+ return nil unless self_attr
59
+
60
+ url_destinations.find { |d| d.self_attr == self_attr }
61
+ end
62
+
63
+ def url_destinations
64
+ @package&.designmap&.hyperlink_url_destination || []
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Locates an sRGB ICC profile for PDF/A output intent embedding.
6
+ # The idml gem does not bundle a binary ICC asset; instead, this
7
+ # helper probes a small set of well-known locations in priority
8
+ # order and returns the first match as raw bytes.
9
+ #
10
+ # Priority:
11
+ # 1. `ENV["IDML_SRGB_ICC"]` — explicit user override.
12
+ # 2. `data/idml/srgb.icc` inside the gem tree (user-vendored).
13
+ # 3. macOS system profile at
14
+ # `/System/Library/ColorSync/Profiles/sRGB Profile.icc`.
15
+ #
16
+ # Returns `nil` when no profile is available — callers (Pipeline)
17
+ # should skip ICC embedding in that case rather than raising.
18
+ module IccProfile
19
+ GEM_DATA_PATH = File.expand_path("../../../data/idml/srgb.icc", __dir__)
20
+ MACOS_SYSTEM_PATH = "/System/Library/ColorSync/Profiles/sRGB Profile.icc"
21
+
22
+ def self.srgb_bytes
23
+ candidate_paths.each do |path|
24
+ bytes = read_if_present(path)
25
+ return bytes if bytes
26
+ end
27
+ nil
28
+ end
29
+
30
+ def self.candidate_paths
31
+ [
32
+ ENV.fetch("IDML_SRGB_ICC", nil),
33
+ GEM_DATA_PATH,
34
+ MACOS_SYSTEM_PATH,
35
+ ].compact
36
+ end
37
+ private_class_method :candidate_paths
38
+
39
+ def self.read_if_present(path)
40
+ return nil unless path && !path.empty?
41
+ return nil unless File.exist?(path)
42
+
43
+ File.binread(path)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+ private_class_method :read_if_present
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Idml
6
+ module Render
7
+ # Builds the PDF Info-dict metadata hash by merging XMP-extracted
8
+ # values from `META-INF/metadata.xml` over sensible defaults.
9
+ # Extracted from `Pipeline` for SRP — Pipeline orchestrates
10
+ # rendering, this module owns metadata assembly.
11
+ class MetadataBuilder
12
+ XMP_PATH = "META-INF/metadata.xml"
13
+
14
+ def initialize(package)
15
+ @package = package
16
+ end
17
+
18
+ # Returns a hash with Producer/CreationDate defaults, overridden
19
+ # by any XMP-supplied fields (Title, Author, Subject, Keywords,
20
+ # Creator, CreationDate, ModDate). XMP dates are converted from
21
+ # ISO 8601 to PDF `D:YYYYMMDDHHmmss` format.
22
+ def build
23
+ defaults.merge(xmp_fields) { |_key, default, xmp| xmp || default }
24
+ end
25
+
26
+ private
27
+
28
+ def defaults
29
+ {
30
+ Producer: "idml gem v#{Idml::VERSION}",
31
+ CreationDate: pdf_date(Time.now.utc),
32
+ }
33
+ end
34
+
35
+ def xmp_fields
36
+ return {} unless xmp_packet
37
+
38
+ {
39
+ Title: xmp_packet.title,
40
+ Author: xmp_packet.author,
41
+ Subject: xmp_packet.description,
42
+ Keywords: xmp_packet.keywords,
43
+ Creator: xmp_packet.creator_tool,
44
+ CreationDate: pdf_date_string(xmp_packet.create_date),
45
+ ModDate: pdf_date_string(xmp_packet.modify_date),
46
+ }.compact
47
+ end
48
+
49
+ def xmp_packet
50
+ return nil unless @package.has_part?(XMP_PATH)
51
+
52
+ @xmp_packet ||= begin
53
+ xml = @package.read_part(XMP_PATH)
54
+ Parts::XmpMeta.from_xml(xml).rdf
55
+ rescue StandardError
56
+ nil
57
+ end
58
+ end
59
+
60
+ def pdf_date_string(iso8601)
61
+ return nil unless iso8601
62
+
63
+ pdf_date(Time.iso8601(iso8601))
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
68
+ def pdf_date(time)
69
+ time.strftime("D:%Y%m%d%H%M%S+00'00'")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -83,6 +83,16 @@ module Idml
83
83
  page: page, mcid: mcid)
84
84
  end
85
85
 
86
+ def add_uri_link_annotation(page_index:, rect:, url:)
87
+ page = @document.pages[page_index]
88
+ action = @document.add({ S: :URI, URI: url },
89
+ type: Pdfrb::Model::Cos::Dictionary)
90
+ action_ref = Pdfrb::Model::Reference.new(action.oid, action.gen)
91
+ annot = @document.annotations.add(page, subtype: :Link, rect: rect)
92
+ annot.value[:A] = action_ref
93
+ annot
94
+ end
95
+
86
96
  def build_structure
87
97
  @document.structure.build!
88
98
  end
@@ -21,10 +21,13 @@ module Idml
21
21
 
22
22
  def call
23
23
  writer = PdfrbWriter.new
24
- metadata = combined_metadata
24
+ metadata = MetadataBuilder.new(@package).build
25
25
  writer.set_info(metadata)
26
26
  writer.enable_tagged if @tagged
27
- PdfaPacket.attach(writer.document, metadata) if pdfa_requested?
27
+ if pdfa_requested?
28
+ PdfaPacket.attach(writer.document, metadata)
29
+ embed_pdfa_output_intent(writer)
30
+ end
28
31
  structure = StructureTracker.new(enabled: @tagged)
29
32
  layer_filter = LayerFilter.from_designmap(@package.designmap)
30
33
  font_ref_resolver = FontReferenceResolver.build(@package)
@@ -42,6 +45,7 @@ module Idml
42
45
 
43
46
  structure.flush(writer)
44
47
  writer.build_structure if @tagged
48
+ emit_bookmarks(writer)
45
49
  writer.subset_fonts! if @subset_fonts
46
50
  writer.write(@output_path)
47
51
  @output_path
@@ -49,6 +53,26 @@ module Idml
49
53
 
50
54
  private
51
55
 
56
+ def embed_pdfa_output_intent(writer)
57
+ bytes = IccProfile.srgb_bytes
58
+ return unless bytes
59
+
60
+ writer.document.output_intents.embed_icc(
61
+ bytes,
62
+ identifier: "sRGB",
63
+ condition: "sRGB IEC61966-2.1",
64
+ subtype: :GTS_PDFA1,
65
+ )
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ def emit_bookmarks(writer)
71
+ BookmarkResolver.new(@package).each do |title, page_index|
72
+ writer.add_bookmark(title, page_index)
73
+ end
74
+ end
75
+
52
76
  def pdfa_requested?
53
77
  @compliance&.to_s&.start_with?("pdfa")
54
78
  end
@@ -71,10 +95,20 @@ module Idml
71
95
  page_height: dims[:height],
72
96
  image_refs: image_refs,
73
97
  page_index: current)
98
+ emit_hyperlinks(writer, spread, current, layer_filter)
74
99
  end
75
100
  current
76
101
  end
77
102
 
103
+ def emit_hyperlinks(writer, spread, page_index, layer_filter)
104
+ HyperlinkEmitter.new(writer: writer, package: @package,
105
+ page_height: DEFAULT_HEIGHT,
106
+ layer_filter: layer_filter)
107
+ .emit_for(spread, page_index)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
78
112
  def build_renderer(layer_filter, font_ref_resolver, font_resource,
79
113
  font_metrics, structure:)
80
114
  SpreadRenderer.new(
@@ -135,60 +169,6 @@ module Idml
135
169
  end
136
170
  nil
137
171
  end
138
-
139
- def combined_metadata
140
- defaults = default_metadata
141
- xmp_metadata.each do |key, value|
142
- next if value.nil? || value.empty?
143
-
144
- defaults[key] = value
145
- end
146
- defaults
147
- end
148
-
149
- def xmp_metadata
150
- return {} unless @package.has_part?(XMP_PATH)
151
-
152
- xml = @package.read_part(XMP_PATH)
153
- meta = Parts::XmpMeta.from_xml(xml)
154
- rdf = meta.rdf
155
- return {} unless rdf
156
-
157
- {
158
- Title: rdf.title,
159
- Author: rdf.author,
160
- Subject: rdf.description,
161
- Keywords: rdf.keywords,
162
- Creator: rdf.creator_tool,
163
- CreationDate: pdf_date_string(rdf.create_date),
164
- ModDate: pdf_date_string(rdf.modify_date),
165
- }
166
- rescue StandardError
167
- {}
168
- end
169
-
170
- def pdf_date_string(iso8601)
171
- return nil unless iso8601
172
-
173
- time = Time.iso8601(iso8601)
174
- pdf_date(time)
175
- rescue ArgumentError
176
- nil
177
- end
178
-
179
- def default_metadata
180
- {
181
- Producer: "idml gem v#{Idml::VERSION}",
182
- CreationDate: pdf_date(Time.now.utc),
183
- }
184
- end
185
-
186
- def pdf_date(time)
187
- time.strftime("D:%Y%m%d%H%M%S+00'00'")
188
- end
189
-
190
- XMP_PATH = "META-INF/metadata.xml"
191
- private_constant :XMP_PATH
192
172
  end
193
173
  end
194
174
  end
@@ -3,7 +3,14 @@
3
3
  module Idml
4
4
  module Render
5
5
  module Renderers
6
+ # Renders an IDML Table. Draws the cell grid via rectangle ops,
7
+ # then renders inline `<CharacterStyleRange>` text in each cell
8
+ # via `canvas.text_rich`. Cells with no text render as empty
9
+ # rectangles.
6
10
  class TableRenderer
11
+ DEFAULT_SIZE = 10.0
12
+ INSET = 4.0
13
+
7
14
  def self.render(canvas, context)
8
15
  table = context.item
9
16
  return if table.visible == false
@@ -17,24 +24,41 @@ module Idml
17
24
 
18
25
  canvas.save_graphics_state do
19
26
  table.table_row.each_with_index do |row, row_index|
20
- render_row(canvas, row, row_index, row_count, box, row_height)
27
+ render_row(canvas, row, row_index, row_count, box, row_height,
28
+ context)
21
29
  end
22
30
  end
23
31
  end
24
32
 
25
- def self.render_row(canvas, row, row_index, row_count, box, row_height)
33
+ def self.render_row(canvas, row, row_index, row_count, box, row_height,
34
+ context)
26
35
  row_y = box[:y] + ((row_count - 1 - row_index) * row_height)
27
36
  cell_count = row.table_cell.length
28
37
  return unless cell_count.positive?
29
38
 
30
39
  cell_width = box[:width] / cell_count
31
- row.table_cell.each_with_index do |_cell, cell_index|
40
+ row.table_cell.each_with_index do |cell, cell_index|
32
41
  cell_x = box[:x] + (cell_index * cell_width)
33
42
  canvas.rectangle(cell_x, row_y, cell_width, row_height)
34
43
  canvas.stroke
44
+ render_cell_text(canvas, cell, cell_x, row_y, row_height, context)
35
45
  end
36
46
  end
37
47
  private_class_method :render_row
48
+
49
+ def self.render_cell_text(canvas, cell, x, y, height, context)
50
+ text = cell.text_content
51
+ return if text.nil? || text.empty?
52
+
53
+ runs = [{
54
+ text: text,
55
+ font: context.font_ps_name,
56
+ size: DEFAULT_SIZE,
57
+ }]
58
+ baseline = y + (height / 2)
59
+ canvas.text_rich(runs, at: [x + INSET, baseline])
60
+ end
61
+ private_class_method :render_cell_text
38
62
  end
39
63
  end
40
64
  end