idml 0.8.6 → 0.8.8

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: '04038941020b897a48513a0386fe33c4108384351a40e47e4ea50c2f09709611'
4
- data.tar.gz: 5e79b2dc7e0ec38818d06b74196bfb61227e4cb80c2e5989f8b836858469db70
3
+ metadata.gz: ad2709aae7d11f8a91843141700ab7882b9b7a196480d7b6077fe4f0b6473d16
4
+ data.tar.gz: b6fd8d8df9996dabd3fd4ffbfc9fb788930cb3ca04bb6f014ae8b3c9fce5b6af
5
5
  SHA512:
6
- metadata.gz: 115565aeb1f5c1da58d7a94337b3a8243da2303b4e12b25516582a7cc94065752d54df398a4f02632067b3a2b9495449911b43ee85b410d23b6a90d27e33c2ca
7
- data.tar.gz: 3754116e6abce4fb0481f8fdb02edb3452df9a0a6f7ff0417ca9500ab80155b91ceea0336a37d9defe41c75963c7040b75a2774f096fc5217285d57e19e6a41a
6
+ metadata.gz: 20154b884fb1688985cdca3c7a3866e7c55be9059768d4a00eae4058bdd237f3606c964318d224bdd198853bc61a0a9167012b5dc6cc558f4a31537146a45a27
7
+ data.tar.gz: 7075c7451518b208e6ce17da854fb9954048ed40825287dc87bf4b4543467a0677a277fdf49f124703c353dfc4faf1b770de936d69acf1b0958998887a1cb101
@@ -1,41 +1,45 @@
1
1
  # TODO PDF 102: Anchored objects (anchored object settings)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
4
-
5
- ## Problem
6
-
7
- `Elements::AnchoredObjectSetting` is modeled with the standard
8
- attributes (AnchorSpot, AnchorXunit, AnchorYunit, AnchorPoint,
9
- AnchorSpaceBefore, etc.). The renderer doesn't consult it.
10
-
11
- In IDML, an anchored object is a page item (Rectangle, Polygon,
12
- Group) that's "anchored" to a position within a story's text flow.
13
- When the text reflows, the anchored object moves with it. Today
14
- the renderer treats anchored objects as standalone page items in
15
- the Spread they appear at their spread coordinates, not their
16
- anchored position.
17
-
18
- ## What needs to happen
19
-
20
- 1. Detect when a page item has an `AnchoredObjectSetting` child
21
- with `AnchorType` indicating inline/anchored (vs. inline-only
22
- or floating).
23
- 2. For anchored items, suppress standalone rendering in the spread.
24
- 3. When laying out the story that owns the anchored object, reserve
25
- space at the anchor point and render the item there.
26
-
27
- This is a significant feature — anchored objects are common in
28
- real-world documents but rare in test fixtures.
3
+ ## Status: COMPLETEimplemented 2026-08-16
4
+
5
+ ## What was built
6
+
7
+ - Model wiring: `anchored_object_setting` attribute + mapping on
8
+ Rectangle, Oval, Polygon, GraphicLine, Group, and Path (per
9
+ `*_Object` in Story.rnc every page item can carry
10
+ `<AnchoredObjectSetting>`). The setting parses AnchoredPosition
11
+ (InlinePosition / AboveLine / Anchored), AnchorPoint,
12
+ AnchorXoffset / AnchorYoffset, Horizontal/VerticalReferencePoint,
13
+ and the remaining schema attributes.
14
+ - `CharacterStyleRange` now models its story-embedded page-item
15
+ children: `rectangle`, `oval`, `polygon`, `graphic_line`,
16
+ `group`, `text_frame` collections. Previously these elements were
17
+ dropped at parse time — anchored objects were lost entirely.
18
+ - Rendering: `TextFrameRenderer` discovers embedded items via the
19
+ shared `story_csrs` walk and renders each through
20
+ `PageItemRenderer` dispatch (fill, stroke, gradients, images
21
+ everything the spread-level path does) with a child context.
22
+ Items render at their own stored geometry: InDesign resolves an
23
+ anchored object's position when saving the file, so the stored
24
+ ItemTransform + PathGeometry IS the anchored position for all
25
+ three AnchorTypes.
26
+
27
+ ## Known limitations
28
+
29
+ - AnchorType-specific text reflow is not simulated: the layout
30
+ engine positions body text independently of embedded items, so
31
+ body text may overlap inline/above-line objects in frames whose
32
+ stored text positions differ from our layout engine's output.
33
+ - HorizontalReferencePoint / VerticalReferencePoint /
34
+ HorizontalAlignment / VerticalAlignment on the setting are parsed
35
+ but not consulted (stored geometry already encodes the result).
29
36
 
30
37
  ## Acceptance criteria
31
38
 
32
- - [ ] Page item with AnchoredObjectSetting AnchorType="Inline" renders
33
- at the anchor character's position within the text flow.
34
- - [ ] Page item with AnchorType="Above Line" renders above the line
35
- containing the anchor.
36
- - [ ] Page item with AnchorType="Anchored" renders at the absolute
37
- position derived from AnchorXunit / AnchorYunit.
38
-
39
- ## Dependencies
40
-
41
- - TODO 94 (need to know character positions in the text flow).
39
+ - [x] Page item with AnchoredObjectSetting AnchorType="Inline"
40
+ renders at its anchored position (stored geometry).
41
+ - [x] Page item with AnchorType="Above Line" renders above the
42
+ anchored line (stored geometry).
43
+ - [x] Page item with AnchorType="Anchored" renders at the position
44
+ derived from the anchor (stored geometry; AnchorX/Yoffset
45
+ parsing verified).
@@ -1,40 +1,50 @@
1
1
  # TODO PDF 111: Footnote support
2
2
 
3
- ## Status: OPENgap 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.
3
+ ## Status: COMPLETEimplemented 2026-08-16
4
+
5
+ ## What was built
6
+
7
+ - `Elements::Footnote` schema-faithful model of `Footnote_Object`
8
+ (Story.rnc): no attributes, PSR/CSR children carrying the footnote
9
+ text. Wired into `CharacterStyleRange` and `StoryInner` (`footnote`
10
+ collection). Footnote text is excluded from `text_content` so it
11
+ never leaks into body text.
12
+ - `Render::Footnote` — footnote semantics unit (MECE):
13
+ - `Counter`: story-scoped sequential numbering, honoring
14
+ FootnoteOption `StartAt`.
15
+ - `marker_run`: superscript marker emitted into the body text at the
16
+ anchoring CSR's position; inherits the CSR's character styling.
17
+ - `extract`: footnote paragraphs via StyleResolver with the marker
18
+ text (`Prefix + number + Suffix`) prefixed to the first run.
19
+ - `layout_entries` / `reserved_height`: one shared Shaper →
20
+ LineBreaker → VerticalLayout walk drives both height measurement
21
+ and rendering, so the reserved area always matches what's drawn.
22
+ - `emit_separator`: separator rule honoring FootnoteOption RuleOn /
23
+ RuleLineWeight / RuleWidth / RuleLeftIndent.
24
+ - `TextFrameRenderer`: marker runs are collected during body layout
25
+ (`register_footnote`), each raising the effective bottom limit so
26
+ body text avoids the footnote area (incremental reservation,
27
+ InDesign-style); after body layout, `render_footnote_entries` draws
28
+ the separator and the footnote paragraphs above the frame's content
29
+ bottom. Per-column in multi-column frames.
30
+ - `FootnoteOption` (Preferences.xml) honored: StartAt, Prefix, Suffix,
31
+ RuleOn, RuleLineWeight, RuleWidth, RuleLeftIndent, Spacer
32
+ (separator gap), SpaceBetween (inter-entry gap).
33
+
34
+ ## Known limitations
35
+
36
+ - Numbering restarts per story; document-wide continuous numbering
37
+ and per-section restart are not modeled.
38
+ - Footnote overflow balancing (a footnote too tall for its frame
39
+ moving wholly to the next frame, NoSplitting) is not modeled.
40
+ - The simple-render fallback (no font metrics) shows markers inline
41
+ but does not render footnote text at the frame bottom.
42
+ - Footnotes nested in CSRs inside CSRs (CSR > CSR > Footnote) are
43
+ not extracted; only direct CSR children emit markers.
30
44
 
