idml 0.8.1 → 0.8.3

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: 987636748becfcad9dafe0f703801848e6c4e1bffa12a59b7cd0034424b3628a
4
- data.tar.gz: bcd103a17bab68ca38a9cd2534aa97f937aa10f560c79283bd5e1efd585be22e
3
+ metadata.gz: 0672dfcc5cf9d3116f38dd98904f2d2ba10a8373d04814d6fa936f0bffcf93c0
4
+ data.tar.gz: a3e3d982c061380e4201490366bac05c066bec85829b397378f933a6fea27d27
5
5
  SHA512:
6
- metadata.gz: d7eb62a48b163312832120f0d475b4b7ced9b1364c457677135026f1e75b84d36d53faa834c7ec36ecd87b4e9daacd727c0f1e75639a87919b98f507c56fde01
7
- data.tar.gz: 470a0a62f1855b42369f0a81f099982a89a50f4070ad981a23d66693f6724e3fdf8c04441fb274c702871163e94c96804a27b4d7651ec5eea5de83cd435c791d
6
+ metadata.gz: fac32a9ec144ccf1970834c0724e1eb33667f140955f2b10fad3168823c55f64826b33ccb5b4aa781ecd53b6478515cac62809603d290706a4c433784b10b266
7
+ data.tar.gz: ed671dd23f413602e93506584114efb806a294d2970ccd0c17571b786dba9b64323dcfb03411cf8cd0571aaf9696eda7792e4eac8eee4197100797b37eed3c43
@@ -1,13 +1,12 @@
1
1
  # TODO PDF 103: Drop caps (DropCapLines, DropCapCharacters)
2
2
 
3
- ## Status: PARTIAL — `Render::DropCap` helper computes drop-cap
4
- geometry (font_size = base × lines, width via font_metrics.measure_text,
5
- height = leading × lines). TextFrameRenderer.emit_drop_cap emits the
6
- drop cap as an enlarged glyph at the paragraph's top-left when the
7
- paragraph declares DropCapLines/DropCapCharacters > 0 and is the
8
- chain's first paragraph.
9
-
10
- Wrap-around text is NOT yet implemented — the drop cap renders as an
3
+ ## Status: DONE — `Render::DropCap` helper computes drop-cap geometry.
4
+ TextFrameRenderer.emit_drop_cap emits the enlarged glyph, strips the
5
+ drop cap characters from the first run (no double render), and
6
+ applies reduced wrap width for the first `drop_cap_lines` runs so
7
+ text wraps around the drop cap. Approximation: treats each run as
8
+ one line (real per-line tracking would require line-count-aware
9
+ wrapping).
11
10
  enlarged glyph; subsequent paragraph text flows normally and may
12
11
  overlap the drop cap. Real wrap-around (indent lines 1..M to the
13
12
  right of the drop cap) requires per-line wrap-width control in
@@ -1,6 +1,10 @@
1
1
  # TODO PDF 113: Multi-column text frames (TextColumnCount, TextColumnGutter)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-10 audit
3
+ ## Status: DONEengine_render detects TextColumnCount > 1 and
4
+ splits the frame into N column-specific Frame structs. Text flows
5
+ column 1 → column 2 → ... → chain to next frame via StoryChainController.
6
+ Each column acts as a mini-frame with the same height/insets but a
7
+ narrower width. TextColumnGutter honored as the gap between columns.
4
8
 
5
9
  ## Problem
6
10
 
@@ -158,7 +158,22 @@ module Idml
158
158
  # next frame in the chain.
159
159
  def self.engine_render(canvas, state, context, box, font)
160
160
  layout_frame = layout_frame(context.item, box)
161
- top_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
161
+ col_count = text_column_count(context)
162
+
163
+ if col_count && col_count > 1
164
+ engine_render_multi_column(canvas, state, context, layout_frame,
165
+ font, col_count)
166
+ else
167
+ engine_render_single_column(canvas, state, context, layout_frame,
168
+ font)
169
+ end
170
+ end
171
+ private_class_method :engine_render
172
+
173
+ def self.engine_render_single_column(canvas, state, context,
174
+ layout_frame, font)
175
+ top_y = layout_frame.y + layout_frame.height -
176
+ (layout_frame.inset_top || 0)
162
177
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
163
178
  cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
