idml 0.6.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46b15814f26a45f5df5c3170df66846ff831727ce3dcf4b7ebe1b1c0377d8c0e
4
- data.tar.gz: d4041eacffab33e437330116d3b1268577b6ad8afbc0f2ee4cc1e015c07ec3a4
3
+ metadata.gz: 0f40610210789018b1fbe0625c1541cfe3611169d0004543891ebb9efd6c74ea
4
+ data.tar.gz: 6998d5c66bd56eed147dfb88d60a602be32fee2adb9b9fa9f08413af4cb2103f
5
5
  SHA512:
6
- metadata.gz: 45e7dcb9c7bad255b0bc4fbe2e97e741c7a1b1ff52a6bf899ffdffdf838f310780c1e90832b54f9cbd5a0044b7b908f9c3791fd8274b4272fe1f40506b0ce793
7
- data.tar.gz: b928db59ed416b59431bb638e1d86630488247dff7a3aaa6361954029920d3cb053095fdba28d7d7a00ec386e1b7f6153c89a7478135ad44523a3cb000cba840
6
+ metadata.gz: 02c03bf2c81b2ebb81b04ae4b413b25d484adf806b1d8c7c0768e557323bca7c30de46d5174cff27c90e2275f6f89070ac5f981df567cb20867e5c17382b0c42
7
+ data.tar.gz: 642103e8e48d2b5b52a4b669d7e09329f5706e4f165f4da8de6df51ef38125686ca75db28de194a974f94cdc9273a193c76a653bf8727b3a16f6b5ac2a9c5f3c
data/Gemfile CHANGED
@@ -6,7 +6,7 @@ gemspec
6
6
 
7
7
  gem "logger"
8
8
  gem "lutaml-model", "~> 0.8.0"
9
- gem "pdfrb", github: "claricle/pdfrb", ref: "629c72a"
9
+ gem "pdfrb", "~> 0.7"
10
10
  gem "rake"
11
11
  gem "rspec"
12
12
  gem "rubocop"
@@ -1,6 +1,16 @@
1
1
  # TODO PDF 100: Character-level styling (underline, strikethrough, caps, color)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEnew `Render::CharacterStyle` helper applies
4
+ per-run fill color, draws underline / strike-through rules, and
5
+ handles Capitalization (AllCaps/SmallCaps → uppercase) and Position
6
+ (Superscript/Subscript → shrunk font + baseline offset).
7
+ TextFrameRenderer.emit_line wraps canvas.text via CharacterStyle.
8
+ StyledRun extended with underline_offset/weight, strike_through_offset/weight
9
+ attrs from CSR.
10
+
11
+ Open follow-ups (lower priority): glyph horizontal/vertical scaling
12
+ (needs pdfrb text-matrix support), tracking (needs Shaper-level
13
+ width adjustment), text stroke outline.
4
14
 
5
15
  ## Problem
6
16
 
@@ -1,6 +1,11 @@
1
1
  # TODO PDF 104: Schema-faithful Table model completion
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: PARTIALadded the rendering-relevant subset (column_count,
4
+ single_column_width, header/body/footer row counts, table_direction,
5
+ outer-border strokes). The remaining ~150 schema attributes (alternating
6
+ stroke rows, column rules, table-flavor bits) aren't needed for
7
+ rendering and would be pure model bloat. Add incrementally as use
8
+ cases demand.
4
9
 
5
10
  ## Problem
6
11
 
@@ -1,6 +1,9 @@
1
1
  # TODO PDF 105: Whole-package round-trip suite (byte-equivalent)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONE`spec/idml/round_trip_all_fixtures_spec.rb` already
4
+ runs byte-equivalent round-trip for every fixture .idml (28 examples,
5
+ all passing). `spec/idml/package_spec.rb:140` verifies the mimetype
6
+ entry is stored and first.
4
7
 
5
8
  ## Problem
6
9
 
