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 +4 -4
- data/TODO.pdf/114-text-wrap-around-shapes.md +3 -1
- data/TODO.pdf/128-first-baseline-offset.md +21 -0
- data/TODO.pdf/129-justification-glyph-scaling.md +18 -0
- data/TODO.pdf/130-contour-text-wrap.md +38 -0
- data/TODO.pdf/61-known-limitations.md +12 -8
- data/lib/idml/elements/properties.rb +2 -0
- data/lib/idml/elements/text_wrap_preference.rb +2 -0
- data/lib/idml/render/footnote.rb +1 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +50 -1
- data/lib/idml/render/style_resolver.rb +4 -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/text_engine/justifier.rb +26 -2
- data/lib/idml/version.rb +1 -1
- metadata +5 -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,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
|
-
|
|
16
|
-
ToBinding / RTL binding
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(
|
|
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
|
|
23
|
-
|
|
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
|
data/lib/idml/render/footnote.rb
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
@@ -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,
|
|
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
|
-
|
|
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
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.
|
|
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
|