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 +4 -4
- data/TODO.pdf/114-text-wrap-around-shapes.md +3 -1
- data/TODO.pdf/130-contour-text-wrap.md +38 -0
- data/TODO.pdf/61-known-limitations.md +3 -2
- data/lib/idml/elements/properties.rb +2 -0
- data/lib/idml/elements/text_wrap_preference.rb +2 -0
- data/lib/idml/render/text_wrap_resolver.rb +27 -8
- data/lib/idml/render/wrap_contour.rb +172 -0
- data/lib/idml/render.rb +1 -0
- data/lib/idml/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7360c896ca2b95a5fa92dd030c71e3170b0f26039f5b48938f3ba038405bc46
|
|
4
|
+
data.tar.gz: 1ce5fccd66f88f3dc5a25289e844cdfd99b391279f8bf8f659d74de9488c866e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 :
|
|
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.
|
|
99
|
-
pref.text_wrap_mode == "
|
|
117
|
+
def self.contour_mode?(pref)
|
|
118
|
+
pref.text_wrap_mode == "Contour"
|
|
100
119
|
end
|
|
101
|
-
private_class_method :
|
|
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
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.
|
|
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
|