idml 0.7.1 → 0.7.3

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: 66e6127aeed8e54926e01cacb67c81445565c1e43a558015fd9740e022c12372
4
+ data.tar.gz: 20b6dcdef5ee316ca394a2b062ed0dac2db9137e08e28f7649944623420e3938
5
5
  SHA512:
6
- metadata.gz: 16b0f93a7c8993c5f98ad84c6e186ed61ebcaaabcdda63b26c9d1e4bfadd1c3986bef933d2a832d2b3463ab5b23324778a37e92e62cbeb8ce0876dbc26bebcbe
7
- data.tar.gz: 9950c7afceda42f377e0d2bef226c4caa346c9d79ac4b3b61dcba99612a321c646eecd6b341be88fd7f8a46c695ecc2449d1bf4d900e4338ed1a4c9f76ea4d45
6
+ metadata.gz: 94180fd8ac2231198178f40f2051d4e1bd9c56d917cd271808fc87f7de66746739c4ecad65e23f6bbb57be539f061ff5ef9abfda56e71a2bb23ddc403ac3d9a5
7
+ data.tar.gz: 353fcc512f92ed689fc1e41cabf4fe9310bcb5ce0464be24b0be6a452c1559f860201c7132318bb3fd4be9c510eed63ebb98f056b927b9e781bffcfc2123f632
@@ -1,6 +1,17 @@
1
1
  # TODO PDF 103: Drop caps (DropCapLines, DropCapCharacters)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: PARTIAL`Render::DropCap` helper computes drop-cap
4
+ geometry (font_size = base × lines, width via font_metrics.measure_text,
5
+ height = leading × lines). TextFrameRenderer.emit_drop_cap emits the
6
+ drop cap as an enlarged glyph at the paragraph's top-left when the
7
+ paragraph declares DropCapLines/DropCapCharacters > 0 and is the
8
+ chain's first paragraph.
9
+
10
+ Wrap-around text is NOT yet implemented — the drop cap renders as an
11
+ enlarged glyph; subsequent paragraph text flows normally and may
12
+ overlap the drop cap. Real wrap-around (indent lines 1..M to the
13
+ right of the drop cap) requires per-line wrap-width control in
14
+ VerticalLayout, which is a bigger refactor. Tracked as follow-up.
4
15
 
5
16
  ## Problem
6
17
 
