idml 0.9.3 → 0.9.4

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: b5306c44be037a52666aa508b9cdb76978561403d72bc43896ea8575b3f61340
4
- data.tar.gz: 1d46f1c6de5f53f564f69ccfc41349d48d12b5f3522b7dfae1d01dc79f92ae00
3
+ metadata.gz: 41d28cdae1d32efe5815542e9f5f702affff8445c1c77b9e62e3c9229f974dca
4
+ data.tar.gz: d9144aafa235ef8364cd373bc7f62742bc95ed1b6646f159f00e67f72a1706e2
5
5
  SHA512:
6
- metadata.gz: 4d6830d86be109139a52ee6c2d780f31c33488eee4090b6a64ca938c871fd78429d9da7dbd9b7911d2a814e6908c1a05d53b6083abbb6b74c9d04fec141fcf18
7
- data.tar.gz: '0886ff7d2aea4389f5bd0779151be35e6dd40473c2bec635ca4ff25f10321b196c09b171d104f405c4133ab699cfc008773a11533ad43fd870e755cd29b2e168'
6
+ metadata.gz: ca86d99a34bfb4cb1ab62580c3283dda29b21a060435f4dacce15e1069a49bf3009701d34c77700f5308b4c1fcd4e5d11788c89f6802398cb05a872516cae803
7
+ data.tar.gz: 6b7033ff88e4e5ea4edc4fbdfe7ebdb0024fbcc024107a7f5e5c18e3c7196b6323dcc66ec55485dbc743351c6d15f7bfd3eff922531d2d1ce6d713c9efa99dc7
@@ -0,0 +1,34 @@
1
+ # TODO PDF 119: Justification word/letter spacing limits
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-19
4
+
5
+ ## Problem
6
+
7
+ `TextEngine::Justifier` stretched inter-word spaces without limit
8
+ under full justification — a line with one space could widen that
9
+ gap across the whole column. IDML paragraphs declare
10
+ MaximumWordSpacing / MaximumLetterSpacing (percentages,
11
+ InDesign defaults 133% word, 0% letter) that cap how justification
12
+ slack is distributed.
13
+
14
+ ## What was done
15
+
16
+ - `Justifier::SpacingLimits` (max_word_spacing, max_letter_spacing
17
+ percentages). `Justifier.justify` takes `limits:`.
18
+ - Distribution: spaces stretch up to the word-spacing cap
19
+ (capacity = Σ natural_space_width × (max−100)/100); any residual
20
+ slack goes to letter spacing — an even per-glyph addition capped
21
+ at max_letter_spacing% of the natural space width. With
22
+ InDesign's default letter cap of 0%, residual slack stays
23
+ unfilled and the line ends short, matching InDesign's behavior
24
+ when limits bind.
25
+ - `StyleResolver::Paragraph` carries maximum_word_spacing /
26
+ maximum_letter_spacing from the PSR; the frame renderer and the
27
+ footnote layout both pass the limits.
28
+
29
+ ## Known limitations
30
+
31
+ - Glyph scaling (Minimum/Maximum/DesiredGlyphScaling) is not
32
+ applied as a last resort.
33
+ - Minimum word/letter spacing (line compression) is unused — this
34
+ justifier only ever stretches (slack ≥ 0).
@@ -0,0 +1,26 @@
1
+ # TODO PDF 120: StartParagraph frame/column breaks
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-19
4
+
5
+ ## Problem
6
+
7
+ PSR `StartParagraph` (Anywhere / NextPage / NextColumn / NextFrame
8
+ / NextOddPage / NextEvenPage) was parsed but ignored — forced-break
9
+ paragraphs (chapter headings, magazine copy) rendered mid-frame.
10
+
11
+ ## What was done
12
+
13
+ - `StyleResolver::Paragraph` carries `start_paragraph` from the
14
+ PSR.
15
+ - `TextFrameRenderer.consume_paragraphs` stops filling the current
16
+ frame/column before a paragraph that requests a break (NextPage /
17
+ NextColumn / NextFrame / NextOddPage / NextEvenPage) — as long as
18
+ at least one paragraph was already placed there; the paragraph
19
+ and everything after it flow to the next frame via the existing
20
+ chain state. Multi-column frames break per column.
21
+
22
+ ## Known limitations
23
+
24
+ - All break flavors act at frame/column granularity (NextPage and
25
+ NextFrame are indistinguishable; odd/even page parity is not
26
+ modeled — the renderer has no page-parity notion at this layer).
@@ -0,0 +1,29 @@
1
+ # TODO PDF 121: Per-side cell edge strokes
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-19
4
+
5
+ ## Problem
6
+
7
+ `TableRenderer` stroked every cell as a uniform rectangle with the
8
+ canvas default line width. IDML cells declare per-side edge stroke
9
+ weights (TopEdgeStrokeWeight, LeftEdgeStrokeWeight,
10
+ BottomEdgeStrokeWeight, RightEdgeStrokeWeight — with the matching
11
+ stroke colors/tints), and the table declares fallback defaults
12
+ (DefaultRowStrokeWeight / DefaultColumnStrokeWeight).
13
+
14
+ ## What was done
15
+
16
+ - Cell_Object schema completion: `Elements::Cell` declares all 95
17
+ schema attributes (77 added — per-side edge strokes + diagonal
18
+ lines + cell style overrides), generated in schema order like the
19
+ Table completion (TODO 104).
20
+ - `TableRenderer.render_cell_border` draws each side separately
21
+ with the cell's edge stroke weight, falling back to the table's
22
+ default row/column stroke weight, and resolves stroke colors via
23
+ the color resolver (black fallback). Sides with zero weight are
24
+ skipped, so weightless cells no longer draw hairlines.
25
+
26
+ ## Known limitations
27
+
28
+ - Diagonal cell strokes (DiagonalLine*) are modeled but not drawn.
29
+ - Edge stroke types (dashes) fall back to solid.
@@ -38,6 +38,88 @@ module Idml
38
38
  attribute :paragraph_style_range, Idml::Elements::ParagraphStyleRange,