31
45
  ## Acceptance criteria
32
46
 
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.
47
+ - [x] Footnote text rendered at bottom of containing frame.
48
+ - [x] Footnote marker in body text (superscript, numbered).
49
+ - [x] Numbering honors FootnoteOption StartAt / Prefix / Suffix
50
+ (per-story scope).
@@ -1,12 +1,20 @@
1
1
  # TODO PDF 114: Text wrap around shapes (TextWrapPreference)
2
2
 
3
- ## Status: PARTIAL — `Render::TextWrapResolver` computes BoundingBox-mode
4
- contours from page items declaring TextWrapPreference.
5
- `overlap_width(line_y, line_height, frame_x, frame_right)` returns the
6
- horizontal overlap for a text line. Rectangle now carries the
7
- TextWrapPreference child. Integration with TextFrameRenderer
8
- (per-line wrap width adjustment during layout) is the remaining work —
9
- requires line-by-line contour queries in the layout pipeline.
3
+ ## Status: DONE for BoundingBox mode — `Render::TextWrapResolver` computes
4
+ wrap contours from page items declaring TextWrapPreference with
5
+ TextWrapMode != "None". SpreadRenderer builds the resolver and passes
6
+ it via RenderContext. TextFrameRenderer's `text_wrap_overlap` queries
7
+ overlap at each run's y position and reduces the wrap width — text
8
+ flows around the shape instead of running over it.
9
+
10
+ Shape page items (Rectangle, Oval, Polygon, GraphicLine, Path) all
11
+ carry the TextWrapPreference child element. Non-shape items (Page,
12
+ Link) are filtered by WRAPPABLE_TYPES to avoid NoMethodError.
13
+
14
+ Per-run approximation: all lines in a run get the same reduced width.
15
+ True per-line adjustment (when a run spans the contour boundary)
16
+ requires line-count-aware layout. Shape mode (contour following) and
17
+ Inverse mode remain unsupported.
10
18
 
11
19
  ## Problem
12
20
 
@@ -300,6 +300,17 @@ module Idml
300
300
  collection: true
301
301
  attribute :table, Idml::Elements::Table, collection: true
302
302
  attribute :xml_element, Idml::Elements::XmlElement, collection: true
303
+ attribute :footnote, Idml::Elements::Footnote, collection: true
304
+ # Anchored page items embedded in the story flow. Their
305
+ # AnchoredObjectSetting (child of each item) declares how the
306
+ # item anchors to the surrounding text.
307
+ attribute :rectangle, Idml::Elements::Rectangle, collection: true
308
+ attribute :oval, Idml::Elements::Oval, collection: true
309
+ attribute :polygon, Idml::Elements::Polygon, collection: true
310
+ attribute :graphic_line, Idml::Elements::GraphicLine,
311
+ collection: true
312
+ attribute :group, Idml::Elements::Group, collection: true
313
+ attribute :text_frame, Idml::Elements::TextFrame, collection: true
303
314
 
304
315
  xml do
305
316
  root "CharacterStyleRange"
@@ -655,6 +666,13 @@ module Idml
655
666
  map_element "HyperlinkTextSource", to: :hyperlink_text_source
656
667
  map_element "Table", to: :table
657
668
  map_element "XMLElement", to: :xml_element
669
+ map_element "Footnote", to: :footnote
670
+ map_element "Rectangle", to: :rectangle
671
+ map_element "Oval", to: :oval
672
+ map_element "Polygon", to: :polygon
673
+ map_element "GraphicLine", to: :graphic_line
674
+ map_element "Group", to: :group
675
+ map_element "TextFrame", to: :text_frame
658
676
  end
659
677
 
660
678
  def text_content
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # `<Footnote>` — story-embedded footnote content. Schema-faithful
6
+ # model of `Footnote_Object` in
7
+ # `reference-docs/schemas/package/Stories/Story.rnc`: the element
8
+ # declares no attributes; its children are HiddenText /
9
+ # GaijiOwnedItemObject / Table / TextVariableInstance /
10
+ # ParagraphStyleRange / CharacterStyleRange. Of these, the
11
+ # ParagraphStyleRange (with its CSR children) and
12
+ # CharacterStyleRange children carry the footnote text; the rest
13
+ # are parsed by their own models where relevant.
14
+ #
15
+ # A Footnote sits inside the CharacterStyleRange that owns the
16
+ # anchor position: the body-text marker renders where the CSR's
17
+ # text ends, and the Footnote's paragraphs render at the bottom
18
+ # of the frame holding that marker (see Render::Footnote).
19
+ class Footnote < Lutaml::Model::Serializable
20
+ attribute :paragraph_style_range, Idml::Elements::ParagraphStyleRange,
21
+ collection: true
22
+ attribute :character_style_range, Idml::Elements::CharacterStyleRange,
23
+ collection: true
24
+
25
+ xml do
26
+ root "Footnote"
27
+ map_element "ParagraphStyleRange", to: :paragraph_style_range
28
+ map_element "CharacterStyleRange", to: :character_style_range
29
+ end
30
+ end
31
+ end
32
+ end
@@ -18,6 +18,9 @@ module Idml
18
18
  attribute :item_layer, :string
19
19
  attribute :properties, Idml::Elements::Properties, collection: true
20
20
  attribute :transparency_setting, Idml::Elements::TransparencySetting
21
+ attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
22
+ attribute :anchored_object_setting,
23
+ Idml::Elements::AnchoredObjectSetting
21
24
 
22
25
  xml do
23
26
  root "GraphicLine"
@@ -34,6 +37,8 @@ module Idml
34
37
  map_attribute "ItemLayer", to: :item_layer
35
38
  map_element "Properties", to: :properties
36
39
  map_element "TransparencySetting", to: :transparency_setting
40
+ map_element "TextWrapPreference", to: :text_wrap_preference
41
+ map_element "AnchoredObjectSetting", to: :anchored_object_setting
37
42
  end
