idml 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -20
- data/TODO.pdf/63-replace-fontmetrics-with-pdfrb.md +59 -58
- data/TODO.pdf/65-pdfrb-019-integration.md +57 -29
- data/TODO.pdf/67-text-rich-multi-run.md +35 -39
- 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/idml.gemspec +0 -1
- 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 +47 -101
- data/lib/idml/render/render_context.rb +3 -1
- data/lib/idml/render/renderers/group_renderer.rb +3 -1
- data/lib/idml/render/renderers/text_frame_renderer.rb +34 -25
- data/lib/idml/render/spread_renderer.rb +9 -5
- 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/text_engine/pdfrb_font_metrics.rb +81 -0
- data/lib/idml/text_engine.rb +1 -2
- data/lib/idml/version.rb +1 -1
- metadata +8 -17
- data/lib/idml/text_engine/font_metrics.rb +0 -226
- data/lib/idml/text_engine/font_resolver.rb +0 -105
|
@@ -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.
|
data/idml.gemspec
CHANGED
|
@@ -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
|