39
39
  collection: true
40
40
 
41
+ # The remaining Cell_Object attributes (per-side edge strokes,
42
+ # diagonal lines, cell style overrides, …) in schema order —
43
+ # generated by `scripts/rnc_to_lutaml.rb` from
44
+ # reference-docs/schemas/package/Stories/Story.rnc.
45
+ attribute :top_inset, :float
46
+ attribute :left_inset, :float
47
+ attribute :bottom_inset, :float
48
+ attribute :right_inset, :float
49
+ attribute :top_left_diagonal_line, :boolean
50
+ attribute :top_right_diagonal_line, :boolean
51
+ attribute :diagonal_line_in_front, :boolean
52
+ attribute :diagonal_line_stroke_weight, :float
53
+ attribute :diagonal_line_stroke_type, :string
54
+ attribute :diagonal_line_stroke_color, :string
55
+ attribute :diagonal_line_stroke_tint, :float
56
+ attribute :diagonal_line_stroke_overprint, :boolean
57
+ attribute :diagonal_line_stroke_gap_color, :string
58
+ attribute :diagonal_line_stroke_gap_tint, :float
59
+ attribute :diagonal_line_stroke_gap_overprint, :boolean
60
+ attribute :clip_content_to_cell, :boolean
61
+ attribute :first_baseline_offset, :string
62
+ attribute :minimum_first_baseline_offset, :float
63
+ attribute :left_edge_stroke_weight, :float
64
+ attribute :left_edge_stroke_type, :string
65
+ attribute :left_edge_stroke_color, :string
66
+ attribute :left_edge_stroke_tint, :float
67
+ attribute :left_edge_stroke_overprint, :boolean
68
+ attribute :left_edge_stroke_gap_color, :string
69
+ attribute :left_edge_stroke_gap_tint, :float
70
+ attribute :left_edge_stroke_gap_overprint, :boolean
71
+ attribute :top_edge_stroke_weight, :float
72
+ attribute :top_edge_stroke_type, :string
73
+ attribute :top_edge_stroke_color, :string
74
+ attribute :top_edge_stroke_tint, :float
75
+ attribute :top_edge_stroke_overprint, :boolean
76
+ attribute :top_edge_stroke_gap_color, :string
77
+ attribute :top_edge_stroke_gap_tint, :float
78
+ attribute :top_edge_stroke_gap_overprint, :boolean
79
+ attribute :right_edge_stroke_weight, :float
80
+ attribute :right_edge_stroke_type, :string
81
+ attribute :right_edge_stroke_color, :string
82
+ attribute :right_edge_stroke_tint, :float
83
+ attribute :right_edge_stroke_overprint, :boolean
84
+ attribute :right_edge_stroke_gap_color, :string
85
+ attribute :right_edge_stroke_gap_tint, :float
86
+ attribute :right_edge_stroke_gap_overprint, :boolean
87
+ attribute :bottom_edge_stroke_weight, :float
88
+ attribute :bottom_edge_stroke_type, :string
89
+ attribute :bottom_edge_stroke_color, :string
90
+ attribute :bottom_edge_stroke_tint, :float
91
+ attribute :bottom_edge_stroke_overprint, :boolean
92
+ attribute :bottom_edge_stroke_gap_color, :string
93
+ attribute :bottom_edge_stroke_gap_tint, :float
94
+ attribute :bottom_edge_stroke_gap_overprint, :boolean
95
+ attribute :inner_row_stroke_weight, :float
96
+ attribute :inner_row_stroke_type, :string
97
+ attribute :inner_row_stroke_color, :string
98
+ attribute :inner_row_stroke_tint, :float
99
+ attribute :inner_row_stroke_overprint, :boolean
100
+ attribute :inner_row_stroke_gap_color, :string
101
+ attribute :inner_row_stroke_gap_tint, :float
102
+ attribute :inner_row_stroke_gap_overprint, :boolean
103
+ attribute :inner_column_stroke_weight, :float
104
+ attribute :inner_column_stroke_type, :string
105
+ attribute :inner_column_stroke_color, :string
106
+ attribute :inner_column_stroke_tint, :float
107
+ attribute :inner_column_stroke_overprint, :boolean
108
+ attribute :inner_column_stroke_gap_color, :string
109
+ attribute :inner_column_stroke_gap_tint, :float
110
+ attribute :inner_column_stroke_gap_overprint, :boolean
111
+ attribute :top_edge_stroke_priority, :integer
112
+ attribute :left_edge_stroke_priority, :integer
113
+ attribute :bottom_edge_stroke_priority, :integer
114
+ attribute :right_edge_stroke_priority, :integer
115
+ attribute :graphic_left_inset, :float
116
+ attribute :graphic_top_inset, :float
117
+ attribute :graphic_right_inset, :float
118
+ attribute :graphic_bottom_inset, :float
119
+ attribute :clip_content_to_graphic_cell, :boolean
120
+ attribute :writing_direction, :boolean
121
+ attribute :applied_cell_style_priority, :integer
122
+
41
123
  xml do