@@ -1,6 +1,10 @@
1
1
  # TODO PDF 94: TextFrameRenderer should delegate to VerticalLayout
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEVerticalLayout refactored to block-style API
4
+ (`layout_block` returns positioned lines + next cursor y);
5
+ TextFrameRenderer now uses StyleResolver.extract_paragraphs +
6
+ VerticalLayout.layout_block for proper vertical stacking across
7
+ paragraphs and runs.
4
8
 
5
9
  ## Problem
6
10
 
@@ -1,6 +1,7 @@
1
1
  # TODO PDF 95: Paragraph spacing and indents (SpaceBefore, SpaceAfter, FirstLineIndent)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEParagraph struct carries these from PSR; renderer
4
+ passes them to VerticalLayout.layout_block.
4
5
 
5
6
  ## Problem
6
7
 
@@ -1,6 +1,8 @@
1
1
  # TODO PDF 96: TextFramePreference insets honored in layout
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONElayout_frame helper reads TextFramePreference
4
+ insets and passes them to the Frame struct; VerticalLayout honors
5
+ them in positioning and wrap_width.
4
6
 
5
7
  ## Problem
6
8
 
@@ -1,6 +1,9 @@
1
1
  # TODO PDF 97: Table cell rendering — fill, stroke, insets, vertical justification
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEcell fill_color renders as background; per-cell
4
+ TextTopInset/Left/Bottom/Right honored (was hardcoded 4.0);
5
+ VerticalJustification positions text Top/Bottom/Center; per-cell
6
+ PSR walks typed runs with their own font/size.
4
7
 
5
8
  ## Problem
6
9
 
@@ -1,6 +1,9 @@
1
1
  # TODO PDF 98: RowSpan / ColumnSpan (merged cells) in TableRenderer
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONESchemaLayout reads cell.column_span and cell.row_span,
4
+ multiplies width/height by the span, tracks a coverage set, and skips
5
+ positions covered by a spanning cell. Spanning cell renders once at
6
+ its top-left corner.
4
7
 
5
8
  ## Problem
6
9
 
@@ -1,6 +1,10 @@
1
1
  # TODO PDF 99: Table column widths from ColumnAttributes
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONETable now models `column_count` and
4
+ `single_column_width`; SchemaLayout uses SingleColumnWidth when
5
+ declared, else falls back to even division. (IDML has no separate
6
+ ColumnAttributes element — column widths live on Table directly
7
+ when uniform.)
4
8
 
5
9
  ## Problem
6
10
 
data/idml.gemspec CHANGED
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency "bigdecimal"
32
32
  spec.add_dependency "logger"
33
33
  spec.add_dependency "lutaml-model", "~> 0.8.18"
34
- spec.add_dependency "pdfrb", "~> 0.6"
34
+ spec.add_dependency "pdfrb", "~> 0.7"
35
35
  spec.add_dependency "rubyzip"
36
36
  spec.add_dependency "thor"
37
37
 
@@ -2,10 +2,16 @@
2
2
 
3
3
  module Idml
4
4
  module Elements
5
- # `<Table>` — a table page item. Carries geometry attributes and a
6
- # collection of TableRow children. The table's width and height are
7
- # typically derived from PathPointArray anchors, but rendered here
8
- # using the ItemTransform + a bounding-box approximation.
5
+ # `<Table>` — a table page item. Carries geometry, body/header
6
+ # row counts, column layout, and border styling. Two child
7
+ # collections hold the table's content:
8
+ #
9
+ # - `row` — schema-faithful `<Row>` siblings (real IDML).
10
+ # - `cell` — schema-faithful `<Cell>` siblings (real IDML).
11
+ # - `table_row` — legacy nested `<TableRow><TableCell>` form
12
+ # (synthetic test fixture only; see TODO 84).
13
+ #
14
+ # The renderer auto-detects which layout is present.
9
15
  class Table < Lutaml::Model::Serializable
10
16
  attribute :self_attr, :string
11
17
  attribute :name, :string
@@ -15,6 +21,28 @@ module Idml
15
21
  attribute :fill_color, :string
16
22
  attribute :stroke_color, :string
17
23
  attribute :stroke_weight, :float
