idml 0.4.0 → 0.4.2

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: d6cd95e24b0c6610673ad2c95308314a391471fa3912d28a660b8381d46f7b81
4
- data.tar.gz: f33fdd9480345a9de8f8fa0e60536b87423978a09732031ab7a4587b99fae59e
3
+ metadata.gz: 79791db7118dac992a6dbfc780f143bc545cc1d6b7c408fac91fe9eb75de8d32
4
+ data.tar.gz: f98eb3f41100e1b7f62c51511f33aa774a5272e14fb4bcc7012dff50c88336d3
5
5
  SHA512:
6
- metadata.gz: 26f80da839299b0fad35c4380511bca3c91d61aabb29c8699f82a50ee7f7a02d5a9ae314643d596a5e0847f91dcbd7782da2bf73892004995578f8d129c52521
7
- data.tar.gz: d416cb234f968d95ed9a5f23fecf7ddbe19a171986e58a98f8eee1483a09db4588496a674bfbcfe52be077c2c8c117978b35314ff335e5498e6cc70367dc11de
6
+ metadata.gz: 34b95aeea5bf217fec6a88f8ae7a8a03c399001c0f4735bc11b0485afe27dbf9012fd481c8e6005f68f17fcc2bf850a63d03a3d23249bd570a57739ab92af1ed
7
+ data.tar.gz: 1f462441786c0a807f3bb0ddb01ff0c1c4a2e9a8e736fbd9e18b7666c2fe1c57c917dae3770b051ec716a25f4202cba5eef86a96bdca9630263ff5f385527367
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idml (0.4.0)
4
+ idml (0.4.2)
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.4.0)
186
+ idml (0.4.2)
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
@@ -0,0 +1,54 @@
1
+ # TODO PDF 83: Pipeline decomposition
2
+
3
+ ## Status: DONE
4
+
5
+ ## What was done
6
+
7
+ Two extractions from `Pipeline` into dedicated classes:
8
+
9
+ 1. **`Render::MetadataBuilder`** — metadata assembly (combined
10
+ defaults + XMP-extracted fields, ISO 8601 → PDF date conversion).
11
+ 2. **`Render::FontSetup`** — document font resolution via
12
+ `Pdfrb::FontResolver`, registration with pdfrb, and
13
+ `PdfrbFontMetrics` adapter construction.
14
+
15
+ Pipeline now does pure orchestration: build writer → set metadata →
16
+ apply compliance → set up structure → resolve font → render spreads
17
+ → flush structure → emit bookmarks → subset → write. No parsing,
18
+ no date math, no font-path lookup.
19
+
20
+ ## Why
21
+
22
+ Pipeline had 9 private methods across metadata, font setup, and
23
+ rendering concerns. Each concern is now its own class:
24
+
25
+ - `MetadataBuilder` — Info dict assembly.
26
+ - `FontSetup` — font file lookup + registration + metrics adapter.
27
+ - `PdfaPacket` — XMP packet + Catalog /Metadata attachment.
28
+ - `IccProfile` — locate sRGB ICC bytes.
29
+ - `BookmarkResolver` — destination chain + page index.
30
+ - `HyperlinkResolver` — source → URL lookup.
31
+ - `HyperlinkEmitter` — Link annotation emission.
32
+ - `StructureTracker` — MCID allocation + element registration.
33
+ - `StructureMapper` — item → PDF structure type.
34
+ - `ImageCollector` — image registration + placement.
35
+
36
+ Pipeline shrunk from ~225 lines (v0.3.0) to ~135 lines (v0.4.2).
37
+
38
+ ## Verification
39
+
40
+ - `lib/idml/render/metadata_builder.rb` — extracted.
41
+ - `lib/idml/render/font_setup.rb` — extracted.
42
+ - `lib/idml/render/pipeline.rb` — orchestration only.
43
+ - `spec/idml/render/metadata_builder_spec.rb` — 5 specs.
44
+ - `spec/idml/render/font_setup_spec.rb` — 4 specs.
45
+ - `spec/idml/render/placement_spec.rb` — added for completeness.
46
+
47
+ ## Acceptance criteria
48
+
49
+ - [x] MetadataBuilder extracted.
50
+ - [x] FontSetup extracted.
51
+ - [x] Pipeline no longer contains metadata/font helpers.
52
+ - [x] Each extracted class has dedicated specs.
53
+ - [x] All existing pipeline specs still pass.
54
+
@@ -0,0 +1,80 @@
1
+ # TODO PDF 84: Schema-faithful Table/Cell/Row structure
2
+
3
+ ## Status: DEFERRED (architectural correction; no fixtures to validate)
4
+
5
+ ## Current state vs schema
6
+
7
+ The existing `Idml::Elements::Table`, `TableRow`, `TableCell` classes
8
+ do not match the IDML RNC schema. Three structural divergences:
9
+
10
+ ### 1. Table lives in Stories, not Spreads
11
+
12
+ Per `reference-docs/schemas/package/Stories/Story.rnc`, `Table_Object`
13
+ appears inside `Story_Object` (as a child of `ParagraphStyleRange` or
14
+ `CharacterStyleRange`). The current `Idml::Elements::SpreadObject`
15
+ incorrectly declares `attribute :table, ... collection: true`, and
16
+ `PageItemRenderer` dispatches spread-level Tables to `TableRenderer`.
17
+
18
+ In real IDML, Tables appear inside Stories. A TextFrame's story
19
+ contains the table; the renderer should walk the story to find
20
+ tables, not the spread.
21
+
22
+ ### 2. Element names: `Cell` and `Row`, not `TableCell` and `TableRow`
23
+
24
+ Per RNC, the element names are:
25
+ - `element Cell` (`Cell_Object`)
26
+ - `element Row` (`Row_Object`)
27
+
28
+ The current model uses `TableCell` and `TableRow` as both class names
29
+ and XML element roots — neither matches the schema.
30
+
31
+ ### 3. Cell and Row are siblings under Table, not nested
32
+
33
+ Per RNC, `Table_Object` contains `Cell_Object* & Row_Object*` as
34
+ **siblings**, not nested `<Row><Cell>` structure:
35
+
36
+ ```
37
+ Table_Object = element Table {
38
+ ...
39
+ Cell_Object*,
40
+ Row_Object*
41
+ }
42
+ ```
43
+
44
+ The current model uses `Table > TableRow > TableCell` nesting, which
45
+ won't parse real IDML.
46
+
47
+ ## Plan
48
+
49
+ 1. Add new `Elements::Cell` (matching `Cell_Object` RNC, ~80 attrs).
50
+ 2. Add new `Elements::Row` (matching `Row_Object` RNC).
51
+ 3. Restructure `Elements::Table` to declare both `cell` and `row`
52
+ sibling collections.
53
+ 4. Remove `Elements::TableCell` and `Elements::TableRow` (broken).
54
+ 5. Remove `:table` from `Elements::SpreadObject`; Tables come via
55
+ Stories only.
56
+ 6. Update `TableRenderer` to:
57
+ - Dispatch from `TextFrameRenderer` when a story contains a Table.
58
+ - Render via `cell` and `row` siblings, using cell `Name` attr
59
+ (e.g., `"0_0"`) to determine column/row position.
60
+ 7. Update `PageItemRenderer::RENDERER_MAP` to remove Table (no
61
+ longer a page item).
62
+ 8. Update specs.
63
+
64
+ ## Why deferred
65
+
66
+ - No fixture in `spec/fixtures/` contains a Table, so the current
67
+ broken model has never been validated against real IDML.
68
+ - The correction touches multiple subsystems (elements, parts,
69
+ renderers, dispatch) and is best done as a focused refactor with
70
+ real-fixture regression tests.
71
+ - Adding fixtures requires either Adobe InDesign (commercial) or a
72
+ hand-crafted IDML ZIP matching the schema.
73
+
74
+ ## Acceptance criteria
75
+
76
+ - [ ] `Elements::Cell` matches `Cell_Object` RNC.
77
+ - [ ] `Elements::Row` matches `Row_Object` RNC.
78
+ - [ ] `Elements::Table` has sibling `cell` and `row` collections.
79
+ - [ ] Tables dispatch from `TextFrameRenderer`, not spread iteration.
80
+ - [ ] Real-fixture regression test with a known Table IDML.
@@ -0,0 +1,93 @@
1
+ # TODO PDF 85: Per-TextRange hyperlink precision
2
+
3
+ ## Status: DEFERRED (requires text-engine position tracking)
4
+
5
+ ## Current state
6
+
7
+ `HyperlinkEmitter` (TODO 78) emits one Link annotation per text
8
+ frame whose story contains a hyperlink source. The annotation
9
+ covers the entire frame's bounding box, not just the linked
10
+ characters.
11
+
12
+ ## What per-TextRange precision requires
13
+
14
+ For each hyperlink source, compute the rect that covers exactly
15
+ the source's characters on the page.
16
+
17
+ ### Position tracking
18
+
19
+ The text engine (`Shaper`, `LineBreaker`, `Justifier`) already
20
+ produces positioned lines via `VerticalLayout`, but
21
+ `TextFrameRenderer` does not surface per-character positions
22
+ outside its own scope. To compute per-source rects we need:
23
+
24
+ 1. **Absolute character index per CSR** — track cumulative char
25
+ position as CSRs are processed within a story.
26
+ 2. **Per-glyph (x, y) after layout** — Shaper gives widths,
27
+ LineBreaker gives line assignments, Justifier gives x_offset,
28
+ but no structured "positioned glyph" output is exposed today.
29
+ 3. **Source range lookup** — map `HyperlinkTextSource#Self` to a
30
+ `[start_char, end_char]` range. Today HyperlinkTextSource is
31
+ modelled as a sibling element of `Content` inside CSR; the
32
+ range is implicit (the source wraps its own content).
33
+
34
+ ### Emitter change
35
+
36
+ `HyperlinkEmitter` currently does:
37
+ ```ruby
38
+ box = Placement.box(frame, @page_height)
39
+ @writer.add_uri_link_annotation(page_index:, rect: rect_for(box), url:)
40
+ ```
41
+
42
+ Per-range version needs the positioned glyph data:
43
+ ```ruby
44
+ positions = context.positioned_glyphs_for_frame(frame.self_attr)
45
+ range = source_range_for(hyperlink_source)
46
+ rect = bounding_rect_for_range(positions, range)
47
+ @writer.add_uri_link_annotation(page_index:, rect:, url:)
48
+ ```
49
+
50
+ ### Threading
51
+
52
+ Positioned glyph data must travel from `TextFrameRenderer` (which
53
+ runs the layout engine) to `HyperlinkEmitter` (which runs after
54
+ the page renders). Options:
55
+
56
+ - **A. Side channel via RenderContext**: add `positioned_glyphs`
57
+ hash to RenderContext, keyed by frame Self. Renderers populate
58
+ it; emitter reads it.
59
+ - **B. Two-pass render**: re-run layout in the emitter to compute
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.
87
+
88
+ ## Acceptance criteria
89
+
90
+ - [ ] PositionTracker accumulates per-frame positioned ranges.
91
+ - [ ] HyperlinkEmitter queries tracker for source ranges.
92
+ - [ ] Each hyperlink source gets its own Link annotation rect.
93
+ - [ ] Spec covers multi-source story with overlapping ranges.
@@ -0,0 +1,71 @@
1
+ # TODO PDF 86: Caching and performance
2
+
3
+ ## Status: PARTIAL — most hot paths cached; one risk noted
4
+
5
+ ## What is cached today
6
+
7
+ - `ColorResolver#resolve` — per-color-name cache (`@cache`).
8
+ - `PdfrbFontMetrics#metrics_data` — read-through to pdfrb's
9
+ `Fonts#metrics_for` which has its own cache.
10
+ - `Pipeline#font_resolver` — lazily memoised via `||=`.
11
+ - `MetadataBuilder#xmp_packet` — memoised via `@xmp_packet ||=`.
12
+ - `PdfrbWriter#@image_cache` — URI → name cache for images.
13
+ - `Pdfrb::Document` internals — fonts, pages, structure all have
14
+ their own caches.
15
+
16
+ ## What is not cached
17
+
18
+ ### `geometric_bounds` on element instances
19
+
20
+ Each call to `Placement.box(item, page_height)` calls
21
+ `item.geometric_bounds`, which walks `Properties → PathGeometry →
22
+ bounding_box`. For each rendered item, this is called:
23
+
24
+ - 1× in the renderer (via `Placement.box`).
25
+ - 1× in `ImageCollector#clip_box_for` (for items that have images).
26
+ - 1× in `HyperlinkEmitter#emit_for_frame` (for text frames).
27
+
28
+ So 2–3 walks per item. Memoising at the element level would save
29
+ the duplicate walks.
30
+
31
+ ### Risk: Lutaml model instance variables
32
+
33
+ Lutaml::Model::Serializable instances are populated via the
34
+ framework's own `instance_variable_set` during deserialization.
35
+ Adding `@geometric_bounds ||= ...` could conflict if Lutaml ever
36
+ adds an attribute of the same name, or if the framework resets
37
+ instance state during round-trip.
38
+
39
+ The safer pattern is to memoise at the helper layer (Placement)
40
+ using `Object#object_id` as the cache key, but that introduces
41
+ memory pressure (unbounded hash growth).
42
+
43
+ ### Decision
44
+
45
+ Defer until profiling shows it matters. Current render of the
46
+ sample-with-image fixture (~60KB PDF, multiple shapes/text frames)
47
+ completes in ~250ms, which is fast enough for typical use.
48
+
49
+ ## Other performance notes
50
+
51
+ - **SpreadRenderer iterates spread items 3×**: once for
52
+ `ImageCollector`, once for rendering, once for `HyperlinkEmitter`.
53
+ Could be combined but couples concerns.
54
+ - **No lazy part loading**: opening a Package reads all parts
55
+ eagerly. For large IDML files this could be slow; not yet a
56
+ measured problem.
57
+
58
+ ## Plan (if profiling shows it matters)
59
+
60
+ 1. Memoise `geometric_bounds` on `Elements::Rectangle`, `Polygon`,
61
+ `GraphicLine`, `TextFrame`, `Table` via `||=` with a
62
+ non-conflicting ivar name (e.g., `@_bounds_cache`).
63
+ 2. Add a spec that asserts the cache is populated after first
64
+ access.
65
+ 3. Re-run render benchmarks.
66
+
67
+ ## Acceptance criteria
68
+
69
+ - [ ] Profile render of a large fixture and identify hot paths.
70
+ - [ ] If `geometric_bounds` shows up, memoise safely.
71
+ - [ ] Spec asserts memoisation doesn't break round-trip.
@@ -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,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
data/lib/idml/elements.rb CHANGED
@@ -64,6 +64,8 @@ module Idml
64
64
  autoload :Hyperlink, "idml/elements/hyperlink"
65
65
  autoload :HyperlinkPageDestination,
66
66
  "idml/elements/hyperlink_page_destination"
67
+ autoload :HyperlinkTextSource,
68
+ "idml/elements/hyperlink_text_source"
67
69
  autoload :HyperlinkURLDestination,
68
70
  "idml/elements/hyperlink_url_destination"
69
71
  autoload :Image, "idml/elements/image"
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Resolves the document font and wraps it in a
6
+ # `TextEngine::PdfrbFontMetrics` adapter. Encapsulates:
7
+ #
8
+ # 1. Finding the IDML document's first non-missing font file
9
+ # via `Pdfrb::FontResolver#find_by_ps_name`.
10
+ # 2. Registering that file with pdfrb's Fonts collection.
11
+ # 3. Wrapping the resulting Symbol resource in
12
+ # `PdfrbFontMetrics` for the text engine.
13
+ #
14
+ # Extracted from `Pipeline` for SRP — Pipeline orchestrates,
15
+ # this object owns the font-setup dance.
16
+ class FontSetup
17
+ DEFAULT_FONT = Render::DEFAULT_FONT
18
+
19
+ def initialize(package:, font_search_paths: nil)
20
+ @package = package
21
+ @font_search_paths = font_search_paths
22
+ end
23
+
24
+ # Registers the resolved font with `writer` and returns the
25
+ # Symbol resource. Falls back to `Render::DEFAULT_FONT`
26
+ # (`"Helvetica"`) when the document has no resolvable font.
27
+ def register(writer)
28
+ path = resolve_document_font_path
29
+ return DEFAULT_FONT unless path
30
+
31
+ writer.register_font(path)
32
+ rescue StandardError
33
+ DEFAULT_FONT
34
+ end
35
+
36
+ # Wraps a registered font resource in a PdfrbFontMetrics
37
+ # adapter. Returns nil when the resource is the default
38
+ # fallback (no real metrics available).
39
+ def metrics_for(writer, font_resource)
40
+ return nil if font_resource == DEFAULT_FONT
41
+
42
+ TextEngine::PdfrbFontMetrics.new(writer.document.fonts, font_resource)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def resolve_document_font_path
50
+ return nil unless @package&.fonts
51
+
52
+ @package.fonts.font_family.each do |family|
53
+ path = find_font_file(family)
54
+ return path if path
55
+ end
56
+ nil
57
+ end
58
+
59
+ def find_font_file(family)
60
+ family.font.each do |font|
61
+ next unless font.post_script_name
62
+ next if font.status == "Missing"
63
+
64
+ path = resolver.find_by_ps_name(font.post_script_name)
65
+ return path if path
66
+ end
67
+ nil
68
+ end
69
+
70
+ def resolver
71
+ @resolver ||= Pdfrb::FontResolver.new(
72
+ search_paths: @font_search_paths || Pdfrb::FontResolver::DEFAULT_SEARCH_PATHS,
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
@@ -12,11 +12,12 @@ module Idml
12
12
  # the source's TextRange. Precise per-range rects require deeper
13
13
  # integration with the text engine — see TODO 78.
14
14
  class HyperlinkEmitter
15
- def initialize(writer:, package:, page_height:)
15
+ def initialize(writer:, package:, page_height:, layer_filter: nil)
16
16
  @writer = writer
17
17
  @package = package
18
18
  @resolver = HyperlinkResolver.new(package)
19
19
  @page_height = page_height
20
+ @layer_filter = layer_filter
20
21
  end
21
22
 
22
23
  def emit_for(spread, page_index)
@@ -29,7 +30,10 @@ module Idml
29
30
 
30
31
  def each_text_frame_on(spread)
31
32
  spread.each_page_item do |item|
32
- yield item if item.is_a?(Idml::Elements::TextFrame)
33
+ next unless item.is_a?(Idml::Elements::TextFrame)
34
+ next if @layer_filter && !@layer_filter.visible?(item)
35
+
36
+ yield item
33
37
  end
34
38
  end
35
39
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Idml
6
+ module Render
7
+ # Builds the PDF Info-dict metadata hash by merging XMP-extracted
8
+ # values from `META-INF/metadata.xml` over sensible defaults.
9
+ # Extracted from `Pipeline` for SRP — Pipeline orchestrates
10
+ # rendering, this module owns metadata assembly.
11
+ class MetadataBuilder
12
+ XMP_PATH = "META-INF/metadata.xml"
13
+
14
+ def initialize(package)
15
+ @package = package
16
+ end
17
+
18
+ # Returns a hash with Producer/CreationDate defaults, overridden
19
+ # by any XMP-supplied fields (Title, Author, Subject, Keywords,
20
+ # Creator, CreationDate, ModDate). XMP dates are converted from
21
+ # ISO 8601 to PDF `D:YYYYMMDDHHmmss` format.
22
+ def build
23
+ defaults.merge(xmp_fields) { |_key, default, xmp| xmp || default }
24
+ end
25
+
26
+ private
27
+
28
+ def defaults
29
+ {
30
+ Producer: "idml gem v#{Idml::VERSION}",
31
+ CreationDate: pdf_date(Time.now.utc),
32
+ }
33
+ end
34
+
35
+ def xmp_fields
36
+ return {} unless xmp_packet
37
+
38
+ {
39
+ Title: xmp_packet.title,
40
+ Author: xmp_packet.author,
41
+ Subject: xmp_packet.description,
42
+ Keywords: xmp_packet.keywords,
43
+ Creator: xmp_packet.creator_tool,
44
+ CreationDate: pdf_date_string(xmp_packet.create_date),
45
+ ModDate: pdf_date_string(xmp_packet.modify_date),
46
+ }.compact
47
+ end
48
+
49
+ def xmp_packet
50
+ return nil unless @package.has_part?(XMP_PATH)
51
+
52
+ @xmp_packet ||= begin
53
+ xml = @package.read_part(XMP_PATH)
54
+ Parts::XmpMeta.from_xml(xml).rdf
55
+ rescue StandardError
56
+ nil
57
+ end
58
+ end
59
+
60
+ def pdf_date_string(iso8601)
61
+ return nil unless iso8601
62
+
63
+ pdf_date(Time.iso8601(iso8601))
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
68
+ def pdf_date(time)
69
+ time.strftime("D:%Y%m%d%H%M%S+00'00'")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -3,8 +3,15 @@
3
3
  module Idml
4
4
  module Render
5
5
  # Top-level pipeline: IDML Package → PDF file using pdfrb.
6
- # All PDF assembly is delegated to Pdfrb::Document. Renderers
7
- # receive a Pdfrb::Content::Canvas and call drawing methods.
6
+ # All PDF assembly is delegated to pdfrb; this class orchestrates
7
+ # the high-level flow:
8
+ #
9
+ # 1. Build writer + set metadata.
10
+ # 2. Apply compliance (PDF/A XMP + ICC) if requested.
11
+ # 3. Set up structure tracker for tagged PDF.
12
+ # 4. Resolve document font.
13
+ # 5. For each spread: collect images, render pages, emit hyperlinks.
14
+ # 6. Flush structure, emit bookmarks, subset fonts, write.
8
15
  class Pipeline
9
16
  DEFAULT_WIDTH = 612
10
17
  DEFAULT_HEIGHT = 792
@@ -21,26 +28,24 @@ module Idml
21
28
 
22
29
  def call
23
30
  writer = PdfrbWriter.new
24
- metadata = combined_metadata
31
+ metadata = MetadataBuilder.new(@package).build
25
32
  writer.set_info(metadata)
26
33
  writer.enable_tagged if @tagged
27
- if pdfa_requested?
28
- PdfaPacket.attach(writer.document, metadata)
29
- embed_pdfa_output_intent(writer)
30
- end
34
+ apply_compliance(writer, metadata) if pdfa_requested?
35
+
31
36
  structure = StructureTracker.new(enabled: @tagged)
32
37
  layer_filter = LayerFilter.from_designmap(@package.designmap)
33
38
  font_ref_resolver = FontReferenceResolver.build(@package)
34
- base_dir = File.dirname(@package.path)
35
- font_resource = register_font(writer)
36
- font_metrics = build_font_metrics(writer, font_resource)
37
- page_index = -1
39
+ font_setup = FontSetup.new(package: @package,
40
+ font_search_paths: @font_search_paths)
41
+ font_resource = font_setup.register(writer)
42
+ font_metrics = font_setup.metrics_for(writer, font_resource)
38
43
 
44
+ page_index = -1
39
45
  @package.spreads.each do |spread|
40
- page_index = render_spread_pages(writer, spread, base_dir,
41
- layer_filter, font_ref_resolver,
42
- font_resource, font_metrics,
43
- structure, page_index)
46
+ page_index = render_spread(writer, spread, layer_filter,
47
+ font_ref_resolver, font_resource,
48
+ font_metrics, structure, page_index)
44
49
  end
45
50
 
46
51
  structure.flush(writer)
@@ -53,7 +58,8 @@ module Idml
53
58
 
54
59
  private
55
60
 
56
- def embed_pdfa_output_intent(writer)
61
+ def apply_compliance(writer, metadata)
62
+ PdfaPacket.attach(writer.document, metadata)
57
63
  bytes = IccProfile.srgb_bytes
58
64
  return unless bytes
59
65
 
@@ -67,24 +73,24 @@ module Idml
67
73
  nil
68
74
  end
69
75
 
76
+ def pdfa_requested?
77
+ @compliance&.to_s&.start_with?("pdfa")
78
+ end
79
+
70
80
  def emit_bookmarks(writer)
71
81
  BookmarkResolver.new(@package).each do |title, page_index|
72
82
  writer.add_bookmark(title, page_index)
73
83
  end
74
84
  end
75
85
 
76
- def pdfa_requested?
77
- @compliance&.to_s&.start_with?("pdfa")
78
- end
79
-
80
- def render_spread_pages(writer, spread, base_dir, layer_filter,
81
- font_ref_resolver, font_resource, font_metrics,
82
- structure, page_offset)
86
+ def render_spread(writer, spread, layer_filter, font_ref_resolver,
87
+ font_resource, font_metrics, structure, page_offset)
83
88
  pages = spread.spread.flat_map(&:page)
84
- image_refs = ImageCollector.new(writer: writer, base_dir: base_dir,
89
+ image_refs = ImageCollector.new(writer: writer,
90
+ base_dir: File.dirname(@package.path),
85
91
  page_height: DEFAULT_HEIGHT).collect(spread)
86
92
  renderer = build_renderer(layer_filter, font_ref_resolver,
87
- font_resource, font_metrics, structure: structure)
93
+ font_resource, font_metrics, structure)
88
94
  current = page_offset
89
95
 
90
96
  pages.each do |page|
@@ -95,20 +101,22 @@ module Idml
95
101
  page_height: dims[:height],
96
102
  image_refs: image_refs,
97
103
  page_index: current)
