idml 0.8.7 → 0.8.9
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/TODO.pdf/102-anchored-objects.md +40 -36
- data/TODO.pdf/106-page-item-type-coverage.md +37 -7
- data/TODO.pdf/111-footnote-support.md +45 -35
- data/lib/idml/elements/character_style_range.rb +18 -0
- data/lib/idml/elements/footnote.rb +32 -0
- data/lib/idml/elements/graphic_line.rb +3 -0
- data/lib/idml/elements/group.rb +3 -0
- data/lib/idml/elements/oval.rb +3 -0
- data/lib/idml/elements/path.rb +3 -0
- data/lib/idml/elements/polygon.rb +3 -0
- data/lib/idml/elements/rectangle.rb +3 -0
- data/lib/idml/elements/story_inner.rb +2 -0
- data/lib/idml/elements.rb +1 -0
- data/lib/idml/render/contour.rb +110 -0
- data/lib/idml/render/footnote.rb +211 -0
- data/lib/idml/render/renderers/oval_renderer.rb +5 -6
- data/lib/idml/render/renderers/path_renderer.rb +5 -6
- data/lib/idml/render/renderers/polygon_renderer.rb +6 -37
- data/lib/idml/render/renderers/rectangle_renderer.rb +5 -94
- data/lib/idml/render/renderers/text_frame_renderer.rb +117 -28
- data/lib/idml/render/shape_paint.rb +172 -0
- data/lib/idml/render/style_resolver.rb +93 -38
- data/lib/idml/render.rb +3 -0
- data/lib/idml/text_engine/vertical_layout.rb +11 -1
- data/lib/idml/version.rb +1 -1
- metadata +6 -2
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Footnote semantics: story-scoped numbering, body-text marker
|
|
6
|
+
# synthesis, footnote-paragraph extraction, and the geometry of
|
|
7
|
+
# the bottom-of-frame footnote area (height reservation +
|
|
8
|
+
# separator rule). Emission of footnote text lines stays in
|
|
9
|
+
# TextFrameRenderer — it owns all canvas text drawing — while
|
|
10
|
+
# this module owns everything footnote-specific (MECE).
|
|
11
|
+
#
|
|
12
|
+
# Numbering is sequential per story, honoring FootnoteOption's
|
|
13
|
+
# StartAt / Prefix / Suffix when the package declares them.
|
|
14
|
+
# Document-wide continuous numbering and per-section restart
|
|
15
|
+
# are not modeled.
|
|
16
|
+
module Footnote
|
|
17
|
+
# One footnote collected during a frame's layout: its number
|
|
18
|
+
# and its extracted (already marker-prefixed) paragraphs.
|
|
19
|
+
Entry = Struct.new(:number, :paragraphs, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# A positioned footnote line plus the run it came from — the
|
|
22
|
+
# unit TextFrameRenderer emits.
|
|
23
|
+
PositionedRun = Struct.new(:line, :run, :font_size, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# Story-scoped sequential counter. Mutated as csr_runs walks
|
|
26
|
+
# the story's style ranges so footnotes number in document
|
|
27
|
+
# order across paragraphs.
|
|
28
|
+
class Counter
|
|
29
|
+
def initialize(start_at = nil)
|
|
30
|
+
@next = start_at || 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def next_number
|
|
34
|
+
value = @next
|
|
35
|
+
@next += 1
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DEFAULT_RULE_WEIGHT = 0.5
|
|
41
|
+
DEFAULT_RULE_GAP = 3.0
|
|
42
|
+
MARKER_TEXT_SEPARATOR = " "
|
|
43
|
+
|
|
44
|
+
# Returns the package's FootnoteOption (Preferences.xml), or
|
|
45
|
+
# nil when the package doesn't declare one.
|
|
46
|
+
def self.option(package)
|
|
47
|
+
preferences = package&.preferences
|
|
48
|
+
preferences&.footnote_option&.first
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.counter_for(option)
|
|
52
|
+
Counter.new(option&.start_at)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Marker text shown in the body text (and prefixed to the
|
|
56
|
+
# footnote's first paragraph): Prefix + number + Suffix.
|
|
57
|
+
def self.marker_text(number, option)
|
|
58
|
+
"#{option&.prefix}#{number}#{option&.suffix}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Builds the superscript marker run emitted into the body
|
|
62
|
+
# text where the footnote anchors. Inherits the base run's
|
|
63
|
+
# character styling so the marker matches its context; when
|
|
64
|
+
# the owning CSR has no text run, falls back to defaults.
|
|
65
|
+
def self.marker_run(number, base, paragraphs, option)
|
|
66
|
+
marker = base || StyleResolver::StyledRun.new(
|
|
67
|
+
point_size: StyleResolver::DEFAULT_POINT_SIZE,
|
|
68
|
+
)
|
|
69
|
+
marker = marker.dup
|
|
70
|
+
marker.text = marker_text(number, option)
|
|
71
|
+
marker.position = "Superscript"
|
|
72
|
+
marker.footnote_number = number
|
|
73
|
+
marker.footnote_paragraphs = paragraphs
|
|
74
|
+
marker
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Extracts the footnote's paragraphs via StyleResolver and
|
|
78
|
+
# prefixes the first paragraph's first run with the marker
|
|
79
|
+
# text ("1 ", "n2." …) so the footnote reads as numbered.
|
|
80
|
+
def self.extract(element, number, condition_filter: nil,
|
|
81
|
+
style_lookup: nil, option: nil)
|
|
82
|
+
paragraphs = StyleResolver.extract_container_paragraphs(
|
|
83
|
+
element, condition_filter: condition_filter,
|
|
84
|
+
style_lookup: style_lookup
|
|
85
|
+
)
|
|
86
|
+
marker = marker_text(number, option)
|
|
87
|
+
prepend_marker(paragraphs, marker) unless marker.empty?
|
|
88
|
+
paragraphs
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.prepend_marker(paragraphs, marker)
|
|
92
|
+
return if paragraphs.empty?
|
|
93
|
+
return if paragraphs.first.runs.empty?
|
|
94
|
+
|
|
95
|
+
first_run = paragraphs.first.runs.first
|
|
96
|
+
first_run.text = "#{marker}#{MARKER_TEXT_SEPARATOR}#{first_run.text}"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :prepend_marker
|
|
99
|
+
|
|
100
|
+
# Lays out all entries' paragraphs top-down starting at
|
|
101
|
+
# `top_y`. Returns `[positioned_runs, bottom_y]` where
|
|
102
|
+
# bottom_y is the cursor after the last line — the same walk
|
|
103
|
+
# drives both height measurement and rendering, so the
|
|
104
|
+
# reserved area always matches what gets drawn.
|
|
105
|
+
def self.layout_entries(entries, frame, font, top_y, option = nil)
|
|
106
|
+
positioned = []
|
|
107
|
+
cursor = top_y
|
|
108
|
+
wrap_width = TextEngine::VerticalLayout.wrap_width(frame)
|
|
109
|
+
|
|
110
|
+
entries.each_with_index do |entry, index|
|
|
111
|
+
cursor -= (option&.space_between || 0).to_f if index.positive?
|
|
112
|
+
entry.paragraphs.each do |paragraph|
|
|
113
|
+
cursor = layout_paragraph(
|
|
114
|
+
paragraph, frame, font, wrap_width, cursor, positioned
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
[positioned, cursor]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Shapes, wraps, and positions one footnote paragraph,
|
|
122
|
+
# appending its positioned lines. Returns the next cursor.
|
|
123
|
+
def self.layout_paragraph(paragraph, frame, font, wrap_width, cursor,
|
|
124
|
+
positioned)
|
|
125
|
+
paragraph.runs.each do |run|
|
|
126
|
+
size = run.point_size || StyleResolver::DEFAULT_POINT_SIZE
|
|
127
|
+
glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
|
|
128
|
+
size: size)
|
|
129
|
+
lines = TextEngine::LineBreaker.break(glyphs: glyphs,
|
|
130
|
+
frame_width: wrap_width)
|
|
131
|
+
lines.each do |line|
|
|
132
|
+
TextEngine::Justifier.justify(
|
|
133
|
+
line: line, frame_width: wrap_width,
|
|
134
|
+
alignment: paragraph.alignment || :left
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
leading = TextEngine::VerticalLayout.leading_for(
|
|
138
|
+
paragraph.auto_leading, size
|
|
139
|
+
)
|
|
140
|
+
block, cursor = TextEngine::VerticalLayout.layout_block(
|
|
141
|
+
lines: lines, frame: frame, font_size: size, leading: leading,
|
|
142
|
+
cursor_y: cursor
|
|
143
|
+
)
|
|
144
|
+
block.each do |line|
|
|
145
|
+
positioned << PositionedRun.new(line: line, run: run,
|
|
146
|
+
font_size: size)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
cursor
|
|
150
|
+
end
|
|
151
|
+
private_class_method :layout_paragraph
|
|
152
|
+
|
|
153
|
+
# Height to reserve at the frame bottom for the entries:
|
|
154
|
+
# separator gap + the measured paragraph stack.
|
|
155
|
+
def self.reserved_height(entries, font, frame, option = nil)
|
|
156
|
+
return 0.0 if entries.empty?
|
|
157
|
+
|
|
158
|
+
content_bottom = TextEngine::VerticalLayout.bottom_limit(frame)
|
|
159
|
+
_, bottom_y = layout_entries(entries, frame, font, content_bottom,
|
|
160
|
+
option)
|
|
161
|
+
content_bottom - bottom_y + rule_gap(option)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Vertical distance between the footnote area's top edge and
|
|
165
|
+
# the first footnote line — room for the separator rule.
|
|
166
|
+
def self.rule_gap(option)
|
|
167
|
+
option&.spacer || DEFAULT_RULE_GAP
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Emits the separator rule at the top of the footnote area.
|
|
171
|
+
# Honors FootnoteOption RuleOn / RuleLineWeight / RuleWidth /
|
|
172
|
+
# RuleLeftIndent; defaults approximate InDesign (0.5pt rule
|
|
173
|
+
# spanning the column).
|
|
174
|
+
def self.emit_separator(canvas, frame, y, option)
|
|
175
|
+
return if option&.rule_on == false
|
|
176
|
+
|
|
177
|
+
thickness = separator_weight(option)
|
|
178
|
+
return unless thickness.positive?
|
|
179
|
+
|
|
180
|
+
stroke_rule(canvas, separator_left(frame, option), y,
|
|
181
|
+
separator_width(frame, option), thickness)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def self.separator_weight(option)
|
|
185
|
+
(option&.rule_line_weight || DEFAULT_RULE_WEIGHT).to_f
|
|
186
|
+
end
|
|
187
|
+
private_class_method :separator_weight
|
|
188
|
+
|
|
189
|
+
def self.separator_left(frame, option)
|
|
190
|
+
TextEngine::VerticalLayout.frame_left(frame) +
|
|
191
|
+
(option&.rule_left_indent || 0).to_f
|
|
192
|
+
end
|
|
193
|
+
private_class_method :separator_left
|
|
194
|
+
|
|
195
|
+
def self.separator_width(frame, option)
|
|
196
|
+
option&.rule_width ||
|
|
197
|
+
TextEngine::VerticalLayout.wrap_width(frame)
|
|
198
|
+
end
|
|
199
|
+
private_class_method :separator_width
|
|
200
|
+
|
|
201
|
+
def self.stroke_rule(canvas, left, y, width, thickness)
|
|
202
|
+
canvas.stroke_color([:gray, 1.0])
|
|
203
|
+
canvas.line_width = thickness
|
|
204
|
+
canvas.move_to(left, y)
|
|
205
|
+
canvas.line_to(left + width, y)
|
|
206
|
+
canvas.stroke
|
|
207
|
+
end
|
|
208
|
+
private_class_method :stroke_rule
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
|
-
# Renders an IDML Oval on a Pdfrb::Content::Canvas
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# the correct extent and colors. See TODO 106 for refinement.
|
|
6
|
+
# Renders an IDML Oval on a Pdfrb::Content::Canvas as a true
|
|
7
|
+
# Bézier ellipse from its PathGeometry (which preserves any
|
|
8
|
+
# rotation in the ItemTransform). Falls back to the
|
|
9
|
+
# bounding-box rectangle when the item carries no geometry.
|
|
11
10
|
class OvalRenderer
|
|
12
11
|
def self.render(canvas, context)
|
|
13
|
-
|
|
12
|
+
Contour.render(canvas, context)
|
|
14
13
|
end
|
|
15
14
|
end
|
|
16
15
|
end
|
|
@@ -4,14 +4,13 @@ module Idml
|
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
6
|
# Renders an IDML Path (standalone compound path) on a
|
|
7
|
-
# Pdfrb::Content::Canvas
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# correct extent and colors. See TODO 106 for refinement.
|
|
7
|
+
# Pdfrb::Content::Canvas as its actual Bézier contours — one
|
|
8
|
+
# subpath per GeometryPathType, closed or open per PathOpen.
|
|
9
|
+
# Falls back to the bounding-box rectangle when the item
|
|
10
|
+
# carries no geometry.
|
|
12
11
|
class PathRenderer
|
|
13
12
|
def self.render(canvas, context)
|
|
14
|
-
|
|
13
|
+
Contour.render(canvas, context)
|
|
15
14
|
end
|
|
16
15
|
end
|
|
17
16
|
end
|
|
@@ -3,46 +3,15 @@
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
|
+
# Renders an IDML Polygon on a Pdfrb::Content::Canvas as its
|
|
7
|
+
# actual Bézier contours from PathGeometry (control handles
|
|
8
|
+
# default to the anchors, so plain polygons render straight
|
|
9
|
+
# edges). Falls back to the bounding-box rectangle when the
|
|
10
|
+
# item carries no geometry.
|
|
6
11
|
class PolygonRenderer
|
|
7
12
|
def self.render(canvas, context)
|
|
8
|
-
|
|
9
|
-
return if poly.visible == false
|
|
10
|
-
|
|
11
|
-
box = Placement.box(poly, context.page_height)
|
|
12
|
-
return unless box
|
|
13
|
-
|
|
14
|
-
Blending.wrap(canvas, poly.transparency_setting) do
|
|
15
|
-
render_fill(canvas, poly, context, box)
|
|
16
|
-
render_stroke(canvas, poly, context, box)
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def self.render_fill(canvas, poly, context, box)
|
|
21
|
-
return unless poly.fill_color && poly.fill_color != "Color/None"
|
|
22
|
-
|
|
23
|
-
color = context.color_resolver&.resolve(poly.fill_color)
|
|
24
|
-
return unless color
|
|
25
|
-
|
|
26
|
-
canvas.fill_color(ColorHelper.to_canvas(color))
|
|
27
|
-
canvas.rectangle(box[:x], box[:y], box[:width], box[:height])
|
|
28
|
-
canvas.fill
|
|
29
|
-
end
|
|
30
|
-
private_class_method :render_fill
|
|
31
|
-
|
|
32
|
-
def self.render_stroke(canvas, poly, context, box)
|
|
33
|
-
return unless StrokeStyle.strokeable?(poly)
|
|
34
|
-
|
|
35
|
-
color = context.color_resolver&.resolve(poly.stroke_color)
|
|
36
|
-
return unless color
|
|
37
|
-
|
|
38
|
-
StrokeStyle.apply(canvas, poly) do
|
|
39
|
-
canvas.stroke_color(ColorHelper.to_canvas(color))
|
|
40
|
-
canvas.line_width = poly.stroke_weight
|
|
41
|
-
canvas.rectangle(box[:x], box[:y], box[:width], box[:height])
|
|
42
|
-
canvas.stroke
|
|
43
|
-
end
|
|
13
|
+
Contour.render(canvas, context)
|
|
44
14
|
end
|
|
45
|
-
private_class_method :render_stroke
|
|
46
15
|
end
|
|
47
16
|
end
|
|
48
17
|
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
|
-
# Renders an IDML Rectangle on a Pdfrb::Content::Canvas
|
|
6
|
+
# Renders an IDML Rectangle on a Pdfrb::Content::Canvas: an
|
|
7
|
+
# axis-aligned rectangle from the item's placement box, with
|
|
8
|
+
# fill / stroke / gradient painting delegated to ShapePaint.
|
|
7
9
|
class RectangleRenderer
|
|
8
10
|
def self.render(canvas, context)
|
|
9
11
|
rect = context.item
|
|
@@ -12,101 +14,10 @@ module Idml
|
|
|
12
14
|
box = Placement.box(rect, context.page_height)
|
|
13
15
|
return unless box
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
render_stroke(canvas, rect, context, box)
|
|
17
|
+
ShapePaint.paint(canvas, rect, context) do |c|
|
|
18
|
+
c.rectangle(box[:x], box[:y], box[:width], box[:height])
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
|
-
|
|
21
|
-
def self.render_fill(canvas, rect, context, box)
|
|
22
|
-
return unless rect.fill_color
|
|
23
|
-
return if rect.fill_color == "Color/None"
|
|
24
|
-
|
|
25
|
-
if GradientResolver.gradient?(rect.fill_color)
|
|
26
|
-
render_gradient_fill(canvas, rect, context, box)
|
|
27
|
-
return
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
color = context.color_resolver&.resolve(rect.fill_color)
|
|
31
|
-
return unless color
|
|
32
|
-
|
|
33
|
-
canvas.fill_color(ColorHelper.to_canvas(color))
|
|
34
|
-
canvas.rectangle(box[:x], box[:y], box[:width], box[:height])
|
|
35
|
-
canvas.fill
|
|
36
|
-
end
|
|
37
|
-
private_class_method :render_fill
|
|
38
|
-
|
|
39
|
-
def self.render_gradient_fill(canvas, rect, context, box)
|
|
40
|
-
gradient = gradient_for(rect, context)
|
|
41
|
-
return unless gradient
|
|
42
|
-
return unless gradient.gradient_stop.length >= 2
|
|
43
|
-
|
|
44
|
-
stops = gradient_stops(gradient, context)
|
|
45
|
-
return unless stops.length >= 2
|
|
46
|
-
|
|
47
|
-
shading = if gradient.type == "Radial"
|
|
48
|
-
radial_shading(canvas, box, stops)
|
|
49
|
-
else
|
|
50
|
-
axial_shading(canvas, box, stops)
|
|
51
|
-
end
|
|
52
|
-
canvas.rectangle(box[:x], box[:y], box[:width], box[:height])
|
|
53
|
-
canvas.fill_shading(shading)
|
|
54
|
-
end
|
|
55
|
-
private_class_method :render_gradient_fill
|
|
56
|
-
|
|
57
|
-
def self.axial_shading(canvas, box, stops)
|
|
58
|
-
canvas.document.shadings.add_axial(
|
|
59
|
-
from: [box[:x], box[:y] + box[:height]],
|
|
60
|
-
to: [box[:x] + box[:width], box[:y]],
|
|
61
|
-
stops: stops,
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
private_class_method :axial_shading
|
|
65
|
-
|
|
66
|
-
def self.radial_shading(canvas, box, stops)
|
|
67
|
-
cx = box[:x] + (box[:width] / 2.0)
|
|
68
|
-
cy = box[:y] + (box[:height] / 2.0)
|
|
69
|
-
radius = [box[:width], box[:height]].max / 2.0
|
|
70
|
-
canvas.document.shadings.add_radial(
|
|
71
|
-
from: [cx, cy, 0.0],
|
|
72
|
-
to: [cx, cy, radius],
|
|
73
|
-
stops: stops,
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
private_class_method :radial_shading
|
|
77
|
-
|
|
78
|
-
def self.gradient_for(rect, context)
|
|
79
|
-
graphic = context.package&.graphic
|
|
80
|
-
return nil unless graphic
|
|
81
|
-
|
|
82
|
-
graphic.gradient.find { |g| g.self_attr == rect.fill_color }
|
|
83
|
-
end
|
|
84
|
-
private_class_method :gradient_for
|
|
85
|
-
|
|
86
|
-
def self.gradient_stops(gradient, context)
|
|
87
|
-
gradient.gradient_stop.filter_map do |stop|
|
|
88
|
-
color = context.color_resolver&.resolve(stop.stop_color)
|
|
89
|
-
next unless color
|
|
90
|
-
|
|
91
|
-
[stop.location || 0.0, ColorHelper.to_canvas(color)]
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
private_class_method :gradient_stops
|
|
95
|
-
|
|
96
|
-
def self.render_stroke(canvas, rect, context, box)
|
|
97
|
-
return unless StrokeStyle.strokeable?(rect)
|
|
98
|
-
|
|
99
|
-
color = context.color_resolver&.resolve(rect.stroke_color)
|
|
100
|
-
return unless color
|
|
101
|
-
|
|
102
|
-
StrokeStyle.apply(canvas, rect) do
|
|
103
|
-
canvas.stroke_color(ColorHelper.to_canvas(color))
|
|
104
|
-
canvas.line_width = rect.stroke_weight
|
|
105
|
-
canvas.rectangle(box[:x], box[:y], box[:width], box[:height])
|
|
106
|
-
canvas.stroke
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
private_class_method :render_stroke
|
|
110
21
|
end
|
|
111
22
|
end
|
|
112
23
|
end
|