idml 0.6.1 → 0.7.0
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/.github/workflows/release.yml +1 -3
- data/.gitignore +1 -0
- data/Gemfile +2 -1
- data/TODO.pdf/01-font-metrics.md +6 -0
- data/TODO.pdf/02-font-resolver.md +5 -0
- data/TODO.pdf/03-text-shaper.md +4 -0
- data/TODO.pdf/04-line-breaker-justifier.md +4 -0
- data/TODO.pdf/05-vertical-layout.md +4 -0
- data/TODO.pdf/06-pdf-color-operators.md +5 -0
- data/TODO.pdf/07-pdf-path-operators.md +5 -0
- data/TODO.pdf/08-pdf-text-operators.md +5 -0
- data/TODO.pdf/09-image-embedding.md +5 -0
- data/TODO.pdf/10-spread-renderer.md +5 -0
- data/TODO.pdf/100-character-level-styling.md +71 -0
- data/TODO.pdf/101-paragraph-rules-borders.md +38 -0
- data/TODO.pdf/102-anchored-objects.md +41 -0
- data/TODO.pdf/103-drop-caps.md +33 -0
- data/TODO.pdf/104-table-schema-completion.md +49 -0
- data/TODO.pdf/105-whole-package-round-trip.md +48 -0
- data/TODO.pdf/106-page-item-type-coverage.md +62 -0
- data/TODO.pdf/107-image-rendering-unification.md +42 -0
- data/TODO.pdf/11-font-embedding.md +6 -0
- data/TODO.pdf/12-idml-to-pdf-pipeline.md +6 -0
- data/TODO.pdf/13-cjk-text-layout.md +5 -0
- data/TODO.pdf/14-page-item-element-models.md +5 -0
- data/TODO.pdf/15-wire-spread-children.md +5 -0
- data/TODO.pdf/16-typed-model-pipeline.md +5 -0
- data/TODO.pdf/17-shape-rendering.md +5 -0
- data/TODO.pdf/18-text-rendering.md +5 -0
- data/TODO.pdf/19-page-dimensions.md +4 -0
- data/TODO.pdf/20-png-image-support.md +4 -0
- data/TODO.pdf/21-coordinate-transform.md +5 -0
- data/TODO.pdf/22-color-resolution.md +5 -0
- data/TODO.pdf/23-fontmetrics-ps-name-fix.md +5 -0
- data/TODO.pdf/24-antipattern-coverage.md +5 -0
- data/TODO.pdf/25-cli-render.md +4 -0
- data/TODO.pdf/26-renderer-registry.md +5 -0
- data/TODO.pdf/27-text-engine-integration.md +6 -0
- data/TODO.pdf/28-path-geometry-models.md +6 -0
- data/TODO.pdf/29-character-style-extraction.md +5 -0
- data/TODO.pdf/30-shape-geometry-rendering.md +6 -0
- data/TODO.pdf/31-stroke-rendering.md +6 -0
- data/TODO.pdf/32-master-spread-rendering.md +6 -0
- data/TODO.pdf/33-font-resolution-from-idml.md +7 -0
- data/TODO.pdf/34-story-threading.md +5 -0
- data/TODO.pdf/35-pdf-metadata.md +6 -0
- data/TODO.pdf/36-performance-caching.md +5 -0
- data/TODO.pdf/37-round-trip-specs.md +5 -0
- data/TODO.pdf/38-pdf-output-validation.md +5 -0
- data/TODO.pdf/41-graphic-line-rendering.md +5 -0
- data/TODO.pdf/42-group-transform-application.md +5 -0
- data/TODO.pdf/43-text-frame-insets.md +6 -0
- data/TODO.pdf/44-layer-support.md +6 -0
- data/TODO.pdf/45-image-clipping.md +4 -0
- data/TODO.pdf/46-spread-to-page-separation.md +5 -0
- data/TODO.pdf/47-pdf-bookmarks.md +6 -0
- data/TODO.pdf/48-table-rendering.md +7 -0
- data/TODO.pdf/49-gradient-fill-rendering.md +6 -0
- data/TODO.pdf/50-pdf-a-compliance.md +6 -0
- data/TODO.pdf/68-radial-gradient-shadings.md +4 -0
- data/TODO.pdf/69-transparency-blend-modes.md +6 -0
- data/TODO.pdf/94-text-frame-vertical-layout.md +67 -0
- data/TODO.pdf/95-paragraph-spacing-indents.md +51 -0
- data/TODO.pdf/96-text-frame-insets-actual.md +41 -0
- data/TODO.pdf/97-table-cell-rendering-fidelity.md +65 -0
- data/TODO.pdf/98-rowspan-columnspan.md +38 -0
- data/TODO.pdf/99-table-column-widths.md +44 -0
- data/TODO.pdf/README.md +6 -9
- data/idml.gemspec +2 -1
- data/lib/idml/elements/table.rb +48 -4
- data/lib/idml/render/character_style.rb +100 -0
- data/lib/idml/render/font_setup.rb +9 -0
- data/lib/idml/render/renderers/table_renderer.rb +188 -27
- data/lib/idml/render/renderers/text_frame_renderer.rb +185 -59
- data/lib/idml/render/style_resolver.rb +89 -7
- data/lib/idml/render.rb +1 -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 +40 -8
- data/Gemfile.lock +0 -235
|
@@ -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,169 @@ 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
|
+
cursor_y, char_cursor = render_runs(
|
|
141
|
+
canvas, paragraph, context, layout_frame, font,
|
|
142
|
+
wrap_width, cursor_y, bottom_limit, char_cursor, para_attrs
|
|
143
|
+
)
|
|
144
|
+
[cursor_y - para_attrs[:space_after], char_cursor]
|
|
145
|
+
end
|
|
146
|
+
private_class_method :render_paragraph
|
|
147
|
+
|
|
148
|
+
def self.paragraph_attrs(paragraph)
|
|
149
|
+
{
|
|
150
|
+
space_before: paragraph.space_before || 0,
|
|
151
|
+
space_after: paragraph.space_after || 0,
|
|
152
|
+
first_line_indent: paragraph.first_line_indent || 0,
|
|
153
|
+
left_indent: paragraph.left_indent || 0,
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
private_class_method :paragraph_attrs
|
|
157
|
+
|
|
158
|
+
def self.render_runs(canvas, paragraph, context, layout_frame, font,
|
|
159
|
+
wrap_width, cursor_y, bottom_limit, char_cursor,
|
|
160
|
+
para_attrs)
|
|
161
|
+
space_before = para_attrs[:space_before]
|
|
162
|
+
paragraph.runs.each do |run|
|
|
163
|
+
break if cursor_y < bottom_limit
|
|
164
|
+
|
|
165
|
+
positioned, next_y = layout_run(
|
|
166
|
+
canvas, run, paragraph, context, layout_frame, font,
|
|
167
|
+
wrap_width, cursor_y, space_before,
|
|
168
|
+
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
169
|
+
char_cursor
|
|
170
|
+
)
|
|
171
|
+
char_cursor += positioned.length
|
|
172
|
+
cursor_y = next_y
|
|
173
|
+
space_before = 0
|
|
174
|
+
end
|
|
175
|
+
[cursor_y, char_cursor]
|
|
176
|
+
end
|
|
177
|
+
private_class_method :render_runs
|
|
178
|
+
|
|
179
|
+
# Shapes one run's text, wraps to the inset/indent-adjusted
|
|
180
|
+
# width, justifies per paragraph alignment, and asks
|
|
181
|
+
# VerticalLayout to position the lines. Emits each line via
|
|
182
|
+
# `canvas.text` and records positions for hyperlink tracking.
|
|
183
|
+
# Returns `[positioned_lines, next_y]`.
|
|
184
|
+
def self.layout_run(canvas, run, paragraph, context, layout_frame,
|
|
185
|
+
font, wrap_width, cursor_y, space_before,
|
|
186
|
+
first_line_indent, left_indent,
|
|
187
|
+
char_cursor)
|
|
188
|
+
size = run.point_size || DEFAULT_SIZE
|
|
98
189
|
glyphs = TextEngine::Shaper.shape(
|
|
99
190
|
text: run.text, font: font, size: size,
|
|
100
191
|
)
|
|
101
192
|
lines = TextEngine::LineBreaker.break(
|
|
102
|
-
glyphs: glyphs, frame_width:
|
|
193
|
+
glyphs: glyphs, frame_width: wrap_width,
|
|
103
194
|
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
line_y = start_y - (idx * size * LEADING_FACTOR)
|
|
110
|
-
break if line_y < box[:y]
|
|
111
|
-
|
|
112
|
-
TextEngine::Justifier.justify(line: line,
|
|
113
|
-
frame_width: box[:width],
|
|
114
|
-
alignment: alignment)
|
|
115
|
-
line_x = box[:x] + line.x_offset
|
|
116
|
-
line_width = line.width
|
|
117
|
-
canvas.text(line_text(line),
|
|
118
|
-
at: [line_x, line_y],
|
|
119
|
-
font: font_for_run(run, context),
|
|
120
|
-
size: size)
|
|
121
|
-
record_position(context, line, line_x, line_y, line_width, size,
|
|
122
|
-
cursor)
|
|
123
|
-
cursor += line.glyphs.length
|
|
195
|
+
lines.each do |line|
|
|
196
|
+
TextEngine::Justifier.justify(
|
|
197
|
+
line: line, frame_width: wrap_width,
|
|
198
|
+
alignment: paragraph.alignment || :left
|
|
199
|
+
)
|
|
124
200
|
end
|
|
125
|
-
|
|
201
|
+
leading = leading_for(paragraph, size)
|
|
202
|
+
|
|
203
|
+
positioned, next_y = TextEngine::VerticalLayout.layout_block(
|
|
204
|
+
lines: lines,
|
|
205
|
+
frame: layout_frame,
|
|
206
|
+
font_size: size,
|
|
207
|
+
leading: leading,
|
|
208
|
+
cursor_y: cursor_y,
|
|
209
|
+
space_before: space_before,
|
|
210
|
+
first_line_indent: first_line_indent,
|
|
211
|
+
left_indent: left_indent,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
215
|
+
positioned.each do |line|
|
|
216
|
+
break if line.y < bottom_limit
|
|
217
|
+
|
|
218
|
+
emit_line(canvas, line, run, context, size)
|
|
219
|
+
record_position(context, line, size, char_cursor)
|
|
220
|
+
end
|
|
221
|
+
[positioned, next_y]
|
|
126
222
|
end
|
|
127
|
-
private_class_method :
|
|
223
|
+
private_class_method :layout_run
|
|
128
224
|
|
|
129
|
-
|
|
225
|
+
# Emits one positioned line: applies character-level styling
|
|
226
|
+
# (fill color, capitalization, position) via CharacterStyle,
|
|
227
|
+
# then draws underline/strike-through rules after the text.
|
|
228
|
+
def self.emit_line(canvas, line, run, context, size)
|
|
229
|
+
text = CharacterStyle.transform_text(line_text(line),
|
|
230
|
+
run.capitalization)
|
|
231
|
+
scaled_size, baseline_offset = CharacterStyle.position_scale(
|
|
232
|
+
run.position, size
|
|
233
|
+
)
|
|
234
|
+
CharacterStyle.apply(canvas, run, context,
|
|
235
|
+
x: line.x, y: line.y + baseline_offset,
|
|
236
|
+
width: line.width, size: scaled_size) do
|
|
237
|
+
canvas.text(
|
|
238
|
+
text,
|
|
239
|
+
at: [line.x, line.y + baseline_offset],
|
|
240
|
+
font: font_for_run(run, context),
|
|
241
|
+
size: scaled_size,
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
private_class_method :emit_line
|
|
246
|
+
|
|
247
|
+
def self.leading_for(paragraph, size)
|
|
248
|
+
explicit = paragraph.auto_leading
|
|
249
|
+
return size * LEADING_FACTOR unless explicit&.positive?
|
|
250
|
+
|
|
251
|
+
size * explicit
|
|
252
|
+
end
|
|
253
|
+
private_class_method :leading_for
|
|
254
|
+
|
|
255
|
+
def self.record_position(context, line, height, cursor)
|
|
130
256
|
tracker = context.position_tracker
|
|
131
257
|
return unless tracker
|
|
132
258
|
return unless context.item&.self_attr
|
|
@@ -135,9 +261,9 @@ module Idml
|
|
|
135
261
|
tracker.add(context.item.self_attr,
|
|
136
262
|
start_char: cursor,
|
|
137
263
|
end_char: cursor + glyph_count,
|
|
138
|
-
x: x,
|
|
139
|
-
y: y,
|
|
140
|
-
width: width,
|
|
264
|
+
x: line.x,
|
|
265
|
+
y: line.y,
|
|
266
|
+
width: line.width,
|
|
141
267
|
height: height)
|
|
142
268
|
end
|
|
143
269
|
private_class_method :record_position
|
|
@@ -146,7 +272,7 @@ module Idml
|
|
|
146
272
|
# run's `applied_font` (family name from CSR's AppliedFont)
|
|
147
273
|
# against the document's font_map. Falls back to the document
|
|
148
274
|
# default when no per-run font is specified or the family
|
|
149
|
-
# isn registered.
|
|
275
|
+
# isn't registered.
|
|
150
276
|
def self.font_for_run(run, context)
|
|
151
277
|
family = run.applied_font
|
|
152
278
|
return context.font_ps_name unless family
|
|
@@ -163,12 +289,12 @@ module Idml
|
|
|
163
289
|
|
|
164
290
|
# Fallback when no metrics are available: emit all runs as
|
|
165
291
|
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return if runs_for.empty?
|
|
292
|
+
def self.simple_render(canvas, paragraphs, context, box)
|
|
293
|
+
runs = paragraphs.flat_map(&:runs)
|
|
294
|
+
return if runs.empty?
|
|
170
295
|
|
|
171
|
-
|
|
296
|
+
runs_for = build_rich_runs(runs, context)
|
|
297
|
+
first_size = runs.first.point_size || DEFAULT_SIZE
|
|
172
298
|
canvas.text_rich(
|
|
173
299
|
runs_for,
|
|
174
300
|
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
@@ -181,7 +307,7 @@ module Idml
|
|
|
181
307
|
{
|
|
182
308
|
text: run.text,
|
|
183
309
|
font: font_for_run(run, context),
|
|
184
|
-
size: run.point_size,
|
|
310
|
+
size: run.point_size || DEFAULT_SIZE,
|
|
185
311
|
}
|
|
186
312
|
end
|
|
187
313
|
end
|
|
@@ -2,15 +2,59 @@
|
|
|
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
|
+
keyword_init: true,
|
|
14
58
|
)
|
|
15
59
|
|
|
16
60
|
DEFAULT_POINT_SIZE = 12.0
|
|
@@ -28,6 +72,8 @@ module Idml
|
|
|
28
72
|
"ToBinding" => :left,
|
|
29
73
|
}.freeze
|
|
30
74
|
|
|
75
|
+
# Returns the flat run list for a story. Each run carries only
|
|
76
|
+
# character-level attributes. Paragraph boundaries are lost.
|
|
31
77
|
def self.extract_runs(story)
|
|
32
78
|
return [] unless story&.inner
|
|
33
79
|
|
|
@@ -36,6 +82,30 @@ module Idml
|
|
|
36
82
|
end
|
|
37
83
|
end
|
|
38
84
|
|
|
85
|
+
# Returns the paragraph list for a story. Each paragraph groups
|
|
86
|
+
# its runs and carries the paragraph-level attributes from the
|
|
87
|
+
# owning PSR. Empty paragraphs (no runs after filtering) are
|
|
88
|
+
# skipped.
|
|
89
|
+
def self.extract_paragraphs(story)
|
|
90
|
+
return [] unless story&.inner
|
|
91
|
+
|
|
92
|
+
story.inner.paragraph_style_range.filter_map do |psr|
|
|
93
|
+
runs = csr_runs(psr)
|
|
94
|
+
next if runs.empty?
|
|
95
|
+
|
|
96
|
+
Paragraph.new(
|
|
97
|
+
runs: runs,
|
|
98
|
+
alignment: runs.first.alignment,
|
|
99
|
+
space_before: psr.space_before,
|
|
100
|
+
space_after: psr.space_after,
|
|
101
|
+
first_line_indent: psr.first_line_indent,
|
|
102
|
+
left_indent: psr.left_indent,
|
|
103
|
+
right_indent: psr.right_indent,
|
|
104
|
+
auto_leading: psr.auto_leading,
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
39
109
|
def self.csr_runs(psr)
|
|
40
110
|
psr.character_style_range.filter_map do |csr|
|
|
41
111
|
text = csr.text_content
|
|
@@ -49,6 +119,18 @@ module Idml
|
|
|
49
119
|
fill_tint: csr.fill_tint,
|
|
50
120
|
applied_font: csr.applied_font,
|
|
51
121
|
alignment: alignment_for(csr),
|
|
122
|
+
tracking: csr.tracking,
|
|
123
|
+
capitalization: csr.capitalization,
|
|
124
|
+
position: csr.position,
|
|
125
|
+
underline: csr.underline,
|
|
126
|
+
underline_offset: csr.underline_offset,
|
|
127
|
+
underline_weight: csr.underline_weight,
|
|
128
|
+
strike_thru: csr.strike_thru,
|
|
129
|
+
strike_through_offset: csr.strike_through_offset,
|
|
130
|
+
strike_through_weight: csr.strike_through_weight,
|
|
131
|
+
horizontal_scale: csr.horizontal_scale,
|
|
132
|
+
vertical_scale: csr.vertical_scale,
|
|
133
|
+
baseline_shift: csr.baseline_shift,
|
|
52
134
|
)
|
|
53
135
|
end
|
|
54
136
|
end
|
data/lib/idml/render.rb
CHANGED
|
@@ -13,6 +13,7 @@ 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"
|
|
16
17
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
17
18
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
18
19
|
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