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.
@@ -25,7 +25,6 @@ module Idml
25
25
  # FontMetrics is available (no shaper → no wrap → rough output).
26
26
  class TextFrameRenderer
27
27
  DEFAULT_SIZE = 12.0
28
- LEADING_FACTOR = 1.2
29
28
 
30
29
  def self.render(canvas, context)
31
30
  frame = context.item
@@ -36,6 +35,7 @@ module Idml
36
35
 
37
36
  box = frame_box(frame, context.page_height)
38
37
  render_inline_tables(canvas, story, box, context)
38
+ render_anchored_objects(canvas, story, context)
39
39
 
40
40
  state = initial_chain_state(frame, story, context)
41
41
  return unless state
@@ -72,7 +72,8 @@ module Idml
72
72
  def self.fresh_state(story, context)
73
73
  paragraphs = StyleResolver.extract_paragraphs(
74
74
  story, condition_filter: context.condition_filter,
75
- style_lookup: context.style_lookup
75
+ style_lookup: context.style_lookup,
76
+ footnote_option: Footnote.option(context.package)
76
77
  )
77
78
  return nil if paragraphs.empty?
78
79
 
@@ -102,14 +103,47 @@ module Idml
102
103
  private_class_method :render_inline_tables
103
104
 
104
105
  def self.tables_in_story(story)
106
+ story_csrs(story).flat_map(&:table).compact
107
+ end
108
+ private_class_method :tables_in_story
109
+
110
+ # All CharacterStyleRanges of a story, in document order.
111
+ # Shared by the story-level item discovery walks (tables,
112
+ # anchored objects).
113
+ def self.story_csrs(story)
105
114
  inner = story&.inner
106
115
  return [] unless inner
107
116
 
108
- inner.paragraph_style_range.flat_map do |psr|
109
- psr.character_style_range.flat_map(&:table)
110
- end.compact
117
+ inner.paragraph_style_range.flat_map(&:character_style_range)
111
118
  end
112
- private_class_method :tables_in_story
119
+ private_class_method :story_csrs
120
+
121
+ # Renders anchored page items embedded in the story flow
122
+ # (Story > PSR > CSR > Rectangle/Oval/Polygon/GraphicLine/
123
+ # Group/TextFrame — the IDML structure for anchored objects).
124
+ # Each item renders at its own stored geometry, which
125
+ # InDesign resolves to the anchored position when saving;
126
+ # AnchorType-specific text reflow is not simulated (the
127
+ # layout engine positions text independently either way).
128
+ def self.render_anchored_objects(canvas, story, context)
129
+ anchored_items_in_story(story).each do |item|
130
+ PageItemRenderer.render(canvas, context_for_item(context, item))
131
+ end
132
+ end
133
+ private_class_method :render_anchored_objects
134
+
135
+ def self.anchored_items_in_story(story)
136
+ story_csrs(story).flat_map do |csr|
137
+ [csr.rectangle, csr.oval, csr.polygon,
138
+ csr.graphic_line, csr.group, csr.text_frame]
139
+ end.flatten.compact
140
+ end
141
+ private_class_method :anchored_items_in_story
142
+
143
+ def self.context_for_item(context, item)
144
+ RenderContext.new(**context.to_h, item: item)
145
+ end
146
+ private_class_method :context_for_item
113
147
 
114
148
  def self.render_text(canvas, state, context, box)
115
149
  font = context.font_metrics
@@ -175,19 +209,23 @@ module Idml
175
209
  top_y = layout_frame.y + layout_frame.height -
176
210
  (layout_frame.inset_top || 0)
177
211
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
212
+ footnotes = []
178
213
  cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
179
214
  char_cursor = state.char_cursor
180
215
 
181
216
  if state.current_paragraph
182
217
  cursor_y, char_cursor = resume_paragraph(
183
218
  canvas, state, context, layout_frame, font,
184
- cursor_y, bottom_limit, char_cursor
219
+ cursor_y, bottom_limit, char_cursor, footnotes
185
220
  )
186
- return if state.current_paragraph
187
221
  end
188
222
 