42
124
  root "Cell"
43
125
  map_attribute "Self", to: :self_attr
@@ -58,6 +140,106 @@ module Idml
58
140
  map_attribute "ParagraphSpacingLimit", to: :paragraph_spacing_limit
59
141
  map_attribute "RotationAngle", to: :rotation_angle
60
142
  map_attribute "AppliedCellStyle", to: :applied_cell_style
143
+ map_attribute "TopInset", to: :top_inset
144
+ map_attribute "LeftInset", to: :left_inset
145
+ map_attribute "BottomInset", to: :bottom_inset
146
+ map_attribute "RightInset", to: :right_inset
147
+ map_attribute "TopLeftDiagonalLine", to: :top_left_diagonal_line
148
+ map_attribute "TopRightDiagonalLine", to: :top_right_diagonal_line
149
+ map_attribute "DiagonalLineInFront", to: :diagonal_line_in_front
150
+ map_attribute "DiagonalLineStrokeWeight",
151
+ to: :diagonal_line_stroke_weight
152
+ map_attribute "DiagonalLineStrokeType", to: :diagonal_line_stroke_type
153
+ map_attribute "DiagonalLineStrokeColor", to: :diagonal_line_stroke_color
154
+ map_attribute "DiagonalLineStrokeTint", to: :diagonal_line_stroke_tint
155
+ map_attribute "DiagonalLineStrokeOverprint",
156
+ to: :diagonal_line_stroke_overprint
157
+ map_attribute "DiagonalLineStrokeGapColor",
158
+ to: :diagonal_line_stroke_gap_color
159
+ map_attribute "DiagonalLineStrokeGapTint",
160
+ to: :diagonal_line_stroke_gap_tint
161
+ map_attribute "DiagonalLineStrokeGapOverprint",
162
+ to: :diagonal_line_stroke_gap_overprint
163
+ map_attribute "ClipContentToCell", to: :clip_content_to_cell
164
+ map_attribute "FirstBaselineOffset", to: :first_baseline_offset
165
+ map_attribute "MinimumFirstBaselineOffset",
166
+ to: :minimum_first_baseline_offset
167
+ map_attribute "LeftEdgeStrokeWeight", to: :left_edge_stroke_weight
168
+ map_attribute "LeftEdgeStrokeType", to: :left_edge_stroke_type
169
+ map_attribute "LeftEdgeStrokeColor", to: :left_edge_stroke_color
170
+ map_attribute "LeftEdgeStrokeTint", to: :left_edge_stroke_tint
171
+ map_attribute "LeftEdgeStrokeOverprint", to: :left_edge_stroke_overprint
172
+ map_attribute "LeftEdgeStrokeGapColor", to: :left_edge_stroke_gap_color
173
+ map_attribute "LeftEdgeStrokeGapTint", to: :left_edge_stroke_gap_tint
174
+ map_attribute "LeftEdgeStrokeGapOverprint",
175
+ to: :left_edge_stroke_gap_overprint
176
+ map_attribute "TopEdgeStrokeWeight", to: :top_edge_stroke_weight
177
+ map_attribute "TopEdgeStrokeType", to: :top_edge_stroke_type
178
+ map_attribute "TopEdgeStrokeColor", to: :top_edge_stroke_color
179
+ map_attribute "TopEdgeStrokeTint", to: :top_edge_stroke_tint
180
+ map_attribute "TopEdgeStrokeOverprint", to: :top_edge_stroke_overprint
181
+ map_attribute "TopEdgeStrokeGapColor", to: :top_edge_stroke_gap_color
182
+ map_attribute "TopEdgeStrokeGapTint", to: :top_edge_stroke_gap_tint
183
+ map_attribute "TopEdgeStrokeGapOverprint",
184
+ to: :top_edge_stroke_gap_overprint
185
+ map_attribute "RightEdgeStrokeWeight", to: :right_edge_stroke_weight
186
+ map_attribute "RightEdgeStrokeType", to: :right_edge_stroke_type
187
+ map_attribute "RightEdgeStrokeColor", to: :right_edge_stroke_color
188
+ map_attribute "RightEdgeStrokeTint", to: :right_edge_stroke_tint
189
+ map_attribute "RightEdgeStrokeOverprint",
190
+ to: :right_edge_stroke_overprint
191
+ map_attribute "RightEdgeStrokeGapColor",
192
+ to: :right_edge_stroke_gap_color
193
+ map_attribute "RightEdgeStrokeGapTint", to: :right_edge_stroke_gap_tint
194
+ map_attribute "RightEdgeStrokeGapOverprint",
195
+ to: :right_edge_stroke_gap_overprint
196
+ map_attribute "BottomEdgeStrokeWeight", to: :bottom_edge_stroke_weight
197
+ map_attribute "BottomEdgeStrokeType", to: :bottom_edge_stroke_type
198
+ map_attribute "BottomEdgeStrokeColor", to: :bottom_edge_stroke_color
199
+ map_attribute "BottomEdgeStrokeTint", to: :bottom_edge_stroke_tint
200
+ map_attribute "BottomEdgeStrokeOverprint",
201
+ to: :bottom_edge_stroke_overprint
202
+ map_attribute "BottomEdgeStrokeGapColor",
203
+ to: :bottom_edge_stroke_gap_color
204
+ map_attribute "BottomEdgeStrokeGapTint",
205
+ to: :bottom_edge_stroke_gap_tint
206
+ map_attribute "BottomEdgeStrokeGapOverprint",
207
+ to: :bottom_edge_stroke_gap_overprint
208
+ map_attribute "InnerRowStrokeWeight", to: :inner_row_stroke_weight
209
+ map_attribute "InnerRowStrokeType", to: :inner_row_stroke_type
210
+ map_attribute "InnerRowStrokeColor", to: :inner_row_stroke_color
211
+ map_attribute "InnerRowStrokeTint", to: :inner_row_stroke_tint
212
+ map_attribute "InnerRowStrokeOverprint", to: :inner_row_stroke_overprint
213
+ map_attribute "InnerRowStrokeGapColor", to: :inner_row_stroke_gap_color
214
+ map_attribute "InnerRowStrokeGapTint", to: :inner_row_stroke_gap_tint
215
+ map_attribute "InnerRowStrokeGapOverprint",
216
+ to: :inner_row_stroke_gap_overprint
217
+ map_attribute "InnerColumnStrokeWeight", to: :inner_column_stroke_weight
218
+ map_attribute "InnerColumnStrokeType", to: :inner_column_stroke_type
219
+ map_attribute "InnerColumnStrokeColor", to: :inner_column_stroke_color
220
+ map_attribute "InnerColumnStrokeTint", to: :inner_column_stroke_tint
221
+ map_attribute "InnerColumnStrokeOverprint",
222
+ to: :inner_column_stroke_overprint
223
+ map_attribute "InnerColumnStrokeGapColor",
224
+ to: :inner_column_stroke_gap_color
225
+ map_attribute "InnerColumnStrokeGapTint",
226
+ to: :inner_column_stroke_gap_tint
227
+ map_attribute "InnerColumnStrokeGapOverprint",
228
+ to: :inner_column_stroke_gap_overprint
229
+ map_attribute "TopEdgeStrokePriority", to: :top_edge_stroke_priority
230
+ map_attribute "LeftEdgeStrokePriority", to: :left_edge_stroke_priority
231
+ map_attribute "BottomEdgeStrokePriority",
232
+ to: :bottom_edge_stroke_priority
233
+ map_attribute "RightEdgeStrokePriority", to: :right_edge_stroke_priority
234
+ map_attribute "GraphicLeftInset", to: :graphic_left_inset
235
+ map_attribute "GraphicTopInset", to: :graphic_top_inset
236
+ map_attribute "GraphicRightInset", to: :graphic_right_inset
237
+ map_attribute "GraphicBottomInset", to: :graphic_bottom_inset
238
+ map_attribute "ClipContentToGraphicCell",
239
+ to: :clip_content_to_graphic_cell
240
+ map_attribute "WritingDirection", to: :writing_direction
241
+ map_attribute "AppliedCellStylePriority",
242
+ to: :applied_cell_style_priority
61
243
  map_element "ParagraphStyleRange", to: :paragraph_style_range
