idml 0.9.9 → 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: fd4031c47fca99598c64ed757ac446d3e1f2d2870603eb31af338132dae01288
4
- data.tar.gz: 520a5ded7c74cfc50d0f893921486930c295b113f7e0d3c7af4dfb576186498c
3
+ metadata.gz: e7360c896ca2b95a5fa92dd030c71e3170b0f26039f5b48938f3ba038405bc46
4
+ data.tar.gz: 1ce5fccd66f88f3dc5a25289e844cdfd99b391279f8bf8f659d74de9488c866e
5
5
  SHA512:
6
- metadata.gz: bad5a41e54b9e173241e08ba9e7006e6f621230ff782e89e4821d6b26749ee11b30fe8a38dae5185ee1cb41e4497ec20f32267584852cac43c9a37e41f488990
7
- data.tar.gz: a62b908ae372636a3dec1722265ee44b24ab847510b348dd0029a3d04b388e41c6ad708f53c11ba21d7077817cfa831d700626ee73cc0380e46bfcb02e7ea58d
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,21 @@
1
+ # TODO PDF 128: Text frame FirstBaselineOffset
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-23
4
+
5
+ ## What was done
6
+
7
+ `TextFrameRenderer` honors
8
+ `TextFramePreference.FirstBaselineOffset` when positioning the
9
+ first line's baseline: AscentOffset places it ascent-below the top
10
+ inset (typically higher than the default leading-based position);
11
+ FixedHeight uses MinimumFirstBaselineOffset. LeadingOffset /
12
+ CapHeight / XHeight / EmboxHeight approximate as the existing
13
+ leading-based default (cap/x-height metrics are not exposed by
14
+ PdfrbFontMetrics). Applied in both the single-column and
15
+ multi-column paths.
16
+
17
+ ## Known limitations
18
+
19
+ - CapHeight / XHeight offsets fall back to leading (font metrics
20
+ expose ascent only).
21
+ - Not applied in vertical writing mode.
@@ -0,0 +1,18 @@
1
+ # TODO PDF 129: Justification glyph scaling
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-23
4
+
5
+ ## What was done
6
+
7
+ `Justifier` distributes any residual slack remaining after the
8
+ word-spacing and letter-spacing caps as uniform glyph scaling,
9
+ capped at `MaximumGlyphScaling` (PSR → Paragraph → SpacingLimits).
10
+ InDesign's default cap (100%) disables scaling — only documents
11
+ that relax the cap get stretched glyphs, matching TODO 119's
12
+ layered distribution: words first, then letters, then glyphs.
13
+
14
+ ## Known limitations
15
+
16
+ - MinimumGlyphScaling (compression of overlong lines) is not
17
+ applied — the breaker never produces overlong justified lines
18
+ except forced unbreakable runs.
@@ -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).
@@ -11,16 +11,20 @@ this list reflects the current state.
11
11
  ### Text layout
12
12
  - **Hyphenation** is not implemented (Hyphenation* attributes are
13
13
  parsed but unused); a hyphenation dictionary would be required.
14
- - **Justification**: word/letter spacing caps apply (TODO 119);
15
- glyph scaling (Min/Max/DesiredGlyphScaling) is not applied;
16
- ToBinding / RTL binding alignment maps to left.
17
- - **Keep options**: whole-paragraph KeepAllLinesTogether and
18
- KeepWithNext(first-line) are honored; partial keep windows
19
- (KeepFirstLines / KeepLastLines) are not.
14
+ - **Justification**: word/letter spacing caps apply (TODO 119) and
15
+ MaximumGlyphScaling stretches as the last resort (TODO 129);
16
+ MinimumGlyphScaling compression and ToBinding / RTL binding
17
+ alignment are not applied.
18
+ - **Keep options**: whole-paragraph KeepAllLinesTogether (TODO
19
+ 123) and KeepWithNext (TODO 127) are honored; partial keep
20
+ windows (KeepFirstLines / KeepLastLines) are not.
21
+ - **First baseline**: AscentOffset and FixedHeight are honored
22
+ (TODO 128); CapHeight / XHeight approximate as leading.
20
23
  - **StartParagraph** breaks act at frame/column granularity — no
21
24
  odd/even page parity.
22
- - **Text wrap** (TODO 114): BoundingBox mode only; Shape contour
23
- 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.
24
28
 
25
29
  ### CJK
26
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
@@ -131,6 +131,7 @@ module Idml
131
131
  limits = TextEngine::Justifier::SpacingLimits.new(
132
132
  max_word_spacing: paragraph.maximum_word_spacing,
133
133
  max_letter_spacing: paragraph.maximum_letter_spacing,
134
+ max_glyph_scaling: paragraph.maximum_glyph_scaling,
134
135
  )
135
136
  lines.each_with_index do |line, index|
136
137
  TextEngine::Justifier.justify(
@@ -433,6 +433,7 @@ module Idml
433
433
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
434
434
  footnotes = []
435
435
  cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
436
+ cursor_y -= first_baseline_offset(context, state, font)
436
437
  char_cursor = state.char_cursor
437
438
 
438
439
  if state.current_paragraph
@@ -473,7 +474,7 @@ module Idml
473
474
  top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
474
475
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
475
476
  footnotes = []
476
- cursor_y = top_y
477
+ cursor_y = top_y - first_baseline_offset(context, state, font)
477
478
  char_cursor = state.char_cursor
478
479
 
479
480
  if state.current_paragraph
@@ -564,6 +565,53 @@ module Idml
564
565
  end
565
566
  private_class_method :vertical_justify_top
566
567
 
568
+ # FirstBaselineOffset: how far below the frame's top inset
569
+ # the first line's baseline sits. The layout's default is
570
+ # leading-based; AscentOffset shifts by (ascent − leading)
571
+ # and FixedHeight by (MinimumFirstBaselineOffset − leading).
572
+ # CapHeight / XHeight / EmboxHeight approximate as leading.
573
+ # Returns the extra shift to subtract from the cursor.
574
+ def self.first_baseline_offset(context, state, font)
575
+ pref = context.item&.text_frame_preference&.first
576
+ mode = pref&.first_baseline_offset
577
+ return 0.0 unless %w[AscentOffset FixedHeight].include?(mode)
578
+
579
+ baseline_target(pref, mode, state, font) -
580
+ first_paragraph_leading(state)
581
+ end
582
+ private_class_method :first_baseline_offset
583
+
584
+ # Distance from the top inset to the first baseline under
585
+ # the given mode.
586
+ def self.baseline_target(pref, mode, state, font)
587
+ if mode == "AscentOffset"
588
+ ascent_scaled(font) * first_paragraph_leading(state)
589
+ else
590
+ pref.minimum_first_baseline_offset ||
591
+ first_paragraph_leading(state)
592
+ end
593
+ end
594
+ private_class_method :baseline_target
595
+
596
+ def self.ascent_scaled(font)
597
+ upem = font.units_per_em.zero? ? 1 : font.units_per_em
598
+ font.ascent / upem
599
+ end
600
+ private_class_method :ascent_scaled
601
+
602
+ def self.first_paragraph_leading(state)
603
+ paragraph = state.current_paragraph || state.paragraphs.first
604
+ unless paragraph
605
+ return DEFAULT_SIZE *
606
+ TextEngine::VerticalLayout::DEFAULT_LEADING_FACTOR
607
+ end
608
+
609
+ size = paragraph.runs.first&.point_size || DEFAULT_SIZE
610
+ TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
611
+ size)
612
+ end
613
+ private_class_method :first_paragraph_leading
614
+
567
615
  def self.text_frame_vertical_justification(context)
568
616
  pref = context.item&.text_frame_preference&.first
569
617
  pref&.vertical_justification
@@ -1051,6 +1099,7 @@ module Idml
1051
1099
  limits = TextEngine::Justifier::SpacingLimits.new(
1052
1100
  max_word_spacing: paragraph.maximum_word_spacing,
1053
1101
  max_letter_spacing: paragraph.maximum_letter_spacing,
1102
+ max_glyph_scaling: paragraph.maximum_glyph_scaling,
1054
1103
  )
1055
1104
  lines.each_with_index do |line, index|
