idml 0.3.0 → 0.4.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -18
  3. data/TODO.pdf/63-replace-fontmetrics-with-pdfrb.md +59 -58
  4. data/TODO.pdf/65-pdfrb-019-integration.md +57 -29
  5. data/TODO.pdf/67-text-rich-multi-run.md +35 -39
  6. data/TODO.pdf/78-hyperlinks.md +54 -74
  7. data/TODO.pdf/79-bookmarks-outline.md +41 -67
  8. data/TODO.pdf/80-paragraph-alignment.md +39 -0
  9. data/TODO.pdf/81-pdfa-icc-output-intent.md +51 -0
  10. data/TODO.pdf/82-table-cell-text.md +54 -0
  11. data/idml.gemspec +0 -1
  12. data/lib/idml/elements/bookmark.rb +25 -0
  13. data/lib/idml/elements/hyperlink.rb +27 -0
  14. data/lib/idml/elements/hyperlink_page_destination.rb +34 -0
  15. data/lib/idml/elements/hyperlink_url_destination.rb +25 -0
  16. data/lib/idml/elements/table_cell.rb +14 -1
  17. data/lib/idml/elements.rb +6 -0
  18. data/lib/idml/parts/designmap.rb +12 -0
  19. data/lib/idml/render/bookmark_resolver.rb +87 -0
  20. data/lib/idml/render/hyperlink_emitter.rb +74 -0
  21. data/lib/idml/render/hyperlink_resolver.rb +68 -0
  22. data/lib/idml/render/icc_profile.rb +50 -0
  23. data/lib/idml/render/pdfrb_writer.rb +10 -0
  24. data/lib/idml/render/pipeline.rb +60 -18
  25. data/lib/idml/render/render_context.rb +1 -1
  26. data/lib/idml/render/renderers/group_renderer.rb +3 -1
  27. data/lib/idml/render/renderers/table_renderer.rb +27 -3
  28. data/lib/idml/render/renderers/text_frame_renderer.rb +54 -39
  29. data/lib/idml/render/spread_renderer.rb +3 -3
  30. data/lib/idml/render/style_resolver.rb +23 -2
  31. data/lib/idml/render.rb +4 -0
  32. data/lib/idml/text_engine/pdfrb_font_metrics.rb +81 -0
  33. data/lib/idml/text_engine.rb +1 -2
  34. data/lib/idml/version.rb +1 -1
  35. metadata +13 -17
  36. data/lib/idml/text_engine/font_metrics.rb +0 -226
  37. data/lib/idml/text_engine/font_resolver.rb +0 -105
@@ -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
@@ -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
@@ -13,7 +13,7 @@ module Idml
13
13
  compliance: nil, tagged: false, subset_fonts: true)
14
14
  @package = package
15
15
  @output_path = output_path
16
- @font_resolver = build_font_resolver(font_search_paths)
16
+ @font_search_paths = font_search_paths
17
17
  @compliance = compliance
18
18
  @tagged = tagged
19
19
  @subset_fonts = subset_fonts
@@ -24,22 +24,28 @@ module Idml
24
24
  metadata = combined_metadata
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)
31
34
  base_dir = File.dirname(@package.path)
32
- font_name = register_font(writer)
35
+ font_resource = register_font(writer)
36
+ font_metrics = build_font_metrics(writer, font_resource)
33
37
  page_index = -1
34
38
 
35
39
  @package.spreads.each do |spread|
36
40
  page_index = render_spread_pages(writer, spread, base_dir,
37
41
  layer_filter, font_ref_resolver,
38
- font_name, structure, page_index)
42
+ font_resource, font_metrics,
43
+ structure, page_index)
39
44
  end
40
45
 
41
46
  structure.flush(writer)
42
47
  writer.build_structure if @tagged
48
+ emit_bookmarks(writer)
43
49
  writer.subset_fonts! if @subset_fonts
44
50
  writer.write(@output_path)
45
51
  @output_path
@@ -47,18 +53,38 @@ module Idml
47
53
 
48
54
  private
49
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
+
50
76
  def pdfa_requested?
51
77
  @compliance&.to_s&.start_with?("pdfa")
52
78
  end
53
79
 