62
244
  end
63
245
 
@@ -128,11 +128,16 @@ module Idml
128
128
  size: size)
129
129
  lines = TextEngine::LineBreaker.break(glyphs: glyphs,
130
130
  frame_width: wrap_width)
131
+ limits = TextEngine::Justifier::SpacingLimits.new(
132
+ max_word_spacing: paragraph.maximum_word_spacing,
133
+ max_letter_spacing: paragraph.maximum_letter_spacing,
134
+ )
131
135
  lines.each_with_index do |line, index|
132
136
  TextEngine::Justifier.justify(
133
137
  line: line, frame_width: wrap_width,
134
138
  alignment: paragraph.alignment || :left,
135
- last_line: last_line?(paragraph, run, index, lines)
139
+ last_line: last_line?(paragraph, run, index, lines),
140
+ limits: limits
136
141
  )
137
142
  end
138
143
  leading = TextEngine::VerticalLayout.leading_for(
@@ -76,7 +76,8 @@ module Idml
76
76
  cell_x, cell_y, cell_w, cell_h, context)
77
77
  render_cell_background(canvas, cell, cell_x, cell_y, cell_w, cell_h,
78
78
  context)
79
- render_cell_border(canvas, cell_x, cell_y, cell_w, cell_h)
79
+ render_cell_border(canvas, cell, table, cell_x, cell_y,
80
+ cell_w, cell_h, context)
80
81
  render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
