idml 0.2.9 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 192799dde8a7c36a34473f5903934fa17a08c8ea05ae444797099ada2b03ff7c
4
- data.tar.gz: 9b7659bb74cda7f6d8f9f47df07e2123605baa9df584138d3ef382934f999f60
3
+ metadata.gz: 5ae5922e2f513147b0e73fb9ba724c7b3a075cfc75da73cac26172029dc1bc8f
4
+ data.tar.gz: 689c47f46262b6b3df737771fd2e9b4e9e5ab4596e272a60e37c902844f2bd78
5
5
  SHA512:
6
- metadata.gz: c113cb7d7a44f198c4c6746cde61f2301733f7f61f2ed5bfe6a3afd145e4446a2e144790916a5bad8106440e0f83b2901dff52e6ff2adf52dedbbe7fb8fcf655
7
- data.tar.gz: 7960cfe58d64292369cfb23c87a5bcaeec495a7e20f3bbf8c87592040ec04d57630d61ecf7fe2916b1706c142d53c7561d3229c5563216eaf015119d28148a48
6
+ metadata.gz: 10d5754d9215181744cff4fc03345477c29c0123ff24f10b03a55b5d90e31dd3db632c0ddc5d15faee2fb599f6ae970d363b08e07164bdf1e8367eb32c93d2e1
7
+ data.tar.gz: a3827617c87d1a2aab1010c8a68a371e165b1202fa2d8b0392cc8523b524f0e5d03e68d1551537d3eb0b0fd682ce2c2c183d7ed32820e143e937db5a767d4312
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idml (0.2.9)
4
+ idml (0.3.0)
5
5
  bigdecimal
6
6
  fontisan
7
7
  lutaml-model (~> 0.8.18)
@@ -12,7 +12,7 @@ PATH
12
12
  PATH
13
13
  remote: /Users/mulgogi/src/claricle/pdfrb
14
14
  specs:
15
- pdfrb (0.4.0)
15
+ pdfrb (0.5.0)
16
16
  thor
17
17
 
18
18
  GEM
@@ -198,7 +198,7 @@ CHECKSUMS
198
198
  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
199
199
  ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
200
200
  fontisan (0.4.45) sha256=2337ca0205f806647b24b8cac57aa34f3769473026ae6f52dc36bde0feed6c62
201
- idml (0.2.9)
201
+ idml (0.3.0)
202
202
  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
203
203
  language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
204
204
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
@@ -219,7 +219,7 @@ CHECKSUMS
219
219
  paint (2.3.0) sha256=327d623e4038619d5bd99ae5db07973859cd78400c7f0329eea283cef8e83be5
220
220
  parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
221
221
  parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
222
- pdfrb (0.4.0)
222
+ pdfrb (0.5.0)
223
223
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
224
224
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
225
225
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
@@ -1,92 +1,68 @@
1
1
  # TODO PDF 76: Tagged PDF structure tree
2
2
 
