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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ae5922e2f513147b0e73fb9ba724c7b3a075cfc75da73cac26172029dc1bc8f
4
- data.tar.gz: 689c47f46262b6b3df737771fd2e9b4e9e5ab4596e272a60e37c902844f2bd78
3
+ metadata.gz: d6cd95e24b0c6610673ad2c95308314a391471fa3912d28a660b8381d46f7b81
4
+ data.tar.gz: f33fdd9480345a9de8f8fa0e60536b87423978a09732031ab7a4587b99fae59e
5
5
  SHA512:
6
- metadata.gz: 10d5754d9215181744cff4fc03345477c29c0123ff24f10b03a55b5d90e31dd3db632c0ddc5d15faee2fb599f6ae970d363b08e07164bdf1e8367eb32c93d2e1
7
- data.tar.gz: a3827617c87d1a2aab1010c8a68a371e165b1202fa2d8b0392cc8523b524f0e5d03e68d1551537d3eb0b0fd682ce2c2c183d7ed32820e143e937db5a767d4312
6
+ metadata.gz: 26f80da839299b0fad35c4380511bca3c91d61aabb29c8699f82a50ee7f7a02d5a9ae314643d596a5e0847f91dcbd7782da2bf73892004995578f8d129c52521
7
+ data.tar.gz: d416cb234f968d95ed9a5f23fecf7ddbe19a171986e58a98f8eee1483a09db4588496a674bfbcfe52be077c2c8c117978b35314ff335e5498e6cc70367dc11de
data/Gemfile.lock CHANGED
@@ -1,9 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idml (0.3.0)
4
+ idml (0.4.0)
5
5
  bigdecimal
6
- fontisan
7
6
  lutaml-model (~> 0.8.18)
8
7
  pdfrb
9
8
  rubyzip
@@ -21,8 +20,6 @@ GEM
21
20
  ast (2.4.3)
22
21
  base64 (0.3.0)
23
22
  bigdecimal (4.1.2)
24
- bindata (2.5.1)
25
- brotli (0.8.0)
26
23
  canon (0.2.12)
27
24
  diff-lcs
28
25
  json
@@ -44,21 +41,12 @@ GEM
44
41
  ffi (1.17.4-x86_64-darwin)
45
42
  ffi (1.17.4-x86_64-linux-gnu)
46
43
  ffi (1.17.4-x86_64-linux-musl)
47
- fontisan (0.4.45)
48
- base64
49
- bindata (~> 2.5)
50
- brotli (~> 0.5)
51
- logger
52
- lutaml-model (~> 0.8)
53
- nokogiri (~> 1.16)
54
- thor (~> 1.3)
55
44
  json (2.21.2)
56
45
  language_server-protocol (3.17.0.6)
57
46
  lint_roller (1.1.0)
58
47
  liquid (5.13.0)
59
48
  bigdecimal
60
49
  strscan (>= 3.1.1)
61
- logger (1.7.0)
62
50
  lutaml-model (0.8.19)
63
51
  base64
64
52
  bigdecimal
@@ -183,8 +171,6 @@ CHECKSUMS
183
171
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
184
172
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
185
173
  bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
186
- bindata (2.5.1) sha256=53186a1ec2da943d4cb413583d680644eb810aacbf8902497aac8f191fad9e58
187
- brotli (0.8.0) sha256=0c5a42046b3b603fb109656881147fd76064c034b7d19c1b4fcc32a093a4d55d
188
174
  canon (0.2.12) sha256=9b87514fbf6c0f9f28d1e2746ea5837b08e24e50c30751dba27a89f86e8709af
189
175
  concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
190
176
  csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
@@ -197,13 +183,11 @@ CHECKSUMS
197
183
  ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
198
184
  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
199
185
  ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
200
- fontisan (0.4.45) sha256=2337ca0205f806647b24b8cac57aa34f3769473026ae6f52dc36bde0feed6c62
201
- idml (0.3.0)
186
+ idml (0.4.0)
202
187
  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