81
82
  end
82
83
  end
@@ -113,12 +114,102 @@ module Idml
113
114
  end
114
115
  private_class_method :render_cell_background
115
116
 
116
- def self.render_cell_border(canvas, x, y, w, h)
117
- canvas.rectangle(x, y, w, h)
118
- canvas.stroke
117
+ # Cell borders: per-side strokes when the cell or table
118
+ # declare edge weights (legacy single rect otherwise, so
119
+ # undeclared tables keep their existing look). Top/bottom
120
+ # fall back to the table's default row stroke, left/right
121
+ # to the default column stroke; zero-weight sides draw
122
+ # nothing.
123
+ def self.render_cell_border(canvas, cell, table, x, y, w, h, context)
124
+ sides = cell_edge_weights(cell, table)
125
+ if sides.nil?
126
+ canvas.rectangle(x, y, w, h)
127
+ canvas.stroke
128
+ return
129
+ end
130
+
131
+ top, right, bottom, left = sides
132
+ stroke_cell_side(canvas, context,
133
+ cell.top_edge_stroke_color,
134
+ table.default_row_stroke_color, top) do
135
+ canvas.move_to(x, y + h)
136
+ canvas.line_to(x + w, y + h)
137
+ end
138
+ stroke_cell_side(canvas, context,
139
+ cell.bottom_edge_stroke_color,
140
+ table.default_row_stroke_color, bottom) do
141
+ canvas.move_to(x, y)
142
+ canvas.line_to(x + w, y)
143
+ end
144
+ stroke_cell_side(canvas, context,
145
+ cell.left_edge_stroke_color,
146
+ table.default_column_stroke_color, left) do
147
+ canvas.move_to(x, y)
148
+ canvas.line_to(x, y + h)
149
+ end
150
+ stroke_cell_side(canvas, context,
151
+ cell.right_edge_stroke_color,
152
+ table.default_column_stroke_color, right) do
153
+ canvas.move_to(x + w, y)
154
+ canvas.line_to(x + w, y + h)
155
+ end
119
156
  end