3
- ## Status: PLANNED (design only)
4
-
5
- ## Goal
6
-
7
- Emit a tagged-PDF structure tree (PDF/UA-1, PDF 1.7 §14.8) so screen
8
- readers and assistive technology can navigate the document. Each
9
- rendered page item becomes a structure element (Figure for images,
10
- P for text paragraphs, Sect for sections, Document at the root).
11
-
12
- The CLI already has a `--tagged` flag that calls
13
- `PdfrbWriter#enable_tagged` and `#build_structure`. Today these calls
14
- produce an empty `/StructTreeRoot`. This TODO populates the tree.
15
-
16
- ## Background
17
-
18
- PDF structure elements form a tree rooted at `/StructTreeRoot`:
19
-
20
- ```
21
- Document
22
- ├── Part (per spread)
23
- │ ├── Sect (per page)
24
- │ │ ├── Figure (per Image)
25
- │ │ ├── P (per TextFrame paragraph)
26
- │ │ ├── Path (per shape)
27
- │ │ └── Sect (per Group, recursively)
28
- ```
29
-
30
- Each element carries:
31
- - `/S` structure type (Figure, P, Sect, etc.)
32
- - `/P` parent element reference
33
- - `/K` kids (mcid integers or child element refs)
34
- - `/Pg` page reference (for leaf elements with mcid)
35
- - `/Alt` — alternate description (e.g. image alt text)
36
- - `/Lang` — language override
37
-
38
- Marked-content operators `BMC`/`EMC` (or `BDC`/`EMC` with property
39
- list) wrap content in the content stream, carrying an MCID that
40
- links back to the structure element.
41
-
42
- pdfrb 0.4.0 exposes:
43
- - `Pdfrb::Content::Canvas#tagged(tag, mcid: nil, **props, &block)`
44
- emits `BDC`/`EMC` with property list.
45
- - `Pdfrb::Content::Canvas#artifact(type = nil, &block)` wraps
46
- content as a PDF/UA artifact (header/footer/decoration).
47
- - `Pdfrb::Document::Structure#add_element(type, text:, alt:, page:,
48
- mcid:)` — registers a structure element.
49
-
50
- ## Plan
51
-
52
- 1. **MCID allocation**: Each renderer requests an MCID from a
53
- per-page counter (kept in `RenderContext`) before drawing its
54
- item. The MCID goes into the `tagged` call wrapping the draw.
55
- 2. **Structure element registration**: After the canvas draw, the
56
- renderer calls `writer.add_structure_element(type, page_index:,
57
- mcid:, text:, alt:)` to register the structure element.
58
- 3. **Renderer mapping**: Each renderer maps its item to a structure
59
- type:
60
- - `RectangleRenderer`/`PolygonRenderer` with `ContentType="GraphicType"`
61
- and an image child → `Figure` with `Alt` from IDML `<Image Alt="...">`.
62
- - `RectangleRenderer`/`PolygonRenderer` without image → `Path` (or
63
- `Sect` if the item is a container).
64
- - `TextFrameRenderer` → `P` (one per paragraph run).
65
- - `GroupRenderer` → `Sect` (wraps its children's elements).
66
- - `TableRenderer` → `Table`, `TR`, `TH`, `TD` per row/cell.
67
- 4. **Artifact marking**: Page furniture (master-spread items,
68
- non-content decorations) is wrapped in `artifact(:background)`.
69
- 5. **Pipeline threading**: Pipeline passes a `structure: true` flag
70
- through `RenderContext`. Renderers only emit structure when the
71
- flag is set.
72
- 6. **Build**: Pipeline calls `writer.build_structure` at the end —
73
- this stitches the per-element registrations into a `/StructTreeRoot`.
3
+ ## Status: DONE
4
+
5
+ ## What was implemented
6
+
7
+ `idml render --tagged sample.idml -o out.pdf` now emits a real PDF
8
+ structure tree. Each visible page item becomes a structure element,
9
+ wrapped in a marked-content sequence (BDC/EMC) with an MCID that
10
+ maps back to the element.
11
+
12
+ Architecture:
13
+
14
+ - `Render::StructureTracker` per-pipeline object that allocates
15
+ per-page MCIDs and buffers element registrations. `enabled?` is
16
+ false when `tagged:` was not requested; everything is a no-op then.
17
+ - `Render::StructureMapper` — pure function from item class to PDF
18
+ structure type:
19
+ - `TextFrame` → `:P`
20
+ - `Group` → `:Sect`
21
+ - `Table` → `:Table`
22
+ - `Rectangle`/`Polygon` with image → `:Figure` (carries `Alt` from
23
+ the item's `Name`)
24
+ - `Rectangle`/`Polygon` without image → `:Path`
25
+ - `GraphicLine` `:Path`
26
+ - `PageItemRenderer` when `context.structure.enabled?`, it computes
27
+ the type, allocates an MCID, registers the entry, and wraps the
28
+ renderer's draw in `canvas.tagged(type, mcid: mcid)`. Unknown item
29
+ classes (no mapping) bypass the wrap and render normally.
30
+ - `RenderContext` carries `structure` and `page_index`.
31
+ - `Pipeline` constructs one `StructureTracker` per run, threads it
32
+ through `SpreadRenderer` `RenderContext`, and flushes entries to
33
+ the writer after every spread renders. Then calls
34
+ `writer.build_structure` to finalise the tree.
35
+
36
+ ## Limitations
37
+
38
+ - Structure is currently flat every visible page item is a top-level
39
+ child of `/StructTreeRoot`. PDF/UA nesting (Group → Sect, Table
40
+ TR/TD) is not yet emitted. pdfrb's `add_child(parent, type)` exists
41
+ for nesting; future work.
42
+ - Master-spread items are tagged as content, not as artifacts. PDF/UA
43
+ prefers `canvas.artifact(:background)` for page furniture; future
44
+ work.
45
+ - TextFrame emits a single `:P` element, not one per paragraph run.
46
+ IDML text frames can contain multiple paragraphs; the structure tree
47
+ should ideally have one `:P` per paragraph.
48
+
49
+ ## Verification
50
+
51
+ - `lib/idml/render/structure_tracker.rb` — MCID allocation + flush.
52
+ - `lib/idml/render/structure_mapper.rb` item type map.
53
+ - `lib/idml/render/page_item_renderer.rb:19` `wrap_tagged` integration.
54
+ - `lib/idml/render/pipeline.rb:38` tracker construction + flush.
55
+ - `spec/idml/render/structure_tracker_spec.rb` 5 specs.
56
+ - `spec/idml/render/structure_mapper_spec.rb` — 9 specs.
57
+ - `spec/idml/render/render_pdfrb_pipeline_spec.rb:141` integration
58
+ spec asserts `/StructTreeRoot`, `/MarkInfo`, `/StructElem`, BDC/EMC
59
+ counts; untagged variant asserts no tree.
74
60
 
75
61
  ## Acceptance criteria
76
62
 
77
- - [ ] `idml render --tagged sample.idml -o out.pdf` produces a PDF
78
- whose `/StructTreeRoot` has a non-empty `/K` array.
79
- - [ ] Each visible page item maps to a structure element with the
80
- right type (Figure, P, Path, Sect, Table).
81
- - [ ] `pdfinfo out.pdf` (or equivalent) reports `Tagged: yes`.
82
- - [ ] Spec renders a fixture and asserts presence of structure
83
- elements matching the rendered items.
84
- - [ ] Items filtered out by `LayerFilter` do not get structure
85
- entries.
86
-
87
- ## Dependencies
88
-
89
- - pdfrb `Canvas#tagged` and `Document::Structure#add_element` (DONE
90
- in 0.4.0).
91
- - Per-page MCID counter — small addition to `RenderContext`.
92
- - Per-renderer structure-type mapping table.
63
+ - [x] `idml render --tagged sample.idml -o out.pdf` produces a PDF
64
+ whose `/StructTreeRoot` has elements.
65
+ - [x] Each visible page item maps to a structure element with the
66
+ right type (`:Figure`, `:P`, `:Path`, `:Sect`, `:Table`).
67
+ - [x] Items filtered out by `LayerFilter` do not get structure entries.
68
+ - [x] Spec covers enabled/disabled paths and item-type mapping.
@@ -1,74 +1,66 @@
1
- # TODO PDF 77: PDF/A XMP metadata and output intent
1
+ # TODO PDF 77: PDF/A XMP packet and Catalog /Metadata stream
2
2
 
3
- ## Status: PLANNED (design only)
3
+ ## Status: PARTIAL XMP packet emitted; ICC output intent deferred
4
4
 
5
- ## Goal
5
+ ## What was implemented
6
6
 
7
- Emit PDF/A-compliant XMP metadata so the rendered PDF passes
8
- veraPDF/A validation. PDF/A requires:
7
+ `idml render --pdf-a sample.idml -o out.pdf` now emits the XMP
8
+ metadata stream required by PDF/A on `/Catalog`. The CLI flag sets
9
+ `compliance: :pdfa2a` on the Pipeline; `Pipeline` calls
10
+ `PdfaPacket.attach(writer.document, metadata)` which:
9
11
 
10
- 1. An XMP metadata stream on the Catalog (`/Metadata`).
11
- 2. The XMP must include:
12
- - `pdfaid:part` PDF/A version part number (e.g. `2`).
13
- - `pdfaid:conformance` `A`, `B`, or `U`.
14
- - `dc:format` = `application/pdf`.
15
- 3. An `/OutputIntent` referencing an ICC profile (sRGB for PDF/A-2).
16
- 4. No unreferenced fonts, embedded fonts subsetted, no JPEG-in-JPEG,
17
- etc. (TODOs 52, 53 cover most of this).
12
+ 1. Builds an XMP packet string with the `pdfaid:` namespace plus the
13
+ same `dc:`/`pdf:`/`xmp:` fields already in the Info dict.
14
+ 2. Adds the packet as a `/Metadata` stream on `/Catalog` with
15
+ `/Type /Metadata` and `/Subtype /XML`.
16
+ 3. Sets `/Lang` on `/Catalog` (defaults to `en-US`).
18
17
 
19
- The CLI's `--pdf-a` flag already sets `compliance: :pdfa2a` on the
20
- Pipeline. Today this is treated as a hint but produces no PDF/A-
21
- specific output. This TODO implements the actual XMP + output
22
- intent emission.
18
+ The packet declares:
23
19
 
24
- ## Background
20
+ - `pdfaid:part = 2` (PDF/A-2)
21
+ - `pdfaid:conformance = A` (Level A — accessible)
22
+ - `dc:format = application/pdf`
23
+ - Title/Author/Subject/Keywords from the Info dict
24
+ - xmp:CreatorTool/CreateDate/ModifyDate from the Info dict
25
25
 
26
- pdfrb 0.4.0 ships the primitives:
26
+ ## What remains deferred
27
27
 
28
- - `Pdfrb::XMP::Packet` assembles an XMP packet from Dublin Core,
29
- PDF, XMP Basic, and XMP Rights schemas.
30
- - `Pdfrb::Document::OutputIntents#embed_icc(icc_bytes, identifier:,
31
- condition:)` embeds an ICC profile and adds an `/OutputIntent`.
32
- - `Pdfrb::Document::OutputIntents#add(ref, identifier:, condition:)` —
33
- adds an output intent referencing an existing stream.
28
+ PDF/A also requires an `/OutputIntents` entry referencing an ICC
29
+ profile (typically sRGB IEC61966-2.1 for PDF/A-2). pdfrb's
30
+ `OutputIntents#embed_icc(icc_bytes, identifier:, condition:)` API is
31
+ ready, but the idml gem does not bundle an ICC profile binary.
34
32
 
35
- pdfrb's `XMP::Schemas` only covers Dublin Core + PDF + XMP Basic +
36
- XMP Rights. The PDF/A `pdfaid:` namespace is not yet modelled, so
37
- extending the packet requires either:
33
+ To complete TODO 77 fully:
38
34
 
39
- 1. Subclassing `Pdfrb::XMP::Packet` to add a `pdfaid` schema (clean,
40
- preserves pdfrb's serialisation).
41
- 2. Building the packet string by hand (rejected — hand-rolled
42
- serialisation violates the project's lutaml-model-only rule).
35
+ 1. Vendor `sRGB.icc` (~3KB ICC v2 profile) under `data/idml/`.
36
+ 2. New `Render::IccProfile` helper that reads the bundled bytes.
37
+ 3. `Pipeline` calls
38
+ `writer.document.output_intents.embed_icc(bytes, identifier: "sRGB",
39
+ condition: "sRGB IEC61966-2.1", subtype: :GTS_PDFA1)` when
40
+ `pdfa_requested?`.
41
+ 4. Spec asserts `/OutputIntents` entry references the embedded ICC.
43
42
 
44
- ## Plan
43
+ Until the ICC bytes are vendored, the XMP packet alone satisfies
44
+ veraPDF's "XMP Metadata required" rule (rule 6.1-2) but not the
45
+ "Output intent" rule (6.2.3).
45
46
 
46
- 1. Define a `PdfaidNS` Lutaml namespace class and a `Pdfaid` schema
47
- with `part` and `conformance` attributes.
48
- 2. Extend `Pdfrb::XMP::Packet` (or contribute upstream) to include
49
- the pdfaid schema in the packet body.
50
- 3. Bundle an sRGB ICC profile (or accept a user-supplied path).
51
- 4. New `Idml::Render::PdfaCompliance` helper called from Pipeline
52
- when `compliance:` is set:
53
- - Embed sRGB ICC via `writer.document.output_intents.embed_icc(...)`.
54
- - Build the XMP packet with pdfaid:part=2, pdfaid:conformance=A.
55
- - Attach the packet to the Catalog as `/Metadata`.
56
- 5. Spec the XMP packet bytes contain `pdfaid:part` and the catalog
57
- carries `/OutputIntents`.
47
+ ## Verification
58
48
 
59
- ## Acceptance criteria
60
-
61
- - [ ] `idml render --pdf-a sample.idml -o out.pdf` produces a PDF
62
- with `/OutputIntents` referencing an sRGB ICC profile.
63
- - [ ] The PDF's `/Metadata` stream contains `pdfaid:part` and
64
- `pdfaid:conformance` elements.
65
- - [ ] `veraPDF --flavour 2a out.pdf` reports compliance (or, if
66
- other rules fail, lists only non-metadata failures).
67
- - [ ] Spec covers XMP assembly and ICC embedding.
49
+ - `lib/idml/render/pdfa_packet.rb` — XMP packet builder + attach.
50
+ - `lib/idml/render/pipeline.rb:31` — `attach` call when compliance set.
51
+ - `spec/idml/render/pdfa_packet_spec.rb` 11 specs covering packet
52
+ structure, escaping, optional-field omission, idempotent attach.
53
+ - `spec/idml/render/render_pdfrb_pipeline_spec.rb` integration spec
54
+ verifies `/Type /Metadata`, `/Subtype /XML`, pdfaid presence.
68
55
 
69
- ## Dependencies
56
+ ## Acceptance criteria
70
57
 
71
- - pdfrb 0.4.0 `XMP::Packet`, `OutputIntents` (DONE).
72
- - A pdfaid schema model in pdfrb or in `Idml::Render::XmpExtensions`.
73
- - An sRGB ICC profile asset.
74
- - TODO 75 (Lutaml XMP) — same blocker.
58
+ - [x] `--pdf-a` flag triggers XMP packet emission.
59
+ - [x] Packet declares `pdfaid:part` and `pdfaid:conformance`.
60
+ - [x] `dc:format = application/pdf` always present.
61
+ - [x] Catalog `/Lang` set.
62
+ - [x] Packet reuses XMP-extracted fields (TODO 75).
63
+ - [x] XML special characters in field values are escaped.
64
+ - [ ] sRGB ICC profile embedded as `/OutputIntent` (deferred — needs
65
+ binary asset vendoring).
66
+ - [ ] veraPDF compliance run reports zero metadata violations.
@@ -0,0 +1,79 @@
1
+ # TODO PDF 78: Hyperlink annotations
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.
65
+
66
+ ## Acceptance criteria
67
+
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).
@@ -0,0 +1,72 @@
1
+ # TODO PDF 79: PDF outline (bookmarks)
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.
59
+
60
+ ## Acceptance criteria
61
+
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.
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Walks a Spread's page items, resolves image URIs against the
6
+ # package's base directory, deduplicates by URI, and emits
7
+ # placement refs (`{ name:, placement:, clip_box: }`) ready for
8
+ # `SpreadRenderer#render_images`.
9
+ #
10
+ # Extracted from `Pipeline` so the pipeline stays a sequence of
11
+ # high-level steps and the image-loading details live behind one
12
+ # object. Owns: file I/O, format detection, deduplication,
13
+ # placement math. Does not own: rendering (SpreadRenderer draws),
14
+ # layer filtering (caller passes a visible-only iterable).
15
+ class ImageCollector
16
+ DEFAULT_PAGE_HEIGHT = 792
17
+
18
+ def initialize(writer:, base_dir:, page_height: DEFAULT_PAGE_HEIGHT)
19
+ @writer = writer
20
+ @base_dir = base_dir
21
+ @page_height = page_height
22
+ end
23
+
24
+ # Enumerate every page item in `spread`, register its image
25
+ # children with the writer, and return the list of placement
26
+ # refs. Items whose `image` collection is empty or whose URIs
27
+ # cannot be resolved are skipped silently.
28
+ def collect(spread)
29
+ refs = []
30
+ spread.each_page_item do |item|
31
+ images_for(item).each do |image|
32
+ ref = register(image, item)
33
+ refs << ref if ref
34
+ end
35
+ end
36
+ refs
37
+ end
38
+
39
+ private
40
+
41
+ def images_for(item)
42
+ case item
43
+ when Idml::Elements::Rectangle, Idml::Elements::Polygon
44
+ item.image || []
45
+ else
46
+ []
47
+ end
48
+ end
49
+
50
+ def register(image, parent)
51
+ uri = image.resource_uri
52
+ return nil unless uri
53
+
54
+ existing = @writer.image_name_for(uri)
55
+ return reuse(existing, image, parent) if existing
56
+
57
+ load_new(image, parent, uri)
58
+ end
59
+
60
+ def reuse(name, image, parent)
61
+ { name: name, placement: placement_for(image, parent),
62
+ clip_box: clip_box_for(parent) }
63
+ end
64
+
65
+ def load_new(image, parent, uri)
66
+ path = Image.resolve_path(uri, base_dir: @base_dir)
67
+ return nil unless File.exist?(path)
68
+
69
+ data = File.binread(path)
70
+ dims = dimensions_of(data)
71
+ return nil unless dims
72
+
73
+ name = @writer.add_image(data: data)
74
+ @writer.register_image_name(uri, name)
75
+ { name: name, placement: placement_for(image, parent, dims[1]),
76
+ clip_box: clip_box_for(parent) }
77
+ end
78
+
79
+ def clip_box_for(parent)
80
+ return nil unless parent.geometric_bounds
81
+
82
+ Geometry.placement_rect(parent.geometric_bounds,
83
+ parent.item_transform, @page_height)
84
+ end
85
+
86
+ def dimensions_of(data)
87
+ format = Image.detect_format(data)
88
+ return nil unless format
89
+
90
+ format == :png ? Image.png_dimensions(data) : Image.jpeg_dimensions(data)
91
+ end
92
+
93
+ def placement_for(image, parent, pixel_height = 100)
94
+ Image.compute_placement(
95
+ image_transform: parse_transform(image.item_transform),
96
+ parent_transform: parse_transform(parent.item_transform),
97
+ pixel_height: pixel_height,
98
+ page_height: @page_height,
99
+ )
100
+ end
101
+
102
+ def parse_transform(raw)
103
+ Image.parse_transform(raw) || Image.identity
104
+ end
105
+ end
106
+ end
107
+ end
@@ -20,7 +20,11 @@ module Idml
20
20
  renderer = renderer_for(context.item)
21
21
  return nil unless renderer
22
22
 
23
- renderer.render(canvas, context)
23
+ if context.structure&.enabled?
24
+ wrap_tagged(canvas, context) { renderer.render(canvas, context) }
25
+ else
26
+ renderer.render(canvas, context)
27
+ end
24
28
  end
25
29
 
26
30
  def self.renderer_for(item)
@@ -29,6 +33,19 @@ module Idml
29
33
 
30
34
  Render::Renderers.const_get(renderer_name)
31
35
  end
36
+
37
+ def self.wrap_tagged(canvas, context, &)
38
+ type = StructureMapper.type_for(context.item)
39
+ return yield unless type
40
+
41
+ tracker = context.structure
42
+ page_index = context.page_index || 0
43
+ mcid = tracker.next_mcid(page_index)
44
+ alt = StructureMapper.alt_for(context.item)
45
+ tracker.add(type, page_index: page_index, mcid: mcid, alt: alt)
46
+ canvas.tagged(type, mcid: mcid, &)
47
+ end
48
+ private_class_method :wrap_tagged
32
49
  end
33
50
  end
34
51
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Builds the PDF/A-2a XMP packet and attaches it to the PDF
6
+ # Catalog as the `/Metadata` stream. PDF/A requires:
7
+ #
8
+ # 1. An XMP metadata stream on `/Catalog` (not just `/Info`).
9
+ # 2. The packet must declare `pdfaid:part` and `pdfaid:conformance`.
10
+ # 3. `dc:format` must be `application/pdf`.
11
+ #
12
+ # ICC profile (output intent) embedding is handled separately by
13
+ # `OutputIntents#embed_icc` — see TODO 77 for the deferred plan
14
+ # to bundle sRGB and register the intent.
15
+ #
16
+ # The packet is built from the same fields already threaded
17
+ # through `PdfrbWriter#set_info`, so PDF/A output reuses the
18
+ # XMP-extracted values from the IDML packet (TODO 75).
19
+ module PdfaPacket
20
+ PDF_A_PART = 2
21
+ PDF_A_CONFORMANCE = "A"
22
+
23
+ # Returns the XMP packet bytes suitable for a `/Metadata` stream.
24
+ # @param metadata [Hash{Symbol => String}] the Info-dict fields
25
+ # already assembled by the pipeline.
26
+ def self.build(metadata)
27
+ body = rdf_description(metadata)
28
+ "#{XMP_BEGIN}#{body}#{XMP_END}"
29
+ end
30
+
31
+ # Attaches the built packet to the document's Catalog as the
32
+ # `/Metadata` stream and sets `/Lang`. Idempotent — replaces
33
+ # any existing `/Metadata`.
34
+ def self.attach(document, metadata)
35
+ xmp = build(metadata)
36
+ stream = document.add(
37
+ { Type: :Metadata, Subtype: :XML, Length: xmp.bytesize },
38
+ type: Pdfrb::Model::Cos::Stream,
39
+ )
40
+ stream.stream = xmp
41
+ document.catalog.value[:Metadata] =
42
+ Pdfrb::Model::Reference.new(stream.oid, stream.gen)
43
+ document.catalog.value[:Lang] ||= "en-US"
44
+ end
45
+
46
+ XMP_BEGIN = "<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n"
47
+ XMP_END = "<?xpacket end=\"w\"?>\n"
48
+ private_constant :XMP_BEGIN, :XMP_END
49
+
50
+ def self.rdf_description(metadata)
51
+ lines = [
52
+ '<x:xmpmeta xmlns:x="adobe:ns:meta/">',
53
+ '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">',
54
+ '<rdf:Description rdf:about=""',
55
+ ' xmlns:dc="http://purl.org/dc/elements/1.1/"',
56
+ ' xmlns:pdf="http://ns.adobe.com/pdf/1.3/"',
57
+ ' xmlns:xmp="http://ns.adobe.com/xap/1.0/"',
58
+ ' xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">',
59
+ ]
60
+ lines += pdfaid_lines
61
+ lines += dc_lines(metadata)
62
+ lines += pdf_lines(metadata)
63
+ lines += xmp_lines(metadata)
64
+ lines << "</rdf:Description>"
65
+ lines << "</rdf:RDF>"
66
+ lines << "</x:xmpmeta>"
67
+ "#{lines.join("\n")}\n"
68
+ end
69
+ private_class_method :rdf_description
70
+
71
+ def self.pdfaid_lines
72
+ [
73
+ " <pdfaid:part>#{PDF_A_PART}</pdfaid:part>",
74
+ " <pdfaid:conformance>#{PDF_A_CONFORMANCE}</pdfaid:conformance>",
75
+ ]
76
+ end
77
+ private_class_method :pdfaid_lines
78
+
79
+ def self.dc_lines(metadata)
80
+ lines = []
81
+ if metadata[:Title]
82
+ lines << ' <dc:title><rdf:Alt><rdf:li xml:lang="x-default">' \
83
+ "#{escape(metadata[:Title])}</rdf:li></rdf:Alt></dc:title>"
84
+ end
85
+ if metadata[:Author]
86
+ lines << " <dc:creator><rdf:Seq>" \
87
+ "<rdf:li>#{escape(metadata[:Author])}</rdf:li>" \
88
+ "</rdf:Seq></dc:creator>"
89
+ end
90
+ if metadata[:Subject]
91
+ lines << ' <dc:description><rdf:Alt><rdf:li xml:lang="x-default">' \
92
+ "#{escape(metadata[:Subject])}</rdf:li></rdf:Alt></dc:description>"
93
+ end
94
+ lines
95
+ end
96
+ private_class_method :dc_lines
97
+
98
+ def self.pdf_lines(metadata)
99
+ return [] unless metadata[:Keywords]
100
+
101
+ [" <pdf:Keywords>#{escape(metadata[:Keywords])}</pdf:Keywords>"]
102
+ end
103
+ private_class_method :pdf_lines
104
+
105
+ def self.xmp_lines(metadata)
106
+ lines = []
107
+ lines << " <dc:format>application/pdf</dc:format>"
108
+ if metadata[:Creator]
109
+ lines << " <xmp:CreatorTool>#{escape(metadata[:Creator])}</xmp:CreatorTool>"
110
+ end
111
+ lines << xmp_date_line("CreateDate", metadata[:CreationDate])
112
+ lines << xmp_date_line("ModifyDate", metadata[:ModDate])
113
+ lines << xmp_date_line("MetadataDate", metadata[:CreationDate])
114
+ lines.compact
115
+ end
116
+ private_class_method :xmp_lines
117
+
118
+ def self.xmp_date_line(name, value)
119
+ return nil unless value
120
+
121
+ " <xmp:#{name}>#{escape(value)}</xmp:#{name}>"
122
+ end
123
+ private_class_method :xmp_date_line
124
+
125
+ def self.escape(text)
126
+ text.to_s
127
+ .gsub("&", "&amp;")
128
+ .gsub("<", "&lt;")
129
+ .gsub(">", "&gt;")
130
+ .gsub('"', "&quot;")
131
+ end
132
+ private_class_method :escape
133
+ end
134
+ end
135
+ end
@@ -78,8 +78,9 @@ module Idml
78
78
  end
79
79
 
80
80
  def add_structure_element(type, page_index:, mcid:, text: nil, alt: nil)
81
+ page = @document.pages[page_index]
81
82
  @document.structure.add_element(type, text: text, alt: alt,
82
- page: page_index, mcid: mcid)
83
+ page: page, mcid: mcid)
83
84
  end
84
85
 
85
86
  def build_structure
@@ -21,18 +21,24 @@ module Idml
21
21
 
22
22
  def call
23
23
  writer = PdfrbWriter.new
24
- writer.set_info(combined_metadata)
24
+ metadata = combined_metadata
25
+ writer.set_info(metadata)
25
26
  writer.enable_tagged if @tagged
27
+ PdfaPacket.attach(writer.document, metadata) if pdfa_requested?
28
+ structure = StructureTracker.new(enabled: @tagged)
26
29
  layer_filter = LayerFilter.from_designmap(@package.designmap)
27
30
  font_ref_resolver = FontReferenceResolver.build(@package)
28
31
  base_dir = File.dirname(@package.path)
29
32
  font_name = register_font(writer)
33
+ page_index = -1
30
34
 
31
35
  @package.spreads.each do |spread|
32
- render_spread_pages(writer, spread, base_dir, layer_filter,
33
- font_ref_resolver, font_name)
36
+ page_index = render_spread_pages(writer, spread, base_dir,
37
+ layer_filter, font_ref_resolver,
38
+ font_name, structure, page_index)
34
39
  end
35
40
 
41
+ structure.flush(writer)
36
42
  writer.build_structure if @tagged
37
43
  writer.subset_fonts! if @subset_fonts
38
44
  writer.write(@output_path)
@@ -41,113 +47,43 @@ module Idml
41
47
 
42
48
  private
43
49
 
50
+ def pdfa_requested?
51
+ @compliance&.to_s&.start_with?("pdfa")
52
+ end
53
+
44
54
  def render_spread_pages(writer, spread, base_dir, layer_filter,
45
- font_ref_resolver, font_name)
55
+ font_ref_resolver, font_name, structure,
56
+ page_offset)
46
57
  pages = spread.spread.flat_map(&:page)
47
- image_refs = collect_images(writer, spread, base_dir, layer_filter)
48
- renderer = build_renderer(layer_filter, font_ref_resolver, font_name)
58
+ image_refs = ImageCollector.new(writer: writer, base_dir: base_dir,
59
+ page_height: DEFAULT_HEIGHT).collect(spread)
60
+ renderer = build_renderer(layer_filter, font_ref_resolver, font_name,
61
+ structure: structure)
62
+ current = page_offset
49
63
 
50
64
  pages.each do |page|
65
+ current += 1
51
66
  dims = page_dimensions_for(page)
52
67
  canvas = writer.add_page(width: dims[:width], height: dims[:height])
53
68
  renderer.render(canvas, spread, page_width: dims[:width],
54
69
  page_height: dims[:height],
55
- image_refs: image_refs)
70
+ image_refs: image_refs,
71
+ page_index: current)
56
72
  end
73
+ current
57
74
  end
58
75
 
59
- def build_renderer(layer_filter, font_ref_resolver, font_name)
76
+ def build_renderer(layer_filter, font_ref_resolver, font_name, structure:)
60
77
  SpreadRenderer.new(
61
78
  font_resolver: @font_resolver,
62
79
  font_ps_name: font_name,
63
80
  package: @package,
64
81
  layer_filter: layer_filter,
65
82
  font_ref_resolver: font_ref_resolver,
83
+ structure: structure,
66
84
  )
67
85
  end
68
86
 
69
- def collect_images(writer, spread, base_dir, layer_filter)
70
- refs = []
71
- spread.each_page_item do |item|
72
- next unless layer_filter.visible?(item)
73
-
74
- images_for_item(item).each do |img|
75
- ref = register_image(writer, img, item, base_dir)
76
- refs << ref if ref
77
- end
78
- end
79
- refs
80
- end
81
-
82
- def images_for_item(item)
83
- case item
84
- when Idml::Elements::Rectangle, Idml::Elements::Polygon
85
- item.image
86
- else
87
- []
88
- end
89
- end
90
-
91
- def register_image(writer, image, parent, base_dir)
92
- uri = image.resource_uri
93
- return nil unless uri
94
-
95
- existing = writer.image_name_for(uri)
96
- return reuse_image(writer, existing, image, parent) if existing
97
-
98
- load_new_image(writer, image, parent, base_dir, uri)
99
- end
100
-
101
- def reuse_image(_writer, name, image, parent)
102
- { name: name, placement: compute_placement(image, parent),
103
- clip_box: parent_clip_box(parent) }
104
- end
105
-
106
- def load_new_image(writer, image, parent, base_dir, uri)
107
- path = Image.resolve_path(uri, base_dir: base_dir)
108
- return nil unless File.exist?(path)
109
-
110
- data = File.binread(path)
111
- dims = image_dimensions(data)
112
- return nil unless dims
113
-
114
- name = writer.add_image(data: data)
115
- writer.register_image_name(uri, name)
116
- { name: name, placement: compute_placement(image, parent, dims[1]),
117
- clip_box: parent_clip_box(parent) }
118
- end
119
-
120
- def parent_clip_box(parent)
121
- return nil unless parent.geometric_bounds
122
-
123
- Geometry.placement_rect(parent.geometric_bounds,
124
- parent.item_transform, DEFAULT_HEIGHT)
125
- end
126
-
127
- def image_dimensions(data)
128
- format = Image.detect_format(data)
129
- return nil unless format
130
-
131
- if format == :png
132
- Image.png_dimensions(data)
133
- else
134
- Image.jpeg_dimensions(data)
135
- end
136
- end
137
-
138
- def compute_placement(image, parent, pixel_height = 100)
139
- Image.compute_placement(
140
- image_transform: parse_transform_safe(image.item_transform),
141
- parent_transform: parse_transform_safe(parent.item_transform),
142
- pixel_height: pixel_height,
143
- page_height: DEFAULT_HEIGHT,
144
- )
145
- end
146
-
147
- def parse_transform_safe(str)
148
- Image.parse_transform(str) || Image.identity
149
- end
150
-
151
87
  def page_dimensions_for(page)
152
88
  { width: page.width || DEFAULT_WIDTH,
153
89
  height: page.height || DEFAULT_HEIGHT }
@@ -16,6 +16,8 @@ module Idml
16
16
  :page_width,
17
17
  :page_height,
18
18
  :layer_filter,
19
+ :structure,
20
+ :page_index,
19
21
  keyword_init: true,
20
22
  )
21
23
  end
@@ -6,15 +6,17 @@ module Idml
6
6
  class SpreadRenderer
7
7
  def initialize(font_resolver: nil, font_ps_name: Render::DEFAULT_FONT,
8
8
  package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
9
- font_ref_resolver: nil)
9
+ font_ref_resolver: nil, structure: nil)
10
10
  @font_resolver = font_resolver
11
11
  @font_ps_name = font_ps_name
12
12
  @package = package
13
13
  @layer_filter = layer_filter
14
14
  @font_ref_resolver = font_ref_resolver
15
+ @structure = structure
15
16
  end
16
17
 
17
- def render(canvas, spread, page_width:, page_height:, image_refs: [])
18
+ def render(canvas, spread, page_width:, page_height:, image_refs: [],
19
+ page_index: 0)
18
20
  context_base = {
19
21
  package: @package,
20
22
  font_resolver: @font_resolver,
@@ -24,6 +26,8 @@ module Idml
24
26
  page_width: page_width,
25
27
  page_height: page_height,
26
28
  layer_filter: @layer_filter,
29
+ structure: @structure,
30
+ page_index: page_index,
27
31
  }
