idml 0.10.9 → 0.10.11

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.
@@ -27,6 +27,13 @@ module Idml
27
27
  DEFAULT_SIZE = 12.0
28
28
  RUBY_SIZE_FACTOR = 0.5
29
29
 
30
+ # Vertical-writing wing (MECE split; shares this class's
31
+ # constants, layout_frame, and font resolution).
32
+ extend VerticalEmit
33
+
34
+ # No-metrics fallback path (MECE split; rough output).
35
+ extend SimpleText
36
+
30
37
  def self.render(canvas, context)
31
38
  frame = context.item
32
39
  return unless frame.parent_story
@@ -221,231 +228,6 @@ module Idml
221
228
 
222
229
  # True when the story's StoryPreference declares vertical
223
230
  # writing (StoryOrientation="Vertical").
224
- def self.vertical_story?(story)
225
- story.inner&.story_preference&.story_orientation == "Vertical"
226
- end
227
- private_class_method :vertical_story?
228
-
229
- # Vertical writing path (StoryOrientation="Vertical", CJK):
230
- # paragraphs flow as upright glyph columns advancing
231
- # right-to-left; each run starts a fresh column (run-mixing
232
- # within a column is not modeled). Runs whose columns would
233
- # cross the frame's left inset overflow to the next frame in
234
- # the chain. Paragraph spacing, rules, and decorations are
235
- # not applied in vertical mode.
236
- def self.vertical_render(canvas, state, context, box, font)
237
- layout_frame = layout_frame(context.item, box)
238
- left_limit = layout_frame.x + (layout_frame.inset_left || 0)
239
- resume_vertical_paragraph(state)
240
- next_column = state.column_offset || 0
241
- pending = 0
242
-
243
- state.paragraphs.each do |paragraph|
244
- remaining_runs, next_column = vertical_paragraph(
245
- canvas, paragraph, context, layout_frame, font,
246
- left_limit, next_column
247
- )
248
- if remaining_runs.empty?
249
- pending += 1
250
- else
251
- state.current_paragraph = paragraph
252
- state.runs_remaining = remaining_runs
253
- state.column_offset = next_column
254
- break
255
- end
256
- end
257
-
258
- state.paragraphs = state.paragraphs[pending..]
259
- end
260
- private_class_method :vertical_render
261
-
262
- # Folds a chain-resumed paragraph (runs_remaining) back to
263
- # the head of the paragraph list so the vertical path sees a
264
- # uniform sequence. The consumed column offset persists in
265
- # the state so the next frame continues right where this
266
- # frame's columns ended.
267
- def self.resume_vertical_paragraph(state)
268
- return unless state.current_paragraph
269
-
270
- state.current_paragraph.runs = state.runs_remaining
271
- state.paragraphs.unshift(state.current_paragraph)
272
- state.current_paragraph = nil
273
- state.runs_remaining = []
274
- end
275
- private_class_method :resume_vertical_paragraph
276
-
277
- # Renders one paragraph's runs as vertical columns starting
278
- # at `start_column`. Returns [runs that did not fit (empty
279
- # when all placed), the next unused column index].
280
- def self.vertical_paragraph(canvas, paragraph, context,
281
- layout_frame, font, left_limit,
282
- start_column)
283
- next_column = start_column
284
- paragraph.runs.each_with_index do |run, index|
285
- size = run.point_size || DEFAULT_SIZE
286
- leading = TextEngine::VerticalLayout.leading_for(
287
- paragraph.auto_leading, size
288
- )
289
- glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
290
- size: size)
291
- grouped = tatechuyoko_group(run, glyphs, layout_frame,
292
- leading, size, next_column)
293
- positioned, next_column = grouped ||
294
- TextEngine::VerticalTextLayout.layout(
295
- glyphs: glyphs, frame: layout_frame, leading: leading,
296
- size: size, start_column: next_column
297
- )
298
- unless vertical_fits?(layout_frame, leading, next_column,
299
- left_limit)
300
- return [paragraph.runs[index..], start_column]
301
- end
302
-
303
- emit_vertical_run(canvas, positioned, grouped, run, context,
304
- size)
305
- emit_vertical_ruby(canvas, run, positioned, layout_frame,
306
- size, context)
307
- end
308
- [[], next_column]
309
- end
310
- private_class_method :vertical_paragraph
311
-
312
- # A tate-chu-yoko group for runs that declare it (and whose
313
- # glyphs fit horizontally in a column slot); nil otherwise.
314
- def self.tatechuyoko_group(run, glyphs, layout_frame, leading,
315
- size, start_column)
316
- return nil unless run.tatechuyoko
317
-
318
- TextEngine::VerticalTextLayout.tatechuyoko_group(
319
- glyphs: glyphs, frame: layout_frame, leading: leading,
320
- size: size, start_column: start_column
321
- )
322
- end
323
- private_class_method :tatechuyoko_group
324
-
325
- # Emits a vertical run's glyphs: rotated/normal vertical
326
- # handling for stacked columns, plain upright emission for
327
- # tate-chu-yoko groups.
328
- def self.emit_vertical_run(canvas, positioned, grouped, run,
329
- context, size)
330
- if grouped
331
- positioned.each do |glyph|
332
- emit_upright_glyph(canvas, glyph, run, context, size)
333
- end
334
- else
335
- emit_vertical_stack(canvas, positioned, run, context, size)
336
- end
337
- end
338
- private_class_method :emit_vertical_run
339
-
340
- # Vertical-stack emission: CJK glyphs stand upright per
341
- # glyph; consecutive non-CJK glyphs rotate as ONE group
342
- # (the whole Latin word sideways, as InDesign does). The
343
- # group's string renders as a single text op inside one
344
- # rotated graphics state — the font's advances land each
345
- # glyph exactly where per-glyph rotation placed it. A group
346
- # never spans columns (x reset) and shares the run's
347
- # styling, so one text op is lossless.
348
- def self.emit_vertical_stack(canvas, positioned, run, context,
349
- size)
350
- segment = []
351
- positioned.each do |glyph|
352
- if TextEngine::CjkLayout.cjk?(glyph.codepoint)
353
- flush_vertical_segment(canvas, segment, run, context, size)
354
- emit_upright_glyph(canvas, glyph, run, context, size)
355
- else
356
- if !segment.empty? && segment.last.x != glyph.x
357
- flush_vertical_segment(canvas, segment, run, context, size)
358
- end
359
- segment << glyph
360
- end
361
- end
362
- flush_vertical_segment(canvas, segment, run, context, size)
363
- end
364
- private_class_method :emit_vertical_stack
365
-
366
- def self.flush_vertical_segment(canvas, segment, run, context,
367
- size)
368
- return if segment.empty?
369
-
370
- text = segment.map { |g| [g.codepoint].pack("U") }.join
371
- first = segment.first
372
- canvas.save_graphics_state do
373
- # 90° clockwise: exact matrix (rotate would emit
374
- # cos/sin float noise like 6.1e-17).
375
- canvas.concat(0, -1, 1, 0, first.x, first.y)
376
- text_kwargs = CharacterStyle.text_kwargs(
377
- run,
378
- {
379
- at: [0, 0],
380
- font: font_for_run(run, context),
381
- size: size,
382
- },
383
- )
384
- canvas.text(text, **text_kwargs)
385
- end
386
- end
387
- private_class_method :flush_vertical_segment
388
-
389
- def self.emit_upright_glyph(canvas, positioned, run, context,
390
- size)
391
- text = [positioned.codepoint].pack("U")
392
- text_kwargs = CharacterStyle.text_kwargs(
393
- run,
394
- {
395
- at: [positioned.x, positioned.y],
396
- font: font_for_run(run, context),
397
- size: size,
398
- },
399
- )
400
- canvas.text(text, **text_kwargs)
401
- end
402
- private_class_method :emit_upright_glyph
403
-
404
- def self.vertical_fits?(layout_frame, leading, columns, left_limit)
405
- right = layout_frame.x + layout_frame.width -
406
- (layout_frame.inset_right || 0)
407
- (right - (columns * leading)) >= left_limit
408
- end
409
- private_class_method :vertical_fits?
410
-
411
- # Emits the run's ruby alongside its vertical glyphs:
412
- # stacked top-to-bottom beside the column — right side by
413
- # default, left for Below* RubyPosition values (the
414
- # horizontal above/below convention mirrored under
415
- # rotation).
416
- def self.emit_vertical_ruby(canvas, run, positioned, frame,
417
- base_size, context)
418
- ruby_text = run.ruby_string
419
- return if ruby_text.nil? || ruby_text.empty?
420
-
421
- ruby_size = run.ruby_font_size ||
422
- (base_size * RUBY_SIZE_FACTOR)
423
- x = ruby_vertical_x(positioned.first, run, base_size)
424
- y = frame.y + frame.height - (frame.inset_top || 0)
425
- ruby_text.each_codepoint do |codepoint|
426
- text_kwargs = CharacterStyle.text_kwargs(
427
- run,
428
- {
429
- at: [x, y - ruby_size],
430
- font: font_for_run(run, context),
431
- size: ruby_size,
432
- },
433
- )
434
- canvas.text([codepoint].pack("U"), **text_kwargs)
435
- y -= ruby_size
436
- end
437
- end
438
- private_class_method :emit_vertical_ruby
439
-
440
- def self.ruby_vertical_x(positioned, run, base_size)
441
- offset = base_size * 0.9
442
- if run.ruby_position.to_s.include?("Below")
443
- positioned.x - offset
444
- else
445
- positioned.x + offset
446
- end
447
- end
448
- private_class_method :ruby_vertical_x
449
231
 