120
157
  private_class_method :render_cell_border
121
158
 
159
+ # [top, right, bottom, left] effective weights, or nil when
160
+ # neither cell edges nor table defaults declare any stroke
161
+ # (the legacy uniform-rect case).
162
+ def self.cell_edge_weights(cell, table)
163
+ return nil unless stroke_declared?(cell, table)
164
+
165
+ [
166
+ side_weight(cell.top_edge_stroke_weight,
167
+ table.default_row_stroke_weight),
168
+ side_weight(cell.right_edge_stroke_weight,
169
+ table.default_column_stroke_weight),
170
+ side_weight(cell.bottom_edge_stroke_weight,
171
+ table.default_row_stroke_weight),
172
+ side_weight(cell.left_edge_stroke_weight,
173
+ table.default_column_stroke_weight),
174
+ ]
175
+ end
176
+ private_class_method :cell_edge_weights
177
+
178
+ def self.stroke_declared?(cell, table)
179
+ [
180
+ cell.top_edge_stroke_weight, cell.right_edge_stroke_weight,
181
+ cell.bottom_edge_stroke_weight, cell.left_edge_stroke_weight,
182
+ table.default_row_stroke_weight,
183
+ table.default_column_stroke_weight
184
+ ].compact.any?
185
+ end
186
+ private_class_method :stroke_declared?
187
+
188
+ def self.side_weight(cell_weight, default_weight)
189
+ cell_weight || default_weight || 0
190
+ end
191
+ private_class_method :side_weight
192
+
193
+ def self.stroke_cell_side(canvas, context, cell_color,
194
+ table_color, weight)
195
+ thickness = weight.to_f
196
+ return unless thickness.positive?
197
+
198
+ apply_cell_stroke_color(canvas, context, cell_color, table_color)
199
+ canvas.line_width = thickness
200
+ yield
201
+ canvas.stroke
202
+ end
203
+ private_class_method :stroke_cell_side
204
+
205
+ def self.apply_cell_stroke_color(canvas, context, cell_color,
206
+ table_color)
207
+ name = cell_color || table_color
208
+ color = name && context.color_resolver&.resolve(name)
209
+ canvas.stroke_color(color ? ColorHelper.to_canvas(color) : [:gray, 1.0])
210
+ end
211
+ private_class_method :apply_cell_stroke_color
212
+
122
213
  def self.cell_fill_color(cell, context)
