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.
- checksums.yaml +4 -4
- data/TODO.pdf/148-architecture-improvements.md +45 -0
- 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/simple_text.rb +82 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +17 -577
- data/lib/idml/render/renderers/vertical_emit.rb +228 -0
- data/lib/idml/render.rb +16 -0
- data/lib/idml/version.rb +1 -1
- metadata +8 -2
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,45 @@
|
|
|
1
|
+
# TODO PDF 148: Codebase architecture improvements
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-29
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Architecture audit findings:
|
|
8
|
+
|
|
9
|
+
1. `TextFrameRenderer` was a 1520-line god class owning four
|
|
10
|
+
unrelated concerns: the horizontal shaping engine, the vertical
|
|
11
|
+
writing path, the no-metrics fallback, and everything else
|
|
12
|
+
(chains, endnotes, footnotes, wrap, keep options).
|
|
13
|
+
2. Twelve render specs each declared their own near-identical
|
|
14
|
+
`*_FONT_CANDIDATES` list (~7 lines each), with two variants
|
|
15
|
+
drifting apart (some lists lacked the CJK-capable fonts —
|
|
16
|
+
CJK-dependent specs silently skipped on platforms that had
|
|
17
|
+
them).
|
|
18
|
+
|
|
19
|
+
## Solution
|
|
20
|
+
|
|
21
|
+
- **VerticalEmit** (`renderers/vertical_emit.rb`, ~230 lines): the
|
|
22
|
+
whole vertical-writing family (vertical_render through
|
|
23
|
+
ruby_vertical_x), extracted as a class-methods module
|
|
24
|
+
`extend`ed into TextFrameRenderer — it shares the host's
|
|
25
|
+
constants, `layout_frame`, and `font_for_run` (MECE: the module
|
|
26
|
+
owns everything vertical; the host owns the horizontal engine).
|
|
27
|
+
Zero behavior change; all vertical/ruby specs pass untouched.
|
|
28
|
+
- **SimpleText** (`renderers/simple_text.rb`, ~90 lines): the
|
|
29
|
+
no-metrics fallback (simple_render, footnote stacking,
|
|
30
|
+
collect/build rich runs), same extend pattern.
|
|
31
|
+
- **Spec font candidates DRY'd**: `SPEC_FONT_CANDIDATES` +
|
|
32
|
+
`spec_font_path` in spec_helper; the twelve per-spec constants
|
|
33
|
+
deleted. Unifying on the CJK-capable superset un-skipped 20
|
|
34
|
+
previously-skipped examples on CJK-font platforms.
|
|
35
|
+
|
|
36
|
+
`TextFrameRenderer` is now 1235 lines of cohesive horizontal
|
|
37
|
+
engine + orchestration.
|
|
38
|
+
|
|
39
|
+
## Files
|
|
40
|
+
|
|
41
|
+
- `lib/idml/render/renderers/vertical_emit.rb` (new)
|
|
42
|
+
- `lib/idml/render/renderers/simple_text.rb` (new)
|
|
43
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb`
|
|
44
|
+
- `lib/idml/render.rb`
|
|
45
|
+
- `spec/spec_helper.rb` + 12 render specs
|
|
@@ -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
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
module Renderers
|
|
6
|
+
# The no-metrics fallback path of TextFrameRenderer: when no
|
|
7
|
+
# FontMetrics is available (no shaper → no wrap), all runs
|
|
8
|
+
# emit as one `text_rich` block per frame and footnote text
|
|
9
|
+
# stacks at the frame bottom. Chain threading is best-effort
|
|
10
|
+
# here — the simple path doesn't wrap, so the first frame's
|
|
11
|
+
# render typically emits all content and clears the chain
|
|
12
|
+
# state.
|
|
13
|
+
#
|
|
14
|
+
# A class-methods module extended into TextFrameRenderer —
|
|
15
|
+
# it shares the host's font resolution (MECE: this module
|
|
16
|
+
# owns the rough path; the host owns the shaping engine).
|
|
17
|
+
module SimpleText
|
|
18
|
+
def simple_render(canvas, state, context, box)
|
|
19
|
+
runs = collect_runs(state)
|
|
20
|
+
return if runs.empty?
|
|
21
|
+
|
|
22
|
+
runs_for = build_rich_runs(runs, context)
|
|
23
|
+
first_size = runs.first.point_size ||
|
|
24
|
+
TextFrameRenderer::DEFAULT_SIZE
|
|
25
|
+
canvas.text_rich(
|
|
26
|
+
runs_for,
|
|
27
|
+
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
28
|
+
)
|
|
29
|
+
emit_simple_footnotes(canvas, runs, context, box)
|
|
30
|
+
state.paragraphs = []
|
|
31
|
+
state.current_paragraph = nil
|
|
32
|
+
state.runs_remaining = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Footnote text for the rough (no-metrics) path: one line
|
|
36
|
+
# per footnote paragraph, stacked upward from the frame's
|
|
37
|
+
# bottom edge below a hairline separator. Paragraphs are
|
|
38
|
+
# already marker-prefixed at extraction.
|
|
39
|
+
def emit_simple_footnotes(canvas, runs, context, box)
|
|
40
|
+
paragraphs = runs.flat_map(&:footnote_paragraphs).compact
|
|
41
|
+
return if paragraphs.empty?
|
|
42
|
+
|
|
43
|
+
size = paragraphs.first.runs.first&.point_size ||
|
|
44
|
+
TextFrameRenderer::DEFAULT_SIZE
|
|
45
|
+
block_top = box[:y] + (paragraphs.length * size)
|
|
46
|
+
canvas.line_width = 0.5
|
|
47
|
+
canvas.stroke_color([:gray, 0.0])
|
|
48
|
+
canvas.move_to(box[:x], block_top + (size * 0.3))
|
|
49
|
+
canvas.line_to(box[:x] + (box[:width] * 0.25),
|
|
50
|
+
block_top + (size * 0.3))
|
|
51
|
+
canvas.stroke
|
|
52
|
+
|
|
53
|
+
paragraphs.reverse_each.with_index do |paragraph, index|
|
|
54
|
+
canvas.text_rich(
|
|
55
|
+
build_rich_runs(paragraph.runs, context),
|
|
56
|
+
at: [box[:x], box[:y] + ((index + 1) * size)],
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def collect_runs(state)
|
|
62
|
+
runs = state.runs_remaining || []
|
|
63
|
+
if state.current_paragraph
|
|
64
|
+
runs + state.current_paragraph.runs
|
|
65
|
+
else
|
|
66
|
+
runs + state.paragraphs.flat_map(&:runs)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_rich_runs(runs, context)
|
|
71
|
+
runs.map do |run|
|
|
72
|
+
{
|
|
73
|
+
text: run.text,
|
|
74
|
+
font: font_for_run(run, context),
|
|
75
|
+
size: run.point_size || TextFrameRenderer::DEFAULT_SIZE,
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|