450
232
  def self.chain_head?(frame)
451
233
  frame.previous_text_frame.nil? || frame.previous_text_frame == "n"
@@ -502,8 +284,10 @@ module Idml
502
284
  (layout_frame.inset_top || 0)
503
285
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
504
286
  footnotes = []
505
- cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
506
- cursor_y -= first_baseline_offset(context, state, font)
287
+ cursor_y = FrameMetrics.vertical_justify_top(top_y, bottom_limit,
288
+ state, context)
289
+ cursor_y -= FrameMetrics.first_baseline_offset(context, state,
290
+ font)
507
291
  char_cursor = state.char_cursor
508
292
 
509
293
  if state.current_paragraph
@@ -544,7 +328,9 @@ module Idml
544
328
  top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
545
329
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
546
330
  footnotes = []
547
- cursor_y = top_y - first_baseline_offset(context, state, font)
331
+ cursor_y = top_y - FrameMetrics.first_baseline_offset(
332
+ context, state, font
333
+ )
548
334
  char_cursor = state.char_cursor
549
335
 
550
336
  if state.current_paragraph
@@ -607,160 +393,6 @@ module Idml
607
393
  private_class_method :build_column_frames
608
394
 
609
395
  # Computes the starting cursor_y for vertical justification.
610
- # IDML's TextFramePreference.VerticalJustification can be:
611
- # - TopAlign (default): start at frame top, no offset
612
- # - CenterAlign: center the content block vertically
613
- # - BottomAlign: align content to the frame bottom
614
- # - JustifyAlign: distribute extra space between paragraphs
615
- # (deferred — falls back to TopAlign behavior)
616
- #
617
- # Content height is estimated by walking paragraphs and
618
- # summing leading × lines + space_before/after. Without
619
- # shaping/wrapping we can't know exact line count, so we
620
- # approximate by treating each run as one line.
621
- def self.vertical_justify_top(top_y, bottom_limit, state, context)
622
- justification = text_frame_vertical_justification(context)
623
- return top_y unless %w[CenterAlign BottomAlign].include?(justification)
624
-
625
- content_height = estimate_content_height(state)
626
- available = top_y - bottom_limit
627
- slack = [available - content_height, 0].max
628
- return top_y if slack.zero?
629
-
630
- case justification
631
- when "CenterAlign" then top_y - (slack / 2)
632
- when "BottomAlign" then top_y - slack
633
- else top_y
634
- end
635
- end
636
- private_class_method :vertical_justify_top
637
-
638
- # FirstBaselineOffset: how far below the frame's top inset
639
- # the first line's baseline sits. The layout's default is
640
- # leading-based; AscentOffset shifts by (ascent − leading)
641
- # and FixedHeight by (MinimumFirstBaselineOffset − leading).
642
- # CapHeight uses the font's cap-height metric (leading
643
- # fallback when the font reports none); XHeight approximates
644
- # as 70% of cap height; EmboxHeight uses the em box (the
645
- # first paragraph's point size). Returns the extra shift to
646
- # subtract from the cursor.
647
- FIRST_BASELINE_MODES = %w[
648
- AscentOffset FixedHeight CapHeight XHeight EmboxHeight
649
- ].freeze
650
-
651
- def self.first_baseline_offset(context, state, font)
652
- pref = context.item&.text_frame_preference&.first
653
- mode = pref&.first_baseline_offset
654
- return 0.0 unless FIRST_BASELINE_MODES.include?(mode)
655
-
656
- baseline_target(pref, mode, state, font) -
657
- first_paragraph_leading(state)
658
- end
659
- private_class_method :first_baseline_offset
660
-
661
- # Distance from the top inset to the first baseline under
662
- # the given mode.
663
- # pdfrb 0.7.1 never fills the cap_height metric, so
664
- # CapHeight / XHeight fall back to standard font
665
- # proportions (cap ≈ 0.72 em, x ≈ 0.52 em — Arial and
666
- # Helvetica both sit within 1% of these).
667
- CAP_HEIGHT_RATIO = 0.72
668
- X_HEIGHT_RATIO = 0.52
669
-
670
- def self.baseline_target(pref, mode, state, font)
671
- leading = first_paragraph_leading(state)
672
- case mode
673
- when "AscentOffset"
674
- ratio_scaled(font.ascent, font) * leading
675
- when "CapHeight"
676
- cap_ratio(font) * leading
677
- when "XHeight"
678
- X_HEIGHT_RATIO * leading
679
- when "EmboxHeight"
680
- first_paragraph_size(state)
681
- else
682
- pref.minimum_first_baseline_offset || leading
683
- end
684
- end
685
- private_class_method :baseline_target
686
-
687
- def self.cap_ratio(font)
688
- cap = font.cap_height
689
- return CAP_HEIGHT_RATIO unless cap
690
-
691
- ratio_scaled(cap, font)
692
- end
693
- private_class_method :cap_ratio
694
-
695
- def self.ratio_scaled(metric, font)
696
- upem = font.units_per_em
697
- return 1.0 if upem.zero?
698
-
699
- metric / upem
700
- end
701
- private_class_method :ratio_scaled
702
-
703
- def self.first_paragraph_size(state)
704
- paragraph = state.current_paragraph || state.paragraphs.first
705
- first_run = paragraph&.runs&.first
706
- first_run&.point_size || DEFAULT_SIZE
707
- end
708
- private_class_method :first_paragraph_size
709
-
710
- def self.first_paragraph_leading(state)
711
- paragraph = state.current_paragraph || state.paragraphs.first
712
- unless paragraph
713
- return DEFAULT_SIZE *
714
- TextEngine::VerticalLayout::DEFAULT_LEADING_FACTOR
715
- end
716
-
717
- size = paragraph.runs.first&.point_size || DEFAULT_SIZE
718
- TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
719
- size)
720
- end
721
- private_class_method :first_paragraph_leading
722
-
723
- def self.text_frame_vertical_justification(context)
724
- pref = context.item&.text_frame_preference&.first
725
- pref&.vertical_justification
726
- end
727
- private_class_method :text_frame_vertical_justification
728
-
729
- # Estimates total renderable content height for vertical
730
- # justification. Walks paragraphs and runs, summing leading
731
- # per run plus paragraph space_before/after. Approximation:
732
- # treats each run as one line (wrap may produce more lines,
733
- # which would over-estimate slack; acceptable for
734
- # justification since we'd rather under-offset than overflow).
735
- def self.estimate_content_height(state)
736
- paragraphs = paragraphs_for_estimate(state)
737
- return 0 if paragraphs.empty?
738
-
739
- paragraphs.sum do |paragraph|
740
- paragraph_height(paragraph)
741
- end
742
- end
743
- private_class_method :estimate_content_height
744
-
745
- def self.paragraphs_for_estimate(state)
746
- result = []
747
- result << state.current_paragraph if state.current_paragraph
748
- result + state.paragraphs
749
- end
750
- private_class_method :paragraphs_for_estimate
751
-
752
- def self.paragraph_height(paragraph)
753
- size = paragraph.runs.first&.point_size || DEFAULT_SIZE
754
- leading = leading_for(paragraph, size)
755
- line_count = [paragraph.runs.length, 1].max
756
- (leading * line_count) + space_before_after(paragraph)
757
- end
758
- private_class_method :paragraph_height
759
-
760
- def self.space_before_after(paragraph)
761
- (paragraph.space_before || 0) + (paragraph.space_after || 0)
762
- end
763
- private_class_method :space_before_after
764
396
 