1056
1105
  TextEngine::Justifier.justify(
@@ -68,6 +68,7 @@ module Idml
68
68
  # Justification distribution caps (percent; from PSR).
69
69
  :maximum_word_spacing,
70
70
  :maximum_letter_spacing,
71
+ :maximum_glyph_scaling,
71
72
  # Forced paragraph break (StartParagraph: NextPage /
72
73
  # NextColumn / NextFrame / NextOddPage / NextEvenPage).
73
74
  :start_paragraph,
@@ -205,6 +206,9 @@ module Idml
205
206
  maximum_letter_spacing: resolve_attr(
206
207
  style_lookup, psr, :maximum_letter_spacing
207
208
  ),
209
+ maximum_glyph_scaling: resolve_attr(
210
+ style_lookup, psr, :maximum_glyph_scaling
211
+ ),
208
212
  start_paragraph: resolve_attr(style_lookup, psr,
209
213
  :start_paragraph),
210
214
  keep_all_lines_together: resolve_attr(
@@ -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"
@@ -10,11 +10,13 @@ module Idml
10
10
  # Justification distribution caps, in percent (100 = natural).
11
11
  # InDesign defaults: word spacing max 133%, letter spacing 0%.
12
12
  SpacingLimits = Struct.new(
13
- :max_word_spacing, :max_letter_spacing, keyword_init: true
13
+ :max_word_spacing, :max_letter_spacing, :max_glyph_scaling,
14
+ keyword_init: true
14
15
  )
15
16
 
16
17
  DEFAULT_MAX_WORD_SPACING = 133.0
17
18
  DEFAULT_MAX_LETTER_SPACING = 0.0
19
+ DEFAULT_MAX_GLYPH_SCALING = 100.0
18
20
 
19
21
  def self.justify(line:, frame_width:, alignment: :left,
20
22
  last_line: false, limits: nil)
@@ -63,7 +65,12 @@ module Idml
63
65
  glyph.width += extra
64
66
  end
65
67
  residual = slack - word_share
66
- distribute_letter_spacing(line, residual) if residual.positive?
68
+ return unless residual.positive?
69
+
70
+ before = line.glyphs.sum(&:width)
71
+ distribute_letter_spacing(line, residual)
72
+ residual -= (line.glyphs.sum(&:width) - before)
73
+ distribute_glyph_scaling(line, residual) if residual.positive?
67
74
  end
68
75
 
69
76
  # Total room the word spaces may grow within the max-word-
@@ -91,6 +98,23 @@ module Idml
91
98
  line.glyphs.each { |glyph| glyph.width += add }
92
99
  end
93
100
  private :distribute_letter_spacing
101
+
102
+ # Last resort: uniform glyph scaling for any remaining slack,
103
+ # capped at max_glyph_scaling percent. InDesign's default cap
104
+ # (100%) disables it; only documents that relax the cap get
105
+ # stretched glyphs.
106
+ def distribute_glyph_scaling(line, residual)
107
+ max_pct = @limits&.max_glyph_scaling || DEFAULT_MAX_GLYPH_SCALING
108
+ grow = (max_pct - 100) / 100.0
109
+ return unless grow.positive?
110
+
111
+ total = line.glyphs.sum(&:width)
112
+ return unless total.positive?
113
+
114
+ factor = [residual / total, grow].min
115
+ line.glyphs.each { |glyph| glyph.width *= (1 + factor) }
116
+ end
117
+ private :distribute_glyph_scaling
94
118
  end
95
119
  end
96
120
  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.9.9"
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.9.9
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -186,7 +186,10 @@ files:
186
186
  - TODO.pdf/125-script-spacing.md
187
187
  - TODO.pdf/126-part-cache.md
188
188
  - TODO.pdf/127-keep-with-next.md
189
+ - TODO.pdf/128-first-baseline-offset.md
190
+ - TODO.pdf/129-justification-glyph-scaling.md
189
191
  - TODO.pdf/13-cjk-text-layout.md
192
+ - TODO.pdf/130-contour-text-wrap.md
190
193
  - TODO.pdf/14-page-item-element-models.md
191
194
  - TODO.pdf/15-wire-spread-children.md
192
195
  - TODO.pdf/16-typed-model-pipeline.md
@@ -495,6 +498,7 @@ files:
495
498
  - lib/idml/render/style_lookup.rb
496
499
  - lib/idml/render/style_resolver.rb
497
500
  - lib/idml/render/text_wrap_resolver.rb
501
+ - lib/idml/render/wrap_contour.rb
498
502
  - lib/idml/text_engine.rb
499
503
  - lib/idml/text_engine/cjk_layout.rb
500
504
  - lib/idml/text_engine/justifier.rb