idml 0.3.0 → 0.4.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 +2 -18
- 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/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/idml.gemspec +0 -1
- data/lib/idml/elements/bookmark.rb +25 -0
- data/lib/idml/elements/hyperlink.rb +27 -0
- data/lib/idml/elements/hyperlink_page_destination.rb +34 -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 +6 -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 +74 -0
- data/lib/idml/render/hyperlink_resolver.rb +68 -0
- data/lib/idml/render/icc_profile.rb +50 -0
- data/lib/idml/render/pdfrb_writer.rb +10 -0
- data/lib/idml/render/pipeline.rb +60 -18
- data/lib/idml/render/render_context.rb +1 -1
- data/lib/idml/render/renderers/group_renderer.rb +3 -1
- data/lib/idml/render/renderers/table_renderer.rb +27 -3
- data/lib/idml/render/renderers/text_frame_renderer.rb +54 -39
- data/lib/idml/render/spread_renderer.rb +3 -3
- data/lib/idml/render/style_resolver.rb +23 -2
- 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 +13 -17
- data/lib/idml/text_engine/font_metrics.rb +0 -226
- data/lib/idml/text_engine/font_resolver.rb +0 -105
|
@@ -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
|
|
@@ -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
|
@@ -13,7 +13,7 @@ module Idml
|
|
|
13
13
|
compliance: nil, tagged: false, subset_fonts: true)
|
|
14
14
|
@package = package
|
|
15
15
|
@output_path = output_path
|
|
16
|
-
@
|
|
16
|
+
@font_search_paths = font_search_paths
|
|
17
17
|
@compliance = compliance
|
|
18
18
|
@tagged = tagged
|
|
19
19
|
@subset_fonts = subset_fonts
|
|
@@ -24,22 +24,28 @@ module Idml
|
|
|
24
24
|
metadata = combined_metadata
|
|
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)
|
|
31
34
|
base_dir = File.dirname(@package.path)
|
|
32
|
-
|
|
35
|
+
font_resource = register_font(writer)
|
|
36
|
+
font_metrics = build_font_metrics(writer, font_resource)
|
|
33
37
|
page_index = -1
|
|
34
38
|
|
|
35
39
|
@package.spreads.each do |spread|
|
|
36
40
|
page_index = render_spread_pages(writer, spread, base_dir,
|
|
37
41
|
layer_filter, font_ref_resolver,
|
|
38
|
-
|
|
42
|
+
font_resource, font_metrics,
|
|
43
|
+
structure, page_index)
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
structure.flush(writer)
|
|
42
47
|
writer.build_structure if @tagged
|
|
48
|
+
emit_bookmarks(writer)
|
|
43
49
|
writer.subset_fonts! if @subset_fonts
|
|
44
50
|
writer.write(@output_path)
|
|
45
51
|
@output_path
|
|
@@ -47,18 +53,38 @@ module Idml
|
|
|
47
53
|
|
|
48
54
|
private
|
|
49
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
|
+
|
|
50
76
|
def pdfa_requested?
|
|
51
77
|
@compliance&.to_s&.start_with?("pdfa")
|
|
52
78
|
end
|
|
53
79
|
|
|
54
80
|
def render_spread_pages(writer, spread, base_dir, layer_filter,
|
|
55
|
-
font_ref_resolver,
|
|
56
|
-
page_offset)
|
|
81
|
+
font_ref_resolver, font_resource, font_metrics,
|
|
82
|
+
structure, page_offset)
|
|
57
83
|
pages = spread.spread.flat_map(&:page)
|
|
58
84
|
image_refs = ImageCollector.new(writer: writer, base_dir: base_dir,
|
|
59
85
|
page_height: DEFAULT_HEIGHT).collect(spread)
|
|
60
|
-
renderer = build_renderer(layer_filter, font_ref_resolver,
|
|
61
|
-
structure: structure)
|
|
86
|
+
renderer = build_renderer(layer_filter, font_ref_resolver,
|
|
87
|
+
font_resource, font_metrics, structure: structure)
|
|
62
88
|
current = page_offset
|
|
63
89
|
|
|
64
90
|
pages.each do |page|
|
|
@@ -69,14 +95,23 @@ module Idml
|
|
|
69
95
|
page_height: dims[:height],
|
|
70
96
|
image_refs: image_refs,
|
|
71
97
|
page_index: current)
|
|
98
|
+
emit_hyperlinks(writer, spread, current)
|
|
72
99
|
end
|
|
73
100
|
current
|
|
74
101
|
end
|
|
75
102
|
|
|
76
|
-
def
|
|
103
|
+
def emit_hyperlinks(writer, spread, page_index)
|
|
104
|
+
HyperlinkEmitter.new(writer: writer, package: @package,
|
|
105
|
+
page_height: DEFAULT_HEIGHT).emit_for(spread, page_index)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_renderer(layer_filter, font_ref_resolver, font_resource,
|
|
111
|
+
font_metrics, structure:)
|
|
77
112
|
SpreadRenderer.new(
|
|
78
|
-
|
|
79
|
-
font_ps_name:
|
|
113
|
+
font_metrics: font_metrics,
|
|
114
|
+
font_ps_name: font_resource,
|
|
80
115
|
package: @package,
|
|
81
116
|
layer_filter: layer_filter,
|
|
82
117
|
font_ref_resolver: font_ref_resolver,
|
|
@@ -89,14 +124,15 @@ module Idml
|
|
|
89
124
|
height: page.height || DEFAULT_HEIGHT }
|
|
90
125
|
end
|
|
91
126
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
127
|
+
def build_font_metrics(writer, font_resource)
|
|
128
|
+
return nil if font_resource == Render::DEFAULT_FONT
|
|
129
|
+
|
|
130
|
+
TextEngine::PdfrbFontMetrics.new(writer.document.fonts, font_resource)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
95
133
|
end
|
|
96
134
|
|
|
97
135
|
def register_font(writer)
|
|
98
|
-
return Render::DEFAULT_FONT unless @font_resolver
|
|
99
|
-
|
|
100
136
|
path = resolve_document_font_path
|
|
101
137
|
return Render::DEFAULT_FONT unless path
|
|
102
138
|
|
|
@@ -105,6 +141,12 @@ module Idml
|
|
|
105
141
|
Render::DEFAULT_FONT
|
|
106
142
|
end
|
|
107
143
|
|
|
144
|
+
def font_resolver
|
|
145
|
+
@font_resolver ||= Pdfrb::FontResolver.new(
|
|
146
|
+
search_paths: @font_search_paths || Pdfrb::FontResolver::DEFAULT_SEARCH_PATHS,
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
108
150
|
def resolve_document_font_path
|
|
109
151
|
return nil unless @package&.fonts
|
|
110
152
|
|
|
@@ -120,8 +162,8 @@ module Idml
|
|
|
120
162
|
next unless font.post_script_name
|
|
121
163
|
next if font.status == "Missing"
|
|
122
164
|
|
|
123
|
-
|
|
124
|
-
return
|
|
165
|
+
path = font_resolver.find_by_ps_name(font.post_script_name)
|
|
166
|
+
return path if path
|
|
125
167
|
end
|
|
126
168
|
nil
|
|
127
169
|
end
|
|
@@ -14,13 +14,15 @@ module Idml
|
|
|
14
14
|
child_context = Render::RenderContext.new(
|
|
15
15
|
item: child,
|
|
16
16
|
package: context.package,
|
|
17
|
-
|
|
17
|
+
font_metrics: context.font_metrics,
|
|
18
18
|
font_ref_resolver: context.font_ref_resolver,
|
|
19
19
|
color_resolver: context.color_resolver,
|
|
20
20
|
font_ps_name: context.font_ps_name,
|
|
21
21
|
page_width: context.page_width,
|
|
22
22
|
page_height: context.page_height,
|
|
23
23
|
layer_filter: context.layer_filter,
|
|
24
|
+
structure: context.structure,
|
|
25
|
+
page_index: context.page_index,
|
|
24
26
|
)
|
|
25
27
|
PageItemRenderer.render(canvas, child_context)
|
|
26
28
|
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
|
|
@@ -4,10 +4,10 @@ module Idml
|
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
6
|
# Renders an IDML TextFrame on a Pdfrb::Content::Canvas. Extracts
|
|
7
|
-
# styled runs via StyleResolver. When a FontMetrics is available
|
|
8
|
-
# runs the full text engine pipeline (Shaper →
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# styled runs via StyleResolver. When a FontMetrics is available
|
|
8
|
+
# (pdfrb-backed), runs the full text engine pipeline (Shaper →
|
|
9
|
+
# LineBreaker) for proper word-wrap; otherwise falls back to a
|
|
10
|
+
# single text_rich call per frame.
|
|
11
11
|
class TextFrameRenderer
|
|
12
12
|
DEFAULT_SIZE = 12.0
|
|
13
13
|
LEADING_FACTOR = 1.2
|
|
@@ -28,7 +28,7 @@ module Idml
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def self.render_text(canvas, runs, context, box)
|
|
31
|
-
font =
|
|
31
|
+
font = context.font_metrics
|
|
32
32
|
if font
|
|
33
33
|
engine_render(canvas, runs, context, box, font)
|
|
34
34
|
else
|
|
@@ -47,26 +47,6 @@ module Idml
|
|
|
47
47
|
end
|
|
48
48
|
private_class_method :frame_box
|
|
49
49
|
|
|
50
|
-
def self.resolve_font_metrics(context)
|
|
51
|
-
return nil unless context.font_resolver
|
|
52
|
-
|
|
53
|
-
context.font_resolver.resolve(
|
|
54
|
-
family_name: Render::DEFAULT_FONT, style_name: "Regular",
|
|
55
|
-
)
|
|
56
|
-
end
|
|
57
|
-
private_class_method :resolve_font_metrics
|
|
58
|
-
|
|
59
|
-
def self.simple_render(canvas, runs, context, box)
|
|
60
|
-
runs.each_with_index do |run, index|
|
|
61
|
-
y = box[:y] + box[:height] -
|
|
62
|
-
((index + 1) * run.point_size * LEADING_FACTOR)
|
|
63
|
-
canvas.text(run.text, at: [box[:x], y],
|
|
64
|
-
font: context.font_ps_name,
|
|
65
|
-
size: run.point_size)
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
private_class_method :simple_render
|
|
69
|
-
|
|
70
50
|
def self.engine_render(canvas, runs, context, box, font)
|
|
71
51
|
baseline_y = box[:y] + box[:height] - runs.first.point_size
|
|
72
52
|
|
|
@@ -78,6 +58,11 @@ module Idml
|
|
|
78
58
|
end
|
|
79
59
|
private_class_method :engine_render
|
|
80
60
|
|
|
61
|
+
# Emits one `canvas.text` call per line, after shaping and
|
|
62
|
+
# line-breaking with real font metrics. Applies paragraph
|
|
63
|
+
# alignment via `Justifier` so each line is offset within
|
|
64
|
+
# the frame box per IDML `Justification`. Lines that fall
|
|
65
|
+
# below the frame's bottom edge are clipped.
|
|
81
66
|
def self.render_run_lines(canvas, run, context, box, font, baseline_y)
|
|
82
67
|
size = run.point_size
|
|
83
68
|
glyphs = TextEngine::Shaper.shape(
|
|
@@ -86,25 +71,55 @@ module Idml
|
|
|
86
71
|
lines = TextEngine::LineBreaker.break(
|
|
87
72
|
glyphs: glyphs, frame_width: box[:width],
|
|
88
73
|
)
|
|
74
|
+
alignment = run.alignment || :left
|
|
75
|
+
start_y = box[:y] + box[:height] - size
|
|
76
|
+
|
|
77
|
+
lines.each_with_index do |line, idx|
|
|
78
|
+
line_y = start_y - (idx * size * LEADING_FACTOR)
|
|
79
|
+
break if line_y < box[:y]
|
|
80
|
+
|
|
81
|
+
TextEngine::Justifier.justify(line: line,
|
|
82
|
+
frame_width: box[:width],
|
|
83
|
+
alignment: alignment)
|
|
84
|
+
canvas.text(line_text(line),
|
|
85
|
+
at: [box[:x] + line.x_offset, line_y],
|
|
86
|
+
font: context.font_ps_name,
|
|
87
|
+
size: size)
|
|
88
|
+
end
|
|
89
|
+
baseline_y
|
|
90
|
+
end
|
|
91
|
+
private_class_method :render_run_lines
|
|
89
92
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
def self.line_text(line)
|
|
94
|
+
line.glyphs.map { |g| [g.codepoint].pack("U") }.join
|
|
95
|
+
end
|
|
96
|
+
private_class_method :line_text
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
# Fallback when no metrics are available: emit all runs as
|
|
99
|
+
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
100
|
+
# Uses pdfrb's measurement API directly (no Fontisan).
|
|
101
|
+
def self.simple_render(canvas, runs, context, box)
|
|
102
|
+
runs_for = build_rich_runs(runs, context)
|
|
103
|
+
return if runs_for.empty?
|
|
97
104
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
first_size = runs.first.point_size
|
|
106
|
+
canvas.text_rich(
|
|
107
|
+
runs_for,
|
|
108
|
+
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
private_class_method :simple_render
|
|
112
|
+
|
|
113
|
+
def self.build_rich_runs(runs, context)
|
|
114
|
+
runs.map do |run|
|
|
115
|
+
{
|
|
116
|
+
text: run.text,
|
|
117
|
+
font: context.font_ps_name,
|
|
118
|
+
size: run.point_size,
|
|
119
|
+
}
|
|
104
120
|
end
|
|
105
|
-
baseline_y
|
|
106
121
|
end
|
|
107
|
-
private_class_method :
|
|
122
|
+
private_class_method :build_rich_runs
|
|
108
123
|
end
|
|
109
124
|
end
|
|
110
125
|
end
|
|
@@ -4,10 +4,10 @@ module Idml
|
|
|
4
4
|
module Render
|
|
5
5
|
# Renders a typed `Parts::Spread` onto a Pdfrb::Content::Canvas.
|
|
6
6
|
class SpreadRenderer
|
|
7
|
-
def initialize(
|
|
7
|
+
def initialize(font_metrics: nil, font_ps_name: Render::DEFAULT_FONT,
|
|
8
8
|
package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
|
|
9
9
|
font_ref_resolver: nil, structure: nil)
|
|
10
|
-
@
|
|
10
|
+
@font_metrics = font_metrics
|
|
11
11
|
@font_ps_name = font_ps_name
|
|
12
12
|
@package = package
|
|
13
13
|
@layer_filter = layer_filter
|
|
@@ -19,7 +19,7 @@ module Idml
|
|
|
19
19
|
page_index: 0)
|
|
20
20
|
context_base = {
|
|
21
21
|
package: @package,
|
|
22
|
-
|
|
22
|
+
font_metrics: @font_metrics,
|
|
23
23
|
font_ref_resolver: @font_ref_resolver,
|
|
24
24
|
color_resolver: build_color_resolver,
|
|
25
25
|
font_ps_name: @font_ps_name,
|
|
@@ -4,15 +4,30 @@ module Idml
|
|
|
4
4
|
module Render
|
|
5
5
|
# Extracts styled text runs from an IDML Story. Each run carries the
|
|
6
6
|
# text content plus the CharacterStyleRange attributes that affect
|
|
7
|
-
# rendering (font style, size, fill color, applied font reference
|
|
7
|
+
# rendering (font style, size, fill color, applied font reference,
|
|
8
|
+
# paragraph alignment).
|
|
8
9
|
class StyleResolver
|
|
9
10
|
StyledRun = Struct.new(
|
|
10
11
|
:text, :font_style, :point_size,
|
|
11
|
-
:fill_color, :fill_tint, :applied_font,
|
|
12
|
+
:fill_color, :fill_tint, :applied_font, :alignment,
|
|
13
|
+
keyword_init: true
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
DEFAULT_POINT_SIZE = 12.0
|
|
15
17
|
|
|
18
|
+
# IDML `Justification` enum → Justifier symbol. Full justify
|
|
19
|
+
# and binding-side variants defer to :left for now (TODO 80).
|
|
20
|
+
ALIGNMENT_MAP = {
|
|
21
|
+
"Left" => :left,
|
|
22
|
+
"Center" => :center,
|
|
23
|
+
"Right" => :right,
|
|
24
|
+
"LeftJustified" => :left,
|
|
25
|
+
"RightJustified" => :right,
|
|
26
|
+
"CenterJustified" => :center,
|
|
27
|
+
"FullyJustified" => :justified,
|
|
28
|
+
"ToBinding" => :left,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
16
31
|
def self.extract_runs(story)
|
|
17
32
|
return [] unless story&.inner
|
|
18
33
|
|
|
@@ -33,11 +48,17 @@ module Idml
|
|
|
33
48
|
fill_color: csr.fill_color,
|
|
34
49
|
fill_tint: csr.fill_tint,
|
|
35
50
|
applied_font: csr.applied_font,
|
|
51
|
+
alignment: alignment_for(csr),
|
|
36
52
|
)
|
|
37
53
|
end
|
|
38
54
|
end
|
|
39
55
|
private_class_method :csr_runs
|
|
40
56
|
|
|
57
|
+
def self.alignment_for(csr)
|
|
58
|
+
ALIGNMENT_MAP[csr.justification] || :left
|
|
59
|
+
end
|
|
60
|
+
private_class_method :alignment_for
|
|
61
|
+
|
|
41
62
|
# Concatenate runs into a single block. Used when the renderer
|
|
42
63
|
# can't handle per-run styling (e.g., no font metrics available).
|
|
43
64
|
def self.concatenate(runs)
|
data/lib/idml/render.rb
CHANGED
|
@@ -19,6 +19,10 @@ module Idml
|
|
|
19
19
|
autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
|
|
20
20
|
autoload :StructureMapper, "#{__dir__}/render/structure_mapper"
|
|
21
21
|
autoload :PdfaPacket, "#{__dir__}/render/pdfa_packet"
|
|
22
|
+
autoload :IccProfile, "#{__dir__}/render/icc_profile"
|
|
23
|
+
autoload :BookmarkResolver, "#{__dir__}/render/bookmark_resolver"
|
|
24
|
+
autoload :HyperlinkResolver, "#{__dir__}/render/hyperlink_resolver"
|
|
25
|
+
autoload :HyperlinkEmitter, "#{__dir__}/render/hyperlink_emitter"
|
|
22
26
|
autoload :StoryThreader, "#{__dir__}/render/story_threader"
|
|
23
27
|
autoload :PdfrbWriter, "#{__dir__}/render/pdfrb_writer"
|
|
24
28
|
autoload :SpreadRenderer, "#{__dir__}/render/spread_renderer"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module TextEngine
|
|
5
|
+
# Adapter that exposes the same measurement surface as
|
|
6
|
+
# `FontMetrics` (the Fontisan-based parser) but delegates to
|
|
7
|
+
# pdfrb's `Fonts` collection + a registered font resource Symbol.
|
|
8
|
+
#
|
|
9
|
+
# Once a font is registered with the pdfrb document via
|
|
10
|
+
# `document.fonts.add(path)`, pdfrb parses the TTF tables (cmap,
|
|
11
|
+
# hmtx, head, hhea) and exposes real per-glyph advance widths
|
|
12
|
+
# through `Fonts#glyph_width(resource, codepoint)` and
|
|
13
|
+
# `Fonts#metrics_for(resource)`. This adapter wraps that pair so
|
|
14
|
+
# the text engine (Shaper, LineBreaker) can use real metrics
|
|
15
|
+
# without any Fontisan dependency.
|
|
16
|
+
class PdfrbFontMetrics
|
|
17
|
+
attr_reader :resource, :fonts
|
|
18
|
+
|
|
19
|
+
def initialize(fonts_collection, resource)
|
|
20
|
+
@fonts = fonts_collection
|
|
21
|
+
@resource = resource
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def units_per_em
|
|
25
|
+
metrics_data[:units_per_em] || 1000
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ascent
|
|
29
|
+
metrics_data[:ascent] || 800
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def descent
|
|
33
|
+
metrics_data[:descent] || -200
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def line_gap
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns raw advance width in font units (not scaled by size).
|
|
41
|
+
def glyph_width(codepoint)
|
|
42
|
+
cp = codepoint.is_a?(String) ? codepoint.each_codepoint.first : codepoint
|
|
43
|
+
@fonts.glyph_width(@resource, cp).to_i
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def measure_text(text, size:)
|
|
47
|
+
return 0.0 if text.nil? || text.empty?
|
|
48
|
+
|
|
49
|
+
text.each_codepoint.sum do |cp|
|
|
50
|
+
glyph_width(cp) * size.to_f / units_per_em
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def kerning_pair(_left_cp, _right_cp)
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def postscript_name
|
|
59
|
+
@resource.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def family_name
|
|
63
|
+
postscript_name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def style_name
|
|
67
|
+
"Regular"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def path
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def metrics_data
|
|
77
|
+
@fonts.metrics_for(@resource) || {}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|