idml 0.6.2 → 0.7.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.
@@ -3,11 +3,26 @@
3
3
  module Idml
4
4
  module Render
5
5
  module Renderers
6
- # Renders an IDML TextFrame on a Pdfrb::Content::Canvas. Extracts
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.
6
+ # Renders an IDML TextFrame on a Pdfrb::Content::Canvas.
7
+ #
8
+ # Pipeline:
9
+ #
10
+ # 1. Resolve the linked Story via Package#story_by_id.
11
+ # 2. Extract `Paragraph`s via StyleResolver (carries PSR
12
+ # attributes like SpaceBefore, FirstLineIndent, AutoLeading
13
+ # alongside the CSR runs).
14
+ # 3. Build a layout `Frame` from the TextFrame's geometric
15
+ # bounds plus TextFramePreference insets.
16
+ # 4. For each paragraph: shape glyphs (Shaper), wrap to the
17
+ # inset/indent-adjusted width (LineBreaker), align (Justifier),
18
+ # position vertically (VerticalLayout), and emit one
19
+ # `canvas.text` per line. Tracks the y cursor across
20
+ # paragraphs and runs so text flows top-to-bottom.
21
+ # 5. Records each positioned line in PositionTracker so
22
+ # HyperlinkEmitter can compute precise per-source link rects.
23
+ #
24
+ # Falls back to a single `canvas.text_rich` per frame when no
25
+ # FontMetrics is available (no shaper → no wrap → rough output).
11
26
  class TextFrameRenderer
12
27
  DEFAULT_SIZE = 12.0
13
28
  LEADING_FACTOR = 1.2
@@ -23,10 +38,10 @@ module Idml
23
38
  box = frame_box(frame, context.page_height)
24
39
  render_inline_tables(canvas, story, box, context)
25
40
 
26
- runs = StyleResolver.extract_runs(story)
27
- return if runs.empty?
41
+ paragraphs = StyleResolver.extract_paragraphs(story)
42
+ return if paragraphs.empty?
28
43
 
29
- render_text(canvas, runs, context, box)
44
+ render_text(canvas, paragraphs, context, box)
30
45
  end
31
46
 
32
47
  # Discovers Tables inlined in the story (Story > PSR > CSR >
@@ -55,12 +70,12 @@ module Idml
55
70
  end
56
71
  private_class_method :tables_in_story
57
72
 
58
- def self.render_text(canvas, runs, context, box)
73
+ def self.render_text(canvas, paragraphs, context, box)
59
74
  font = context.font_metrics
60
75
  if font
61
- engine_render(canvas, runs, context, box, font)
76
+ engine_render(canvas, paragraphs, context, box, font)
62
77
  else
63
- simple_render(canvas, runs, context, box)
78
+ simple_render(canvas, paragraphs, context, box)
64
79
  end
65
80
  end
66
81
  private_class_method :render_text
@@ -75,58 +90,201 @@ module Idml
75
90
  end
76
91
  private_class_method :frame_box
77
92
 
78
- def self.engine_render(canvas, runs, context, box, font)
93
+ # Builds a layout Frame from the TextFrame's PDF-placement
94
+ # box plus any TextFramePreference insets. Inset values fall
95
+ # back to 0 when the preference is absent or doesn't declare
96
+ # them.
97
+ def self.layout_frame(frame_item, box)
98
+ pref = frame_item.text_frame_preference&.first
99
+ TextEngine::Frame.new(
100
+ x: box[:x],
101
+ y: box[:y],
102
+ width: box[:width],
103
+ height: box[:height],
104
+ inset_top: pref&.inset_top,
105
+ inset_bottom: pref&.inset_bottom,
106
+ inset_left: pref&.inset_left,
107
+ inset_right: pref&.inset_right,
108
+ )
109
+ end
110
+ private_class_method :layout_frame
111
+
112
+ # Walks paragraphs and emits one `canvas.text` per line via
113
+ # the Shaper → LineBreaker → Justifier → VerticalLayout
114
+ # pipeline. Cursor y descends monotonically across
115
+ # paragraphs so text flows top-to-bottom (the previous
116
+ # implementation reset every run to the frame top).
117
+ def self.engine_render(canvas, paragraphs, context, box, font)
118
+ layout_frame = layout_frame(context.item, box)
119
+ cursor_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
120
+ bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
79
121
  char_cursor = 0