@@ -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,53 @@
1
+ # TODO PDF 112: TextFrame vertical justification (TopAlign / CenterAlign / BottomAlign / JustifyAlign)
2
+
3
+ ## Status: DONE for TopAlign / CenterAlign / BottomAlign. JustifyAlign
4
+ defers to TopAlign (inter-paragraph space distribution not yet
5
+ implemented). Content height is approximated by treating each run
6
+ as one line — wrap may produce more lines, so slack is over-estimated;
7
+ we'd rather under-offset than overflow.
8
+
9
+ ## Problem
10
+
11
+ `Elements::TextFramePreference` carries `VerticalJustification`
12
+ (attribute VerticalJustification on TextFramePreference_Object in
13
+ Styles.rnc). Possible values: TopAlign, CenterAlign, BottomAlign,
14
+ JustifyAlign.
15
+
16
+ Today TextFrameRenderer always starts text at the top of the frame
17
+ (`cursor_y = box[:y] + box[:height] - inset_top`). For
18
+ `VerticalJustification="CenterAlign"` documents, the entire text
19
+ block should be vertically centered within the frame's text area;
20
+ for `BottomAlign`, text sits at the bottom.
21
+
22
+ This is a common layout requirement — callouts, captions, footers,
23
+ and sidebars often use bottom or center vertical justification.
24
+
25
+ ## What needs to happen
26
+
27
+ 1. `TextFrameRenderer#engine_render` reads
28
+ `frame.text_frame_preference.vertical_justification`.
29
+ 2. Compute the total content height (sum of all paragraph heights +
30
+ space_before/after).
31
+ 3. If VerticalJustification is CenterAlign: top-offset =
32
+ (frame_text_area_height - content_height) / 2.
33
+ 4. If BottomAlign: top-offset = (frame_text_area_height - content_height).
34
+ 5. Adjust the initial `cursor_y` by top-offset before laying out
35
+ the first paragraph.
36
+ 6. JustifyAlign: distribute extra vertical space between paragraphs
37
+ (more complex — defer).
38
+
39
+ ## Acceptance criteria
40
+
41
+ - [ ] TextFrame with VerticalJustification="CenterAlign" centers
42
+ text vertically in the frame.
43
+ - [ ] TextFrame with VerticalJustification="BottomAlign" aligns
44
+ text to the bottom.
45
+ - [ ] TextFrame with VerticalJustification="TopAlign" (default)
46
+ renders as today.
47
+ - [ ] Spec coverage per value.
48
+
49
+ ## Dependencies
50
+
51
+ - TODO 108 (multi-frame story flow) — content_height calculation
52
+ needs to consider all paragraphs that will render, which the
53
+ chain state already tracks.
@@ -0,0 +1,48 @@
1
+ # TODO PDF 113: Multi-column text frames (TextColumnCount, TextColumnGutter)
2
+
3
+ ## Status: OPEN — gap identified in 2026-08-10 audit
4
+
5
+ ## Problem
6
+
7
+ `Elements::TextFramePreference` declares:
8
+ - `TextColumnCount` — number of text columns in the frame (1-40)
9
+ - `TextColumnGutter` — gap between columns (in points)
10
+ - `TextColumnFixedWidth` — width of each column when uniform
11
+ - `UseFixedColumnWidth` — boolean
12
+ - `UseFlexibleColumnWidth` — boolean
13
+ - `TextColumnMaxWidth` — max column width when flexible
14
+
15
+ Real-world documents frequently use multi-column layouts
16
+ (magazines, newspapers, technical reports, brochures). Today the
17
+ renderer treats every text frame as a single column, ignoring
18
+ TextColumnCount > 1.
19
+
20
+ Effect: multi-column stories render as one wide column that over-
21
+ laps adjacent content. The PDF is unreadable for multi-column docs.
22
+
23
+ ## What needs to happen
24
+
25
+ 1. `TextFrameRenderer` reads `text_frame_preference.text_column_count`.
26
+ 2. When > 1: split the frame's text area into N columns separated
27
+ by `text_column_gutter`.
28
+ 3. Render text column-by-column: column 1 fills top-to-bottom,
29
+ overflow continues at top of column 2, etc.
30
+ 4. StoryThreader chain state threads across columns WITHIN a frame
31
+ before crossing to the next frame.
32
+ 5. Optional: support TextColumnFixedWidth vs flexible-width
33
+ distribution.
34
+
35
+ Architecture: this combines TODO 108 (chain flow) with column
36
+ awareness. Each column acts like a mini-frame in the chain.
37
+
38
+ ## Acceptance criteria
39
+
40
+ - [ ] TextFrame with TextColumnCount=2 renders two side-by-side columns.
41
+ - [ ] Text overflows from column 1 → column 2 → next frame.
42
+ - [ ] TextColumnGutter honored as the gap between columns.
43
+ - [ ] Single-column frames render as today.
44
+
45
+ ## Dependencies
46
+
47
+ - TODO 108 (multi-frame story flow) — column-to-column flow uses
48
+ the same chain state mechanism.
@@ -0,0 +1,50 @@
1
+ # TODO PDF 114: Text wrap around shapes (TextWrapPreference)
2
+
3
+ ## Status: OPEN — gap identified in 2026-08-10 audit
4
+
5
+ ## Problem
6
+
7
+ IDML page items (Rectangle, Polygon, GraphicLine, Group, Image, EPS)
8
+ can declare a `<TextWrapPreference>` child that controls how text
9
+ in neighboring TextFrames wraps around the item. Settings include:
10
+
11
+ - WrapMode: None / Jump / Next / Bounding Box / Shape / Drop Cap
12
+ - Contour: the wrap boundary (could differ from the visual shape)
13
+ - Inverse: text wraps INSIDE the contour (rare)
14
+ - OffsetTop/Left/Bottom/Right: additional margin around the contour
15
+
16
+ Real documents wrap text around images and callout boxes constantly.
17
+ Without text wrap support, overlapping items produce unreadable
18
+ output — text from one frame runs over a graphic in another.
19
+
20
+ Today TextFrameRenderer knows nothing about TextWrapPreference on
21
+ other page items. It renders into the frame's full geometric
22
+ rectangle regardless of overlapping wrap contours.
23
+
24
+ ## What needs to happen
25
+
26
+ 1. `Render::TextWrapResolver` reads TextWrapPreference from all
27
+ page items in the spread and indexes their contours by Self.
28
+ 2. Per TextFrame: compute the intersection of the frame's text area
29
+ with all wrap contours in the spread.
30
+ 3. TextFrameRenderer queries the resolver per text line; each line's
31
+ wrap width is reduced by the overlap on the left and right.
32
+ 4. Lines that fall entirely inside a wrap region are skipped.
33
+
34
+ This is a substantial feature — wrap computation is non-trivial
35
+ (geometry intersection, contour following). Most documents use a
36
+ simple Bounding Box mode (rectangle subtract), which is tractable.
37
+
38
+ ## Acceptance criteria
39
+
40
+ - [ ] TextWrapMode="BoundingBox" with an image inside the frame's
41
+ bounds reduces wrap width on affected lines.
42
+ - [ ] TextWrapMode="None" (default) renders as today.
43
+ - [ ] OffsetTop/Left/Bottom/Right honored (expands the contour by
44
+ the offsets).
45
+
46
+ ## Dependencies
47
+
48
+ - TODO 108 (multi-frame story flow) — wrap-aware wrap width needs
49
+ to combine with chain-threaded line layout.
50
+ - TODO 113 (multi-column) — wrap interacts with column boundaries.
@@ -0,0 +1,46 @@
1
+ # TODO PDF 115: Bullet and numbered list rendering
2
+
3
+ ## Status: OPEN — gap identified in 2026-08-10 audit
4
+
5
+ ## Problem
6
+
7
+ `Elements::ParagraphStyleRange` carries a large set of bullets and
8
+ numbering attributes (BulletsAndNumberingListType, NumberingExpression,
9
+ BulletsTextAfter, NumberingLevel, NumberingContinue, NumberingStartAt,
10
+ BulletsAlignment, NumberingAlignment, BulletCharacterType,
11
+ BulletCharacterValue, RestartPolicy, LowerLevel, UpperLevel).
12
+
13
+ The renderer ignores all of these. Lists appear in the PDF as plain
14
+ paragraphs without bullet markers or numbering.
15
+
16
+ Real documents (technical specs, tutorials, procedures, contracts)
17
+ use lists extensively.
18
+
19
+ ## What needs to happen
20
+
21
+ 1. Read BulletsAndNumberingListType from PSR:
22
+ - "BulletList" → emit a bullet glyph at the paragraph's start
23
+ - "NumberedList" → emit a number/letter at the paragraph's start
24
+ 2. Apply BulletsAlignment (left/center/right) within a hanging-indent
25
+ gutter.
26
+ 3. Auto-increment numbering per NumberingLevel +
27
+ NumberingContinue + NumberingStartAt rules.
28
+ 4. Honor RestartPolicy for sub-list restart behavior.
29
+ 5. BulletCharacterValue picks the glyph (Unicode codepoint).
30
+
31
+ Architecture: list markers prepend the paragraph's first run; the
32
+ renderer needs an in-memory counter per numbering chain (similar
33
+ to StoryChainController).
34
+
35
+ ## Acceptance criteria
36
+
37
+ - [ ] PSR with BulletsAndNumberingListType="BulletList" renders a
38
+ bullet glyph before the paragraph's first character.
39
+ - [ ] PSR with BulletsAndNumberingListType="NumberedList" renders
40
+ a sequential number, auto-incremented per chain.
41
+ - [ ] BulletsTextAfter (e.g., ". ", ") ") honored as the marker's
42
+ suffix.
43
+
44
+ ## Dependencies
45
+
46
+ - None — independent feature work on PSR.
@@ -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
@@ -0,0 +1,83 @@
1
+ # frozen_string: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Computes drop-cap geometry for a paragraph. IDML's DropCapLines
6
+ # (M) and DropCapCharacters (N) declare that the first N
7
+ # characters of the paragraph should render at M-lines-tall and
8
+ # the next M text lines should wrap to the right of the drop cap.
9
+ #
10
+ # The helper returns a `DropCapLayout` struct with the drop cap
11
+ # text, font size, width (for wrap offset), and total height.
12
+ # Returns nil when the paragraph doesn't declare drop caps.
13
+ module DropCap
14
+ DropCapLayout = Struct.new(
15
+ :text, :width, :height, :font_size, :wrap_offset, :lines,
16
+ keyword_init: true
17
+ )
18
+
19
+ # Returns a DropCapLayout for the paragraph, or nil when drop
20
+ # caps aren't declared. `font_metrics` measures the drop-cap
21
+ # text's natural width. `base_size` is the paragraph's normal
22
+ # font size (drop cap is enlarged relative to it).
23
+ def self.layout(paragraph, font_metrics:, base_size:, leading:)
24
+ return nil unless active?(paragraph)
25
+ return nil unless font_metrics
26
+
27
+ chars = paragraph.drop_cap_characters.to_i
28
+ lines = paragraph.drop_cap_lines.to_i
29
+ return nil if chars <= 0 || lines <= 0
30
+
31
+ build_layout(paragraph, font_metrics, base_size, leading, chars, lines)
32
+ end
33
+
34
+ # True when the paragraph declares an active drop cap
35
+ # (both DropCapLines and DropCapCharacters are positive integers).
36
+ def self.active?(paragraph)
37
+ return false unless paragraph
38
+ return false unless paragraph.drop_cap_lines.to_i.positive?
39
+
40
+ paragraph.drop_cap_characters.to_i.positive?
41
+ end
42
+
43
+ # Pulls the first N characters from the paragraph's first run.
44
+ # If the first run is shorter than N, takes from subsequent runs
45
+ # until N chars are accumulated (rare — drop caps typically use
46
+ # 1-2 chars).
47
+ def self.extract_drop_cap_text(paragraph, n)
48
+ runs = paragraph.runs
49
+ return nil if runs.empty?
50
+
51
+ collected = +""
52
+ runs.each do |run|
53
+ needed = n - collected.length
54
+ break if needed <= 0
55
+
56
+ collected << run.text[0, needed]
57
+ break if collected.length >= n
58
+ end
59
+ collected
60
+ end
61
+
62
+ def self.build_layout(paragraph, font_metrics, base_size, leading,
63
+ chars, lines)
64
+ text = extract_drop_cap_text(paragraph, chars)
65
+ return nil if text.nil? || text.empty?
66
+
67
+ font_size = base_size * lines
68
+ width = font_metrics.measure_text(text, font_size).to_f
69
+ return nil unless width&.positive?
70
+
71
+ DropCapLayout.new(
72
+ text: text,
73
+ width: width,
74
+ height: leading * lines,
75
+ font_size: font_size,
76
+ wrap_offset: width,
77
+ lines: lines,
78
+ )
79
+ end
80
+ private_class_method :build_layout
81
+ end
82
+ end
83
+ 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,251 @@ 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
- cursor_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
160
+ top_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
120
161
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
121
- char_cursor = 0
162
+ cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
163
+ char_cursor = state.char_cursor
122
164
 
