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.
@@ -21,18 +21,24 @@ module Idml
21
21
 
22
22
  def call
23
23
  writer = PdfrbWriter.new
24
- writer.set_info(default_metadata)
24
+ metadata = combined_metadata
25
+ writer.set_info(metadata)
25
26
  writer.enable_tagged if @tagged
27
+ PdfaPacket.attach(writer.document, metadata) if pdfa_requested?
28
+ structure = StructureTracker.new(enabled: @tagged)
26
29
  layer_filter = LayerFilter.from_designmap(@package.designmap)
27
30
  font_ref_resolver = FontReferenceResolver.build(@package)
28
31
  base_dir = File.dirname(@package.path)
29
32
  font_name = register_font(writer)
33
+ page_index = -1
30
34
 
31
35
  @package.spreads.each do |spread|
32
- render_spread_pages(writer, spread, base_dir, layer_filter,
33
- font_ref_resolver, font_name)
36
+ page_index = render_spread_pages(writer, spread, base_dir,
37
+ layer_filter, font_ref_resolver,
38
+ font_name, structure, page_index)
34
39
  end
35
40
 
41
+ structure.flush(writer)
36
42
  writer.build_structure if @tagged
37
43
  writer.subset_fonts! if @subset_fonts
38
44
  writer.write(@output_path)
@@ -41,113 +47,43 @@ module Idml
41
47
 
42
48
  private
43
49
 
50
+ def pdfa_requested?
51
+ @compliance&.to_s&.start_with?("pdfa")
52
+ end
53
+
44
54
  def render_spread_pages(writer, spread, base_dir, layer_filter,
45
- font_ref_resolver, font_name)
55
+ font_ref_resolver, font_name, structure,
56
+ page_offset)
46
57
  pages = spread.spread.flat_map(&:page)
47
- image_refs = collect_images(writer, spread, base_dir, layer_filter)
48
- renderer = build_renderer(layer_filter, font_ref_resolver, font_name)
58
+ image_refs = ImageCollector.new(writer: writer, base_dir: base_dir,
59
+ page_height: DEFAULT_HEIGHT).collect(spread)
60
+ renderer = build_renderer(layer_filter, font_ref_resolver, font_name,
61
+ structure: structure)
62
+ current = page_offset
49
63
 
50
64
  pages.each do |page|
65
+ current += 1
51
66
  dims = page_dimensions_for(page)
52
67
  canvas = writer.add_page(width: dims[:width], height: dims[:height])
53
68
  renderer.render(canvas, spread, page_width: dims[:width],
54
69
  page_height: dims[:height],
55
- image_refs: image_refs)
70
+ image_refs: image_refs,
71
+ page_index: current)
56
72
  end
73
+ current
57
74
  end
58
75
 
59
- def build_renderer(layer_filter, font_ref_resolver, font_name)
76
+ def build_renderer(layer_filter, font_ref_resolver, font_name, structure:)
60
77
  SpreadRenderer.new(
61
78
  font_resolver: @font_resolver,
62
79
  font_ps_name: font_name,
63
80
  package: @package,
64
81
  layer_filter: layer_filter,
65
82
  font_ref_resolver: font_ref_resolver,
83
+ structure: structure,
66
84
  )
67
85
  end
68
86
 