765
397
  def self.resume_paragraph(canvas, state, context, layout_frame, font,
766
398
  cursor_y, bottom_limit, char_cursor,
@@ -789,9 +421,9 @@ module Idml
789
421
  break if cursor_y < bottom_limit
790
422
 
791
423
  next_paragraph = state.paragraphs[index + 1]
792
- break if paragraph_deferred?(paragraph, next_paragraph,
793
- layout_frame, font, cursor_y,
794
- bottom_limit, pending.positive?)
424
+ break if KeepPolicy.paragraph_deferred?(paragraph, next_paragraph,
425
+ layout_frame, font, cursor_y,
426
+ bottom_limit, pending.positive?)
795
427
 
796
428
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
797
429
  canvas, paragraph, paragraph.runs, context,
@@ -1185,130 +817,6 @@ module Idml
1185
817
  end
1186
818
  private_class_method :mojikumi_aki_overrides
1187
819
 
1188
- # True when the paragraph should move wholly to the next
1189
- # frame: a StartParagraph forced break, or KeepAllLines-
1190
- # Together with insufficient remaining space (never for the
1191
- # frame's first paragraph, so progress is always made).
1192
- def self.paragraph_deferred?(paragraph, next_paragraph,
1193
- layout_frame, font, cursor_y,
1194
- bottom_limit, placed_any)
1195
- return true if paragraph_break?(paragraph) && placed_any
1196
- return false unless placed_any
1197
- return true if keep_with_next_break?(paragraph, next_paragraph,
1198
- layout_frame, font,
1199
- cursor_y, bottom_limit)
1200
-
1201
- keep_all_lines_break?(paragraph, layout_frame, font,
1202
- cursor_y, bottom_limit) ||
1203
- keep_windows_break?(paragraph, layout_frame, font,
1204
- cursor_y, bottom_limit)
1205
- end
1206
- private_class_method :paragraph_deferred?
1207
-
1208
- # KeepAllLinesTogether: defer when the paragraph cannot
1209
- # fully fit the remaining space.
1210
- def self.keep_all_lines_break?(paragraph, layout_frame, font,
1211
- cursor_y, bottom_limit)
1212
- return false unless paragraph.keep_all_lines_together
1213
-
1214
- wrap_width = TextEngine::VerticalLayout.wrap_width(
1215
- layout_frame, paragraph.right_indent || 0
1216
- )
1217
- height = TextEngine::Measurement.paragraph_height(
1218
- paragraph, font, wrap_width
1219
- )
1220
- (cursor_y - height) < bottom_limit
1221
- end
1222
- private_class_method :keep_all_lines_break?
1223
-
1224
- # Partial keep windows, approximated by measured line
1225
- # counts: KeepFirstLines defers when fewer than N lines
1226
- # fit; KeepLastLines defers when the overflow tail for the
1227
- # next frame would strand fewer than N lines.
1228
- def self.keep_windows_break?(paragraph, layout_frame, font,
1229
- cursor_y, bottom_limit)
1230
- return false unless paragraph.keep_first_lines ||
1231
- paragraph.keep_last_lines
1232
-
1233
- wrap_width = TextEngine::VerticalLayout.wrap_width(
1234
- layout_frame, paragraph.right_indent || 0
1235
- )
1236
- height = TextEngine::Measurement.paragraph_height(
1237
- paragraph, font, wrap_width
1238
- )
1239
- return false if (cursor_y - height) >= bottom_limit
1240
-
1241
- leading = first_paragraph_leading_for(paragraph)
1242
- lines_fit = [((cursor_y - bottom_limit) / leading).floor, 0].max
1243
- first_window_break?(paragraph, lines_fit) ||
1244
- last_window_break?(paragraph, lines_fit, height, leading)
1245
- end
1246
- private_class_method :keep_windows_break?
1247
-
1248
- def self.first_window_break?(paragraph, lines_fit)
1249
- paragraph.keep_first_lines &&
1250
- lines_fit < paragraph.keep_first_lines
1251
- end
1252
- private_class_method :first_window_break?
1253
-
1254
- def self.last_window_break?(paragraph, lines_fit, height, leading)
1255
- return false unless paragraph.keep_last_lines
1256
-
1257
- total_lines = [(height / leading).ceil, 1].max
1258
- lines_fit.positive? &&
1259
- (total_lines - lines_fit) < paragraph.keep_last_lines
1260
- end
1261
- private_class_method :last_window_break?
1262
-
1263
- def self.first_paragraph_leading_for(paragraph)
1264
- size = paragraph.runs.first&.point_size || DEFAULT_SIZE
1265
- TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
1266
- size)
1267
- end
1268
- private_class_method :first_paragraph_leading_for
1269
-
1270
- # KeepWithNext: this paragraph defers when the next
1271
- # paragraph is forced to the next frame, or when the next
1272
- # paragraph's first line would not fit after this one.
1273
- def self.keep_with_next_break?(paragraph, next_paragraph,
1274
- layout_frame, font, cursor_y,
1275
- bottom_limit)
1276
- return false unless keepable_pair?(paragraph, next_paragraph)
1277
- return true if paragraph_break?(next_paragraph)
1278
-
1279
- wrap_width = TextEngine::VerticalLayout.wrap_width(
1280
- layout_frame, paragraph.right_indent || 0
1281
- )
1282
- height = TextEngine::Measurement.paragraph_height(
1283
- paragraph, font, wrap_width
1284
- )
1285
- (cursor_y - height - follower_leading(next_paragraph)) <
1286
- bottom_limit
1287
- end
1288
- private_class_method :keep_with_next_break?
1289
-
1290
- def self.keepable_pair?(paragraph, next_paragraph)
1291
- !paragraph.keep_with_next.nil? && !next_paragraph.nil?
1292
- end
1293
- private_class_method :keepable_pair?
1294
-
1295
- def self.follower_leading(next_paragraph)
1296
- size = next_paragraph.runs.first&.point_size || DEFAULT_SIZE
1297
- TextEngine::VerticalLayout.leading_for(
1298
- next_paragraph.auto_leading, size
1299
- )
1300
- end
1301
- private_class_method :follower_leading
1302
-
1303
- # True when the paragraph requests a forced break to the
1304
- # next frame/column (StartParagraph). All break flavors act
1305
- # at frame/column granularity.
1306
- def self.paragraph_break?(paragraph)
1307
- %w[NextPage NextColumn NextFrame NextOddPage NextEvenPage]
1308
- .include?(paragraph.start_paragraph)
1309
- end
1310
- private_class_method :paragraph_break?
1311
-
1312
820
  # Justifies the run's lines; only the final line of the
1313
821
  # paragraph's final run stays ragged under full
1314
822
  # justification.
@@ -1446,74 +954,6 @@ module Idml
1446
954
  line.glyphs.map { |g| [g.codepoint].pack("U") }.join
1447
955
  end
1448
956
  private_class_method :line_text
1449
-
1450
- # Fallback when no metrics are available: emit all runs as
1451
- # one `text_rich` block, letting pdfrb measure advance widths.
1452
- # Chain threading is best-effort here — the simple path
1453
- # doesn't wrap, so the first frame's render typically emits
1454
- # all content and clears the chain state.
1455
- def self.simple_render(canvas, state, context, box)
1456
- runs = collect_runs(state)
1457
- return if runs.empty?
1458
-
1459
- runs_for = build_rich_runs(runs, context)
1460
- first_size = runs.first.point_size || DEFAULT_SIZE
1461
- canvas.text_rich(
1462
- runs_for,
1463
- at: [box[:x], box[:y] + box[:height] - first_size],
1464
- )
1465
- emit_simple_footnotes(canvas, runs, context, box)
1466
- state.paragraphs = []
1467
- state.current_paragraph = nil
1468
- state.runs_remaining = []
1469
- end
1470
- private_class_method :simple_render
1471
-
1472
- # Footnote text for the rough (no-metrics) path: one line
1473
- # per footnote paragraph, stacked upward from the frame's
1474
- # bottom edge below a hairline separator. Paragraphs are
1475
- # already marker-prefixed at extraction.
1476
- def self.emit_simple_footnotes(canvas, runs, context, box)
1477
- paragraphs = runs.flat_map(&:footnote_paragraphs).compact
1478
- return if paragraphs.empty?
1479
-
1480
- size = paragraphs.first.runs.first&.point_size || DEFAULT_SIZE
1481
- block_top = box[:y] + (paragraphs.length * size)
1482
- canvas.line_width = 0.5
1483
- canvas.stroke_color([:gray, 0.0])
1484
- canvas.move_to(box[:x], block_top + (size * 0.3))
1485
- canvas.line_to(box[:x] + (box[:width] * 0.25), block_top + (size * 0.3))
1486
- canvas.stroke
1487
-
1488
- paragraphs.reverse_each.with_index do |paragraph, index|
1489
- canvas.text_rich(
1490
- build_rich_runs(paragraph.runs, context),
1491
- at: [box[:x], box[:y] + ((index + 1) * size)],
1492
- )
1493
- end
1494
- end
1495
- private_class_method :emit_simple_footnotes
1496
-
1497
- def self.collect_runs(state)
1498
- runs = state.runs_remaining || []
1499
- if state.current_paragraph
1500
- runs + state.current_paragraph.runs
1501
- else
1502
- runs + state.paragraphs.flat_map(&:runs)
1503
- end
1504
- end
1505
- private_class_method :collect_runs
1506
-
1507
- def self.build_rich_runs(runs, context)
1508
- runs.map do |run|
1509
- {
1510
- text: run.text,
1511
- font: font_for_run(run, context),
1512
- size: run.point_size || DEFAULT_SIZE,
1513
- }
1514
- end
1515
- end
1516
- private_class_method :build_rich_runs
1517
957
  end
1518
958
  end
1519
959
  end