idml 0.7.0 → 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: 0f40610210789018b1fbe0625c1541cfe3611169d0004543891ebb9efd6c74ea
4
- data.tar.gz: 6998d5c66bd56eed147dfb88d60a602be32fee2adb9b9fa9f08413af4cb2103f
3
+ metadata.gz: c6cc7058e67830e2dc6ea523ea6ca054f702ce29b51989a19deb023e5a840bee
4
+ data.tar.gz: 434b5af5ea49b9f2b48728b100b612ff0cd784079abfd6d419dbeb16554b3ebb
5
5
  SHA512:
6
- metadata.gz: 02c03bf2c81b2ebb81b04ae4b413b25d484adf806b1d8c7c0768e557323bca7c30de46d5174cff27c90e2275f6f89070ac5f981df567cb20867e5c17382b0c42
7
- data.tar.gz: 642103e8e48d2b5b52a4b669d7e09329f5706e4f165f4da8de6df51ef38125686ca75db28de194a974f94cdc9273a193c76a653bf8727b3a16f6b5ac2a9c5f3c
6
+ metadata.gz: a59cabfafa44138cb40ed8b6e493396c110ffe43728c0d0ea94d8ef4d8039efad635ba23660852b1040b58ce7141b6e301bf27ecb96599efd6d72069e6d81a95
7
+ data.tar.gz: d142ebcfc9eb6591827c72d3cd33303ee3a4c70637031b03622ea1355d453cac4aac1654f60ee7a3ee54a050755e88fb84c32c01df11df802c434442c0f9a4e2
@@ -1,6 +1,15 @@
1
1
  # TODO PDF 101: Paragraph borders and rules (RuleAbove, RuleBelow, ParagraphBorder)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: PARTIALRuleAbove and RuleBelow implemented via new
4
+ `Render::ParagraphRules` helper; TextFrameRenderer emits them at
5
+ paragraph top/bottom. Rule color comes from PSR#stroke_color (IDML's
6
+ PSR doesn't have a dedicated RuleAboveColor attribute). RuleAboveWidth
7
+ "Text" vs "Column" honored. Tint scaling + line weight + offset honored.
8
+
9
+ ParagraphBorder and ParagraphShading remain open — they need the
10
+ paragraph's bounding rect, which the renderer doesn't currently
11
+ expose to ParagraphRules. Will add when a real document fixture
12
+ exercises them.
4
13
 
5
14
  ## Problem
6
15
 
@@ -0,0 +1,54 @@
1
+ # TODO PDF 108: Multi-frame story flow (use StoryThreader)
2
+
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.
8
+
9
+ ## Problem
10
+
11
+ `Render::StoryThreader` exists with specs (`spec/idml/render_layer_threader_spec.rb`).
12
+ It builds text-frame chains from PreviousTextFrame/NextTextFrame
13
+ links. The intent: a story flows across linked frames, with each
14
+ frame getting the lines that fit and overflow going to the next.
15
+
16
+ But `TextFrameRenderer` doesn't use it. The renderer treats each
17
+ frame independently:
18
+ - `chain_head?` filter skips non-head frames (correct — they'd
19
+ double-render)
20
+ - Each chain-head frame renders its entire story from the top
21
+ - Overflow that doesn't fit is silently dropped
22
+
23
+ For real documents with multi-page stories (books, magazines,
24
+ newspapers), this loses text. The PDF shows only what fits in the
25
+ first frame.
26
+
27
+ ## What needs to happen
28
+
29
+ 1. TextFrameRenderer accepts the chain (list of frames) instead
30
+ of one frame at a time.
31
+ 2. For each chain:
32
+ - Lay out story text starting at frame 1's top
33
+ - When text overflows frame 1's bottom, continue at frame 2's top
34
+ - Repeat until either text ends or chain ends
35
+ 3. The PageItemRenderer dispatch needs to know about chains, not
36
+ just per-item rendering.
37
+
38
+ Architecture: the chain context needs to live in RenderContext
39
+ (or a new RenderChainContext). Each frame's render call passes the
40
+ current y cursor + remaining paragraphs/runs/lines, and the next
41
+ frame's render call picks up where the previous left off.
42
+
43
+ ## Acceptance criteria
44
+
45
+ - [ ] Story that overflows frame 1 continues in frame 2.
46
+ - [ ] Hyperlink rects resolve correctly across frames.
47
+ - [ ] Spec: 2 linked TextFrames, story of 50 lines, each frame fits
48
+ 20 lines → frame 1 shows lines 1-20, frame 2 shows 21-40,
49
+ frame 3 (if present) shows 41-50.
50
+
51
+ ## Dependencies
52
+
53
+ - None — independent feature work, but changes the renderer's
54
+ per-frame abstraction significantly.
@@ -0,0 +1,42 @@
1
+ # TODO PDF 109: DRY color tint application (ColorHelper vs CharacterStyle vs ParagraphRules)
2
+
3
+ ## Status: DONE — `ColorHelper.apply_tint(color, tint)` is the single
4
+ canonical tint helper. CharacterStyle and ParagraphRules both delegate
5
+ to it. 7 new specs cover nil tint, tint >= 1.0, RGB/CMYK scaling,
6
+ tint 0.0, unknown models, nil input.
7
+
8
+ ## Problem
9
+
10
+ Color tint (scaling a color's components toward zero) is implemented
11
+ in three places with slightly different shapes:
12
+
13
+ 1. `Render::CharacterStyle.apply_tint(color, tint)` — used for
14
+ text fill color.
15
+ 2. `Render::ParagraphRules.scale_color(color, tint_value)` — used
16
+ for paragraph rule colors.
17
+ 3. (Implicit) `Render::ColorResolver` resolves tints on its own
18
+ when reading ColorValue, before returning the color hash.
19
+
20
+ The first two are essentially the same logic with different method
21
+ names. The third means a `ColorResolver.resolve` result might or
22
+ might not have tint already applied — callers can't tell.
23
+
24
+ ## What needs to happen
25
+
26
+ 1. Pick one canonical tint helper. Either:
27
+ - Move to `ColorHelper.apply_tint(color, tint)`, or
28
+ - Create `Render::ColorTint.apply(color, tint)`.
29
+ 2. CharacterStyle and ParagraphRules call the canonical helper.
30
+ 3. ColorResolver documents whether it applies tint or returns raw
31
+ colors (today it appears to apply tint internally).
32
+
33
+ ## Acceptance criteria
34
+
35
+ - [ ] Single tint helper in one place.
36
+ - [ ] CharacterStyle and ParagraphRules both delegate to it.
37
+ - [ ] ColorResolver's tint behavior is documented.
38
+ - [ ] Spec coverage for tint 0.0, 0.5, 1.0 on RGB and CMYK colors.
39
+
40
+ ## Dependencies
41
+
42
+ - None — pure refactor.
@@ -0,0 +1,43 @@
1
+ # TODO PDF 110: Conditional text (AppliedConditions)
2
+
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.
10
+
11
+ ## Problem
12
+
13
+ PSR and CSR both carry an `AppliedConditions` attribute (a list of
14
+ condition Self IDs). In InDesign, conditions let authors mark text
15
+ as conditional (e.g., "show only in instructor edition", "draft
16
+ review comments"). Each `<Condition>` in Resources/Styles.xml
17
+ declares whether it's visible, its highlight color, etc.
18
+
19
+ Today the renderer ignores `AppliedConditions` — all conditional
20
+ text renders regardless of condition visibility. For documents that
21
+ use conditions heavily (textbooks, legal, regulatory), this means
22
+ the PDF always shows every variant instead of the chosen subset.
23
+
24
+ ## What needs to happen
25
+
26
+ 1. Verify `Elements::Condition` exists in the model (Resources/Styles.rnc).
27
+ 2. Add `Render::ConditionFilter` that reads visible conditions from
28
+ Resources/Styles.xml.
29
+ 3. StyleResolver filters PSRs/CSRs whose AppliedConditions reference
30
+ a hidden condition.
31
+ 4. Spec: PSR with condition A (hidden) doesn't render; condition B
32
+ (visible) renders.
33
+
34
+ ## Acceptance criteria
35
+
36
+ - [ ] Condition model from RNC.
37
+ - [ ] ConditionFilter.from_styles(graphic_or_styles).
38
+ - [ ] StyleResolver.extract_paragraphs takes optional ConditionFilter.
39
+ - [ ] Hidden-condition runs filtered out.
40
+
41
+ ## Dependencies
42
+
43
+ - None — pure feature work on existing models.
@@ -0,0 +1,40 @@
1
+ # TODO PDF 111: Footnote support
2
+
3
+ ## Status: OPEN — gap identified in 2026-08-09 audit
4
+
5
+ ## Problem
6
+
7
+ IDML has full footnote support:
8
+ - `<Footnote>` elements in Stories (inline footnote markers + text)
9
+ - `<FootnoteOption>` on PSR (numbering rules, layout)
10
+ - `TextFramePreference.FootnotesEnableOverrides`,
11
+ `FootnotesSpanAcrossColumns`, `FootnotesMinimumSpacing`,
12
+ `FootnotesSpaceBetween` — frame-level footnote layout
13
+ - `<TextFrameFootnoteOptionsObject>` — per-frame footnote config
14
+
15
+ The renderer doesn't model footnote content or lay it out.
16
+ Documents with footnotes (academic, legal, technical) currently
17
+ lose footnote text in the PDF.
18
+
19
+ ## What needs to happen
20
+
21
+ 1. Model `<Footnote>` element (story-embedded footnote text +
22
+ reference marker).
23
+ 2. Story parsing extracts footnotes alongside main text.
24
+ 3. Footnote text renders at the bottom of the page that contains
25
+ the reference marker.
26
+ 4. Footnote numbering rules from FootnoteOption honored.
27
+ 5. Continuous footnotes flow across pages.
28
+
29
+ This is a large feature — defer until needed by a real fixture.
30
+
31
+ ## Acceptance criteria
32
+
33
+ - [ ] Footnote text rendered at bottom of containing page.
34
+ - [ ] Footnote marker in body text links to footnote text.
35
+ - [ ] Numbering restarts per document section per FootnoteOption.
36
+
37
+ ## Dependencies
38
+
39
+ - TODO 108 (multi-frame story flow) — footnotes need to know which
40
+ page a reference appears on.
@@ -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
@@ -20,7 +20,10 @@ module Idml
20
20
  # Yields with the run's fill color applied, then draws underline
21
21
  # and/or strike-through rules above/below the text baseline.
22
22
  # `x`, `y`, `width`, `size` describe the line's geometry so the
23
- # rules can be positioned correctly.
23
+ # rules can be positioned correctly. The block's emitted text
24
+ # gets the run's character-level adjustments baked in via
25
+ # `text_kwargs` (caller passes the canvas.text kwargs in, and
26
+ # CharacterStyle adds tracking/scale on top).
24
27
  def self.apply(canvas, run, context, x:, y:, width:, size:)
25
28
  apply_fill_color(canvas, run, context)
26
29
  yield
@@ -28,6 +31,42 @@ module Idml
28
31
  draw_strike_through(canvas, run, x, y, width, size)
29
32
  end
30
33
 
34
+ # Returns the canvas.text kwargs augmented with the run's
35
+ # Tracking (PDF char_spacing). IDML Tracking is in points;
36
+ # pdfrb's char_spacing is also in text-space units, so the
37
+ # mapping is 1:1.
38
+ def self.text_kwargs(run, base_kwargs)
39
+ return base_kwargs unless run.tracking
40
+
41
+ base_kwargs.merge(char_spacing: run.tracking.to_f)
42
+ end
43
+
44
+ # Yields within a transformed coordinate space when the run
45
+ # declares glyph scaling. IDML HorizontalScale/VerticalScale
46
+ # are percentages (100 = no scaling). When both are 100 / nil,
47
+ # yields without transformation (no graphics-state push).
48
+ def self.with_glyph_scaling(canvas, run, &)
49
+ sx = scale_factor(run.horizontal_scale)
50
+ sy = scale_factor(run.vertical_scale)
51
+ return yield if scaling_identity?(sx, sy)
52
+
53
+ canvas.scale(sx, sy, &)
54
+ end
55
+
56
+ def self.scaling_identity?(sx, sy)
57
+ (sx - 1.0).abs < FLOAT_EPSILON && (sy - 1.0).abs < FLOAT_EPSILON
58
+ end
59
+
60
+ FLOAT_EPSILON = 1e-9
61
+ private_constant :FLOAT_EPSILON
62
+
63
+ # Returns the run's baseline shift (in PDF units). Positive
64
+ # values shift up; negative shift down. Used to offset the
65
+ # text's `at: y` for CSR's BaselineShift.
66
+ def self.baseline_offset(run)
67
+ run.baseline_shift&.nonzero? ? run.baseline_shift.to_f : 0.0
68
+ end
69
+
31
70
  # Transforms the text per the CSR's Capitalization attribute.
32
71
  # AllCaps/SmallCaps → uppercase (true small-caps would require
33
72
  # an OpenType feature; deferred). Other values pass through.
@@ -55,10 +94,19 @@ module Idml
55
94
  color = context.color_resolver&.resolve(run.fill_color)
56
95
  return unless color
57
96
 
58
- canvas.fill_color(ColorHelper.to_canvas(color))
97
+ tinted = ColorHelper.apply_tint(color, run.fill_tint)
98
+ canvas.fill_color(ColorHelper.to_canvas(tinted))
59
99
  end
60
100
  private_class_method :apply_fill_color
61
101
 
102
+ def self.scale_factor(declared)
103
+ return 1.0 unless declared
104
+ return 1.0 if declared <= 0
105
+
106
+ declared.to_f / 100.0
107
+ end
108
+ private_class_method :scale_factor
109
+
62
110
  def self.draw_underline(canvas, run, x, y, width, size)
63
111
  return unless run.underline
64
112
 
@@ -1,9 +1,10 @@
1
- # frozen_string_literal: true
1
+ # frozen_string: true
2
2
 
3
3
  module Idml
4
4
  module Render
5
- # Converts IDML color hashes to pdfrb Canvas color arrays.
6
- # Centralizes the mapping so all renderers use a consistent format.
5
+ # Converts IDML color hashes to pdfrb Canvas color arrays and
6
+ # applies tint scaling. Centralizes the mapping so all renderers
7
+ # use a consistent format.
7
8
  module ColorHelper
8
9
  module_function
9
10
 
@@ -18,6 +19,29 @@ module Idml
18
19
  when :cmyk then [:cmyk, color[:c], color[:m], color[:y], color[:k]]
19
20
  end
20
21
  end
22
+
23
+ # Scales a color's components by `tint` (1.0 = full strength,
24
+ # 0.5 = halved). Tint is the IDML mechanism for lightening a
25
+ # color without defining a new one. Returns the original color
26
+ # unchanged when tint is nil or >= 1.0. Single canonical
27
+ # implementation — CharacterStyle, ParagraphRules, and any
28
+ # future caller should all delegate here.
29
+ def apply_tint(color, tint)
30
+ return color unless color
31
+ return color unless tint
32
+ return color if tint >= 1.0
33
+
34
+ case color[:model]
35
+ when :rgb
36
+ { model: :rgb, r: color[:r] * tint, g: color[:g] * tint,
37
+ b: color[:b] * tint }
38
+ when :cmyk
39
+ { model: :cmyk, c: color[:c] * tint, m: color[:m] * tint,
40
+ y: color[:y] * tint, k: color[:k] * tint }
41
+ else
42
+ color
43
+ end
44
+ end
21
45
  end
22
46
  end
23
47
  end
@@ -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
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Emits paragraph-level rules (RuleAbove / RuleBelow) on a pdfrb
6
+ # canvas. Centralizes the IDML → PDF mapping for the paragraph
7
+ # rule attributes carried by `StyleResolver::Paragraph`.
8
+ #
9
+ # Each rule is a horizontal stroke above (RuleAbove) or below
10
+ # (RuleBelow) the paragraph block. The rule's weight, color,
11
+ # tint, offset, and indent come from the PSR's RuleAbove* /
12
+ # RuleBelow* attributes; defaults approximate InDesign behavior
13
+ # when an attribute is missing.
14
+ #
15
+ # Used by `TextFrameRenderer` so paragraph rules live in one
16
+ # place (DRY).
17
+ module ParagraphRules
18
+ DEFAULT_LINE_WEIGHT = 0.5
19
+ DEFAULT_TINT = 1.0
20
+ DEFAULT_OFFSET = 0.0
21
+
22
+ # Emits RuleAbove if `paragraph.rule_above` is true. The rule
23
+ # sits above the paragraph's first line. `top_y` is the y of
24
+ # the paragraph's top edge (before SpaceBefore is applied);
25
+ # `frame_left` / `frame_right` are the column's text bounds.
26
+ def self.emit_rule_above(canvas, paragraph, context, top_y,
27
+ frame_left, frame_right)
28
+ return unless paragraph.rule_above
29
+
30
+ emit_rule(canvas, paragraph, context, top_y, frame_left,
31
+ frame_right,
32
+ weight: paragraph.rule_above_line_weight,
33
+ color: paragraph.rule_above_color,
34
+ tint: paragraph.rule_above_tint,
35
+ offset: paragraph.rule_above_offset,
36
+ left_indent: paragraph.rule_above_left_indent,
37
+ right_indent: paragraph.rule_above_right_indent,
38
+ width_mode: paragraph.rule_above_width,
39
+ position: :above)
40
+ end
41
+
42
+ # Emits RuleBelow if `paragraph.rule_below` is true. The rule
43
+ # sits below the paragraph's last line. `bottom_y` is the y of
44
+ # the paragraph's bottom edge (after SpaceAfter is applied).
45
+ def self.emit_rule_below(canvas, paragraph, context, bottom_y,
46
+ frame_left, frame_right)
47
+ return unless paragraph.rule_below
48
+
49
+ emit_rule(canvas, paragraph, context, bottom_y, frame_left,
50
+ frame_right,
51
+ weight: paragraph.rule_below_line_weight,
52
+ color: paragraph.rule_below_color,
53
+ tint: paragraph.rule_below_tint,
54
+ offset: paragraph.rule_below_offset,
55
+ left_indent: paragraph.rule_below_left_indent,
56
+ right_indent: paragraph.rule_below_right_indent,
57
+ width_mode: paragraph.rule_below_width,
58
+ position: :below)
59
+ end
60
+
61
+ def self.emit_rule(canvas, paragraph, context, anchor_y,
62
+ frame_left, frame_right,
63
+ weight:, color:, tint:, offset:,
64
+ left_indent:, right_indent:, width_mode:,
65
+ position:)
66
+ thickness = (weight || DEFAULT_LINE_WEIGHT).to_f
67
+ return unless thickness.positive?
68
+
69
+ tint_value = (tint || DEFAULT_TINT).to_f
70
+ return unless tint_value.positive?
71
+
72
+ offset_value = (offset || DEFAULT_OFFSET).to_f
73
+ x1, x2 = rule_extents(frame_left, frame_right, left_indent,
74
+ right_indent, width_mode, paragraph)
75
+ y = rule_y(anchor_y, offset_value, position)
76
+
77
+ apply_rule_color(canvas, color, tint_value, context)
78
+ canvas.line_width = thickness
79
+ canvas.move_to(x1, y)
80
+ canvas.line_to(x2, y)
81
+ canvas.stroke
82
+ end
83
+ private_class_method :emit_rule
84
+
85
+ # Returns `[x1, x2]` for the rule. Width mode "Text" indents
86
+ # the rule by the paragraph's left/right indent (matches the
87
+ # text bounds); "Column" (default) spans the full column.
88
+ def self.rule_extents(frame_left, frame_right, rule_left_indent,
89
+ rule_right_indent, width_mode, paragraph)
90
+ left = frame_left
91
+ right = frame_right
92
+ if width_mode == "Text"
93
+ left += (paragraph.left_indent || 0).to_f
94
+ right -= (paragraph.right_indent || 0).to_f
95
+ end
96
+ left += (rule_left_indent || 0).to_f
97
+ right -= (rule_right_indent || 0).to_f
98
+ [left, right]
99
+ end
100
+ private_class_method :rule_extents
101
+
102
+ def self.rule_y(anchor_y, offset, position)
103
+ position == :above ? anchor_y + offset : anchor_y - offset
104
+ end
105
+ private_class_method :rule_y
106
+
107
+ def self.apply_rule_color(canvas, color_name, tint_value, context)
108
+ color = resolve_color(color_name, tint_value, context)
109
+ canvas.stroke_color(color)
110
+ end
111
+ private_class_method :apply_rule_color
112
+
113
+ def self.resolve_color(color_name, tint_value, context)
114
+ return [:gray, 1.0 * tint_value] unless color_name
115
+ return [:gray, 1.0 * tint_value] if color_name == "Color/None"
116
+
117
+ color = context&.color_resolver&.resolve(color_name)
118
+ return [:gray, 1.0 * tint_value] unless color
119
+
120
+ ColorHelper.apply_tint(color, tint_value)
121
+ end
122
+ private_class_method :resolve_color
123
+ end
124
+ end
125
+ 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,59 +149,97 @@ 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
- 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
232
+ frame_left, frame_right = paragraph_frame_extents(layout_frame)
147
233
 
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
234
+ emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
235
+ frame_left, frame_right, first_paragraph)
157
236
 
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|
237
+ rendered_count = 0
238
+ runs.each do |run|
163
239
  break if cursor_y < bottom_limit
164
240
 
241
+ space_before = paragraph_space_before(para_attrs, first_paragraph,
242
+ rendered_count)
165
243
  positioned, next_y = layout_run(
166
244
  canvas, run, paragraph, context, layout_frame, font,
167
245
  wrap_width, cursor_y, space_before,
@@ -170,11 +248,69 @@ module Idml
170
248
  )
171
249
  char_cursor += positioned.length
172
250
  cursor_y = next_y
173
- space_before = 0
251
+ rendered_count += 1
174
252
  end
175
- [cursor_y, char_cursor]
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
+
268
+ ParagraphRules.emit_rule_above(canvas, paragraph, context,
269
+ cursor_y, frame_left, frame_right)
270
+ end
271
+ private_class_method :emit_paragraph_top_rule
272
+
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
280
+
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]
291
+ ParagraphRules.emit_rule_below(canvas, paragraph, context,
292
+ after_y, frame_left, frame_right)
293
+ after_y
294
+ end
295
+ private_class_method :emit_paragraph_bottom_rule
296
+
297
+ def self.paragraph_frame_extents(layout_frame)
298
+ inset_left = layout_frame.inset_left || 0
299
+ inset_right = layout_frame.inset_right || 0
300
+ [layout_frame.x + inset_left,
301
+ layout_frame.x + layout_frame.width - inset_right]
302
+ end
303
+ private_class_method :paragraph_frame_extents
304
+
305
+ def self.paragraph_attrs(paragraph)
306
+ {
307
+ space_before: paragraph.space_before || 0,
308
+ space_after: paragraph.space_after || 0,
309
+ first_line_indent: paragraph.first_line_indent || 0,
310
+ left_indent: paragraph.left_indent || 0,
311
+ }
176
312
  end
