idml 0.10.10 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69238283188d95396073ce5caacf5662573ccaa6d913e1f76d056b24284449d7
4
- data.tar.gz: 20639926a31480df14b3225e04a3eb04f3042bfb105aa9377a63bcb1a0cc5850
3
+ metadata.gz: ea3cefcb949b7fd6399d850354df7fe37e6f822e766994522b9df4ceaa13cfd7
4
+ data.tar.gz: 531db1a6a0d5ccb32cd7f3fddd751b99d140a5504a3ab7645913167e34fdeb7e
5
5
  SHA512:
6
- metadata.gz: 474d4266f64894e0fde05e60e2791402854e20b0a4820950e165f8552fb6be7aa644eb7a5303f8f2fa0e86e6dd4df1673d6b53f6ae9a2fac304f36c4fa5cc5e0
7
- data.tar.gz: 48ba09b818f5079b981cbed968a49779a69fc38c61e6500c973036f404f6ce1796d7238fea2a0532355b3d5a61130d1d741ac59b77fa823fb4b8e3a4fe0b8f5b
6
+ metadata.gz: 6659beee3fa4e1d325bed4d0ed86a4a1db4c2944010d8a3deffbe3bf029df137187cb3b34fa064b5feecd000abad671fd712fb083caae837796339157bde9e25
7
+ data.tar.gz: d225c68cea43b34da224e37809cbfa5c0be3abc8775c7f10baee3d1756c69c325a002562070a54a5994ecc7da3883f58bbd407b337b66d9994a99b3532436049
@@ -0,0 +1,53 @@
1
+ # TODO PDF 149: Architecture review round two — policy modules + test seam
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-29
4
+
5
+ ## Problem
6
+
7
+ Follow-up architecture review (hot-spot: the renderer) after
8
+ TODO 148's wing split:
9
+
10
+ 1. The keep-options family (paragraph deferral: KeepAllLines /
11
+ KeepFirstLines / KeepLastLines windows, KeepWithNext,
12
+ StartParagraph breaks) was ten private predicates buried in
13
+ the engine — only testable through full end-to-end renders.
14
+ 2. Frame-positioning policy (FirstBaselineOffset modes, vertical
15
+ justification, content-height estimation) was interleaved with
16
+ run emission, and `first_paragraph_leading(state)` /
17
+ `first_paragraph_leading_for(paragraph)` were near-duplicate
18
+ leading helpers maintained apart.
19
+ 3. The render specs' real interface — the PDF content stream —
20
+ was parsed by copy-pasted regexes (99 scan() sites, five
21
+ private helper reimplementations); two spec bugs this week
22
+ came from exactly this duplication.
23
+
24
+ ## Solution
25
+
26
+ - **`Renderers::KeepPolicy`** (~118 lines, module_function): the
27
+ deferral predicates extracted as a standalone pure module. The
28
+ engine calls one entry point,
29
+ `KeepPolicy.paragraph_deferred?(...)`. New direct unit spec
30
+ (`keep_policy_spec.rb`, 14 examples) drives the window
31
+ arithmetic with real `StyleResolver::Paragraph` structs — no
32
+ canvas, writer, or fonts.
33
+ - **`Renderers::FrameMetrics`** (~160 lines, module_function):
34
+ baseline modes, vertical-justify offsets, and content-height
35
+ estimation in one home. The duplicate leading helpers are
36
+ consolidated onto `FrameMetrics.leading_for(paragraph)`, which
37
+ KeepPolicy also reuses.
38
+ - **`PdfStream`** (spec_helper): one named parser for the content
39
+ stream (`text_positions`, `text_ys`, `bt_count`, `stroke_count`,
40
+ `rect_count`); the five private spec helpers and all inline
41
+ scan() idioms replaced.
42
+
43
+ TextFrameRenderer: 1235 → 960 lines (flow + emission only). Suite
44
+ 3222 → 3256 examples. Zero behavior change.
45
+
46
+ ## Files
47
+
48
+ - `lib/idml/render/renderers/keep_policy.rb` (new)
49
+ - `lib/idml/render/renderers/frame_metrics.rb` (new)
50
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
51
+ - `lib/idml/render.rb`
52
+ - `spec/spec_helper.rb` + 10 render specs
53
+ - `spec/idml/render/renderers/keep_policy_spec.rb` (new)
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ module Renderers
6
+ # Frame positioning policy for the text engine: where the
7
+ # first baseline sits under each FirstBaselineOffset mode,
8
+ # where the content block starts under vertical
9
+ # justification, and how tall the pending content is
10
+ # estimated to be. Pure functions of the TextFrame's
11
+ # preference, the chain state, and font metrics — no canvas,
12
+ # no emission (MECE: this module owns frame-positioning
13
+ # policy; TextFrameRenderer owns flow and emission;
14
+ # KeepPolicy owns paragraph deferral).
15
+ module FrameMetrics
16
+ FIRST_BASELINE_MODES = %w[
17
+ AscentOffset FixedHeight CapHeight XHeight EmboxHeight
18
+ ].freeze
19
+
20
+ # pdfrb 0.7.1 never fills the cap_height metric, so
21
+ # CapHeight / XHeight fall back to standard font
22
+ # proportions (cap ≈ 0.72 em, x ≈ 0.52 em — Arial and
23
+ # Helvetica both sit within 1% of these).
24
+ CAP_HEIGHT_RATIO = 0.72
25
+ X_HEIGHT_RATIO = 0.52
26
+
27
+ module_function
28
+
29
+ # Top y for the content block under the frame's vertical
30
+ # justification: TopAlign keeps the frame top;
31
+ # CenterAlign / BottomAlign offset by the slack between the
32
+ # estimated content height and the available space
33
+ # (JustifyAlign falls back to TopAlign behavior).
34
+ def vertical_justify_top(top_y, bottom_limit, state, context)
35
+ justification = text_frame_vertical_justification(context)
36
+ return top_y unless %w[CenterAlign BottomAlign].include?(justification)
37
+
38
+ available = top_y - bottom_limit
39
+ slack = [available - estimate_content_height(state), 0].max
40
+ return top_y if slack.zero?
41
+
42
+ case justification
43
+ when "CenterAlign" then top_y - (slack / 2)
44
+ when "BottomAlign" then top_y - slack
45
+ else top_y
46
+ end
47
+ end
48
+
49
+ # FirstBaselineOffset: how far below the frame's top inset
50
+ # the first line's baseline sits, relative to the layout's
51
+ # leading-based default. AscentOffset shifts by (ascent −
52
+ # leading), FixedHeight by (MinimumFirstBaselineOffset −
53
+ # leading), CapHeight by the cap-height proportion, XHeight
54
+ # by the x-height proportion, EmboxHeight by the em box
55
+ # (the first paragraph's point size).
56
+ def first_baseline_offset(context, state, font)
57
+ pref = context.item&.text_frame_preference&.first
58
+ mode = pref&.first_baseline_offset
59
+ return 0.0 unless FIRST_BASELINE_MODES.include?(mode)
60
+
61
+ baseline_target(pref, mode, state, font) -
62
+ first_paragraph_leading(state)
63
+ end
64
+
65
+ # Distance from the top inset to the first baseline under
66
+ # the given mode.
67
+ def baseline_target(pref, mode, state, font)
68
+ leading = first_paragraph_leading(state)
69
+ case mode
70
+ when "AscentOffset"
71
+ ratio_scaled(font.ascent, font) * leading
72
+ when "CapHeight"
73
+ cap_ratio(font) * leading
74
+ when "XHeight"
75
+ X_HEIGHT_RATIO * leading
76
+ when "EmboxHeight"
77
+ first_paragraph_size(state)
78
+ else
79
+ pref.minimum_first_baseline_offset || leading
80
+ end
81
+ end
82
+
83
+ def cap_ratio(font)
84
+ cap = font.cap_height
85
+ return CAP_HEIGHT_RATIO unless cap
86
+
87
+ ratio_scaled(cap, font)
88
+ end
89
+
90
+ def ratio_scaled(metric, font)
91
+ upem = font.units_per_em
92
+ return 1.0 if upem.zero?
93
+
94
+ metric / upem
95
+ end
96
+
97
+ def first_paragraph_size(state)
98
+ paragraph = state.current_paragraph || state.paragraphs.first
99
+ first_run = paragraph&.runs&.first
100
+ first_run&.point_size || TextFrameRenderer::DEFAULT_SIZE
101
+ end
102
+
103
+ # Leading of a paragraph computed from its first run's
104
+ # point size (the default size when it has no runs yet).
105
+ def leading_for(paragraph)
106
+ size = paragraph.runs.first&.point_size ||
107
+ TextFrameRenderer::DEFAULT_SIZE
108
+ TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
109
+ size)
110
+ end
111
+
112
+ def first_paragraph_leading(state)
113
+ paragraph = state.current_paragraph || state.paragraphs.first
114
+ unless paragraph
115
+ return TextFrameRenderer::DEFAULT_SIZE *
116
+ TextEngine::VerticalLayout::DEFAULT_LEADING_FACTOR
117
+ end
118
+
119
+ leading_for(paragraph)
120
+ end
121
+
122
+ def text_frame_vertical_justification(context)
123
+ pref = context.item&.text_frame_preference&.first
124
+ pref&.vertical_justification
125
+ end
126
+
127
+ # Estimates total renderable content height for vertical
128
+ # justification. Walks paragraphs and runs, summing leading
129
+ # per run plus paragraph space_before/after. Approximation:
130
+ # treats each run as one line (wrap may produce more lines,
131
+ # which would over-estimate slack; acceptable for
132
+ # justification since we'd rather under-offset than
133
+ # overflow).
134
+ def estimate_content_height(state)
135
+ paragraphs_for_estimate(state).sum { |p| paragraph_height(p) }
136
+ end
137
+
138
+ def paragraphs_for_estimate(state)
139
+ result = []
140
+ result << state.current_paragraph if state.current_paragraph
141
+ result + state.paragraphs
142
+ end
143
+
144
+ def paragraph_height(paragraph)
145
+ size = paragraph.runs.first&.point_size ||
146
+ TextFrameRenderer::DEFAULT_SIZE
147
+ leading = TextEngine::VerticalLayout.leading_for(
148
+ paragraph.auto_leading, size
149
+ )
150
+ line_count = [paragraph.runs.length, 1].max
151
+ (leading * line_count) + space_before_after(paragraph)
152
+ end
153
+
154
+ def space_before_after(paragraph)
155
+ (paragraph.space_before || 0) + (paragraph.space_after || 0)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ module Renderers
6
+ # Paragraph deferral policy (keep options + forced breaks):
7
+ # decides whether a paragraph moves wholly to the next
8
+ # frame/column instead of being split. Honors StartParagraph
9
+ # forced breaks, KeepAllLinesTogether, the KeepFirstLines /
10
+ # KeepLastLines partial windows (measured-line-count
11
+ # approximation), and KeepWithNext. Pure predicates over the
12
+ # paragraph chain state and frame geometry — no canvas, no
13
+ # emission (MECE: TextFrameRenderer owns flow and emission;
14
+ # FrameMetrics owns frame positioning).
15
+ module KeepPolicy
16
+ BREAK_MODES = %w[
17
+ NextPage NextColumn NextFrame NextOddPage NextEvenPage
18
+ ].freeze
19
+
20
+ module_function
21
+
22
+ # True when the paragraph should move wholly to the next
23
+ # frame: a StartParagraph forced break, or a keep rule that
24
+ # the remaining space violates (never for the frame's
25
+ # first paragraph, so progress is always made).
26
+ def paragraph_deferred?(paragraph, next_paragraph,
27
+ layout_frame, font, cursor_y,
28
+ bottom_limit, placed_any)
29
+ return true if paragraph_break?(paragraph) && placed_any
30
+ return false unless placed_any
31
+ return true if keep_with_next_break?(paragraph, next_paragraph,
32
+ layout_frame, font,
33
+ cursor_y, bottom_limit)
34
+
35
+ keep_all_lines_break?(paragraph, layout_frame, font,
36
+ cursor_y, bottom_limit) ||
37
+ keep_windows_break?(paragraph, layout_frame, font,
38
+ cursor_y, bottom_limit)
39
+ end
40
+
41
+ # KeepAllLinesTogether: defer when the paragraph cannot
42
+ # fully fit the remaining space.
43
+ def keep_all_lines_break?(paragraph, layout_frame, font,
44
+ cursor_y, bottom_limit)
45
+ return false unless paragraph.keep_all_lines_together
46
+
47
+ height = paragraph_block_height(paragraph, layout_frame, font)
48
+ (cursor_y - height) < bottom_limit
49
+ end
50
+
51
+ # Partial keep windows, approximated by measured line
52
+ # counts: KeepFirstLines defers when fewer than N lines
53
+ # fit; KeepLastLines defers when the overflow tail for the
54
+ # next frame would strand fewer than N lines.
55
+ def keep_windows_break?(paragraph, layout_frame, font,
56
+ cursor_y, bottom_limit)
57
+ return false unless paragraph.keep_first_lines ||
58
+ paragraph.keep_last_lines
59
+
60
+ height = paragraph_block_height(paragraph, layout_frame, font)
61
+ return false if (cursor_y - height) >= bottom_limit
62
+
63
+ leading = FrameMetrics.leading_for(paragraph)
64
+ lines_fit = [((cursor_y - bottom_limit) / leading).floor, 0].max
65
+ first_window_break?(paragraph, lines_fit) ||
66
+ last_window_break?(paragraph, lines_fit, height, leading)
67
+ end
68
+
69
+ def first_window_break?(paragraph, lines_fit)
70
+ paragraph.keep_first_lines &&
71
+ lines_fit < paragraph.keep_first_lines
72
+ end
73
+
74
+ def last_window_break?(paragraph, lines_fit, height, leading)
75
+ return false unless paragraph.keep_last_lines
76
+
77
+ total_lines = [(height / leading).ceil, 1].max
78
+ lines_fit.positive? &&
79
+ (total_lines - lines_fit) < paragraph.keep_last_lines
80
+ end
81
+
82
+ # KeepWithNext: this paragraph defers when the next
83
+ # paragraph is forced to the next frame, or when the next
84
+ # paragraph's first line would not fit after this one.
85
+ def keep_with_next_break?(paragraph, next_paragraph,
86
+ layout_frame, font, cursor_y,
87
+ bottom_limit)
88
+ return false unless keepable_pair?(paragraph, next_paragraph)
89
+ return true if paragraph_break?(next_paragraph)
90
+
91
+ height = paragraph_block_height(paragraph, layout_frame, font)
92
+ (cursor_y - height - FrameMetrics.leading_for(next_paragraph)) <
93
+ bottom_limit
94
+ end
95
+
96
+ def keepable_pair?(paragraph, next_paragraph)
97
+ !paragraph.keep_with_next.nil? && !next_paragraph.nil?
98
+ end
99
+
100
+ # True when the paragraph requests a forced break to the
101
+ # next frame/column (StartParagraph). All break flavors act
102
+ # at frame/column granularity.
103
+ def paragraph_break?(paragraph)
104
+ BREAK_MODES.include?(paragraph.start_paragraph)
105
+ end
106
+
107
+ def paragraph_block_height(paragraph, layout_frame, font)
108
+ wrap_width = TextEngine::VerticalLayout.wrap_width(
109
+ layout_frame, paragraph.right_indent || 0
110
+ )
111
+ TextEngine::Measurement.paragraph_height(
112
+ paragraph, font, wrap_width
113
+ )
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -284,8 +284,10 @@ module Idml
284
284
  (layout_frame.inset_top || 0)