189
- consume_paragraphs(canvas, state, context, layout_frame, font,
190
- cursor_y, bottom_limit, char_cursor)
223
+ unless state.current_paragraph
224
+ consume_paragraphs(canvas, state, context, layout_frame, font,
225
+ cursor_y, bottom_limit, char_cursor, footnotes)
226
+ end
227
+ render_footnote_entries(canvas, footnotes, layout_frame,
228
+ context, font)
191
229
  end
192
230
  private_class_method :engine_render_single_column
193
231
 
@@ -212,19 +250,23 @@ module Idml
212
250
  def self.render_column(canvas, state, context, col_frame, font)
213
251
  top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
214
252
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
253
+ footnotes = []
215
254
  cursor_y = top_y
216
255
  char_cursor = state.char_cursor
217
256
 
218
257
  if state.current_paragraph
219
258
  cursor_y, char_cursor = resume_paragraph(
220
259
  canvas, state, context, col_frame, font,
221
- cursor_y, bottom_limit, char_cursor
260
+ cursor_y, bottom_limit, char_cursor, footnotes
222
261
  )
223
- return if state.current_paragraph
224
262
  end
225
263
 
226
- consume_paragraphs(canvas, state, context, col_frame, font,
227
- cursor_y, bottom_limit, char_cursor)
264
+ unless state.current_paragraph
265
+ consume_paragraphs(canvas, state, context, col_frame, font,
266
+ cursor_y, bottom_limit, char_cursor, footnotes)
267
+ end
268
+ render_footnote_entries(canvas, footnotes, col_frame,
269
+ context, font)
228
270
  end
229
271
  private_class_method :render_column
230
272
 
@@ -343,12 +385,13 @@ module Idml
343
385
  private_class_method :space_before_after
344
386
 
345
387
  def self.resume_paragraph(canvas, state, context, layout_frame, font,
346
- cursor_y, bottom_limit, char_cursor)
388
+ cursor_y, bottom_limit, char_cursor,
389
+ footnotes)
347
390
  paragraph = state.current_paragraph
348
391
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
349
392
  canvas, paragraph, state.runs_remaining, context,
350
393
  layout_frame, font, cursor_y, bottom_limit, char_cursor,
351
- first_paragraph: true
394
+ first_paragraph: true, footnotes: footnotes
352
395
  )
353
396
  if remaining_runs.any?
354
397
  state.runs_remaining = remaining_runs
@@ -361,7 +404,8 @@ module Idml
361
404
  private_class_method :resume_paragraph
362
405
 
363
406
  def self.consume_paragraphs(canvas, state, context, layout_frame, font,
364
- cursor_y, bottom_limit, char_cursor)
407
+ cursor_y, bottom_limit, char_cursor,
408
+ footnotes)
365
409
  pending = 0
366
410
  state.paragraphs.each do |paragraph|
367
411
  break if cursor_y < bottom_limit
@@ -369,7 +413,7 @@ module Idml
369
413
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
370
414
  canvas, paragraph, paragraph.runs, context,
371
415
  layout_frame, font, cursor_y, bottom_limit, char_cursor,
372
- first_paragraph: pending.zero?
416
+ first_paragraph: pending.zero?, footnotes: footnotes
373
417
  )
374
418
  if remaining_runs.any?
375
419
  state.current_paragraph = paragraph
@@ -392,7 +436,7 @@ module Idml
392
436
  def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
393
437
  layout_frame, font, cursor_y,
394
438
  bottom_limit, char_cursor,
395
- first_paragraph:)
439
+ first_paragraph:, footnotes:)
396
440
  wrap_width = TextEngine::VerticalLayout.wrap_width(
397
441
  layout_frame, paragraph.right_indent || 0
398
442
  )
@@ -411,7 +455,7 @@ module Idml
411
455
  rendered, cursor_y, char_cursor = iterate_paragraph_runs(
412
456
  canvas, paragraph, effective_runs, context, layout_frame,
413
457
  font, wrap_width, cursor_y, bottom_limit, char_cursor,
414
- first_paragraph, para_attrs, dc_width, dc_lines
458
+ first_paragraph, para_attrs, dc_width, dc_lines, footnotes
415
459
  )
416
460
 
417
461
  remaining_runs = effective_runs[rendered..]
@@ -427,7 +471,7 @@ module Idml
427
471
  layout_frame, font, wrap_width,
428
472
  cursor_y, bottom_limit, char_cursor,
429
473
  first_paragraph, para_attrs,
430
- dc_width, dc_lines)
474
+ dc_width, dc_lines, footnotes)
431
475
  rendered_count = 0
432
476
  runs.each do |run|
433
477
  break if cursor_y < bottom_limit
@@ -442,16 +486,65 @@ module Idml
442
486
  canvas, run, paragraph, context, layout_frame, font,
443
487
  run_wrap, cursor_y, space_before,
444
488
  para_attrs[:first_line_indent], para_attrs[:left_indent],
445
- char_cursor
489
+ char_cursor, bottom_limit
446
490
  )
447
491
  char_cursor += positioned.length
448
492
  cursor_y = next_y
449
493
  rendered_count += 1
494
+ bottom_limit = register_footnote(footnotes, run, layout_frame,
495
+ font, context, bottom_limit)
450
496
  end
451
497
  [rendered_count, cursor_y, char_cursor]
452
498
  end
453
499
  private_class_method :iterate_paragraph_runs
454
500
 
501
+ # Collects the footnote anchored by a marker run and raises
502
+ # the effective bottom limit to reserve room for all
503
+ # footnotes collected so far. Pass-through for regular runs.
504
+ def self.register_footnote(footnotes, run, layout_frame, font,
505
+ context, bottom_limit)
506
+ return bottom_limit unless run.footnote_paragraphs
507
+
508
+ footnotes << Footnote::Entry.new(
509
+ number: run.footnote_number,
510
+ paragraphs: run.footnote_paragraphs,
511
+ )
512
+ option = Footnote.option(context.package)
513
+ frame_bottom = TextEngine::VerticalLayout.bottom_limit(layout_frame)
514
+ reserved = Footnote.reserved_height(footnotes, font, layout_frame,
515
+ option)
516
+ frame_bottom + reserved
517
+ end
518
+ private_class_method :register_footnote
519
+
520
+ # Renders the collected footnotes at the bottom of the
521
+ # frame: separator rule at the area's top edge, then the
522
+ # footnote paragraphs (marker-prefixed at extraction)
523
+ # flowing downward to the content bottom.
524
+ def self.render_footnote_entries(canvas, footnotes, layout_frame,
525
+ context, font)
526
+ return if footnotes.empty?
527
+
528
+ option = Footnote.option(context.package)
529
+ content_bottom = TextEngine::VerticalLayout.bottom_limit(
530
+ layout_frame,
531
+ )
532
+ area_top = content_bottom +
533
+ Footnote.reserved_height(footnotes, font, layout_frame, option)
534
+
535
+ Footnote.emit_separator(canvas, layout_frame, area_top, option)
536
+ positioned, = Footnote.layout_entries(
537
+ footnotes, layout_frame, font,
538
+ area_top - Footnote.rule_gap(option), option
539
+ )
540
+ positioned.each do |item|
541
+ next if item.line.y < content_bottom
542
+
543
+ emit_line(canvas, item.line, item.run, context, item.font_size)
544
+ end
545
+ end
546
+ private_class_method :render_footnote_entries
547
+
455
548
  # Computes the text-wrap overlap for a run at its current y
456
549
  # position. Uses the run's point_size as the line height
457
550
  # approximation. Returns 0.0 when no resolver is wired or no
@@ -594,7 +687,7 @@ module Idml
594
687
  def self.layout_run(canvas, run, paragraph, context, layout_frame,
595
688
  font, wrap_width, cursor_y, space_before,
596
689
  first_line_indent, left_indent,
597
- char_cursor)
690
+ char_cursor, bottom_limit)
598
691
  size = run.point_size || DEFAULT_SIZE
599
692
  glyphs = TextEngine::Shaper.shape(
600
693
  text: run.text, font: font, size: size,
@@ -621,7 +714,6 @@ module Idml
621
714
  left_indent: left_indent,
622
715
  )
623
716
 
624
- bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
625
717
  positioned.each do |line|
626
718
  break if line.y < bottom_limit
627
719
 
@@ -664,10 +756,7 @@ module Idml
664
756
  private_class_method :emit_line
665
757
 
666
758
  def self.leading_for(paragraph, size)
667
- explicit = paragraph.auto_leading
668
- return size * LEADING_FACTOR unless explicit&.positive?
669
-
670
- size * explicit
759
+ TextEngine::VerticalLayout.leading_for(paragraph.auto_leading, size)
671
760
  end
672
761
  private_class_method :leading_for
673
762
 
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Paints a page item's fill and stroke on a pdfrb canvas,
6
+ # delegating path construction to the caller's block. The
7
+ # single owner of the IDML → PDF paint mapping — solid fill,
8
+ # gradient shading fill, stroke styling — shared by every
9
+ # shape renderer. Callers choose the geometry (axis-aligned
10
+ # rectangle, Bézier contour); this module chooses the paint
11
+ # and the PDF paint op sequencing (fill, stroke, or
12
+ # fill-and-stroke on one path).
13
+ module ShapePaint
14
+ # Paints the item. The block receives the canvas and must
15
+ # construct the current path (move_to/line_to/curve_to/...).
16
+ # The block may be called more than once (a path is consumed
17
+ # by each paint op, so fill-then-stroke rebuilds it).
18
+ # Returns nil when there is nothing to paint; truthy
19
+ # otherwise.
20
+ def self.paint(canvas, item, context, &build_path)
21
+ fill = fill_style(item, context)
22
+ stroke = stroke_style(item, context)
23
+ return unless fill || stroke
24
+
25
+ Blending.wrap(canvas, item.transparency_setting) do
26
+ paint_styles(canvas, item, context, fill, stroke, &build_path)
27
+ end
28
+ end
29
+
30
+ # Returns the fill plan: { kind: :solid, color: } for a
31
+ # resolved color, { kind: :gradient, gradient:, stops: } for
32
+ # a fill referencing a Gradient, nil when nothing fillable.
33
+ def self.fill_style(item, context)
34
+ return nil unless item.fill_color
35
+ return nil if item.fill_color == "Color/None"
36
+
37
+ if GradientResolver.gradient?(item.fill_color)
38
+ gradient_fill_style(item, context)
39
+ else
40
+ solid_fill_style(item, context)
41
+ end
42
+ end
43
+ private_class_method :fill_style
44
+
45
+ def self.gradient_fill_style(item, context)
46
+ gradient = gradient_for(item, context)
47
+ return nil unless gradient
48
+
49
+ stops = gradient_stops(gradient, context)
50
+ return nil unless stops.length >= 2
51
+
52
+ { kind: :gradient, gradient: gradient, stops: stops }
53
+ end
54
+ private_class_method :gradient_fill_style
55
+
56
+ def self.solid_fill_style(item, context)
57
+ color = context.color_resolver&.resolve(item.fill_color)
58
+ return nil unless color
59
+
60
+ { kind: :solid, color: ColorHelper.to_canvas(color) }
61
+ end
62
+ private_class_method :solid_fill_style
63
+
64
+ def self.stroke_style(item, context)
65
+ return nil unless StrokeStyle.strokeable?(item)
66
+
67
+ color = context.color_resolver&.resolve(item.stroke_color)
68
+ return nil unless color
69
+
70
+ { color: ColorHelper.to_canvas(color) }
71
+ end
72
+ private_class_method :stroke_style
73
+
74
+ def self.paint_styles(canvas, item, context, fill, stroke, &)
75
+ if fill && stroke
76
+ paint_fill_and_stroke(canvas, item, context, fill, stroke,
77
+ &)
78
+ elsif fill
79
+ paint_fill(canvas, item, context, fill, &)
80
+ else
81
+ paint_stroke(canvas, item, stroke, &)
82
+ end
83
+ end
84
+ private_class_method :paint_styles
85
+
86
+ def self.paint_fill(canvas, item, context, fill, &)
87
+ yield(canvas)
88
+ if fill[:kind] == :gradient
89
+ canvas.fill_shading(shading_for(canvas, item, context, fill))
90
+ else
91
+ canvas.fill_color(fill[:color])
92
+ canvas.fill
93
+ end
94
+ end
95
+ private_class_method :paint_fill
96
+
97
+ def self.paint_stroke(canvas, item, stroke, &)
98
+ StrokeStyle.apply(canvas, item) do
99
+ canvas.stroke_color(stroke[:color])
100
+ canvas.line_width = item.stroke_weight
101
+ yield(canvas)
102
+ canvas.stroke
103
+ end
104
+ end
105
+ private_class_method :paint_stroke
106
+
107
+ # Fill and stroke on one path where possible (fill_stroke);
108
+ # a gradient fill consumes the path, so the stroke rebuilds
109
+ # it afterward.
110
+ def self.paint_fill_and_stroke(canvas, item, context, fill, stroke,
111
+ &)
112
+ if fill[:kind] == :gradient
113
+ paint_fill(canvas, item, context, fill, &)
114
+ paint_stroke(canvas, item, stroke, &)
115
+ else
116
+ yield(canvas)
117
+ canvas.fill_color(fill[:color])
118
+ StrokeStyle.apply(canvas, item) do
119
+ canvas.stroke_color(stroke[:color])
120
+ canvas.line_width = item.stroke_weight
121
+ canvas.fill_stroke
122
+ end
123
+ end
124
+ end
125
+ private_class_method :paint_fill_and_stroke
126
+
127
+ def self.shading_for(canvas, item, context, fill)
128
+ box = Placement.box(item, context.page_height)
129
+ stops = fill[:stops]
130
+ if fill[:gradient].type == "Radial"
131
+ canvas.document.shadings.add_radial(
132
+ from: radial_center(box, 0.0),
133
+ to: radial_center(box, [box[:width], box[:height]].max / 2.0),
134
+ stops: stops,
135
+ )
136
+ else
137
+ canvas.document.shadings.add_axial(
138
+ from: [box[:x], box[:y] + box[:height]],
139
+ to: [box[:x] + box[:width], box[:y]],
140
+ stops: stops,
141
+ )
142
+ end
143
+ end
144
+ private_class_method :shading_for
145
+
146
+ def self.radial_center(box, radius)
147
+ cx = box[:x] + (box[:width] / 2.0)
148
+ cy = box[:y] + (box[:height] / 2.0)
149
+ [cx, cy, radius]
150
+ end
151
+ private_class_method :radial_center
152
+
153
+ def self.gradient_for(item, context)
154
+ graphic = context.package&.graphic
155
+ return nil unless graphic
156
+
157
+ graphic.gradient.find { |g| g.self_attr == item.fill_color }
158
+ end
159
+ private_class_method :gradient_for
160
+
161
+ def self.gradient_stops(gradient, context)
162
+ gradient.gradient_stop.filter_map do |stop|
163
+ color = context.color_resolver&.resolve(stop.stop_color)
164
+ next unless color
165
+
166
+ [stop.location || 0.0, ColorHelper.to_canvas(color)]
167
+ end
168
+ end
169
+ private_class_method :gradient_stops
170
+ end
171
+ end
172
+ end
@@ -18,7 +18,9 @@ module Idml
18
18
  class StyleResolver
19
19
  # Character-level attributes carried per run. Defaults are
20
20
  # applied at render time (e.g., DEFAULT_POINT_SIZE) — the Struct
21
- # itself stores only what the CSR declares.
21
+ # itself stores only what the CSR declares. `footnote_number`
22
+ # and `footnote_paragraphs` are set only on synthesized
23
+ # footnote-marker runs (see Render::Footnote).
22
24
  StyledRun = Struct.new(
23
25
  :text,
24
26
  :font_style,
@@ -39,6 +41,8 @@ module Idml
39
41
  :horizontal_scale,
40
42
  :vertical_scale,
41
43
  :baseline_shift,
44
+ :footnote_number,
45
+ :footnote_paragraphs,
42
46
  keyword_init: true,
43
47
  )
44
48
 
@@ -98,27 +102,48 @@ module Idml
98
102
 
99
103
  # Returns the flat run list for a story. Each run carries only
100
104
  # character-level attributes. Paragraph boundaries are lost.
105
+ # Footnote markers number sequentially across the story.
101
106
  def self.extract_runs(story)
102
107
  return [] unless story&.inner
103
108
 
109
+ counter = Footnote.counter_for(nil)
104
110
  story.inner.paragraph_style_range.flat_map do |psr|
105
- csr_runs(psr)
111
+ csr_runs(psr, footnote_counter: counter)
106
112
  end
107
113
  end
108
114
 
109
115
  # Returns the paragraph list for a story. Each paragraph groups
110
116
  # its runs and carries the paragraph-level attributes from the