123
- paragraphs.each do |paragraph|
124
- break if cursor_y < bottom_limit
125
-
126
- cursor_y, char_cursor = render_paragraph(
127
- canvas, paragraph, context, layout_frame, font,
165
+ if state.current_paragraph
166
+ cursor_y, char_cursor = resume_paragraph(
167
+ canvas, state, context, layout_frame, font,
128
168
  cursor_y, bottom_limit, char_cursor
129
169
  )
170
+ return if state.current_paragraph
130
171
  end
172
+
173
+ consume_paragraphs(canvas, state, context, layout_frame, font,
174
+ cursor_y, bottom_limit, char_cursor)
131
175
  end
132
176
  private_class_method :engine_render
133
177
 
134
- def self.render_paragraph(canvas, paragraph, context, layout_frame,
135
- font, cursor_y, bottom_limit, char_cursor)
178
+ # Computes the starting cursor_y for vertical justification.
179
+ # IDML's TextFramePreference.VerticalJustification can be:
180
+ # - TopAlign (default): start at frame top, no offset
181
+ # - CenterAlign: center the content block vertically
182
+ # - BottomAlign: align content to the frame bottom
183
+ # - JustifyAlign: distribute extra space between paragraphs
184
+ # (deferred — falls back to TopAlign behavior)
185
+ #
186
+ # Content height is estimated by walking paragraphs and
187
+ # summing leading × lines + space_before/after. Without
188
+ # shaping/wrapping we can't know exact line count, so we
189
+ # approximate by treating each run as one line.
190
+ def self.vertical_justify_top(top_y, bottom_limit, state, context)
191
+ justification = text_frame_vertical_justification(context)
192
+ return top_y unless %w[CenterAlign BottomAlign].include?(justification)
193
+
194
+ content_height = estimate_content_height(state)
195
+ available = top_y - bottom_limit
196
+ slack = [available - content_height, 0].max
197
+ return top_y if slack.zero?
198
+
199
+ case justification
200
+ when "CenterAlign" then top_y - (slack / 2)
201
+ when "BottomAlign" then top_y - slack
202
+ else top_y
203
+ end
204
+ end
205
+ private_class_method :vertical_justify_top
206
+
207
+ def self.text_frame_vertical_justification(context)
208
+ pref = context.item&.text_frame_preference&.first
209
+ pref&.vertical_justification
210
+ end
211
+ private_class_method :text_frame_vertical_justification
212
+
213
+ # Estimates total renderable content height for vertical
214
+ # justification. Walks paragraphs and runs, summing leading
215
+ # per run plus paragraph space_before/after. Approximation:
216
+ # treats each run as one line (wrap may produce more lines,
217
+ # which would over-estimate slack; acceptable for
218
+ # justification since we'd rather under-offset than overflow).
219
+ def self.estimate_content_height(state)
220
+ paragraphs = paragraphs_for_estimate(state)
221
+ return 0 if paragraphs.empty?
222
+
223
+ paragraphs.sum do |paragraph|
224
+ paragraph_height(paragraph)
225
+ end
226
+ end
227
+ private_class_method :estimate_content_height
228
+
229
+ def self.paragraphs_for_estimate(state)
230
+ result = []
231
+ result << state.current_paragraph if state.current_paragraph
232
+ result + state.paragraphs
233
+ end
234
+ private_class_method :paragraphs_for_estimate
235
+
236
+ def self.paragraph_height(paragraph)
237
+ size = paragraph.runs.first&.point_size || DEFAULT_SIZE
238
+ leading = leading_for(paragraph, size)
239
+ line_count = [paragraph.runs.length, 1].max
240
+ (leading * line_count) + space_before_after(paragraph)
241
+ end
242
+ private_class_method :paragraph_height
243
+
244
+ def self.space_before_after(paragraph)
245
+ (paragraph.space_before || 0) + (paragraph.space_after || 0)
246
+ end
247
+ private_class_method :space_before_after
248
+
249
+ def self.resume_paragraph(canvas, state, context, layout_frame, font,
250
+ cursor_y, bottom_limit, char_cursor)
251
+ paragraph = state.current_paragraph
252
+ remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
253
+ canvas, paragraph, state.runs_remaining, context,
254
+ layout_frame, font, cursor_y, bottom_limit, char_cursor,
255
+ first_paragraph: true
256
+ )
257
+ if remaining_runs.any?
258
+ state.runs_remaining = remaining_runs
259
+ else
260
+ state.current_paragraph = nil
261
+ state.runs_remaining = []
262
+ end
263
+ [cursor_y, char_cursor]
264
+ end
265
+ private_class_method :resume_paragraph
266
+
267
+ def self.consume_paragraphs(canvas, state, context, layout_frame, font,
268
+ cursor_y, bottom_limit, char_cursor)
269
+ pending = 0
270
+ state.paragraphs.each do |paragraph|
271
+ break if cursor_y < bottom_limit
272
+
273
+ remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
274
+ canvas, paragraph, paragraph.runs, context,
275
+ layout_frame, font, cursor_y, bottom_limit, char_cursor,
276
+ first_paragraph: pending.zero?
277
+ )
278
+ if remaining_runs.any?
279
+ state.current_paragraph = paragraph
280
+ state.runs_remaining = remaining_runs
281
+ break
282
+ end
283
+ pending += 1
284
+ end
285
+
286
+ state.paragraphs = state.paragraphs[pending..]
287
+ state.char_cursor = char_cursor
288
+ end
289
+ private_class_method :consume_paragraphs
290
+
291
+ # Renders a paragraph's runs (a subset when resuming mid-para).
292
+ # Returns [remaining_runs, cursor_y, char_cursor] where
293
+ # remaining_runs is the runs that didn't fit (empty when all
294
+ # done). Emits RuleAbove before the first run and RuleBelow
295
+ # after the last successfully-rendered run.
296
+ def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
297
+ layout_frame, font, cursor_y,
298
+ bottom_limit, char_cursor,
299
+ first_paragraph:)
136
300
  wrap_width = TextEngine::VerticalLayout.wrap_width(
137
301
  layout_frame, paragraph.right_indent || 0
138
302
  )
