idml 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -18
  3. data/TODO.pdf/63-replace-fontmetrics-with-pdfrb.md +59 -58
  4. data/TODO.pdf/65-pdfrb-019-integration.md +57 -29
  5. data/TODO.pdf/67-text-rich-multi-run.md +35 -39
  6. data/TODO.pdf/78-hyperlinks.md +54 -74
  7. data/TODO.pdf/79-bookmarks-outline.md +41 -67
  8. data/TODO.pdf/80-paragraph-alignment.md +39 -0
  9. data/TODO.pdf/81-pdfa-icc-output-intent.md +51 -0
  10. data/TODO.pdf/82-table-cell-text.md +54 -0
  11. data/idml.gemspec +0 -1
  12. data/lib/idml/elements/bookmark.rb +25 -0
  13. data/lib/idml/elements/hyperlink.rb +27 -0
  14. data/lib/idml/elements/hyperlink_page_destination.rb +34 -0
  15. data/lib/idml/elements/hyperlink_url_destination.rb +25 -0
  16. data/lib/idml/elements/table_cell.rb +14 -1
  17. data/lib/idml/elements.rb +6 -0
  18. data/lib/idml/parts/designmap.rb +12 -0
  19. data/lib/idml/render/bookmark_resolver.rb +87 -0
  20. data/lib/idml/render/hyperlink_emitter.rb +74 -0
  21. data/lib/idml/render/hyperlink_resolver.rb +68 -0
  22. data/lib/idml/render/icc_profile.rb +50 -0
  23. data/lib/idml/render/pdfrb_writer.rb +10 -0
  24. data/lib/idml/render/pipeline.rb +60 -18
  25. data/lib/idml/render/render_context.rb +1 -1
  26. data/lib/idml/render/renderers/group_renderer.rb +3 -1
  27. data/lib/idml/render/renderers/table_renderer.rb +27 -3
  28. data/lib/idml/render/renderers/text_frame_renderer.rb +54 -39
  29. data/lib/idml/render/spread_renderer.rb +3 -3
  30. data/lib/idml/render/style_resolver.rb +23 -2
  31. data/lib/idml/render.rb +4 -0
  32. data/lib/idml/text_engine/pdfrb_font_metrics.rb +81 -0
  33. data/lib/idml/text_engine.rb +1 -2
  34. data/lib/idml/version.rb +1 -1
  35. metadata +13 -17
  36. data/lib/idml/text_engine/font_metrics.rb +0 -226
  37. data/lib/idml/text_engine/font_resolver.rb +0 -105
@@ -1,72 +1,46 @@
1
1
  # TODO PDF 79: PDF outline (bookmarks)
2
2
 
3
- ## Status: PLANNED (design only)
4
-
5
- ## Goal
6
-
7
- Map IDML `<Bookmark>` elements in `designmap.xml` to PDF outline
8
- entries via `PdfrbWriter#add_bookmark` (which delegates to
9
- `Pdfrb::Document::Outline#add`).
10
-
11
- Each IDML bookmark references a destination (page or page item) via
12
- its `Destination` attribute. The PDF outline is the clickable
13
- "bookmarks" panel in PDF readers — bookmarks make large documents
14
- navigable.
15
-
16
- ## Background
17
-
18
- IDML models bookmarks in `designmap.xml`:
19
-
20
- ```xml
21
- <Bookmark Self="Bookmark/abc" Name="Section 1"
22
- Destination="PDFPageDestination/def"/>
23
- ```
24
-
25
- The `Destination` attribute references a `PDFPageDestination_Object`
26
- (also in designmap.xml) which names the destination page and either
27
- a viewport setting (`FitView`, `FitWidth`, etc.) or a Y position.
28
-
29
- pdfrb's Outline API:
30
-
31
- ```ruby
32
- document.outline.add(title, dest: page) # add bookmark
33
- document.outline.add(title, dest: page, parent: parent) # nested
34
- ```
35
-
36
- `PdfrbWriter#add_bookmark(title, page_index)` already exists as a
37
- thin wrapper; this TODO extends it to accept named-destination
38
- resolution and adds Pipeline integration.
39
-
40
- ## Plan
41
-
42
- 1. **Parse bookmarks**: extend `Parts::Designmap` to expose
43
- `bookmark` and `pdf_page_destination` collections (element classes
44
- may need adding to `Idml::Elements`).
45
- 2. **Resolve destinations**: a bookmark's `Destination` references a
46
- `PDFPageDestination` whose `PageDestinationPage` references a
47
- spread-page. Build a Self → page_index map at pipeline start.
48
- 3. **Pipeline emit step**: after rendering all spreads, iterate
49
- bookmarks and call
50
- `writer.add_bookmark(name, page_index: page_index_of(destination))`.
51
- 4. **Optional nesting**: if IDML exposes a parent/child relationship
52
- (it doesn't directly — bookmarks are flat), the outline stays
53
- flat. Future work could derive hierarchy from heading styles.
54
-
55
- ## pdfrb dependencies
56
-
57
- - `Pdfrb::Document::Outline#add(title, dest:, parent: nil)` — DONE in
58
- pdfrb 0.4.0. `dest` accepts a page object or reference.
3
+ ## Status: DONE
4
+
5
+ ## What was implemented
6
+
7
+ `idml render` now emits PDF outline entries from IDML bookmarks. The
8
+ flow:
9
+
10
+ 1. `<Bookmark>` and `<HyperlinkPageDestination>` elements are parsed
11
+ from designmap (`Parts::Designmap#bookmark`,
12
+ `#hyperlink_page_destination`).
13
+ 2. `Render::BookmarkResolver` walks bookmarks, resolves
14
+ `Bookmark#destination` (Self) →
15
+ `HyperlinkPageDestination#destination_page` (Page Self) →
16
+ page index (via spread iteration), and yields `[title, page_index]`
17
+ pairs.
18
+ 3. `Pipeline#emit_bookmarks` calls
19
+ `PdfrbWriter#add_bookmark(title, page_index)` for each resolved
20
+ entry. `PdfrbWriter` delegates to `Pdfrb::Document::Outline#add`.
21
+
22
+ ## Element models added
23
+
24
+ - `Idml::Elements::Bookmark` — `<Bookmark>` element with Self, Name,
25
+ and Destination attributes.
26
+ - `Idml::Elements::HyperlinkPageDestination` (added for hyperlinks)
27
+ reused here.
28
+
29
+ ## Verification
30
+
31
+ - `lib/idml/elements/bookmark.rb` — element model.
32
+ - `lib/idml/parts/designmap.rb:50` bookmark + hyperlink_page_destination
33
+ attributes and element mappings.
34
+ - `lib/idml/render/bookmark_resolver.rb` — destination chain + page
35
+ lookup.
36
+ - `lib/idml/render/pipeline.rb:60` `emit_bookmarks` call.
37
+ - `spec/idml/render/bookmark_resolver_spec.rb` 2 specs covering
38
+ resolved and unresolved destinations.
59
39
 
60
40
  ## Acceptance criteria
61
41
 
62
- - [ ] IDML bookmarks appear as top-level PDF outline entries.
63
- - [ ] Clicking a bookmark navigates to the right page.
64
- - [ ] Bookmarks with unresolvable destinations are skipped (no crash).
65
- - [ ] Spec covers a fixture with bookmarks + a fixture without.
66
-
67
- ## Dependencies
68
-
69
- - Element classes `Idml::Elements::Bookmark`, `PDFPageDestination`
70
- (TODO: add via rnc_to_lutaml generator).
71
- - Pipeline step ordering: outline must be built after all pages are
72
- registered with the writer.
42
+ - [x] IDML bookmarks appear as top-level PDF outline entries.
43
+ - [x] Clicking a bookmark navigates to the right page (page_index).
44
+ - [x] Bookmarks with unresolvable destinations skipped.
45
+ - [x] Spec covers a fixture-equivalent synthetic with two resolvable
46
+ and one broken bookmark.
@@ -0,0 +1,39 @@
1
+ # TODO PDF 80: Paragraph alignment via Justifier
2
+
3
+ ## Status: DONE
4
+
5
+ ## What was implemented
6
+
7
+ `TextFrameRenderer#engine_render` now applies paragraph alignment
8
+ from each `StyledRun` via the existing `TextEngine::Justifier`.
9
+
10
+ 1. `StyleResolver::StyledRun` gains an `alignment` field, populated
11
+ from `CharacterStyleRange#justification` via
12
+ `StyleResolver::ALIGNMENT_MAP` (IDML enum → Justifier symbol).
13
+ 2. `TextFrameRenderer#render_run_lines` calls
14
+ `Justifier.justify(line:, frame_width:, alignment:)` per line
15
+ after `LineBreaker.break`, then emits `canvas.text` per line at
16
+ `box[:x] + line.x_offset`.
17
+ 3. IDML Justification values map cleanly:
18
+ - `Left`, `LeftJustified`, `ToBinding` → `:left`
19
+ - `Center`, `CenterJustified` → `:center`
20
+ - `Right`, `RightJustified` → `:right`
21
+ - `FullyJustified` → `:justified` (Justifier distributes slack
22
+ across spaces)
23
+
24
+ ## Verification
25
+
26
+ - `lib/idml/render/style_resolver.rb:20` — `ALIGNMENT_MAP`.
27
+ - `lib/idml/render/style_resolver.rb:48` — `alignment_for(csr)`.
28
+ - `lib/idml/render/renderers/text_frame_renderer.rb:73` —
29
+ `Justifier.justify` call + per-line text emission.
30
+ - `spec/idml/render/render_helpers_spec.rb` — alignment field on
31
+ StyledRun + ALIGNMENT_MAP specs.
32
+
33
+ ## Acceptance criteria
34
+
35
+ - [x] StyledRun carries alignment.
36
+ - [x] Center-aligned paragraphs render centered in their frame.
37
+ - [x] Right-aligned paragraphs render flush-right.
38
+ - [x] Left alignment (default) renders unchanged.
39
+ - [x] Fully-justified distributes slack across spaces via Justifier.
@@ -0,0 +1,51 @@
1
+ # TODO PDF 81: sRGB ICC profile + output intent for PDF/A
2
+
3
+ ## Status: DONE
4
+
5
+ ## What was implemented
6
+
7
+ Pipeline now embeds an sRGB ICC profile and registers a PDF/A
8
+ output intent when `compliance: :pdfa2a` (or any `:pdfa*`).
9
+
10
+ 1. `Render::IccProfile.srgb_bytes` returns ICC bytes from the first
11
+ available source:
12
+ - `ENV["IDML_SRGB_ICC"]` (explicit override)
13
+ - `data/idml/srgb.icc` inside the gem tree (user-vendored)
14
+ - macOS system profile at
15
+ `/System/Library/ColorSync/Profiles/sRGB Profile.icc`
16
+ Returns `nil` if none available.
17
+ 2. `Pipeline#embed_pdfa_output_intent` reads bytes and calls
18
+ `writer.document.output_intents.embed_icc(bytes, identifier:
19
+ "sRGB", condition: "sRGB IEC61966-2.1", subtype: :GTS_PDFA1)`.
20
+ 3. Embedding is graceful — if no profile is available, the XMP
21
+ packet is still attached (from TODO 77) but `/OutputIntents` is
22
+ omitted.
23
+
24
+ ## Why no bundled ICC
25
+
26
+ The idml gem stays dependency-free for binary assets. Users who
27
+ need PDF/A on non-macOS systems can either:
28
+ - Set `IDML_SRGB_ICC=/path/to/srgb.icc` in the environment, or
29
+ - Drop a profile at `<gem>/data/idml/srgb.icc` after install.
30
+
31
+ This avoids bundling a binary whose licensing might be ambiguous,
32
+ while still using well-formed ICC bytes when they're available.
33
+
34
+ ## Verification
35
+
36
+ - `lib/idml/render/icc_profile.rb` — locator helper.
37
+ - `lib/idml/render/pipeline.rb:32` — `embed_pdfa_output_intent` call.
38
+ - `spec/idml/render/icc_profile_spec.rb` — 3 specs covering env var,
39
+ system path, and no-source fallback.
40
+ - Pipeline integration test: PDF/A output now includes both
41
+ `/Metadata` (XMP) and `/OutputIntents` referencing sRGB.
42
+
43
+ ## Acceptance criteria
44
+
45
+ - [x] `data/idml/srgb.icc` path supported (user-vendored).
46
+ - [x] `IDML_SRGB_ICC` env var supported.
47
+ - [x] macOS system path probed as fallback.
48
+ - [x] PDF/A output has `/OutputIntents` referencing embedded ICC
49
+ when any source is available.
50
+ - [x] Non-PDF/A output unchanged (no ICC embedding).
51
+ - [x] Graceful fallback when no source available (XMP only).
@@ -0,0 +1,54 @@
1
+ # TODO PDF 82: Table cell text rendering
2
+
3
+ ## Status: DONE (basic; full schema-faithful Cell model deferred)
4
+
5
+ ## What was implemented
6
+
7
+ `TableRenderer` now renders inline text inside each `<TableCell>`
8
+ in addition to the cell grid rectangles.
9
+
10
+ 1. `Idml::Elements::TableCell` gains a `character_style_range`
11
+ collection (typed `Idml::Elements::CharacterStyleRange`).
12
+ 2. `TableCell#text_content` concatenates the text content of all
13
+ inline CSRs.
14
+ 3. `TableRenderer#render_cell_text` reads `cell.text_content`,
15
+ builds a single-run `{ text:, font:, size: }` array, and emits
16
+ `canvas.text_rich(runs, at: [cell_x + INSET, baseline])` per cell
17
+ that has text.
18
+ 4. Default font size 10pt, 4pt inset — sensible defaults that match
19
+ typical IDML table styling.
20
+
21
+ ## Schema divergence (deferred)
22
+
23
+ The IDML RNC names this element `<Cell>` (not `<TableCell>`) and
24
+ declares many more attributes (RowSpan, ColumnSpan, insets per edge,
25
+ stroke per edge, FillColor, etc.). The current model captures only
26
+ the renderer-immediate subset. Faithful Cell modeling per RNC is a
27
+ separate refactor:
28
+
29
+ - Rename `TableCell` → `Cell` (or add a `Cell` alias).
30
+ - Add the full attribute set per `Cell_Object` in
31
+ `reference-docs/schemas/package/Stories/Story.rnc`.
32
+ - Update `Table` model: schema says `<Cell>` and `<Row>` are
33
+ siblings (not nested `<TableRow><TableCell>`). Restructuring
34
+ breaks existing fixtures.
35
+
36
+ These schema corrections are tracked separately.
37
+
38
+ ## Verification
39
+
40
+ - `lib/idml/elements/table_cell.rb` — character_style_range added,
41
+ `text_content` method.
42
+ - `lib/idml/render/renderers/table_renderer.rb:42` —
43
+ `render_cell_text` via `canvas.text_rich`.
44
+ - Existing `table_renderer_spec.rb` still passes (cells without
45
+ text render as before).
46
+
47
+ ## Acceptance criteria
48
+
49
+ - [x] Cells with inline CSR children render the text.
50
+ - [x] Empty cells render as empty rectangles (previous behavior).
51
+ - [x] Text uses the frame's registered font (via context.font_ps_name).
52
+ - [ ] Per-cell font, color, alignment from CSR attributes (deferred).
53
+ - [ ] Schema-faithful `<Cell>` element with full attribute set
54
+ (deferred — separate refactor).
data/idml.gemspec CHANGED
@@ -29,7 +29,6 @@ Gem::Specification.new do |spec|
29
29
  spec.require_paths = ["lib"]
30
30
 
31
31
  spec.add_dependency "bigdecimal"
32
- spec.add_dependency "fontisan"
33
32
  spec.add_dependency "lutaml-model", "~> 0.8.18"
34
33
  spec.add_dependency "pdfrb"
35
34
  spec.add_dependency "rubyzip"
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # `<Bookmark>` — IDML bookmark element. References a
6
+ # `<HyperlinkPageDestination>` (or similar) by Self via the
7
+ # `Destination` attribute. The destination carries the actual
8
+ # page+viewpoint target.
9
+ #
10
+ # Attributes per `Bookmark_Object` in
11
+ # `reference-docs/schemas/package/designmap.rnc`.
12
+ class Bookmark < Lutaml::Model::Serializable
13
+ attribute :self_attr, :string
14
+ attribute :name, :string
15
+ attribute :destination, :string
16
+
17
+ xml do
18
+ root "Bookmark"
19
+ map_attribute "Self", to: :self_attr
20
+ map_attribute "Name", to: :name
21
+ map_attribute "Destination", to: :destination
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # `<Hyperlink>` — connects a hyperlink source to a destination.
6
+ # Per `Hyperlink_Object` in
7
+ # `reference-docs/schemas/package/designmap.rnc`.
8
+ class Hyperlink < Lutaml::Model::Serializable
9
+ attribute :self_attr, :string
10
+ attribute :name, :string
11
+ attribute :source, :string
12
+ attribute :destination, :string
13
+ attribute :visible, :boolean
14
+ attribute :hidden, :boolean
15
+
16
+ xml do
17
+ root "Hyperlink"
18
+ map_attribute "Self", to: :self_attr
19
+ map_attribute "Name", to: :name
20
+ map_attribute "Source", to: :source
21
+ map_attribute "Destination", to: :destination
22
+ map_attribute "Visible", to: :visible
23
+ map_attribute "Hidden", to: :hidden
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # `<HyperlinkPageDestination>` — target of an IDML hyperlink or
6
+ # bookmark. The `DestinationPage` attribute references a Spread
7
+ # Page by Self, which the renderer maps to a PDF page index.
8
+ #
9
+ # Attributes per `HyperlinkPageDestination_Object` in
10
+ # `reference-docs/schemas/package/designmap.rnc`.
11
+ class HyperlinkPageDestination < Lutaml::Model::Serializable
12
+ attribute :self_attr, :string
13
+ attribute :name, :string
14
+ attribute :name_manually, :boolean
15
+ attribute :destination_page, :string
16
+ attribute :view_setting, :string
17
+ attribute :view_percentage, :float
18
+ attribute :hidden, :boolean
19
+ attribute :destination_unique_key, :integer
20
+
21
+ xml do
22
+ root "HyperlinkPageDestination"
23
+ map_attribute "Self", to: :self_attr
24
+ map_attribute "Name", to: :name
25
+ map_attribute "NameManually", to: :name_manually
26
+ map_attribute "DestinationPage", to: :destination_page
27
+ map_attribute "ViewSetting", to: :view_setting
28
+ map_attribute "ViewPercentage", to: :view_percentage
29
+ map_attribute "Hidden", to: :hidden
30
+ map_attribute "DestinationUniqueKey", to: :destination_unique_key
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # `<HyperlinkURLDestination>` — URL target of a hyperlink.
6
+ # Per `HyperlinkURLDestination_Object` in
7
+ # `reference-docs/schemas/package/designmap.rnc`.
8
+ class HyperlinkURLDestination < Lutaml::Model::Serializable
9
+ attribute :self_attr, :string
10
+ attribute :name, :string
11
+ attribute :destination_url, :string
12
+ attribute :hidden, :boolean
13
+ attribute :destination_unique_key, :integer
14
+
15
+ xml do
16
+ root "HyperlinkURLDestination"
17
+ map_attribute "Self", to: :self_attr
18
+ map_attribute "Name", to: :name
19
+ map_attribute "DestinationURL", to: :destination_url
20
+ map_attribute "Hidden", to: :hidden
21
+ map_attribute "DestinationUniqueKey", to: :destination_unique_key
22
+ end
23
+ end
24
+ end
25
+ end
@@ -3,13 +3,21 @@
3
3
  module Idml
4
4
  module Elements
5
5
  # `<TableCell>` — a single cell within a table row. Carries the
6
- # cell's content type and dimensions.
6
+ # cell's content type, dimensions, and any inline text content
7
+ # (`<CharacterStyleRange>` children that carry the cell's text).
8
+ #
9
+ # IDML's actual schema (`Cell_Object` in
10
+ # `reference-docs/schemas/package/Stories/Story.rnc`) names this
11
+ # element `Cell` and includes many more attributes; this model
12
+ # captures the subset the renderer needs today.
7
13
  class TableCell < Lutaml::Model::Serializable
8
14
  attribute :self_attr, :string
9
15
  attribute :name, :string
10
16
  attribute :column, :integer
11
17
  attribute :row, :integer
12
18
  attribute :key_value, :string
19
+ attribute :character_style_range, Idml::Elements::CharacterStyleRange,
20
+ collection: true
13
21
 
14
22
  xml do
15
23
  root "TableCell"
@@ -18,6 +26,11 @@ module Idml
18
26
  map_attribute "Column", to: :column
19
27
  map_attribute "Row", to: :row
20
28
  map_attribute "KeyValue", to: :key_value
29
+ map_element "CharacterStyleRange", to: :character_style_range
30
+ end
31
+
32
+ def text_content
33
+ character_style_range.filter_map(&:text_content).join
21
34
  end
22
35
  end
23
36
  end
data/lib/idml/elements.rb CHANGED
@@ -15,6 +15,7 @@ module Idml
15
15
  autoload :BevelAndEmbossSetting, "idml/elements/bevel_and_emboss_setting"
16
16
  autoload :BlendingSetting, "idml/elements/blending_setting"
17
17
  autoload :ButtonPreference, "idml/elements/pref_button_preference"
18
+ autoload :Bookmark, "idml/elements/bookmark"
18
19
  autoload :CellStyle, "idml/elements/cell_style"
19
20
  autoload :CellStyleGroup, "idml/elements/cell_style_group"
20
21
  autoload :ChapterNumberPreference,
@@ -60,6 +61,11 @@ module Idml
60
61
  autoload :Group, "idml/elements/group"
61
62
  autoload :GuidePreference, "idml/elements/pref_guide_preference"
62
63
  autoload :HTMLExportPreference, "idml/elements/pref_html_export_preference"
64
+ autoload :Hyperlink, "idml/elements/hyperlink"
65
+ autoload :HyperlinkPageDestination,
66
+ "idml/elements/hyperlink_page_destination"
67
+ autoload :HyperlinkURLDestination,
68
+ "idml/elements/hyperlink_url_destination"
63
69
  autoload :Image, "idml/elements/image"
64
70
  autoload :IndexHeaderSetting, "idml/elements/pref_index_header_setting"
65
71
  autoload :IndexOptions, "idml/elements/pref_index_options"
@@ -47,6 +47,12 @@ module Idml
47
47
  attribute :active_process, :string
48
48
 
49
49
  attribute :layer, Idml::Elements::Layer, collection: true
50
+ attribute :bookmark, Idml::Elements::Bookmark, collection: true
51
+ attribute :hyperlink, Idml::Elements::Hyperlink, collection: true
52
+ attribute :hyperlink_page_destination,
53
+ Idml::Elements::HyperlinkPageDestination, collection: true
54
+ attribute :hyperlink_url_destination,
55
+ Idml::Elements::HyperlinkURLDestination, collection: true
50
56
 
51
57
  xml do
52
58
  root "Document"
@@ -85,6 +91,12 @@ module Idml
85
91
  map_attribute "PreferMathMLInEpubExport", to: :prefer_math_ml_in_epub_export
86
92
  map_attribute "ActiveProcess", to: :active_process
87
93
  map_element "Layer", to: :layer
94
+ map_element "Bookmark", to: :bookmark
95
+ map_element "Hyperlink", to: :hyperlink
96
+ map_element "HyperlinkPageDestination",
97
+ to: :hyperlink_page_destination
98
+ map_element "HyperlinkURLDestination",
99
+ to: :hyperlink_url_destination
88
100
  end
89
101
  end
90
102
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Resolves IDML bookmarks to (title, page_index) pairs ready for
6
+ # `PdfrbWriter#add_bookmark`. Uses the document's designmap to
7
+ # look up:
8
+ #
9
+ # Bookmark#destination (Self) →
10
+ # HyperlinkPageDestination#destination_page (Page Self) →
11
+ # PDF page index (via spread iteration)
12
+ #
13
+ # Bookmarks whose destination chain cannot be resolved are
14
+ # skipped silently.
15
+ class BookmarkResolver
16
+ def initialize(package)
17
+ @package = package
18
+ end
19
+
20
+ # Yields [title, page_index] pairs for each resolvable bookmark.
21
+ def each
22
+ return enum_for(:each) unless block_given?
23
+
24
+ bookmarks.each do |bookmark|
25
+ entry = resolve(bookmark)
26
+ yield entry if entry
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def bookmarks
33
+ designmap&.bookmark || []
34
+ end
35
+
36
+ def designmap
37
+ @package&.designmap
38
+ end
39
+
40
+ def resolve(bookmark)
41
+ destination = destination_by_self(bookmark.destination)
42
+ return nil unless destination&.destination_page
43
+
44
+ page_index = page_index_by_self(destination.destination_page)
45
+ return nil unless page_index
46
+
47
+ title = bookmark.name || destination.name || "Bookmark"
48
+ [title, page_index]
49
+ end
50
+
51
+ def destination_by_self(self_attr)
52
+ return nil unless self_attr
53
+
54
+ destinations.find { |d| d.self_attr == self_attr }
55
+ end
56
+
57
+ def destinations
58
+ designmap&.hyperlink_page_destination || []
59
+ end
60
+
61
+ def page_index_by_self(page_self)
62
+ page_self_table[page_self]
63
+ end
64
+
65
+ def page_self_table
66
+ @page_self_table ||= begin
67
+ table = {}
68
+ @package.spreads.each_with_index do |spread, spread_idx|
69
+ spread_pages = spread.spread.flat_map(&:page)
70
+ spread_pages.each_with_index do |page, page_idx|
71
+ table[page.self_attr] = cumulative_page_index(spread_idx, page_idx)
72
+ end
73
+ end
74
+ table
75
+ end
76
+ end
77
+
78
+ def cumulative_page_index(spread_idx, page_idx)
79
+ prior_pages = 0
80
+ @package.spreads.first(spread_idx).each do |spread|
81
+ prior_pages += spread.spread.flat_map(&:page).length
82
+ end
83
+ prior_pages + page_idx
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Emits PDF Link annotations for IDML hyperlinks. After a spread
6
+ # renders, walk each visible text frame, look up its story's
7
+ # hyperlink sources, resolve each to a URL via HyperlinkResolver,
8
+ # and emit a `/Subtype /Link` annotation over the frame's box.
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.
14
+ class HyperlinkEmitter
15
+ def initialize(writer:, package:, page_height:)
16
+ @writer = writer
17
+ @package = package
18
+ @resolver = HyperlinkResolver.new(package)
19
+ @page_height = page_height
20
+ end
21
+
22
+ def emit_for(spread, page_index)
23
+ each_text_frame_on(spread) do |frame|
24
+ emit_for_frame(frame, page_index)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def each_text_frame_on(spread)
31
+ spread.each_page_item do |item|
32
+ yield item if item.is_a?(Idml::Elements::TextFrame)
33
+ end
34
+ end
35
+
36
+ def emit_for_frame(frame, page_index)
37
+ urls = urls_for_frame(frame)
38
+ return if urls.empty?
39
+
40
+ box = Placement.box(frame, @page_height)
41
+ return unless box
42
+
43
+ urls.each do |url|
44
+ @writer.add_uri_link_annotation(
45
+ page_index: page_index,
46
+ rect: rect_for(box),
47
+ url: url,
48
+ )
49
+ end
50
+ end
51
+
52
+ def urls_for_frame(frame)
53
+ story = frame.parent_story ? @package.story_by_id(frame.parent_story) : nil
54
+ return [] unless story
55
+
56
+ sources = hyperlink_sources_in(story)
57
+ sources.filter_map { |source| @resolver.url_for_source(source.self_attr) }
58
+ end
59
+
60
+ def hyperlink_sources_in(story)
61
+ inner = story&.inner
62
+ return [] unless inner
63
+
64
+ inner.paragraph_style_range.flat_map do |psr|
65
+ psr.character_style_range.flat_map(&:hyperlink_text_source)
66
+ end.compact
67
+ end
68
+
69
+ def rect_for(box)
70
+ [box[:x], box[:y], box[:x] + box[:width], box[:y] + box[:height]]
71
+ end
72
+ end
73
+ end
74
+ end