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 +4 -4
- data/Gemfile.lock +4 -4
- data/TODO.pdf/76-tagged-pdf-structure.md +63 -87
- data/TODO.pdf/77-pdfa-xmp-output-intent.md +51 -59
- data/TODO.pdf/78-hyperlinks.md +79 -0
- data/TODO.pdf/79-bookmarks-outline.md +72 -0
- data/lib/idml/render/image_collector.rb +107 -0
- data/lib/idml/render/page_item_renderer.rb +18 -1
- data/lib/idml/render/pdfa_packet.rb +135 -0
- data/lib/idml/render/pdfrb_writer.rb +2 -1
- data/lib/idml/render/pipeline.rb +26 -90
- data/lib/idml/render/render_context.rb +2 -0
- data/lib/idml/render/spread_renderer.rb +6 -2
- data/lib/idml/render/structure_mapper.rb +54 -0
- data/lib/idml/render/structure_tracker.rb +55 -0
- data/lib/idml/render.rb +4 -0
- data/lib/idml/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ae5922e2f513147b0e73fb9ba724c7b3a075cfc75da73cac26172029dc1bc8f
|
|
4
|
+
data.tar.gz: 689c47f46262b6b3df737771fd2e9b4e9e5ab4596e272a60e37c902844f2bd78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
- [
|
|
78
|
-
whose `/StructTreeRoot` has
|
|
79
|
-
- [
|
|
80
|
-
right type (Figure
|
|
81
|
-
- [
|
|
82
|
-
- [
|
|
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
|
|
1
|
+
# TODO PDF 77: PDF/A XMP packet and Catalog /Metadata stream
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: PARTIAL — XMP packet emitted; ICC output intent deferred
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What was implemented
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
## What remains deferred
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
-
|
|
64
|
-
|
|
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
|
-
##
|
|
56
|
+
## Acceptance criteria
|
|
70
57
|
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
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
|
-
|
|
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("&", "&")
|
|
128
|
+
.gsub("<", "<")
|
|
129
|
+
.gsub(">", ">")
|
|
130
|
+
.gsub('"', """)
|
|
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:
|
|
83
|
+
page: page, mcid: mcid)
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
def build_structure
|
data/lib/idml/render/pipeline.rb
CHANGED
|
@@ -21,18 +21,24 @@ module Idml
|
|
|
21
21
|
|
|
22
22
|
def call
|
|
23
23
|
writer = PdfrbWriter.new
|
|
24
|
-
|
|
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,
|
|
33
|
-
|
|
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 =
|
|
48
|
-
|
|
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 }
|
|
@@ -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
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
|
+
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
|