285
285
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
286
286
  footnotes = []
287
- cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
288
- 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)
289
291
  char_cursor = state.char_cursor
290
292
 
291
293
  if state.current_paragraph
@@ -326,7 +328,9 @@ module Idml
326
328
  top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
327
329
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
328
330
  footnotes = []
329
- cursor_y = top_y - first_baseline_offset(context, state, font)
331
+ cursor_y = top_y - FrameMetrics.first_baseline_offset(
332
+ context, state, font
333
+ )
330
334
  char_cursor = state.char_cursor
331
335
 
332
336
  if state.current_paragraph
@@ -389,160 +393,6 @@ module Idml
389
393
  private_class_method :build_column_frames
390
394
 
391
395
  # Computes the starting cursor_y for vertical justification.
392
- # IDML's TextFramePreference.VerticalJustification can be:
393
- # - TopAlign (default): start at frame top, no offset
394
- # - CenterAlign: center the content block vertically
395
- # - BottomAlign: align content to the frame bottom
396
- # - JustifyAlign: distribute extra space between paragraphs
397
- # (deferred — falls back to TopAlign behavior)
398
- #
399
- # Content height is estimated by walking paragraphs and
400
- # summing leading × lines + space_before/after. Without
401
- # shaping/wrapping we can't know exact line count, so we
402
- # approximate by treating each run as one line.
403
- def self.vertical_justify_top(top_y, bottom_limit, state, context)
404
- justification = text_frame_vertical_justification(context)
405
- return top_y unless %w[CenterAlign BottomAlign].include?(justification)
406
-
407
- content_height = estimate_content_height(state)
408
- available = top_y - bottom_limit
409
- slack = [available - content_height, 0].max
410
- return top_y if slack.zero?
411
-
412
- case justification
413
- when "CenterAlign" then top_y - (slack / 2)
414
- when "BottomAlign" then top_y - slack
415
- else top_y
416
- end
417
- end
418
- private_class_method :vertical_justify_top
419
-
420
- # FirstBaselineOffset: how far below the frame's top inset
421
- # the first line's baseline sits. The layout's default is
422
- # leading-based; AscentOffset shifts by (ascent − leading)
423
- # and FixedHeight by (MinimumFirstBaselineOffset − leading).
424
- # CapHeight uses the font's cap-height metric (leading
425
- # fallback when the font reports none); XHeight approximates
426
- # as 70% of cap height; EmboxHeight uses the em box (the
427
- # first paragraph's point size). Returns the extra shift to
428
- # subtract from the cursor.
429
- FIRST_BASELINE_MODES = %w[
430
- AscentOffset FixedHeight CapHeight XHeight EmboxHeight
431
- ].freeze
432
-
433
- def self.first_baseline_offset(context, state, font)
434
- pref = context.item&.text_frame_preference&.first
435
- mode = pref&.first_baseline_offset
436
- return 0.0 unless FIRST_BASELINE_MODES.include?(mode)
437
-
438
- baseline_target(pref, mode, state, font) -
439
- first_paragraph_leading(state)
440
- end
441
- private_class_method :first_baseline_offset
442
-
443
- # Distance from the top inset to the first baseline under
444
- # the given mode.
445
- # pdfrb 0.7.1 never fills the cap_height metric, so
446
- # CapHeight / XHeight fall back to standard font
447
- # proportions (cap ≈ 0.72 em, x ≈ 0.52 em — Arial and
448
- # Helvetica both sit within 1% of these).
449
- CAP_HEIGHT_RATIO = 0.72
450
- X_HEIGHT_RATIO = 0.52
451
-
452
- def self.baseline_target(pref, mode, state, font)
453
- leading = first_paragraph_leading(state)
454
- case mode
455
- when "AscentOffset"
456
- ratio_scaled(font.ascent, font) * leading
457
- when "CapHeight"
458
- cap_ratio(font) * leading
459
- when "XHeight"
460
- X_HEIGHT_RATIO * leading
461
- when "EmboxHeight"
462
- first_paragraph_size(state)
463
- else
464
- pref.minimum_first_baseline_offset || leading
465
- end
466
- end
467
- private_class_method :baseline_target
468
-
469
- def self.cap_ratio(font)
470
- cap = font.cap_height
471
- return CAP_HEIGHT_RATIO unless cap
472
-
473
- ratio_scaled(cap, font)
474
- end
475
- private_class_method :cap_ratio
476
-
477
- def self.ratio_scaled(metric, font)
478
- upem = font.units_per_em
479
- return 1.0 if upem.zero?
480
-
481
- metric / upem
482
- end
483
- private_class_method :ratio_scaled
484
-
485
- def self.first_paragraph_size(state)
486
- paragraph = state.current_paragraph || state.paragraphs.first
487
- first_run = paragraph&.runs&.first
488
- first_run&.point_size || DEFAULT_SIZE
489
- end
490
- private_class_method :first_paragraph_size
491
-
492
- def self.first_paragraph_leading(state)
493
- paragraph = state.current_paragraph || state.paragraphs.first
494
- unless paragraph
495
- return DEFAULT_SIZE *
496
- TextEngine::VerticalLayout::DEFAULT_LEADING_FACTOR
497
- end
498
-
499
- size = paragraph.runs.first&.point_size || DEFAULT_SIZE
500
- TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
501
- size)
502
- end
503
- private_class_method :first_paragraph_leading
504
-
505
- def self.text_frame_vertical_justification(context)
506
- pref = context.item&.text_frame_preference&.first
507
- pref&.vertical_justification
508
- end
509
- private_class_method :text_frame_vertical_justification
510
-
511
- # Estimates total renderable content height for vertical
512
- # justification. Walks paragraphs and runs, summing leading
513
- # per run plus paragraph space_before/after. Approximation:
514
- # treats each run as one line (wrap may produce more lines,
515
- # which would over-estimate slack; acceptable for
516
- # justification since we'd rather under-offset than overflow).
517
- def self.estimate_content_height(state)
518
- paragraphs = paragraphs_for_estimate(state)
519
- return 0 if paragraphs.empty?
520
-
521
- paragraphs.sum do |paragraph|
522
- paragraph_height(paragraph)
523
- end
524
- end
525
- private_class_method :estimate_content_height
526
-
527
- def self.paragraphs_for_estimate(state)
528
- result = []
529
- result << state.current_paragraph if state.current_paragraph
530
- result + state.paragraphs
531
- end
532
- private_class_method :paragraphs_for_estimate
533
-
534
- def self.paragraph_height(paragraph)
535
- size = paragraph.runs.first&.point_size || DEFAULT_SIZE
536
- leading = leading_for(paragraph, size)
537
- line_count = [paragraph.runs.length, 1].max
538
- (leading * line_count) + space_before_after(paragraph)
539
- end
540
- private_class_method :paragraph_height
541
-
542
- def self.space_before_after(paragraph)
543
- (paragraph.space_before || 0) + (paragraph.space_after || 0)
544
- end
545
- private_class_method :space_before_after
546
396
 
547
397
  def self.resume_paragraph(canvas, state, context, layout_frame, font,
548
398
  cursor_y, bottom_limit, char_cursor,
@@ -571,9 +421,9 @@ module Idml
571
421
  break if cursor_y < bottom_limit
572
422
 
573
423
  next_paragraph = state.paragraphs[index + 1]
574
- break if paragraph_deferred?(paragraph, next_paragraph,
575
- layout_frame, font, cursor_y,
576
- bottom_limit, pending.positive?)
424
+ break if KeepPolicy.paragraph_deferred?(paragraph, next_paragraph,
425
+ layout_frame, font, cursor_y,
426
+ bottom_limit, pending.positive?)
577
427
 
578
428
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
579
429
  canvas, paragraph, paragraph.runs, context,
@@ -967,130 +817,6 @@ module Idml
967
817
  end
968
818
  private_class_method :mojikumi_aki_overrides
969
819
 
970
- # True when the paragraph should move wholly to the next
971
- # frame: a StartParagraph forced break, or KeepAllLines-
972
- # Together with insufficient remaining space (never for the
973
- # frame's first paragraph, so progress is always made).
974
- def self.paragraph_deferred?(paragraph, next_paragraph,
975
- layout_frame, font, cursor_y,
976
- bottom_limit, placed_any)
977
- return true if paragraph_break?(paragraph) && placed_any
978
- return false unless placed_any
979
- return true if keep_with_next_break?(paragraph, next_paragraph,
980
- layout_frame, font,
981
- cursor_y, bottom_limit)
982
-
983
- keep_all_lines_break?(paragraph, layout_frame, font,
984
- cursor_y, bottom_limit) ||
985
- keep_windows_break?(paragraph, layout_frame, font,
986
- cursor_y, bottom_limit)
987
- end
988
- private_class_method :paragraph_deferred?
989
-
990
- # KeepAllLinesTogether: defer when the paragraph cannot
991
- # fully fit the remaining space.
992
- def self.keep_all_lines_break?(paragraph, layout_frame, font,
993
- cursor_y, bottom_limit)
994
- return false unless paragraph.keep_all_lines_together
995
-
996
- wrap_width = TextEngine::VerticalLayout.wrap_width(
997
- layout_frame, paragraph.right_indent || 0
998
- )
999
- height = TextEngine::Measurement.paragraph_height(
1000
- paragraph, font, wrap_width
1001
- )
1002
- (cursor_y - height) < bottom_limit
1003
- end
1004
- private_class_method :keep_all_lines_break?
1005
-
1006
- # Partial keep windows, approximated by measured line
1007
- # counts: KeepFirstLines defers when fewer than N lines
1008
- # fit; KeepLastLines defers when the overflow tail for the
1009
- # next frame would strand fewer than N lines.
1010
- def self.keep_windows_break?(paragraph, layout_frame, font,
1011
- cursor_y, bottom_limit)
1012
- return false unless paragraph.keep_first_lines ||
1013
- paragraph.keep_last_lines
1014
-
1015
- wrap_width = TextEngine::VerticalLayout.wrap_width(
1016
- layout_frame, paragraph.right_indent || 0
1017
- )
1018
- height = TextEngine::Measurement.paragraph_height(
1019
- paragraph, font, wrap_width
1020
- )
1021
- return false if (cursor_y - height) >= bottom_limit
1022
-
1023
- leading = first_paragraph_leading_for(paragraph)
1024
- lines_fit = [((cursor_y - bottom_limit) / leading).floor, 0].max
1025
- first_window_break?(paragraph, lines_fit) ||
1026
- last_window_break?(paragraph, lines_fit, height, leading)
1027
- end
1028
- private_class_method :keep_windows_break?
1029
-
1030
- def self.first_window_break?(paragraph, lines_fit)
1031
- paragraph.keep_first_lines &&
1032
- lines_fit < paragraph.keep_first_lines
1033
- end
1034
- private_class_method :first_window_break?
1035
-
1036
- def self.last_window_break?(paragraph, lines_fit, height, leading)
1037
- return false unless paragraph.keep_last_lines
1038
-
1039
- total_lines = [(height / leading).ceil, 1].max
1040
- lines_fit.positive? &&
1041
- (total_lines - lines_fit) < paragraph.keep_last_lines
1042
- end
1043
- private_class_method :last_window_break?
1044
-
1045
- def self.first_paragraph_leading_for(paragraph)
1046
- size = paragraph.runs.first&.point_size || DEFAULT_SIZE
1047
- TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
1048
- size)
1049
- end
1050
- private_class_method :first_paragraph_leading_for
1051
-
1052
- # KeepWithNext: this paragraph defers when the next
1053
- # paragraph is forced to the next frame, or when the next
1054
- # paragraph's first line would not fit after this one.
1055
- def self.keep_with_next_break?(paragraph, next_paragraph,
1056
- layout_frame, font, cursor_y,
1057
- bottom_limit)
1058
- return false unless keepable_pair?(paragraph, next_paragraph)
1059
- return true if paragraph_break?(next_paragraph)
1060
-
1061
- wrap_width = TextEngine::VerticalLayout.wrap_width(
1062
- layout_frame, paragraph.right_indent || 0
1063
- )
1064
- height = TextEngine::Measurement.paragraph_height(
1065
- paragraph, font, wrap_width
1066
- )
1067
- (cursor_y - height - follower_leading(next_paragraph)) <
1068
- bottom_limit
1069
- end
1070
- private_class_method :keep_with_next_break?
1071
-
1072
- def self.keepable_pair?(paragraph, next_paragraph)
1073
- !paragraph.keep_with_next.nil? && !next_paragraph.nil?
1074
- end
1075
- private_class_method :keepable_pair?
1076
-
1077
- def self.follower_leading(next_paragraph)
1078
- size = next_paragraph.runs.first&.point_size || DEFAULT_SIZE
1079
- TextEngine::VerticalLayout.leading_for(
1080
- next_paragraph.auto_leading, size
1081
- )
1082
- end
1083
- private_class_method :follower_leading
1084
-
1085
- # True when the paragraph requests a forced break to the
1086
- # next frame/column (StartParagraph). All break flavors act
1087
- # at frame/column granularity.
1088
- def self.paragraph_break?(paragraph)
1089
- %w[NextPage NextColumn NextFrame NextOddPage NextEvenPage]
1090
- .include?(paragraph.start_paragraph)
1091
- end
1092
- private_class_method :paragraph_break?
1093
-
1094
820
  # Justifies the run's lines; only the final line of the