69
- def collect_images(writer, spread, base_dir, layer_filter)
70
- refs = []
71
- spread.each_page_item do |item|
72
- next unless layer_filter.visible?(item)
73
-
74
- images_for_item(item).each do |img|
75
- ref = register_image(writer, img, item, base_dir)
76
- refs << ref if ref
77
- end
78
- end
79
- refs
80
- end
81
-
82
- def images_for_item(item)
83
- case item
84
- when Idml::Elements::Rectangle, Idml::Elements::Polygon
85
- item.image
86
- else
87
- []
88
- end
89
- end
90
-
91
- def register_image(writer, image, parent, base_dir)
92
- uri = image.resource_uri
93
- return nil unless uri
94
-
95
- existing = writer.image_name_for(uri)
96
- return reuse_image(writer, existing, image, parent) if existing
97
-
98
- load_new_image(writer, image, parent, base_dir, uri)
99
- end
100
-
101
- def reuse_image(_writer, name, image, parent)
102
- { name: name, placement: compute_placement(image, parent),
103
- clip_box: parent_clip_box(parent) }
104
- end
105
-
106
- def load_new_image(writer, image, parent, base_dir, uri)
107
- path = Image.resolve_path(uri, base_dir: base_dir)
108
- return nil unless File.exist?(path)
109
-
110
- data = File.binread(path)
111
- dims = image_dimensions(data)
112
- return nil unless dims
113
-
114
- name = writer.add_image(data: data)
115
- writer.register_image_name(uri, name)
116
- { name: name, placement: compute_placement(image, parent, dims[1]),
117
- clip_box: parent_clip_box(parent) }
118
- end
119
-
120
- def parent_clip_box(parent)
121
- return nil unless parent.geometric_bounds
122
-
123
- Geometry.placement_rect(parent.geometric_bounds,
124
- parent.item_transform, DEFAULT_HEIGHT)
125
- end
126
-
127
- def image_dimensions(data)
128
- format = Image.detect_format(data)
129
- return nil unless format
130
-
131
- if format == :png
132
- Image.png_dimensions(data)
133
- else
134
- Image.jpeg_dimensions(data)
135
- end
136
- end
137
-
138
- def compute_placement(image, parent, pixel_height = 100)
139
- Image.compute_placement(
140
- image_transform: parse_transform_safe(image.item_transform),
141
- parent_transform: parse_transform_safe(parent.item_transform),
142
- pixel_height: pixel_height,
143
- page_height: DEFAULT_HEIGHT,
144
- )
145
- end
146
-
147
- def parse_transform_safe(str)
148
- Image.parse_transform(str) || Image.identity
149
- end
150
-
151
87
  def page_dimensions_for(page)
152
88
  { width: page.width || DEFAULT_WIDTH,
153
89
  height: page.height || DEFAULT_HEIGHT }
@@ -190,6 +126,46 @@ module Idml
190
126
  nil
191
127
  end
192
128
 
129
+ def combined_metadata
130
+ defaults = default_metadata
131
+ xmp_metadata.each do |key, value|
132
+ next if value.nil? || value.empty?
133
+
134
+ defaults[key] = value
135
+ end
136
+ defaults
137
+ end
138
+
139
+ def xmp_metadata
140
+ return {} unless @package.has_part?(XMP_PATH)
141
+
142
+ xml = @package.read_part(XMP_PATH)
143
+ meta = Parts::XmpMeta.from_xml(xml)
144
+ rdf = meta.rdf
145
+ return {} unless rdf
146
+
147
+ {
148
+ Title: rdf.title,
149
+ Author: rdf.author,
150
+ Subject: rdf.description,
151
+ Keywords: rdf.keywords,
152
+ Creator: rdf.creator_tool,
153
+ CreationDate: pdf_date_string(rdf.create_date),
154
+ ModDate: pdf_date_string(rdf.modify_date),
155
+ }
156
+ rescue StandardError
157
+ {}
158
+ end
159
+
160
+ def pdf_date_string(iso8601)
161
+ return nil unless iso8601
162
+
163
+ time = Time.iso8601(iso8601)
164
+ pdf_date(time)
165
+ rescue ArgumentError
166
+ nil
167
+ end
168
+
193
169
  def default_metadata
194
170
  {
195
171
  Producer: "idml gem v#{Idml::VERSION}",
@@ -200,6 +176,9 @@ module Idml
200
176
  def pdf_date(time)
201
177
  time.strftime("D:%Y%m%d%H%M%S+00'00'")
202
178
  end
179
+
180
+ XMP_PATH = "META-INF/metadata.xml"
181
+ private_constant :XMP_PATH
203
182
  end
204
183
  end
205
184
  end
@@ -16,6 +16,8 @@ module Idml
16
16
  :page_width,
17
17
  :page_height,
18
18
  :layer_filter,
19
+ :structure,
20
+ :page_index,
19
21
  keyword_init: true,
20
22
  )