123
214
  return nil unless cell.fill_color
124
215
  return nil if cell.fill_color == "Color/None"
@@ -410,6 +410,7 @@ module Idml
410
410
  pending = 0
411
411
  state.paragraphs.each do |paragraph|
412
412
  break if cursor_y < bottom_limit
413
+ break if paragraph_break?(paragraph) && pending.positive?
413
414
 
414
415
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
415
416
  canvas, paragraph, paragraph.runs, context,
@@ -741,16 +742,30 @@ module Idml
741
742
  end
742
743
  private_class_method :layout_run
743
744
 
745
+ # True when the paragraph requests a forced break to the
746
+ # next frame/column (StartParagraph). All break flavors act
747
+ # at frame/column granularity.
748
+ def self.paragraph_break?(paragraph)
749
+ %w[NextPage NextColumn NextFrame NextOddPage NextEvenPage]
750
+ .include?(paragraph.start_paragraph)
751
+ end
752
+ private_class_method :paragraph_break?
753
+
744
754
  # Justifies the run's lines; only the final line of the
745
755
  # paragraph's final run stays ragged under full
746
756
  # justification.
747
757
  def self.justify_lines(lines, paragraph, wrap_width,
748
758
  paragraph_last)
759
+ limits = TextEngine::Justifier::SpacingLimits.new(
760
+ max_word_spacing: paragraph.maximum_word_spacing,
761
+ max_letter_spacing: paragraph.maximum_letter_spacing,
762
+ )
749
763
  lines.each_with_index do |line, index|
750
764
  TextEngine::Justifier.justify(
751
765
  line: line, frame_width: wrap_width,
752
766
  alignment: paragraph.alignment || :left,
753
- last_line: paragraph_last && index == lines.length - 1
767
+ last_line: paragraph_last && index == lines.length - 1,
768
+ limits: limits
754
769
  )
755
770
  end
756
771
  end
@@ -64,6 +64,12 @@ module Idml
64
64
  :right_indent,
65
65
  :auto_leading,
66
66
  :drop_cap_lines,
67
+ # Justification distribution caps (percent; from PSR).
68
+ :maximum_word_spacing,
69
+ :maximum_letter_spacing,
70
+ # Forced paragraph break (StartParagraph: NextPage /
71
+ # NextColumn / NextFrame / NextOddPage / NextEvenPage).
72
+ :start_paragraph,
67
73
  :bullets_and_numbering_list_type,
68
74
  :bullet_character_value,
69
75
  :bullets_text_after,
@@ -183,6 +189,14 @@ module Idml
183
189
  left_indent: resolve_attr(style_lookup, psr, :left_indent),
184
190
  right_indent: resolve_attr(style_lookup, psr, :right_indent),
185
191
  auto_leading: resolve_attr(style_lookup, psr, :auto_leading),
192
+ maximum_word_spacing: resolve_attr(
193
+ style_lookup, psr, :maximum_word_spacing
194
+ ),
195
+ maximum_letter_spacing: resolve_attr(
196
+ style_lookup, psr, :maximum_letter_spacing
197
+ ),
198
+ start_paragraph: resolve_attr(style_lookup, psr,
199
+ :start_paragraph),
186
200
  drop_cap_lines: resolve_attr(style_lookup, psr, :drop_cap_lines),
187
201
  drop_cap_characters: resolve_attr(style_lookup, psr,
188
202
  :drop_cap_characters),