80
- runs.each do |run|
81
- char_cursor = render_run_lines(
82
- canvas, run, context, box, font, char_cursor
122
+
123
+ paragraphs.each do |paragraph|
124
+ break if cursor_y < bottom_limit
125
+
126
+ cursor_y, char_cursor = render_paragraph(
127
+ canvas, paragraph, context, layout_frame, font,
128
+ cursor_y, bottom_limit, char_cursor
83
129
  )
84
130
  end
85
131
  end
86
132
  private_class_method :engine_render
87
133
 
88
- # Emits one `canvas.text` call per line, after shaping and
89
- # line-breaking with real font metrics. Applies paragraph
90
- # alignment via `Justifier` so each line is offset within
91
- # the frame box per IDML `Justification`. Lines that fall
92
- # below the frame's bottom edge are clipped.
93
- #
94
- # Tracks absolute character position so HyperlinkEmitter can
95
- # compute precise link rects per source range.
96
- def self.render_run_lines(canvas, run, context, box, font, char_cursor)
97
- size = run.point_size
134
+ def self.render_paragraph(canvas, paragraph, context, layout_frame,
135
+ font, cursor_y, bottom_limit, char_cursor)
136
+ wrap_width = TextEngine::VerticalLayout.wrap_width(
137
+ layout_frame, paragraph.right_indent || 0
138
+ )
139
+ para_attrs = paragraph_attrs(paragraph)
140
+ frame_left, frame_right = paragraph_frame_extents(layout_frame)
141
+
142
+ # RuleAbove sits at the paragraph's top edge (before
143
+ # space_before is subtracted), so emit it first using the
144
+ # incoming cursor_y as the anchor.
145
+ ParagraphRules.emit_rule_above(canvas, paragraph, context,
146
+ cursor_y, frame_left, frame_right)
147
+
148
+ cursor_y, char_cursor = render_runs(
149
+ canvas, paragraph, context, layout_frame, font,
150
+ wrap_width, cursor_y, bottom_limit, char_cursor, para_attrs
151
+ )
152
+ after_y = cursor_y - para_attrs[:space_after]
153
+
154
+ # RuleBelow sits at the paragraph's bottom edge (after the
155
+ # last line + space_after), so emit it last using after_y
156
+ # as the anchor.
157
+ ParagraphRules.emit_rule_below(canvas, paragraph, context,
158
+ after_y, frame_left, frame_right)
159
+ [after_y, char_cursor]
160
+ end
161
+ private_class_method :render_paragraph
162
+
163
+ def self.paragraph_frame_extents(layout_frame)
164
+ inset_left = layout_frame.inset_left || 0
165
+ inset_right = layout_frame.inset_right || 0
166
+ [layout_frame.x + inset_left,
167
+ layout_frame.x + layout_frame.width - inset_right]
168
+ end
169
+ private_class_method :paragraph_frame_extents
170
+
171
+ def self.paragraph_attrs(paragraph)
172
+ {
173
+ space_before: paragraph.space_before || 0,
174
+ space_after: paragraph.space_after || 0,
175
+ first_line_indent: paragraph.first_line_indent || 0,
176
+ left_indent: paragraph.left_indent || 0,
177
+ }
178
+ end
179
+ private_class_method :paragraph_attrs
180
+
181
+ def self.render_runs(canvas, paragraph, context, layout_frame, font,
182
+ wrap_width, cursor_y, bottom_limit, char_cursor,
183
+ para_attrs)
184
+ space_before = para_attrs[:space_before]
185
+ paragraph.runs.each do |run|
186
+ break if cursor_y < bottom_limit
187
+
188
+ positioned, next_y = layout_run(
189
+ canvas, run, paragraph, context, layout_frame, font,
190
+ wrap_width, cursor_y, space_before,
191
+ para_attrs[:first_line_indent], para_attrs[:left_indent],
192
+ char_cursor
193
+ )
194
+ char_cursor += positioned.length
195
+ cursor_y = next_y
196
+ space_before = 0
197
+ end
198
+ [cursor_y, char_cursor]
199
+ end
200
+ private_class_method :render_runs
201
+
202
+ # Shapes one run's text, wraps to the inset/indent-adjusted
203
+ # width, justifies per paragraph alignment, and asks
204
+ # VerticalLayout to position the lines. Emits each line via
205
+ # `canvas.text` and records positions for hyperlink tracking.
206
+ # Returns `[positioned_lines, next_y]`.
207
+ def self.layout_run(canvas, run, paragraph, context, layout_frame,
208
+ font, wrap_width, cursor_y, space_before,
209
+ first_line_indent, left_indent,
210
+ char_cursor)
211
+ size = run.point_size || DEFAULT_SIZE
98
212
  glyphs = TextEngine::Shaper.shape(
99
213
  text: run.text, font: font, size: size,
100
214
  )
101
215
  lines = TextEngine::LineBreaker.break(
102
- glyphs: glyphs, frame_width: box[:width],
216
+ glyphs: glyphs, frame_width: wrap_width,
217
+ )
218
+ lines.each do |line|
219
+ TextEngine::Justifier.justify(
220
+ line: line, frame_width: wrap_width,
221
+ alignment: paragraph.alignment || :left
222
+ )
223
+ end
224
+ leading = leading_for(paragraph, size)
225
+
226
+ positioned, next_y = TextEngine::VerticalLayout.layout_block(
227
+ lines: lines,
228
+ frame: layout_frame,
229
+ font_size: size,
230
+ leading: leading,
231
+ cursor_y: cursor_y,
232
+ space_before: space_before,
233
+ first_line_indent: first_line_indent,
234
+ left_indent: left_indent,
235
+ )
236
+
237
+ bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
238
+ positioned.each do |line|
239
+ break if line.y < bottom_limit
240
+
241
+ emit_line(canvas, line, run, context, size)
242
+ record_position(context, line, size, char_cursor)
243
+ end
244
+ [positioned, next_y]
245
+ end
246
+ private_class_method :layout_run
247
+
248
+ # Emits one positioned line: applies character-level styling
249
+ # (fill color + tint, capitalization, position, tracking,
250
+ # glyph scaling, baseline shift) via CharacterStyle, then
251
+ # draws underline/strike-through rules after the text.
252
+ def self.emit_line(canvas, line, run, context, size)
253
+ text = CharacterStyle.transform_text(line_text(line),
254
+ run.capitalization)
255
+ scaled_size, position_offset = CharacterStyle.position_scale(
256
+ run.position, size
103
257
  )
104
- alignment = run.alignment || :left
105
- start_y = box[:y] + box[:height] - size
106
- cursor = char_cursor
107
-
108
- lines.each_with_index do |line, idx|
109
- line_y = start_y - (idx * size * LEADING_FACTOR)
110
- break if line_y < box[:y]
111
-
112
- TextEngine::Justifier.justify(line: line,
113
- frame_width: box[:width],
114
- alignment: alignment)
115
- line_x = box[:x] + line.x_offset
116
- line_width = line.width
117
- canvas.text(line_text(line),
118
- at: [line_x, line_y],
119
- font: font_for_run(run, context),
120
- size: size)
121
- record_position(context, line, line_x, line_y, line_width, size,
122
- cursor)
123
- cursor += line.glyphs.length
258
+ baseline_offset = CharacterStyle.baseline_offset(run) +
259
+ position_offset
260
+ text_at = [line.x, line.y + baseline_offset]
261
+ text_kwargs = CharacterStyle.text_kwargs(
262
+ run,
263
+ {
264
+ at: text_at,
265
+ font: font_for_run(run, context),
266
+ size: scaled_size,
267
+ },
268
+ )
269
+ CharacterStyle.apply(canvas, run, context,
270
+ x: line.x, y: line.y + baseline_offset,
271
+ width: line.width, size: scaled_size) do
272
+ CharacterStyle.with_glyph_scaling(canvas, run) do
273
+ canvas.text(text, **text_kwargs)
274
+ end
124
275
  end
125
- cursor
126
276
  end
127
- private_class_method :render_run_lines
277
+ private_class_method :emit_line
278
+
279
+ def self.leading_for(paragraph, size)
280
+ explicit = paragraph.auto_leading
281
+ return size * LEADING_FACTOR unless explicit&.positive?
282
+
283
+ size * explicit
284
+ end
285
+ private_class_method :leading_for
128
286
 
129
- def self.record_position(context, line, x, y, width, height, cursor)
287
+ def self.record_position(context, line, height, cursor)
130
288
  tracker = context.position_tracker
131
289
  return unless tracker
132
290
  return unless context.item&.self_attr
@@ -135,9 +293,9 @@ module Idml
135
293
  tracker.add(context.item.self_attr,
136
294
  start_char: cursor,
137
295
  end_char: cursor + glyph_count,
138
- x: x,
139
- y: y,
140
- width: width,
296
+ x: line.x,
297
+ y: line.y,
298
+ width: line.width,
141
299
  height: height)
142
300
  end
143
301
  private_class_method :record_position
@@ -146,7 +304,7 @@ module Idml
146
304
  # run's `applied_font` (family name from CSR's AppliedFont)
147
305
  # against the document's font_map. Falls back to the document
148
306
  # default when no per-run font is specified or the family
149
- # isn registered.
307
+ # isn't registered.
150
308
  def self.font_for_run(run, context)
151
309
  family = run.applied_font
152
310
  return context.font_ps_name unless family
@@ -163,12 +321,12 @@ module Idml
163
321
 
164
322
  # Fallback when no metrics are available: emit all runs as
165
323
  # one `text_rich` block, letting pdfrb measure advance widths.
166
- # Uses pdfrb's measurement API directly (no Fontisan).
167
- def self.simple_render(canvas, runs, context, box)
168
- runs_for = build_rich_runs(runs, context)
169
- return if runs_for.empty?
324
+ def self.simple_render(canvas, paragraphs, context, box)
325
+ runs = paragraphs.flat_map(&:runs)
326
+ return if runs.empty?
170
327
 
171
- first_size = runs.first.point_size
328
+ runs_for = build_rich_runs(runs, context)
329
+ first_size = runs.first.point_size || DEFAULT_SIZE
172
330
  canvas.text_rich(
173
331
  runs_for,
174
332
  at: [box[:x], box[:y] + box[:height] - first_size],
@@ -181,7 +339,7 @@ module Idml
181
339
  {
182
340
  text: run.text,
183
341
  font: font_for_run(run, context),
184
- size: run.point_size,
342
+ size: run.point_size || DEFAULT_SIZE,
185
343
  }
186
344
  end
187
345
  end
@@ -2,15 +2,77 @@
2
2
 
3
3
  module Idml
4
4
  module Render
5
- # Extracts styled text runs from an IDML Story. Each run carries the
6
- # text content plus the CharacterStyleRange attributes that affect
7
- # rendering (font style, size, fill color, applied font reference,
8
- # paragraph alignment).
5
+ # Extracts styled text from an IDML Story in two granularities:
6
+ #
7
+ # 1. `extract_runs` flat list of `StyledRun` (character-level
8
+ # attributes only). Useful when paragraph boundaries don't
9
+ # matter (e.g., simple-render fallback).
10
+ # 2. `extract_paragraphs` — list of `Paragraph` structs, each
11
+ # carrying paragraph-level attributes (spacing, indents,
12
+ # leading) plus its runs. Used by the layout engine so the
13
+ # renderer can apply SpaceBefore / FirstLineIndent / etc.
14
+ #
15
+ # Both methods read the same source (PSR → CSR → Content) so the
16
+ # text content is identical; only the grouping and attribute
17
+ # propagation differ.
9
18
  class StyleResolver
19
+ # Character-level attributes carried per run. Defaults are
20
+ # applied at render time (e.g., DEFAULT_POINT_SIZE) — the Struct
21
+ # itself stores only what the CSR declares.
10
22
  StyledRun = Struct.new(
11
- :text, :font_style, :point_size,
12
- :fill_color, :fill_tint, :applied_font, :alignment,
13
- keyword_init: true
23
+ :text,
24
+ :font_style,
25
+ :point_size,
26
+ :fill_color,
27
+ :fill_tint,
28
+ :applied_font,
29
+ :alignment,
30
+ :tracking,
31
+ :capitalization,
32
+ :position,
33
+ :underline,
34
+ :underline_offset,
35
+ :underline_weight,
36
+ :strike_thru,
37
+ :strike_through_offset,
38
+ :strike_through_weight,
39
+ :horizontal_scale,
40
+ :vertical_scale,
41
+ :baseline_shift,
42
+ keyword_init: true,
43
+ )
44
+
45
+ # Paragraph-level attributes carried alongside a run list.
46
+ # Each attribute comes from the owning PSR and is nil when the
47
+ # PSR doesn't declare it.
48
+ Paragraph = Struct.new(
49
+ :runs,
50
+ :alignment,
51
+ :space_before,
52
+ :space_after,
53
+ :first_line_indent,
54
+ :left_indent,
55
+ :right_indent,
56
+ :auto_leading,
57
+ # RuleAbove / RuleBelow — paragraph rules (horizontal lines
58
+ # above/below the paragraph block). Driven by PSR attributes.
59
+ :rule_above,
60
+ :rule_above_line_weight,
61
+ :rule_above_color,
62
+ :rule_above_tint,
63
+ :rule_above_offset,
64
+ :rule_above_left_indent,
65
+ :rule_above_right_indent,
66
+ :rule_above_width,
67
+ :rule_below,
68
+ :rule_below_line_weight,
69
+ :rule_below_color,
70
+ :rule_below_tint,
71
+ :rule_below_offset,
72
+ :rule_below_left_indent,
73
+ :rule_below_right_indent,
74
+ :rule_below_width,
75
+ keyword_init: true,
14
76
  )
15
77
 
16
78
  DEFAULT_POINT_SIZE = 12.0
@@ -28,6 +90,8 @@ module Idml
28
90
  "ToBinding" => :left,
29
91
  }.freeze
30
92
 
93
+ # Returns the flat run list for a story. Each run carries only
94
+ # character-level attributes. Paragraph boundaries are lost.
31
95
  def self.extract_runs(story)
32
96
  return [] unless story&.inner
33
97
 
@@ -36,6 +100,46 @@ module Idml
36
100
  end
37
101
  end
38
102
 
103
+ # Returns the paragraph list for a story. Each paragraph groups
104
+ # its runs and carries the paragraph-level attributes from the
105
+ # owning PSR. Empty paragraphs (no runs after filtering) are
106
+ # skipped.
107
+ def self.extract_paragraphs(story)
108
+ return [] unless story&.inner
109
+
110
+ story.inner.paragraph_style_range.filter_map do |psr|
111
+ runs = csr_runs(psr)
112
+ next if runs.empty?
113
+
114
+ Paragraph.new(
115
+ runs: runs,
116
+ alignment: runs.first.alignment,
117
+ space_before: psr.space_before,
118
+ space_after: psr.space_after,
119
+ first_line_indent: psr.first_line_indent,
120
+ left_indent: psr.left_indent,
121
+ right_indent: psr.right_indent,
122
+ auto_leading: psr.auto_leading,
123
+ rule_above: psr.rule_above,
124
+ rule_above_line_weight: psr.rule_above_line_weight,
125
+ rule_above_color: psr.stroke_color,
126
+ rule_above_tint: psr.rule_above_tint,
127
+ rule_above_offset: psr.rule_above_offset,
128
+ rule_above_left_indent: psr.rule_above_left_indent,
129
+ rule_above_right_indent: psr.rule_above_right_indent,
130
+ rule_above_width: psr.rule_above_width,
131
+ rule_below: psr.rule_below,
132
+ rule_below_line_weight: psr.rule_below_line_weight,
133
+ rule_below_color: psr.stroke_color,
134
+ rule_below_tint: psr.rule_below_tint,
135
+ rule_below_offset: psr.rule_below_offset,
136
+ rule_below_left_indent: psr.rule_below_left_indent,
137
+ rule_below_right_indent: psr.rule_below_right_indent,
138
+ rule_below_width: psr.rule_below_width,
139
+ )
140
+ end
141
+ end
142
+
39
143
  def self.csr_runs(psr)
40
144
  psr.character_style_range.filter_map do |csr|
41
145
  text = csr.text_content
@@ -49,6 +153,18 @@ module Idml
49
153
  fill_tint: csr.fill_tint,
50
154
  applied_font: csr.applied_font,
51
155
  alignment: alignment_for(csr),
156
+ tracking: csr.tracking,
157
+ capitalization: csr.capitalization,
158
+ position: csr.position,
159
+ underline: csr.underline,
160
+ underline_offset: csr.underline_offset,
161
+ underline_weight: csr.underline_weight,
162
+ strike_thru: csr.strike_thru,
163
+ strike_through_offset: csr.strike_through_offset,
164
+ strike_through_weight: csr.strike_through_weight,
165
+ horizontal_scale: csr.horizontal_scale,
166
+ vertical_scale: csr.vertical_scale,
167
+ baseline_shift: csr.baseline_shift,
52
168
  )
