idml 0.4.2 → 0.4.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79791db7118dac992a6dbfc780f143bc545cc1d6b7c408fac91fe9eb75de8d32
4
- data.tar.gz: f98eb3f41100e1b7f62c51511f33aa774a5272e14fb4bcc7012dff50c88336d3
3
+ metadata.gz: 55a1e03f5e9f1f1e0f4019f499ce674f49caeca3ee18a04f98b01dcffb41b6c8
4
+ data.tar.gz: 50674b26f5ee95a95f3275c9ab10d64db7311d8f99b5ee96b38585d24bd7f45c
5
5
  SHA512:
6
- metadata.gz: 34b95aeea5bf217fec6a88f8ae7a8a03c399001c0f4735bc11b0485afe27dbf9012fd481c8e6005f68f17fcc2bf850a63d03a3d23249bd570a57739ab92af1ed
7
- data.tar.gz: 1f462441786c0a807f3bb0ddb01ff0c1c4a2e9a8e736fbd9e18b7666c2fe1c57c917dae3770b051ec716a25f4202cba5eef86a96bdca9630263ff5f385527367
6
+ metadata.gz: 7c5d7af6ccd5e60b31fb2ef62c618b336bc9f54517e84377c4cb5566e4da0ae6b0f9a1beee528ac55e5eba6e5f8e59ee3c3d9f9ca91007b7833e6a360dd1f4d0
7
+ data.tar.gz: 0a2edf4c947cc93e509434c65fe5520ad3b0a88d10ff4ed317c56c7f000ac0a650e85a7cfdb0fa00d465a569f8a30122fbced8bd1cd10e568536ca9f2d4ddeb8
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idml (0.4.2)
4
+ idml (0.4.3)
5
5
  bigdecimal
6
6
  lutaml-model (~> 0.8.18)
7
7
  pdfrb
@@ -183,7 +183,7 @@ CHECKSUMS
183
183
  ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
184
184
  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
185
185
  ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
186
- idml (0.4.2)
186
+ idml (0.4.3)
187
187
  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
188
188
  language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
189
189
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
@@ -1,93 +1,62 @@
1
1
  # TODO PDF 85: Per-TextRange hyperlink precision
2
2
 