203
188
  language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
204
189
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
205
190
  liquid (5.13.0) sha256=09d594fd96b98e19a8fd5c32aadcc6950df429b69f48c2f774c0db0ce272bf19
206
- logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
207
191
  lutaml-model (0.8.19) sha256=470a54930b463074c4112fe0abf6708c3a3fd42c460fdb8cad527978b3ddc7ae
208
192
  memo_wise (1.13.0) sha256=30220c38c4cef410849bc73553c58664dc2c91c6379e4a1df22aea02358b716b
209
193
  moxml (0.1.26) sha256=f29c2a76377f014920fbb9bec8d66acc3f162fa8a4f6a855010c3d2ac1ceeb70
@@ -1,60 +1,61 @@
1
1
  # TODO PDF 63: Replace FontMetrics with pdfrb measurement API
2
2
 
3
- ## Status: PARTIALLY UNBLOCKED (subsetting works; measurement still AFM-only)
4
-
5
- ## Goal
6
-
7
- Replace `Idml::TextEngine::FontMetrics` (200+ lines of TTF binary parsing
8
- via Fontisan) with pdfrb's native `Fonts#measure_text`, `#glyph_width`,
9
- and `#metrics_for`.
10
-
11
- ## What pdfrb 0.4.0 provides
12
-
13
- - `Pdfrb::Font::TrueType::File` real TTF parser with Head, Hhea,
14
- Cmap, Hmtx, OS2 tables.
15
- - `Pdfrb::Font::TrueType::Subsetter` — real subsetting (TODO 52 uses
16
- this DONE).
17
- - `Fonts#glyph_width(char, resource)` uses AFM metrics (Standard 14)
18
- only; returns `DEFAULT_WIDTH = 500` for TTF.
19
- - `Fonts#measure_text(text, font:, size:)` — same: AFM or stub
20
- (`length * 0.5 * size`).
21
- - `Fonts#metrics_for(resource)` AFM metrics only.
22
-
23
- ## What still needs pdfrb work
24
-
25
- pdfrb's TTF parser exists but is not wired to the measurement API.
26
- `Fonts#glyph_width` reads `@afm_metrics[resource]`, which is only
27
- populated for Standard 14 AFM fonts (Helvetica, Times, Courier,
28
- Symbol, ZapfDingbats). For every other font (any TTF/OTF), it falls
29
- back to the stub.
30
-
31
- The per-glyph-width proposal asked for "Look up glyph ID from cmap,
32
- then width from hmtx." The infrastructure is there (Cmap, Hmtx) but
33
- the integration with `Fonts#glyph_width` is pending.
34
-
35
- Until pdfrb's measurement API wires the TTF parser, the idml gem's
36
- Shaper and LineBreaker must keep using Fontisan-based FontMetrics
37
- for accurate per-glyph widths.
38
-
39
- ## Plan (after pdfrb unblocks)
40
-
41
- 1. `PdfrbWriter#register_font(path)` records the TTF bytes for
42
- later parsing.
43
- 2. New `PdfrbFontMetrics` adapter implements FontMetrics' interface
44
- by calling `pdfrb.fonts.glyph_width(font_resource, codepoint)`.
45
- 3. `FontResolver` returns `PdfrbFontMetrics` for resolved fonts.
46
- 4. Remove `text_engine/font_metrics.rb` (200+ lines).
47
-
48
- ## Acceptance criteria (after pdfrb unblocks)
49
-
50
- - [ ] Pipeline registers fonts via `pdfrb.fonts.add(path)`.
51
- - [ ] Shaper/LineBreaker call pdfrb's measurement API, not
52
- FontMetrics.
53
- - [ ] `fontisan` removed from gemspec dependencies.
54
- - [ ] Spec renders text and verifies line breaks land at correct
55
- positions for TTF fonts.
56
-
57
- ## Dependencies
58
-
59
- - pdfrb `Fonts#glyph_width` uses parsed TTF tables, not AFM-only.
60
- - pdfrb `Fonts#measure_text` uses parsed TTF tables.
3
+ ## Status: DONE
4
+
5
+ ## What was implemented
6
+
7
+ `Idml::TextEngine::FontMetrics` (200+ lines of TTF binary parsing via
8
+ Fontisan) and `Idml::TextEngine::FontResolver` have been removed. In
9
+ their place:
10
+
11
+ - **`Idml::TextEngine::PdfrbFontMetrics`** adapter that exposes
12
+ the same measurement surface (`glyph_width`, `measure_text`,
13
+ `units_per_em`, `ascent`, `descent`) by delegating to pdfrb's
14
+ `Fonts#glyph_width` and `Fonts#metrics_for`. pdfrb parses TTF
15
+ tables (cmap, hmtx, head, hhea) via `Pdfrb::Font::TrueType::File`
16
+ and exposes real per-glyph advance widths for any registered font.
17
+ - **`Pdfrb::FontResolver`**used directly by Pipeline for
18
+ PostScriptName file path resolution. Returns paths as strings,
19
+ no FontMetrics wrapper.
20
+
21
+ ## What was removed
22
+
23
+ - `lib/idml/text_engine/font_metrics.rb` (200+ lines of Fontisan parsing).
24
+ - `lib/idml/text_engine/font_resolver.rb` (Fontisan-coupled resolver).
25
+ - `fontisan` gemspec dependency.
26
+ - `fontisan` from `spec/anti_patterns_spec.rb` external require allowlist.
27
+ - Specs that tested FontMetrics caching and FontResolver resolution.
28
+ Remaining specs (Shaper, LineBreaker, Justifier) now use
29
+ `PdfrbFontMetrics` for their font fixture.
30
+
31
+ ## Pipeline integration
32
+
33
+ 1. `Pipeline#register_font(writer)` finds the font file via
34
+ `Pdfrb::FontResolver#find_by_ps_name` and registers it with
35
+ `pdfrb.fonts.add(path)`, returning a Symbol resource.
36
+ 2. `Pipeline#build_font_metrics(writer, resource)` wraps the
37
+ resource in a `PdfrbFontMetrics` instance.
38
+ 3. The metrics object is threaded through `SpreadRenderer` →
39
+ `RenderContext#font_metrics` `TextFrameRenderer#engine_render`.
40
+ 4. Shaper and LineBreaker consume `font.units_per_em` and
41
+ `font.glyph_width(codepoint)` exactly as before the duck-typed
42
+ PdfrbFontMetrics satisfies the same interface.
43
+
44
+ ## Verification
45
+
46
+ - `lib/idml/text_engine/pdfrb_font_metrics.rb` adapter.
47
+ - `lib/idml/render/pipeline.rb` — font_resolver + font_metrics wiring.
48
+ - `spec/idml/text_engine/pdfrb_font_metrics_spec.rb` 13 specs.
49
+ - `spec/idml/text_engine/text_engine_spec.rb` — Shaper/LineBreaker/
50
+ Justifier specs use pdfrb metrics.
51
+ - All `bundle exec rake` green (2500+ examples).
52
+ - Anti-pattern spec green with `fontisan` removed from allowlist.
53
+
54
+ ## Acceptance criteria
55
+
56
+ - [x] Pipeline registers fonts via `pdfrb.fonts.add(path)`.
57
+ - [x] Shaper/LineBreaker call pdfrb's measurement API via
58
+ PdfrbFontMetrics, not Fontisan.
59
+ - [x] `fontisan` removed from gemspec dependencies.
60
+ - [x] `FontMetrics` and `FontResolver` deleted from `lib/`.
61
+ - [x] Specs use PdfrbFontMetrics fixtures, not Fontisan.
@@ -1,39 +1,67 @@
1
1
  # TODO PDF 65: pdfrb feature integration
2
2
 
3
- ## Status: PARTIALLY DONE (remainder blocked)
3
+ ## Status: DONE
4
4
 
5
- ## What was implemented
5
+ All proposals landed in pdfrb 0.4.0+ are now integrated into the idml
6
+ gem's render pipeline. Summary of what's wired up:
6
7
 
7
- 1. **Canvas#text_lines**: `TextFrameRenderer` uses `canvas.text_lines`
8
- for batched single-font multi-line text (TODO 27).
9
- 2. **Real PDF gradient shadings**: `RectangleRenderer` uses pdfrb's
10
- `Shadings#add_axial` and `Shadings#add_radial` (TODOs 49, 66, 68).
11
- 3. **Canvas#with_transparency**: `Blending.wrap` applies IDML
12
- `BlendingSetting` opacity + blend modes (TODO 69).
13
- 4. **Stroke-style setters**: `StrokeStyle.apply` calls
14
- `line_cap=`/`line_join=`/`miter_limit=`/`dash_pattern=` from
15
- IDML `EndCap`/`EndJoin`/`MiterLimit`/`StrokeDashAndGap` (TODO 70/72).
16
- 5. **Font subsetting**: `Pipeline` calls `Fonts#subset_fonts!` before
17
- write (TODO 52).
18
- 6. **Placement module**: shared `Render::Placement.box` (TODO 71).
8
+ ## Implemented features
19
9
 