53
169
  end
54
170
  end
data/lib/idml/render.rb CHANGED
@@ -13,6 +13,8 @@ module Idml
13
13
  autoload :GradientResolver, "#{__dir__}/render/gradient_resolver"
14
14
  autoload :Blending, "#{__dir__}/render/blending"
15
15
  autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
16
+ autoload :CharacterStyle, "#{__dir__}/render/character_style"
17
+ autoload :ParagraphRules, "#{__dir__}/render/paragraph_rules"
16
18
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
17
19
  autoload :Placement, "#{__dir__}/render/placement"
18
20
  autoload :ImageCollector, "#{__dir__}/render/image_collector"
@@ -2,63 +2,68 @@
2
2
 
3
3
  module Idml
4
4
  module TextEngine
5
- # A glyph with absolute (x, y) position, ready for rendering.
6
- PositionedGlyph = Struct.new(:codepoint, :x, :y, :font_size)
5
+ # Geometry of a text frame as seen by the layout engine: outer
6
+ # bounds plus the four inset margins. Inset values are nil when
7
+ # the TextFramePreference doesn't declare them.
8
+ Frame = Struct.new(
9
+ :x, :y, :width, :height,
10
+ :inset_top, :inset_bottom, :inset_left, :inset_right,
11
+ keyword_init: true
12
+ )
7
13
 