54
80
  def render_spread_pages(writer, spread, base_dir, layer_filter,
55
- font_ref_resolver, font_name, structure,
56
- page_offset)
81
+ font_ref_resolver, font_resource, font_metrics,
82
+ structure, page_offset)
57
83
  pages = spread.spread.flat_map(&:page)
58
84
  image_refs = ImageCollector.new(writer: writer, base_dir: base_dir,
59
85
  page_height: DEFAULT_HEIGHT).collect(spread)
60
- renderer = build_renderer(layer_filter, font_ref_resolver, font_name,
61
- structure: structure)
86
+ renderer = build_renderer(layer_filter, font_ref_resolver,
87
+ font_resource, font_metrics, structure: structure)
62
88
  current = page_offset
63
89
 
64
90
  pages.each do |page|
@@ -69,14 +95,23 @@ module Idml
69
95
  page_height: dims[:height],
70
96
  image_refs: image_refs,
71
97
  page_index: current)
98
+ emit_hyperlinks(writer, spread, current)
72
99
  end
73
100
  current
74
101
  end
75
102
 
76
- def build_renderer(layer_filter, font_ref_resolver, font_name, structure:)
103
+ def emit_hyperlinks(writer, spread, page_index)
104
+ HyperlinkEmitter.new(writer: writer, package: @package,
105
+ page_height: DEFAULT_HEIGHT).emit_for(spread, page_index)
106
+ rescue StandardError
107
+ nil
108
+ end
109
+
110
+ def build_renderer(layer_filter, font_ref_resolver, font_resource,
111
+ font_metrics, structure:)
77
112
  SpreadRenderer.new(
78
- font_resolver: @font_resolver,
79
- font_ps_name: font_name,
113
+ font_metrics: font_metrics,
114
+ font_ps_name: font_resource,
80
115
  package: @package,
81
116
  layer_filter: layer_filter,
82
117
  font_ref_resolver: font_ref_resolver,
@@ -89,14 +124,15 @@ module Idml
89
124
  height: page.height || DEFAULT_HEIGHT }
90
125
  end
91
126
 
92
- def build_font_resolver(paths)
93
- search = paths || TextEngine::FontResolver::DEFAULT_SEARCH_PATHS
94
- TextEngine::FontResolver.new(search_paths: search)
127
+ def build_font_metrics(writer, font_resource)
128
+ return nil if font_resource == Render::DEFAULT_FONT
129
+
130
+ TextEngine::PdfrbFontMetrics.new(writer.document.fonts, font_resource)
131
+ rescue StandardError
132
+ nil
95
133
  end
96
134
 
97
135
  def register_font(writer)
98
- return Render::DEFAULT_FONT unless @font_resolver
99
-
100
136
  path = resolve_document_font_path
101
137
  return Render::DEFAULT_FONT unless path
102
138
 
@@ -105,6 +141,12 @@ module Idml
105
141
  Render::DEFAULT_FONT
106
142
  end
107
143
 
144
+ def font_resolver
145
+ @font_resolver ||= Pdfrb::FontResolver.new(
146
+ search_paths: @font_search_paths || Pdfrb::FontResolver::DEFAULT_SEARCH_PATHS,
147
+ )
148
+ end
149
+
108
150
  def resolve_document_font_path
109
151
  return nil unless @package&.fonts
110
152
 
@@ -120,8 +162,8 @@ module Idml
120
162
  next unless font.post_script_name
121
163
  next if font.status == "Missing"
122
164
 
123
- metrics = @font_resolver.resolve_by_ps_name(font.post_script_name)
124
- return metrics.path if metrics
165
+ path = font_resolver.find_by_ps_name(font.post_script_name)
166
+ return path if path
125
167
  end
126
168
  nil
127
169
  end
@@ -9,7 +9,7 @@ module Idml
9
9
  RenderContext = Struct.new(
10
10
  :item,
11
11
  :package,
12
- :font_resolver,
12
+ :font_metrics,
13
13
  :font_ref_resolver,
14
14
  :color_resolver,
15
15
  :font_ps_name,
@@ -14,13 +14,15 @@ module Idml
14
14
  child_context = Render::RenderContext.new(
15
15
  item: child,
16
16
  package: context.package,
17
- font_resolver: context.font_resolver,
17
+ font_metrics: context.font_metrics,
18
18
  font_ref_resolver: context.font_ref_resolver,
19
19
  color_resolver: context.color_resolver,
20
20
  font_ps_name: context.font_ps_name,
21
21
  page_width: context.page_width,
22
22
  page_height: context.page_height,
23
23
  layer_filter: context.layer_filter,
24
+ structure: context.structure,
25
+ page_index: context.page_index,
24
26
  )
25
27
  PageItemRenderer.render(canvas, child_context)
26
28
  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
@@ -4,10 +4,10 @@ module Idml
4
4
  module Render
5
5
  module Renderers
6
6
  # Renders an IDML TextFrame on a Pdfrb::Content::Canvas. Extracts
7
- # styled runs via StyleResolver. When a FontMetrics is available,
8
- # runs the full text engine pipeline (Shaper → LineBreaker →
9
- # VerticalLayout) for proper word-wrap; otherwise falls back to
10
- # simple one-text-per-run positioning.
7
+ # styled runs via StyleResolver. When a FontMetrics is available
8
+ # (pdfrb-backed), runs the full text engine pipeline (Shaper →
9
+ # LineBreaker) for proper word-wrap; otherwise falls back to a
10
+ # single text_rich call per frame.
11
11
  class TextFrameRenderer
12
12
  DEFAULT_SIZE = 12.0
13
13
  LEADING_FACTOR = 1.2
@@ -28,7 +28,7 @@ module Idml
28
28
  end
29
29
 
30
30
  def self.render_text(canvas, runs, context, box)
31
- font = resolve_font_metrics(context)
31
+ font = context.font_metrics
32
32
  if font
33
33
  engine_render(canvas, runs, context, box, font)
34
34
  else
@@ -47,26 +47,6 @@ module Idml
47
47
  end
48
48
  private_class_method :frame_box
49
49
 
50
- def self.resolve_font_metrics(context)
51
- return nil unless context.font_resolver
52
-
53
- context.font_resolver.resolve(
54
- family_name: Render::DEFAULT_FONT, style_name: "Regular",
55
- )
56
- end
57
- private_class_method :resolve_font_metrics
58
-
59
- def self.simple_render(canvas, runs, context, box)
60
- runs.each_with_index do |run, index|
61
- y = box[:y] + box[:height] -
62
- ((index + 1) * run.point_size * LEADING_FACTOR)
63
- canvas.text(run.text, at: [box[:x], y],
64
- font: context.font_ps_name,
65
- size: run.point_size)
66
- end
67
- end
68
- private_class_method :simple_render
69
-
70
50
  def self.engine_render(canvas, runs, context, box, font)
71
51
  baseline_y = box[:y] + box[:height] - runs.first.point_size
72
52
 
@@ -78,6 +58,11 @@ module Idml
78
58
  end
79
59
  private_class_method :engine_render
80
60
 
61
+ # Emits one `canvas.text` call per line, after shaping and
62
+ # line-breaking with real font metrics. Applies paragraph
63
+ # alignment via `Justifier` so each line is offset within
64
+ # the frame box per IDML `Justification`. Lines that fall
65
+ # below the frame's bottom edge are clipped.
81
66
  def self.render_run_lines(canvas, run, context, box, font, baseline_y)
82
67
  size = run.point_size
83
68
  glyphs = TextEngine::Shaper.shape(
@@ -86,25 +71,55 @@ module Idml
86
71
  lines = TextEngine::LineBreaker.break(
87
72
  glyphs: glyphs, frame_width: box[:width],
88
73
  )
74
+ alignment = run.alignment || :left
75
+ start_y = box[:y] + box[:height] - size
76
+
77
+ lines.each_with_index do |line, idx|
78
+ line_y = start_y - (idx * size * LEADING_FACTOR)
79
+ break if line_y < box[:y]
80
+
81
+ TextEngine::Justifier.justify(line: line,
82
+ frame_width: box[:width],
83
+ alignment: alignment)
84
+ canvas.text(line_text(line),
85
+ at: [box[:x] + line.x_offset, line_y],
86
+ font: context.font_ps_name,
87
+ size: size)
88
+ end
89
+ baseline_y
90
+ end
91
+ private_class_method :render_run_lines
89
92
 
90
- line_texts = []
91
- lines.each do |line|
92
- break if baseline_y < box[:y]
93
+ def self.line_text(line)
94
+ line.glyphs.map { |g| [g.codepoint].pack("U") }.join
95
+ end
96
+ private_class_method :line_text
93
97
 
94
- line_texts << line.glyphs.map { |g| [g.codepoint].pack("U") }.join
95
- baseline_y -= size * LEADING_FACTOR
96
- end
98
+ # Fallback when no metrics are available: emit all runs as
99
+ # one `text_rich` block, letting pdfrb measure advance widths.
100
+ # Uses pdfrb's measurement API directly (no Fontisan).
101
+ def self.simple_render(canvas, runs, context, box)
102
+ runs_for = build_rich_runs(runs, context)
103
+ return if runs_for.empty?
97
104
 
98
- if line_texts.any?
99
- canvas.text_lines(line_texts,
100
- font: context.font_ps_name,
101
- size: size,
102
- at: [box[:x], box[:y] + box[:height] - size],
103
- leading: size * LEADING_FACTOR)
105
+ first_size = runs.first.point_size
106
+ canvas.text_rich(
107
+ runs_for,
108
+ at: [box[:x], box[:y] + box[:height] - first_size],
109
+ )
110
+ end
111
+ private_class_method :simple_render
112
+
113
+ def self.build_rich_runs(runs, context)
114
+ runs.map do |run|
115
+ {
116
+ text: run.text,
117
+ font: context.font_ps_name,
118
+ size: run.point_size,
119
+ }
104
120
  end
105
- baseline_y
106
121
  end
107
- private_class_method :render_run_lines
122
+ private_class_method :build_rich_runs
108
123
  end
109
124
  end
110
125
  end
@@ -4,10 +4,10 @@ module Idml
4
4
  module Render
5
5
  # Renders a typed `Parts::Spread` onto a Pdfrb::Content::Canvas.
6
6
  class SpreadRenderer
7
- def initialize(font_resolver: nil, font_ps_name: Render::DEFAULT_FONT,
7
+ def initialize(font_metrics: nil, font_ps_name: Render::DEFAULT_FONT,
8
8
  package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
9
9
  font_ref_resolver: nil, structure: nil)
10
- @font_resolver = font_resolver
10
+ @font_metrics = font_metrics
11
11
  @font_ps_name = font_ps_name
12
12
  @package = package
13
13
  @layer_filter = layer_filter
@@ -19,7 +19,7 @@ module Idml
19
19
  page_index: 0)
20
20
  context_base = {
21
21
  package: @package,
22
- font_resolver: @font_resolver,
22
+ font_metrics: @font_metrics,
23
23
  font_ref_resolver: @font_ref_resolver,
24
24
  color_resolver: build_color_resolver,
25
25
  font_ps_name: @font_ps_name,
@@ -4,15 +4,30 @@ module Idml
4
4
  module Render
5
5
  # Extracts styled text runs from an IDML Story. Each run carries the
6
6
  # text content plus the CharacterStyleRange attributes that affect
7
- # rendering (font style, size, fill color, applied font reference).
7
+ # rendering (font style, size, fill color, applied font reference,
8
+ # paragraph alignment).
8
9
  class StyleResolver
9
10
  StyledRun = Struct.new(
10
11
  :text, :font_style, :point_size,
11
- :fill_color, :fill_tint, :applied_font, keyword_init: true
12
+ :fill_color, :fill_tint, :applied_font, :alignment,
13
+ keyword_init: true
12
14
  )
13
15
 
14
16
  DEFAULT_POINT_SIZE = 12.0
15
17
 
18
+ # IDML `Justification` enum → Justifier symbol. Full justify
19
+ # and binding-side variants defer to :left for now (TODO 80).
20
+ ALIGNMENT_MAP = {
21
+ "Left" => :left,
22
+ "Center" => :center,
23
+ "Right" => :right,
24
+ "LeftJustified" => :left,
25
+ "RightJustified" => :right,
26
+ "CenterJustified" => :center,
27
+ "FullyJustified" => :justified,
28
+ "ToBinding" => :left,
29
+ }.freeze
30
+
16
31
  def self.extract_runs(story)
17
32
  return [] unless story&.inner
18
33
 
@@ -33,11 +48,17 @@ module Idml
33
48
  fill_color: csr.fill_color,
34
49
  fill_tint: csr.fill_tint,
35
50
  applied_font: csr.applied_font,
51
+ alignment: alignment_for(csr),
36
52
  )
37
53
  end
38
54
  end
39
55
  private_class_method :csr_runs
40
56
 
57
+ def self.alignment_for(csr)
58
+ ALIGNMENT_MAP[csr.justification] || :left
59
+ end
60
+ private_class_method :alignment_for
61
+
41
62
  # Concatenate runs into a single block. Used when the renderer
42
63
  # can't handle per-run styling (e.g., no font metrics available).
43
64
  def self.concatenate(runs)
data/lib/idml/render.rb CHANGED
@@ -19,6 +19,10 @@ module Idml
19
19
  autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
20
20
  autoload :StructureMapper, "#{__dir__}/render/structure_mapper"
21
21
  autoload :PdfaPacket, "#{__dir__}/render/pdfa_packet"
22
+ autoload :IccProfile, "#{__dir__}/render/icc_profile"
23
+ autoload :BookmarkResolver, "#{__dir__}/render/bookmark_resolver"
24
+ autoload :HyperlinkResolver, "#{__dir__}/render/hyperlink_resolver"
25
+ autoload :HyperlinkEmitter, "#{__dir__}/render/hyperlink_emitter"
22
26
  autoload :StoryThreader, "#{__dir__}/render/story_threader"
23
27
  autoload :PdfrbWriter, "#{__dir__}/render/pdfrb_writer"
24
28
  autoload :SpreadRenderer, "#{__dir__}/render/spread_renderer"
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module TextEngine
5
+ # Adapter that exposes the same measurement surface as
6
+ # `FontMetrics` (the Fontisan-based parser) but delegates to
7
+ # pdfrb's `Fonts` collection + a registered font resource Symbol.
8
+ #
9
+ # Once a font is registered with the pdfrb document via
10
+ # `document.fonts.add(path)`, pdfrb parses the TTF tables (cmap,
11
+ # hmtx, head, hhea) and exposes real per-glyph advance widths
12
+ # through `Fonts#glyph_width(resource, codepoint)` and
13
+ # `Fonts#metrics_for(resource)`. This adapter wraps that pair so
14
+ # the text engine (Shaper, LineBreaker) can use real metrics
15
+ # without any Fontisan dependency.
16
+ class PdfrbFontMetrics
17
+ attr_reader :resource, :fonts
18
+
19
+ def initialize(fonts_collection, resource)
20
+ @fonts = fonts_collection
21
+ @resource = resource
22
+ end
23
+
24
+ def units_per_em
25
+ metrics_data[:units_per_em] || 1000
26
+ end
27
+
28
+ def ascent
29
+ metrics_data[:ascent] || 800
30
+ end
31
+
32
+ def descent
33
+ metrics_data[:descent] || -200
34
+ end
35
+
36
+ def line_gap
37
+ 0
38
+ end
39
+
40
+ # Returns raw advance width in font units (not scaled by size).
41
+ def glyph_width(codepoint)
42
+ cp = codepoint.is_a?(String) ? codepoint.each_codepoint.first : codepoint
43
+ @fonts.glyph_width(@resource, cp).to_i
44
+ end
45
+
46
+ def measure_text(text, size:)
47
+ return 0.0 if text.nil? || text.empty?
48
+
49
+ text.each_codepoint.sum do |cp|
50
+ glyph_width(cp) * size.to_f / units_per_em
51
+ end
52
+ end
53
+
54
+ def kerning_pair(_left_cp, _right_cp)
55
+ 0
56
+ end
57
+
58
+ def postscript_name
59
+ @resource.to_s
60
+ end
61
+
62
+ def family_name
63
+ postscript_name
64
+ end
65
+
66
+ def style_name
67
+ "Regular"
68
+ end
69
+
70
+ def path
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ def metrics_data
77
+ @fonts.metrics_for(@resource) || {}
78
+ end
79
+ end
80
+ end
81
+ end