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.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Emits paragraph-level rules (RuleAbove / RuleBelow) on a pdfrb
6
+ # canvas. Centralizes the IDML → PDF mapping for the paragraph
7
+ # rule attributes carried by `StyleResolver::Paragraph`.
8
+ #
9
+ # Each rule is a horizontal stroke above (RuleAbove) or below
10
+ # (RuleBelow) the paragraph block. The rule's weight, color,
11
+ # tint, offset, and indent come from the PSR's RuleAbove* /
12
+ # RuleBelow* attributes; defaults approximate InDesign behavior
13
+ # when an attribute is missing.
14
+ #
15
+ # Used by `TextFrameRenderer` so paragraph rules live in one
16
+ # place (DRY).
17
+ module ParagraphRules
18
+ DEFAULT_LINE_WEIGHT = 0.5
19
+ DEFAULT_TINT = 1.0
20
+ DEFAULT_OFFSET = 0.0
21
+
22
+ # Emits RuleAbove if `paragraph.rule_above` is true. The rule
23
+ # sits above the paragraph's first line. `top_y` is the y of
24
+ # the paragraph's top edge (before SpaceBefore is applied);
25
+ # `frame_left` / `frame_right` are the column's text bounds.
26
+ def self.emit_rule_above(canvas, paragraph, context, top_y,
27
+ frame_left, frame_right)
28
+ return unless paragraph.rule_above
29
+
30
+ emit_rule(canvas, paragraph, context, top_y, frame_left,
31
+ frame_right,
32
+ weight: paragraph.rule_above_line_weight,
33
+ color: paragraph.rule_above_color,
34
+ tint: paragraph.rule_above_tint,
35
+ offset: paragraph.rule_above_offset,
36
+ left_indent: paragraph.rule_above_left_indent,
37
+ right_indent: paragraph.rule_above_right_indent,
38
+ width_mode: paragraph.rule_above_width,
39
+ position: :above)
40
+ end
41
+
42
+ # Emits RuleBelow if `paragraph.rule_below` is true. The rule
43
+ # sits below the paragraph's last line. `bottom_y` is the y of
44
+ # the paragraph's bottom edge (after SpaceAfter is applied).
45
+ def self.emit_rule_below(canvas, paragraph, context, bottom_y,
46
+ frame_left, frame_right)
47
+ return unless paragraph.rule_below
48
+
49
+ emit_rule(canvas, paragraph, context, bottom_y, frame_left,
50
+ frame_right,
51
+ weight: paragraph.rule_below_line_weight,
52
+ color: paragraph.rule_below_color,
53
+ tint: paragraph.rule_below_tint,
54
+ offset: paragraph.rule_below_offset,
55
+ left_indent: paragraph.rule_below_left_indent,
56
+ right_indent: paragraph.rule_below_right_indent,
57
+ width_mode: paragraph.rule_below_width,
58
+ position: :below)
59
+ end
60
+
61
+ def self.emit_rule(canvas, paragraph, context, anchor_y,
62
+ frame_left, frame_right,
63
+ weight:, color:, tint:, offset:,
64
+ left_indent:, right_indent:, width_mode:,
65
+ position:)
66
+ thickness = (weight || DEFAULT_LINE_WEIGHT).to_f
67
+ return unless thickness.positive?
68
+
69
+ tint_value = (tint || DEFAULT_TINT).to_f
70
+ return unless tint_value.positive?
71
+
72
+ offset_value = (offset || DEFAULT_OFFSET).to_f
73
+ x1, x2 = rule_extents(frame_left, frame_right, left_indent,
74
+ right_indent, width_mode, paragraph)
75
+ y = rule_y(anchor_y, offset_value, position)
76
+
77
+ apply_rule_color(canvas, color, tint_value, context)
78
+ canvas.line_width = thickness
79
+ canvas.move_to(x1, y)
80
+ canvas.line_to(x2, y)
81
+ canvas.stroke
82
+ end
83
+ private_class_method :emit_rule
84
+
85
+ # Returns `[x1, x2]` for the rule. Width mode "Text" indents
86
+ # the rule by the paragraph's left/right indent (matches the
87
+ # text bounds); "Column" (default) spans the full column.
88
+ def self.rule_extents(frame_left, frame_right, rule_left_indent,
89
+ rule_right_indent, width_mode, paragraph)
90
+ left = frame_left
91
+ right = frame_right
92
+ if width_mode == "Text"
93
+ left += (paragraph.left_indent || 0).to_f
94
+ right -= (paragraph.right_indent || 0).to_f
95
+ end
96
+ left += (rule_left_indent || 0).to_f
97
+ right -= (rule_right_indent || 0).to_f
98
+ [left, right]
99
+ end
100
+ private_class_method :rule_extents
101
+
102
+ def self.rule_y(anchor_y, offset, position)
103
+ position == :above ? anchor_y + offset : anchor_y - offset
104
+ end
105
+ private_class_method :rule_y
106
+
107
+ def self.apply_rule_color(canvas, color_name, tint_value, context)
108
+ color = resolve_color(color_name, tint_value, context)
109
+ canvas.stroke_color(color)
110
+ end
111
+ private_class_method :apply_rule_color
112
+
113
+ def self.resolve_color(color_name, tint_value, context)
114
+ return [:gray, 1.0 * tint_value] unless color_name
115
+ return [:gray, 1.0 * tint_value] if color_name == "Color/None"
116
+
117
+ color = context&.color_resolver&.resolve(color_name)
118
+ return [:gray, 1.0 * tint_value] unless color
119
+
120
+ ColorHelper.apply_tint(color, tint_value)
121
+ end
122
+ private_class_method :resolve_color
123
+ end
124
+ end
125
+ 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