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 +4 -4
- data/TODO.pdf/149-architecture-round-two.md +53 -0
- data/lib/idml/render/renderers/frame_metrics.rb +160 -0
- data/lib/idml/render/renderers/keep_policy.rb +118 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +10 -284
- data/lib/idml/render.rb +8 -0
- data/lib/idml/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea3cefcb949b7fd6399d850354df7fe37e6f822e766994522b9df4ceaa13cfd7
|
|
4
|
+
data.tar.gz: 531db1a6a0d5ccb32cd7f3fddd751b99d140a5504a3ab7645913167e34fdeb7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
288
|
-
|
|
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(
|
|
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
|
-
|
|
576
|
-
|
|
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
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.
|
|
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
|