1095
821
  # paragraph's final run stays ragged under full
1096
822
  # justification.
data/lib/idml/render.rb CHANGED
@@ -100,3 +100,11 @@ Idml::Render::Renderers.autoload(
100
100
  :SimpleText,
101
101
  "#{__dir__}/render/renderers/simple_text",
102
102
  )
103
+ Idml::Render::Renderers.autoload(
104
+ :FrameMetrics,
105
+ "#{__dir__}/render/renderers/frame_metrics",
106
+ )
107
+ Idml::Render::Renderers.autoload(
108
+ :KeepPolicy,
109
+ "#{__dir__}/render/renderers/keep_policy",
110
+ )
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.10.10"
4
+ VERSION = "0.10.11"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.10
4
+ version: 0.10.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -212,6 +212,7 @@ files:
212
212
  - TODO.pdf/146-next-column-wrap.md
213
213
  - TODO.pdf/147-side-aware-shapes.md
214
214
  - TODO.pdf/148-architecture-improvements.md
215
+ - TODO.pdf/149-architecture-round-two.md
215
216
  - TODO.pdf/15-wire-spread-children.md
216
217
  - TODO.pdf/16-typed-model-pipeline.md
217
218
  - TODO.pdf/17-shape-rendering.md
@@ -502,8 +503,10 @@ files:
502
503
  - lib/idml/render/position_tracker.rb
503
504
  - lib/idml/render/render_context.rb
504
505
  - lib/idml/render/renderers.rb
506
+ - lib/idml/render/renderers/frame_metrics.rb
505
507
  - lib/idml/render/renderers/graphic_line_renderer.rb
506
508
  - lib/idml/render/renderers/group_renderer.rb
509
+ - lib/idml/render/renderers/keep_policy.rb
507
510
  - lib/idml/render/renderers/oval_renderer.rb
508
511
  - lib/idml/render/renderers/path_renderer.rb
509
512
  - lib/idml/render/renderers/polygon_renderer.rb