idml 0.3.1 → 0.4.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 -4
- data/TODO.pdf/78-hyperlinks.md +54 -74
- data/TODO.pdf/79-bookmarks-outline.md +41 -67
- data/TODO.pdf/80-paragraph-alignment.md +39 -0
- data/TODO.pdf/81-pdfa-icc-output-intent.md +51 -0
- data/TODO.pdf/82-table-cell-text.md +54 -0
- data/TODO.pdf/83-pipeline-decomposition.md +38 -0
- data/lib/idml/elements/bookmark.rb +25 -0
- data/lib/idml/elements/character_style_range.rb +3 -0
- data/lib/idml/elements/hyperlink.rb +27 -0
- data/lib/idml/elements/hyperlink_page_destination.rb +34 -0
- data/lib/idml/elements/hyperlink_text_source.rb +31 -0
- data/lib/idml/elements/hyperlink_url_destination.rb +25 -0
- data/lib/idml/elements/table_cell.rb +14 -1
- data/lib/idml/elements.rb +8 -0
- data/lib/idml/parts/designmap.rb +12 -0
- data/lib/idml/render/bookmark_resolver.rb +87 -0
- data/lib/idml/render/hyperlink_emitter.rb +78 -0
- data/lib/idml/render/hyperlink_resolver.rb +68 -0
- data/lib/idml/render/icc_profile.rb +50 -0
- data/lib/idml/render/metadata_builder.rb +73 -0
- data/lib/idml/render/pdfrb_writer.rb +10 -0
- data/lib/idml/render/pipeline.rb +36 -56
- data/lib/idml/render/renderers/table_renderer.rb +27 -3
- data/lib/idml/render/renderers/text_frame_renderer.rb +23 -17
- data/lib/idml/render/style_resolver.rb +23 -2
- data/lib/idml/render.rb +5 -0
- data/lib/idml/version.rb +1 -1
- metadata +15 -1
|
@@ -3,13 +3,21 @@
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Elements
|
|
5
5
|
# `<TableCell>` — a single cell within a table row. Carries the
|
|
6
|
-
# cell's content type and
|
|
6
|
+
# cell's content type, dimensions, and any inline text content
|
|
7
|
+
# (`<CharacterStyleRange>` children that carry the cell's text).
|
|
8
|
+
#
|
|
9
|
+
# IDML's actual schema (`Cell_Object` in
|
|
10
|
+
# `reference-docs/schemas/package/Stories/Story.rnc`) names this
|
|
11
|
+
# element `Cell` and includes many more attributes; this model
|
|
12
|
+
# captures the subset the renderer needs today.
|
|
7
13
|
class TableCell < Lutaml::Model::Serializable
|
|
8
14
|
attribute :self_attr, :string
|
|
9
15
|
attribute :name, :string
|
|
10
16
|
attribute :column, :integer
|
|
11
17
|
attribute :row, :integer
|
|
12
18
|
attribute :key_value, :string
|
|
19
|
+
attribute :character_style_range, Idml::Elements::CharacterStyleRange,
|
|
20
|
+
collection: true
|
|
13
21
|
|
|
14
22
|
xml do
|
|
15
23
|
root "TableCell"
|
|
@@ -18,6 +26,11 @@ module Idml
|
|
|
18
26
|
map_attribute "Column", to: :column
|
|
19
27
|
map_attribute "Row", to: :row
|
|
20
28
|
map_attribute "KeyValue", to: :key_value
|
|
29
|
+
map_element "CharacterStyleRange", to: :character_style_range
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def text_content
|
|
33
|
+
character_style_range.filter_map(&:text_content).join
|
|
21
34
|
end
|
|
22
35
|
end
|
|
23
36
|
end
|
data/lib/idml/elements.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Idml
|
|
|
15
15
|
autoload :BevelAndEmbossSetting, "idml/elements/bevel_and_emboss_setting"
|
|
16
16
|
autoload :BlendingSetting, "idml/elements/blending_setting"
|
|
17
17
|
autoload :ButtonPreference, "idml/elements/pref_button_preference"
|
|
18
|
+
autoload :Bookmark, "idml/elements/bookmark"
|
|
18
19
|
autoload :CellStyle, "idml/elements/cell_style"
|
|
19
20
|
autoload :CellStyleGroup, "idml/elements/cell_style_group"
|
|
20
21
|
autoload :ChapterNumberPreference,
|
|
@@ -60,6 +61,13 @@ module Idml
|
|
|
60
61
|
autoload :Group, "idml/elements/group"
|
|
61
62
|
autoload :GuidePreference, "idml/elements/pref_guide_preference"
|
|
62
63
|
autoload :HTMLExportPreference, "idml/elements/pref_html_export_preference"
|
|
64
|
+
autoload :Hyperlink, "idml/elements/hyperlink"
|
|
65
|
+
autoload :HyperlinkPageDestination,
|
|
66
|
+
"idml/elements/hyperlink_page_destination"
|
|
67
|
+
autoload :HyperlinkTextSource,
|
|
68
|
+
"idml/elements/hyperlink_text_source"
|
|
69
|
+
autoload :HyperlinkURLDestination,
|
|
70
|
+
"idml/elements/hyperlink_url_destination"
|
|
63
71
|
autoload :Image, "idml/elements/image"
|
|
64
72
|
autoload :IndexHeaderSetting, "idml/elements/pref_index_header_setting"
|
|
65
73
|
autoload :IndexOptions, "idml/elements/pref_index_options"
|
data/lib/idml/parts/designmap.rb
CHANGED
|
@@ -47,6 +47,12 @@ module Idml
|
|
|
47
47
|
attribute :active_process, :string
|
|
48
48
|
|
|
49
49
|
attribute :layer, Idml::Elements::Layer, collection: true
|
|
50
|
+
attribute :bookmark, Idml::Elements::Bookmark, collection: true
|
|
51
|
+
attribute :hyperlink, Idml::Elements::Hyperlink, collection: true
|
|
52
|
+
attribute :hyperlink_page_destination,
|
|
53
|
+
Idml::Elements::HyperlinkPageDestination, collection: true
|
|
54
|
+
attribute :hyperlink_url_destination,
|
|
55
|
+
Idml::Elements::HyperlinkURLDestination, collection: true
|
|
50
56
|
|
|
51
57
|
xml do
|
|
52
58
|
root "Document"
|
|
@@ -85,6 +91,12 @@ module Idml
|
|
|
85
91
|
map_attribute "PreferMathMLInEpubExport", to: :prefer_math_ml_in_epub_export
|
|
86
92
|
map_attribute "ActiveProcess", to: :active_process
|
|
87
93
|
map_element "Layer", to: :layer
|
|
94
|
+
map_element "Bookmark", to: :bookmark
|
|
95
|
+
map_element "Hyperlink", to: :hyperlink
|
|
96
|
+
map_element "HyperlinkPageDestination",
|
|
97
|
+
to: :hyperlink_page_destination
|
|
98
|
+
map_element "HyperlinkURLDestination",
|
|
99
|
+
to: :hyperlink_url_destination
|
|
88
100
|
end
|
|
89
101
|
end
|
|
90
102
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Resolves IDML bookmarks to (title, page_index) pairs ready for
|
|
6
|
+
# `PdfrbWriter#add_bookmark`. Uses the document's designmap to
|
|
7
|
+
# look up:
|
|
8
|
+
#
|
|
9
|
+
# Bookmark#destination (Self) →
|
|
10
|
+
# HyperlinkPageDestination#destination_page (Page Self) →
|
|
11
|
+
# PDF page index (via spread iteration)
|
|
12
|
+
#
|
|
13
|
+
# Bookmarks whose destination chain cannot be resolved are
|
|
14
|
+
# skipped silently.
|
|
15
|
+
class BookmarkResolver
|
|
16
|
+
def initialize(package)
|
|
17
|
+
@package = package
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Yields [title, page_index] pairs for each resolvable bookmark.
|
|
21
|
+
def each
|
|
22
|
+
return enum_for(:each) unless block_given?
|
|
23
|
+
|
|
24
|
+
bookmarks.each do |bookmark|
|
|
25
|
+
entry = resolve(bookmark)
|
|
26
|
+
yield entry if entry
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def bookmarks
|
|
33
|
+
designmap&.bookmark || []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def designmap
|
|
37
|
+
@package&.designmap
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve(bookmark)
|
|
41
|
+
destination = destination_by_self(bookmark.destination)
|
|
42
|
+
return nil unless destination&.destination_page
|
|
43
|
+
|
|
44
|
+
page_index = page_index_by_self(destination.destination_page)
|
|
45
|
+
return nil unless page_index
|
|
46
|
+
|
|
47
|
+
title = bookmark.name || destination.name || "Bookmark"
|
|
48
|
+
[title, page_index]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def destination_by_self(self_attr)
|
|
52
|
+
return nil unless self_attr
|
|
53
|
+
|
|
54
|
+
destinations.find { |d| d.self_attr == self_attr }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def destinations
|
|
58
|
+
designmap&.hyperlink_page_destination || []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def page_index_by_self(page_self)
|
|
62
|
+
page_self_table[page_self]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def page_self_table
|
|
66
|
+
@page_self_table ||= begin
|
|
67
|
+
table = {}
|
|
68
|
+
@package.spreads.each_with_index do |spread, spread_idx|
|
|
69
|
+
spread_pages = spread.spread.flat_map(&:page)
|
|
70
|
+
spread_pages.each_with_index do |page, page_idx|
|
|
71
|
+
table[page.self_attr] = cumulative_page_index(spread_idx, page_idx)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
table
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cumulative_page_index(spread_idx, page_idx)
|
|
79
|
+
prior_pages = 0
|
|
80
|
+
@package.spreads.first(spread_idx).each do |spread|
|
|
81
|
+
prior_pages += spread.spread.flat_map(&:page).length
|
|
82
|
+
end
|
|
83
|
+
prior_pages + page_idx
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Emits PDF Link annotations for IDML hyperlinks. After a spread
|
|
6
|
+
# renders, walk each visible text frame, look up its story's
|
|
7
|
+
# hyperlink sources, resolve each to a URL via HyperlinkResolver,
|
|
8
|
+
# and emit a `/Subtype /Link` annotation over the frame's box.
|
|
9
|
+
#
|
|
10
|
+
# Limitation: this implementation is frame-level, not text-range
|
|
11
|
+
# level. The link covers the entire text frame rather than just
|
|
12
|
+
# the source's TextRange. Precise per-range rects require deeper
|
|
13
|
+
# integration with the text engine — see TODO 78.
|
|
14
|
+
class HyperlinkEmitter
|
|
15
|
+
def initialize(writer:, package:, page_height:, layer_filter: nil)
|
|
16
|
+
@writer = writer
|
|
17
|
+
@package = package
|
|
18
|
+
@resolver = HyperlinkResolver.new(package)
|
|
19
|
+
@page_height = page_height
|
|
20
|
+
@layer_filter = layer_filter
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def emit_for(spread, page_index)
|
|
24
|
+
each_text_frame_on(spread) do |frame|
|
|
25
|
+
emit_for_frame(frame, page_index)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def each_text_frame_on(spread)
|
|
32
|
+
spread.each_page_item do |item|
|
|
33
|
+
next unless item.is_a?(Idml::Elements::TextFrame)
|
|
34
|
+
next if @layer_filter && !@layer_filter.visible?(item)
|
|
35
|
+
|
|
36
|
+
yield item
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def emit_for_frame(frame, page_index)
|
|
41
|
+
urls = urls_for_frame(frame)
|
|
42
|
+
return if urls.empty?
|
|
43
|
+
|
|
44
|
+
box = Placement.box(frame, @page_height)
|
|
45
|
+
return unless box
|
|
46
|
+
|
|
47
|
+
urls.each do |url|
|
|
48
|
+
@writer.add_uri_link_annotation(
|
|
49
|
+
page_index: page_index,
|
|
50
|
+
rect: rect_for(box),
|
|
51
|
+
url: url,
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def urls_for_frame(frame)
|
|
57
|
+
story = frame.parent_story ? @package.story_by_id(frame.parent_story) : nil
|
|
58
|
+
return [] unless story
|
|
59
|
+
|
|
60
|
+
sources = hyperlink_sources_in(story)
|
|
61
|
+
sources.filter_map { |source| @resolver.url_for_source(source.self_attr) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def hyperlink_sources_in(story)
|
|
65
|
+
inner = story&.inner
|
|
66
|
+
return [] unless inner
|
|
67
|
+
|
|
68
|
+
inner.paragraph_style_range.flat_map do |psr|
|
|
69
|
+
psr.character_style_range.flat_map(&:hyperlink_text_source)
|
|
70
|
+
end.compact
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def rect_for(box)
|
|
74
|
+
[box[:x], box[:y], box[:x] + box[:width], box[:y] + box[:height]]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Resolves IDML hyperlink definitions to URL destinations. Looks
|
|
6
|
+
# up `Hyperlink#source` → `Hyperlink#destination` →
|
|
7
|
+
# `HyperlinkURLDestination#destination_url` from the designmap.
|
|
8
|
+
#
|
|
9
|
+
# The renderer is responsible for mapping source Self IDs to
|
|
10
|
+
# page-item rectangles; this resolver only handles the
|
|
11
|
+
# destination lookup.
|
|
12
|
+
class HyperlinkResolver
|
|
13
|
+
def initialize(package)
|
|
14
|
+
@package = package
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the URL for the given hyperlink-source Self, or nil.
|
|
18
|
+
def url_for_source(source_self)
|
|
19
|
+
return nil unless source_self
|
|
20
|
+
|
|
21
|
+
hyperlink = hyperlink_by_source(source_self)
|
|
22
|
+
return nil unless hyperlink
|
|
23
|
+
|
|
24
|
+
url_destination_by_self(hyperlink.destination)&.destination_url
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Yields [source_self, url] for every visible hyperlink whose
|
|
28
|
+
# destination chain resolves to a URL.
|
|
29
|
+
def each_visible
|
|
30
|
+
return enum_for(:each_visible) unless block_given?
|
|
31
|
+
|
|
32
|
+
hyperlinks.each do |hyperlink|
|
|
33
|
+
entry = visible_entry(hyperlink)
|
|
34
|
+
yield(*entry) if entry
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def visible_entry(hyperlink)
|
|
41
|
+
return nil if hyperlink.visible == false || hyperlink.hidden == true
|
|
42
|
+
|
|
43
|
+
url = url_destination_by_self(hyperlink.destination)&.destination_url
|
|
44
|
+
return nil unless url
|
|
45
|
+
|
|
46
|
+
[hyperlink.source, url]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hyperlinks
|
|
50
|
+
@package&.designmap&.hyperlink || []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def hyperlink_by_source(source_self)
|
|
54
|
+
hyperlinks.find { |h| h.source == source_self }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def url_destination_by_self(self_attr)
|
|
58
|
+
return nil unless self_attr
|
|
59
|
+
|
|
60
|
+
url_destinations.find { |d| d.self_attr == self_attr }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def url_destinations
|
|
64
|
+
@package&.designmap&.hyperlink_url_destination || []
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Locates an sRGB ICC profile for PDF/A output intent embedding.
|
|
6
|
+
# The idml gem does not bundle a binary ICC asset; instead, this
|
|
7
|
+
# helper probes a small set of well-known locations in priority
|
|
8
|
+
# order and returns the first match as raw bytes.
|
|
9
|
+
#
|
|
10
|
+
# Priority:
|
|
11
|
+
# 1. `ENV["IDML_SRGB_ICC"]` — explicit user override.
|
|
12
|
+
# 2. `data/idml/srgb.icc` inside the gem tree (user-vendored).
|
|
13
|
+
# 3. macOS system profile at
|
|
14
|
+
# `/System/Library/ColorSync/Profiles/sRGB Profile.icc`.
|
|
15
|
+
#
|
|
16
|
+
# Returns `nil` when no profile is available — callers (Pipeline)
|
|
17
|
+
# should skip ICC embedding in that case rather than raising.
|
|
18
|
+
module IccProfile
|
|
19
|
+
GEM_DATA_PATH = File.expand_path("../../../data/idml/srgb.icc", __dir__)
|
|
20
|
+
MACOS_SYSTEM_PATH = "/System/Library/ColorSync/Profiles/sRGB Profile.icc"
|
|
21
|
+
|
|
22
|
+
def self.srgb_bytes
|
|
23
|
+
candidate_paths.each do |path|
|
|
24
|
+
bytes = read_if_present(path)
|
|
25
|
+
return bytes if bytes
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.candidate_paths
|
|
31
|
+
[
|
|
32
|
+
ENV.fetch("IDML_SRGB_ICC", nil),
|
|
33
|
+
GEM_DATA_PATH,
|
|
34
|
+
MACOS_SYSTEM_PATH,
|
|
35
|
+
].compact
|
|
36
|
+
end
|
|
37
|
+
private_class_method :candidate_paths
|
|
38
|
+
|
|
39
|
+
def self.read_if_present(path)
|
|
40
|
+
return nil unless path && !path.empty?
|
|
41
|
+
return nil unless File.exist?(path)
|
|
42
|
+
|
|
43
|
+
File.binread(path)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
private_class_method :read_if_present
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Idml
|
|
6
|
+
module Render
|
|
7
|
+
# Builds the PDF Info-dict metadata hash by merging XMP-extracted
|
|
8
|
+
# values from `META-INF/metadata.xml` over sensible defaults.
|
|
9
|
+
# Extracted from `Pipeline` for SRP — Pipeline orchestrates
|
|
10
|
+
# rendering, this module owns metadata assembly.
|
|
11
|
+
class MetadataBuilder
|
|
12
|
+
XMP_PATH = "META-INF/metadata.xml"
|
|
13
|
+
|
|
14
|
+
def initialize(package)
|
|
15
|
+
@package = package
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns a hash with Producer/CreationDate defaults, overridden
|
|
19
|
+
# by any XMP-supplied fields (Title, Author, Subject, Keywords,
|
|
20
|
+
# Creator, CreationDate, ModDate). XMP dates are converted from
|
|
21
|
+
# ISO 8601 to PDF `D:YYYYMMDDHHmmss` format.
|
|
22
|
+
def build
|
|
23
|
+
defaults.merge(xmp_fields) { |_key, default, xmp| xmp || default }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def defaults
|
|
29
|
+
{
|
|
30
|
+
Producer: "idml gem v#{Idml::VERSION}",
|
|
31
|
+
CreationDate: pdf_date(Time.now.utc),
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def xmp_fields
|
|
36
|
+
return {} unless xmp_packet
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
Title: xmp_packet.title,
|
|
40
|
+
Author: xmp_packet.author,
|
|
41
|
+
Subject: xmp_packet.description,
|
|
42
|
+
Keywords: xmp_packet.keywords,
|
|
43
|
+
Creator: xmp_packet.creator_tool,
|
|
44
|
+
CreationDate: pdf_date_string(xmp_packet.create_date),
|
|
45
|
+
ModDate: pdf_date_string(xmp_packet.modify_date),
|
|
46
|
+
}.compact
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def xmp_packet
|
|
50
|
+
return nil unless @package.has_part?(XMP_PATH)
|
|
51
|
+
|
|
52
|
+
@xmp_packet ||= begin
|
|
53
|
+
xml = @package.read_part(XMP_PATH)
|
|
54
|
+
Parts::XmpMeta.from_xml(xml).rdf
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pdf_date_string(iso8601)
|
|
61
|
+
return nil unless iso8601
|
|
62
|
+
|
|
63
|
+
pdf_date(Time.iso8601(iso8601))
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def pdf_date(time)
|
|
69
|
+
time.strftime("D:%Y%m%d%H%M%S+00'00'")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -83,6 +83,16 @@ module Idml
|
|
|
83
83
|
page: page, mcid: mcid)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
def add_uri_link_annotation(page_index:, rect:, url:)
|
|
87
|
+
page = @document.pages[page_index]
|
|
88
|
+
action = @document.add({ S: :URI, URI: url },
|
|
89
|
+
type: Pdfrb::Model::Cos::Dictionary)
|
|
90
|
+
action_ref = Pdfrb::Model::Reference.new(action.oid, action.gen)
|
|
91
|
+
annot = @document.annotations.add(page, subtype: :Link, rect: rect)
|
|
92
|
+
annot.value[:A] = action_ref
|
|
93
|
+
annot
|
|
94
|
+
end
|
|
95
|
+
|
|
86
96
|
def build_structure
|
|
87
97
|
@document.structure.build!
|
|
88
98
|
end
|
data/lib/idml/render/pipeline.rb
CHANGED
|
@@ -21,10 +21,13 @@ module Idml
|
|
|
21
21
|
|
|
22
22
|
def call
|
|
23
23
|
writer = PdfrbWriter.new
|
|
24
|
-
metadata =
|
|
24
|
+
metadata = MetadataBuilder.new(@package).build
|
|
25
25
|
writer.set_info(metadata)
|
|
26
26
|
writer.enable_tagged if @tagged
|
|
27
|
-
|
|
27
|
+
if pdfa_requested?
|
|
28
|
+
PdfaPacket.attach(writer.document, metadata)
|
|
29
|
+
embed_pdfa_output_intent(writer)
|
|
30
|
+
end
|
|
28
31
|
structure = StructureTracker.new(enabled: @tagged)
|
|
29
32
|
layer_filter = LayerFilter.from_designmap(@package.designmap)
|
|
30
33
|
font_ref_resolver = FontReferenceResolver.build(@package)
|
|
@@ -42,6 +45,7 @@ module Idml
|
|
|
42
45
|
|
|
43
46
|
structure.flush(writer)
|
|
44
47
|
writer.build_structure if @tagged
|
|
48
|
+
emit_bookmarks(writer)
|
|
45
49
|
writer.subset_fonts! if @subset_fonts
|
|
46
50
|
writer.write(@output_path)
|
|
47
51
|
@output_path
|
|
@@ -49,6 +53,26 @@ module Idml
|
|
|
49
53
|
|
|
50
54
|
private
|
|
51
55
|
|
|
56
|
+
def embed_pdfa_output_intent(writer)
|
|
57
|
+
bytes = IccProfile.srgb_bytes
|
|
58
|
+
return unless bytes
|
|
59
|
+
|
|
60
|
+
writer.document.output_intents.embed_icc(
|
|
61
|
+
bytes,
|
|
62
|
+
identifier: "sRGB",
|
|
63
|
+
condition: "sRGB IEC61966-2.1",
|
|
64
|
+
subtype: :GTS_PDFA1,
|
|
65
|
+
)
|
|
66
|
+
rescue StandardError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def emit_bookmarks(writer)
|
|
71
|
+
BookmarkResolver.new(@package).each do |title, page_index|
|
|
72
|
+
writer.add_bookmark(title, page_index)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
52
76
|
def pdfa_requested?
|
|
53
77
|
@compliance&.to_s&.start_with?("pdfa")
|
|
54
78
|
end
|
|
@@ -71,10 +95,20 @@ module Idml
|
|
|
71
95
|
page_height: dims[:height],
|
|
72
96
|
image_refs: image_refs,
|
|
73
97
|
page_index: current)
|
|
98
|
+
emit_hyperlinks(writer, spread, current, layer_filter)
|
|
74
99
|
end
|
|
75
100
|
current
|
|
76
101
|
end
|
|
77
102
|
|
|
103
|
+
def emit_hyperlinks(writer, spread, page_index, layer_filter)
|
|
104
|
+
HyperlinkEmitter.new(writer: writer, package: @package,
|
|
105
|
+
page_height: DEFAULT_HEIGHT,
|
|
106
|
+
layer_filter: layer_filter)
|
|
107
|
+
.emit_for(spread, page_index)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
78
112
|
def build_renderer(layer_filter, font_ref_resolver, font_resource,
|
|
79
113
|
font_metrics, structure:)
|
|
80
114
|
SpreadRenderer.new(
|
|
@@ -135,60 +169,6 @@ module Idml
|
|
|
135
169
|
end
|
|
136
170
|
nil
|
|
137
171
|
end
|
|
138
|
-
|
|
139
|
-
def combined_metadata
|
|
140
|
-
defaults = default_metadata
|
|
141
|
-
xmp_metadata.each do |key, value|
|
|
142
|
-
next if value.nil? || value.empty?
|
|
143
|
-
|
|
144
|
-
defaults[key] = value
|
|
145
|
-
end
|
|
146
|
-
defaults
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def xmp_metadata
|
|
150
|
-
return {} unless @package.has_part?(XMP_PATH)
|
|
151
|
-
|
|
152
|
-
xml = @package.read_part(XMP_PATH)
|
|
153
|
-
meta = Parts::XmpMeta.from_xml(xml)
|
|
154
|
-
rdf = meta.rdf
|
|
155
|
-
return {} unless rdf
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
Title: rdf.title,
|
|
159
|
-
Author: rdf.author,
|
|
160
|
-
Subject: rdf.description,
|
|
161
|
-
Keywords: rdf.keywords,
|
|
162
|
-
Creator: rdf.creator_tool,
|
|
163
|
-
CreationDate: pdf_date_string(rdf.create_date),
|
|
164
|
-
ModDate: pdf_date_string(rdf.modify_date),
|
|
165
|
-
}
|
|
166
|
-
rescue StandardError
|
|
167
|
-
{}
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def pdf_date_string(iso8601)
|
|
171
|
-
return nil unless iso8601
|
|
172
|
-
|
|
173
|
-
time = Time.iso8601(iso8601)
|
|
174
|
-
pdf_date(time)
|
|
175
|
-
rescue ArgumentError
|
|
176
|
-
nil
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def default_metadata
|
|
180
|
-
{
|
|
181
|
-
Producer: "idml gem v#{Idml::VERSION}",
|
|
182
|
-
CreationDate: pdf_date(Time.now.utc),
|
|
183
|
-
}
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def pdf_date(time)
|
|
187
|
-
time.strftime("D:%Y%m%d%H%M%S+00'00'")
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
XMP_PATH = "META-INF/metadata.xml"
|
|
191
|
-
private_constant :XMP_PATH
|
|
192
172
|
end
|
|
193
173
|
end
|
|
194
174
|
end
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
|
+
# Renders an IDML Table. Draws the cell grid via rectangle ops,
|
|
7
|
+
# then renders inline `<CharacterStyleRange>` text in each cell
|
|
8
|
+
# via `canvas.text_rich`. Cells with no text render as empty
|
|
9
|
+
# rectangles.
|
|
6
10
|
class TableRenderer
|
|
11
|
+
DEFAULT_SIZE = 10.0
|
|
12
|
+
INSET = 4.0
|
|
13
|
+
|
|
7
14
|
def self.render(canvas, context)
|
|
8
15
|
table = context.item
|
|
9
16
|
return if table.visible == false
|
|
@@ -17,24 +24,41 @@ module Idml
|
|
|
17
24
|
|
|
18
25
|
canvas.save_graphics_state do
|
|
19
26
|
table.table_row.each_with_index do |row, row_index|
|
|
20
|
-
render_row(canvas, row, row_index, row_count, box, row_height
|
|
27
|
+
render_row(canvas, row, row_index, row_count, box, row_height,
|
|
28
|
+
context)
|
|
21
29
|
end
|
|
22
30
|
end
|
|
23
31
|
end
|
|
24
32
|
|
|
25
|
-
def self.render_row(canvas, row, row_index, row_count, box, row_height
|
|
33
|
+
def self.render_row(canvas, row, row_index, row_count, box, row_height,
|
|
34
|
+
context)
|
|
26
35
|
row_y = box[:y] + ((row_count - 1 - row_index) * row_height)
|
|
27
36
|
cell_count = row.table_cell.length
|
|
28
37
|
return unless cell_count.positive?
|
|
29
38
|
|
|
30
39
|
cell_width = box[:width] / cell_count
|
|
31
|
-
row.table_cell.each_with_index do |
|
|
40
|
+
row.table_cell.each_with_index do |cell, cell_index|
|
|
32
41
|
cell_x = box[:x] + (cell_index * cell_width)
|
|
33
42
|
canvas.rectangle(cell_x, row_y, cell_width, row_height)
|
|
34
43
|
canvas.stroke
|
|
44
|
+
render_cell_text(canvas, cell, cell_x, row_y, row_height, context)
|
|
35
45
|
end
|
|
36
46
|
end
|
|
37
47
|
private_class_method :render_row
|
|
48
|
+
|
|
49
|
+
def self.render_cell_text(canvas, cell, x, y, height, context)
|
|
50
|
+
text = cell.text_content
|
|
51
|
+
return if text.nil? || text.empty?
|
|
52
|
+
|
|
53
|
+
runs = [{
|
|
54
|
+
text: text,
|
|
55
|
+
font: context.font_ps_name,
|
|
56
|
+
size: DEFAULT_SIZE,
|
|
57
|
+
}]
|
|
58
|
+
baseline = y + (height / 2)
|
|
59
|
+
canvas.text_rich(runs, at: [x + INSET, baseline])
|
|
60
|
+
end
|
|
61
|
+
private_class_method :render_cell_text
|
|
38
62
|
end
|
|
39
63
|
end
|
|
40
64
|
end
|