idml 0.10.0 → 0.10.1

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: 13770ed8937eeed41a40bd223bb8caadc833da870858d691caa0e1806058c286
4
- data.tar.gz: dec0fe8f24c1aaa8258ecf18506560b1e0793bd7d4ebe56bf361dc1a69ff736a
3
+ metadata.gz: e7360c896ca2b95a5fa92dd030c71e3170b0f26039f5b48938f3ba038405bc46
4
+ data.tar.gz: 1ce5fccd66f88f3dc5a25289e844cdfd99b391279f8bf8f659d74de9488c866e
5
5
  SHA512:
6
- metadata.gz: '0394efac9722c854cd4e8c2771d0ec494aaa8d3b50ca6ea471bbe8f5f44daf62a1f1bd4d12031ee9d59b0d7b6c5f65f5378db661d5a110d056734958369a870e'
7
- data.tar.gz: 07d3a9492ea5a41819f4fe0353176671307ad7263feedeb909bd08f8284eb79b84df0fef0057db1d089a3a02ff7d255459ea2e232e5c929d3d1ffccc70685e81
6
+ metadata.gz: e9aaaa8864f60931ebf50be726503f2b9db6313135262aa6d740d6fda4e06a57b03b8970aaaa87eddcd29a82e562cf0bd0ee56df227c2729782f276bca447e06
7
+ data.tar.gz: b4a07f57002002cc027fb1459b9524107f1fc309a1d34cbb23575416054488589e355b4bc0a548ee59f6d69e50225dc2a8a021f4848007d9f04820b3925af778
@@ -1,6 +1,8 @@
1
1
  # TODO PDF 114: Text wrap around shapes (TextWrapPreference)
2
2
 
3
- ## Status: DONE for BoundingBox mode `Render::TextWrapResolver` computes
3
+ ## Status: COMPLETE for BoundingBoxTextWrap + Contour (Contour and
4
+ the schema-spelling enum fix landed 2026-08-23 as TODO 130; Inverse
5
+ mode remains open) — `Render::TextWrapResolver` computes
4
6
  wrap contours from page items declaring TextWrapPreference with
5
7
  TextWrapMode != "None". SpreadRenderer builds the resolver and passes
6
8
  it via RenderContext. TextFrameRenderer's `text_wrap_overlap` queries
@@ -0,0 +1,38 @@
1
+ # TODO PDF 130: Shape-mode (Contour) text wrap
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-23
4
+
5
+ ## Problem
6
+
7
+ Two gaps in the text wrap (TODO 114):
8
+
9
+ 1. The TextWrapMode dispatch compared against "BoundingBox" — the
10
+ schema enum (TextWrapModes_EnumValue in datatype.rnc) spells it
11
+ "BoundingBoxTextWrap", so REAL documents never matched any mode
12
+ and text never wrapped.
13
+ 2. Contour mode (text following the item's actual shape) was not
14
+ implemented at all.
15
+
16
+ ## What was done
17
+
18
+ - Enum fix: BoundingBoxTextWrap accepted (legacy "BoundingBox"
19
+ spelling kept for the synthetic fixtures); JumpObjectTextWrap /
20
+ NextColumnTextWrap approximate as the bounding box (the per-run
21
+ width reduction already pushes text past the object);
22
+ Contour gets real contour following.
23
+ - `Render::WrapContour`: flattens the item's PathGeometry (Bézier
24
+ segments sampled at 8 steps, ItemTransform applied, y flipped)
25
+ into PDF-space polygons — one per subpath, so compound paths
26
+ with holes work via even-odd ray casting at the text band's
27
+ midline. The overlap intervals expand by the TextWrapOffset
28
+ bounds (`Properties > TextWrapOffset`, now modeled on
29
+ TextWrapPreference via TypedValue) and clip to the frame.
30
+ - `TextWrapResolver` dispatches per mode; overlap_width handles
31
+ both the box rect and polygon shapes.
32
+
33
+ ## Known limitations
34
+
35
+ - Band coverage samples the midline (sub-half-line-height notches
36
+ are missed); offsets expand intervals uniformly rather than
37
+ offsetting the polygon geometrically; Inverse mode is not
38
+ implemented (falls back to no wrap).
@@ -22,8 +22,9 @@ this list reflects the current state.
22
22
  (TODO 128); CapHeight / XHeight approximate as leading.
23
23
  - **StartParagraph** breaks act at frame/column granularity — no
24
24
  odd/even page parity.