177
- private_class_method :render_runs
313
+ private_class_method :paragraph_attrs
178
314
 
179
315
  # Shapes one run's text, wraps to the inset/indent-adjusted
180
316
  # width, justifies per paragraph alignment, and asks
@@ -223,23 +359,32 @@ module Idml
223
359
  private_class_method :layout_run
224
360
 
225
361
  # 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.
362
+ # (fill color + tint, capitalization, position, tracking,
363
+ # glyph scaling, baseline shift) via CharacterStyle, then
364
+ # draws underline/strike-through rules after the text.
228
365
  def self.emit_line(canvas, line, run, context, size)
229
366
  text = CharacterStyle.transform_text(line_text(line),
230
367
  run.capitalization)
231
- scaled_size, baseline_offset = CharacterStyle.position_scale(
368
+ scaled_size, position_offset = CharacterStyle.position_scale(
232
369
  run.position, size
233
370
  )
371
+ baseline_offset = CharacterStyle.baseline_offset(run) +
372
+ position_offset
373
+ text_at = [line.x, line.y + baseline_offset]
374
+ text_kwargs = CharacterStyle.text_kwargs(
375
+ run,
376
+ {
377
+ at: text_at,
378
+ font: font_for_run(run, context),
379
+ size: scaled_size,
380
+ },
381
+ )
234
382
  CharacterStyle.apply(canvas, run, context,
235
383
  x: line.x, y: line.y + baseline_offset,
236
384
  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
- )
385
+ CharacterStyle.with_glyph_scaling(canvas, run) do
386
+ canvas.text(text, **text_kwargs)
387
+ end
243
388
  end
244
389
  end
245
390
  private_class_method :emit_line
@@ -289,8 +434,11 @@ module Idml
289
434
 
290
435
  # Fallback when no metrics are available: emit all runs as
291
436
  # one `text_rich` block, letting pdfrb measure advance widths.
292
- def self.simple_render(canvas, paragraphs, context, box)
293
- 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)
294
442
  return if runs.empty?
295
443
 
296
444
  runs_for = build_rich_runs(runs, context)
@@ -299,9 +447,22 @@ module Idml
299
447
  runs_for,
300
448
  at: [box[:x], box[:y] + box[:height] - first_size],
301
449
  )
450
+ state.paragraphs = []
451
+ state.current_paragraph = nil
452
+ state.runs_remaining = []
302
453
  end
303
454
  private_class_method :simple_render
304
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
+
305
466
  def self.build_rich_runs(runs, context)
306
467
  runs.map do |run|
307
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
@@ -54,6 +54,24 @@ module Idml
54
54
  :left_indent,
55
55
  :right_indent,
56
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,
57
75
  keyword_init: true,
58
76
  )
59
77
 
@@ -86,11 +104,13 @@ module Idml
86
104
  # its runs and carries the paragraph-level attributes from the
87
105
  # owning PSR. Empty paragraphs (no runs after filtering) are
88
106
  # skipped.
89
- def self.extract_paragraphs(story)
107
+ def self.extract_paragraphs(story, condition_filter: nil)
90
108
  return [] unless story&.inner
91
109
 
92
110
  story.inner.paragraph_style_range.filter_map do |psr|
93
- runs = csr_runs(psr)
111
+ next unless condition_visible?(psr, condition_filter)
112
+
113
+ runs = csr_runs(psr, condition_filter: condition_filter)
94
114
  next if runs.empty?
95
115
 
96
116
  Paragraph.new(
@@ -102,12 +122,30 @@ module Idml
102
122
  left_indent: psr.left_indent,
103
123
  right_indent: psr.right_indent,
104
124
  auto_leading: psr.auto_leading,
125
+ rule_above: psr.rule_above,
126
+ rule_above_line_weight: psr.rule_above_line_weight,
127
+ rule_above_color: psr.stroke_color,
128
+ rule_above_tint: psr.rule_above_tint,
129
+ rule_above_offset: psr.rule_above_offset,
130
+ rule_above_left_indent: psr.rule_above_left_indent,
131
+ rule_above_right_indent: psr.rule_above_right_indent,
132
+ rule_above_width: psr.rule_above_width,
133
+ rule_below: psr.rule_below,
134
+ rule_below_line_weight: psr.rule_below_line_weight,
135
+ rule_below_color: psr.stroke_color,
136
+ rule_below_tint: psr.rule_below_tint,
137
+ rule_below_offset: psr.rule_below_offset,
138
+ rule_below_left_indent: psr.rule_below_left_indent,
139
+ rule_below_right_indent: psr.rule_below_right_indent,
140
+ rule_below_width: psr.rule_below_width,
105
141
  )
106
142
  end
107
143
  end
108
144
 
109
- def self.csr_runs(psr)
145
+ def self.csr_runs(psr, condition_filter: nil)
110
146
  psr.character_style_range.filter_map do |csr|
147
+ next unless condition_visible?(csr, condition_filter)
148
+
111
149
  text = csr.text_content
112
150
  next if text.nil? || text.empty?
113
151
 
@@ -136,6 +174,16 @@ module Idml
136
174
  end
137
175
  private_class_method :csr_runs
138
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
+
139
187
  def self.alignment_for(csr)
140
188
  ALIGNMENT_MAP[csr.justification] || :left
141
189
  end
data/lib/idml/render.rb CHANGED
@@ -14,7 +14,10 @@ module Idml
14
14
  autoload :Blending, "#{__dir__}/render/blending"
15
15
  autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
16
16
  autoload :CharacterStyle, "#{__dir__}/render/character_style"
17
+ autoload :ParagraphRules, "#{__dir__}/render/paragraph_rules"
18
+ autoload :StoryChainController, "#{__dir__}/render/story_chain_controller"
17
19
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
20
+ autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
18
21
  autoload :Placement, "#{__dir__}/render/placement"
19
22
  autoload :ImageCollector, "#{__dir__}/render/image_collector"
20
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.0"
4
+ VERSION = "0.7.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-08 00:00:00.000000000 Z
11
+ date: 2026-08-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -163,7 +163,11 @@ files:
163
163
  - TODO.pdf/105-whole-package-round-trip.md
164
164
  - TODO.pdf/106-page-item-type-coverage.md
165
165
  - TODO.pdf/107-image-rendering-unification.md
166
+ - TODO.pdf/108-multi-frame-story-flow.md
167
+ - TODO.pdf/109-dry-color-tint.md
166
168
  - TODO.pdf/11-font-embedding.md
169
+ - TODO.pdf/110-conditional-text.md
170
+ - TODO.pdf/111-footnote-support.md
167
171
  - TODO.pdf/12-idml-to-pdf-pipeline.md
168
172
  - TODO.pdf/13-cjk-text-layout.md
169
173
  - TODO.pdf/14-page-item-element-models.md
@@ -272,6 +276,7 @@ files:
272
276
  - lib/idml/elements/character_style_group.rb
273
277
  - lib/idml/elements/character_style_range.rb
274
278
  - lib/idml/elements/color.rb
279
+ - lib/idml/elements/condition.rb
275
280
  - lib/idml/elements/content.rb
276
281
  - lib/idml/elements/content_transparency_setting.rb
277
282
  - lib/idml/elements/contour_option.rb
@@ -424,6 +429,7 @@ files:
424
429
  - lib/idml/render/character_style.rb
425
430
  - lib/idml/render/color_helper.rb
426
431
  - lib/idml/render/color_resolver.rb
432
+ - lib/idml/render/condition_filter.rb
427
433
  - lib/idml/render/font_reference_resolver.rb
428
434
  - lib/idml/render/font_setup.rb
429
435
  - lib/idml/render/gradient_resolver.rb
@@ -435,6 +441,7 @@ files:
435
441
  - lib/idml/render/layer_filter.rb
436
442
  - lib/idml/render/metadata_builder.rb
437
443
  - lib/idml/render/page_item_renderer.rb
444
+ - lib/idml/render/paragraph_rules.rb
438
445
  - lib/idml/render/pdfa_packet.rb
439
446
  - lib/idml/render/pdfrb_writer.rb
440
447
  - lib/idml/render/pipeline.rb
@@ -449,6 +456,7 @@ files:
449
456
  - lib/idml/render/renderers/table_renderer.rb
450
457
  - lib/idml/render/renderers/text_frame_renderer.rb
451
458
  - lib/idml/render/spread_renderer.rb
459
+ - lib/idml/render/story_chain_controller.rb
452
460
  - lib/idml/render/story_threader.rb
453
461
  - lib/idml/render/stroke_style.rb
454
462
  - lib/idml/render/structure_mapper.rb