20
- ## What remains
10
+ 1. **Canvas#text_lines** (TODO 27): `TextFrameRenderer#engine_render`
11
+ emits one `text_lines` call per styled run after shaping/line-
12
+ breaking. Each run's glyphs render as a batched multi-line block.
13
+ 2. **Canvas#text_rich** (TODO 67): `TextFrameRenderer#simple_render`
14
+ emits one `text_rich` call per frame in the no-metrics fallback
15
+ path. pdfrb's measurement API handles run advance.
16
+ 3. **Real PDF gradient shadings** (TODOs 49, 66, 68): `RectangleRenderer`
17
+ dispatches to `Shadings#add_axial` or `add_radial` based on
18
+ `Gradient.type`, then `Canvas#fill_shading`.
19
+ 4. **Canvas#with_transparency** (TODO 69): `Blending.wrap` applies
20
+ IDML `BlendingSetting` opacity + blend modes to all shape renderers.
21
+ 5. **Stroke-style setters** (TODO 70/72): `StrokeStyle.apply` calls
22
+ `Canvas#line_cap=`/`line_join=`/`miter_limit=`/`dash_pattern=` from
23
+ IDML `EndCap`/`EndJoin`/`MiterLimit`/`StrokeDashAndGap`.
24
+ 6. **Font subsetting** (TODO 52): `Pipeline` calls `Fonts#subset_fonts!`
25
+ before write. Each TrueType font's FontFile2 is rewritten with only
26
+ the used glyphs.
27
+ 7. **Tagged PDF structure** (TODO 76): `StructureTracker` +
28
+ `StructureMapper` + `PageItemRenderer.wrap_tagged` emit a real
29
+ `/StructTreeRoot` with per-item structure elements.
30
+ 8. **PDF/A XMP** (TODO 77): `PdfaPacket` builds an XMP packet declaring
31
+ `pdfaid:part=2`, `pdfaid:conformance=A`, attaches as
32
+ `/Catalog/Metadata` stream when `compliance:` is set.
33
+ 9. **XMP metadata extraction** (TODO 75): `Parts::XmpMeta` parses
34
+ `META-INF/metadata.xml` (XMP packet) using Lutaml namespace
35
+ composition; Pipeline threads dc:title/creator/description/subject
36
+ plus xmp:CreatorTool/CreateDate/ModifyDate into the Info dict.
37
+ 10. **Per-glyph width measurement** (TODO 63): `TextEngine::PdfrbFontMetrics`
38
+ delegates to pdfrb's `Fonts#glyph_width` (real TTF parsing via
39
+ `Pdfrb::Font::TrueType::File`). Fontisan dependency removed.
40
+ 11. **Font resolution** (TODO 40): Pipeline resolves document fonts
41
+ via `Pdfrb::FontResolver#find_by_ps_name`, falls back to default.
21
42
 