25
- - **Text wrap** (TODO 114): BoundingBox mode only; Shape contour
26
- following and Inverse mode are not implemented.
25
+ - **Text wrap**: BoundingBoxTextWrap and Contour modes render
26
+ (TODO 130); JumpObject/NextColumn approximate as the box;
27
+ Inverse mode is not implemented.
27
28
 
28
29
  ### CJK
29
30
  - **Mojikumi**: CJK/Latin auto script spacing is applied (TODO
@@ -16,6 +16,7 @@ module Idml
16
16
  attribute :rule_below_color, Idml::Elements::TypedValue
17
17
  attribute :paragraph_shading_color, Idml::Elements::TypedValue
18
18
  attribute :paragraph_border_color, Idml::Elements::TypedValue
19
+ attribute :text_wrap_offset, Idml::Elements::TypedValue
19
20
  attribute :paragraph_border_gap_color, Idml::Elements::TypedValue
20
21
 
21
22
  xml do
@@ -26,6 +27,7 @@ module Idml
26
27
  map_element "RuleBelowColor", to: :rule_below_color
27
28
  map_element "ParagraphShadingColor", to: :paragraph_shading_color
28
29
  map_element "ParagraphBorderColor", to: :paragraph_border_color
30
+ map_element "TextWrapOffset", to: :text_wrap_offset
29
31
  map_element "ParagraphBorderGapColor", to: :paragraph_border_gap_color
30
32
  end
31
33
 
@@ -11,6 +11,7 @@ module Idml
11
11
  attribute :apply_to_master_page_only, :boolean
12
12
  attribute :text_wrap_side, :string
13
13
  attribute :text_wrap_mode, :string
14
+ attribute :properties, Idml::Elements::Properties, collection: true
14
15
 
15
16
  xml do
16
17
  root "TextWrapPreference"
@@ -18,6 +19,7 @@ module Idml
18
19
  map_attribute "ApplyToMasterPageOnly", to: :apply_to_master_page_only
19
20
  map_attribute "TextWrapSide", to: :text_wrap_side
20
21
  map_attribute "TextWrapMode", to: :text_wrap_mode
22
+ map_element "Properties", to: :properties
21
23
  end
22
24
  end
23
25
  end
@@ -43,17 +43,36 @@ module Idml
43
43
  # Returns 0.0 when there's no overlap.
44
44
  def overlap_width(line_y, line_height, frame_x, frame_right)
45
45
  @contours.sum do |contour|
46
- overlap = line_overlap(contour, line_y, line_height,
47
- frame_x, frame_right)
48
- overlap[:width]
46
+ if contour.is_a?(WrapContour::Shape)
47
+ WrapContour.overlap_width(contour, line_y, line_height,
48
+ frame_x, frame_right)
49
+ else
50
+ line_overlap(contour, line_y, line_height,
51
+ frame_x, frame_right)[:width]
52
+ end
49
53
  end
50
54
  end
51
55
 
56
+ # The wrap shape for an item: a bounding-box rectangle
57
+ # (BoundingBoxTextWrap — the legacy "BoundingBox" spelling is
58
+ # also accepted from early synthetic fixtures) or a flattened
59
+ # PathGeometry polygon (Contour mode). JumpObject /
60
+ # NextColumn modes approximate as the bounding box (the
61
+ # per-run width reduction already pushes text past the
62
+ # object).
52
63
  def self.contour_for(item, page_height)
53
64
  pref = wrap_preference(item)
54
65
  return nil unless pref
55
- return nil unless bounding_box_mode?(pref)
56
66
 
67
+ if contour_mode?(pref)
68
+ WrapContour.shape(item, pref, page_height)
69
+ else
70
+ box_contour(item, page_height)
71
+ end
72
+ end
73
+ private_class_method :contour_for
74
+
75
+ def self.box_contour(item, page_height)
57
76
  bounds = item.geometric_bounds
58
77
  return nil unless bounds
59
78
 
@@ -64,7 +83,7 @@ module Idml
64
83
  width: rect[:width], height: rect[:height]
65
84
  )
66
85
  end
67
- private_class_method :contour_for
86
+ private_class_method :box_contour
68
87
 
69
88
  # Page item types that can declare TextWrapPreference per the
70
89
  # Spread schema. Used to guard the attribute read — querying
@@ -95,10 +114,10 @@ module Idml
95
114
  end
96
115
  private_class_method :wrappable?
97
116
 
98
- def self.bounding_box_mode?(pref)
99
- pref.text_wrap_mode == "BoundingBox"
117
+ def self.contour_mode?(pref)
118
+ pref.text_wrap_mode == "Contour"
100
119
  end
101
- private_class_method :bounding_box_mode?
120
+ private_class_method :contour_mode?
102
121
 
103
122
  def line_overlap(contour, line_y, line_height, frame_x, frame_right)
104
123
  return { width: 0.0 } unless y_overlap?(contour, line_y, line_height)
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Shape-mode text wrap: builds a PDF-space polygon from a page
6
+ # item's PathGeometry (Bézier segments flattened at a fixed
7
+ # resolution) and measures the horizontal overlap of a text
8
+ # line's band with the polygon, expanded by the wrap offsets.
9
+ # Band coverage uses even-odd ray casting at the band's
10
+ # midline, so compound paths with holes wrap correctly.
11
+ module WrapContour
12
+ FLATTEN_STEPS = 8
13
+
14
+ # A wrap shape: flattened polygons (one per subpath) plus the
15
+ # TextWrapOffset bounds.
16
+ Shape = Struct.new(:polygons, :offset, keyword_init: true)
17
+
18
+ # Builds the wrap shape for an item's PathGeometry and wrap
19
+ # preference. Returns nil when the item has no geometry.
20
+ def self.shape(item, pref, page_height)
21
+ polygons = polygons_for(item, page_height)
22
+ return nil if polygons.empty?
23
+
24
+ Shape.new(polygons: polygons, offset: wrap_offset(pref))
25
+ end
26
+
27
+ def self.polygons_for(item, page_height)
28
+ transform = Geometry.parse_transform(item.item_transform)
29
+ paths = item.properties.flat_map do |props|
30
+ props.path_geometry.flat_map(&:geometry_path_type)
31
+ end
32
+ paths.filter_map do |path|
33
+ points = flatten_path(path, transform, page_height)
34
+ points.length >= 3 ? points : nil
35
+ end
36
+ end
37
+ private_class_method :polygons_for
38
+
39
+ # Flattens one subpath's Bézier segments into line segments
40
+ # in PDF space (ItemTransform applied, y flipped).
41
+ def self.flatten_path(path, transform, page_height)
42
+ anchors = path.points
43
+ return [] if anchors.length < 2
44
+
45
+ open = path.path_open == true
46
+ segments = open ? anchors.length - 1 : anchors.length
47
+ points = [pdf_point(anchors.first, transform, page_height)]
48
+ segments.times do |index|
49
+ from = anchors[index]
50
+ to = anchors[(index + 1) % anchors.length]
51
+ append_segment(points, from, to, transform, page_height)
52
+ end
53
+ points
54
+ end
55
+ private_class_method :flatten_path
56
+
57
+ def self.append_segment(points, from, to, transform, page_height)
58
+ c1 = pair_or_nil(from, :right_direction, transform, page_height)
59
+ c2 = pair_or_nil(to, :left_direction, transform, page_height)
60
+ start = points.last
61
+ if c1.nil? && c2.nil?
62
+ points << pdf_point(to, transform, page_height)
63
+ return
64
+ end
65
+
66
+ c1 ||= start
67
+ c2 ||= pdf_point(to, transform, page_height)
68
+ (1..FLATTEN_STEPS).each do |step|
69
+ t = step.to_f / FLATTEN_STEPS
70
+ points << bezier_point(start, c1, c2,
71
+ pdf_point(to, transform, page_height), t)
72
+ end
73
+ end
74
+ private_class_method :append_segment
75
+
76
+ def self.bezier_point(p0, p1, p2, p3, t)
77
+ u = 1 - t
78
+ w0 = u * u * u
79
+ w1 = 3 * u * u * t
80
+ w2 = 3 * u * t * t
81
+ w3 = t * t * t
82
+ [
83
+ (w0 * p0[0]) + (w1 * p1[0]) + (w2 * p2[0]) + (w3 * p3[0]),
84
+ (w0 * p0[1]) + (w1 * p1[1]) + (w2 * p2[1]) + (w3 * p3[1]),
85
+ ]
86
+ end
87
+ private_class_method :bezier_point
88
+
89
+ def self.pair_or_nil(point, direction_attr, transform, page_height)
90
+ raw = point.public_send(direction_attr)
91
+ raw = nil if raw && raw.strip.empty?
92
+ raw && pdf_pair(raw, transform, page_height)
93
+ end
94
+ private_class_method :pair_or_nil
95
+
96
+ def self.pdf_point(point, transform, page_height)
97
+ pdf_pair(point.anchor, transform, page_height)
98
+ end
99
+ private_class_method :pdf_point
100
+
101
+ def self.pdf_pair(raw, transform, page_height)
102
+ x, y = raw.to_s.split(/\s+/).map(&:to_f)
103
+ tx, ty = Geometry.apply_transform(transform, x, y)
104
+ [tx, page_height - ty]
105
+ end
106
+ private_class_method :pdf_pair
107
+
108
+ # The wrap offsets from Properties > TextWrapOffset
109
+ # ("top left bottom right"); zero when absent.
110
+ def self.wrap_offset(pref)
111
+ raw = pref.properties.first&.text_wrap_offset&.value
112
+ parts = raw.to_s.split(/\s+/).map(&:to_f)
113
+ return [0.0, 0.0] if parts.length < 4
114
+
115
+ [parts[1], parts[3]] # left, right
116
+ end
117
+ private_class_method :wrap_offset
118
+
119
+ # Total horizontal overlap of the text line's band with the
120
+ # shape, expanded by the offsets and clipped to the frame.
121
+ def self.overlap_width(shape, line_y, line_height, frame_x,
122
+ frame_right)
123
+ mid_y = line_y + (line_height / 2.0)
124
+ width = shape.polygons.sum do |polygon|
125
+ polygon_overlap(polygon, mid_y, shape.offset)
126
+ end
127
+ clip_width(width, frame_x, frame_right)
128
+ end
129
+
130
+ # Even-odd intervals at mid_y expanded by [left, right]
131
+ # offsets, summed (separate subpaths sum; the common case is
132
+ # one polygon).
133
+ def self.polygon_overlap(polygon, mid_y, offset)
134
+ crossings = edge_crossings(ring(polygon), mid_y).sort
135
+ return 0.0 if crossings.length < 2
136
+
137
+ left, right = offset
138
+ crossings.each_slice(2).sum do |entry_x, exit_x|
139
+ (exit_x + right) - (entry_x - left)
140
+ end
141
+ end
142
+ private_class_method :polygon_overlap
143
+
144
+ # Closes the polygon ring (each_cons alone misses the
145
+ # last→first edge).
146
+ def self.ring(polygon)
147
+ polygon + [polygon.first]
148
+ end
149
+ private_class_method :ring
150
+
151
+ def self.edge_crossings(points, mid_y)
152
+ points.each_cons(2).filter_map do |(x1, y1), (x2, y2)|
153
+ next unless crosses_midline?(y1, y2, mid_y)
154
+
155
+ t = (mid_y - y1) / (y2 - y1)
156
+ x1 + (t * (x2 - x1))
157
+ end
158
+ end
159
+ private_class_method :edge_crossings
160
+
161
+ def self.crosses_midline?(y1, y2, mid_y)
162
+ (y1 <= mid_y && y2 > mid_y) || (y2 <= mid_y && y1 > mid_y)
163
+ end
164
+ private_class_method :crosses_midline?
165
+
166
+ def self.clip_width(width, frame_x, frame_right)
167
+ [width, frame_right - frame_x].min
168
+ end
169
+ private_class_method :clip_width
170
+ end
171
+ end
172
+ end
data/lib/idml/render.rb CHANGED
@@ -25,6 +25,7 @@ module Idml
25
25
  autoload :Footnote, "#{__dir__}/render/footnote"
26
26
  autoload :ShapePaint, "#{__dir__}/render/shape_paint"
27
27
  autoload :Contour, "#{__dir__}/render/contour"
28
+ autoload :WrapContour, "#{__dir__}/render/wrap_contour"
28
29
  autoload :Placement, "#{__dir__}/render/placement"
29
30
  autoload :ImageCollector, "#{__dir__}/render/image_collector"
30
31
  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.10.0"
4
+ VERSION = "0.10.1"
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.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -189,6 +189,7 @@ files:
189
189
  - TODO.pdf/128-first-baseline-offset.md
190
190
  - TODO.pdf/129-justification-glyph-scaling.md
191
191
  - TODO.pdf/13-cjk-text-layout.md
192
+ - TODO.pdf/130-contour-text-wrap.md
192
193
  - TODO.pdf/14-page-item-element-models.md
193
194
  - TODO.pdf/15-wire-spread-children.md
194
195
  - TODO.pdf/16-typed-model-pipeline.md
@@ -497,6 +498,7 @@ files:
497
498
  - lib/idml/render/style_lookup.rb
498
499
  - lib/idml/render/style_resolver.rb
499
500
  - lib/idml/render/text_wrap_resolver.rb
501
+ - lib/idml/render/wrap_contour.rb
500
502
  - lib/idml/text_engine.rb
501
503
  - lib/idml/text_engine/cjk_layout.rb
502
504
  - lib/idml/text_engine/justifier.rb