111
117
  # owning PSR. Empty paragraphs (no runs after filtering) are
112
- # skipped.
118
+ # skipped. Footnotes anchored in the story emit superscript
119
+ # marker runs, numbered sequentially from the FootnoteOption's
120
+ # StartAt (default 1).
113
121
  def self.extract_paragraphs(story, condition_filter: nil,
114
- style_lookup: nil)
122
+ style_lookup: nil, footnote_option: nil)
115
123
  return [] unless story&.inner
116
124
 
117
- story.inner.paragraph_style_range.filter_map do |psr|
125
+ extract_container_paragraphs(
126
+ story.inner,
127
+ condition_filter: condition_filter, style_lookup: style_lookup,
128
+ footnote_option: footnote_option
129
+ )
130
+ end
131
+
132
+ # Extracts paragraphs from any container exposing
133
+ # `paragraph_style_range` (StoryInner, Elements::Footnote).
134
+ # Footnote-counter state is shared across the whole walk so
135
+ # footnotes number in document order.
136
+ def self.extract_container_paragraphs(container, condition_filter: nil,
137
+ style_lookup: nil,
138
+ footnote_option: nil)
139
+ counter = Footnote.counter_for(footnote_option)
140
+ container.paragraph_style_range.filter_map do |psr|
118
141
  next unless condition_visible?(psr, condition_filter)
119
142
 
120
143
  runs = csr_runs(psr, condition_filter: condition_filter,
121
- style_lookup: style_lookup)
144
+ style_lookup: style_lookup,
145
+ footnote_counter: counter,
146
+ footnote_option: footnote_option)
122
147
  next if runs.empty?
123
148
 
124
149
  paragraph = Paragraph.new(
@@ -177,43 +202,73 @@ module Idml
177
202
  end
178
203
  end
179
204
 
180
- def self.csr_runs(psr, condition_filter: nil, style_lookup: nil)
181
- psr.character_style_range.filter_map do |csr|
182
- next unless condition_visible?(csr, condition_filter)
205
+ def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
206
+ footnote_counter: nil, footnote_option: nil)
207
+ counter = footnote_counter || Footnote.counter_for(footnote_option)
208
+ psr.character_style_range.flat_map do |csr|
209
+ next [] unless condition_visible?(csr, condition_filter)
183
210
 
184
- text = csr.text_content
185
- next if text.nil? || text.empty?
211
+ csr_content_runs(csr, condition_filter: condition_filter,
212
+ style_lookup: style_lookup,
213
+ footnote_counter: counter,
214
+ footnote_option: footnote_option)
215
+ end
216
+ end
186
217
 