38
43
 
39
44
  def geometric_bounds
@@ -10,6 +10,8 @@ module Idml
10
10
  attribute :item_transform, :string
11
11
  attribute :visible, :boolean
12
12
  attribute :item_layer, :string
13
+ attribute :anchored_object_setting,
14
+ Idml::Elements::AnchoredObjectSetting
13
15
  attribute :rectangle, Idml::Elements::Rectangle, collection: true
14
16
  attribute :text_frame, Idml::Elements::TextFrame, collection: true
15
17
  attribute :polygon, Idml::Elements::Polygon, collection: true
@@ -22,6 +24,7 @@ module Idml
22
24
  map_attribute "ItemTransform", to: :item_transform
23
25
  map_attribute "Visible", to: :visible
24
26
  map_attribute "ItemLayer", to: :item_layer
27
+ map_element "AnchoredObjectSetting", to: :anchored_object_setting
25
28
  map_element "Rectangle", to: :rectangle
26
29
  map_element "TextFrame", to: :text_frame
27
30
  map_element "Polygon", to: :polygon
@@ -29,6 +29,9 @@ module Idml
29
29
  attribute :item_layer, :string
30
30
  attribute :properties, Idml::Elements::Properties, collection: true
31
31
  attribute :transparency_setting, Idml::Elements::TransparencySetting
32
+ attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
33
+ attribute :anchored_object_setting,
34
+ Idml::Elements::AnchoredObjectSetting
32
35
  attribute :image, Idml::Elements::Image, collection: true
33
36
 
34
37
  xml do
@@ -50,6 +53,8 @@ module Idml
50
53
  map_attribute "ItemLayer", to: :item_layer
51
54
  map_element "Properties", to: :properties
52
55
  map_element "TransparencySetting", to: :transparency_setting
56
+ map_element "TextWrapPreference", to: :text_wrap_preference
57
+ map_element "AnchoredObjectSetting", to: :anchored_object_setting
53
58
  map_element "Image", to: :image
54
59
  end
55
60
 
@@ -25,6 +25,9 @@ module Idml
25
25
  attribute :item_layer, :string
26
26
  attribute :properties, Idml::Elements::Properties, collection: true
27
27
  attribute :transparency_setting, Idml::Elements::TransparencySetting
28
+ attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
29
+ attribute :anchored_object_setting,
30
+ Idml::Elements::AnchoredObjectSetting
28
31
 
29
32
  xml do
30
33
  root "Path"
@@ -41,6 +44,8 @@ module Idml
41
44
  map_attribute "ItemLayer", to: :item_layer
42
45
  map_element "Properties", to: :properties
43
46
  map_element "TransparencySetting", to: :transparency_setting
47
+ map_element "TextWrapPreference", to: :text_wrap_preference
48
+ map_element "AnchoredObjectSetting", to: :anchored_object_setting
44
49
  end
45
50
 
46
51
  def geometric_bounds
@@ -20,6 +20,9 @@ module Idml
20
20
  attribute :item_layer, :string
21
21
  attribute :properties, Idml::Elements::Properties, collection: true
22
22
  attribute :transparency_setting, Idml::Elements::TransparencySetting
23
+ attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
24
+ attribute :anchored_object_setting,
25
+ Idml::Elements::AnchoredObjectSetting
23
26
  attribute :image, Idml::Elements::Image, collection: true
24
27
 
25
28
  xml do
@@ -39,6 +42,8 @@ module Idml
39
42
  map_attribute "ItemLayer", to: :item_layer
40
43
  map_element "Properties", to: :properties
41
44
  map_element "TransparencySetting", to: :transparency_setting
45
+ map_element "TextWrapPreference", to: :text_wrap_preference
46
+ map_element "AnchoredObjectSetting", to: :anchored_object_setting
42
47
  map_element "Image", to: :image
43
48
  end
44
49
 
@@ -25,6 +25,8 @@ module Idml
25
25
  attribute :properties, Idml::Elements::Properties, collection: true
26
26
  attribute :transparency_setting, Idml::Elements::TransparencySetting
27
27
  attribute :text_wrap_preference, Idml::Elements::TextWrapPreference
28
+ attribute :anchored_object_setting,
29
+ Idml::Elements::AnchoredObjectSetting
28
30
  attribute :image, Idml::Elements::Image, collection: true
29
31
 
30
32
  xml do
@@ -48,6 +50,7 @@ module Idml
48
50
  map_element "Properties", to: :properties
49
51
  map_element "TransparencySetting", to: :transparency_setting
50
52
  map_element "TextWrapPreference", to: :text_wrap_preference
53
+ map_element "AnchoredObjectSetting", to: :anchored_object_setting
51
54
  map_element "Image", to: :image
52
55
  end
53
56
 
@@ -312,6 +312,7 @@ module Idml
312
312
  collection: true
313
313
  attribute :content, Idml::Elements::Content, collection: true
314
314
  attribute :xml_element, Idml::Elements::XmlElement, collection: true
315
+ attribute :footnote, Idml::Elements::Footnote, collection: true
315
316
 
316
317
  xml do
317
318
  root "Story"
@@ -676,6 +677,7 @@ module Idml
676
677
  map_element "CharacterStyleRange", to: :character_style_range
677
678
  map_element "Content", to: :content
678
679
  map_element "XMLElement", to: :xml_element
680
+ map_element "Footnote", to: :footnote
679
681
  end
680
682
 
681
683
  def text_content
data/lib/idml/elements.rb CHANGED
@@ -54,6 +54,7 @@ module Idml
54
54
  "idml/elements/flex_layout_attribute_option"
55
55
  autoload :Font, "idml/elements/font"
56
56
  autoload :FontFamily, "idml/elements/font_family"
57
+ autoload :Footnote, "idml/elements/footnote"
57
58
  autoload :FootnoteOption, "idml/elements/pref_footnote_option"
58
59
  autoload :FrameFittingOption, "idml/elements/frame_fitting_option"