@@ -7,15 +7,25 @@ module Idml
7
7
  # spaces for justified text. `last_line:` (the paragraph's final
8
8
  # line) keeps ragged right under :justified, as InDesign does.
9
9
  class Justifier
10
+ # Justification distribution caps, in percent (100 = natural).
11
+ # InDesign defaults: word spacing max 133%, letter spacing 0%.
12
+ SpacingLimits = Struct.new(
13
+ :max_word_spacing, :max_letter_spacing, keyword_init: true
14
+ )
15
+
16
+ DEFAULT_MAX_WORD_SPACING = 133.0
17
+ DEFAULT_MAX_LETTER_SPACING = 0.0
18
+
10
19
  def self.justify(line:, frame_width:, alignment: :left,
11
- last_line: false)
12
- new(frame_width, alignment, last_line).justify(line)
20
+ last_line: false, limits: nil)
21
+ new(frame_width, alignment, last_line, limits).justify(line)
13
22
  end
14
23
 
15
- def initialize(frame_width, alignment, last_line)
24
+ def initialize(frame_width, alignment, last_line, limits)
16
25
  @frame_width = frame_width
17
26
  @alignment = alignment
18
27
  @last_line = last_line
28
+ @limits = limits
19
29
  end
20
30
 
21
31
  def justify(line)
@@ -45,13 +55,42 @@ module Idml
45
55
  slack = @frame_width - line.width
46
56
  return if spaces.zero? || slack <= 0
47
57
 
48
- extra = slack / spaces
58
+ word_share = [slack, word_capacity(line, spaces)].min
59
+ extra = word_share / spaces
49
60
  line.glyphs.each do |glyph|
50
61
  next unless glyph.is_space
51
62
 
52
63
  glyph.width += extra
53
64
  end
65
+ residual = slack - word_share
66
+ distribute_letter_spacing(line, residual) if residual.positive?
67
+ end
68
+
69
+ # Total room the word spaces may grow within the max-word-
70
+ # spacing cap (percent of each space's natural width).
71
+ def word_capacity(line, _spaces)
72
+ max_pct = @limits&.max_word_spacing || DEFAULT_MAX_WORD_SPACING
73
+ grow = [(max_pct - 100) / 100.0, 0].max
74
+ line.glyphs.sum { |g| g.is_space ? g.width * grow : 0.0 } * 1.0
75
+ end
76
+ private :word_capacity
77
+
78
+ # Even per-glyph letter spacing for residual slack, capped at
79
+ # max_letter_spacing percent of the natural space width. With
80
+ # the default 0% cap nothing is added — the line ends short,
81
+ # as InDesign does when limits bind.
82
+ def distribute_letter_spacing(line, residual)
83
+ max_pct = @limits&.max_letter_spacing || DEFAULT_MAX_LETTER_SPACING
84
+ return unless max_pct.positive?
85
+
86
+ space_ref = line.glyphs.find(&:is_space)&.width || 0
87
+ return unless space_ref.positive?
88
+
89
+ cap = (max_pct / 100.0) * space_ref * line.glyphs.length
90
+ add = [residual, cap].min / line.glyphs.length
91
+ line.glyphs.each { |glyph| glyph.width += add }
54
92
  end
93
+ private :distribute_letter_spacing
55
94
  end
56
95
  end
57
96
  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.9.3"
4
+ VERSION = "0.9.4"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.3
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-18 00:00:00.000000000 Z
11
+ date: 2026-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -176,7 +176,10 @@ files:
176
176
  - TODO.pdf/116-justification-last-line.md
177
177
  - TODO.pdf/117-endnote-support.md
178
178
  - TODO.pdf/118-table-band-fills.md
179
+ - TODO.pdf/119-justification-spacing-limits.md
179
180
  - TODO.pdf/12-idml-to-pdf-pipeline.md
181
+ - TODO.pdf/120-start-paragraph-breaks.md
182
+ - TODO.pdf/121-cell-edge-strokes.md
180
183
  - TODO.pdf/13-cjk-text-layout.md
181
184
  - TODO.pdf/14-page-item-element-models.md
182
185
  - TODO.pdf/15-wire-spread-children.md