139
303
  para_attrs = paragraph_attrs(paragraph)
140
304
  frame_left, frame_right = paragraph_frame_extents(layout_frame)
141
305
 
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.
306
+ emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
307
+ frame_left, frame_right, first_paragraph)
308
+ emit_drop_cap(canvas, paragraph, context, frame_left, cursor_y,
309
+ first_paragraph)
310
+
311
+ rendered_count = 0
312
+ runs.each do |run|
313
+ break if cursor_y < bottom_limit
314
+
315
+ space_before = paragraph_space_before(para_attrs, first_paragraph,
316
+ rendered_count)
317
+ positioned, next_y = layout_run(
318
+ canvas, run, paragraph, context, layout_frame, font,
319
+ wrap_width, cursor_y, space_before,
320
+ para_attrs[:first_line_indent], para_attrs[:left_indent],
321
+ char_cursor
322
+ )
323
+ char_cursor += positioned.length
324
+ cursor_y = next_y
325
+ rendered_count += 1
326
+ end
327
+
328
+ remaining_runs = runs[rendered_count..]
329
+ cursor_y = emit_paragraph_bottom_rule(canvas, paragraph, context,
330
+ cursor_y, frame_left,
331
+ frame_right, para_attrs,
332
+ remaining_runs)
333
+ [remaining_runs, cursor_y, char_cursor]
334
+ end
335
+ private_class_method :render_runs_for_paragraph
336
+
337
+ # Renders the drop cap for a paragraph (when declared and the
338
+ # first paragraph of the chain). Wrap-around text is not yet
339
+ # implemented — the drop cap renders as an enlarged glyph at
340
+ # the paragraph's top-left; subsequent text may overlap it.
341
+ # Marked as a follow-up in TODO 103.
342
+ def self.emit_drop_cap(canvas, paragraph, context, frame_left,
343
+ cursor_y, first_paragraph)
344
+ return nil unless first_paragraph
345
+ return nil unless DropCap.active?(paragraph)
346
+
347
+ base_size = paragraph.runs.first&.point_size || DEFAULT_SIZE
348
+ leading = leading_for(paragraph, base_size)
349
+ drop_cap = DropCap.layout(paragraph,
350
+ font_metrics: context.font_metrics,
351
+ base_size: base_size, leading: leading)
352
+ return nil unless drop_cap
353
+
354
+ drop_at_y = cursor_y - drop_cap.font_size + (base_size * 0.25)
355
+ canvas.text(
356
+ drop_cap.text,
357
+ at: [frame_left, drop_at_y],
358
+ font: font_for_run(paragraph.runs.first, context),
359
+ size: drop_cap.font_size,
360
+ )
361
+ end
362
+ private_class_method :emit_drop_cap
363
+
364
+ def self.emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
365
+ frame_left, frame_right,
366
+ first_paragraph)
367
+ return unless first_paragraph
368
+
145
369
  ParagraphRules.emit_rule_above(canvas, paragraph, context,
146
370
  cursor_y, frame_left, frame_right)