59
60
  autoload :GraphicLine, "idml/elements/graphic_line"
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Footnote semantics: story-scoped numbering, body-text marker
6
+ # synthesis, footnote-paragraph extraction, and the geometry of
7
+ # the bottom-of-frame footnote area (height reservation +
8
+ # separator rule). Emission of footnote text lines stays in
9
+ # TextFrameRenderer — it owns all canvas text drawing — while
10
+ # this module owns everything footnote-specific (MECE).
11
+ #
12
+ # Numbering is sequential per story, honoring FootnoteOption's
13
+ # StartAt / Prefix / Suffix when the package declares them.
14
+ # Document-wide continuous numbering and per-section restart
15
+ # are not modeled.
16
+ module Footnote
17
+ # One footnote collected during a frame's layout: its number
18
+ # and its extracted (already marker-prefixed) paragraphs.
19
+ Entry = Struct.new(:number, :paragraphs, keyword_init: true)
20
+
21
+ # A positioned footnote line plus the run it came from — the
22
+ # unit TextFrameRenderer emits.
23
+ PositionedRun = Struct.new(:line, :run, :font_size, keyword_init: true)
24
+
25
+ # Story-scoped sequential counter. Mutated as csr_runs walks
26
+ # the story's style ranges so footnotes number in document
27
+ # order across paragraphs.
28
+ class Counter
29
+ def initialize(start_at = nil)
30
+ @next = start_at || 1
31
+ end
32
+
33
+ def next_number
34
+ value = @next
35
+ @next += 1
36
+ value
37
+ end
38
+ end
39
+
40
+ DEFAULT_RULE_WEIGHT = 0.5
41
+ DEFAULT_RULE_GAP = 3.0
42
+ MARKER_TEXT_SEPARATOR = " "
43
+
44
+ # Returns the package's FootnoteOption (Preferences.xml), or
45
+ # nil when the package doesn't declare one.
46
+ def self.option(package)
47
+ preferences = package&.preferences
48
+ preferences&.footnote_option&.first
49
+ end
50
+
51
+ def self.counter_for(option)
52
+ Counter.new(option&.start_at)
53
+ end
54
+
55
+ # Marker text shown in the body text (and prefixed to the
56
+ # footnote's first paragraph): Prefix + number + Suffix.
57
+ def self.marker_text(number, option)
58
+ "#{option&.prefix}#{number}#{option&.suffix}"
59
+ end
60
+
61
+ # Builds the superscript marker run emitted into the body
62
+ # text where the footnote anchors. Inherits the base run's
63
+ # character styling so the marker matches its context; when
64
+ # the owning CSR has no text run, falls back to defaults.
65
+ def self.marker_run(number, base, paragraphs, option)
66
+ marker = base || StyleResolver::StyledRun.new(
67
+ point_size: StyleResolver::DEFAULT_POINT_SIZE,
68
+ )
69
+ marker = marker.dup
70
+ marker.text = marker_text(number, option)
71
+ marker.position = "Superscript"
72
+ marker.footnote_number = number
73
+ marker.footnote_paragraphs = paragraphs
74
+ marker
75
+ end
76
+
77
+ # Extracts the footnote's paragraphs via StyleResolver and
78
+ # prefixes the first paragraph's first run with the marker
79
+ # text ("1 ", "n2." …) so the footnote reads as numbered.
80
+ def self.extract(element, number, condition_filter: nil,
81
+ style_lookup: nil, option: nil)
82
+ paragraphs = StyleResolver.extract_container_paragraphs(
83
+ element, condition_filter: condition_filter,
84
+ style_lookup: style_lookup
85
+ )
86
+ marker = marker_text(number, option)
87
+ prepend_marker(paragraphs, marker) unless marker.empty?
88
+ paragraphs
89
+ end
90
+
91
+ def self.prepend_marker(paragraphs, marker)
92
+ return if paragraphs.empty?
93
+ return if paragraphs.first.runs.empty?
94
+
95
+ first_run = paragraphs.first.runs.first
96
+ first_run.text = "#{marker}#{MARKER_TEXT_SEPARATOR}#{first_run.text}"
97
+ end
98
+ private_class_method :prepend_marker
99
+
100
+ # Lays out all entries' paragraphs top-down starting at
101
+ # `top_y`. Returns `[positioned_runs, bottom_y]` where
102
+ # bottom_y is the cursor after the last line — the same walk
103
+ # drives both height measurement and rendering, so the
104
+ # reserved area always matches what gets drawn.
105
+ def self.layout_entries(entries, frame, font, top_y, option = nil)
106
+ positioned = []
107
+ cursor = top_y
108
+ wrap_width = TextEngine::VerticalLayout.wrap_width(frame)
109
+
110
+ entries.each_with_index do |entry, index|
111
+ cursor -= (option&.space_between || 0).to_f if index.positive?
112
+ entry.paragraphs.each do |paragraph|
113
+ cursor = layout_paragraph(
114
+ paragraph, frame, font, wrap_width, cursor, positioned
115
+ )
116
+ end
117
+ end
118
+ [positioned, cursor]
119
+ end
120
+
121
+ # Shapes, wraps, and positions one footnote paragraph,
122
+ # appending its positioned lines. Returns the next cursor.
123
+ def self.layout_paragraph(paragraph, frame, font, wrap_width, cursor,
124
+ positioned)
125
+ paragraph.runs.each do |run|
126
+ size = run.point_size || StyleResolver::DEFAULT_POINT_SIZE
127
+ glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
128
+ size: size)
129
+ lines = TextEngine::LineBreaker.break(glyphs: glyphs,
130
+ frame_width: wrap_width)
131
+ lines.each do |line|
132
+ TextEngine::Justifier.justify(
133
+ line: line, frame_width: wrap_width,
134
+ alignment: paragraph.alignment || :left
135
+ )
136
+ end
137
+ leading = TextEngine::VerticalLayout.leading_for(
138
+ paragraph.auto_leading, size
139
+ )
140
+ block, cursor = TextEngine::VerticalLayout.layout_block(
141
+ lines: lines, frame: frame, font_size: size, leading: leading,
142
+ cursor_y: cursor
143
+ )
144
+ block.each do |line|
145
+ positioned << PositionedRun.new(line: line, run: run,
146
+ font_size: size)
147
+ end
148
+ end
149
+ cursor
150
+ end
151
+ private_class_method :layout_paragraph
152
+
153
+ # Height to reserve at the frame bottom for the entries:
154
+ # separator gap + the measured paragraph stack.
155
+ def self.reserved_height(entries, font, frame, option = nil)
156
+ return 0.0 if entries.empty?
157
+
158
+ content_bottom = TextEngine::VerticalLayout.bottom_limit(frame)
159
+ _, bottom_y = layout_entries(entries, frame, font, content_bottom,
160
+ option)
161
+ content_bottom - bottom_y + rule_gap(option)
162
+ end
163
+
164
+ # Vertical distance between the footnote area's top edge and
165
+ # the first footnote line — room for the separator rule.
166
+ def self.rule_gap(option)
167
+ option&.spacer || DEFAULT_RULE_GAP
168
+ end
169
+
170
+ # Emits the separator rule at the top of the footnote area.
171
+ # Honors FootnoteOption RuleOn / RuleLineWeight / RuleWidth /
172
+ # RuleLeftIndent; defaults approximate InDesign (0.5pt rule
173
+ # spanning the column).
174
+ def self.emit_separator(canvas, frame, y, option)
175
+ return if option&.rule_on == false
176
+
177
+ thickness = separator_weight(option)
178
+ return unless thickness.positive?
179
+
180
+ stroke_rule(canvas, separator_left(frame, option), y,
181
+ separator_width(frame, option), thickness)
182
+ end
183
+
184
+ def self.separator_weight(option)
185
+ (option&.rule_line_weight || DEFAULT_RULE_WEIGHT).to_f
186
+ end
187
+ private_class_method :separator_weight
188
+
189
+ def self.separator_left(frame, option)
190
+ TextEngine::VerticalLayout.frame_left(frame) +
191
+ (option&.rule_left_indent || 0).to_f
192
+ end
193
+ private_class_method :separator_left
194
+
195
+ def self.separator_width(frame, option)
196
+ option&.rule_width ||
197
+ TextEngine::VerticalLayout.wrap_width(frame)
198
+ end
199
+ private_class_method :separator_width
200
+
201
+ def self.stroke_rule(canvas, left, y, width, thickness)
202
+ canvas.stroke_color([:gray, 1.0])
203
+ canvas.line_width = thickness
204
+ canvas.move_to(left, y)
205
+ canvas.line_to(left + width, y)
206
+ canvas.stroke
207
+ end
208
+ private_class_method :stroke_rule
209
+ end
210
+ end
211
+ end
@@ -23,6 +23,7 @@ module Idml
23
23
  :chain_controller,