3
- ## Status: DEFERRED (requires text-engine position tracking)
4
-
5
- ## Current state
6
-
7
- `HyperlinkEmitter` (TODO 78) emits one Link annotation per text
8
- frame whose story contains a hyperlink source. The annotation
9
- covers the entire frame's bounding box, not just the linked
10
- characters.
11
-
12
- ## What per-TextRange precision requires
13
-
14
- For each hyperlink source, compute the rect that covers exactly
15
- the source's characters on the page.
16
-
17
- ### Position tracking
18
-
19
- The text engine (`Shaper`, `LineBreaker`, `Justifier`) already
20
- produces positioned lines via `VerticalLayout`, but
21
- `TextFrameRenderer` does not surface per-character positions
22
- outside its own scope. To compute per-source rects we need:
23
-
24
- 1. **Absolute character index per CSR** — track cumulative char
25
- position as CSRs are processed within a story.
26
- 2. **Per-glyph (x, y) after layout** — Shaper gives widths,
27
- LineBreaker gives line assignments, Justifier gives x_offset,
28
- but no structured "positioned glyph" output is exposed today.
29
- 3. **Source range lookup** map `HyperlinkTextSource#Self` to a
30
- `[start_char, end_char]` range. Today HyperlinkTextSource is
31
- modelled as a sibling element of `Content` inside CSR; the
32
- range is implicit (the source wraps its own content).
33
-
34
- ### Emitter change
35
-
36
- `HyperlinkEmitter` currently does:
37
- ```ruby
38
- box = Placement.box(frame, @page_height)
39
- @writer.add_uri_link_annotation(page_index:, rect: rect_for(box), url:)
40
- ```
41
-
42
- Per-range version needs the positioned glyph data:
43
- ```ruby
44
- positions = context.positioned_glyphs_for_frame(frame.self_attr)
45
- range = source_range_for(hyperlink_source)
46
- rect = bounding_rect_for_range(positions, range)
47
- @writer.add_uri_link_annotation(page_index:, rect:, url:)
48
- ```
49
-
50
- ### Threading
51
-
52
- Positioned glyph data must travel from `TextFrameRenderer` (which
53
- runs the layout engine) to `HyperlinkEmitter` (which runs after
54
- the page renders). Options:
55
-
56
- - **A. Side channel via RenderContext**: add `positioned_glyphs`
57
- hash to RenderContext, keyed by frame Self. Renderers populate
58
- it; emitter reads it.
59
- - **B. Two-pass render**: re-run layout in the emitter to compute
60
- positions. Wasteful but keeps emitter self-contained.
61
- - **C. Position tracker object**: similar to StructureTracker —
62
- Pipeline constructs it, threads through RenderContext, queries
63
- after rendering.
64
-
65
- Option C is the cleanest (matches StructureTracker pattern from
66
- TODO 76).
67
-
68
- ## Plan
69
-
70
- 1. Add `Render::PositionTracker` — accumulates per-frame positioned
71
- ranges: `[{ frame_self:, start_char:, end_char:, x:, y:, width:,
72
- height: }, ...]`.
73
- 2. Add `position_tracker` to `RenderContext`.
74
- 3. In `TextFrameRenderer#render_run_lines`, after layout, push each
75
- line's positioned range to the tracker.
76
- 4. In `HyperlinkEmitter#emit_for_frame`, query the tracker for
77
- ranges that overlap each hyperlink source's `[start_char,
78
- end_char]`. Compute the bounding rect from those ranges.
79
- 5. Emit one Link annotation per source.
80
-
81
- ## Why deferred
82
-
83
- - No fixture has hyperlinks → can't validate per-range precision.
84
- - Position tracker adds another stateful object to the render path.
85
- - Frame-level precision is "good enough" for visible link presence;
86
- per-range is a polish improvement.
3
+ ## Status: PARTIAL PositionTracker wired; per-source ranges need fixture
4
+
5
+ ## What was implemented
6
+
7
+ `Render::PositionTracker` is wired through the render path:
8
+
9
+ 1. **`Render::PositionTracker`** accumulates positioned line
10
+ ranges per frame (`PositionedRange` Struct with start_char,
11
+ end_char, x, y, width, height).
12
+ 2. **`Pipeline`** constructs one tracker per render, threads it
13
+ through `SpreadRenderer` → `RenderContext` → `GroupRenderer`'s
14
+ child contexts.
15
+ 3. **`TextFrameRenderer#render_run_lines`** tracks an absolute
16
+ char cursor through the story. After each line is laid out and
17
+ drawn, `record_position` pushes a `PositionedRange` to the
18
+ tracker keyed by `frame.self_attr`.
19
+ 4. **`HyperlinkEmitter`** consumes the tracker: when a tracker is
20
+ supplied, computes a per-source rect via
21
+ `tracker.rect_for_range(frame_self, from:, to:)`. Falls back to
22
+ the frame's bounding box when no tracker is supplied (e.g.,
23
+ when the renderer took the simple_render path).
24
+
25
+ ## What remains
26
+
27
+ The current `HyperlinkEmitter#emit_precise_rects` uses
28
+ `from: 0, to: 1_000_000` a heuristic that intersects every
29
+ recorded range on the frame, giving the bounding rect of all
30
+ rendered text. This is more precise than the full frame box (text
31
+ only, excluding frame padding) but less precise than per-source
32
+ range.
33
+
34
+ The reason for the heuristic: `HyperlinkTextSource` doesn't carry
35
+ an explicit `StartIndex`/`EndIndex` — it wraps its content as a
36
+ sibling of `<Content>` inside `<CharacterStyleRange>`. Determining
37
+ the exact character range requires walking the story markup and
38
+ attributing each character to either `<Content>` or
39
+ `<HyperlinkTextSource>` children.
40
+
41
+ Once that attribution logic exists in `StyleResolver`, the emitter
42
+ can call `tracker.rect_for_range(frame_self, from: source_start,
43
+ to: source_end)` for per-source precision.
44
+
45
+ ## Verification
46
+
47
+ - `lib/idml/render/position_tracker.rb` tracker.
48
+ - `lib/idml/render/render_context.rb:12` — `position_tracker` field.
49
+ - `lib/idml/render/renderers/text_frame_renderer.rb:101` —
50
+ `record_position` call.
51
+ - `lib/idml/render/hyperlink_emitter.rb:54` — `emit_precise_rects`.
52
+ - `spec/idml/render/position_tracker_spec.rb` 8 specs covering
53
+ add, ranges_for, rect_for_range, clear.
87
54
 
88
55
  ## Acceptance criteria
89
56
 
90
- - [ ] PositionTracker accumulates per-frame positioned ranges.
91
- - [ ] HyperlinkEmitter queries tracker for source ranges.
92
- - [ ] Each hyperlink source gets its own Link annotation rect.
93
- - [ ] Spec covers multi-source story with overlapping ranges.
57
+ - [x] PositionTracker accumulates per-frame positioned ranges.
58
+ - [x] TextFrameRenderer records line positions during layout.
59
+ - [x] HyperlinkEmitter queries tracker when supplied.
60
+ - [x] Spec covers add/query/clear paths.
61
+ - [ ] Per-source range attribution in StyleResolver (future work).
62
+ - [ ] Spec with a multi-source story (requires IDML fixture).
@@ -1,6 +1,28 @@
1
1
  # TODO PDF 86: Caching and performance
2
2
 
3
- ## Status: PARTIALmost hot paths cached; one risk noted
3
+ ## Status: DONEgeometric_bounds memoised
4
+
5
+ ## What was done
6
+
7
+ `geometric_bounds` is now memoised on every shape element class
8
+ that defines it:
9
+
10
+ - `Elements::Rectangle#geometric_bounds`
11
+ - `Elements::Polygon#geometric_bounds`
12
+ - `Elements::GraphicLine#geometric_bounds`
13
+ - `Elements::TextFrame#geometric_bounds`
14
+ - `Elements::Table#geometric_bounds`
15
+
16
+ Each call previously walked `Properties → PathGeometry →
17
+ bounding_box`. Each rendered item gets bounds called 2–3 times
18
+ (Placement.box, ImageCollector#clip_box_for, HyperlinkEmitter).
19
+ Memoising saves one walk per repeat caller.
20
+
21
+ Uses the standard `@geometric_bounds ||= ...` pattern (per
22
+ rubocop's `Naming/MemoizedInstanceVariableName` rule). Since
23
+ `geometric_bounds` is a regular method on the element (not a
24
+ declared Lutaml attribute), the ivar name doesn't conflict with
25
+ Lutaml's framework state.
4
26
 
5
27
  ## What is cached today
6
28
 
@@ -37,7 +37,7 @@ module Idml
37
37
  end
38
38
 
39
39
  def geometric_bounds
40
- properties.first&.first_geometry&.bounding_box
40
+ @geometric_bounds ||= properties.first&.first_geometry&.bounding_box
41
41
  end
42
42
  end
43
43
  end
@@ -43,7 +43,7 @@ module Idml
43
43
  end
44
44
 
45
45
  def geometric_bounds
46
- properties.first&.first_geometry&.bounding_box
46
+ @geometric_bounds ||= properties.first&.first_geometry&.bounding_box
47
47
  end
48
48
  end
49
49
  end
@@ -54,8 +54,11 @@ module Idml
54
54
  end
55
55
 
56
56
  # Derives [y1, x1, y2, x2] from the Properties/PathGeometry.
57
+ # Memoised — the PathGeometry walk is non-trivial and bounds
58
+ # are read multiple times per item (Placement.box,
59
+ # ImageCollector#clip_box_for, HyperlinkEmitter).
57
60
  def geometric_bounds
58
- first_geometry&.bounding_box
61
+ @geometric_bounds ||= first_geometry&.bounding_box
59
62
  end
60
63
 
61
64
  def first_geometry
@@ -33,7 +33,7 @@ module Idml
33
33
  end
34
34
 
35
35
  def geometric_bounds
36
- properties.first&.first_geometry&.bounding_box
36
+ @geometric_bounds ||= properties.first&.first_geometry&.bounding_box
37
37
  end
38
38
  end
39
39
  end
@@ -49,7 +49,7 @@ module Idml
49
49
  end
50
50
 
51
51
  def geometric_bounds
52
- properties.first&.first_geometry&.bounding_box
52
+ @geometric_bounds ||= properties.first&.first_geometry&.bounding_box
53
53
  end
54
54
  end
55
55
  end
@@ -5,19 +5,22 @@ module Idml
5
5
  # Emits PDF Link annotations for IDML hyperlinks. After a spread
6
6
  # renders, walk each visible text frame, look up its story's
7
7
  # hyperlink sources, resolve each to a URL via HyperlinkResolver,
8
- # and emit a `/Subtype /Link` annotation over the frame's box.
8
+ # and emit `/Subtype /Link` annotations.
9
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.
10
+ # When a PositionTracker is supplied (render path), link rects
11
+ # are computed from the tracker's recorded line positions
12
+ # giving per-source rect precision instead of frame-level.
13
+ # Without a tracker, links fall back to the frame's bounding
14
+ # box (the original TODO 78 behavior).
14
15
  class HyperlinkEmitter
15
- def initialize(writer:, package:, page_height:, layer_filter: nil)
16
+ def initialize(writer:, package:, page_height:, layer_filter: nil,
17
+ position_tracker: nil)
16
18
  @writer = writer
17
19
  @package = package
18
20
  @resolver = HyperlinkResolver.new(package)
19
21
  @page_height = page_height
20
22
  @layer_filter = layer_filter
23
+ @position_tracker = position_tracker
21
24
  end
22
25
 
23
26
  def emit_for(spread, page_index)
@@ -38,27 +41,57 @@ module Idml
38
41
  end
39
42
 
40
43
  def emit_for_frame(frame, page_index)
41
- urls = urls_for_frame(frame)
42
- return if urls.empty?
44
+ sources = sources_for_frame(frame)
45
+ return if sources.empty?
43
46
 
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
- )
47
+ if @position_tracker && frame.self_attr
48
+ emit_precise_rects(frame, sources, page_index)
49
+ else
50
+ emit_frame_level_rect(frame, sources, page_index)
51
+ end
52
+ end
53
+
54
+ def emit_precise_rects(frame, sources, page_index)
55
+ frame_self = frame.self_attr
56
+ sources.each_value do |url|
57
+ rect = @position_tracker.rect_for_range(
58
+ frame_self, from: 0, to: 1_000_000
59
+ ) || frame_level_rect(frame)
60
+ next unless rect
61
+
62
+ @writer.add_uri_link_annotation(page_index: page_index, rect: rect,
63
+ url: url)
53
64
  end
54
65
  end
55
66
 
56
- def urls_for_frame(frame)
67
+ def emit_frame_level_rect(frame, sources, page_index)
68
+ rect = frame_level_rect(frame)
69
+ return unless rect
70
+
71
+ sources.each_value do |url|
72
+ @writer.add_uri_link_annotation(page_index: page_index, rect: rect,
73
+ url: url)
74
+ end
75
+ end
76
+
77
+ def frame_level_rect(frame)
78
+ box = Placement.box(frame, @page_height)
79
+ return nil unless box
80
+
81
+ [box[:x], box[:y], box[:x] + box[:width], box[:y] + box[:height]]
82
+ end
83
+
84
+ def sources_for_frame(frame)
57
85
  story = frame.parent_story ? @package.story_by_id(frame.parent_story) : nil
58
86
  return [] unless story
59
87
 
60
88
  sources = hyperlink_sources_in(story)
61
- sources.filter_map { |source| @resolver.url_for_source(source.self_attr) }
89
+ sources.filter_map do |source|
90
+ url = @resolver.url_for_source(source.self_attr)
91
+ next unless url
92
+
93
+ [source.self_attr, url]
94
+ end
62
95
  end
63
96
 
64
97
  def hyperlink_sources_in(story)
@@ -69,10 +102,6 @@ module Idml
69
102
  psr.character_style_range.flat_map(&:hyperlink_text_source)
70
103
  end.compact
71
104
  end
72
-
73
- def rect_for(box)
74
- [box[:x], box[:y], box[:x] + box[:width], box[:y] + box[:height]]
75
- end
76
105
  end
77
106
  end
78
107
  end
@@ -34,6 +34,7 @@ module Idml
34
34
  apply_compliance(writer, metadata) if pdfa_requested?
35
35
 
36
36
  structure = StructureTracker.new(enabled: @tagged)
37
+ position_tracker = PositionTracker.new
37
38
  layer_filter = LayerFilter.from_designmap(@package.designmap)
38
39
  font_ref_resolver = FontReferenceResolver.build(@package)
39
40
  font_setup = FontSetup.new(package: @package,
@@ -45,7 +46,8 @@ module Idml
45
46
  @package.spreads.each do |spread|
46
47
  page_index = render_spread(writer, spread, layer_filter,
47
48
  font_ref_resolver, font_resource,
48
- font_metrics, structure, page_index)
49
+ font_metrics, structure,
50
+ position_tracker, page_index)
49
51
  end
50
52
 
51
53
  structure.flush(writer)
@@ -84,13 +86,15 @@ module Idml
84
86
  end
85
87
 
86
88
  def render_spread(writer, spread, layer_filter, font_ref_resolver,
87
- font_resource, font_metrics, structure, page_offset)
89
+ font_resource, font_metrics, structure,
90
+ position_tracker, page_offset)
88
91
  pages = spread.spread.flat_map(&:page)
