idml 0.8.7 → 0.8.9
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/102-anchored-objects.md +40 -36
- data/TODO.pdf/106-page-item-type-coverage.md +37 -7
- data/TODO.pdf/111-footnote-support.md +45 -35
- data/lib/idml/elements/character_style_range.rb +18 -0
- data/lib/idml/elements/footnote.rb +32 -0
- data/lib/idml/elements/graphic_line.rb +3 -0
- data/lib/idml/elements/group.rb +3 -0
- data/lib/idml/elements/oval.rb +3 -0
- data/lib/idml/elements/path.rb +3 -0
- data/lib/idml/elements/polygon.rb +3 -0
- data/lib/idml/elements/rectangle.rb +3 -0
- data/lib/idml/elements/story_inner.rb +2 -0
- data/lib/idml/elements.rb +1 -0
- data/lib/idml/render/contour.rb +110 -0
- data/lib/idml/render/footnote.rb +211 -0
- data/lib/idml/render/renderers/oval_renderer.rb +5 -6
- data/lib/idml/render/renderers/path_renderer.rb +5 -6
- data/lib/idml/render/renderers/polygon_renderer.rb +6 -37
- data/lib/idml/render/renderers/rectangle_renderer.rb +5 -94
- data/lib/idml/render/renderers/text_frame_renderer.rb +117 -28
- data/lib/idml/render/shape_paint.rb +172 -0
- data/lib/idml/render/style_resolver.rb +93 -38
- data/lib/idml/render.rb +3 -0
- data/lib/idml/text_engine/vertical_layout.rb +11 -1
- data/lib/idml/version.rb +1 -1
- metadata +6 -2
|
@@ -25,7 +25,6 @@ module Idml
|
|
|
25
25
|
# FontMetrics is available (no shaper → no wrap → rough output).
|
|
26
26
|
class TextFrameRenderer
|
|
27
27
|
DEFAULT_SIZE = 12.0
|
|
28
|
-
LEADING_FACTOR = 1.2
|
|
29
28
|
|
|
30
29
|
def self.render(canvas, context)
|
|
31
30
|
frame = context.item
|
|
@@ -36,6 +35,7 @@ module Idml
|
|
|
36
35
|
|
|
37
36
|
box = frame_box(frame, context.page_height)
|
|
38
37
|
render_inline_tables(canvas, story, box, context)
|
|
38
|
+
render_anchored_objects(canvas, story, context)
|
|
39
39
|
|
|
40
40
|
state = initial_chain_state(frame, story, context)
|
|
41
41
|
return unless state
|
|
@@ -72,7 +72,8 @@ module Idml
|
|
|
72
72
|
def self.fresh_state(story, context)
|
|
73
73
|
paragraphs = StyleResolver.extract_paragraphs(
|
|
74
74
|
story, condition_filter: context.condition_filter,
|
|
75
|
-
style_lookup: context.style_lookup
|
|
75
|
+
style_lookup: context.style_lookup,
|
|
76
|
+
footnote_option: Footnote.option(context.package)
|
|
76
77
|
)
|
|
77
78
|
return nil if paragraphs.empty?
|
|
78
79
|
|
|
@@ -102,14 +103,47 @@ module Idml
|
|
|
102
103
|
private_class_method :render_inline_tables
|
|
103
104
|
|
|
104
105
|
def self.tables_in_story(story)
|
|
106
|
+
story_csrs(story).flat_map(&:table).compact
|
|
107
|
+
end
|
|
108
|
+
private_class_method :tables_in_story
|
|
109
|
+
|
|
110
|
+
# All CharacterStyleRanges of a story, in document order.
|
|
111
|
+
# Shared by the story-level item discovery walks (tables,
|
|
112
|
+
# anchored objects).
|
|
113
|
+
def self.story_csrs(story)
|
|
105
114
|
inner = story&.inner
|
|
106
115
|
return [] unless inner
|
|
107
116
|
|
|
108
|
-
inner.paragraph_style_range.flat_map
|
|
109
|
-
psr.character_style_range.flat_map(&:table)
|
|
110
|
-
end.compact
|
|
117
|
+
inner.paragraph_style_range.flat_map(&:character_style_range)
|
|
111
118
|
end
|
|
112
|
-
private_class_method :
|
|
119
|
+
private_class_method :story_csrs
|
|
120
|
+
|
|
121
|
+
# Renders anchored page items embedded in the story flow
|
|
122
|
+
# (Story > PSR > CSR > Rectangle/Oval/Polygon/GraphicLine/
|
|
123
|
+
# Group/TextFrame — the IDML structure for anchored objects).
|
|
124
|
+
# Each item renders at its own stored geometry, which
|
|
125
|
+
# InDesign resolves to the anchored position when saving;
|
|
126
|
+
# AnchorType-specific text reflow is not simulated (the
|
|
127
|
+
# layout engine positions text independently either way).
|
|
128
|
+
def self.render_anchored_objects(canvas, story, context)
|
|
129
|
+
anchored_items_in_story(story).each do |item|
|
|
130
|
+
PageItemRenderer.render(canvas, context_for_item(context, item))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_class_method :render_anchored_objects
|
|
134
|
+
|
|
135
|
+
def self.anchored_items_in_story(story)
|
|
136
|
+
story_csrs(story).flat_map do |csr|
|
|
137
|
+
[csr.rectangle, csr.oval, csr.polygon,
|
|
138
|
+
csr.graphic_line, csr.group, csr.text_frame]
|
|
139
|
+
end.flatten.compact
|
|
140
|
+
end
|
|
141
|
+
private_class_method :anchored_items_in_story
|
|
142
|
+
|
|
143
|
+
def self.context_for_item(context, item)
|
|
144
|
+
RenderContext.new(**context.to_h, item: item)
|
|
145
|
+
end
|
|
146
|
+
private_class_method :context_for_item
|
|
113
147
|
|
|
114
148
|
def self.render_text(canvas, state, context, box)
|
|
115
149
|
font = context.font_metrics
|
|
@@ -175,19 +209,23 @@ module Idml
|
|
|
175
209
|
top_y = layout_frame.y + layout_frame.height -
|
|
176
210
|
(layout_frame.inset_top || 0)
|
|
177
211
|
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
212
|
+
footnotes = []
|
|
178
213
|
cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
|
|
179
214
|
char_cursor = state.char_cursor
|
|
180
215
|
|
|
181
216
|
if state.current_paragraph
|
|
182
217
|
cursor_y, char_cursor = resume_paragraph(
|
|
183
218
|
canvas, state, context, layout_frame, font,
|
|
184
|
-
cursor_y, bottom_limit, char_cursor
|
|
219
|
+
cursor_y, bottom_limit, char_cursor, footnotes
|
|
185
220
|
)
|
|
186
|
-
return if state.current_paragraph
|
|
187
221
|
end
|
|
188
222
|
|
|
189
|
-
|
|
190
|
-
|
|
223
|
+
unless state.current_paragraph
|
|
224
|
+
consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
225
|
+
cursor_y, bottom_limit, char_cursor, footnotes)
|
|
226
|
+
end
|
|
227
|
+
render_footnote_entries(canvas, footnotes, layout_frame,
|
|
228
|
+
context, font)
|
|
191
229
|
end
|
|
192
230
|
private_class_method :engine_render_single_column
|
|
193
231
|
|
|
@@ -212,19 +250,23 @@ module Idml
|
|
|
212
250
|
def self.render_column(canvas, state, context, col_frame, font)
|
|
213
251
|
top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
|
|
214
252
|
bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
|
|
253
|
+
footnotes = []
|
|
215
254
|
cursor_y = top_y
|
|
216
255
|
char_cursor = state.char_cursor
|
|
217
256
|
|
|
218
257
|
if state.current_paragraph
|
|
219
258
|
cursor_y, char_cursor = resume_paragraph(
|
|
220
259
|
canvas, state, context, col_frame, font,
|
|
221
|
-
cursor_y, bottom_limit, char_cursor
|
|
260
|
+
cursor_y, bottom_limit, char_cursor, footnotes
|
|
222
261
|
)
|
|
223
|
-
return if state.current_paragraph
|
|
224
262
|
end
|
|
225
263
|
|
|
226
|
-
|
|
227
|
-
|
|
264
|
+
unless state.current_paragraph
|
|
265
|
+
consume_paragraphs(canvas, state, context, col_frame, font,
|
|
266
|
+
cursor_y, bottom_limit, char_cursor, footnotes)
|
|
267
|
+
end
|
|
268
|
+
render_footnote_entries(canvas, footnotes, col_frame,
|
|
269
|
+
context, font)
|
|
228
270
|
end
|
|
229
271
|
private_class_method :render_column
|
|
230
272
|
|
|
@@ -343,12 +385,13 @@ module Idml
|
|
|
343
385
|
private_class_method :space_before_after
|
|
344
386
|
|
|
345
387
|
def self.resume_paragraph(canvas, state, context, layout_frame, font,
|
|
346
|
-
cursor_y, bottom_limit, char_cursor
|
|
388
|
+
cursor_y, bottom_limit, char_cursor,
|
|
389
|
+
footnotes)
|
|
347
390
|
paragraph = state.current_paragraph
|
|
348
391
|
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
349
392
|
canvas, paragraph, state.runs_remaining, context,
|
|
350
393
|
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
351
|
-
first_paragraph: true
|
|
394
|
+
first_paragraph: true, footnotes: footnotes
|
|
352
395
|
)
|
|
353
396
|
if remaining_runs.any?
|
|
354
397
|
state.runs_remaining = remaining_runs
|
|
@@ -361,7 +404,8 @@ module Idml
|
|
|
361
404
|
private_class_method :resume_paragraph
|
|
362
405
|
|
|
363
406
|
def self.consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
364
|
-
cursor_y, bottom_limit, char_cursor
|
|
407
|
+
cursor_y, bottom_limit, char_cursor,
|
|
408
|
+
footnotes)
|
|
365
409
|
pending = 0
|
|
366
410
|
state.paragraphs.each do |paragraph|
|
|
367
411
|
break if cursor_y < bottom_limit
|
|
@@ -369,7 +413,7 @@ module Idml
|
|
|
369
413
|
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
370
414
|
canvas, paragraph, paragraph.runs, context,
|
|
371
415
|
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
372
|
-
first_paragraph: pending.zero
|
|
416
|
+
first_paragraph: pending.zero?, footnotes: footnotes
|
|
373
417
|
)
|
|
374
418
|
if remaining_runs.any?
|
|
375
419
|
state.current_paragraph = paragraph
|
|
@@ -392,7 +436,7 @@ module Idml
|
|
|
392
436
|
def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
|
|
393
437
|
layout_frame, font, cursor_y,
|
|
394
438
|
bottom_limit, char_cursor,
|
|
395
|
-
first_paragraph:)
|
|
439
|
+
first_paragraph:, footnotes:)
|
|
396
440
|
wrap_width = TextEngine::VerticalLayout.wrap_width(
|
|
397
441
|
layout_frame, paragraph.right_indent || 0
|
|
398
442
|
)
|
|
@@ -411,7 +455,7 @@ module Idml
|
|
|
411
455
|
rendered, cursor_y, char_cursor = iterate_paragraph_runs(
|
|
412
456
|
canvas, paragraph, effective_runs, context, layout_frame,
|
|
413
457
|
font, wrap_width, cursor_y, bottom_limit, char_cursor,
|
|
414
|
-
first_paragraph, para_attrs, dc_width, dc_lines
|
|
458
|
+
first_paragraph, para_attrs, dc_width, dc_lines, footnotes
|
|
415
459
|
)
|
|
416
460
|
|
|
417
461
|
remaining_runs = effective_runs[rendered..]
|
|
@@ -427,7 +471,7 @@ module Idml
|
|
|
427
471
|
layout_frame, font, wrap_width,
|
|
428
472
|
cursor_y, bottom_limit, char_cursor,
|
|
429
473
|
first_paragraph, para_attrs,
|
|
430
|
-
dc_width, dc_lines)
|
|
474
|
+
dc_width, dc_lines, footnotes)
|
|
431
475
|
rendered_count = 0
|
|
432
476
|
runs.each do |run|
|
|
433
477
|
break if cursor_y < bottom_limit
|
|
@@ -442,16 +486,65 @@ module Idml
|
|
|
442
486
|
canvas, run, paragraph, context, layout_frame, font,
|
|
443
487
|
run_wrap, cursor_y, space_before,
|
|
444
488
|
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
445
|
-
char_cursor
|
|
489
|
+
char_cursor, bottom_limit
|
|
446
490
|
)
|
|
447
491
|
char_cursor += positioned.length
|
|
448
492
|
cursor_y = next_y
|
|
449
493
|
rendered_count += 1
|
|
494
|
+
bottom_limit = register_footnote(footnotes, run, layout_frame,
|
|
495
|
+
font, context, bottom_limit)
|
|
450
496
|
end
|
|
451
497
|
[rendered_count, cursor_y, char_cursor]
|
|
452
498
|
end
|
|
453
499
|
private_class_method :iterate_paragraph_runs
|
|
454
500
|
|
|
501
|
+
# Collects the footnote anchored by a marker run and raises
|
|
502
|
+
# the effective bottom limit to reserve room for all
|
|
503
|
+
# footnotes collected so far. Pass-through for regular runs.
|
|
504
|
+
def self.register_footnote(footnotes, run, layout_frame, font,
|
|
505
|
+
context, bottom_limit)
|
|
506
|
+
return bottom_limit unless run.footnote_paragraphs
|
|
507
|
+
|
|
508
|
+
footnotes << Footnote::Entry.new(
|
|
509
|
+
number: run.footnote_number,
|
|
510
|
+
paragraphs: run.footnote_paragraphs,
|
|
511
|
+
)
|
|
512
|
+
option = Footnote.option(context.package)
|
|
513
|
+
frame_bottom = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
514
|
+
reserved = Footnote.reserved_height(footnotes, font, layout_frame,
|
|
515
|
+
option)
|
|
516
|
+
frame_bottom + reserved
|
|
517
|
+
end
|
|
518
|
+
private_class_method :register_footnote
|
|
519
|
+
|
|
520
|
+
# Renders the collected footnotes at the bottom of the
|
|
521
|
+
# frame: separator rule at the area's top edge, then the
|
|
522
|
+
# footnote paragraphs (marker-prefixed at extraction)
|
|
523
|
+
# flowing downward to the content bottom.
|
|
524
|
+
def self.render_footnote_entries(canvas, footnotes, layout_frame,
|
|
525
|
+
context, font)
|
|
526
|
+
return if footnotes.empty?
|
|
527
|
+
|
|
528
|
+
option = Footnote.option(context.package)
|
|
529
|
+
content_bottom = TextEngine::VerticalLayout.bottom_limit(
|
|
530
|
+
layout_frame,
|
|
531
|
+
)
|
|
532
|
+
area_top = content_bottom +
|
|
533
|
+
Footnote.reserved_height(footnotes, font, layout_frame, option)
|
|
534
|
+
|
|
535
|
+
Footnote.emit_separator(canvas, layout_frame, area_top, option)
|
|
536
|
+
positioned, = Footnote.layout_entries(
|
|
537
|
+
footnotes, layout_frame, font,
|
|
538
|
+
area_top - Footnote.rule_gap(option), option
|
|
539
|
+
)
|
|
540
|
+
positioned.each do |item|
|
|
541
|
+
next if item.line.y < content_bottom
|
|
542
|
+
|
|
543
|
+
emit_line(canvas, item.line, item.run, context, item.font_size)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
private_class_method :render_footnote_entries
|
|
547
|
+
|
|
455
548
|
# Computes the text-wrap overlap for a run at its current y
|
|
456
549
|
# position. Uses the run's point_size as the line height
|
|
457
550
|
# approximation. Returns 0.0 when no resolver is wired or no
|
|
@@ -594,7 +687,7 @@ module Idml
|
|
|
594
687
|
def self.layout_run(canvas, run, paragraph, context, layout_frame,
|
|
595
688
|
font, wrap_width, cursor_y, space_before,
|
|
596
689
|
first_line_indent, left_indent,
|
|
597
|
-
char_cursor)
|
|
690
|
+
char_cursor, bottom_limit)
|
|
598
691
|
size = run.point_size || DEFAULT_SIZE
|
|
599
692
|
glyphs = TextEngine::Shaper.shape(
|
|
600
693
|
text: run.text, font: font, size: size,
|
|
@@ -621,7 +714,6 @@ module Idml
|
|
|
621
714
|
left_indent: left_indent,
|
|
622
715
|
)
|
|
623
716
|
|
|
624
|
-
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
625
717
|
positioned.each do |line|
|
|
626
718
|
break if line.y < bottom_limit
|
|
627
719
|
|
|
@@ -664,10 +756,7 @@ module Idml
|
|
|
664
756
|
private_class_method :emit_line
|
|
665
757
|
|
|
666
758
|
def self.leading_for(paragraph, size)
|
|
667
|
-
|
|
668
|
-
return size * LEADING_FACTOR unless explicit&.positive?
|
|
669
|
-
|
|
670
|
-
size * explicit
|
|
759
|
+
TextEngine::VerticalLayout.leading_for(paragraph.auto_leading, size)
|
|
671
760
|
end
|
|
672
761
|
private_class_method :leading_for
|
|
673
762
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Paints a page item's fill and stroke on a pdfrb canvas,
|
|
6
|
+
# delegating path construction to the caller's block. The
|
|
7
|
+
# single owner of the IDML → PDF paint mapping — solid fill,
|
|
8
|
+
# gradient shading fill, stroke styling — shared by every
|
|
9
|
+
# shape renderer. Callers choose the geometry (axis-aligned
|
|
10
|
+
# rectangle, Bézier contour); this module chooses the paint
|
|
11
|
+
# and the PDF paint op sequencing (fill, stroke, or
|
|
12
|
+
# fill-and-stroke on one path).
|
|
13
|
+
module ShapePaint
|
|
14
|
+
# Paints the item. The block receives the canvas and must
|
|
15
|
+
# construct the current path (move_to/line_to/curve_to/...).
|
|
16
|
+
# The block may be called more than once (a path is consumed
|
|
17
|
+
# by each paint op, so fill-then-stroke rebuilds it).
|
|
18
|
+
# Returns nil when there is nothing to paint; truthy
|
|
19
|
+
# otherwise.
|
|
20
|
+
def self.paint(canvas, item, context, &build_path)
|
|
21
|
+
fill = fill_style(item, context)
|
|
22
|
+
stroke = stroke_style(item, context)
|
|
23
|
+
return unless fill || stroke
|
|
24
|
+
|
|
25
|
+
Blending.wrap(canvas, item.transparency_setting) do
|
|
26
|
+
paint_styles(canvas, item, context, fill, stroke, &build_path)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the fill plan: { kind: :solid, color: } for a
|
|
31
|
+
# resolved color, { kind: :gradient, gradient:, stops: } for
|
|
32
|
+
# a fill referencing a Gradient, nil when nothing fillable.
|
|
33
|
+
def self.fill_style(item, context)
|
|
34
|
+
return nil unless item.fill_color
|
|
35
|
+
return nil if item.fill_color == "Color/None"
|
|
36
|
+
|
|
37
|
+
if GradientResolver.gradient?(item.fill_color)
|
|
38
|
+
gradient_fill_style(item, context)
|
|
39
|
+
else
|
|
40
|
+
solid_fill_style(item, context)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
private_class_method :fill_style
|
|
44
|
+
|
|
45
|
+
def self.gradient_fill_style(item, context)
|
|
46
|
+
gradient = gradient_for(item, context)
|
|
47
|
+
return nil unless gradient
|
|
48
|
+
|
|
49
|
+
stops = gradient_stops(gradient, context)
|
|
50
|
+
return nil unless stops.length >= 2
|
|
51
|
+
|
|
52
|
+
{ kind: :gradient, gradient: gradient, stops: stops }
|
|
53
|
+
end
|
|
54
|
+
private_class_method :gradient_fill_style
|
|
55
|
+
|
|
56
|
+
def self.solid_fill_style(item, context)
|
|
57
|
+
color = context.color_resolver&.resolve(item.fill_color)
|
|
58
|
+
return nil unless color
|
|
59
|
+
|
|
60
|
+
{ kind: :solid, color: ColorHelper.to_canvas(color) }
|
|
61
|
+
end
|
|
62
|
+
private_class_method :solid_fill_style
|
|
63
|
+
|
|
64
|
+
def self.stroke_style(item, context)
|
|
65
|
+
return nil unless StrokeStyle.strokeable?(item)
|
|
66
|
+
|
|
67
|
+
color = context.color_resolver&.resolve(item.stroke_color)
|
|
68
|
+
return nil unless color
|
|
69
|
+
|
|
70
|
+
{ color: ColorHelper.to_canvas(color) }
|
|
71
|
+
end
|
|
72
|
+
private_class_method :stroke_style
|
|
73
|
+
|
|
74
|
+
def self.paint_styles(canvas, item, context, fill, stroke, &)
|
|
75
|
+
if fill && stroke
|
|
76
|
+
paint_fill_and_stroke(canvas, item, context, fill, stroke,
|
|
77
|
+
&)
|
|
78
|
+
elsif fill
|
|
79
|
+
paint_fill(canvas, item, context, fill, &)
|
|
80
|
+
else
|
|
81
|
+
paint_stroke(canvas, item, stroke, &)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
private_class_method :paint_styles
|
|
85
|
+
|
|
86
|
+
def self.paint_fill(canvas, item, context, fill, &)
|
|
87
|
+
yield(canvas)
|
|
88
|
+
if fill[:kind] == :gradient
|
|
89
|
+
canvas.fill_shading(shading_for(canvas, item, context, fill))
|
|
90
|
+
else
|
|
91
|
+
canvas.fill_color(fill[:color])
|
|
92
|
+
canvas.fill
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
private_class_method :paint_fill
|
|
96
|
+
|
|
97
|
+
def self.paint_stroke(canvas, item, stroke, &)
|
|
98
|
+
StrokeStyle.apply(canvas, item) do
|
|
99
|
+
canvas.stroke_color(stroke[:color])
|
|
100
|
+
canvas.line_width = item.stroke_weight
|
|
101
|
+
yield(canvas)
|
|
102
|
+
canvas.stroke
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
private_class_method :paint_stroke
|
|
106
|
+
|
|
107
|
+
# Fill and stroke on one path where possible (fill_stroke);
|
|
108
|
+
# a gradient fill consumes the path, so the stroke rebuilds
|
|
109
|
+
# it afterward.
|
|
110
|
+
def self.paint_fill_and_stroke(canvas, item, context, fill, stroke,
|
|
111
|
+
&)
|
|
112
|
+
if fill[:kind] == :gradient
|
|
113
|
+
paint_fill(canvas, item, context, fill, &)
|
|
114
|
+
paint_stroke(canvas, item, stroke, &)
|
|
115
|
+
else
|
|
116
|
+
yield(canvas)
|
|
117
|
+
canvas.fill_color(fill[:color])
|
|
118
|
+
StrokeStyle.apply(canvas, item) do
|
|
119
|
+
canvas.stroke_color(stroke[:color])
|
|
120
|
+
canvas.line_width = item.stroke_weight
|
|
121
|
+
canvas.fill_stroke
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
private_class_method :paint_fill_and_stroke
|
|
126
|
+
|
|
127
|
+
def self.shading_for(canvas, item, context, fill)
|
|
128
|
+
box = Placement.box(item, context.page_height)
|
|
129
|
+
stops = fill[:stops]
|
|
130
|
+
if fill[:gradient].type == "Radial"
|
|
131
|
+
canvas.document.shadings.add_radial(
|
|
132
|
+
from: radial_center(box, 0.0),
|
|
133
|
+
to: radial_center(box, [box[:width], box[:height]].max / 2.0),
|
|
134
|
+
stops: stops,
|
|
135
|
+
)
|
|
136
|
+
else
|
|
137
|
+
canvas.document.shadings.add_axial(
|
|
138
|
+
from: [box[:x], box[:y] + box[:height]],
|
|
139
|
+
to: [box[:x] + box[:width], box[:y]],
|
|
140
|
+
stops: stops,
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
private_class_method :shading_for
|
|
145
|
+
|
|
146
|
+
def self.radial_center(box, radius)
|
|
147
|
+
cx = box[:x] + (box[:width] / 2.0)
|
|
148
|
+
cy = box[:y] + (box[:height] / 2.0)
|
|
149
|
+
[cx, cy, radius]
|
|
150
|
+
end
|
|
151
|
+
private_class_method :radial_center
|
|
152
|
+
|
|
153
|
+
def self.gradient_for(item, context)
|
|
154
|
+
graphic = context.package&.graphic
|
|
155
|
+
return nil unless graphic
|
|
156
|
+
|
|
157
|
+
graphic.gradient.find { |g| g.self_attr == item.fill_color }
|
|
158
|
+
end
|
|
159
|
+
private_class_method :gradient_for
|
|
160
|
+
|
|
161
|
+
def self.gradient_stops(gradient, context)
|
|
162
|
+
gradient.gradient_stop.filter_map do |stop|
|
|
163
|
+
color = context.color_resolver&.resolve(stop.stop_color)
|
|
164
|
+
next unless color
|
|
165
|
+
|
|
166
|
+
[stop.location || 0.0, ColorHelper.to_canvas(color)]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
private_class_method :gradient_stops
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -18,7 +18,9 @@ module Idml
|
|
|
18
18
|
class StyleResolver
|
|
19
19
|
# Character-level attributes carried per run. Defaults are
|
|
20
20
|
# applied at render time (e.g., DEFAULT_POINT_SIZE) — the Struct
|
|
21
|
-
# itself stores only what the CSR declares.
|
|
21
|
+
# itself stores only what the CSR declares. `footnote_number`
|
|
22
|
+
# and `footnote_paragraphs` are set only on synthesized
|
|
23
|
+
# footnote-marker runs (see Render::Footnote).
|
|
22
24
|
StyledRun = Struct.new(
|
|
23
25
|
:text,
|
|
24
26
|
:font_style,
|
|
@@ -39,6 +41,8 @@ module Idml
|
|
|
39
41
|
:horizontal_scale,
|
|
40
42
|
:vertical_scale,
|
|
41
43
|
:baseline_shift,
|
|
44
|
+
:footnote_number,
|
|
45
|
+
:footnote_paragraphs,
|
|
42
46
|
keyword_init: true,
|
|
43
47
|
)
|
|
44
48
|
|
|
@@ -98,27 +102,48 @@ module Idml
|
|
|
98
102
|
|
|
99
103
|
# Returns the flat run list for a story. Each run carries only
|
|
100
104
|
# character-level attributes. Paragraph boundaries are lost.
|
|
105
|
+
# Footnote markers number sequentially across the story.
|
|
101
106
|
def self.extract_runs(story)
|
|
102
107
|
return [] unless story&.inner
|
|
103
108
|
|
|
109
|
+
counter = Footnote.counter_for(nil)
|
|
104
110
|
story.inner.paragraph_style_range.flat_map do |psr|
|
|
105
|
-
csr_runs(psr)
|
|
111
|
+
csr_runs(psr, footnote_counter: counter)
|
|
106
112
|
end
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
# Returns the paragraph list for a story. Each paragraph groups
|
|
110
116
|
# its runs and carries the paragraph-level attributes from the
|
|
111
117
|
# owning PSR. Empty paragraphs (no runs after filtering) are
|
|
112
|
-
# skipped.
|
|
118
|
+
# skipped. Footnotes anchored in the story emit superscript
|
|
119
|
+
# marker runs, numbered sequentially from the FootnoteOption's
|
|
120
|
+
# StartAt (default 1).
|
|
113
121
|
def self.extract_paragraphs(story, condition_filter: nil,
|
|
114
|
-
style_lookup: nil)
|
|
122
|
+
style_lookup: nil, footnote_option: nil)
|
|
115
123
|
return [] unless story&.inner
|
|
116
124
|
|
|
117
|
-
|
|
125
|
+
extract_container_paragraphs(
|
|
126
|
+
story.inner,
|
|
127
|
+
condition_filter: condition_filter, style_lookup: style_lookup,
|
|
128
|
+
footnote_option: footnote_option
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extracts paragraphs from any container exposing
|
|
133
|
+
# `paragraph_style_range` (StoryInner, Elements::Footnote).
|
|
134
|
+
# Footnote-counter state is shared across the whole walk so
|
|
135
|
+
# footnotes number in document order.
|
|
136
|
+
def self.extract_container_paragraphs(container, condition_filter: nil,
|
|
137
|
+
style_lookup: nil,
|
|
138
|
+
footnote_option: nil)
|
|
139
|
+
counter = Footnote.counter_for(footnote_option)
|
|
140
|
+
container.paragraph_style_range.filter_map do |psr|
|
|
118
141
|
next unless condition_visible?(psr, condition_filter)
|
|
119
142
|
|
|
120
143
|
runs = csr_runs(psr, condition_filter: condition_filter,
|
|
121
|
-
style_lookup: style_lookup
|
|
144
|
+
style_lookup: style_lookup,
|
|
145
|
+
footnote_counter: counter,
|
|
146
|
+
footnote_option: footnote_option)
|
|
122
147
|
next if runs.empty?
|
|
123
148
|
|
|
124
149
|
paragraph = Paragraph.new(
|
|
@@ -177,43 +202,73 @@ module Idml
|
|
|
177
202
|
end
|
|
178
203
|
end
|
|
179
204
|
|
|
180
|
-
def self.csr_runs(psr, condition_filter: nil, style_lookup: nil
|
|
181
|
-
|
|
182
|
-
|
|
205
|
+
def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
|
|
206
|
+
footnote_counter: nil, footnote_option: nil)
|
|
207
|
+
counter = footnote_counter || Footnote.counter_for(footnote_option)
|
|
208
|
+
psr.character_style_range.flat_map do |csr|
|
|
209
|
+
next [] unless condition_visible?(csr, condition_filter)
|
|
183
210
|
|
|
184
|
-
|
|
185
|
-
|
|
211
|
+
csr_content_runs(csr, condition_filter: condition_filter,
|
|
212
|
+
style_lookup: style_lookup,
|
|
213
|
+
footnote_counter: counter,
|
|
214
|
+
footnote_option: footnote_option)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
186
217
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
:underline_weight),
|
|
204
|
-
strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
|
|
205
|
-
strike_through_offset: resolve_csr(style_lookup, csr,
|
|
206
|
-
:strike_through_offset),
|
|
207
|
-
strike_through_weight: resolve_csr(style_lookup, csr,
|
|
208
|
-
:strike_through_weight),
|
|
209
|
-
horizontal_scale: resolve_csr(style_lookup, csr,
|
|
210
|
-
:horizontal_scale),
|
|
211
|
-
vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
|
|
212
|
-
baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
|
|
218
|
+
# Builds the runs for one CSR: its text run (when non-empty)
|
|
219
|
+
# plus one superscript marker run per anchored Footnote. A CSR
|
|
220
|
+
# holding only a footnote still emits its marker.
|
|
221
|
+
def self.csr_content_runs(csr, condition_filter:, style_lookup:,
|
|
222
|
+
footnote_counter:, footnote_option:)
|
|
223
|
+
text = csr.text_content
|
|
224
|
+
runs = []
|
|
225
|
+
unless text.nil? || text.empty?
|
|
226
|
+
runs << text_run(csr, style_lookup: style_lookup)
|
|
227
|
+
end
|
|
228
|
+
csr.footnote.each do |element|
|
|
229
|
+
number = footnote_counter.next_number
|
|
230
|
+
paragraphs = Footnote.extract(
|
|
231
|
+
element, number, condition_filter: condition_filter,
|
|
232
|
+
style_lookup: style_lookup,
|
|
233
|
+
option: footnote_option
|
|
213
234
|
)
|
|
235
|
+
runs << Footnote.marker_run(number, runs.last, paragraphs,
|
|
236
|
+
footnote_option)
|
|
214
237
|
end
|
|
238
|
+
runs
|
|
239
|
+
end
|
|
240
|
+
private_class_method :csr_content_runs
|
|
241
|
+
|
|
242
|
+
def self.text_run(csr, style_lookup:)
|
|
243
|
+
StyledRun.new(
|
|
244
|
+
text: csr.text_content,
|
|
245
|
+
font_style: resolve_csr(style_lookup, csr, :font_style),
|
|
246
|
+
point_size: resolve_csr(style_lookup, csr, :point_size) ||
|
|
247
|
+
DEFAULT_POINT_SIZE,
|
|
248
|
+
fill_color: resolve_csr(style_lookup, csr, :fill_color),
|
|
249
|
+
fill_tint: resolve_csr(style_lookup, csr, :fill_tint),
|
|
250
|
+
applied_font: resolve_csr(style_lookup, csr, :applied_font),
|
|
251
|
+
alignment: alignment_for(csr),
|
|
252
|
+
tracking: resolve_csr(style_lookup, csr, :tracking),
|
|
253
|
+
capitalization: resolve_csr(style_lookup, csr, :capitalization),
|
|
254
|
+
position: resolve_csr(style_lookup, csr, :position),
|
|
255
|
+
underline: resolve_csr(style_lookup, csr, :underline),
|
|
256
|
+
underline_offset: resolve_csr(style_lookup, csr,
|
|
257
|
+
:underline_offset),
|
|
258
|
+
underline_weight: resolve_csr(style_lookup, csr,
|
|
259
|
+
:underline_weight),
|
|
260
|
+
strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
|
|
261
|
+
strike_through_offset: resolve_csr(style_lookup, csr,
|
|
262
|
+
:strike_through_offset),
|
|
263
|
+
strike_through_weight: resolve_csr(style_lookup, csr,
|
|
264
|
+
:strike_through_weight),
|
|
265
|
+
horizontal_scale: resolve_csr(style_lookup, csr,
|
|
266
|
+
:horizontal_scale),
|
|
267
|
+
vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
|
|
268
|
+
baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
|
|
269
|
+
)
|
|
215
270
|
end
|
|
216
|
-
private_class_method :
|
|
271
|
+
private_class_method :text_run
|
|
217
272
|
|
|
218
273
|
# Resolves a paragraph-level attribute from PSR or its
|
|
219
274
|
# referenced ParagraphStyle. When style_lookup is nil,
|
data/lib/idml/render.rb
CHANGED
|
@@ -22,6 +22,9 @@ module Idml
|
|
|
22
22
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
23
23
|
autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
|
|
24
24
|
autoload :TextWrapResolver, "#{__dir__}/render/text_wrap_resolver"
|
|
25
|
+
autoload :Footnote, "#{__dir__}/render/footnote"
|
|
26
|
+
autoload :ShapePaint, "#{__dir__}/render/shape_paint"
|
|
27
|
+
autoload :Contour, "#{__dir__}/render/contour"
|
|
25
28
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
26
29
|
autoload :ImageCollector, "#{__dir__}/render/image_collector"
|
|
27
30
|
autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
|
|
@@ -54,6 +54,17 @@ module Idml
|
|
|
54
54
|
frame.y + (frame.inset_bottom || 0)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Leading for a text block: AutoLeading × size when the
|
|
58
|
+
# paragraph declares a positive AutoLeading, else the default
|
|
59
|
+
# factor. Shared by the frame renderer and the footnote
|
|
60
|
+
# layout so both stack lines identically.
|
|
61
|
+
def self.leading_for(auto_leading, font_size)
|
|
62
|
+
return font_size * DEFAULT_LEADING_FACTOR unless
|
|
63
|
+
auto_leading&.positive?
|
|
64
|
+
|
|
65
|
+
font_size * auto_leading
|
|
66
|
+
end
|
|
67
|
+
|
|
57
68
|
# Effective wrap width after subtracting insets and indents.
|
|
58
69
|
def self.wrap_width(frame, right_indent = 0)
|
|
59
70
|
width = frame.width - (frame.inset_left || 0) - (frame.inset_right || 0)
|
|
@@ -63,7 +74,6 @@ module Idml
|
|
|
63
74
|
def self.frame_left(frame)
|
|
64
75
|
frame.x + (frame.inset_left || 0)
|
|
65
76
|
end
|
|
66
|
-
private_class_method :frame_left
|
|
67
77
|
end
|
|
68
78
|
end
|
|
69
79
|
end
|
data/lib/idml/version.rb
CHANGED