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.
@@ -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. Delegates to
7
- # RectangleRenderer which draws the bounding box with fill/stroke.
8
- # Approximation: a true ellipse would generate bezier anchor and
9
- # control points from the PathGeometry; the bounding box renders
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
- RectangleRenderer.render(canvas, context)
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. Delegates to RectangleRenderer which
8
- # draws the bounding box with fill/stroke. Approximation: a true
9
- # compound path would draw each sub-path from the EntirePath
10
- # list or PathPoint children; the bounding box renders the
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
- RectangleRenderer.render(canvas, context)
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
- poly = context.item
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
- Blending.wrap(canvas, rect.transparency_setting) do
16
- render_fill(canvas, rect, context, box)
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