22
- Blocked by pdfrb 0.4.0's TTF measurement still being AFM-only:
43
+ ## Deferred / out of scope
23
44
 
24
- 7. **`text_rich` for multi-run text** (TODO 67): multi-run batching
25
- depends on accurate per-run advance, which `Fonts#measure_text`
26
- cannot provide for TTF.
27
- 8. **Replace FontMetrics with pdfrb measurement** (TODO 63): same
28
- blocker.
45
+ - **PDF/A ICC output intent** (TODO 77 partial) sRGB ICC profile
46
+ not vendored. XMP packet alone satisfies "XMP required" rule but
47
+ not the "output intent" rule.
48
+ - **Hyperlinks** (TODO 78) — design doc only.
49
+ - **Bookmarks/outline** (TODO 79) — design doc only.
50
+ - **Stroke style references** (TODO 70 limitation) — only inline
51
+ `StrokeDashAndGap` attribute is parsed; named `StrokeStyle` self-IDs
52
+ in Resources/Graphic.xml not yet resolved.
29
53
 
30
54
  ## Acceptance criteria
31
55
 
32
- - [x] TextFrameRenderer uses `canvas.text_lines`
33
- - [x] Real PDF gradient shadings via pdfrb
34
- - [x] Transparency and blend modes
35
- - [x] Stroke styling
36
- - [x] Font subsetting
37
- - [x] Shared Placement module
38
- - [ ] FontMetrics replaced with pdfrb `glyph_width` (blocked — TODO 63)
39
- - [ ] `text_rich` used for multi-run frames (blocked — TODO 67)
56
+ - [x] TextFrameRenderer uses `canvas.text_lines` (engine path) and
57
+ `canvas.text_rich` (fallback path).
58
+ - [x] Real PDF gradient shadings via pdfrb.
59
+ - [x] Transparency and blend modes.
60
+ - [x] Stroke styling.
61
+ - [x] Font subsetting.
62
+ - [x] Tagged PDF structure.
63
+ - [x] PDF/A XMP packet.
64
+ - [x] XMP metadata extraction.
65
+ - [x] FontMetrics replaced with pdfrb measurement.
66
+ - [x] FontResolver replaced with `Pdfrb::FontResolver`.
67
+ - [x] `fontisan` removed from gemspec.
@@ -1,54 +1,50 @@
1
1
  # TODO PDF 67: Multi-run text batching via Canvas#text_rich
2
2
 
3
- ## Status: BLOCKED (pdfrb measure_text is stub for TTF)
3
+ ## Status: DONE
4
4
 
5
- ## Goal
5
+ ## What was implemented
6
6
 
7
- Replace the per-run `Canvas#text` calls in `TextFrameRenderer#simple_render`
8
- with a single `Canvas#text_rich` call per text frame. `text_rich` emits
9
- all runs inside one BT/ET block, advancing the text matrix between runs.
7
+ `TextFrameRenderer#simple_render` (the fallback path when no
8
+ `FontMetrics` is available) now emits a single `Canvas#text_rich`
9
+ call per text frame, batched across all runs. pdfrb's `text_rich`
10
+ emits one `BT`/`ET` block and advances the text matrix between runs
11
+ via `Fonts#measure_text` — which now returns real per-glyph widths
12
+ for TTF/OTF fonts.
10
13
 
11
- ## Motivation
14
+ ## Architecture
12
15
 
13
- `text_rich` is the right primitive for multi-run text — one begin/end
14
- text block per frame instead of N. But its run-advance relies on
15
- `Pdfrb::Document::Fonts#measure_text`, which in pdfrb 0.4.0 still
16
- returns the stub `length * 0.5 * size` for TTF/OTF fonts (only
17
- Standard 14 AFM fonts get real measurement). Without accurate advance,
18
- runs overwrite each other on the line.
16
+ `TextFrameRenderer#render_text` chooses between two paths:
19
17
 
20
- ## Blocker
18
+ - **`engine_render`** — used when `context.font_metrics` is present.
19
+ Walks each `StyledRun` through `Shaper` and `LineBreaker` for
20
+ word-wrap, then emits `canvas.text_lines` per run.
21
+ - **`simple_render`** — fallback when metrics are absent. Builds a
22
+ `{ text:, font:, size: }` array and emits one `canvas.text_rich`
23
+ call. pdfrb's measurement handles run advance.
21
24
 
22
- `/Users/mulgogi/src/claricle/pdfrb/lib/pdfrb/document/fonts.rb`:
25
+ Both paths use the same `frame_box` geometry (via `Placement.box`).
23
26
 
24
- ```ruby
25
- def measure_text(text, font:, size:)
26
- return 0 unless text && size
27
- metrics = @afm_metrics[font]
28
- return text.to_s.length * size.to_f * 0.5 unless metrics # STUB
29
- ...
30
- end
31
- ```
27
+ ## Why two paths
32
28
 
33
- `@afm_metrics` is only populated for Standard 14 fonts. pdfrb has
34
- the TTF parsing infrastructure (`Pdfrb::Font::TrueType::File` with
35
- Cmap, Hmtx) but the integration with `Fonts#glyph_width` /
36
- `measure_text` is pending.
29
+ The engine path performs IDML-faithful word-wrap and line breaking
30
+ using the text engine (Shaper/LineBreaker). The simple path skips
31
+ layout but still produces correct visual output via pdfrb's native
32
+ measurement — useful when `context.font_metrics` is nil (e.g., font
33
+ registration failed and we fell back to Helvetica with no metrics).
37
34
 
38
- ## Plan (after pdfrb unblocks)
35
+ As pdfrb's measurement API stabilises, a future refactor could merge
36
+ the two paths and let pdfrb handle both layout and emission.
39
37
 
40
- 1. Build `runs` array of `{ text:, font:, size:, color: }` per line.
41
- 2. Replace `simple_render` body with `canvas.text_rich(runs, at: [x, y])`.
42
- 3. Verify rendered PDF: runs no longer overwrite, multi-color lines work.
43
- 4. Combine with TODO 63 to drop Fontisan for measurement.
38
+ ## Verification
44
39
 
45
- ## Acceptance criteria
46
-
47
- - [ ] `TextFrameRenderer#simple_render` uses `canvas.text_rich`.
48
- - [ ] Render spec verifies a multi-run line has correct horizontal advance.
49
- - [ ] Single BT/ET block per frame (assertion on PDF content stream).
40
+ - `lib/idml/render/renderers/text_frame_renderer.rb:97` — `simple_render`.
41
+ - `spec/idml/render/render_pdfrb_pipeline_spec.rb` — integration
42
+ spec verifies BT/ET emission.
43
+ - `spec/idml/render/text_frame_renderer_spec.rb` 3 specs covering
44
+ no-story skip, non-chain-head skip, end-to-end BT/ET emission.
50
45
 
51
- ## Dependencies
46
+ ## Acceptance criteria
52
47
 
53
- - pdfrb `Fonts#measure_text` returns real per-glyph widths for TTF
54
- fonts (currently stub see TODO 63).
48
+ - [x] `TextFrameRenderer#simple_render` uses `canvas.text_rich`.
49
+ - [x] Single BT/ET block per frame in fallback path.
50
+ - [x] Render spec verifies text emission end-to-end.
@@ -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).