idml 0.4.3 → 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: 55a1e03f5e9f1f1e0f4019f499ce674f49caeca3ee18a04f98b01dcffb41b6c8
4
- data.tar.gz: 50674b26f5ee95a95f3275c9ab10d64db7311d8f99b5ee96b38585d24bd7f45c
3
+ metadata.gz: e54e07729eb2840c505e903d5aeaf6a69372776cc4b4f1e552d523362b0e861c
4
+ data.tar.gz: d424a2a1092c636c7d757a390350ea5cecdb4d36cc458aaea4ee2497aec9e418
5
5
  SHA512:
6
- metadata.gz: 7c5d7af6ccd5e60b31fb2ef62c618b336bc9f54517e84377c4cb5566e4da0ae6b0f9a1beee528ac55e5eba6e5f8e59ee3c3d9f9ca91007b7833e6a360dd1f4d0
7
- data.tar.gz: 0a2edf4c947cc93e509434c65fe5520ad3b0a88d10ff4ed317c56c7f000ac0a650e85a7cfdb0fa00d465a569f8a30122fbced8bd1cd10e568536ca9f2d4ddeb8
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.3)
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.3)
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,10 +1,14 @@
1
1
  # TODO PDF 85: Per-TextRange hyperlink precision
2
2
 
3
- ## Status: PARTIAL — PositionTracker wired; per-source ranges need fixture
3
+ ## Status: DONE (architecture); fixture validation deferred
4
4
 
5
5
  ## What was implemented
6
6
 
7
- `Render::PositionTracker` is wired through the render path:
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)
8
12
 
9
13
  1. **`Render::PositionTracker`** — accumulates positioned line
10
14
  ranges per frame (`PositionedRange` Struct with start_char,
@@ -16,31 +20,29 @@
16
20
  char cursor through the story. After each line is laid out and
17
21
  drawn, `record_position` pushes a `PositionedRange` to the
18
22
  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.
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).
44
46
 
45
47
  ## Verification
46
48
 
@@ -48,15 +50,21 @@ to: source_end)` for per-source precision.
48
50
  - `lib/idml/render/render_context.rb:12` — `position_tracker` field.
49
51
  - `lib/idml/render/renderers/text_frame_renderer.rb:101` —
50
52
  `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.
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).
54
60
 
55
61
  ## Acceptance criteria
56
62
 
57
63
  - [x] PositionTracker accumulates per-frame positioned ranges.
58
64
  - [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).
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).
@@ -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
@@ -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
@@ -53,10 +53,19 @@ module Idml
53
53
 
54
54
  def emit_precise_rects(frame, sources, page_index)
55
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)
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)
60
69
  next unless rect
61
70
 
62
71
  @writer.add_uri_link_annotation(page_index: page_index, rect: rect,
@@ -64,6 +73,39 @@ module Idml
64
73
  end
65
74
  end
66
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
+
67
109
  def emit_frame_level_rect(frame, sources, page_index)
68
110
  rect = frame_level_rect(frame)
69
111
  return unless rect
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.3"
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.3
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose