idml 0.7.1 → 0.7.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f43caa091c83d9d8288f22f3913d21760bf5a5dac986f76b72521bb19f5110e0
4
- data.tar.gz: 4e33ef7a0c119936d16c0eecb687d153e04d9dc29d789511633fb6fff7f1a152
3
+ metadata.gz: c6cc7058e67830e2dc6ea523ea6ca054f702ce29b51989a19deb023e5a840bee
4
+ data.tar.gz: 434b5af5ea49b9f2b48728b100b612ff0cd784079abfd6d419dbeb16554b3ebb
5
5
  SHA512:
6
- metadata.gz: 16b0f93a7c8993c5f98ad84c6e186ed61ebcaaabcdda63b26c9d1e4bfadd1c3986bef933d2a832d2b3463ab5b23324778a37e92e62cbeb8ce0876dbc26bebcbe
7
- data.tar.gz: 9950c7afceda42f377e0d2bef226c4caa346c9d79ac4b3b61dcba99612a321c646eecd6b341be88fd7f8a46c695ecc2449d1bf4d900e4338ed1a4c9f76ea4d45
6
+ metadata.gz: a59cabfafa44138cb40ed8b6e493396c110ffe43728c0d0ea94d8ef4d8039efad635ba23660852b1040b58ce7141b6e301bf27ecb96599efd6d72069e6d81a95
7
+ data.tar.gz: d142ebcfc9eb6591827c72d3cd33303ee3a4c70637031b03622ea1355d453cac4aac1654f60ee7a3ee54a050755e88fb84c32c01df11df802c434442c0f9a4e2
@@ -1,6 +1,10 @@
1
1
  # TODO PDF 108: Multi-frame story flow (use StoryThreader)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-09 audit
3
+ ## Status: DONE`Render::StoryChainController` threads paragraph/run
4
+ state across linked TextFrames. TextFrameRenderer no longer treats
5
+ chain non-heads as standalone; it picks up leftover state from the
6
+ controller, renders what fits, and stores the new leftover for the
7
+ next frame. Overflow text is no longer silently dropped.
4
8
 
5
9
  ## Problem
6
10
 
@@ -1,6 +1,12 @@
1
1
  # TODO PDF 110: Conditional text (AppliedConditions)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-09 audit
3
+ ## Status: DONE`Elements::Condition` models the `<Condition>`
4
+ element from designmap.rnc. `Render::ConditionFilter` indexes
5
+ conditions by Self and answers `visible?(applied_conditions_string)`
6
+ per run. `StyleResolver.extract_paragraphs` takes optional
7
+ `condition_filter:` kwarg and drops PSRs/CSRs whose AppliedConditions
8
+ reference a hidden Condition. Pipeline constructs the filter from
9
+ `designmap.condition` and threads it through RenderContext.
4
10
 
5
11
  ## Problem
6
12
 
@@ -0,0 +1,31 @@
1
+ # frozen_string: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # `<Condition>` — a single condition declared in `designmap.xml`.
6
+ # Authors tag text runs with one or more conditions (the CSR's
7
+ # `AppliedConditions` attribute is a list of condition Self IDs).
8
+ # The condition's `Visible` flag controls whether tagged text
9
+ # appears in the rendered output.
10
+ #
11
+ # Schema: `Condition_Object` in
12
+ # `reference-docs/schemas/package/designmap.rnc`.
13
+ class Condition < Lutaml::Model::Serializable
14
+ attribute :self_attr, :string
15
+ attribute :name, :string
16
+ attribute :indicator_method, :string
17
+ attribute :underline_indicator_appearance, :string
18
+ attribute :visible, :boolean, default: true
19
+
20
+ xml do
21
+ root "Condition"
22
+ map_attribute "Self", to: :self_attr
23
+ map_attribute "Name", to: :name
24
+ map_attribute "IndicatorMethod", to: :indicator_method
25
+ map_attribute "UnderlineIndicatorAppearance",
26
+ to: :underline_indicator_appearance
27
+ map_attribute "Visible", to: :visible
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/idml/elements.rb CHANGED
@@ -17,6 +17,7 @@ module Idml
17
17
  autoload :ButtonPreference, "idml/elements/pref_button_preference"
18
18
  autoload :Bookmark, "idml/elements/bookmark"
19
19
  autoload :Cell, "idml/elements/cell"
20
+ autoload :Condition, "idml/elements/condition"
20
21
  autoload :CellStyle, "idml/elements/cell_style"
21
22
  autoload :CellStyleGroup, "idml/elements/cell_style_group"
22
23
  autoload :ChapterNumberPreference,
@@ -48,6 +48,7 @@ module Idml
48
48
 
49
49
  attribute :layer, Idml::Elements::Layer, collection: true
50
50
  attribute :bookmark, Idml::Elements::Bookmark, collection: true
51
+ attribute :condition, Idml::Elements::Condition, collection: true
51
52
  attribute :hyperlink, Idml::Elements::Hyperlink, collection: true
52
53
  attribute :hyperlink_page_destination,
53
54
  Idml::Elements::HyperlinkPageDestination, collection: true
@@ -92,6 +93,7 @@ module Idml
92
93
  map_attribute "ActiveProcess", to: :active_process
93
94
  map_element "Layer", to: :layer
94
95
  map_element "Bookmark", to: :bookmark
96
+ map_element "Condition", to: :condition
95
97
  map_element "Hyperlink", to: :hyperlink
96
98
  map_element "HyperlinkPageDestination",
97
99
  to: :hyperlink_page_destination
@@ -0,0 +1,57 @@
1
+ # frozen_string: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Resolves whether a styled run should render based on its
6
+ # AppliedConditions and the visibility flags declared on the
7
+ # corresponding `<Condition>` elements in `designmap.xml`.
8
+ #
9
+ # A run is visible when either:
10
+ # - It has no AppliedConditions (the common case), OR
11
+ # - Every condition it references is itself Visible.
12
+ #
13
+ # A run is hidden when ANY referenced condition is hidden. This
14
+ # matches InDesign's "hide any text the user has toggled off"
15
+ # semantics.
16
+ #
17
+ # Construction: `ConditionFilter.from_designmap(designmap)`
18
+ # reads the condition collection and indexes by Self. Use
19
+ # `filter.visible?(applied_conditions_string)` per run; pass nil
20
+ # or empty string for untagged runs.
21
+ class ConditionFilter
22
+ def self.from_designmap(designmap)
23
+ conditions = designmap ? designmap.condition : []
24
+ new(conditions)
25
+ end
26
+
27
+ def initialize(conditions)
28
+ @visibility_by_self = conditions.to_h do |cond|
29
+ [cond.self_attr, condition_visible?(cond)]
30
+ end
31
+ end
32
+
33
+ # `applied_conditions` is the raw PSR/CSR string. IDML encodes
34
+ # it as a space-separated list of condition Self IDs. Returns
35
+ # true when the run should render.
36
+ def visible?(applied_conditions)
37
+ return true if applied_conditions.nil?
38
+ return true if applied_conditions.strip.empty?
39
+
40
+ applied_conditions.split.uniq.all? do |cond_self|
41
+ condition_visible?(cond_self)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # A condition is visible when explicitly Visible="true" OR when
48
+ # the Visibility flag is unset (default true). Only an explicit
49
+ # Visible="false" hides it.
50
+ def condition_visible?(cond_or_self)
51
+ return @visibility_by_self.fetch(cond_or_self, true) if cond_or_self.is_a?(String)
52
+
53
+ cond_or_self.visible.nil? || cond_or_self.visible
54
+ end
55
+ end
56
+ end
57
+ end
@@ -38,6 +38,7 @@ module Idml
38
38
  structure = StructureTracker.new(enabled: @tagged)
39
39
  position_tracker = PositionTracker.new
40
40
  layer_filter = LayerFilter.from_designmap(@package.designmap)
41
+ condition_filter = ConditionFilter.from_designmap(@package.designmap)
41
42
  font_ref_resolver = FontReferenceResolver.build(@package)
42
43
  font_setup = FontSetup.new(package: @package,
43
44
  font_search_paths: @font_search_paths)
@@ -50,7 +51,8 @@ module Idml
50
51
  page_index = render_spread(writer, spread, layer_filter,
51
52
  font_ref_resolver, font_resource,
52
53
  font_metrics, font_map, structure,
53
- position_tracker, page_index)
54
+ position_tracker, condition_filter,
55
+ page_index)
54
56
  end
55
57
 
56
58
  structure.flush(writer)
@@ -90,14 +92,14 @@ module Idml
90
92
 
91
93
  def render_spread(writer, spread, layer_filter, font_ref_resolver,
92
94
  font_resource, font_metrics, font_map, structure,
93
- position_tracker, page_offset)
95
+ position_tracker, condition_filter, page_offset)
94
96
  pages = spread.spread.flat_map(&:page)
95
97
  image_refs = ImageCollector.new(writer: writer,
96
98
  base_dir: File.dirname(@package.path),
97
99
  page_height: DEFAULT_HEIGHT).collect(spread)
98
100
  renderer = build_renderer(layer_filter, font_ref_resolver,
99
101
  font_resource, font_metrics, font_map,
100
- structure, position_tracker)
102
+ structure, position_tracker, condition_filter)
101
103
  current = page_offset
102
104
 
103
105
  pages.each do |page|
@@ -126,7 +128,8 @@ module Idml
126
128
  end
127
129
 
128
130
  def build_renderer(layer_filter, font_ref_resolver, font_resource,
129
- font_metrics, font_map, structure, position_tracker)
131
+ font_metrics, font_map, structure, position_tracker,
132
+ condition_filter)
130
133
  SpreadRenderer.new(
131
134
  font_metrics: font_metrics,
132
135
  font_ps_name: font_resource,
@@ -136,6 +139,7 @@ module Idml
136
139
  font_ref_resolver: font_ref_resolver,
137
140
  structure: structure,
138
141
  position_tracker: position_tracker,
142
+ condition_filter: condition_filter,
139
143
  )
140
144
  end
141
145
 
@@ -20,6 +20,8 @@ module Idml
20
20
  :structure,
21
21
  :page_index,
22
22
  :position_tracker,
23
+ :chain_controller,
24
+ :condition_filter,
23
25
  keyword_init: true,
24
26
  )
25
27
  end
@@ -30,7 +30,6 @@ module Idml
30
30
  def self.render(canvas, context)
31
31
  frame = context.item
32
32
  return unless frame.parent_story
33
- return unless chain_head?(frame)
34
33
 
35
34
  story = context.package&.story_by_id(frame.parent_story)
36
35
  return unless story
@@ -38,12 +37,53 @@ module Idml
38
37
  box = frame_box(frame, context.page_height)
39
38
  render_inline_tables(canvas, story, box, context)
40
39
 
41
- paragraphs = StyleResolver.extract_paragraphs(story)
42
- return if paragraphs.empty?
40
+ state = initial_chain_state(frame, story, context)
41
+ return unless state
43
42
 
44
- render_text(canvas, paragraphs, context, box)
43
+ render_text(canvas, state, context, box)
44
+ store_chain_state(frame, state, context)
45
45
  end
46
46
 
47
+ # Returns the StoryChainController::State to render this frame
48
+ # with. For chain heads (no predecessor), this is a fresh
49
+ # state built from extract_paragraphs. For chain non-heads,
50
+ # this is the leftover state from the previous frame in the
51
+ # chain (nil when there's nothing left to render).
52
+ def self.initial_chain_state(frame, story, context)
53
+ controller = context.chain_controller
54
+ return fresh_state(story, context) if chain_head?(frame)
55
+ return nil unless controller
56
+
57
+ controller.state_for(frame.parent_story)
58
+ end
59
+ private_class_method :initial_chain_state
60
+
61
+ # Records the post-render state for the chain so the next
62
+ # frame in the chain picks up where this one left off. Only
63
+ # meaningful when a chain_controller is wired.
64
+ def self.store_chain_state(frame, state, context)
65
+ controller = context.chain_controller
66
+ return unless controller
67
+
68
+ controller.store_state(frame.parent_story, state)
69
+ end
70
+ private_class_method :store_chain_state
71
+
72
+ def self.fresh_state(story, context)
73
+ paragraphs = StyleResolver.extract_paragraphs(
74
+ story, condition_filter: context.condition_filter
75
+ )
76
+ return nil if paragraphs.empty?
77
+
78
+ StoryChainController::State.new(
79
+ paragraphs: paragraphs,
80
+ current_paragraph: nil,
81
+ runs_remaining: [],
82
+ char_cursor: 0,
83
+ )
84
+ end
85
+ private_class_method :fresh_state
86
+
47
87
  # Discovers Tables inlined in the story (Story > PSR > CSR >
48
88
  # Table — the real IDML structure) and renders each within
49
89
  # the frame's bounds. Real IDML Tables have no own geometry,
@@ -70,12 +110,12 @@ module Idml
70
110
  end
71
111
  private_class_method :tables_in_story
72
112
 
73
- def self.render_text(canvas, paragraphs, context, box)
113
+ def self.render_text(canvas, state, context, box)
74
114
  font = context.font_metrics
75
115
  if font
76
- engine_render(canvas, paragraphs, context, box, font)
116
+ engine_render(canvas, state, context, box, font)
77
117
  else
78
- simple_render(canvas, paragraphs, context, box)
118
+ simple_render(canvas, state, context, box)
79
119
  end
80
120
  end
81
121
  private_class_method :render_text
@@ -109,56 +149,150 @@ module Idml
109
149
  end
110
150
  private_class_method :layout_frame
111
151
 
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)
152
+ # Walks the chain state's paragraphs/runs and emits one
153
+ # `canvas.text` per line via the Shaper → LineBreaker →
154
+ # Justifier → VerticalLayout pipeline. Cursor y descends
155
+ # monotonically across paragraphs. When a paragraph partially
156
+ # fits, its remaining runs go back into the state for the
157
+ # next frame in the chain.
158
+ def self.engine_render(canvas, state, context, box, font)
118
159
  layout_frame = layout_frame(context.item, box)
119
160
  cursor_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
120
161
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
121
- char_cursor = 0
122
-
123
- paragraphs.each do |paragraph|
124
- break if cursor_y < bottom_limit
162
+ char_cursor = state.char_cursor
125
163
 
126
- cursor_y, char_cursor = render_paragraph(
127
- canvas, paragraph, context, layout_frame, font,
164
+ if state.current_paragraph
165
+ cursor_y, char_cursor = resume_paragraph(
166
+ canvas, state, context, layout_frame, font,
128
167
  cursor_y, bottom_limit, char_cursor
129
168
  )
169
+ return if state.current_paragraph
130
170
  end
171
+
172
+ consume_paragraphs(canvas, state, context, layout_frame, font,
173
+ cursor_y, bottom_limit, char_cursor)
131
174
  end
132
175
  private_class_method :engine_render
133
176
 
134
- def self.render_paragraph(canvas, paragraph, context, layout_frame,
135
- font, cursor_y, bottom_limit, char_cursor)
177
+ def self.resume_paragraph(canvas, state, context, layout_frame, font,
178
+ cursor_y, bottom_limit, char_cursor)
179
+ paragraph = state.current_paragraph
180
+ remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
181
+ canvas, paragraph, state.runs_remaining, context,
182
+ layout_frame, font, cursor_y, bottom_limit, char_cursor,
183
+ first_paragraph: true
184
+ )
185
+ if remaining_runs.any?
186
+ state.runs_remaining = remaining_runs
187
+ else
188
+ state.current_paragraph = nil
189
+ state.runs_remaining = []
190
+ end
191
+ [cursor_y, char_cursor]
192
+ end
193
+ private_class_method :resume_paragraph
194
+
195
+ def self.consume_paragraphs(canvas, state, context, layout_frame, font,
196
+ cursor_y, bottom_limit, char_cursor)
197
+ pending = 0
198
+ state.paragraphs.each do |paragraph|
199
+ break if cursor_y < bottom_limit
200
+
201
+ remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
202
+ canvas, paragraph, paragraph.runs, context,
203
+ layout_frame, font, cursor_y, bottom_limit, char_cursor,
204
+ first_paragraph: pending.zero?
205
+ )
206
+ if remaining_runs.any?
207
+ state.current_paragraph = paragraph
208
+ state.runs_remaining = remaining_runs
209
+ break
210
+ end
211
+ pending += 1
212
+ end
213
+
214
+ state.paragraphs = state.paragraphs[pending..]
215
+ state.char_cursor = char_cursor
216
+ end
217
+ private_class_method :consume_paragraphs
218
+
219
+ # Renders a paragraph's runs (a subset when resuming mid-para).
220
+ # Returns [remaining_runs, cursor_y, char_cursor] where
221
+ # remaining_runs is the runs that didn't fit (empty when all
222
+ # done). Emits RuleAbove before the first run and RuleBelow
223
+ # after the last successfully-rendered run.
224
+ def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
225
+ layout_frame, font, cursor_y,
226
+ bottom_limit, char_cursor,
227
+ first_paragraph:)
136
228
  wrap_width = TextEngine::VerticalLayout.wrap_width(
137
229
  layout_frame, paragraph.right_indent || 0
138
230
  )
139
231
  para_attrs = paragraph_attrs(paragraph)
140
232
  frame_left, frame_right = paragraph_frame_extents(layout_frame)
141
233
 
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.
234
+ emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
235
+ frame_left, frame_right, first_paragraph)
236
+
237
+ rendered_count = 0
238
+ runs.each do |run|
239
+ break if cursor_y < bottom_limit
240
+
241
+ space_before = paragraph_space_before(para_attrs, first_paragraph,
242
+ rendered_count)
243
+ positioned, next_y = layout_run(
244
+ canvas, run, paragraph, context, layout_frame, font,
245
+ wrap_width, cursor_y, space_before,
246
+ para_attrs[:first_line_indent], para_attrs[:left_indent],
247
+ char_cursor
248
+ )
249
+ char_cursor += positioned.length
250
+ cursor_y = next_y
251
+ rendered_count += 1
252
+ end
253
+
254
+ remaining_runs = runs[rendered_count..]
255
+ cursor_y = emit_paragraph_bottom_rule(canvas, paragraph, context,
256
+ cursor_y, frame_left,
257
+ frame_right, para_attrs,
258
+ remaining_runs)
259
+ [remaining_runs, cursor_y, char_cursor]
260
+ end
261
+ private_class_method :render_runs_for_paragraph
262
+
263
+ def self.emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
264
+ frame_left, frame_right,
265
+ first_paragraph)
266
+ return unless first_paragraph
267
+
145
268
  ParagraphRules.emit_rule_above(canvas, paragraph, context,
146
269
  cursor_y, frame_left, frame_right)
270
+ end
271
+ private_class_method :emit_paragraph_top_rule
147
272
 
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]
273
+ def self.paragraph_space_before(para_attrs, first_paragraph,
274
+ rendered_count)
275
+ return 0 unless first_paragraph && rendered_count.zero?
276
+
277
+ para_attrs[:space_before]
278
+ end
279
+ private_class_method :paragraph_space_before
153
280
 
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.
281
+ # Emits RuleBelow after a paragraph's last run. Returns the
282
+ # updated cursor_y (subtracting space_after when the paragraph
283
+ # completed).
284
+ def self.emit_paragraph_bottom_rule(canvas, paragraph, context,
285
+ cursor_y, frame_left,
286
+ frame_right, para_attrs,
287
+ remaining_runs)
288
+ return cursor_y unless remaining_runs.empty?
289
+
290
+ after_y = cursor_y - para_attrs[:space_after]
157
291
  ParagraphRules.emit_rule_below(canvas, paragraph, context,
158
292
  after_y, frame_left, frame_right)
159
- [after_y, char_cursor]
293
+ after_y
160
294
  end
161
- private_class_method :render_paragraph
295
+ private_class_method :emit_paragraph_bottom_rule
162
296
 
163
297
  def self.paragraph_frame_extents(layout_frame)
164
298
  inset_left = layout_frame.inset_left || 0
@@ -178,27 +312,6 @@ module Idml
178
312
  end
179
313
  private_class_method :paragraph_attrs
180
314
 
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
315
  # Shapes one run's text, wraps to the inset/indent-adjusted
203
316
  # width, justifies per paragraph alignment, and asks
204
317
  # VerticalLayout to position the lines. Emits each line via
@@ -321,8 +434,11 @@ module Idml
321
434
 
322
435
  # Fallback when no metrics are available: emit all runs as
323
436
  # one `text_rich` block, letting pdfrb measure advance widths.
324
- def self.simple_render(canvas, paragraphs, context, box)
325
- runs = paragraphs.flat_map(&:runs)
437
+ # Chain threading is best-effort here — the simple path
438
+ # doesn't wrap, so the first frame's render typically emits
439
+ # all content and clears the chain state.
440
+ def self.simple_render(canvas, state, context, box)
441
+ runs = collect_runs(state)
326
442
  return if runs.empty?
327
443
 
328
444
  runs_for = build_rich_runs(runs, context)
@@ -331,9 +447,22 @@ module Idml
331
447
  runs_for,
332
448
  at: [box[:x], box[:y] + box[:height] - first_size],
333
449
  )
450
+ state.paragraphs = []
451
+ state.current_paragraph = nil
452
+ state.runs_remaining = []
334
453
  end
335
454
  private_class_method :simple_render
336
455
 
456
+ def self.collect_runs(state)
457
+ runs = state.runs_remaining || []
458
+ if state.current_paragraph
459
+ runs + state.current_paragraph.runs
460
+ else
461
+ runs + state.paragraphs.flat_map(&:runs)
462
+ end
463
+ end
464
+ private_class_method :collect_runs
465
+
337
466
  def self.build_rich_runs(runs, context)
338
467
  runs.map do |run|
339
468
  {
@@ -7,7 +7,8 @@ module Idml
7
7
  def initialize(font_metrics: nil, font_ps_name: Render::DEFAULT_FONT,
8
8
  package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
9
9
  font_ref_resolver: nil, structure: nil,
10
- position_tracker: nil, font_map: {})
10
+ position_tracker: nil, font_map: {},
11
+ condition_filter: nil)
11
12
  @font_metrics = font_metrics
12
13
  @font_ps_name = font_ps_name
13
14
  @package = package
@@ -16,6 +17,7 @@ module Idml
16
17
  @structure = structure
17
18
  @position_tracker = position_tracker
18
19
  @font_map = font_map
20
+ @condition_filter = condition_filter
19
21
  end
20
22
 
21
23
  def render(canvas, spread, page_width:, page_height:, image_refs: [],
@@ -33,6 +35,8 @@ module Idml
33
35
  structure: @structure,
34
36
  page_index: page_index,
35
37
  position_tracker: @position_tracker,
38
+ chain_controller: StoryChainController.new,
39
+ condition_filter: @condition_filter,
36
40
  }
37
41
 
38
42
  canvas.save_graphics_state do
@@ -0,0 +1,66 @@
1
+ # frozen_string: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Threads story-rendering state across linked TextFrames in a
6
+ # chain. A story overflows from frame 1 → frame 2 → frame 3 etc.
7
+ # via PreviousTextFrame/NextTextFrame links; without chain
8
+ # awareness, the renderer would silently truncate overflow text.
9
+ #
10
+ # State is per story_id (the ParentStory attribute). State is
11
+ # populated when a chain head renders and consumed when chain
12
+ # non-head frames render.
13
+ #
14
+ # Threading granularity: paragraphs and runs. If a run partially
15
+ # fits in a frame, the next frame picks up where the previous
16
+ # left off (the partially-rendered lines stay in the previous
17
+ # frame; remaining lines come from re-wrapping the run).
18
+ class StoryChainController
19
+ # `paragraphs` : Array<Paragraph> not yet started
20
+ # `current_paragraph` : Paragraph in progress (nil if none)
21
+ # `runs_remaining` : Array<StyledRun> in current_paragraph not yet rendered
22
+ # `char_cursor` : global character cursor (for hyperlink tracking)
23
+ State = Struct.new(
24
+ :paragraphs,
25
+ :current_paragraph,
26
+ :runs_remaining,
27
+ :char_cursor,
28
+ keyword_init: true,
29
+ )
30
+
31
+ def initialize
32
+ @states = {}
33
+ end
34
+
35
+ # Returns the chain state for a story, or nil if the story has
36
+ # no leftover content. Chain heads (no predecessor) get no
37
+ # state — they render from scratch.
38
+ def state_for(story_id)
39
+ @states[story_id]
40
+ end
41
+
42
+ # Records the chain state for a story after a frame's render.
43
+ # Removes the state entirely when no content remains.
44
+ def store_state(story_id, state)
45
+ if state.nil? || empty_state?(state)
46
+ @states.delete(story_id)
47
+ else
48
+ @states[story_id] = state
49
+ end
50
+ end
51
+
52
+ # True when a story has pending content from a previous frame.
53
+ def has_pending?(story_id)
54
+ @states.key?(story_id)
55
+ end
56
+
57
+ private
58
+
59
+ def empty_state?(state)
60
+ state.paragraphs.empty? &&
61
+ state.current_paragraph.nil? &&
62
+ state.runs_remaining&.empty?
63
+ end
64
+ end
65
+ end
66
+ end
@@ -104,11 +104,13 @@ module Idml
104
104
  # its runs and carries the paragraph-level attributes from the
105
105
  # owning PSR. Empty paragraphs (no runs after filtering) are
106
106
  # skipped.
107
- def self.extract_paragraphs(story)
107
+ def self.extract_paragraphs(story, condition_filter: nil)
108
108
  return [] unless story&.inner
109
109
 
110
110
  story.inner.paragraph_style_range.filter_map do |psr|
111
- runs = csr_runs(psr)
111
+ next unless condition_visible?(psr, condition_filter)
112
+
113
+ runs = csr_runs(psr, condition_filter: condition_filter)
112
114
  next if runs.empty?
113
115
 
114
116
  Paragraph.new(
@@ -140,8 +142,10 @@ module Idml
140
142
  end
141
143
  end
142
144
 
143
- def self.csr_runs(psr)
145
+ def self.csr_runs(psr, condition_filter: nil)
144
146
  psr.character_style_range.filter_map do |csr|
147
+ next unless condition_visible?(csr, condition_filter)
148
+
145
149
  text = csr.text_content
146
150
  next if text.nil? || text.empty?
147
151
 
@@ -170,6 +174,16 @@ module Idml
170
174
  end
171
175
  private_class_method :csr_runs
172
176
 
177
+ # Drops the PSR/CSR when any of its AppliedConditions
178
+ # references a hidden Condition. Returns true when no filter
179
+ # is supplied (caller doesn't care about conditions).
180
+ def self.condition_visible?(psr_or_csr, condition_filter)
181
+ return true unless condition_filter
182
+
183
+ condition_filter.visible?(psr_or_csr.applied_conditions)
184
+ end
185
+ private_class_method :condition_visible?
186
+
173
187
  def self.alignment_for(csr)
174
188
  ALIGNMENT_MAP[csr.justification] || :left
175
189
  end
data/lib/idml/render.rb CHANGED
@@ -15,7 +15,9 @@ module Idml
15
15
  autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
16
16
  autoload :CharacterStyle, "#{__dir__}/render/character_style"
17
17
  autoload :ParagraphRules, "#{__dir__}/render/paragraph_rules"
18
+ autoload :StoryChainController, "#{__dir__}/render/story_chain_controller"
18
19
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
20
+ autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
19
21
  autoload :Placement, "#{__dir__}/render/placement"
20
22
  autoload :ImageCollector, "#{__dir__}/render/image_collector"
21
23
  autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.7.1"
4
+ VERSION = "0.7.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -276,6 +276,7 @@ files:
276
276
  - lib/idml/elements/character_style_group.rb
277
277
  - lib/idml/elements/character_style_range.rb
278
278
  - lib/idml/elements/color.rb
279
+ - lib/idml/elements/condition.rb
279
280
  - lib/idml/elements/content.rb
280
281
  - lib/idml/elements/content_transparency_setting.rb
281
282
  - lib/idml/elements/contour_option.rb
@@ -428,6 +429,7 @@ files:
428
429
  - lib/idml/render/character_style.rb
429
430
  - lib/idml/render/color_helper.rb
430
431
  - lib/idml/render/color_resolver.rb
432
+ - lib/idml/render/condition_filter.rb
431
433
  - lib/idml/render/font_reference_resolver.rb
432
434
  - lib/idml/render/font_setup.rb
433
435
  - lib/idml/render/gradient_resolver.rb
@@ -454,6 +456,7 @@ files:
454
456
  - lib/idml/render/renderers/table_renderer.rb
455
457
  - lib/idml/render/renderers/text_frame_renderer.rb
456
458
  - lib/idml/render/spread_renderer.rb
459
+ - lib/idml/render/story_chain_controller.rb
457
460
  - lib/idml/render/story_threader.rb
458
461
  - lib/idml/render/stroke_style.rb
459
462
  - lib/idml/render/structure_mapper.rb