idml 0.4.2 → 0.4.4

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: e54e07729eb2840c505e903d5aeaf6a69372776cc4b4f1e552d523362b0e861c
4
+ data.tar.gz: d424a2a1092c636c7d757a390350ea5cecdb4d36cc458aaea4ee2497aec9e418
5
5
  SHA512:
6
- metadata.gz: 34b95aeea5bf217fec6a88f8ae7a8a03c399001c0f4735bc11b0485afe27dbf9012fd481c8e6005f68f17fcc2bf850a63d03a3d23249bd570a57739ab92af1ed
7
- data.tar.gz: 1f462441786c0a807f3bb0ddb01ff0c1c4a2e9a8e736fbd9e18b7666c2fe1c57c917dae3770b051ec716a25f4202cba5eef86a96bdca9630263ff5f385527367
6
+ metadata.gz: 2b9b79e3aebb407be02d20a75b451bcdcb8ed8eb94813470b7f0dd31d3443850e194d3deb813b317ef1536706dbcbca9473d6f8666f12cbefcb83e2247d25c33
7
+ data.tar.gz: 06cbf92ecebe9363a343f146b2903cf50bae58f27943e6ea4c80d87f80eaf9e718b923a31fd193fa54cc28075ff20fe696248a00773aeee6f477117814515a1b
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.4)
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.4)
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,70 @@
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: DONE (architecture); fixture validation deferred
4
+
5
+ ## What was implemented
6
+
7
+ `Render::PositionTracker` is wired through the render path AND
8
+ per-source character ranges are computed via CSR's
9
+ `attributed_text` method.
10
+
11
+ ### Position tracker (Pipeline-level)
12
+
13
+ 1. **`Render::PositionTracker`** — accumulates positioned line
14
+ ranges per frame (`PositionedRange` Struct with start_char,
15
+ end_char, x, y, width, height).
16
+ 2. **`Pipeline`** constructs one tracker per render, threads it
17
+ through `SpreadRenderer` → `RenderContext` → `GroupRenderer`'s
18
+ child contexts.
19
+ 3. **`TextFrameRenderer#render_run_lines`** tracks an absolute
20
+ char cursor through the story. After each line is laid out and
21
+ drawn, `record_position` pushes a `PositionedRange` to the
22
+ tracker keyed by `frame.self_attr`.
23
+
24
+ ### Per-source attribution (CSR-level)
25
+
26
+ 4. **`Elements::CharacterStyleRange#attributed_text`** returns an
27
+ array of `{char:, source_self:}` tuples. Plain chars have no
28
+ `source_self`; chars inside a `HyperlinkTextSource` carry the
29
+ source's Self attribute. The method walks Content,
30
+ HyperlinkTextSource, and nested CSR children.
31
+ 5. **`Elements::HyperlinkTextSource`** now captures inline content
32
+ (`content`, `character_style_range`, `hyperlink_text_source`
33
+ collections) and exposes `text_content` that walks them all.
34
+ 6. **`HyperlinkEmitter#source_char_ranges_for_frame`** walks the
35
+ story's CSRs via `attributed_text`, building a
36
+ `source_self → [start_char, end_char]` map.
37
+ 7. **`HyperlinkEmitter#emit_precise_rects`** queries
38
+ `PositionTracker#rect_for_range(frame_self, from:, to:)` for
39
+ each source's range, emitting one Link annotation per source.
40
+
41
+ ### Fallback
42
+
43
+ When no tracker is supplied (e.g., the renderer took the
44
+ simple_render path), HyperlinkEmitter falls back to the frame's
45
+ bounding box (the original TODO 78 behavior).
46
+
47
+ ## Verification
48
+
49
+ - `lib/idml/render/position_tracker.rb` — tracker.
50
+ - `lib/idml/render/render_context.rb:12` — `position_tracker` field.
51
+ - `lib/idml/render/renderers/text_frame_renderer.rb:101` —
52
+ `record_position` call.
53
+ - `lib/idml/render/hyperlink_emitter.rb:79` `source_char_ranges_for_frame`.
54
+ - `lib/idml/elements/character_style_range.rb:669` — `attributed_text`.
55
+ - `lib/idml/elements/hyperlink_text_source.rb` — content collections.
56
+ - `spec/idml/render/position_tracker_spec.rb` 8 specs.
57
+ - `spec/idml/elements/character_style_range_spec.rb` 4 specs
58
+ covering text_content (Content join, HyperlinkTextSource wrap)
59
+ and attributed_text (plain vs linked char tagging).
87
60
 
88
61
  ## Acceptance criteria
89
62
 
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.
63
+ - [x] PositionTracker accumulates per-frame positioned ranges.
64
+ - [x] TextFrameRenderer records line positions during layout.
65
+ - [x] CSR#attributed_text tags characters per source Self.
66
+ - [x] HyperlinkEmitter computes per-source link rects via
67
+ tracker queries against per-source character ranges.
68
+ - [x] Falls back to frame-level rect when no tracker is supplied.
69
+ - [x] Spec coverage: PositionTracker (8) + CSR attribution (4).
70
+ - [ ] Real-fixture end-to-end test (requires IDML with hyperlinks).
@@ -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
 
@@ -657,10 +657,44 @@ module Idml
657
657
 
658
658
  def text_content
659
659
  parts = content.map(&:text)
660
+ hyperlink_text_source.each { |s| parts << s.text_content }
660
661
  character_style_range.each { |c| parts << c.text_content }
661
662
  parts.join
662
663
  end
663
664
 
665
+ # Returns an array of {char:, source_self:} tuples. Plain
666
+ # Content chars have source_self == nil; chars inside a
667
+ # HyperlinkTextSource carry the source's Self attribute. Used
668
+ # by HyperlinkEmitter to compute per-source link rects.
669
+ def attributed_text
670
+ result = []
671
+ append_plain_content(result)
672
+ append_hyperlink_content(result)
673
+ append_nested_csr(result)
674
+ result
675
+ end
676
+
677
+ def append_plain_content(result)
678
+ content.each do |c|
679
+ c.text.to_s.each_char { |ch| result << { char: ch } }
680
+ end
681
+ end
682
+ private :append_plain_content
683
+
684
+ def append_hyperlink_content(result)
685
+ hyperlink_text_source.each do |source|
686
+ source.text_content.each_char do |ch|
687
+ result << { char: ch, source_self: source.self_attr }
688
+ end
689
+ end
690
+ end
691
+ private :append_hyperlink_content
692
+
693
+ def append_nested_csr(result)
694
+ character_style_range.each { |c| result.concat(c.attributed_text) }
695
+ end
696
+ private :append_nested_csr
697
+
664
698
  def each_xml_element(&block)
665
699
  xml_element.each do |e|
666
700
  yield e
@@ -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
@@ -8,16 +8,19 @@ module Idml
8
8
  # attribute is referenced by `<Hyperlink Source="...">` in the
9
9
  # document designmap.
10
10
  #
11
- # Note on text range: IDML's HyperlinkTextSource wraps the
12
- # character content it covers (it is a sibling of `<Content>`
13
- # inside a `<CharacterStyleRange>`). The wrapper's position in
14
- # the story flow determines the range — there is no explicit
15
- # `StartIndex`/`EndIndex` on the element itself.
11
+ # The element wraps character content (CSR children + nested
12
+ # HyperlinkTextSource children + Content text). `text_content`
13
+ # walks all those children to produce the wrapped string.
16
14
  class HyperlinkTextSource < Lutaml::Model::Serializable
17
15
  attribute :self_attr, :string
18
16
  attribute :name, :string
19
17
  attribute :hidden, :boolean
20
18
  attribute :applied_character_style, :string
19
+ attribute :character_style_range, Idml::Elements::CharacterStyleRange,
20
+ collection: true
21
+ attribute :hyperlink_text_source, Idml::Elements::HyperlinkTextSource,
22
+ collection: true
23
+ attribute :content, Idml::Elements::Content, collection: true
21
24
 
22
25
  xml do
23
26
  root "HyperlinkTextSource"
@@ -25,6 +28,16 @@ module Idml
25
28
  map_attribute "Name", to: :name
26
29
  map_attribute "Hidden", to: :hidden