371
+ end
372
+ private_class_method :emit_paragraph_top_rule
147
373
 
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]
374
+ def self.paragraph_space_before(para_attrs, first_paragraph,
375
+ rendered_count)
376
+ return 0 unless first_paragraph && rendered_count.zero?
377
+
378
+ para_attrs[:space_before]
379
+ end
380
+ private_class_method :paragraph_space_before
153
381
 
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.
382
+ # Emits RuleBelow after a paragraph's last run. Returns the
383
+ # updated cursor_y (subtracting space_after when the paragraph
384
+ # completed).
385
+ def self.emit_paragraph_bottom_rule(canvas, paragraph, context,
386
+ cursor_y, frame_left,
387
+ frame_right, para_attrs,
388
+ remaining_runs)
389
+ return cursor_y unless remaining_runs.empty?
390
+
391
+ after_y = cursor_y - para_attrs[:space_after]
157
392
  ParagraphRules.emit_rule_below(canvas, paragraph, context,
158
393
  after_y, frame_left, frame_right)
159
- [after_y, char_cursor]
394
+ after_y
160
395
  end
161
- private_class_method :render_paragraph
396
+ private_class_method :emit_paragraph_bottom_rule
162
397
 
163
398
  def self.paragraph_frame_extents(layout_frame)
164
399
  inset_left = layout_frame.inset_left || 0
@@ -178,27 +413,6 @@ module Idml
178
413
  end
179
414
  private_class_method :paragraph_attrs
180
415
 
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
416
  # Shapes one run's text, wraps to the inset/indent-adjusted
203
417
  # width, justifies per paragraph alignment, and asks
204
418
  # VerticalLayout to position the lines. Emits each line via
@@ -321,8 +535,11 @@ module Idml
321
535
 
322
536
  # Fallback when no metrics are available: emit all runs as
323
537
  # one `text_rich` block, letting pdfrb measure advance widths.
324
- def self.simple_render(canvas, paragraphs, context, box)
325
- runs = paragraphs.flat_map(&:runs)
538
+ # Chain threading is best-effort here — the simple path
539
+ # doesn't wrap, so the first frame's render typically emits
540
+ # all content and clears the chain state.
541
+ def self.simple_render(canvas, state, context, box)
542
+ runs = collect_runs(state)
326
543
  return if runs.empty?
327
544
 
328
545
  runs_for = build_rich_runs(runs, context)
@@ -331,9 +548,22 @@ module Idml
331
548
  runs_for,
332
549
  at: [box[:x], box[:y] + box[:height] - first_size],
333
550
  )
551
+ state.paragraphs = []
552
+ state.current_paragraph = nil
553
+ state.runs_remaining = []
334
554
  end