187
- StyledRun.new(
188
- text: text,
189
- font_style: resolve_csr(style_lookup, csr, :font_style),
190
- point_size: resolve_csr(style_lookup, csr, :point_size) ||
191
- DEFAULT_POINT_SIZE,
192
- fill_color: resolve_csr(style_lookup, csr, :fill_color),
193
- fill_tint: resolve_csr(style_lookup, csr, :fill_tint),
194
- applied_font: resolve_csr(style_lookup, csr, :applied_font),
195
- alignment: alignment_for(csr),
196
- tracking: resolve_csr(style_lookup, csr, :tracking),
197
- capitalization: resolve_csr(style_lookup, csr, :capitalization),
198
- position: resolve_csr(style_lookup, csr, :position),
199
- underline: resolve_csr(style_lookup, csr, :underline),
200
- underline_offset: resolve_csr(style_lookup, csr,
201
- :underline_offset),
202
- underline_weight: resolve_csr(style_lookup, csr,
203
- :underline_weight),
204
- strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
205
- strike_through_offset: resolve_csr(style_lookup, csr,
206
- :strike_through_offset),
207
- strike_through_weight: resolve_csr(style_lookup, csr,
208
- :strike_through_weight),
209
- horizontal_scale: resolve_csr(style_lookup, csr,
210
- :horizontal_scale),
211
- vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
212
- baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
218
+ # Builds the runs for one CSR: its text run (when non-empty)
219
+ # plus one superscript marker run per anchored Footnote. A CSR
220
+ # holding only a footnote still emits its marker.
221
+ def self.csr_content_runs(csr, condition_filter:, style_lookup:,
222
+ footnote_counter:, footnote_option:)
223
+ text = csr.text_content
224
+ runs = []
225
+ unless text.nil? || text.empty?
226
+ runs << text_run(csr, style_lookup: style_lookup)
227
+ end
228
+ csr.footnote.each do |element|
229
+ number = footnote_counter.next_number
230
+ paragraphs = Footnote.extract(
231
+ element, number, condition_filter: condition_filter,
232
+ style_lookup: style_lookup,
233
+ option: footnote_option
213
234
  )
235
+ runs << Footnote.marker_run(number, runs.last, paragraphs,
236
+ footnote_option)
214
237
  end
238
+ runs
239
+ end
240
+ private_class_method :csr_content_runs
241
+
242
+ def self.text_run(csr, style_lookup:)
243
+ StyledRun.new(
244
+ text: csr.text_content,
245
+ font_style: resolve_csr(style_lookup, csr, :font_style),
246
+ point_size: resolve_csr(style_lookup, csr, :point_size) ||
247
+ DEFAULT_POINT_SIZE,
248
+ fill_color: resolve_csr(style_lookup, csr, :fill_color),
249
+ fill_tint: resolve_csr(style_lookup, csr, :fill_tint),
250
+ applied_font: resolve_csr(style_lookup, csr, :applied_font),
251
+ alignment: alignment_for(csr),
252
+ tracking: resolve_csr(style_lookup, csr, :tracking),
253
+ capitalization: resolve_csr(style_lookup, csr, :capitalization),
254
+ position: resolve_csr(style_lookup, csr, :position),
255
+ underline: resolve_csr(style_lookup, csr, :underline),
256
+ underline_offset: resolve_csr(style_lookup, csr,
257
+ :underline_offset),
258
+ underline_weight: resolve_csr(style_lookup, csr,
259
+ :underline_weight),
260
+ strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
261
+ strike_through_offset: resolve_csr(style_lookup, csr,
262
+ :strike_through_offset),
263
+ strike_through_weight: resolve_csr(style_lookup, csr,
264
+ :strike_through_weight),
265
+ horizontal_scale: resolve_csr(style_lookup, csr,
266
+ :horizontal_scale),
267
+ vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
268
+ baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
269
+ )
215
270
  end
216
- private_class_method :csr_runs
271
+ private_class_method :text_run
217
272
 
218
273
  # Resolves a paragraph-level attribute from PSR or its
219
274
  # referenced ParagraphStyle. When style_lookup is nil,
data/lib/idml/render.rb CHANGED
@@ -22,6 +22,9 @@ module Idml
22
22
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
23
23
  autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
24
24
  autoload :TextWrapResolver, "#{__dir__}/render/text_wrap_resolver"
25
+ autoload :Footnote, "#{__dir__}/render/footnote"
26
+ autoload :ShapePaint, "#{__dir__}/render/shape_paint"
27
+ autoload :Contour, "#{__dir__}/render/contour"
25
28
  autoload :Placement, "#{__dir__}/render/placement"
26
29
  autoload :ImageCollector, "#{__dir__}/render/image_collector"
27
30
  autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
@@ -54,6 +54,17 @@ module Idml
54
54
  frame.y + (frame.inset_bottom || 0)
55
55
  end
56
56
 
57
+ # Leading for a text block: AutoLeading × size when the
58
+ # paragraph declares a positive AutoLeading, else the default
59
+ # factor. Shared by the frame renderer and the footnote
60
+ # layout so both stack lines identically.
61
+ def self.leading_for(auto_leading, font_size)
62
+ return font_size * DEFAULT_LEADING_FACTOR unless
63
+ auto_leading&.positive?
64
+
65
+ font_size * auto_leading
66
+ end
67
+
57
68
  # Effective wrap width after subtracting insets and indents.
58
69
  def self.wrap_width(frame, right_indent = 0)
59
70
  width = frame.width - (frame.inset_left || 0) - (frame.inset_right || 0)
@@ -63,7 +74,6 @@ module Idml
63
74
  def self.frame_left(frame)
64
75
  frame.x + (frame.inset_left || 0)
65
76
  end
66
- private_class_method :frame_left
67
77
  end
68
78
  end
69
79
  end
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.8.7"
4
+ VERSION = "0.8.9"
5
5
  end