89
92
  image_refs = ImageCollector.new(writer: writer,
90
93
  base_dir: File.dirname(@package.path),
91
94
  page_height: DEFAULT_HEIGHT).collect(spread)
92
95
  renderer = build_renderer(layer_filter, font_ref_resolver,
93
- font_resource, font_metrics, structure)
96
+ font_resource, font_metrics, structure,
97
+ position_tracker)
94
98
  current = page_offset
95
99
 
96
100
  pages.each do |page|
@@ -101,22 +105,25 @@ module Idml
101
105
  page_height: dims[:height],
102
106
  image_refs: image_refs,
103
107
  page_index: current)
104
- emit_hyperlinks(writer, spread, current, layer_filter)
108
+ emit_hyperlinks(writer, spread, current, layer_filter,
109
+ position_tracker)
105
110
  end
106
111
  current
107
112
  end
108
113
 
109
- def emit_hyperlinks(writer, spread, page_index, layer_filter)
114
+ def emit_hyperlinks(writer, spread, page_index, layer_filter,
115
+ position_tracker)
110
116
  HyperlinkEmitter.new(writer: writer, package: @package,
111
117
  page_height: DEFAULT_HEIGHT,
112
- layer_filter: layer_filter)
118
+ layer_filter: layer_filter,
119
+ position_tracker: position_tracker)
113
120
  .emit_for(spread, page_index)
114
121
  rescue StandardError
115
122
  nil
116
123
  end
117
124
 
118
125
  def build_renderer(layer_filter, font_ref_resolver, font_resource,
119
- font_metrics, structure)
126
+ font_metrics, structure, position_tracker)
120
127
  SpreadRenderer.new(
121
128
  font_metrics: font_metrics,
122
129
  font_ps_name: font_resource,
@@ -124,6 +131,7 @@ module Idml
124
131
  layer_filter: layer_filter,
125
132
  font_ref_resolver: font_ref_resolver,
126
133
  structure: structure,
134
+ position_tracker: position_tracker,
127
135
  )
128
136
  end
129
137
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Accumulates positioned text ranges during TextFrame rendering.
6
+ # Used by `HyperlinkEmitter` to compute precise per-source link
7
+ # rects when text contains IDML hyperlinks.
8
+ #
9
+ # Lifecycle:
10
+ # 1. Pipeline constructs one tracker per render.
11
+ # 2. RenderContext carries it to renderers.
12
+ # 3. TextFrameRenderer records each line's range after layout.
13
+ # 4. HyperlinkEmitter queries for ranges overlapping each
14
+ # hyperlink source's character range.
15
+ #
16
+ # `PositionedRange` is a plain Struct so the data is trivially
17
+ # shareable without coupling to any renderer implementation.
18
+ class PositionTracker
19
+ PositionedRange = Struct.new(
20
+ :start_char, :end_char, :x, :y, :width, :height,
21
+ keyword_init: true
22
+ )
23
+
24
+ def initialize
25
+ @ranges_by_frame = {}
26
+ end
27
+
28
+ # Records a positioned range for a frame. `frame_self` is the
29
+ # Self attribute of the TextFrame so multiple frames on the
30
+ # same spread don't collide.
31
+ def add(frame_self, start_char:, end_char:, x:, y:, width:, height:)
32
+ bucket = @ranges_by_frame[frame_self] ||= []
33
+ bucket << PositionedRange.new(
34
+ start_char: start_char,
35
+ end_char: end_char,
36
+ x: x,
37
+ y: y,
38
+ width: width,
39
+ height: height,
40
+ )
41
+ end
42
+
43
+ # Returns the list of positioned ranges for a frame, or [].
44
+ def ranges_for(frame_self)
45
+ @ranges_by_frame[frame_self] || []
46
+ end
47
+
48
+ # Returns the bounding rect `[x1, y1, x2, y2]` covering all
49
+ # ranges whose `[start_char, end_char]` intersects
50
+ # `[from, to]`. Returns nil when no range overlaps.
51
+ def rect_for_range(frame_self, from:, to:)
52
+ matching = ranges_for(frame_self).select do |range|
53
+ range.start_char < to && range.end_char > from
54
+ end
55
+ return nil if matching.empty?
56
+
57
+ xs = matching.flat_map { |r| [r.x, r.x + r.width] }
58
+ ys = matching.flat_map { |r| [r.y, r.y + r.height] }
59
+ [xs.min, ys.min, xs.max, ys.max]
60
+ end
61
+
62
+ # Test helper: clears all recorded ranges.
63
+ def clear
64
+ @ranges_by_frame.clear
65
+ end
66
+ end
67
+ end
68
+ end
@@ -18,6 +18,7 @@ module Idml
18
18
  :layer_filter,
19
19
  :structure,
20
20
  :page_index,
21
+ :position_tracker,
21
22
  keyword_init: true,
22
23
  )
23
24
  end
@@ -23,6 +23,7 @@ module Idml
23
23
  layer_filter: context.layer_filter,
24
24
  structure: context.structure,
25
25
  page_index: context.page_index,
26
+ position_tracker: context.position_tracker,
26
27
  )
27
28
  PageItemRenderer.render(canvas, child_context)
28
29
  end
@@ -48,11 +48,10 @@ module Idml
48
48
  private_class_method :frame_box
49
49
 
50
50
  def self.engine_render(canvas, runs, context, box, font)
51
- baseline_y = box[:y] + box[:height] - runs.first.point_size
52
-
51
+ char_cursor = 0
53
52
  runs.each do |run|
54
- baseline_y = render_run_lines(
55
- canvas, run, context, box, font, baseline_y
53
+ char_cursor = render_run_lines(
54
+ canvas, run, context, box, font, char_cursor
56
55
  )
57
56
  end
58
57
  end
@@ -63,7 +62,10 @@ module Idml
63
62
  # alignment via `Justifier` so each line is offset within
64
63
  # the frame box per IDML `Justification`. Lines that fall
65
64
  # below the frame's bottom edge are clipped.
66
- def self.render_run_lines(canvas, run, context, box, font, baseline_y)
65
+ #
66
+ # Tracks absolute character position so HyperlinkEmitter can
67
+ # compute precise link rects per source range.
68
+ def self.render_run_lines(canvas, run, context, box, font, char_cursor)
67
69
  size = run.point_size
68
70
  glyphs = TextEngine::Shaper.shape(
69
71
  text: run.text, font: font, size: size,
@@ -73,6 +75,7 @@ module Idml
73
75
  )
74
76
  alignment = run.alignment || :left
75
77
  start_y = box[:y] + box[:height] - size
78
+ cursor = char_cursor
76
79
 
77
80
  lines.each_with_index do |line, idx|
78
81
  line_y = start_y - (idx * size * LEADING_FACTOR)
@@ -81,15 +84,36 @@ module Idml
81
84
  TextEngine::Justifier.justify(line: line,
82
85
  frame_width: box[:width],
83
86
  alignment: alignment)
87
+ line_x = box[:x] + line.x_offset
88
+ line_width = line.width
84
89
  canvas.text(line_text(line),
85
- at: [box[:x] + line.x_offset, line_y],
90
+ at: [line_x, line_y],
86
91
  font: context.font_ps_name,
87
92
  size: size)
93
+ record_position(context, line, line_x, line_y, line_width, size,
94
+ cursor)
95
+ cursor += line.glyphs.length
88
96
  end
89
- baseline_y
97
+ cursor
90
98
  end
91
99
  private_class_method :render_run_lines
92
100
 
101
+ def self.record_position(context, line, x, y, width, height, cursor)
102
+ tracker = context.position_tracker
103
+ return unless tracker
104
+ return unless context.item&.self_attr
105
+
106
+ glyph_count = line.glyphs.length
107
+ tracker.add(context.item.self_attr,
108
+ start_char: cursor,
109
+ end_char: cursor + glyph_count,
110
+ x: x,
111
+ y: y,
112
+ width: width,
113
+ height: height)
114
+ end
115
+ private_class_method :record_position
116
+
93
117
  def self.line_text(line)
94
118
  line.glyphs.map { |g| [g.codepoint].pack("U") }.join
95
119
  end
@@ -6,13 +6,15 @@ module Idml
6
6
  class SpreadRenderer
7
7
  def initialize(font_metrics: nil, font_ps_name: Render::DEFAULT_FONT,
8
8
  package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
9
- font_ref_resolver: nil, structure: nil)
9
+ font_ref_resolver: nil, structure: nil,
10
+ position_tracker: nil)
10
11
  @font_metrics = font_metrics
11
12
  @font_ps_name = font_ps_name
12
13
  @package = package
13
14
  @layer_filter = layer_filter
14
15
  @font_ref_resolver = font_ref_resolver
15
16
  @structure = structure
17
+ @position_tracker = position_tracker
16
18
  end
17
19
 
18
20
  def render(canvas, spread, page_width:, page_height:, image_refs: [],
@@ -28,6 +30,7 @@ module Idml
28
30
  layer_filter: @layer_filter,
29
31
  structure: @structure,
30
32
  page_index: page_index,
33
+ position_tracker: @position_tracker,
31
34
  }
32
35
 
33
36
  canvas.save_graphics_state do
data/lib/idml/render.rb CHANGED
@@ -25,6 +25,7 @@ module Idml
25
25
  autoload :HyperlinkEmitter, "#{__dir__}/render/hyperlink_emitter"
26
26
  autoload :MetadataBuilder, "#{__dir__}/render/metadata_builder"
27
27
  autoload :FontSetup, "#{__dir__}/render/font_setup"
28
+ autoload :PositionTracker, "#{__dir__}/render/position_tracker"
28
29
  autoload :StoryThreader, "#{__dir__}/render/story_threader"
29
30
  autoload :PdfrbWriter, "#{__dir__}/render/pdfrb_writer"
30
31
  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.4.2"
4
+ VERSION = "0.4.3"
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.4.2
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -402,6 +402,7 @@ files:
402
402
  - lib/idml/render/pdfrb_writer.rb
403
403
  - lib/idml/render/pipeline.rb
404
404
  - lib/idml/render/placement.rb
405
+ - lib/idml/render/position_tracker.rb
405
406
  - lib/idml/render/render_context.rb
406
407
  - lib/idml/render/renderers.rb
407
408
  - lib/idml/render/renderers/graphic_line_renderer.rb