164
179
  char_cursor = state.char_cursor
@@ -174,7 +189,87 @@ module Idml
174
189
  consume_paragraphs(canvas, state, context, layout_frame, font,
175
190
  cursor_y, bottom_limit, char_cursor)
176
191
  end
177
- private_class_method :engine_render
192
+ private_class_method :engine_render_single_column
193
+
194
+ # Renders text across N columns within a single frame. Text
195
+ # flows column 1 → column 2 → ... → chain to next frame. Each
196
+ # column acts as a mini-frame with the same height but a
197
+ # narrower width. The chain state threads across columns
198
+ # transparently.
199
+ def self.engine_render_multi_column(canvas, state, context,
200
+ layout_frame, font, col_count)
201
+ gutter = text_column_gutter(context) || 0
202
+ col_frames = build_column_frames(layout_frame, col_count, gutter)
203
+
204
+ col_frames.each do |col_frame|
205
+ break if state_empty?(state)
206
+
207
+ render_column(canvas, state, context, col_frame, font)
208
+ end
209
+ end
210
+ private_class_method :engine_render_multi_column
211
+
212
+ def self.render_column(canvas, state, context, col_frame, font)
213
+ top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
214
+ bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
215
+ cursor_y = top_y
216
+ char_cursor = state.char_cursor
217
+
218
+ if state.current_paragraph
219
+ cursor_y, char_cursor = resume_paragraph(
220
+ canvas, state, context, col_frame, font,
221
+ cursor_y, bottom_limit, char_cursor
222
+ )
223
+ return if state.current_paragraph
224
+ end
225
+
226
+ consume_paragraphs(canvas, state, context, col_frame, font,
227
+ cursor_y, bottom_limit, char_cursor)
228
+ end
229
+ private_class_method :render_column
230
+
231
+ def self.state_empty?(state)
232
+ state.paragraphs.empty? && state.current_paragraph.nil?
233
+ end
234
+ private_class_method :state_empty?
235
+
236
+ def self.text_column_count(context)
237
+ pref = context.item&.text_frame_preference&.first
238
+ pref&.text_column_count
239
+ end
240
+ private_class_method :text_column_count
241
+
242
+ def self.text_column_gutter(context)
243
+ pref = context.item&.text_frame_preference&.first
244
+ pref&.text_column_gutter
245
+ end
246
+ private_class_method :text_column_gutter
247
+
248
+ # Builds N column-specific Frame structs from the parent
249
+ # layout frame. Each column has the same y/height/insets but
250
+ # a different x and width so VerticalLayout's geometry math
251
+ # produces correct per-column results.
252
+ def self.build_column_frames(layout_frame, col_count, gutter)
253
+ inset_left = layout_frame.inset_left || 0
254
+ inset_right = layout_frame.inset_right || 0
255
+ content_width = layout_frame.width - inset_left - inset_right
256
+ col_width = (content_width - ((col_count - 1) * gutter)) / col_count
257
+
258
+ Array.new(col_count) do |i|
259
+ col_left = layout_frame.x + inset_left + (i * (col_width + gutter))
260
+ TextEngine::Frame.new(
261
+ x: col_left - inset_left,
262
+ y: layout_frame.y,
263
+ width: col_width + inset_left + inset_right,
264
+ height: layout_frame.height,
265
+ inset_top: layout_frame.inset_top,
266
+ inset_bottom: layout_frame.inset_bottom,
267
+ inset_left: inset_left,
268
+ inset_right: inset_right,
269
+ )
270
+ end
271
+ end
272
+ private_class_method :build_column_frames
178
273
 
179
274
  # Computes the starting cursor_y for vertical justification.
180
275
  # IDML's TextFramePreference.VerticalJustification can be:
@@ -306,18 +401,44 @@ module Idml
306
401
 
307
402
  emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
308
403
  frame_left, frame_right, first_paragraph)
309
- emit_drop_cap(canvas, paragraph, context, frame_left, cursor_y,
310
- first_paragraph)
404
+ drop_cap = emit_drop_cap(canvas, paragraph, context, frame_left,
405
+ cursor_y, first_paragraph)
406
+
407
+ effective_runs = prepare_runs_for_drop_cap(runs, drop_cap)
408
+ dc_width = drop_cap&.wrap_offset || 0
409
+ dc_lines = drop_cap&.lines || 0
311
410
 