335
555
  private_class_method :simple_render
336
556
 
557
+ def self.collect_runs(state)
558
+ runs = state.runs_remaining || []
559
+ if state.current_paragraph
560
+ runs + state.current_paragraph.runs
561
+ else
562
+ runs + state.paragraphs.flat_map(&:runs)
563
+ end
564
+ end
565
+ private_class_method :collect_runs
566
+
337
567
  def self.build_rich_runs(runs, context)
338
568
  runs.map do |run|
339
569
  {
@@ -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,8 @@ module Idml
54
54
  :left_indent,
55
55
  :right_indent,
56
56
  :auto_leading,
57
+ :drop_cap_lines,
58
+ :drop_cap_characters,
57
59
  # RuleAbove / RuleBelow — paragraph rules (horizontal lines
58
60
  # above/below the paragraph block). Driven by PSR attributes.
59
61
  :rule_above,
@@ -104,11 +106,13 @@ module Idml
104
106
  # its runs and carries the paragraph-level attributes from the
105
107
  # owning PSR. Empty paragraphs (no runs after filtering) are
106
108
  # skipped.
107
- def self.extract_paragraphs(story)
109
+ def self.extract_paragraphs(story, condition_filter: nil)
108
110
  return [] unless story&.inner
109
111
 
110
112
  story.inner.paragraph_style_range.filter_map do |psr|
111
- runs = csr_runs(psr)
113
+ next unless condition_visible?(psr, condition_filter)
114
+
115
+ runs = csr_runs(psr, condition_filter: condition_filter)
112
116
  next if runs.empty?
113
117
 
114
118
  Paragraph.new(
@@ -120,6 +124,8 @@ module Idml
120
124
  left_indent: psr.left_indent,
121
125
  right_indent: psr.right_indent,
122
126
  auto_leading: psr.auto_leading,
127
+ drop_cap_lines: psr.drop_cap_lines,
128
+ drop_cap_characters: psr.drop_cap_characters,
123
129
  rule_above: psr.rule_above,
124
130
  rule_above_line_weight: psr.rule_above_line_weight,
125
131
  rule_above_color: psr.stroke_color,
@@ -140,8 +146,10 @@ module Idml
140
146
  end
141
147
  end
142
148
 
143
- def self.csr_runs(psr)
149
+ def self.csr_runs(psr, condition_filter: nil)
144
150
  psr.character_style_range.filter_map do |csr|
151
+ next unless condition_visible?(csr, condition_filter)
152
+
145
153
  text = csr.text_content
146
154
  next if text.nil? || text.empty?
147
155
 
@@ -170,6 +178,16 @@ module Idml
170
178
  end
171
179
  private_class_method :csr_runs
172
180
 
181
+ # Drops the PSR/CSR when any of its AppliedConditions
182
+ # references a hidden Condition. Returns true when no filter
183
+ # is supplied (caller doesn't care about conditions).
184
+ def self.condition_visible?(psr_or_csr, condition_filter)
185
+ return true unless condition_filter
186
+
187
+ condition_filter.visible?(psr_or_csr.applied_conditions)
188
+ end
189
+ private_class_method :condition_visible?
190
+
173
191
  def self.alignment_for(csr)
174
192
  ALIGNMENT_MAP[csr.justification] || :left
175
193
  end
data/lib/idml/render.rb CHANGED
@@ -15,7 +15,10 @@ 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 :DropCap, "#{__dir__}/render/drop_cap"
19
+ autoload :StoryChainController, "#{__dir__}/render/story_chain_controller"
18
20
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
21
+ autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
19
22
  autoload :Placement, "#{__dir__}/render/placement"
20
23
  autoload :ImageCollector, "#{__dir__}/render/image_collector"
21
24
  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.3"
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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -168,6 +168,10 @@ files:
168
168
  - TODO.pdf/11-font-embedding.md
169
169
  - TODO.pdf/110-conditional-text.md
170
170
  - TODO.pdf/111-footnote-support.md
171
+ - TODO.pdf/112-text-frame-vertical-justification.md
172
+ - TODO.pdf/113-multi-column-text-frames.md
173
+ - TODO.pdf/114-text-wrap-around-shapes.md
174
+ - TODO.pdf/115-bullet-numbered-lists.md
171
175
  - TODO.pdf/12-idml-to-pdf-pipeline.md
172
176
  - TODO.pdf/13-cjk-text-layout.md
173
177
  - TODO.pdf/14-page-item-element-models.md
@@ -276,6 +280,7 @@ files:
276
280
  - lib/idml/elements/character_style_group.rb
277
281
  - lib/idml/elements/character_style_range.rb
278
282
  - lib/idml/elements/color.rb
283
+ - lib/idml/elements/condition.rb
279
284
  - lib/idml/elements/content.rb
280
285
  - lib/idml/elements/content_transparency_setting.rb
281
286
  - lib/idml/elements/contour_option.rb
@@ -428,6 +433,8 @@ files:
428
433
  - lib/idml/render/character_style.rb
429
434
  - lib/idml/render/color_helper.rb
430
435
  - lib/idml/render/color_resolver.rb
436
+ - lib/idml/render/condition_filter.rb
437
+ - lib/idml/render/drop_cap.rb
431
438
  - lib/idml/render/font_reference_resolver.rb
432
439
  - lib/idml/render/font_setup.rb
433
440
  - lib/idml/render/gradient_resolver.rb
@@ -454,6 +461,7 @@ files:
454
461
  - lib/idml/render/renderers/table_renderer.rb
455
462
  - lib/idml/render/renderers/text_frame_renderer.rb
456
463
  - lib/idml/render/spread_renderer.rb
464
+ - lib/idml/render/story_chain_controller.rb
457
465
  - lib/idml/render/story_threader.rb
458
466
  - lib/idml/render/stroke_style.rb
459
467
  - lib/idml/render/structure_mapper.rb