21
23
  end
@@ -6,15 +6,17 @@ module Idml
6
6
  class SpreadRenderer
7
7
  def initialize(font_resolver: nil, font_ps_name: Render::DEFAULT_FONT,
8
8
  package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
9
- font_ref_resolver: nil)
9
+ font_ref_resolver: nil, structure: nil)
10
10
  @font_resolver = font_resolver
11
11
  @font_ps_name = font_ps_name
12
12
  @package = package
13
13
  @layer_filter = layer_filter
14
14
  @font_ref_resolver = font_ref_resolver
15
+ @structure = structure
15
16
  end
16
17
 
17
- def render(canvas, spread, page_width:, page_height:, image_refs: [])
18
+ def render(canvas, spread, page_width:, page_height:, image_refs: [],
19
+ page_index: 0)
18
20
  context_base = {
19
21
  package: @package,
20
22
  font_resolver: @font_resolver,
@@ -24,6 +26,8 @@ module Idml
24
26
  page_width: page_width,
25
27
  page_height: page_height,
26
28
  layer_filter: @layer_filter,
29
+ structure: @structure,
30
+ page_index: page_index,
27
31
  }
28
32
 
29
33
  canvas.save_graphics_state do
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Maps IDML page-item classes to PDF structure types (PDF 1.7
6
+ # §14.8.4). Each visible page item becomes one structure element
7
+ # when tagged output is enabled.
8
+ #
9
+ # Mapping rationale:
10
+ # TextFrame → P (paragraph)
11
+ # Group → Sect (section, contains children)
12
+ # Table → Table
13
+ # Rectangle/Polygon with image → Figure
14
+ # Rectangle/Polygon without image → Path (decorative shape)
15
+ # GraphicLine → Path
16
+ #
17
+ # The PDF spec reserves lowercase for standard types; pdfrb's
18
+ # `add_element(type:)` accepts a Symbol which is serialised as
19
+ # `/Type`. We use Title-case Symbols to match the spec's typical
20
+ # spelling (P, Sect, Table, Figure, Path).
21
+ module StructureMapper
22
+ def self.type_for(item)
23
+ case item
24
+ when Idml::Elements::TextFrame then :P
25
+ when Idml::Elements::Group then :Sect
26
+ when Idml::Elements::Table then :Table
27
+ when Idml::Elements::GraphicLine then :Path
28
+ when Idml::Elements::Rectangle, Idml::Elements::Polygon
29
+ shape_type(item)
30
+ end
31
+ end
32
+
33
+ def self.shape_type(item)
34
+ image?(item) ? :Figure : :Path
35
+ end
36
+ private_class_method :shape_type
37
+
38
+ # Optional `Alt` text for Figure elements. IDML doesn't have a
39
+ # dedicated alt-text attribute on `<Image>`, but the parent
40
+ # page item's `Name` (if present) is a useful fallback.
41
+ def self.alt_for(item)
42
+ case item
43
+ when Idml::Elements::Rectangle, Idml::Elements::Polygon
44
+ image?(item) ? item.name : nil
45
+ end
46
+ end
47
+
48
+ def self.image?(item)
49
+ Array(item.image).any?
50
+ end
51
+ private_class_method :image?
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Allocates MCID integers per page and buffers structure-element
6
+ # registrations until `Pipeline` is ready to flush them. Created
7
+ # once per pipeline run; `enabled?` is false when `tagged:` was
8
+ # not requested, in which case every method is a no-op.
9
+ class StructureTracker
10
+ def initialize(enabled: false)
11
+ @enabled = enabled
12
+ @entries = []
13
+ @page_mcids = Hash.new(0)
14
+ end
15
+
16
+ def enabled?
17
+ @enabled
18
+ end
19
+
20
+ # Returns the next MCID for the given page index. MCIDs are
21
+ # per-page in PDF (reset to 0 on each new page).
22
+ def next_mcid(page_index)
23
+ current = @page_mcids[page_index]
24
+ @page_mcids[page_index] = current + 1
25
+ current
26
+ end
27
+
28
+ def add(type, page_index:, mcid:, text: nil, alt: nil)
29
+ return unless @enabled
30
+
31
+ @entries << Entry.new(type, page_index, mcid, text, alt)
32
+ end
33
+
34
+ # Push buffered entries into the writer in registration order.
35
+ def flush(writer)
36
+ return unless @enabled
37
+
38
+ @entries.each do |entry|
39
+ writer.add_structure_element(entry.type, page_index: entry.page_index,
40
+ mcid: entry.mcid,
41
+ text: entry.text,
42
+ alt: entry.alt)
43
+ end
44
+ end
45
+
46
+ Entry = Struct.new(:type, :page_index, :mcid, :text, :alt, keyword_init: true) do
47
+ def initialize(type, page_index, mcid, text, alt)
48
+ super(type: type, page_index: page_index, mcid: mcid,
49
+ text: text, alt: alt)
50
+ end
51
+ end
52
+ private_constant :Entry
53
+ end
54
+ end
55
+ end
data/lib/idml/render.rb CHANGED
@@ -15,6 +15,10 @@ module Idml
15
15
  autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
16
16
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
17
17
  autoload :Placement, "#{__dir__}/render/placement"
18
+ autoload :ImageCollector, "#{__dir__}/render/image_collector"
19
+ autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
20
+ autoload :StructureMapper, "#{__dir__}/render/structure_mapper"
21
+ autoload :PdfaPacket, "#{__dir__}/render/pdfa_packet"
18
22
  autoload :StoryThreader, "#{__dir__}/render/story_threader"
19
23
  autoload :PdfrbWriter, "#{__dir__}/render/pdfrb_writer"
20
24
  autoload :SpreadRenderer, "#{__dir__}/render/spread_renderer"
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.2.8"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -218,6 +218,8 @@ files:
218
218
  - TODO.pdf/75-pdf-metadata-from-xmp.md
219
219
  - TODO.pdf/76-tagged-pdf-structure.md
220
220
  - TODO.pdf/77-pdfa-xmp-output-intent.md
221
+ - TODO.pdf/78-hyperlinks.md
222
+ - TODO.pdf/79-bookmarks-outline.md
221
223
  - TODO.pdf/README.md
222
224
  - exe/idml
223
225
  - idml.gemspec
@@ -381,6 +383,7 @@ files:
381
383
  - lib/idml/parts/style.rb
382
384
  - lib/idml/parts/style_mapping.rb
383
385
  - lib/idml/parts/tags.rb
386
+ - lib/idml/parts/xmp.rb
384
387
  - lib/idml/render.rb
385
388
  - lib/idml/render/blending.rb
386
389
  - lib/idml/render/color_helper.rb
@@ -388,8 +391,10 @@ files:
388
391
  - lib/idml/render/font_reference_resolver.rb
389
392
  - lib/idml/render/gradient_resolver.rb
390
393
  - lib/idml/render/image.rb
394
+ - lib/idml/render/image_collector.rb
391
395
  - lib/idml/render/layer_filter.rb
392
396
  - lib/idml/render/page_item_renderer.rb
397
+ - lib/idml/render/pdfa_packet.rb
393
398
  - lib/idml/render/pdfrb_writer.rb
394
399
  - lib/idml/render/pipeline.rb
395
400
  - lib/idml/render/placement.rb
@@ -404,6 +409,8 @@ files:
404
409
  - lib/idml/render/spread_renderer.rb
405
410
  - lib/idml/render/story_threader.rb
406
411
  - lib/idml/render/stroke_style.rb
412
+ - lib/idml/render/structure_mapper.rb
413
+ - lib/idml/render/structure_tracker.rb
407
414
  - lib/idml/render/style_resolver.rb
408
415
  - lib/idml/text_engine.rb
409
416
  - lib/idml/text_engine/cjk_layout.rb