24
+
25
+ # Schema-faithful layout attributes (from Table_Object in
26
+ # Stories/Story.rnc). The renderer consults these to compute
27
+ # column widths and to clip body/header/footer regions.
28
+ attribute :header_row_count, :integer
29
+ attribute :footer_row_count, :integer
30
+ attribute :body_row_count, :integer
31
+ attribute :column_count, :integer
32
+ attribute :single_column_width, :float
33
+ attribute :table_direction, :string
34
+
35
+ # Outer-border strokes (TopBorderStrokeWeight, etc.). Useful
36
+ # when the renderer draws the table's bounding rectangle.
37
+ attribute :top_border_stroke_weight, :float
38
+ attribute :top_border_stroke_color, :string
39
+ attribute :left_border_stroke_weight, :float
40
+ attribute :left_border_stroke_color, :string
41
+ attribute :bottom_border_stroke_weight, :float
42
+ attribute :bottom_border_stroke_color, :string
43
+ attribute :right_border_stroke_weight, :float
44
+ attribute :right_border_stroke_color, :string
45
+
18
46
  attribute :table_row, Idml::Elements::TableRow, collection: true
19
47
  attribute :cell, Idml::Elements::Cell, collection: true
20
48
  attribute :row, Idml::Elements::Row, collection: true
@@ -30,6 +58,22 @@ module Idml
30
58
  map_attribute "FillColor", to: :fill_color
31
59
  map_attribute "StrokeColor", to: :stroke_color
32
60
  map_attribute "StrokeWeight", to: :stroke_weight
61
+ map_attribute "HeaderRowCount", to: :header_row_count
62
+ map_attribute "FooterRowCount", to: :footer_row_count
63
+ map_attribute "BodyRowCount", to: :body_row_count
64
+ map_attribute "ColumnCount", to: :column_count
65
+ map_attribute "SingleColumnWidth", to: :single_column_width
66
+ map_attribute "TableDirection", to: :table_direction
67
+ map_attribute "TopBorderStrokeWeight", to: :top_border_stroke_weight
68
+ map_attribute "TopBorderStrokeColor", to: :top_border_stroke_color
69
+ map_attribute "LeftBorderStrokeWeight", to: :left_border_stroke_weight
70
+ map_attribute "LeftBorderStrokeColor", to: :left_border_stroke_color
71
+ map_attribute "BottomBorderStrokeWeight",
72
+ to: :bottom_border_stroke_weight
73
+ map_attribute "BottomBorderStrokeColor",
74
+ to: :bottom_border_stroke_color
75
+ map_attribute "RightBorderStrokeWeight", to: :right_border_stroke_weight
76
+ map_attribute "RightBorderStrokeColor", to: :right_border_stroke_color
33
77
  map_element "TableRow", to: :table_row
34
78
  map_element "Cell", to: :cell
