idml 0.6.2 → 0.7.1
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/Gemfile +1 -1
- data/TODO.pdf/100-character-level-styling.md +11 -1
- data/TODO.pdf/101-paragraph-rules-borders.md +10 -1
- data/TODO.pdf/104-table-schema-completion.md +6 -1
- data/TODO.pdf/105-whole-package-round-trip.md +4 -1
- data/TODO.pdf/108-multi-frame-story-flow.md +50 -0
- data/TODO.pdf/109-dry-color-tint.md +42 -0
- data/TODO.pdf/110-conditional-text.md +37 -0
- data/TODO.pdf/111-footnote-support.md +40 -0
- data/TODO.pdf/94-text-frame-vertical-layout.md +5 -1
- data/TODO.pdf/95-paragraph-spacing-indents.md +2 -1
- data/TODO.pdf/96-text-frame-insets-actual.md +3 -1
- data/TODO.pdf/97-table-cell-rendering-fidelity.md +4 -1
- data/TODO.pdf/98-rowspan-columnspan.md +4 -1
- data/TODO.pdf/99-table-column-widths.md +5 -1
- data/idml.gemspec +1 -1
- data/lib/idml/elements/table.rb +48 -4
- data/lib/idml/render/character_style.rb +148 -0
- data/lib/idml/render/color_helper.rb +27 -3
- data/lib/idml/render/paragraph_rules.rb +125 -0
- data/lib/idml/render/renderers/table_renderer.rb +188 -27
- data/lib/idml/render/renderers/text_frame_renderer.rb +217 -59
- data/lib/idml/render/style_resolver.rb +123 -7
- data/lib/idml/render.rb +2 -0
- data/lib/idml/text_engine/vertical_layout.rb +50 -45
- data/lib/idml/text_engine.rb +3 -2
- data/lib/idml/version.rb +1 -1
- metadata +10 -4
|
@@ -3,11 +3,26 @@
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
5
|
module Renderers
|
|
6
|
-
# Renders an IDML TextFrame on a Pdfrb::Content::Canvas.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# Renders an IDML TextFrame on a Pdfrb::Content::Canvas.
|
|
7
|
+
#
|
|
8
|
+
# Pipeline:
|
|
9
|
+
#
|
|
10
|
+
# 1. Resolve the linked Story via Package#story_by_id.
|
|
11
|
+
# 2. Extract `Paragraph`s via StyleResolver (carries PSR
|
|
12
|
+
# attributes like SpaceBefore, FirstLineIndent, AutoLeading
|
|
13
|
+
# alongside the CSR runs).
|
|
14
|
+
# 3. Build a layout `Frame` from the TextFrame's geometric
|
|
15
|
+
# bounds plus TextFramePreference insets.
|
|
16
|
+
# 4. For each paragraph: shape glyphs (Shaper), wrap to the
|
|
17
|
+
# inset/indent-adjusted width (LineBreaker), align (Justifier),
|
|
18
|
+
# position vertically (VerticalLayout), and emit one
|
|
19
|
+
# `canvas.text` per line. Tracks the y cursor across
|
|
20
|
+
# paragraphs and runs so text flows top-to-bottom.
|
|
21
|
+
# 5. Records each positioned line in PositionTracker so
|
|
22
|
+
# HyperlinkEmitter can compute precise per-source link rects.
|
|
23
|
+
#
|
|
24
|
+
# Falls back to a single `canvas.text_rich` per frame when no
|
|
25
|
+
# FontMetrics is available (no shaper → no wrap → rough output).
|
|
11
26
|
class TextFrameRenderer
|
|
12
27
|
DEFAULT_SIZE = 12.0
|
|
13
28
|
LEADING_FACTOR = 1.2
|
|
@@ -23,10 +38,10 @@ module Idml
|
|
|
23
38
|
box = frame_box(frame, context.page_height)
|
|
24
39
|
render_inline_tables(canvas, story, box, context)
|
|
25
40
|
|
|
26
|
-
|
|
27
|
-
return if
|
|
41
|
+
paragraphs = StyleResolver.extract_paragraphs(story)
|
|
42
|
+
return if paragraphs.empty?
|
|
28
43
|
|
|
29
|
-
render_text(canvas,
|
|
44
|
+
render_text(canvas, paragraphs, context, box)
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
# Discovers Tables inlined in the story (Story > PSR > CSR >
|
|
@@ -55,12 +70,12 @@ module Idml
|
|
|
55
70
|
end
|
|
56
71
|
private_class_method :tables_in_story
|
|
57
72
|
|
|
58
|
-
def self.render_text(canvas,
|
|
73
|
+
def self.render_text(canvas, paragraphs, context, box)
|
|
59
74
|
font = context.font_metrics
|
|
60
75
|
if font
|
|
61
|
-
engine_render(canvas,
|
|
76
|
+
engine_render(canvas, paragraphs, context, box, font)
|
|
62
77
|
else
|
|
63
|
-
simple_render(canvas,
|
|
78
|
+
simple_render(canvas, paragraphs, context, box)
|
|
64
79
|
end
|
|
65
80
|
end
|
|
66
81
|
private_class_method :render_text
|
|
@@ -75,58 +90,201 @@ module Idml
|
|
|
75
90
|
end
|
|
76
91
|
private_class_method :frame_box
|
|
77
92
|
|
|
78
|
-
|
|
93
|
+
# Builds a layout Frame from the TextFrame's PDF-placement
|
|
94
|
+
# box plus any TextFramePreference insets. Inset values fall
|
|
95
|
+
# back to 0 when the preference is absent or doesn't declare
|
|
96
|
+
# them.
|
|
97
|
+
def self.layout_frame(frame_item, box)
|
|
98
|
+
pref = frame_item.text_frame_preference&.first
|
|
99
|
+
TextEngine::Frame.new(
|
|
100
|
+
x: box[:x],
|
|
101
|
+
y: box[:y],
|
|
102
|
+
width: box[:width],
|
|
103
|
+
height: box[:height],
|
|
104
|
+
inset_top: pref&.inset_top,
|
|
105
|
+
inset_bottom: pref&.inset_bottom,
|
|
106
|
+
inset_left: pref&.inset_left,
|
|
107
|
+
inset_right: pref&.inset_right,
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
private_class_method :layout_frame
|
|
111
|
+
|
|
112
|
+
# Walks paragraphs and emits one `canvas.text` per line via
|
|
113
|
+
# the Shaper → LineBreaker → Justifier → VerticalLayout
|
|
114
|
+
# pipeline. Cursor y descends monotonically across
|
|
115
|
+
# paragraphs so text flows top-to-bottom (the previous
|
|
116
|
+
# implementation reset every run to the frame top).
|
|
117
|
+
def self.engine_render(canvas, paragraphs, context, box, font)
|
|
118
|
+
layout_frame = layout_frame(context.item, box)
|
|
119
|
+
cursor_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
|
|
120
|
+
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
79
121
|
char_cursor = 0
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
122
|
+
|
|
123
|
+
paragraphs.each do |paragraph|
|
|
124
|
+
break if cursor_y < bottom_limit
|
|
125
|
+
|
|
126
|
+
cursor_y, char_cursor = render_paragraph(
|
|
127
|
+
canvas, paragraph, context, layout_frame, font,
|
|
128
|
+
cursor_y, bottom_limit, char_cursor
|
|
83
129
|
)
|
|
84
130
|
end
|
|
85
131
|
end
|
|
86
132
|
private_class_method :engine_render
|
|
87
133
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
def self.render_paragraph(canvas, paragraph, context, layout_frame,
|
|
135
|
+
font, cursor_y, bottom_limit, char_cursor)
|
|
136
|
+
wrap_width = TextEngine::VerticalLayout.wrap_width(
|
|
137
|
+
layout_frame, paragraph.right_indent || 0
|
|
138
|
+
)
|
|
139
|
+
para_attrs = paragraph_attrs(paragraph)
|
|
140
|
+
frame_left, frame_right = paragraph_frame_extents(layout_frame)
|
|
141
|
+
|
|
142
|
+
# RuleAbove sits at the paragraph's top edge (before
|
|
143
|
+
# space_before is subtracted), so emit it first using the
|
|
144
|
+
# incoming cursor_y as the anchor.
|
|
145
|
+
ParagraphRules.emit_rule_above(canvas, paragraph, context,
|
|
146
|
+
cursor_y, frame_left, frame_right)
|
|
147
|
+
|
|
148
|
+
cursor_y, char_cursor = render_runs(
|
|
149
|
+
canvas, paragraph, context, layout_frame, font,
|
|
150
|
+
wrap_width, cursor_y, bottom_limit, char_cursor, para_attrs
|
|
151
|
+
)
|
|
152
|
+
after_y = cursor_y - para_attrs[:space_after]
|
|
153
|
+
|
|
154
|
+
# RuleBelow sits at the paragraph's bottom edge (after the
|
|
155
|
+
# last line + space_after), so emit it last using after_y
|
|
156
|
+
# as the anchor.
|
|
157
|
+
ParagraphRules.emit_rule_below(canvas, paragraph, context,
|
|
158
|
+
after_y, frame_left, frame_right)
|
|
159
|
+
[after_y, char_cursor]
|
|
160
|
+
end
|
|
161
|
+
private_class_method :render_paragraph
|
|
162
|
+
|
|
163
|
+
def self.paragraph_frame_extents(layout_frame)
|
|
164
|
+
inset_left = layout_frame.inset_left || 0
|
|
165
|
+
inset_right = layout_frame.inset_right || 0
|
|
166
|
+
[layout_frame.x + inset_left,
|
|
167
|
+
layout_frame.x + layout_frame.width - inset_right]
|
|
168
|
+
end
|
|
169
|
+
private_class_method :paragraph_frame_extents
|
|
170
|
+
|
|
171
|
+
def self.paragraph_attrs(paragraph)
|
|
172
|
+
{
|
|
173
|
+
space_before: paragraph.space_before || 0,
|
|
174
|
+
space_after: paragraph.space_after || 0,
|
|
175
|
+
first_line_indent: paragraph.first_line_indent || 0,
|
|
176
|
+
left_indent: paragraph.left_indent || 0,
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
private_class_method :paragraph_attrs
|
|
180
|
+
|
|
181
|
+
def self.render_runs(canvas, paragraph, context, layout_frame, font,
|
|
182
|
+
wrap_width, cursor_y, bottom_limit, char_cursor,
|
|
183
|
+
para_attrs)
|
|
184
|
+
space_before = para_attrs[:space_before]
|
|
185
|
+
paragraph.runs.each do |run|
|
|
186
|
+
break if cursor_y < bottom_limit
|
|
187
|
+
|
|
188
|
+
positioned, next_y = layout_run(
|
|
189
|
+
canvas, run, paragraph, context, layout_frame, font,
|
|
190
|
+
wrap_width, cursor_y, space_before,
|
|
191
|
+
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
192
|
+
char_cursor
|
|
193
|
+
)
|
|
194
|
+
char_cursor += positioned.length
|
|
195
|
+
cursor_y = next_y
|
|
196
|
+
space_before = 0
|
|
197
|
+
end
|
|
198
|
+
[cursor_y, char_cursor]
|
|
199
|
+
end
|
|
200
|
+
private_class_method :render_runs
|
|
201
|
+
|
|
202
|
+
# Shapes one run's text, wraps to the inset/indent-adjusted
|
|
203
|
+
# width, justifies per paragraph alignment, and asks
|
|
204
|
+
# VerticalLayout to position the lines. Emits each line via
|
|
205
|
+
# `canvas.text` and records positions for hyperlink tracking.
|
|
206
|
+
# Returns `[positioned_lines, next_y]`.
|
|
207
|
+
def self.layout_run(canvas, run, paragraph, context, layout_frame,
|
|
208
|
+
font, wrap_width, cursor_y, space_before,
|
|
209
|
+
first_line_indent, left_indent,
|
|
210
|
+
char_cursor)
|
|
211
|
+
size = run.point_size || DEFAULT_SIZE
|
|
98
212
|
glyphs = TextEngine::Shaper.shape(
|
|
99
213
|
text: run.text, font: font, size: size,
|
|
100
214
|
)
|
|
101
215
|
lines = TextEngine::LineBreaker.break(
|
|
102
|
-
glyphs: glyphs, frame_width:
|
|
216
|
+
glyphs: glyphs, frame_width: wrap_width,
|
|
217
|
+
)
|
|
218
|
+
lines.each do |line|
|
|
219
|
+
TextEngine::Justifier.justify(
|
|
220
|
+
line: line, frame_width: wrap_width,
|
|
221
|
+
alignment: paragraph.alignment || :left
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
leading = leading_for(paragraph, size)
|
|
225
|
+
|
|
226
|
+
positioned, next_y = TextEngine::VerticalLayout.layout_block(
|
|
227
|
+
lines: lines,
|
|
228
|
+
frame: layout_frame,
|
|
229
|
+
font_size: size,
|
|
230
|
+
leading: leading,
|
|
231
|
+
cursor_y: cursor_y,
|
|
232
|
+
space_before: space_before,
|
|
233
|
+
first_line_indent: first_line_indent,
|
|
234
|
+
left_indent: left_indent,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
238
|
+
positioned.each do |line|
|
|
239
|
+
break if line.y < bottom_limit
|
|
240
|
+
|
|
241
|
+
emit_line(canvas, line, run, context, size)
|
|
242
|
+
record_position(context, line, size, char_cursor)
|
|
243
|
+
end
|
|
244
|
+
[positioned, next_y]
|
|
245
|
+
end
|
|
246
|
+
private_class_method :layout_run
|
|
247
|
+
|
|
248
|
+
# Emits one positioned line: applies character-level styling
|
|
249
|
+
# (fill color + tint, capitalization, position, tracking,
|
|
250
|
+
# glyph scaling, baseline shift) via CharacterStyle, then
|
|
251
|
+
# draws underline/strike-through rules after the text.
|
|
252
|
+
def self.emit_line(canvas, line, run, context, size)
|
|
253
|
+
text = CharacterStyle.transform_text(line_text(line),
|
|
254
|
+
run.capitalization)
|
|
255
|
+
scaled_size, position_offset = CharacterStyle.position_scale(
|
|
256
|
+
run.position, size
|
|
103
257
|
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
record_position(context, line, line_x, line_y, line_width, size,
|
|
122
|
-
cursor)
|
|
123
|
-
cursor += line.glyphs.length
|
|
258
|
+
baseline_offset = CharacterStyle.baseline_offset(run) +
|
|
259
|
+
position_offset
|
|
260
|
+
text_at = [line.x, line.y + baseline_offset]
|
|
261
|
+
text_kwargs = CharacterStyle.text_kwargs(
|
|
262
|
+
run,
|
|
263
|
+
{
|
|
264
|
+
at: text_at,
|
|
265
|
+
font: font_for_run(run, context),
|
|
266
|
+
size: scaled_size,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
CharacterStyle.apply(canvas, run, context,
|
|
270
|
+
x: line.x, y: line.y + baseline_offset,
|
|
271
|
+
width: line.width, size: scaled_size) do
|
|
272
|
+
CharacterStyle.with_glyph_scaling(canvas, run) do
|
|
273
|
+
canvas.text(text, **text_kwargs)
|
|
274
|
+
end
|
|
124
275
|
end
|
|
125
|
-
cursor
|
|
126
276
|
end
|
|
127
|
-
private_class_method :
|
|
277
|
+
private_class_method :emit_line
|
|
278
|
+
|
|
279
|
+
def self.leading_for(paragraph, size)
|
|
280
|
+
explicit = paragraph.auto_leading
|
|
281
|
+
return size * LEADING_FACTOR unless explicit&.positive?
|
|
282
|
+
|
|
283
|
+
size * explicit
|
|
284
|
+
end
|
|
285
|
+
private_class_method :leading_for
|
|
128
286
|
|
|
129
|
-
def self.record_position(context, line,
|
|
287
|
+
def self.record_position(context, line, height, cursor)
|
|
130
288
|
tracker = context.position_tracker
|
|
131
289
|
return unless tracker
|
|
132
290
|
return unless context.item&.self_attr
|
|
@@ -135,9 +293,9 @@ module Idml
|
|
|
135
293
|
tracker.add(context.item.self_attr,
|
|
136
294
|
start_char: cursor,
|
|
137
295
|
end_char: cursor + glyph_count,
|
|
138
|
-
x: x,
|
|
139
|
-
y: y,
|
|
140
|
-
width: width,
|
|
296
|
+
x: line.x,
|
|
297
|
+
y: line.y,
|
|
298
|
+
width: line.width,
|
|
141
299
|
height: height)
|
|
142
300
|
end
|
|
143
301
|
private_class_method :record_position
|
|
@@ -146,7 +304,7 @@ module Idml
|
|
|
146
304
|
# run's `applied_font` (family name from CSR's AppliedFont)
|
|
147
305
|
# against the document's font_map. Falls back to the document
|
|
148
306
|
# default when no per-run font is specified or the family
|
|
149
|
-
# isn registered.
|
|
307
|
+
# isn't registered.
|
|
150
308
|
def self.font_for_run(run, context)
|
|
151
309
|
family = run.applied_font
|
|
152
310
|
return context.font_ps_name unless family
|
|
@@ -163,12 +321,12 @@ module Idml
|
|
|
163
321
|
|
|
164
322
|
# Fallback when no metrics are available: emit all runs as
|
|
165
323
|
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return if runs_for.empty?
|
|
324
|
+
def self.simple_render(canvas, paragraphs, context, box)
|
|
325
|
+
runs = paragraphs.flat_map(&:runs)
|
|
326
|
+
return if runs.empty?
|
|
170
327
|
|
|
171
|
-
|
|
328
|
+
runs_for = build_rich_runs(runs, context)
|
|
329
|
+
first_size = runs.first.point_size || DEFAULT_SIZE
|
|
172
330
|
canvas.text_rich(
|
|
173
331
|
runs_for,
|
|
174
332
|
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
@@ -181,7 +339,7 @@ module Idml
|
|
|
181
339
|
{
|
|
182
340
|
text: run.text,
|
|
183
341
|
font: font_for_run(run, context),
|
|
184
|
-
size: run.point_size,
|
|
342
|
+
size: run.point_size || DEFAULT_SIZE,
|
|
185
343
|
}
|
|
186
344
|
end
|
|
187
345
|
end
|
|
@@ -2,15 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
|
-
# Extracts styled text
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# paragraph
|
|
5
|
+
# Extracts styled text from an IDML Story in two granularities:
|
|
6
|
+
#
|
|
7
|
+
# 1. `extract_runs` — flat list of `StyledRun` (character-level
|
|
8
|
+
# attributes only). Useful when paragraph boundaries don't
|
|
9
|
+
# matter (e.g., simple-render fallback).
|
|
10
|
+
# 2. `extract_paragraphs` — list of `Paragraph` structs, each
|
|
11
|
+
# carrying paragraph-level attributes (spacing, indents,
|
|
12
|
+
# leading) plus its runs. Used by the layout engine so the
|
|
13
|
+
# renderer can apply SpaceBefore / FirstLineIndent / etc.
|
|
14
|
+
#
|
|
15
|
+
# Both methods read the same source (PSR → CSR → Content) so the
|
|
16
|
+
# text content is identical; only the grouping and attribute
|
|
17
|
+
# propagation differ.
|
|
9
18
|
class StyleResolver
|
|
19
|
+
# Character-level attributes carried per run. Defaults are
|
|
20
|
+
# applied at render time (e.g., DEFAULT_POINT_SIZE) — the Struct
|
|
21
|
+
# itself stores only what the CSR declares.
|
|
10
22
|
StyledRun = Struct.new(
|
|
11
|
-
:text,
|
|
12
|
-
:
|
|
13
|
-
|
|
23
|
+
:text,
|
|
24
|
+
:font_style,
|
|
25
|
+
:point_size,
|
|
26
|
+
:fill_color,
|
|
27
|
+
:fill_tint,
|
|
28
|
+
:applied_font,
|
|
29
|
+
:alignment,
|
|
30
|
+
:tracking,
|
|
31
|
+
:capitalization,
|
|
32
|
+
:position,
|
|
33
|
+
:underline,
|
|
34
|
+
:underline_offset,
|
|
35
|
+
:underline_weight,
|
|
36
|
+
:strike_thru,
|
|
37
|
+
:strike_through_offset,
|
|
38
|
+
:strike_through_weight,
|
|
39
|
+
:horizontal_scale,
|
|
40
|
+
:vertical_scale,
|
|
41
|
+
:baseline_shift,
|
|
42
|
+
keyword_init: true,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Paragraph-level attributes carried alongside a run list.
|
|
46
|
+
# Each attribute comes from the owning PSR and is nil when the
|
|
47
|
+
# PSR doesn't declare it.
|
|
48
|
+
Paragraph = Struct.new(
|
|
49
|
+
:runs,
|
|
50
|
+
:alignment,
|
|
51
|
+
:space_before,
|
|
52
|
+
:space_after,
|
|
53
|
+
:first_line_indent,
|
|
54
|
+
:left_indent,
|
|
55
|
+
:right_indent,
|
|
56
|
+
:auto_leading,
|
|
57
|
+
# RuleAbove / RuleBelow — paragraph rules (horizontal lines
|
|
58
|
+
# above/below the paragraph block). Driven by PSR attributes.
|
|
59
|
+
:rule_above,
|
|
60
|
+
:rule_above_line_weight,
|
|
61
|
+
:rule_above_color,
|
|
62
|
+
:rule_above_tint,
|
|
63
|
+
:rule_above_offset,
|
|
64
|
+
:rule_above_left_indent,
|
|
65
|
+
:rule_above_right_indent,
|
|
66
|
+
:rule_above_width,
|
|
67
|
+
:rule_below,
|
|
68
|
+
:rule_below_line_weight,
|
|
69
|
+
:rule_below_color,
|
|
70
|
+
:rule_below_tint,
|
|
71
|
+
:rule_below_offset,
|
|
72
|
+
:rule_below_left_indent,
|
|
73
|
+
:rule_below_right_indent,
|
|
74
|
+
:rule_below_width,
|
|
75
|
+
keyword_init: true,
|
|
14
76
|
)
|
|
15
77
|
|
|
16
78
|
DEFAULT_POINT_SIZE = 12.0
|
|
@@ -28,6 +90,8 @@ module Idml
|
|
|
28
90
|
"ToBinding" => :left,
|
|
29
91
|
}.freeze
|
|
30
92
|
|
|
93
|
+
# Returns the flat run list for a story. Each run carries only
|
|
94
|
+
# character-level attributes. Paragraph boundaries are lost.
|
|
31
95
|
def self.extract_runs(story)
|
|
32
96
|
return [] unless story&.inner
|
|
33
97
|
|
|
@@ -36,6 +100,46 @@ module Idml
|
|
|
36
100
|
end
|
|
37
101
|
end
|
|
38
102
|
|
|
103
|
+
# Returns the paragraph list for a story. Each paragraph groups
|
|
104
|
+
# its runs and carries the paragraph-level attributes from the
|
|
105
|
+
# owning PSR. Empty paragraphs (no runs after filtering) are
|
|
106
|
+
# skipped.
|
|
107
|
+
def self.extract_paragraphs(story)
|
|
108
|
+
return [] unless story&.inner
|
|
109
|
+
|
|
110
|
+
story.inner.paragraph_style_range.filter_map do |psr|
|
|
111
|
+
runs = csr_runs(psr)
|
|
112
|
+
next if runs.empty?
|
|
113
|
+
|
|
114
|
+
Paragraph.new(
|
|
115
|
+
runs: runs,
|
|
116
|
+
alignment: runs.first.alignment,
|
|
117
|
+
space_before: psr.space_before,
|
|
118
|
+
space_after: psr.space_after,
|
|
119
|
+
first_line_indent: psr.first_line_indent,
|
|
120
|
+
left_indent: psr.left_indent,
|
|
121
|
+
right_indent: psr.right_indent,
|
|
122
|
+
auto_leading: psr.auto_leading,
|
|
123
|
+
rule_above: psr.rule_above,
|
|
124
|
+
rule_above_line_weight: psr.rule_above_line_weight,
|
|
125
|
+
rule_above_color: psr.stroke_color,
|
|
126
|
+
rule_above_tint: psr.rule_above_tint,
|
|
127
|
+
rule_above_offset: psr.rule_above_offset,
|
|
128
|
+
rule_above_left_indent: psr.rule_above_left_indent,
|
|
129
|
+
rule_above_right_indent: psr.rule_above_right_indent,
|
|
130
|
+
rule_above_width: psr.rule_above_width,
|
|
131
|
+
rule_below: psr.rule_below,
|
|
132
|
+
rule_below_line_weight: psr.rule_below_line_weight,
|
|
133
|
+
rule_below_color: psr.stroke_color,
|
|
134
|
+
rule_below_tint: psr.rule_below_tint,
|
|
135
|
+
rule_below_offset: psr.rule_below_offset,
|
|
136
|
+
rule_below_left_indent: psr.rule_below_left_indent,
|
|
137
|
+
rule_below_right_indent: psr.rule_below_right_indent,
|
|
138
|
+
rule_below_width: psr.rule_below_width,
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
39
143
|
def self.csr_runs(psr)
|
|
40
144
|
psr.character_style_range.filter_map do |csr|
|
|
41
145
|
text = csr.text_content
|
|
@@ -49,6 +153,18 @@ module Idml
|
|
|
49
153
|
fill_tint: csr.fill_tint,
|
|
50
154
|
applied_font: csr.applied_font,
|
|
51
155
|
alignment: alignment_for(csr),
|
|
156
|
+
tracking: csr.tracking,
|
|
157
|
+
capitalization: csr.capitalization,
|
|
158
|
+
position: csr.position,
|
|
159
|
+
underline: csr.underline,
|
|
160
|
+
underline_offset: csr.underline_offset,
|
|
161
|
+
underline_weight: csr.underline_weight,
|
|
162
|
+
strike_thru: csr.strike_thru,
|
|
163
|
+
strike_through_offset: csr.strike_through_offset,
|
|
164
|
+
strike_through_weight: csr.strike_through_weight,
|
|
165
|
+
horizontal_scale: csr.horizontal_scale,
|
|
166
|
+
vertical_scale: csr.vertical_scale,
|
|
167
|
+
baseline_shift: csr.baseline_shift,
|
|
52
168
|
)
|
|
53
169
|
end
|
|
54
170
|
end
|
data/lib/idml/render.rb
CHANGED
|
@@ -13,6 +13,8 @@ module Idml
|
|
|
13
13
|
autoload :GradientResolver, "#{__dir__}/render/gradient_resolver"
|
|
14
14
|
autoload :Blending, "#{__dir__}/render/blending"
|
|
15
15
|
autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
|
|
16
|
+
autoload :CharacterStyle, "#{__dir__}/render/character_style"
|
|
17
|
+
autoload :ParagraphRules, "#{__dir__}/render/paragraph_rules"
|
|
16
18
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
17
19
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
18
20
|
autoload :ImageCollector, "#{__dir__}/render/image_collector"
|
|
@@ -2,63 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
module TextEngine
|
|
5
|
-
#
|
|
6
|
-
|
|
5
|
+
# Geometry of a text frame as seen by the layout engine: outer
|
|
6
|
+
# bounds plus the four inset margins. Inset values are nil when
|
|
7
|
+
# the TextFramePreference doesn't declare them.
|
|
8
|
+
Frame = Struct.new(
|
|
9
|
+
:x, :y, :width, :height,
|
|
10
|
+
:inset_top, :inset_bottom, :inset_left, :inset_right,
|
|
11
|
+
keyword_init: true
|
|
12
|
+
)
|
|
7
13
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# left/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
:inset_top, :inset_bottom,
|
|
14
|
-
:inset_left, :inset_right)
|
|
14
|
+
# A line that's been positioned absolutely within a frame.
|
|
15
|
+
# `glyphs` is the original ShapedGlyph array (carrying widths);
|
|
16
|
+
# `x`, `y` is the left edge / baseline; `width` is the line's
|
|
17
|
+
# natural width after justification.
|
|
18
|
+
PositionedLine = Struct.new(:glyphs, :x, :y, :width, keyword_init: true)
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
# Positions lines vertically within a text frame. The layout is
|
|
21
|
+
# block-oriented: each call to `layout_block` returns the
|
|
22
|
+
# positioned lines for one block (paragraph, run, etc.) and the
|
|
23
|
+
# y cursor for the next block, so callers can stack paragraphs
|
|
24
|
+
# without re-deriving cursor math.
|
|
25
|
+
class VerticalLayout
|
|
26
|
+
DEFAULT_LEADING_FACTOR = 1.2
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
# Position `lines` starting at `cursor_y` and walking downward.
|
|
29
|
+
# `cursor_y` is the top of the available space; the first line
|
|
30
|
+
# sits one `leading` below it. Returns `[positioned_lines, next_y]`
|
|
31
|
+
# where `next_y` is where the next block should start (i.e. below
|
|
32
|
+
# this block's last line plus `space_after`).
|
|
33
|
+
def self.layout_block(lines:, frame:, font_size:, cursor_y:, leading: nil, space_before: 0, space_after: 0,
|
|
34
|
+
first_line_indent: 0, left_indent: 0)
|
|
35
|
+
effective_leading = leading || (font_size * DEFAULT_LEADING_FACTOR)
|
|
36
|
+
y = cursor_y - space_before
|
|
37
|
+
positioned = []
|
|
33
38
|
|
|
34
|
-
def layout(lines)
|
|
35
|
-
result = []
|
|
36
|
-
y = top_y - @space_before
|
|
37
39
|
lines.each_with_index do |line, idx|
|
|
38
|
-
y -=
|
|
39
|
-
x = frame_left +
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
current_x,
|
|
45
|
-
y,
|
|
46
|
-
@font_size)
|
|
47
|
-
current_x += glyph.width
|
|
48
|
-
end
|
|
40
|
+
y -= effective_leading
|
|
41
|
+
x = frame_left(frame) + left_indent +
|
|
42
|
+
(idx.zero? ? first_line_indent : 0) + line.x_offset.to_f
|
|
43
|
+
positioned << PositionedLine.new(
|
|
44
|
+
glyphs: line.glyphs, x: x, y: y, width: line.width,
|
|
45
|
+
)
|
|
49
46
|
end
|
|
50
|
-
|
|
47
|
+
|
|
48
|
+
[positioned, y - space_after]
|
|
51
49
|
end
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
# Bottom y of the frame's text area (frame bottom edge + bottom
|
|
52
|
+
# inset). Lines whose y falls below this are clipped.
|
|
53
|
+
def self.bottom_limit(frame)
|
|
54
|
+
frame.y + (frame.inset_bottom || 0)
|
|
55
|
+
end
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
# Effective wrap width after subtracting insets and indents.
|
|
58
|
+
def self.wrap_width(frame, right_indent = 0)
|
|
59
|
+
width = frame.width - (frame.inset_left || 0) - (frame.inset_right || 0)
|
|
60
|
+
width - right_indent
|
|
57
61
|
end
|
|
58
62
|
|
|
59
|
-
def frame_left
|
|
60
|
-
|
|
63
|
+
def self.frame_left(frame)
|
|
64
|
+
frame.x + (frame.inset_left || 0)
|
|
61
65
|
end
|
|
66
|
+
private_class_method :frame_left
|
|
62
67
|
end
|
|
63
68
|
end
|
|
64
69
|
end
|
data/lib/idml/text_engine.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
# Pure-Ruby text layout engine. Takes styled text runs + frame
|
|
5
|
-
# geometry and produces positioned
|
|
5
|
+
# geometry and produces positioned lines using font metrics from
|
|
6
6
|
# .ttf/.otf files. IDML-agnostic — works on any styled text.
|
|
7
7
|
module TextEngine
|
|
8
8
|
autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
|
|
@@ -12,7 +12,8 @@ module Idml
|
|
|
12
12
|
autoload :Line, "#{__dir__}/text_engine/line_breaker"
|
|
13
13
|
autoload :Justifier, "#{__dir__}/text_engine/justifier"
|
|
14
14
|
autoload :VerticalLayout, "#{__dir__}/text_engine/vertical_layout"
|
|
15
|
-
autoload :
|
|
15
|
+
autoload :Frame, "#{__dir__}/text_engine/vertical_layout"
|
|
16
|
+
autoload :PositionedLine, "#{__dir__}/text_engine/vertical_layout"
|
|
16
17
|
autoload :CjkLayout, "#{__dir__}/text_engine/cjk_layout"
|
|
17
18
|
end
|
|
18
19
|
end
|
data/lib/idml/version.rb
CHANGED