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
@@ -0,0 +1,44 @@
1
+ # TODO PDF 99: Table column widths from ColumnAttributes
2
+
3
+ ## Status: DONE — Table 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.)
8
+
9
+ ## Problem
10
+
11
+ `TableRenderer::SchemaLayout#cell_w` divides the table width evenly
12
+ across all columns:
13
+
14
+ ```ruby
15
+ def cell_w
16
+ box[:width] / [col_count, 1].max
17
+ end
18
+ ```
19
+
20
+ Real IDML documents declare per-column widths via the
21
+ `ColumnAttributes` child of `Table` (with `SingleColumnWidth`
22
+ attributes). The renderer ignores these, producing visually wrong
23
+ tables whenever columns aren't equal-width.
24
+
25
+ ## What needs to happen
26
+
27
+ 1. Verify `Elements::Table` exposes the `column_attributes`
28
+ collection (add it if missing).
29
+ 2. `SchemaLayout` reads each `ColumnAttributes#single_column_width`
30
+ and computes a cumulative column-x table.
31
+ 3. `cell_x(col)` looks up the cumulative position; `cell_w(col)`
32
+ uses the actual width for that column.
33
+ 4. Falls back to even division when `ColumnAttributes` is absent.
34
+
35
+ ## Acceptance criteria
36
+
37
+ - [ ] Table with two columns, widths 100pt and 200pt, renders cells
38
+ at the correct x positions and widths.
39
+ - [ ] Table without ColumnAttributes renders as today (even division).
40
+ - [ ] Spanning cells (ColumnSpan) cover the correct cumulative width.
41
+
42
+ ## Dependencies
43
+
44
+ - TODO 98 (spans interact with column widths).
data/TODO.pdf/README.md CHANGED
@@ -5,17 +5,14 @@ without InDesign Server. The pipeline has three layers:
5
5
 
6
6
  1. **Text engine** (TODOs 01–05): a pure-Ruby text layout engine
7
7
  that takes styled text runs + frame geometry and produces
8
- positioned glyphs. Uses font metrics from .ttf/.otf files.
9
-
8
+ positioned glyphs. Uses font metrics via pdfrb's typed Fonts API.
10
9
  2. **PDF content stream** (TODOs 06–10): maps IDML page items
11
- (shapes, text, colors, images) to PDF content-stream operators
12
- using pdfrb's Document/Canvas API.
13
-
10
+ (shapes, text, colors, images) to pdfrb's Canvas API.
14
11
  3. **Pipeline** (TODOs 11–13): ties the text engine and PDF
15
12
  output together. IDML Package → typed model → render plan →
16
13
  pdfrb Document → PDF file.
17
14
 
18
- The text engine is intentionally IDML-agnostic: it works on
19
- styled text runs and frame geometry, not IDML-specific types.
20
- This makes it reusable (e.g., for generating PDFs from scratch
21
- or from other layout formats).
15
+ All entries below are functionally DONE (basic CJK in TODO 13 is
16
+ the only PARTIAL vertical writing mode, kinsoku, ruby, and
17
+ tate-chu-yoko remain stretch goals). Each file carries a
18
+ `## Status:` header with a brief implementation summary.
data/idml.gemspec CHANGED
@@ -29,8 +29,9 @@ Gem::Specification.new do |spec|
29
29
  spec.require_paths = ["lib"]
30
30
 
31
31
  spec.add_dependency "bigdecimal"
32
+ spec.add_dependency "logger"
32
33
  spec.add_dependency "lutaml-model", "~> 0.8.18"
33
- spec.add_dependency "pdfrb"
34
+ spec.add_dependency "pdfrb", "~> 0.7"
34
35
  spec.add_dependency "rubyzip"
35
36
  spec.add_dependency "thor"
36
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
@@ -66,6 +66,15 @@ module Idml
66
66
  map
67
67
  end
68
68
 
69
+ # True if at least one non-missing font declared in the
70
+ # document can be located on disk. Lets callers (and tests)
71
+ # gate font-embedding assertions on the document's fonts
72
+ # actually being installed, rather than silently falling
73
+ # back to the default.
74
+ def font_resolvable?
75
+ !resolve_document_font_path.nil?
76
+ end
77
+
69
78
  def resource_for_family(writer, family)
70
79
  path = find_font_file(family)
71
80
  return DEFAULT_FONT unless path
@@ -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