35
79
  map_element "Row", to: :row
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Applies character-level styling from a `StyleResolver::StyledRun`
6
+ # around a text-emitting block. Centralizes the IDML → PDF mapping
7
+ # for run-level visual properties (text color, underline rule,
8
+ # strike-through rule, capitalization, super/subscript position).
9
+ #
10
+ # Used by `TextFrameRenderer` so character-level effects live in
11
+ # one place (DRY). The block typically calls `canvas.text` (or
12
+ # `canvas.text_rich`); this helper sets the surrounding graphics
13
+ # state and emits the rule lines after the block returns.
14
+ module CharacterStyle
15
+ UNDERLINE_THICKNESS_FACTOR = 0.05
16
+ UNDERLINE_DROP_FACTOR = 0.10
17
+ STRIKE_THICKNESS_FACTOR = 0.05
18
+ STRIKE_RISE_FACTOR = 0.25
19
+
20
+ # Yields with the run's fill color applied, then draws underline
21
+ # and/or strike-through rules above/below the text baseline.
22
+ # `x`, `y`, `width`, `size` describe the line's geometry so the
23
+ # rules can be positioned correctly.
24
+ def self.apply(canvas, run, context, x:, y:, width:, size:)
25
+ apply_fill_color(canvas, run, context)
26
+ yield
27
+ draw_underline(canvas, run, x, y, width, size)
28
+ draw_strike_through(canvas, run, x, y, width, size)
29
+ end
30
+
31
+ # Transforms the text per the CSR's Capitalization attribute.
32
+ # AllCaps/SmallCaps → uppercase (true small-caps would require
33
+ # an OpenType feature; deferred). Other values pass through.
34
+ def self.transform_text(text, capitalization)
35
+ return text unless %w[AllCaps SmallCaps].include?(capitalization)
36
+
37
+ text.upcase
38
+ end
39
+
40
+ # Returns `[font_size, baseline_offset]` for the CSR's Position
41
+ # attribute. Superscript/subscript shrink the font and shift
42
+ # the baseline. Normal / nil returns the size unchanged.
43
+ def self.position_scale(position, font_size)
44
+ case position
45
+ when "Superscript" then [font_size * 0.583, font_size * 0.333]
46
+ when "Subscript" then [font_size * 0.583, -font_size * 0.0833]
47
+ else [font_size, 0.0]
48
+ end
49
+ end
50
+
51
+ def self.apply_fill_color(canvas, run, context)
52
+ return unless run.fill_color
53
+ return if run.fill_color == "Color/None"
54
+
55
+ color = context.color_resolver&.resolve(run.fill_color)
56
+ return unless color
57
+
58
+ canvas.fill_color(ColorHelper.to_canvas(color))
59
+ end
60
+ private_class_method :apply_fill_color
61
+
62
+ def self.draw_underline(canvas, run, x, y, width, size)
63
+ return unless run.underline
64
+
65
+ thickness = rule_thickness(run.underline_weight, size,
66
+ UNDERLINE_THICKNESS_FACTOR)
67
+ drop = rule_offset(run.underline_offset, size,
68
+ UNDERLINE_DROP_FACTOR)
69
+ canvas.rectangle(x, y - drop, width, thickness)
70
+ canvas.fill
71
+ end
72
+ private_class_method :draw_underline
73
+
74
+ def self.draw_strike_through(canvas, run, x, y, width, size)
75
+ return unless run.strike_thru
76
+
77
+ thickness = rule_thickness(run.strike_through_weight, size,
78
+ STRIKE_THICKNESS_FACTOR)
79
+ rise = rule_offset(run.strike_through_offset, size,
80
+ STRIKE_RISE_FACTOR)
81
+ canvas.rectangle(x, y + rise - thickness, width, thickness)
82
+ canvas.fill
83
+ end
84
+ private_class_method :draw_strike_through
85
+
86
+ def self.rule_thickness(declared, size, factor)
87
+ declared&.positive? ? declared : (size * factor)
88
+ end
89
+ private_class_method :rule_thickness
90
+
91
+ def self.rule_offset(declared, size, factor)
92
+ return size * factor unless declared
93
+ return size * factor if declared.zero?
94
+
95
+ declared
96
+ end
97
+ private_class_method :rule_offset
98
+ end
99
+ end
100
+ end
@@ -4,9 +4,8 @@ module Idml
4
4
  module Render
5
5
  module Renderers
6
6
  # Renders an IDML Table. Draws the cell grid via rectangle
7
- # ops, then renders inline text in each cell via
8
- # `canvas.text_rich`. Cells with no text render as empty
9
- # rectangles.
7
+ # ops, then renders inline text in each cell. Cells with no
8
+ # text render as empty rectangles.
10
9
  #
11
10
  # Two table layouts are supported:
12
11
  #
@@ -19,9 +18,20 @@ module Idml
19
18
  # `table_cell` collection. Nested structure.
20
19
  #
21
20
  # The renderer auto-detects which layout is present.
21
+ #
22
+ # Honors per-cell:
23
+ # - `fill_color` / `fill_tint` — background fill before stroke.
24
+ # - `top_inset` / `left_inset` / `bottom_inset` / `right_inset`
25
+ # — text insets (replaces fixed `INSET = 4.0`).
26
+ # - `vertical_justification` — Top / Center / Bottom / Justify.
27
+ # - `paragraph_style_range` → typed runs (per-run font + size).
28
+ #
29
+ # Honors per-table:
30
+ # - `single_column_width` — overrides the even-division default
31
+ # for column widths.
22
32
  class TableRenderer