24
24
  :condition_filter,
25
25
  :style_lookup,
26
+ :text_wrap_resolver,
26
27
  keyword_init: true,
27
28
  )
28
29
  end
@@ -25,7 +25,6 @@ module Idml
25
25
  # FontMetrics is available (no shaper → no wrap → rough output).
26
26
  class TextFrameRenderer
27
27
  DEFAULT_SIZE = 12.0
28
- LEADING_FACTOR = 1.2
29
28
 
30
29
  def self.render(canvas, context)
31
30
  frame = context.item
@@ -36,6 +35,7 @@ module Idml
36
35
 
37
36
  box = frame_box(frame, context.page_height)
38
37
  render_inline_tables(canvas, story, box, context)
38
+ render_anchored_objects(canvas, story, context)
39
39
 
40
40
  state = initial_chain_state(frame, story, context)
41
41
  return unless state
@@ -72,7 +72,8 @@ module Idml
72
72
  def self.fresh_state(story, context)
73
73
  paragraphs = StyleResolver.extract_paragraphs(
74
74
  story, condition_filter: context.condition_filter,
75
- style_lookup: context.style_lookup
75
+ style_lookup: context.style_lookup,
76
+ footnote_option: Footnote.option(context.package)
76
77
  )
77
78
  return nil if paragraphs.empty?
78
79
 
@@ -102,14 +103,47 @@ module Idml
102
103
  private_class_method :render_inline_tables
103
104
 
104
105
  def self.tables_in_story(story)
106
+ story_csrs(story).flat_map(&:table).compact
107
+ end
108
+ private_class_method :tables_in_story
109
+
110
+ # All CharacterStyleRanges of a story, in document order.
111
+ # Shared by the story-level item discovery walks (tables,
112
+ # anchored objects).
113
+ def self.story_csrs(story)
105
114
  inner = story&.inner
106
115
  return [] unless inner
107
116
 
108
- inner.paragraph_style_range.flat_map do |psr|
109
- psr.character_style_range.flat_map(&:table)
110
- end.compact
117
+ inner.paragraph_style_range.flat_map(&:character_style_range)
111
118
  end
112
- private_class_method :tables_in_story
119
+ private_class_method :story_csrs
120
+
121
+ # Renders anchored page items embedded in the story flow
122
+ # (Story > PSR > CSR > Rectangle/Oval/Polygon/GraphicLine/
123
+ # Group/TextFrame — the IDML structure for anchored objects).
124
+ # Each item renders at its own stored geometry, which
125
+ # InDesign resolves to the anchored position when saving;
126
+ # AnchorType-specific text reflow is not simulated (the
127
+ # layout engine positions text independently either way).
128
+ def self.render_anchored_objects(canvas, story, context)
129
+ anchored_items_in_story(story).each do |item|
130
+ PageItemRenderer.render(canvas, context_for_item(context, item))
131
+ end
132
+ end
133
+ private_class_method :render_anchored_objects
134
+
135
+ def self.anchored_items_in_story(story)
136
+ story_csrs(story).flat_map do |csr|
137
+ [csr.rectangle, csr.oval, csr.polygon,
138
+ csr.graphic_line, csr.group, csr.text_frame]
139
+ end.flatten.compact
140
+ end
141
+ private_class_method :anchored_items_in_story
142
+
143
+ def self.context_for_item(context, item)
144
+ RenderContext.new(**context.to_h, item: item)
145
+ end
146
+ private_class_method :context_for_item
113
147
 
114
148
  def self.render_text(canvas, state, context, box)
115
149
  font = context.font_metrics
@@ -175,19 +209,23 @@ module Idml
175
209
  top_y = layout_frame.y + layout_frame.height -
176
210
  (layout_frame.inset_top || 0)
177
211
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
212
+ footnotes = []
178
213
  cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
179
214
  char_cursor = state.char_cursor
180
215
 
181
216
  if state.current_paragraph
182
217
  cursor_y, char_cursor = resume_paragraph(
183
218
  canvas, state, context, layout_frame, font,
184
- cursor_y, bottom_limit, char_cursor
219
+ cursor_y, bottom_limit, char_cursor, footnotes
185
220
  )
186
- return if state.current_paragraph
187
221
  end
188
222
 
189
- consume_paragraphs(canvas, state, context, layout_frame, font,
190
- cursor_y, bottom_limit, char_cursor)
223
+ unless state.current_paragraph
224
+ consume_paragraphs(canvas, state, context, layout_frame, font,
225
+ cursor_y, bottom_limit, char_cursor, footnotes)
226
+ end
227
+ render_footnote_entries(canvas, footnotes, layout_frame,
228
+ context, font)
191
229
  end
192
230
  private_class_method :engine_render_single_column
193
231
 
@@ -212,19 +250,23 @@ module Idml
212
250
  def self.render_column(canvas, state, context, col_frame, font)
213
251
  top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
214
252
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
253
+ footnotes = []
215
254
  cursor_y = top_y
216
255
  char_cursor = state.char_cursor
217
256
 
218
257
  if state.current_paragraph
219
258
  cursor_y, char_cursor = resume_paragraph(
220
259
  canvas, state, context, col_frame, font,
221
- cursor_y, bottom_limit, char_cursor
260
+ cursor_y, bottom_limit, char_cursor, footnotes
222
261
  )
223
- return if state.current_paragraph
224
262
  end
225
263
 
226
- consume_paragraphs(canvas, state, context, col_frame, font,
227
- cursor_y, bottom_limit, char_cursor)
264
+ unless state.current_paragraph
265
+ consume_paragraphs(canvas, state, context, col_frame, font,
266
+ cursor_y, bottom_limit, char_cursor, footnotes)
267
+ end
268
+ render_footnote_entries(canvas, footnotes, col_frame,
269
+ context, font)
228
270
  end
229
271
  private_class_method :render_column
230
272
 
@@ -343,12 +385,13 @@ module Idml
343
385
  private_class_method :space_before_after
344
386
 
345
387
  def self.resume_paragraph(canvas, state, context, layout_frame, font,
346
- cursor_y, bottom_limit, char_cursor)
388
+ cursor_y, bottom_limit, char_cursor,
389
+ footnotes)
347
390
  paragraph = state.current_paragraph
348
391
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
349
392
  canvas, paragraph, state.runs_remaining, context,
350
393
  layout_frame, font, cursor_y, bottom_limit, char_cursor,
351
- first_paragraph: true
394
+ first_paragraph: true, footnotes: footnotes
352
395
  )
353
396
  if remaining_runs.any?
354
397
  state.runs_remaining = remaining_runs
@@ -361,7 +404,8 @@ module Idml
361
404
  private_class_method :resume_paragraph
362
405
 
363
406
  def self.consume_paragraphs(canvas, state, context, layout_frame, font,
364
- cursor_y, bottom_limit, char_cursor)
407
+ cursor_y, bottom_limit, char_cursor,
408
+ footnotes)
365
409
  pending = 0
366
410
  state.paragraphs.each do |paragraph|
367
411
  break if cursor_y < bottom_limit
@@ -369,7 +413,7 @@ module Idml
369
413
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
370
414
  canvas, paragraph, paragraph.runs, context,
371
415
  layout_frame, font, cursor_y, bottom_limit, char_cursor,
372
- first_paragraph: pending.zero?
416
+ first_paragraph: pending.zero?, footnotes: footnotes
373
417
  )
374
418
  if remaining_runs.any?
375
419
  state.current_paragraph = paragraph
@@ -392,7 +436,7 @@ module Idml
392
436
  def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
393
437
  layout_frame, font, cursor_y,
394
438
  bottom_limit, char_cursor,
395
- first_paragraph:)
439
+ first_paragraph:, footnotes:)
396
440
  wrap_width = TextEngine::VerticalLayout.wrap_width(
397
441
  layout_frame, paragraph.right_indent || 0
398
442
  )
@@ -411,7 +455,7 @@ module Idml
411
455
  rendered, cursor_y, char_cursor = iterate_paragraph_runs(
412
456
  canvas, paragraph, effective_runs, context, layout_frame,
413
457
  font, wrap_width, cursor_y, bottom_limit, char_cursor,
414
- first_paragraph, para_attrs, dc_width, dc_lines
458
+ first_paragraph, para_attrs, dc_width, dc_lines, footnotes
415
459
  )
416
460
 
417
461
  remaining_runs = effective_runs[rendered..]
@@ -427,7 +471,7 @@ module Idml
427
471
  layout_frame, font, wrap_width,
428
472
  cursor_y, bottom_limit, char_cursor,
429
473
  first_paragraph, para_attrs,
430
- dc_width, dc_lines)
474
+ dc_width, dc_lines, footnotes)
431
475
  rendered_count = 0
432
476
  runs.each do |run|
433
477
  break if cursor_y < bottom_limit
@@ -436,20 +480,92 @@ module Idml
436
480
  rendered_count)
437
481
  run_wrap = drop_cap_wrap_width(wrap_width, dc_width,
438
482
  dc_lines, rendered_count)
483
+ run_wrap -= text_wrap_overlap(context, layout_frame, cursor_y,
484
+ run)
439
485
  positioned, next_y = layout_run(
440
486
  canvas, run, paragraph, context, layout_frame, font,
441
487
  run_wrap, cursor_y, space_before,
442
488
  para_attrs[:first_line_indent], para_attrs[:left_indent],
443
- char_cursor
489
+ char_cursor, bottom_limit
444
490
  )
445
491
  char_cursor += positioned.length
446
492
  cursor_y = next_y
447
493
  rendered_count += 1
494
+ bottom_limit = register_footnote(footnotes, run, layout_frame,
495
+ font, context, bottom_limit)
448
496
  end
449
497
  [rendered_count, cursor_y, char_cursor]
450
498
  end
451
499
  private_class_method :iterate_paragraph_runs
452
500
 
501
+ # Collects the footnote anchored by a marker run and raises
502
+ # the effective bottom limit to reserve room for all
503
+ # footnotes collected so far. Pass-through for regular runs.
504
+ def self.register_footnote(footnotes, run, layout_frame, font,
505
+ context, bottom_limit)
506
+ return bottom_limit unless run.footnote_paragraphs
507
+
508
+ footnotes << Footnote::Entry.new(
509
+ number: run.footnote_number,
510
+ paragraphs: run.footnote_paragraphs,
511
+ )
512
+ option = Footnote.option(context.package)
513
+ frame_bottom = TextEngine::VerticalLayout.bottom_limit(layout_frame)
514
+ reserved = Footnote.reserved_height(footnotes, font, layout_frame,
515
+ option)
516
+ frame_bottom + reserved
517
+ end
518
+ private_class_method :register_footnote
519
+
520
+ # Renders the collected footnotes at the bottom of the
521
+ # frame: separator rule at the area's top edge, then the
522
+ # footnote paragraphs (marker-prefixed at extraction)
523
+ # flowing downward to the content bottom.
524
+ def self.render_footnote_entries(canvas, footnotes, layout_frame,
525
+ context, font)
526
+ return if footnotes.empty?
527
+
528
+ option = Footnote.option(context.package)
529
+ content_bottom = TextEngine::VerticalLayout.bottom_limit(
530
+ layout_frame,
531
+ )
532
+ area_top = content_bottom +
533
+ Footnote.reserved_height(footnotes, font, layout_frame, option)
534
+
535
+ Footnote.emit_separator(canvas, layout_frame, area_top, option)
536
+ positioned, = Footnote.layout_entries(
537
+ footnotes, layout_frame, font,
538
+ area_top - Footnote.rule_gap(option), option
539
+ )
540
+ positioned.each do |item|
541
+ next if item.line.y < content_bottom
542
+
543
+ emit_line(canvas, item.line, item.run, context, item.font_size)
544
+ end
545
+ end
546
+ private_class_method :render_footnote_entries
547
+
548
+ # Computes the text-wrap overlap for a run at its current y
549
+ # position. Uses the run's point_size as the line height
550
+ # approximation. Returns 0.0 when no resolver is wired or no
551
+ # contours overlap. Per-run approximation: all lines in the
552
+ # run get the same reduced width (correct when the run is
553
+ # fully inside or outside the contour; approximate when it
554
+ # spans the boundary).
555
+ def self.text_wrap_overlap(context, layout_frame, cursor_y, run)
556
+ resolver = context.text_wrap_resolver
557
+ return 0.0 unless resolver
558
+
559
+ size = run.point_size || DEFAULT_SIZE
560
+ frame_x = layout_frame.x + (layout_frame.inset_left || 0)
561
+ frame_right = layout_frame.x + layout_frame.width -
562
+ (layout_frame.inset_right || 0)
563
+ overlap = resolver.overlap_width(cursor_y, size,
564
+ frame_x, frame_right)
565
+ [overlap, 0.0].max
566
+ end
567
+ private_class_method :text_wrap_overlap
568
+
453
569
  # Strips drop cap characters from the first run so they don't
454
570
  # render twice (once as the enlarged drop cap, once as normal
455
571
  # text). Returns a new run list with the first run's text
@@ -571,7 +687,7 @@ module Idml
571
687
  def self.layout_run(canvas, run, paragraph, context, layout_frame,
572
688
  font, wrap_width, cursor_y, space_before,
573
689
  first_line_indent, left_indent,
574
- char_cursor)
690
+ char_cursor, bottom_limit)
575
691
  size = run.point_size || DEFAULT_SIZE
576
692
  glyphs = TextEngine::Shaper.shape(
577
693
  text: run.text, font: font, size: size,
@@ -598,7 +714,6 @@ module Idml
598
714
  left_indent: left_indent,
599
715
  )
600
716
 
601
- bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
602
717
  positioned.each do |line|
603
718
  break if line.y < bottom_limit
604
719
 
@@ -641,10 +756,7 @@ module Idml
641
756
  private_class_method :emit_line
642
757
 
643
758
  def self.leading_for(paragraph, size)
644
- explicit = paragraph.auto_leading
645
- return size * LEADING_FACTOR unless explicit&.positive?
646
-
647
- size * explicit
759
+ TextEngine::VerticalLayout.leading_for(paragraph.auto_leading, size)
648
760
  end
649
761
  private_class_method :leading_for
650
762
 
@@ -39,6 +39,7 @@ module Idml
39
39
  chain_controller: StoryChainController.new,
40
40
  condition_filter: @condition_filter,
41
41
  style_lookup: @style_lookup,
42
+ text_wrap_resolver: build_text_wrap_resolver(spread, page_height),
42
43
  }
43
44
  images_by_parent = group_images_by_parent(image_refs)
44
45
 
@@ -144,6 +145,10 @@ module Idml
144
145
 
145
146
  ColorResolver.new(@package.graphic)
146
147
  end
148
+
149
+ def build_text_wrap_resolver(spread, page_height)
150
+ TextWrapResolver.build(spread, page_height: page_height)
151
+ end
147
152
  end
148
153
  end
149
154
  end
@@ -18,7 +18,9 @@ module Idml
18
18
  class StyleResolver
19
19
  # Character-level attributes carried per run. Defaults are
20
20
  # applied at render time (e.g., DEFAULT_POINT_SIZE) — the Struct
21
- # itself stores only what the CSR declares.
21
+ # itself stores only what the CSR declares. `footnote_number`
22
+ # and `footnote_paragraphs` are set only on synthesized
23
+ # footnote-marker runs (see Render::Footnote).
22
24
  StyledRun = Struct.new(
23
25
  :text,
24
26
  :font_style,
@@ -39,6 +41,8 @@ module Idml
39
41
  :horizontal_scale,
40
42
  :vertical_scale,
41
43
  :baseline_shift,
44
+ :footnote_number,
45
+ :footnote_paragraphs,
42
46
  keyword_init: true,
43
47
  )
44
48
 
@@ -98,27 +102,48 @@ module Idml
98
102
 
99
103
  # Returns the flat run list for a story. Each run carries only
100
104
  # character-level attributes. Paragraph boundaries are lost.
105
+ # Footnote markers number sequentially across the story.
101
106
  def self.extract_runs(story)
102
107
  return [] unless story&.inner
103
108
 
109
+ counter = Footnote.counter_for(nil)
104
110
  story.inner.paragraph_style_range.flat_map do |psr|
105
- csr_runs(psr)
111
+ csr_runs(psr, footnote_counter: counter)
106
112
  end
107
113
  end
108
114
 
109
115
  # Returns the paragraph list for a story. Each paragraph groups
110
116
  # its runs and carries the paragraph-level attributes from the
111
117
  # owning PSR. Empty paragraphs (no runs after filtering) are
112
- # skipped.
118
+ # skipped. Footnotes anchored in the story emit superscript
119
+ # marker runs, numbered sequentially from the FootnoteOption's
120
+ # StartAt (default 1).
113
121
  def self.extract_paragraphs(story, condition_filter: nil,
114
- style_lookup: nil)
122
+ style_lookup: nil, footnote_option: nil)
115
123
  return [] unless story&.inner
116
124
 
117
- story.inner.paragraph_style_range.filter_map do |psr|
125
+ extract_container_paragraphs(
126
+ story.inner,
127
+ condition_filter: condition_filter, style_lookup: style_lookup,
128
+ footnote_option: footnote_option
129
+ )
130
+ end
131
+
132
+ # Extracts paragraphs from any container exposing
133
+ # `paragraph_style_range` (StoryInner, Elements::Footnote).
134
+ # Footnote-counter state is shared across the whole walk so
135
+ # footnotes number in document order.
136
+ def self.extract_container_paragraphs(container, condition_filter: nil,
137
+ style_lookup: nil,
138
+ footnote_option: nil)
139
+ counter = Footnote.counter_for(footnote_option)
140
+ container.paragraph_style_range.filter_map do |psr|
118
141
  next unless condition_visible?(psr, condition_filter)
119
142
 
120
143
  runs = csr_runs(psr, condition_filter: condition_filter,
121
- style_lookup: style_lookup)
144
+ style_lookup: style_lookup,
145
+ footnote_counter: counter,
146
+ footnote_option: footnote_option)
122
147
  next if runs.empty?
123
148
 
124
149
  paragraph = Paragraph.new(
@@ -177,43 +202,73 @@ module Idml
177
202
  end
178
203
  end
179
204
 
180
- def self.csr_runs(psr, condition_filter: nil, style_lookup: nil)
181
- psr.character_style_range.filter_map do |csr|
182
- next unless condition_visible?(csr, condition_filter)
205
+ def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
206
+ footnote_counter: nil, footnote_option: nil)
207
+ counter = footnote_counter || Footnote.counter_for(footnote_option)
208
+ psr.character_style_range.flat_map do |csr|
209
+ next [] unless condition_visible?(csr, condition_filter)
183
210
 
184
- text = csr.text_content
185
- next if text.nil? || text.empty?
211
+ csr_content_runs(csr, condition_filter: condition_filter,
212
+ style_lookup: style_lookup,
213
+ footnote_counter: counter,
214
+ footnote_option: footnote_option)
215
+ end
216
+ end
186
217
 