98
- emit_hyperlinks(writer, spread, current)
104
+ emit_hyperlinks(writer, spread, current, layer_filter)
99
105
  end
100
106
  current
101
107
  end
102
108
 
103
- def emit_hyperlinks(writer, spread, page_index)
109
+ def emit_hyperlinks(writer, spread, page_index, layer_filter)
104
110
  HyperlinkEmitter.new(writer: writer, package: @package,
105
- page_height: DEFAULT_HEIGHT).emit_for(spread, page_index)
111
+ page_height: DEFAULT_HEIGHT,
112
+ layer_filter: layer_filter)
113
+ .emit_for(spread, page_index)
106
114
  rescue StandardError
107
115
  nil
108
116
  end
109
117
 
110
118
  def build_renderer(layer_filter, font_ref_resolver, font_resource,
111
- font_metrics, structure:)
119
+ font_metrics, structure)
112
120
  SpreadRenderer.new(
113
121
  font_metrics: font_metrics,
114
122
  font_ps_name: font_resource,
@@ -123,104 +131,6 @@ module Idml
123
131
  { width: page.width || DEFAULT_WIDTH,
124
132
  height: page.height || DEFAULT_HEIGHT }
125
133
  end
126
-
127
- def build_font_metrics(writer, font_resource)
128
- return nil if font_resource == Render::DEFAULT_FONT
129
-
130
- TextEngine::PdfrbFontMetrics.new(writer.document.fonts, font_resource)
131
- rescue StandardError
132
- nil
133
- end
134
-
135
- def register_font(writer)
136
- path = resolve_document_font_path
137
- return Render::DEFAULT_FONT unless path
138
-
139
- writer.register_font(path)
140
- rescue StandardError
141
- Render::DEFAULT_FONT
142
- end
143
-
144
- def font_resolver
145
- @font_resolver ||= Pdfrb::FontResolver.new(
146
- search_paths: @font_search_paths || Pdfrb::FontResolver::DEFAULT_SEARCH_PATHS,
147
- )
148
- end
149
-
150
- def resolve_document_font_path
151
- return nil unless @package&.fonts
152
-
153
- @package.fonts.font_family.each do |family|
154
- path = find_font_file(family)
155
- return path if path
156
- end
157
- nil
158
- end
159
-
160
- def find_font_file(family)
161
- family.font.each do |font|
162
- next unless font.post_script_name
163
- next if font.status == "Missing"
164
-
165
- path = font_resolver.find_by_ps_name(font.post_script_name)
166
- return path if path
167
- end
168
- nil
169
- end
170
-
171
- def combined_metadata
172
- defaults = default_metadata
173
- xmp_metadata.each do |key, value|
174
- next if value.nil? || value.empty?
175
-
176
- defaults[key] = value
177
- end
178
- defaults
179
- end
180
-
181
- def xmp_metadata
182
- return {} unless @package.has_part?(XMP_PATH)
183
-
184
- xml = @package.read_part(XMP_PATH)
185
- meta = Parts::XmpMeta.from_xml(xml)
186
- rdf = meta.rdf
187
- return {} unless rdf
188
-
189
- {
190
- Title: rdf.title,
191
- Author: rdf.author,
192
- Subject: rdf.description,
193
- Keywords: rdf.keywords,
194
- Creator: rdf.creator_tool,
195
- CreationDate: pdf_date_string(rdf.create_date),
196
- ModDate: pdf_date_string(rdf.modify_date),
197
- }
198
- rescue StandardError
199
- {}
200
- end
201
-
202
- def pdf_date_string(iso8601)
203
- return nil unless iso8601
204
-
205
- time = Time.iso8601(iso8601)
206
- pdf_date(time)
207
- rescue ArgumentError
208
- nil
209
- end
210
-
211
- def default_metadata
212
- {
213
- Producer: "idml gem v#{Idml::VERSION}",
214
- CreationDate: pdf_date(Time.now.utc),
215
- }
216
- end
217
-
218
- def pdf_date(time)
219
- time.strftime("D:%Y%m%d%H%M%S+00'00'")
220
- end
221
-
222
- XMP_PATH = "META-INF/metadata.xml"
223
- private_constant :XMP_PATH
224
134
  end
225
135
  end
226
136
  end
data/lib/idml/render.rb CHANGED
@@ -23,6 +23,8 @@ module Idml
23
23
  autoload :BookmarkResolver, "#{__dir__}/render/bookmark_resolver"
24
24
  autoload :HyperlinkResolver, "#{__dir__}/render/hyperlink_resolver"
25
25
  autoload :HyperlinkEmitter, "#{__dir__}/render/hyperlink_emitter"
26
+ autoload :MetadataBuilder, "#{__dir__}/render/metadata_builder"
27
+ autoload :FontSetup, "#{__dir__}/render/font_setup"
26
28
  autoload :StoryThreader, "#{__dir__}/render/story_threader"
27
29
  autoload :PdfrbWriter, "#{__dir__}/render/pdfrb_writer"
28
30
  autoload :SpreadRenderer, "#{__dir__}/render/spread_renderer"
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -209,6 +209,10 @@ files:
209
209
  - TODO.pdf/80-paragraph-alignment.md
210
210
  - TODO.pdf/81-pdfa-icc-output-intent.md
211
211
  - TODO.pdf/82-table-cell-text.md
212
+ - TODO.pdf/83-pipeline-decomposition.md
213
+ - TODO.pdf/84-schema-faithful-table.md
214
+ - TODO.pdf/85-per-text-range-hyperlinks.md
215
+ - TODO.pdf/86-caching-performance.md
212
216
  - TODO.pdf/README.md
213
217
  - exe/idml
214
218
  - idml.gemspec
@@ -255,6 +259,7 @@ files:
255
259
  - lib/idml/elements/group.rb
256
260
  - lib/idml/elements/hyperlink.rb
257
261
  - lib/idml/elements/hyperlink_page_destination.rb
262
+ - lib/idml/elements/hyperlink_text_source.rb
258
263
  - lib/idml/elements/hyperlink_url_destination.rb
259
264
  - lib/idml/elements/image.rb
260
265
  - lib/idml/elements/ink.rb
@@ -383,6 +388,7 @@ files:
383
388
  - lib/idml/render/color_helper.rb
384
389
  - lib/idml/render/color_resolver.rb
385
390
  - lib/idml/render/font_reference_resolver.rb
391
+ - lib/idml/render/font_setup.rb
386
392
  - lib/idml/render/gradient_resolver.rb
387
393
  - lib/idml/render/hyperlink_emitter.rb
388
394
  - lib/idml/render/hyperlink_resolver.rb
@@ -390,6 +396,7 @@ files:
390
396
  - lib/idml/render/image.rb
391
397
  - lib/idml/render/image_collector.rb
392
398
  - lib/idml/render/layer_filter.rb
399
+ - lib/idml/render/metadata_builder.rb
393
400
  - lib/idml/render/page_item_renderer.rb
394
401
  - lib/idml/render/pdfa_packet.rb
395
402
  - lib/idml/render/pdfrb_writer.rb