idml 0.6.1 → 0.7.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +1 -3
  3. data/.gitignore +1 -0
  4. data/Gemfile +2 -1
  5. data/TODO.pdf/01-font-metrics.md +6 -0
  6. data/TODO.pdf/02-font-resolver.md +5 -0
  7. data/TODO.pdf/03-text-shaper.md +4 -0
  8. data/TODO.pdf/04-line-breaker-justifier.md +4 -0
  9. data/TODO.pdf/05-vertical-layout.md +4 -0
  10. data/TODO.pdf/06-pdf-color-operators.md +5 -0
  11. data/TODO.pdf/07-pdf-path-operators.md +5 -0
  12. data/TODO.pdf/08-pdf-text-operators.md +5 -0
  13. data/TODO.pdf/09-image-embedding.md +5 -0
  14. data/TODO.pdf/10-spread-renderer.md +5 -0
  15. data/TODO.pdf/100-character-level-styling.md +71 -0
  16. data/TODO.pdf/101-paragraph-rules-borders.md +38 -0
  17. data/TODO.pdf/102-anchored-objects.md +41 -0
  18. data/TODO.pdf/103-drop-caps.md +33 -0
  19. data/TODO.pdf/104-table-schema-completion.md +49 -0
  20. data/TODO.pdf/105-whole-package-round-trip.md +48 -0
  21. data/TODO.pdf/106-page-item-type-coverage.md +62 -0
  22. data/TODO.pdf/107-image-rendering-unification.md +42 -0
  23. data/TODO.pdf/11-font-embedding.md +6 -0
  24. data/TODO.pdf/12-idml-to-pdf-pipeline.md +6 -0
  25. data/TODO.pdf/13-cjk-text-layout.md +5 -0
  26. data/TODO.pdf/14-page-item-element-models.md +5 -0
  27. data/TODO.pdf/15-wire-spread-children.md +5 -0
  28. data/TODO.pdf/16-typed-model-pipeline.md +5 -0
  29. data/TODO.pdf/17-shape-rendering.md +5 -0
  30. data/TODO.pdf/18-text-rendering.md +5 -0
  31. data/TODO.pdf/19-page-dimensions.md +4 -0
  32. data/TODO.pdf/20-png-image-support.md +4 -0
  33. data/TODO.pdf/21-coordinate-transform.md +5 -0
  34. data/TODO.pdf/22-color-resolution.md +5 -0
  35. data/TODO.pdf/23-fontmetrics-ps-name-fix.md +5 -0
  36. data/TODO.pdf/24-antipattern-coverage.md +5 -0
  37. data/TODO.pdf/25-cli-render.md +4 -0
  38. data/TODO.pdf/26-renderer-registry.md +5 -0
  39. data/TODO.pdf/27-text-engine-integration.md +6 -0
  40. data/TODO.pdf/28-path-geometry-models.md +6 -0
  41. data/TODO.pdf/29-character-style-extraction.md +5 -0
  42. data/TODO.pdf/30-shape-geometry-rendering.md +6 -0
  43. data/TODO.pdf/31-stroke-rendering.md +6 -0
  44. data/TODO.pdf/32-master-spread-rendering.md +6 -0
  45. data/TODO.pdf/33-font-resolution-from-idml.md +7 -0
  46. data/TODO.pdf/34-story-threading.md +5 -0
  47. data/TODO.pdf/35-pdf-metadata.md +6 -0
  48. data/TODO.pdf/36-performance-caching.md +5 -0
  49. data/TODO.pdf/37-round-trip-specs.md +5 -0
  50. data/TODO.pdf/38-pdf-output-validation.md +5 -0
  51. data/TODO.pdf/41-graphic-line-rendering.md +5 -0
  52. data/TODO.pdf/42-group-transform-application.md +5 -0
  53. data/TODO.pdf/43-text-frame-insets.md +6 -0
  54. data/TODO.pdf/44-layer-support.md +6 -0
  55. data/TODO.pdf/45-image-clipping.md +4 -0
  56. data/TODO.pdf/46-spread-to-page-separation.md +5 -0
  57. data/TODO.pdf/47-pdf-bookmarks.md +6 -0
  58. data/TODO.pdf/48-table-rendering.md +7 -0
  59. data/TODO.pdf/49-gradient-fill-rendering.md +6 -0
  60. data/TODO.pdf/50-pdf-a-compliance.md +6 -0
  61. data/TODO.pdf/68-radial-gradient-shadings.md +4 -0
  62. data/TODO.pdf/69-transparency-blend-modes.md +6 -0
  63. data/TODO.pdf/94-text-frame-vertical-layout.md +67 -0
  64. data/TODO.pdf/95-paragraph-spacing-indents.md +51 -0
  65. data/TODO.pdf/96-text-frame-insets-actual.md +41 -0
  66. data/TODO.pdf/97-table-cell-rendering-fidelity.md +65 -0
  67. data/TODO.pdf/98-rowspan-columnspan.md +38 -0
  68. data/TODO.pdf/99-table-column-widths.md +44 -0
  69. data/TODO.pdf/README.md +6 -9
  70. data/idml.gemspec +2 -1
  71. data/lib/idml/elements/table.rb +48 -4
  72. data/lib/idml/render/character_style.rb +100 -0
  73. data/lib/idml/render/font_setup.rb +9 -0
  74. data/lib/idml/render/renderers/table_renderer.rb +188 -27
  75. data/lib/idml/render/renderers/text_frame_renderer.rb +185 -59
  76. data/lib/idml/render/style_resolver.rb +89 -7
  77. data/lib/idml/render.rb +1 -0
  78. data/lib/idml/text_engine/vertical_layout.rb +50 -45
  79. data/lib/idml/text_engine.rb +3 -2
  80. data/lib/idml/version.rb +1 -1
  81. metadata +40 -8
  82. data/Gemfile.lock +0 -235
@@ -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,169 @@ 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
+ cursor_y, char_cursor = render_runs(
141
+ canvas, paragraph, context, layout_frame, font,
142
+ wrap_width, cursor_y, bottom_limit, char_cursor, para_attrs
143
+ )
144
+ [cursor_y - para_attrs[:space_after], char_cursor]
145
+ end
146
+ private_class_method :render_paragraph
147
+
148
+ def self.paragraph_attrs(paragraph)
149
+ {
150
+ space_before: paragraph.space_before || 0,
151
+ space_after: paragraph.space_after || 0,
152
+ first_line_indent: paragraph.first_line_indent || 0,
153
+ left_indent: paragraph.left_indent || 0,
154
+ }
155
+ end
156
+ private_class_method :paragraph_attrs
157
+
158
+ def self.render_runs(canvas, paragraph, context, layout_frame, font,
159
+ wrap_width, cursor_y, bottom_limit, char_cursor,
160
+ para_attrs)
161
+ space_before = para_attrs[:space_before]
162
+ paragraph.runs.each do |run|
163
+ break if cursor_y < bottom_limit
164
+
165
+ positioned, next_y = layout_run(
166
+ canvas, run, paragraph, context, layout_frame, font,
167
+ wrap_width, cursor_y, space_before,
168
+ para_attrs[:first_line_indent], para_attrs[:left_indent],
169
+ char_cursor
170
+ )
171
+ char_cursor += positioned.length
172
+ cursor_y = next_y
173
+ space_before = 0
174
+ end
175
+ [cursor_y, char_cursor]
176
+ end
177
+ private_class_method :render_runs
178
+
179
+ # Shapes one run's text, wraps to the inset/indent-adjusted
180
+ # width, justifies per paragraph alignment, and asks
181
+ # VerticalLayout to position the lines. Emits each line via
182
+ # `canvas.text` and records positions for hyperlink tracking.
183
+ # Returns `[positioned_lines, next_y]`.
184
+ def self.layout_run(canvas, run, paragraph, context, layout_frame,
185
+ font, wrap_width, cursor_y, space_before,
186
+ first_line_indent, left_indent,
187
+ char_cursor)
188
+ size = run.point_size || DEFAULT_SIZE
98
189
  glyphs = TextEngine::Shaper.shape(
99
190
  text: run.text, font: font, size: size,
100
191
  )
101
192
  lines = TextEngine::LineBreaker.break(
102
- glyphs: glyphs, frame_width: box[:width],
193
+ glyphs: glyphs, frame_width: wrap_width,
103
194
  )
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
195
+ lines.each do |line|
196
+ TextEngine::Justifier.justify(
197
+ line: line, frame_width: wrap_width,
198
+ alignment: paragraph.alignment || :left
199
+ )
124
200
  end
125
- cursor
201
+ leading = leading_for(paragraph, size)
202
+
203
+ positioned, next_y = TextEngine::VerticalLayout.layout_block(
204
+ lines: lines,
205
+ frame: layout_frame,
206
+ font_size: size,
207
+ leading: leading,
208
+ cursor_y: cursor_y,
209
+ space_before: space_before,
210
+ first_line_indent: first_line_indent,
211
+ left_indent: left_indent,
212
+ )
213
+
214
+ bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
215
+ positioned.each do |line|
216
+ break if line.y < bottom_limit
217
+
218
+ emit_line(canvas, line, run, context, size)
219
+ record_position(context, line, size, char_cursor)
220
+ end
221
+ [positioned, next_y]
126
222
  end
127
- private_class_method :render_run_lines
223
+ private_class_method :layout_run
128
224
 
129
- def self.record_position(context, line, x, y, width, height, cursor)
225
+ # Emits one positioned line: applies character-level styling
226
+ # (fill color, capitalization, position) via CharacterStyle,
227
+ # then draws underline/strike-through rules after the text.
228
+ def self.emit_line(canvas, line, run, context, size)
229
+ text = CharacterStyle.transform_text(line_text(line),
230
+ run.capitalization)
231
+ scaled_size, baseline_offset = CharacterStyle.position_scale(
232
+ run.position, size
233
+ )
234
+ CharacterStyle.apply(canvas, run, context,
235
+ x: line.x, y: line.y + baseline_offset,
236
+ width: line.width, size: scaled_size) do
237
+ canvas.text(
238
+ text,
239
+ at: [line.x, line.y + baseline_offset],
240
+ font: font_for_run(run, context),
241
+ size: scaled_size,
242
+ )
243
+ end
244
+ end
245
+ private_class_method :emit_line
246
+
247
+ def self.leading_for(paragraph, size)
248
+ explicit = paragraph.auto_leading
249
+ return size * LEADING_FACTOR unless explicit&.positive?
250
+
251
+ size * explicit
252
+ end
253
+ private_class_method :leading_for
254
+
255
+ def self.record_position(context, line, height, cursor)
130
256
  tracker = context.position_tracker
131
257
  return unless tracker
132
258
  return unless context.item&.self_attr
@@ -135,9 +261,9 @@ module Idml
135
261
  tracker.add(context.item.self_attr,
136
262
  start_char: cursor,
137
263
  end_char: cursor + glyph_count,
138
- x: x,
139
- y: y,
140
- width: width,
264
+ x: line.x,
265
+ y: line.y,
266
+ width: line.width,
141
267
  height: height)
142
268
  end
143
269
  private_class_method :record_position
@@ -146,7 +272,7 @@ module Idml
146
272
  # run's `applied_font` (family name from CSR's AppliedFont)
147
273
  # against the document's font_map. Falls back to the document
148
274
  # default when no per-run font is specified or the family
149
- # isn registered.
275
+ # isn't registered.
150
276
  def self.font_for_run(run, context)
151
277
  family = run.applied_font
152
278
  return context.font_ps_name unless family
@@ -163,12 +289,12 @@ module Idml
163
289
 
164
290
  # Fallback when no metrics are available: emit all runs as
165
291
  # 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?
292
+ def self.simple_render(canvas, paragraphs, context, box)
293
+ runs = paragraphs.flat_map(&:runs)
294
+ return if runs.empty?
170
295
 
171
- first_size = runs.first.point_size
296
+ runs_for = build_rich_runs(runs, context)
297
+ first_size = runs.first.point_size || DEFAULT_SIZE
172
298
  canvas.text_rich(
173
299
  runs_for,
174
300
  at: [box[:x], box[:y] + box[:height] - first_size],
@@ -181,7 +307,7 @@ module Idml
181
307
  {
182
308
  text: run.text,
183
309
  font: font_for_run(run, context),
184
- size: run.point_size,
310
+ size: run.point_size || DEFAULT_SIZE,
185
311
  }
186
312
  end
187
313
  end
@@ -2,15 +2,59 @@
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
+ keyword_init: true,
14
58
  )
15
59
 
16
60
  DEFAULT_POINT_SIZE = 12.0
@@ -28,6 +72,8 @@ module Idml
28
72
  "ToBinding" => :left,
29
73
  }.freeze
30
74
 
75
+ # Returns the flat run list for a story. Each run carries only
76
+ # character-level attributes. Paragraph boundaries are lost.
31
77
  def self.extract_runs(story)
32
78
  return [] unless story&.inner
33
79
 
@@ -36,6 +82,30 @@ module Idml
36
82
  end
37
83
  end
38
84
 
85
+ # Returns the paragraph list for a story. Each paragraph groups
86
+ # its runs and carries the paragraph-level attributes from the
87
+ # owning PSR. Empty paragraphs (no runs after filtering) are
88
+ # skipped.
89
+ def self.extract_paragraphs(story)
90
+ return [] unless story&.inner
91
+
92
+ story.inner.paragraph_style_range.filter_map do |psr|
93
+ runs = csr_runs(psr)
94
+ next if runs.empty?
95
+
96
+ Paragraph.new(
97
+ runs: runs,
98
+ alignment: runs.first.alignment,
99
+ space_before: psr.space_before,
100
+ space_after: psr.space_after,
101
+ first_line_indent: psr.first_line_indent,
102
+ left_indent: psr.left_indent,
103
+ right_indent: psr.right_indent,
104
+ auto_leading: psr.auto_leading,
105
+ )
106
+ end
107
+ end
108
+
39
109
  def self.csr_runs(psr)
40
110
  psr.character_style_range.filter_map do |csr|
41
111
  text = csr.text_content
@@ -49,6 +119,18 @@ module Idml
49
119
  fill_tint: csr.fill_tint,
50
120
  applied_font: csr.applied_font,
51
121
  alignment: alignment_for(csr),
122
+ tracking: csr.tracking,
123
+ capitalization: csr.capitalization,
124
+ position: csr.position,
125
+ underline: csr.underline,
126
+ underline_offset: csr.underline_offset,
127
+ underline_weight: csr.underline_weight,
128
+ strike_thru: csr.strike_thru,
129
+ strike_through_offset: csr.strike_through_offset,
130
+ strike_through_weight: csr.strike_through_weight,
131
+ horizontal_scale: csr.horizontal_scale,
132
+ vertical_scale: csr.vertical_scale,
133
+ baseline_shift: csr.baseline_shift,
52
134
  )
53
135
  end
54
136
  end
data/lib/idml/render.rb CHANGED
@@ -13,6 +13,7 @@ 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"
16
17
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
17
18
  autoload :Placement, "#{__dir__}/render/placement"
18
19
  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.1"
4
+ VERSION = "0.7.0"
5
5
  end