27
30
  map_attribute "AppliedCharacterStyle", to: :applied_character_style
31
+ map_element "CharacterStyleRange", to: :character_style_range
32
+ map_element "HyperlinkTextSource", to: :hyperlink_text_source
33
+ map_element "Content", to: :content
34
+ end
35
+
36
+ def text_content
37
+ parts = content.map(&:text)
38
+ character_style_range.each { |c| parts << c.text_content }
39
+ hyperlink_text_source.each { |nested| parts << nested.text_content }
40
+ parts.join
28
41
  end
29
42
  end
30
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,99 @@ 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
+ source_ranges = source_char_ranges_for_frame(frame)
57
+
58
+ sources.each do |source_self, url|
59
+ range = source_ranges[source_self]
60
+ rect = if range
61
+ @position_tracker.rect_for_range(
62
+ frame_self, from: range[0], to: range[1]
63
+ )
64
+ else
65
+ @position_tracker.rect_for_range(
66
+ frame_self, from: 0, to: 1_000_000
67
+ )
68
+ end || frame_level_rect(frame)
69
+ next unless rect
70
+
71
+ @writer.add_uri_link_annotation(page_index: page_index, rect: rect,
72
+ url: url)
73
+ end
74
+ end
75
+
76
+ # Returns a map of source_self -> [start_char, end_char] across
77
+ # all CSRs in the frame's story. Character positions are
78
+ # absolute within the story text, matching the cursor used by
79
+ # `TextFrameRenderer#record_position`.
80
+ def source_char_ranges_for_frame(frame)
81
+ story = frame.parent_story ? @package.story_by_id(frame.parent_story) : nil
82
+ return {} unless story&.inner
83
+
84
+ cursor = [0]
85
+ ranges = {}
86
+ walk_attribution(story.inner, cursor, ranges)
87
+ ranges
88
+ end
89
+
90
+ def walk_attribution(story_inner, cursor, ranges)
91
+ story_inner.paragraph_style_range.each do |psr|
92
+ psr.character_style_range.each do |csr|
93
+ csr.attributed_text.each do |tuple|
94
+ record_tuple(tuple, cursor, ranges)
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ def record_tuple(tuple, cursor, ranges)
101
+ source = tuple[:source_self]
102
+ if source
103
+ ranges[source] ||= [cursor[0], cursor[0]]
104
+ ranges[source][1] = cursor[0] + 1
105
+ end
106
+ cursor[0] += 1
107
+ end
108
+
109
+ def emit_frame_level_rect(frame, sources, page_index)
110
+ rect = frame_level_rect(frame)
111
+ return unless rect
112
+
113
+ sources.each_value do |url|
114
+ @writer.add_uri_link_annotation(page_index: page_index, rect: rect,
115
+ url: url)
53
116
  end
54
117
  end
55
118
 
56
- def urls_for_frame(frame)
119
+ def frame_level_rect(frame)
120
+ box = Placement.box(frame, @page_height)
121
+ return nil unless box
122
+
123
+ [box[:x], box[:y], box[:x] + box[:width], box[:y] + box[:height]]
124
+ end
125
+
126
+ def sources_for_frame(frame)
57
127
  story = frame.parent_story ? @package.story_by_id(frame.parent_story) : nil
58
128
  return [] unless story
59
129
 
60
130
  sources = hyperlink_sources_in(story)
61
- sources.filter_map { |source| @resolver.url_for_source(source.self_attr) }
131
+ sources.filter_map do |source|
132
+ url = @resolver.url_for_source(source.self_attr)
133
+ next unless url
134
+
135
+ [source.self_attr, url]
136
+ end
62
137
  end
63
138
 
64
139
  def hyperlink_sources_in(story)
@@ -69,10 +144,6 @@ module Idml
69
144
  psr.character_style_range.flat_map(&:hyperlink_text_source)
70
145
  end.compact
71
146
  end
72
-
73
- def rect_for(box)
74
- [box[:x], box[:y], box[:x] + box[:width], box[:y] + box[:height]]
75
- end
76
147
  end
77
148
  end
78
149
  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.4"
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.4
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