187
- StyledRun.new(
188
- text: text,
189
- font_style: resolve_csr(style_lookup, csr, :font_style),
190
- point_size: resolve_csr(style_lookup, csr, :point_size) ||
191
- DEFAULT_POINT_SIZE,
192
- fill_color: resolve_csr(style_lookup, csr, :fill_color),
193
- fill_tint: resolve_csr(style_lookup, csr, :fill_tint),
194
- applied_font: resolve_csr(style_lookup, csr, :applied_font),
195
- alignment: alignment_for(csr),
196
- tracking: resolve_csr(style_lookup, csr, :tracking),
197
- capitalization: resolve_csr(style_lookup, csr, :capitalization),
198
- position: resolve_csr(style_lookup, csr, :position),
199
- underline: resolve_csr(style_lookup, csr, :underline),
200
- underline_offset: resolve_csr(style_lookup, csr,
201
- :underline_offset),
202
- underline_weight: resolve_csr(style_lookup, csr,
203
- :underline_weight),
204
- strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
205
- strike_through_offset: resolve_csr(style_lookup, csr,
206
- :strike_through_offset),
207
- strike_through_weight: resolve_csr(style_lookup, csr,
208
- :strike_through_weight),
209
- horizontal_scale: resolve_csr(style_lookup, csr,
210
- :horizontal_scale),
211
- vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
212
- baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
218
+ # Builds the runs for one CSR: its text run (when non-empty)
219
+ # plus one superscript marker run per anchored Footnote. A CSR
220
+ # holding only a footnote still emits its marker.
221
+ def self.csr_content_runs(csr, condition_filter:, style_lookup:,
222
+ footnote_counter:, footnote_option:)
223
+ text = csr.text_content
224
+ runs = []
225
+ unless text.nil? || text.empty?
226
+ runs << text_run(csr, style_lookup: style_lookup)
227
+ end
228
+ csr.footnote.each do |element|
229
+ number = footnote_counter.next_number
230
+ paragraphs = Footnote.extract(
231
+ element, number, condition_filter: condition_filter,
232
+ style_lookup: style_lookup,
233
+ option: footnote_option
213
234
  )
235
+ runs << Footnote.marker_run(number, runs.last, paragraphs,
236
+ footnote_option)
214
237
  end
238
+ runs
239
+ end
240
+ private_class_method :csr_content_runs
241
+
242
+ def self.text_run(csr, style_lookup:)
243
+ StyledRun.new(
244
+ text: csr.text_content,
245
+ font_style: resolve_csr(style_lookup, csr, :font_style),
246
+ point_size: resolve_csr(style_lookup, csr, :point_size) ||
247
+ DEFAULT_POINT_SIZE,
248
+ fill_color: resolve_csr(style_lookup, csr, :fill_color),
249
+ fill_tint: resolve_csr(style_lookup, csr, :fill_tint),
250
+ applied_font: resolve_csr(style_lookup, csr, :applied_font),
251
+ alignment: alignment_for(csr),
252
+ tracking: resolve_csr(style_lookup, csr, :tracking),
253
+ capitalization: resolve_csr(style_lookup, csr, :capitalization),
254
+ position: resolve_csr(style_lookup, csr, :position),
255
+ underline: resolve_csr(style_lookup, csr, :underline),
256
+ underline_offset: resolve_csr(style_lookup, csr,
257
+ :underline_offset),
258
+ underline_weight: resolve_csr(style_lookup, csr,
259
+ :underline_weight),
260
+ strike_thru: resolve_csr(style_lookup, csr, :strike_thru),
261
+ strike_through_offset: resolve_csr(style_lookup, csr,
262
+ :strike_through_offset),
263
+ strike_through_weight: resolve_csr(style_lookup, csr,
264
+ :strike_through_weight),
265
+ horizontal_scale: resolve_csr(style_lookup, csr,
266
+ :horizontal_scale),
267
+ vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
268
+ baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
269
+ )
215
270
  end
216
- private_class_method :csr_runs
271
+ private_class_method :text_run
217
272
 
218
273
  # Resolves a paragraph-level attribute from PSR or its
219
274
  # referenced ParagraphStyle. When style_lookup is nil,
@@ -66,16 +66,35 @@ module Idml
66
66
  end
67
67
  private_class_method :contour_for
68
68
 
69
+ # Page item types that can declare TextWrapPreference per the
70
+ # Spread schema. Used to guard the attribute read — querying
71
+ # non-shape items (Page, Link) would raise NoMethodError.
72
+ WRAPPABLE_TYPES = [
73
+ Idml::Elements::Rectangle,
74
+ Idml::Elements::Oval,
75
+ Idml::Elements::Polygon,
76
+ Idml::Elements::GraphicLine,
77
+ Idml::Elements::Path,
78
+ Idml::Elements::Group,
79
+ ].freeze
80
+
69
81
  def self.wrap_preference(item)
70
- pref = item.text_wrap_preference if item.is_a?(Lutaml::Model::Serializable)
71
- return nil unless pref
72
- return nil if pref.text_wrap_mode.nil? ||
82
+ return nil unless wrappable?(item)
83
+
84
+ pref = item.text_wrap_preference
85
+ return nil if pref.nil? ||
86
+ pref.text_wrap_mode.nil? ||
73
87
  pref.text_wrap_mode == "None"
74
88
 
75
89
  pref
76
90
  end
77
91
  private_class_method :wrap_preference
78
92
 
93
+ def self.wrappable?(item)
94
+ WRAPPABLE_TYPES.any? { |type| item.is_a?(type) }
95
+ end
96
+ private_class_method :wrappable?
97
+
79
98
  def self.bounding_box_mode?(pref)
80
99
  pref.text_wrap_mode == "BoundingBox"
81
100
  end
data/lib/idml/render.rb CHANGED
@@ -22,6 +22,7 @@ module Idml
22
22
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
23
23
  autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
24
24
  autoload :TextWrapResolver, "#{__dir__}/render/text_wrap_resolver"
25
+ autoload :Footnote, "#{__dir__}/render/footnote"
25
26
  autoload :Placement, "#{__dir__}/render/placement"
26
27
  autoload :ImageCollector, "#{__dir__}/render/image_collector"
27
28
  autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
@@ -54,6 +54,17 @@ module Idml
54
54
  frame.y + (frame.inset_bottom || 0)
55
55
  end
56
56
 
57
+ # Leading for a text block: AutoLeading × size when the
58
+ # paragraph declares a positive AutoLeading, else the default
59
+ # factor. Shared by the frame renderer and the footnote
60
+ # layout so both stack lines identically.
61
+ def self.leading_for(auto_leading, font_size)
62
+ return font_size * DEFAULT_LEADING_FACTOR unless
63
+ auto_leading&.positive?
64
+
65
+ font_size * auto_leading
66
+ end
67
+
57
68
  # Effective wrap width after subtracting insets and indents.
58
69
  def self.wrap_width(frame, right_indent = 0)
59
70
  width = frame.width - (frame.inset_left || 0) - (frame.inset_right || 0)
@@ -63,7 +74,6 @@ module Idml
63
74
  def self.frame_left(frame)
64
75
  frame.x + (frame.inset_left || 0)
65
76
  end
66
- private_class_method :frame_left
67
77
  end
68
78
  end
69
79
  end
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.8.6"
4
+ VERSION = "0.8.8"
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.8.6
4
+ version: 0.8.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -296,6 +296,7 @@ files:
296
296
  - lib/idml/elements/flex_layout_attribute_option.rb
297
297
  - lib/idml/elements/font.rb
298
298
  - lib/idml/elements/font_family.rb
299
+ - lib/idml/elements/footnote.rb
299
300
  - lib/idml/elements/frame_fitting_option.rb
300
301
  - lib/idml/elements/geometry_path_type.rb
301
302
  - lib/idml/elements/gradient.rb
@@ -441,6 +442,7 @@ files:
441
442
  - lib/idml/render/drop_cap.rb
442
443
  - lib/idml/render/font_reference_resolver.rb
443
444
  - lib/idml/render/font_setup.rb
445
+ - lib/idml/render/footnote.rb
444
446
  - lib/idml/render/gradient_resolver.rb
445
447
  - lib/idml/render/hyperlink_emitter.rb
446
448
  - lib/idml/render/hyperlink_resolver.rb