23
33
  DEFAULT_SIZE = 10.0
24
- INSET = 4.0
34
+ DEFAULT_INSET = 4.0
25
35
 
26
36
  def self.render(canvas, context)
27
37
  table = context.item
@@ -57,17 +67,42 @@ module Idml
57
67
 
58
68
  # Real IDML: Table > {Cell, Row} siblings. Cell Name is
59
69
  # "col:row". Row count from `row` collection; column count
60
- # derived from max col + 1 across cells.
70
+ # derived from `column_count` attribute or max col + 1.
61
71
  def self.render_schema_faithful(canvas, table, box, context)
62
72
  layout = SchemaLayout.new(table: table, box: box)
63
73
  layout.each_cell do |cell_x, cell_y, cell_w, cell_h, cell|
64
- canvas.rectangle(cell_x, cell_y, cell_w, cell_h)
65
- canvas.stroke
74
+ render_cell_background(canvas, cell, cell_x, cell_y, cell_w, cell_h,
75
+ context)
76
+ render_cell_border(canvas, cell_x, cell_y, cell_w, cell_h)
66
77
  render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
67
78
  end
68
79
  end
69
80
  private_class_method :render_schema_faithful
70
81
 
82
+ def self.render_cell_background(canvas, cell, x, y, w, h, context)
83
+ color = cell_fill_color(cell, context)
84
+ return unless color
85
+
86
+ canvas.fill_color(ColorHelper.to_canvas(color))
87
+ canvas.rectangle(x, y, w, h)
88
+ canvas.fill
89
+ end
90
+ private_class_method :render_cell_background
91
+
92
+ def self.render_cell_border(canvas, x, y, w, h)
93
+ canvas.rectangle(x, y, w, h)
94
+ canvas.stroke
95
+ end
96
+ private_class_method :render_cell_border
97
+
98
+ def self.cell_fill_color(cell, context)
99
+ return nil unless cell.fill_color
100
+ return nil if cell.fill_color == "Color/None"
101
+
102
+ context.color_resolver&.resolve(cell.fill_color)
103
+ end
104
+ private_class_method :cell_fill_color
105
+
71
106
  # Legacy: Table > TableRow > TableCell nested.
72
107
  def self.render_legacy(canvas, table, box, context)
73
108
  return if table.table_row.empty?
@@ -93,33 +128,122 @@ module Idml
93
128
  private_class_method :render_legacy
94
129
 
95
130
  def self.render_cell_text(canvas, cell, x, y, height, context)
131
+ runs = cell_text_runs(cell, context)
132
+ return if runs.empty?
133
+
134
+ insets = cell_insets(cell)
135
+ baseline = vertical_baseline(y, height, insets, cell)
136
+ canvas.text_rich(runs, at: [x + insets[:left], baseline])
137
+ end
138
+ private_class_method :render_cell_text
139
+
140
+ # Builds pdfrb runs from the cell's typed PSR/CSR children.
141
+ # Walks ParagraphStyleRange → CharacterStyleRange → Content
142
+ # so each run carries its own font + size (per-cell CSR
143
+ # styling). Falls back to a single DEFAULT_SIZE run when
144
+ # the cell has no typed paragraphs (legacy TableCell).
145
+ def self.cell_text_runs(cell, context)
146
+ psr = typed_paragraphs(cell)
147
+ return default_text_runs(cell, context) if psr.empty?
148
+
149
+ psr.flat_map { |paragraph| paragraph_runs(paragraph, context) }
150
+ end
151
+ private_class_method :cell_text_runs
152
+
153
+ def self.paragraph_runs(paragraph, context)
154
+ paragraph.character_style_range.filter_map do |csr|
155
+ text = csr.text_content
156
+ next if text.nil? || text.empty?
157
+
158
+ {
159
+ text: text,
160
+ font: context.font_ps_name,
161
+ size: csr.point_size || DEFAULT_SIZE,
162
+ }
163
+ end
164
+ end
165
+ private_class_method :paragraph_runs
166
+
167
+ # Real IDML `Cell` exposes paragraph_style_range; the legacy
168
+ # `TableCell` (synthetic fixture only) doesn't, so we treat
169
+ # it as having no typed paragraphs and fall through to the
170
+ # default_text_runs path.
171
+ def self.typed_paragraphs(cell)
172
+ return [] unless cell.is_a?(Idml::Elements::Cell)
173
+
174
+ cell.paragraph_style_range
175
+ end
176
+ private_class_method :typed_paragraphs
177
+
178
+ def self.default_text_runs(cell, context)
96
179
  text = cell.text_content