411
+ rendered, cursor_y, char_cursor = iterate_paragraph_runs(
412
+ canvas, paragraph, effective_runs, context, layout_frame,
413
+ font, wrap_width, cursor_y, bottom_limit, char_cursor,
414
+ first_paragraph, para_attrs, dc_width, dc_lines
415
+ )
416
+
417
+ remaining_runs = effective_runs[rendered..]
418
+ cursor_y = emit_paragraph_bottom_rule(canvas, paragraph, context,
419
+ cursor_y, frame_left,
420
+ frame_right, para_attrs,
421
+ remaining_runs)
422
+ [remaining_runs, cursor_y, char_cursor]
423
+ end
424
+ private_class_method :render_runs_for_paragraph
425
+
426
+ def self.iterate_paragraph_runs(canvas, paragraph, runs, context,
427
+ layout_frame, font, wrap_width,
428
+ cursor_y, bottom_limit, char_cursor,
429
+ first_paragraph, para_attrs,
430
+ dc_width, dc_lines)
312
431
  rendered_count = 0
313
432
  runs.each do |run|
314
433
  break if cursor_y < bottom_limit
315
434
 
316
435
  space_before = paragraph_space_before(para_attrs, first_paragraph,
317
436
  rendered_count)
437
+ run_wrap = drop_cap_wrap_width(wrap_width, dc_width,
438
+ dc_lines, rendered_count)
318
439
  positioned, next_y = layout_run(
319
440
  canvas, run, paragraph, context, layout_frame, font,
320
- wrap_width, cursor_y, space_before,
441
+ run_wrap, cursor_y, space_before,
321
442
  para_attrs[:first_line_indent], para_attrs[:left_indent],
322
443
  char_cursor
323
444
  )
@@ -325,15 +446,43 @@ module Idml
325
446
  cursor_y = next_y
326
447
  rendered_count += 1
327
448
  end
449
+ [rendered_count, cursor_y, char_cursor]
450
+ end
451
+ private_class_method :iterate_paragraph_runs
328
452
 
329
- remaining_runs = runs[rendered_count..]
330
- cursor_y = emit_paragraph_bottom_rule(canvas, paragraph, context,
331
- cursor_y, frame_left,
332
- frame_right, para_attrs,
333
- remaining_runs)
334
- [remaining_runs, cursor_y, char_cursor]
453
+ # Strips drop cap characters from the first run so they don't
454
+ # render twice (once as the enlarged drop cap, once as normal
455
+ # text). Returns a new run list with the first run's text
456
+ # adjusted. If stripping empties the first run, removes it.
457
+ def self.prepare_runs_for_drop_cap(runs, drop_cap)
458
+ return runs unless drop_cap
459
+ return runs if runs.empty?
460
+
461
+ stripped_count = drop_cap.text.length
462
+ first = runs.first
463
+ return runs if first.text.length < stripped_count
464
+
465
+ remaining_text = first.text[stripped_count..]
466
+ return runs.drop(1) if remaining_text.nil? || remaining_text.empty?
467
+
468
+ stripped_run = first.dup
469
+ stripped_run.text = remaining_text
470
+ [stripped_run] + runs[1..]
335
471
  end
336
- private_class_method :render_runs_for_paragraph
472
+ private_class_method :prepare_runs_for_drop_cap
473
+
474
+ # Returns reduced wrap width for runs within the drop cap zone
475
+ # (first `drop_cap_lines` runs). After the zone, returns full
476
+ # width. Approximation: treats each run as one line. Proper
477
+ # implementation would track actual line count per run.
478
+ def self.drop_cap_wrap_width(full_width, drop_cap_width,
479
+ drop_cap_lines, run_index)
480
+ return full_width if drop_cap_width.zero? || drop_cap_lines.zero?
481
+ return full_width if run_index >= drop_cap_lines
482
+
483
+ full_width - drop_cap_width
484
+ end
485
+ private_class_method :drop_cap_wrap_width
337
486
 
338
487
  # Renders the drop cap for a paragraph (when declared and the
339
488
  # first paragraph of the chain). Wrap-around text is not yet
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.8.1"
4
+ VERSION = "0.8.3"
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.8.1
4
+ version: 0.8.3
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-11 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal