idml 0.3.1 → 0.4.1

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: a99eee06508972bf25be9b3c086af445dd8fab275b8c83e9e588e6fc15c5ce2c
4
- data.tar.gz: 4fc3c99e4dd7be9bfd2c00ab90bf3684bfc397fb838f4504cde8eed575e7ff65
3
+ metadata.gz: d7defde95ef6dc98c93c16a27f09b93c17aaea161a6878b1b130b58b3d8ba9cd
4
+ data.tar.gz: 603a52445ffcb85e2e384e097d74810a302b045aecfaf4937af10d763c076c2d
5
5
  SHA512:
6
- metadata.gz: ef32e1d3a418e26d020638aeb955a4296a5f2574c7ae2d75bc105c345b170bcf4497c3f8fc4e7201dbf159302fcb9c4f2881d6da26355e4f1027973f88ed5140
7
- data.tar.gz: fb60042f7b9c4fec7cb619a7c2d747daf64229136997cd35d043ee02546facd2003f4cf6a9d52f6444549b4fa7a700371732566722dbd84a6cdac7cb10993a2b
6
+ metadata.gz: da8441f49894dcce6656d429357421ee101c59061738e73527b9dc574d8905adf68b8a2fb78a5cc6bd5a4b67d2f4774893d96c9608ea7e6b91d945a1189b8e20
7
+ data.tar.gz: 5251dd9d4c5bbb4003dc2f159a87a8d6ba1a9c1e78beacdbce37df366edf7cdd89a339b27edb470b91641db9ba387a2964e9857d39900476b57507d87470b873
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idml (0.3.1)
4
+ idml (0.4.1)
5
5
  bigdecimal
6
6
  lutaml-model (~> 0.8.18)
7
7
  pdfrb
@@ -11,7 +11,7 @@ PATH
11
11
  PATH
12
12
  remote: /Users/mulgogi/src/claricle/pdfrb
13
13
  specs:
14
- pdfrb (0.5.0)
14
+ pdfrb (0.6.0)
15
15
  thor
16
16
 
17
17
  GEM
@@ -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.3.1)
186
+ idml (0.4.1)
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
@@ -203,7 +203,7 @@ CHECKSUMS
203
203
  paint (2.3.0) sha256=327d623e4038619d5bd99ae5db07973859cd78400c7f0329eea283cef8e83be5
204
204
  parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
205
205
  parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
206
- pdfrb (0.5.0)
206
+ pdfrb (0.6.0)
207
207
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
208
208
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
209
209
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
@@ -1,79 +1,59 @@
1
1
  # TODO PDF 78: Hyperlink annotations
2
2
 
3
- ## Status: PLANNED (design only)
4
-
5
- ## Goal
6
-
7
- Map IDML `<HyperlinkTextSource>` / `<HyperlinkTextDestination>` /
8
- `<HyperlinkURLObject>` to PDF Link annotations:
9
-
10
- - URL hyperlinks → `/Subtype /Link` with `/A /URI` action.
11
- - Page-item cross-references → `/Subtype /Link` with `/D` destination.
12
- - Email, file, and text-anchor destinations per the same pattern.
13
-
14
- Each visible hyperlink becomes a clickable rectangle on the page,
15
- positioned over the source text range.
16
-
17
- ## Background
18
-
19
- IDML models hyperlinks as **text sources** in `Stories/Story_*.xml`
20
- and **destinations** in `designmap.xml` and across stories:
21
-
22
- ```xml
23
- <!-- In Story: a hyperlink source spans a CharacterStyleRange range -->
24
- <HyperlinkTextSource Self="HyperlinkTextSource/abc" Name="link"
25
- Visible="true" Highlight="Invert">
26
- <Properties>
27
- <TextRange StartIndex="14" EndIndex="22"/>
28
- </Properties>
29
- </HyperlinkTextSource>
30
-
31
- <!-- In designmap.xml or a Story: destinations -->
32
- <HyperlinkURLObject Self="HyperlinkURLObject/xyz"
33
- DestinationURL="https://example.com"
34
- DestinationName="Example"/>
35
- <HyperlinkPageItemReference Self="..." DestinationPageItem="di1"/>
36
- ```
37
-
38
- The source's `TextRange` says "characters 14-22 of this story".
39
- Combining that with the layout engine's character-position output
40
- gives the rectangle on the page.
41
-
42
- ## Plan
43
-
44
- 1. **Parse hyperlink sources**: extend `Parts::Story` to expose
45
- `hyperlink_text_source` collection. Same for
46
- `hyperlink_text_destination`. Element classes already in
47
- `Idml::Elements` (TODO: add if missing).
48
- 2. **Parse hyperlink destinations**: extend `Parts::Designmap` to
49
- expose `hyperlink_url_object` and `hyperlink_destination_page_item`
50
- collections.
51
- 3. **Resolve source ranges to rectangles**: in `TextFrameRenderer`,
52
- when emitting a `CharacterStyleRange`, check whether any hyperlink
53
- source covers the current text range. If yes, emit the rectangle
54
- for that range as a Link annotation.
55
- 4. **Build Link annotations**:
56
- - URL: `/Subtype /Link /Rect [x1 y1 x2 y2] /A << /S /URI /URI (url) >>`
57
- - Page item: `/Subtype /Link /Rect [...] /D [page_ref /XYZ x y zoom]`
58
- 5. **Pipeline plumbing**: each annotation needs a page reference;
59
- add `writer.add_link_annotation(page_index:, rect:, uri: nil, dest: nil)`.
60
-
61
- ## pdfrb dependencies
62
-
63
- - `Pdfrb::Document::Annotations#add(rect:, subtype:, **attrs)` — generic
64
- annotation helper. Verify presence and signature.
3
+ ## Status: DONE (frame-level precision; per-range precision future work)
4
+
5
+ ## What was implemented
6
+
7
+ IDML hyperlinks render as PDF `/Subtype /Link` annotations. The flow:
8
+
9
+ 1. `<Hyperlink>` and `<HyperlinkURLDestination>` elements are parsed
10
+ from designmap (`Parts::Designmap#hyperlink`,
11
+ `#hyperlink_url_destination`).
12
+ 2. `Render::HyperlinkResolver` connects a source Self hyperlink
13
+ destination Self → URL.
14
+ 3. `Render::HyperlinkEmitter` runs after each spread renders. It
15
+ walks every `TextFrame` on the spread, looks up its story's
16
+ hyperlink sources via StyleResolver, and emits one Link
17
+ annotation per resolved URL via `PdfrbWriter#add_uri_link_annotation`.
18
+ 4. `PdfrbWriter#add_uri_link_annotation(page_index:, rect:, url:)`
19
+ builds a PDF URI action (`/A << /S /URI /URI (url) >>`) and
20
+ attaches it to a Link annotation registered on the page.
21
+
22
+ ## Element models added
23
+
24
+ - `Idml::Elements::Hyperlink` — `<Hyperlink>` element.
25
+ - `Idml::Elements::HyperlinkURLDestination` — `<HyperlinkURLDestination>` element.
26
+ - `Idml::Elements::HyperlinkPageDestination` (already existed) reused
27
+ for bookmark destinations (TODO 79).
28
+
29
+ ## Limitation
30
+
31
+ The emitter is frame-level, not text-range-level. Each text frame
32
+ with a hyperlink source gets a single Link annotation covering the
33
+ entire frame box, regardless of which characters the source actually
34
+ covers. Per-range precision requires deep text-engine integration —
35
+ tracking each character's (x, y) after layout and emitting one
36
+ annotation per source's TextRange.
37
+
38
+ Future enhancement: have `TextFrameRenderer` record glyph positions
39
+ during layout, then `HyperlinkEmitter` can compute precise rects.
40
+
41
+ ## Verification
42
+
43
+ - `lib/idml/elements/hyperlink.rb` — element model.
44
+ - `lib/idml/elements/hyperlink_url_destination.rb` element model.
45
+ - `lib/idml/render/hyperlink_resolver.rb` source → URL resolution.
46
+ - `lib/idml/render/hyperlink_emitter.rb` annotation emission per spread.
47
+ - `lib/idml/render/pdfrb_writer.rb` `add_uri_link_annotation`.
48
+ - `lib/idml/render/pipeline.rb:97` emitter call after each spread.
49
+ - `spec/idml/render/hyperlink_resolver_spec.rb` 5 specs covering
50
+ resolution, hidden skipping, missing-destination handling.
65
51
 
66
52
  ## Acceptance criteria
67
53
 
68
- - [ ] URL hyperlinks in IDML render as clickable Link annotations.
69
- - [ ] Cross-reference links jump to the correct page+position.
70
- - [ ] Hidden hyperlinks (`Visible="false"`) are skipped.
71
- - [ ] Spec covers URL, page-item, and invisible cases.
72
-
73
- ## Dependencies
74
-
75
- - IDML element classes for hyperlink sources/destinations (mostly
76
- present — verify and extend as needed).
77
- - pdfrb Annotations API.
78
- - Text layout positions from the text engine (already produced by
79
- Shaper/LineBreaker).
54
+ - [x] URL hyperlinks in IDML render as clickable Link annotations.
55
+ - [x] Hidden hyperlinks (`Visible="false"` or `Hidden="true"`) skipped.
56
+ - [x] Hyperlinks with unresolvable destinations skipped silently.
57
+ - [x] Spec covers visible, hidden, and missing-destination cases.
58
+ - [ ] Per-TextRange rect precision (deferred — current behavior is
59
+ frame-level).
@@ -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).
@@ -0,0 +1,38 @@
1
+ # TODO PDF 83: Pipeline decomposition
2
+
3
+ ## Status: DONE
4
+
5
+ ## What was done
6
+
7
+ Extracted metadata assembly from `Pipeline` into a dedicated
8
+ `Render::MetadataBuilder` class. Pipeline now calls
9
+ `MetadataBuilder.new(@package).build` instead of inlining the XMP
10
+ parsing, date conversion, and defaults logic.
11
+
12
+ ## Why
13
+
14
+ Pipeline had five private methods dedicated to metadata
15
+ (`combined_metadata`, `xmp_metadata`, `default_metadata`,
16
+ `pdf_date_string`, `pdf_date`). Each was a single responsibility
17
+ unrelated to the pipeline's core orchestration role. Moving them
18
+ into a focused class:
19
+
20
+ - Shrinks Pipeline by ~55 lines.
21
+ - Makes MetadataBuilder independently testable.
22
+ - Makes Pipeline's intent clearer: orchestrate, not parse.
23
+ - Lets future metadata sources (e.g., IDML document properties,
24
+ custom user metadata) plug in without touching Pipeline.
25
+
26
+ ## Verification
27
+
28
+ - `lib/idml/render/metadata_builder.rb` — extracted class.
29
+ - `lib/idml/render/pipeline.rb:25` — single-call usage.
30
+ - `spec/idml/render/metadata_builder_spec.rb` — 5 specs covering
31
+ defaults, XMP extraction, and the no-XMP fallback.
32
+
33
+ ## Acceptance criteria
34
+
35
+ - [x] MetadataBuilder extracted as a separate class.
36
+ - [x] Pipeline no longer contains XMP/date helpers.
37
+ - [x] Existing pipeline specs still pass.
38
+ - [x] MetadataBuilder has dedicated specs.
@@ -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
@@ -296,6 +296,8 @@ module Idml
296
296
  attribute :character_style_range, Idml::Elements::CharacterStyleRange,
297
297
  collection: true
298
298
  attribute :content, Idml::Elements::Content, collection: true
299
+ attribute :hyperlink_text_source, Idml::Elements::HyperlinkTextSource,
300
+ collection: true
299
301
  attribute :xml_element, Idml::Elements::XmlElement, collection: true
300
302
 
301
303
  xml do
@@ -649,6 +651,7 @@ module Idml
649
651
  map_attribute "UpperLevel", to: :upper_level
650
652
  map_element "CharacterStyleRange", to: :character_style_range
651
653
  map_element "Content", to: :content
654
+ map_element "HyperlinkTextSource", to: :hyperlink_text_source
652
655
  map_element "XMLElement", to: :xml_element
653
656
  end
654
657
 
@@ -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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # `<HyperlinkTextSource>` — source anchor of a hyperlink in a
6
+ # story. Per `HyperlinkTextSource_Object` in
7
+ # `reference-docs/schemas/package/Stories/Story.rnc`. The `Self`
8
+ # attribute is referenced by `<Hyperlink Source="...">` in the
9
+ # document designmap.
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.
16
+ class HyperlinkTextSource < Lutaml::Model::Serializable
17
+ attribute :self_attr, :string
18
+ attribute :name, :string
19
+ attribute :hidden, :boolean
20
+ attribute :applied_character_style, :string
21
+
22
+ xml do
23
+ root "HyperlinkTextSource"
24
+ map_attribute "Self", to: :self_attr
25
+ map_attribute "Name", to: :name
26
+ map_attribute "Hidden", to: :hidden
27
+ map_attribute "AppliedCharacterStyle", to: :applied_character_style
28
+ end
29
+ end
30
+ end
31
+ 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