8
- # Positions justified lines vertically within a text frame.
9
- # Applies leading, paragraph spacing, first-line indent, and
10
- # left/right insets.
11
- class VerticalLayout
12
- Frame = Struct.new(:x, :y, :width, :height,
13
- :inset_top, :inset_bottom,
14
- :inset_left, :inset_right)
14
+ # A line that's been positioned absolutely within a frame.
15
+ # `glyphs` is the original ShapedGlyph array (carrying widths);
16
+ # `x`, `y` is the left edge / baseline; `width` is the line's
17
+ # natural width after justification.
18
+ PositionedLine = Struct.new(:glyphs, :x, :y, :width, keyword_init: true)
15
19
 
16
- def self.layout(lines:, frame:, font_size:,
17
- leading: nil, space_before: 0, space_after: 0,
18
- first_line_indent: 0, left_indent: 0)
19
- new(frame, font_size, leading, space_before, space_after,
20
- first_line_indent, left_indent).layout(lines)
21
- end
20
+ # Positions lines vertically within a text frame. The layout is
21
+ # block-oriented: each call to `layout_block` returns the
22
+ # positioned lines for one block (paragraph, run, etc.) and the
23
+ # y cursor for the next block, so callers can stack paragraphs
24
+ # without re-deriving cursor math.
25
+ class VerticalLayout
26
+ DEFAULT_LEADING_FACTOR = 1.2
22
27
 
23
- def initialize(frame, font_size, leading, space_before,
24
- space_after, first_line_indent, left_indent)
25
- @frame = frame
26
- @font_size = font_size
27
- @leading = leading || (font_size * 1.2)
28
- @space_before = space_before
29
- @space_after = space_after
30
- @first_line_indent = first_line_indent
31
- @left_indent = left_indent
32
- end
28
+ # Position `lines` starting at `cursor_y` and walking downward.
29
+ # `cursor_y` is the top of the available space; the first line
30
+ # sits one `leading` below it. Returns `[positioned_lines, next_y]`
31
+ # where `next_y` is where the next block should start (i.e. below
32
+ # this block's last line plus `space_after`).
33
+ def self.layout_block(lines:, frame:, font_size:, cursor_y:, leading: nil, space_before: 0, space_after: 0,
34
+ first_line_indent: 0, left_indent: 0)
35
+ effective_leading = leading || (font_size * DEFAULT_LEADING_FACTOR)
36
+ y = cursor_y - space_before
37
+ positioned = []
33
38
 
34
- def layout(lines)
35
- result = []
36
- y = top_y - @space_before
37
39
  lines.each_with_index do |line, idx|
38
- y -= @leading
39
- x = frame_left + (idx.zero? ? @first_line_indent : @left_indent)
40
- x += line.x_offset.to_f
41
- current_x = x
42
- line.glyphs.each do |glyph|
43
- result << PositionedGlyph.new(glyph.codepoint,
44
- current_x,
45
- y,
46
- @font_size)
47
- current_x += glyph.width
48
- end
40
+ y -= effective_leading
41
+ x = frame_left(frame) + left_indent +
42
+ (idx.zero? ? first_line_indent : 0) + line.x_offset.to_f
43
+ positioned << PositionedLine.new(
44
+ glyphs: line.glyphs, x: x, y: y, width: line.width,
45
+ )
49
46
  end
50
- result
47
+
48
+ [positioned, y - space_after]
51
49
  end
52
50
 
53
- private
51
+ # Bottom y of the frame's text area (frame bottom edge + bottom
52
+ # inset). Lines whose y falls below this are clipped.
53
+ def self.bottom_limit(frame)
54
+ frame.y + (frame.inset_bottom || 0)
55
+ end
54
56
 
55
- def top_y
56
- @frame.y - (@frame.inset_top || 0)
57
+ # Effective wrap width after subtracting insets and indents.
58
+ def self.wrap_width(frame, right_indent = 0)
59
+ width = frame.width - (frame.inset_left || 0) - (frame.inset_right || 0)
60
+ width - right_indent
57
61
  end
58
62
 
59
- def frame_left
60
- @frame.x + (@frame.inset_left || 0)
63
+ def self.frame_left(frame)
64
+ frame.x + (frame.inset_left || 0)
61
65
  end
66
+ private_class_method :frame_left
62
67
  end
63
68
  end
64
69
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Idml
4
4
  # Pure-Ruby text layout engine. Takes styled text runs + frame
5
- # geometry and produces positioned glyphs using font metrics from
5
+ # geometry and produces positioned lines using font metrics from
6
6
  # .ttf/.otf files. IDML-agnostic — works on any styled text.
7
7
  module TextEngine
8
8
  autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
@@ -12,7 +12,8 @@ module Idml
12
12
  autoload :Line, "#{__dir__}/text_engine/line_breaker"
13
13
  autoload :Justifier, "#{__dir__}/text_engine/justifier"
14
14
  autoload :VerticalLayout, "#{__dir__}/text_engine/vertical_layout"
15
- autoload :PositionedGlyph, "#{__dir__}/text_engine/vertical_layout"
15
+ autoload :Frame, "#{__dir__}/text_engine/vertical_layout"
16
+ autoload :PositionedLine, "#{__dir__}/text_engine/vertical_layout"
16
17
  autoload :CjkLayout, "#{__dir__}/text_engine/cjk_layout"
17
18
  end
18
19
  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.6.2"
4
+ VERSION = "0.7.1"
5
5
  end