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 +4 -4
- data/Gemfile.lock +2 -2
- data/TODO.pdf/85-per-text-range-hyperlinks.md +65 -88
- data/TODO.pdf/86-caching-performance.md +23 -1
- data/lib/idml/elements/character_style_range.rb +34 -0
- data/lib/idml/elements/graphic_line.rb +1 -1
- data/lib/idml/elements/hyperlink_text_source.rb +18 -5
- data/lib/idml/elements/polygon.rb +1 -1
- data/lib/idml/elements/rectangle.rb +4 -1
- data/lib/idml/elements/table.rb +1 -1
- data/lib/idml/elements/text_frame.rb +1 -1
- data/lib/idml/render/hyperlink_emitter.rb +94 -23
- data/lib/idml/render/pipeline.rb +15 -7
- data/lib/idml/render/position_tracker.rb +68 -0
- data/lib/idml/render/render_context.rb +1 -0
- data/lib/idml/render/renderers/group_renderer.rb +1 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +31 -7
- data/lib/idml/render/spread_renderer.rb +4 -1
- data/lib/idml/render.rb +1 -0
- data/lib/idml/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e54e07729eb2840c505e903d5aeaf6a69372776cc4b4f1e552d523362b0e861c
|
|
4
|
+
data.tar.gz: d424a2a1092c636c7d757a390350ea5cecdb4d36cc458aaea4ee2497aec9e418
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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:
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
`
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
- [
|
|
91
|
-
- [
|
|
92
|
-
- [
|
|
93
|
-
- [
|
|
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:
|
|
3
|
+
## Status: DONE — geometric_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
|
|
@@ -8,16 +8,19 @@ module Idml
|
|
|
8
8
|
# attribute is referenced by `<Hyperlink Source="...">` in the
|
|
9
9
|
# document designmap.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
@@ -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
|
data/lib/idml/elements/table.rb
CHANGED
|
@@ -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
|
|
8
|
+
# and emit `/Subtype /Link` annotations.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
|
|
42
|
-
return if
|
|
44
|
+
sources = sources_for_frame(frame)
|
|
45
|
+
return if sources.empty?
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/idml/render/pipeline.rb
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
@@ -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
|
-
|
|
52
|
-
|
|
51
|
+
char_cursor = 0
|
|
53
52
|
runs.each do |run|
|
|
54
|
-
|
|
55
|
-
canvas, run, context, box, font,
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
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.
|
|
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
|