28
32
 
29
33
  canvas.save_graphics_state do
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Maps IDML page-item classes to PDF structure types (PDF 1.7
6
+ # §14.8.4). Each visible page item becomes one structure element
7
+ # when tagged output is enabled.
8
+ #
9
+ # Mapping rationale:
10
+ # TextFrame → P (paragraph)
11
+ # Group → Sect (section, contains children)
12
+ # Table → Table
13
+ # Rectangle/Polygon with image → Figure
14
+ # Rectangle/Polygon without image → Path (decorative shape)
15
+ # GraphicLine → Path
16
+ #
17
+ # The PDF spec reserves lowercase for standard types; pdfrb's
18
+ # `add_element(type:)` accepts a Symbol which is serialised as
19
+ # `/Type`. We use Title-case Symbols to match the spec's typical
20
+ # spelling (P, Sect, Table, Figure, Path).
21
+ module StructureMapper
22
+ def self.type_for(item)
23
+ case item
24
+ when Idml::Elements::TextFrame then :P
25
+ when Idml::Elements::Group then :Sect
26
+ when Idml::Elements::Table then :Table
27
+ when Idml::Elements::GraphicLine then :Path
28
+ when Idml::Elements::Rectangle, Idml::Elements::Polygon
29
+ shape_type(item)
30
+ end
31
+ end
32
+
33
+ def self.shape_type(item)
34
+ image?(item) ? :Figure : :Path
35
+ end
36
+ private_class_method :shape_type
37
+
38
+ # Optional `Alt` text for Figure elements. IDML doesn't have a
39
+ # dedicated alt-text attribute on `<Image>`, but the parent
40
+ # page item's `Name` (if present) is a useful fallback.
41
+ def self.alt_for(item)
42
+ case item
43
+ when Idml::Elements::Rectangle, Idml::Elements::Polygon
44
+ image?(item) ? item.name : nil
45
+ end
46
+ end
47
+
48
+ def self.image?(item)
49
+ Array(item.image).any?
50
+ end
51
+ private_class_method :image?
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Allocates MCID integers per page and buffers structure-element
6
+ # registrations until `Pipeline` is ready to flush them. Created
7
+ # once per pipeline run; `enabled?` is false when `tagged:` was
8
+ # not requested, in which case every method is a no-op.
9
+ class StructureTracker
10
+ def initialize(enabled: false)
11
+ @enabled = enabled
12
+ @entries = []
13
+ @page_mcids = Hash.new(0)
14
+ end
15
+
16
+ def enabled?
17
+ @enabled
18
+ end
19
+
20
+ # Returns the next MCID for the given page index. MCIDs are
21
+ # per-page in PDF (reset to 0 on each new page).
22
+ def next_mcid(page_index)
23
+ current = @page_mcids[page_index]
24
+ @page_mcids[page_index] = current + 1
25
+ current
26
+ end
27
+
28
+ def add(type, page_index:, mcid:, text: nil, alt: nil)
29
+ return unless @enabled
30
+
31
+ @entries << Entry.new(type, page_index, mcid, text, alt)
32
+ end
33
+
34
+ # Push buffered entries into the writer in registration order.
35
+ def flush(writer)
36
+ return unless @enabled
37
+
38
+ @entries.each do |entry|
39
+ writer.add_structure_element(entry.type, page_index: entry.page_index,
40
+ mcid: entry.mcid,
41
+ text: entry.text,
42
+ alt: entry.alt)
43
+ end
44
+ end
45
+
46
+ Entry = Struct.new(:type, :page_index, :mcid, :text, :alt, keyword_init: true) do
47
+ def initialize(type, page_index, mcid, text, alt)
48
+ super(type: type, page_index: page_index, mcid: mcid,
49
+ text: text, alt: alt)
50
+ end
51
+ end
52
+ private_constant :Entry
53
+ end
54
+ end
55
+ end
data/lib/idml/render.rb CHANGED
@@ -15,6 +15,10 @@ module Idml
15
15
  autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
16
16
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
17
17
  autoload :Placement, "#{__dir__}/render/placement"
18
+ autoload :ImageCollector, "#{__dir__}/render/image_collector"
19
+ autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
20
+ autoload :StructureMapper, "#{__dir__}/render/structure_mapper"
21
+ autoload :PdfaPacket, "#{__dir__}/render/pdfa_packet"
18
22
  autoload :StoryThreader, "#{__dir__}/render/story_threader"
19
23
  autoload :PdfrbWriter, "#{__dir__}/render/pdfrb_writer"
20
24
  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.2.9"
4
+ VERSION = "0.3.0"
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.2.9
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -218,6 +218,8 @@ files:
218
218
  - TODO.pdf/75-pdf-metadata-from-xmp.md
219
219
  - TODO.pdf/76-tagged-pdf-structure.md
220
220
  - TODO.pdf/77-pdfa-xmp-output-intent.md
221
+ - TODO.pdf/78-hyperlinks.md
222
+ - TODO.pdf/79-bookmarks-outline.md
221
223
  - TODO.pdf/README.md
222
224
  - exe/idml
223
225
  - idml.gemspec
@@ -389,8 +391,10 @@ files:
389
391
  - lib/idml/render/font_reference_resolver.rb
390
392
  - lib/idml/render/gradient_resolver.rb
391
393
  - lib/idml/render/image.rb
394
+ - lib/idml/render/image_collector.rb
392
395
  - lib/idml/render/layer_filter.rb
393
396
  - lib/idml/render/page_item_renderer.rb
397
+ - lib/idml/render/pdfa_packet.rb
394
398
  - lib/idml/render/pdfrb_writer.rb
395
399
  - lib/idml/render/pipeline.rb
396
400
  - lib/idml/render/placement.rb
@@ -405,6 +409,8 @@ files:
405
409
  - lib/idml/render/spread_renderer.rb
406
410
  - lib/idml/render/story_threader.rb
407
411
  - lib/idml/render/stroke_style.rb
412
+ - lib/idml/render/structure_mapper.rb
413
+ - lib/idml/render/structure_tracker.rb
408
414
  - lib/idml/render/style_resolver.rb
409
415
  - lib/idml/text_engine.rb
410
416
  - lib/idml/text_engine/cjk_layout.rb