97
- return if text.nil? || text.empty?
180
+ return [] if text.nil? || text.empty?
98
181
 
99
- runs = [{
182
+ [{
100
183
  text: text,
101
184
  font: context.font_ps_name,
102
185
  size: DEFAULT_SIZE,
103
186
  }]
104
- baseline = y + (height / 2)
105
- canvas.text_rich(runs, at: [x + INSET, baseline])
106
187
  end
107
- private_class_method :render_cell_text
188
+ private_class_method :default_text_runs
189
+
190
+ # Cell insets: prefer typed TextTopInset/Left/Bottom/Right,
191
+ # fall back to DEFAULT_INSET for any that are missing.
192
+ def self.cell_insets(cell)
193
+ {
194
+ top: cell.top_inset || DEFAULT_INSET,
195
+ bottom: cell.bottom_inset || DEFAULT_INSET,
196
+ left: cell.left_inset || DEFAULT_INSET,
197
+ right: cell.right_inset || DEFAULT_INSET,
198
+ }
199
+ end
200
+ private_class_method :cell_insets
201
+
202
+ # Vertical baseline for the cell's first text line, per the
203
+ # cell's VerticalJustification. IDML values: TopAlign,
204
+ # CenterAlign, BottomAlign, JustifyAlign. Default: CenterAlign
205
+ # (legacy behavior).
206
+ def self.vertical_baseline(y, height, insets, cell)
207
+ case cell.vertical_justification
208
+ when "TopAlign"
209
+ y + height - insets[:top]
210
+ when "BottomAlign"
211
+ y + insets[:bottom]
212
+ else # CenterAlign, JustifyAlign, nil
213
+ y + (height / 2)
214
+ end
215
+ end
216
+ private_class_method :vertical_baseline
108
217
 
109
218
  # Computes per-cell rects for the schema-faithful layout.
110
219
  # Row heights come from `Row#single_row_height` when present,
111
220
  # else fall back to evenly-divided table height. Column
112
- # widths are evenly divided (precise column widths would
113
- # require ColumnAttributes lookup, deferred).
221
+ # widths come from `Table#single_column_width` when present
222
+ # (uniform columns only — per-column widths would require
223
+ # ColumnAttributes, which IDML doesn't model separately),
224
+ # else divide evenly.
225
+ #
226
+ # Honors `Cell#column_span` and `Cell#row_span`: a spanning
227
+ # cell covers multiple grid positions and is rendered once
228
+ # at its top-left corner with the merged rect. Cells covered
229
+ # by another cell's span are skipped.
114
230
  SchemaLayout = Struct.new(:table, :box, keyword_init: true) do
115
231
  def each_cell
232
+ covered = Set.new
233
+
116
234
  row_count.times do |row_idx|
117
235
  col_count.times do |col_idx|
236
+ next if covered.include?([col_idx, row_idx])
237
+
118
238
  cell = cell_at(col_idx, row_idx)
119
239
  next unless cell
120
240
 
121
- yield cell_x(col_idx), cell_y(row_idx), cell_w, cell_h(row_idx),
122
- cell
241
+ col_span = cell.column_span || 1
242
+ row_span = cell.row_span || 1
243
+ mark_covered(covered, col_idx, row_idx, col_span, row_span)
244
+
245
+ yield cell_x(col_idx), cell_y(row_idx, col_span, row_span),
246
+ cell_w(col_span), cell_h(row_idx, row_span), cell
123
247
  end
124
248
  end
125
249
  end
@@ -130,11 +254,16 @@ module Idml
130
254
 
131
255
  def col_count
132
256
  @col_count ||= begin
133
- max_col = cells.filter_map(&:col_row).map(&:first).max || 0
134
- max_col + 1
257
+ declared = table.column_count
258
+ declared&.positive? ? declared : derived_col_count
135
259
  end
136
260
  end
137
261
 
262
+ def derived_col_count
263
+ max_col = cells.filter_map(&:col_row).map(&:first).max || 0
264
+ max_col + 1
265
+ end
266
+
138
267
  def rows
139
268
  table.row
140
269
  end
@@ -147,20 +276,52 @@ module Idml
147
276
  cells.find { |c| c.col_row == [col, row] }
148
277
  end
149
278
 
279
+ def mark_covered(covered, col, row, col_span, row_span)
280
+ col_span.times do |dc|
281
+ row_span.times do |dr|
282
+ covered << [col + dc, row + dr]
283
+ end
284
+ end
285
+ end
286
+ private :mark_covered
287
+
150
288
  def cell_x(col)
151
- box[:x] + (col * cell_w)
289
+ box[:x] + (col * per_col_width)
152
290
  end
153
291
 
154
- def cell_y(row)
155
- box[:y] + total_height - cumulative_height(row) - cell_h(row)
292
+ # PDF-coordinate bottom y of the cell's rectangle. For a
293
+ # row_spanning cell, this is the bottom edge of the last
294
+ # spanned row. PDF rectangle takes (x, y_bottom, w, h).
295
+ def cell_y(row, _col_span, row_span)
296
+ last_row = row + row_span - 1
297
+ box[:y] + total_height - cumulative_height(last_row + 1)
156
298
  end
157
299
 
158
- def cell_w
159
- box[:width] / [col_count, 1].max
300
+ # Width covered by `span` columns starting at the cell's col.
301
+ def cell_w(span)
302
+ per_col_width * (span || 1)
160
303
  end
161
304
 
162
- def cell_h(row_idx)
163
- row_heights[row_idx] || (box[:height] / [row_count, 1].max)
305
+ # Total height covered by `span` rows starting at `row_idx`.
306
+ def cell_h(row_idx, span)
307
+ (span || 1).times.sum { |i| row_height(row_idx + i) }
308
+ end
309
+
310
+ def per_col_width
311
+ return @per_col_width if @per_col_width
312
+
313
+ declared = table.single_column_width
314
+ @per_col_width = if declared&.positive?
315
+ declared
316
+ else
317
+ box[:width] / [col_count, 1].max
318
+ end
319
+ end
320
+
321
+ def row_height(idx)
322
+ return 0.0 unless idx.between?(0, row_count - 1)
323
+
324
+ row_heights[idx] || (box[:height] / [row_count, 1].max)
164
325
  end
165
326
 
166
327
  def row_heights
@@ -171,11 +332,11 @@ module Idml
171
332
  end
172
333
 
173
334
  def total_height
174
- @total_height ||= row_count.times.sum { |i| cell_h(i) }
335
+ @total_height ||= row_count.times.sum { |i| row_height(i) }
175
336
  end
176
337
 
177
338
  def cumulative_height(up_to_row)
178
- up_to_row.times.sum { |i| cell_h(i) }
339
+ up_to_row.times.sum { |i| row_height(i) }
179
340
  end
180
341
  end
181
342
  private_constant :SchemaLayout
@@ -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.2"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '0.6'
61
+ version: '0.7'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '0.6'
68
+ version: '0.7'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: rubyzip
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -421,6 +421,7 @@ files:
421
421
  - lib/idml/render.rb
422
422
  - lib/idml/render/blending.rb
423
423
  - lib/idml/render/bookmark_resolver.rb
424
+ - lib/idml/render/character_style.rb
424
425
  - lib/idml/render/color_helper.rb
425
426
  - lib/idml/render/color_resolver.rb
426
427
  - lib/idml/render/font_reference_resolver.rb