idml 0.10.0 → 0.10.2
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/README.adoc +62 -2
- data/TODO.pdf/114-text-wrap-around-shapes.md +3 -1
- data/TODO.pdf/13-cjk-text-layout.md +2 -1
- data/TODO.pdf/130-contour-text-wrap.md +38 -0
- data/TODO.pdf/131-inverse-wrap.md +19 -0
- data/TODO.pdf/132-line-end-compression.md +22 -0
- data/TODO.pdf/61-known-limitations.md +6 -4
- 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 +35 -10
- data/lib/idml/render/wrap_contour.rb +176 -0
- data/lib/idml/render.rb +1 -0
- data/lib/idml/text_engine/cjk_layout.rb +35 -0
- data/lib/idml/text_engine/line_breaker.rb +2 -1
- 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: 40262a683342f0a9308e33ab2cce4a8bcf6b803288d6a0bc3ece3d2b376c6761
|
|
4
|
+
data.tar.gz: 78cb5495d1d9b72d5e850c6d25c8cd594622d6484476ea61a1ff239df41e9e99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b10228f8870d90e1a66f8fb9b621a26951db4a71e155a3f28165c22a3e9c44b53e7dbc17916ec4b5db0f49987403e4e645e5f0872a79cb0fe591881ff6f9caca
|
|
7
|
+
data.tar.gz: d4904d3ef2a6d0b1c4dfcd497597042fcedee1b5c3c1643ff892ac867e7b4c3f0881419a906527e5f1705a64292c8c5c5e06ae8af774b8ce725f81c8bcf9ff92
|
data/README.adoc
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
|
-
=
|
|
1
|
+
= idml
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A pure-Ruby toolkit for Adobe InDesign IDML files: parse, validate,
|
|
4
|
+
round-trip, compose, and render to PDF.
|
|
4
5
|
|
|
6
|
+
== What it does
|
|
7
|
+
|
|
8
|
+
* **Parse & round-trip** — every IDML part is a typed
|
|
9
|
+
`Lutaml::Model` class generated from Adobe's RelaxNG schemas
|
|
10
|
+
(`reference-docs/schemas/`): byte-faithful re-serialization with
|
|
11
|
+
`ZIP_STORED` mimetype ordering preserved.
|
|
12
|
+
* **Validate** — RelaxNG validation against the committed InDesign
|
|
13
|
+
2026 (DOMVersion 21.5) schemas.
|
|
14
|
+
* **Compose** — `insert_idml`, `add_page_from_idml`, `prefix`,
|
|
15
|
+
`import_xml`/`export_xml` (ported from SimpleIDML).
|
|
16
|
+
* **Render to PDF** — a full pure-Ruby layout + paint pipeline
|
|
17
|
+
(pdfrb): multi-frame threaded stories, justification with
|
|
18
|
+
word/letter/glyph limits, footnotes (bottom-of-frame with
|
|
19
|
+
separator rules), anchored objects, tables (row/column banding,
|
|
20
|
+
per-side cell strokes, merged cells), Bézier shape contours,
|
|
21
|
+
gradients, text wrap (bounding-box, contour, and inverse modes),
|
|
22
|
+
paragraph shading/borders/rules, drop caps, bullets & numbering,
|
|
23
|
+
CJK layout (kinsoku shori, ruby, vertical writing with
|
|
24
|
+
tate-chu-yoko and rotated Latin, script spacing, line-end
|
|
25
|
+
punctuation compression), tagged PDF, hyperlinks, bookmarks,
|
|
26
|
+
PDF/A, and font subsetting.
|
|
27
|
+
|
|
28
|
+
== Install
|
|
29
|
+
|
|
30
|
+
....
|
|
31
|
+
gem install idml
|
|
32
|
+
....
|
|
33
|
+
|
|
34
|
+
`.indd` files are a proprietary binary format — export them to
|
|
35
|
+
`.idml` from InDesign first (File → Export), or use InDesign Server
|
|
36
|
+
for headless automation.
|
|
37
|
+
|
|
38
|
+
== Quick start
|
|
39
|
+
|
|
40
|
+
....
|
|
41
|
+
require "idml"
|
|
42
|
+
|
|
43
|
+
# Round-trip a package
|
|
44
|
+
pkg = Idml::Package.new("doc.idml")
|
|
45
|
+
spec = pkg.spreads.first
|
|
46
|
+
|
|
47
|
+
# Render to PDF
|
|
48
|
+
Idml.render(package: pkg, to: "doc.pdf")
|
|
49
|
+
....
|
|
50
|
+
|
|
51
|
+
== Requirements
|
|
52
|
+
|
|
53
|
+
* Ruby >= 3.3 (tested through 4.0 on macOS/Linux/Windows)
|
|
54
|
+
|
|
55
|
+
== Reference material
|
|
56
|
+
|
|
57
|
+
The `reference-docs/` tree carries Adobe's IDML specification, the
|
|
58
|
+
Plug-in SDK's idmltools (behavioral ground truth), and the
|
|
59
|
+
generated RelaxNG schemas — the attribute authority for every
|
|
60
|
+
model class.
|
|
61
|
+
|
|
62
|
+
== License
|
|
63
|
+
|
|
64
|
+
BSD-2-Clause.
|
|
@@ -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
|
|
@@ -24,7 +24,8 @@ kinsoku shori, ruby annotations).
|
|
|
24
24
|
and vertical ruby (TODO 122), CJK/Latin automatic script spacing
|
|
25
25
|
(TODO 125, a mojikumi subset wired into Shaper). The remaining
|
|
26
26
|
stretch goal is full class-based mojikumi (per-pair punctuation
|
|
27
|
-
compression tables)
|
|
27
|
+
compression tables); line-end punctuation compression landed
|
|
28
|
+
2026-08-23 as TODO 132. See `lib/idml/text_engine/cjk_layout.rb` and
|
|
28
29
|
`lib/idml/text_engine/vertical_text_layout.rb`.
|
|
29
30
|
|
|
30
31
|
## Progress 2026-08-17
|
|
@@ -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).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# TODO PDF 131: Inverse text wrap
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-23
|
|
4
|
+
|
|
5
|
+
## What was done
|
|
6
|
+
|
|
7
|
+
`TextWrapPreference Inverse="true"` flips the wrap region: text
|
|
8
|
+
may only flow INSIDE the wrap shape (the avoid-region becomes
|
|
9
|
+
everything outside it within the frame). Works for both the
|
|
10
|
+
bounding-box contour and the Contour-mode polygon shape (TODO
|
|
11
|
+
130); the inversion lives in one place — TextWrapResolver's
|
|
12
|
+
overlap dispatch — and reduces by (frame width − inside width),
|
|
13
|
+
so bands outside the shape block text entirely.
|
|
14
|
+
|
|
15
|
+
## Known limitations
|
|
16
|
+
|
|
17
|
+
- Side-aware narrowing is not implemented: the reduction shrinks
|
|
18
|
+
the wrap width from the right regardless of which side the
|
|
19
|
+
shape occupies.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# TODO PDF 132: Line-end punctuation compression (mojikumi subset 2)
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-23
|
|
4
|
+
|
|
5
|
+
## What was done
|
|
6
|
+
|
|
7
|
+
`CjkLayout.apply_line_end_compression`: a CJK line whose final
|
|
8
|
+
glyph is full-width closing punctuation (。、」』)]}ー etc.) renders
|
|
9
|
+
that glyph at half advance (行末約物半角詰め), tightening the line
|
|
10
|
+
as InDesign's default mojikumi does. Applied in LineBreaker's CJK
|
|
11
|
+
post-pass after kinsoku shori, so horizontal and vertical layout
|
|
12
|
+
both benefit.
|
|
13
|
+
|
|
14
|
+
Also fixed a latent bug the specs caught: kinsoku's return value
|
|
15
|
+
(new dup'd line array) was discarded when composing the post-pass
|
|
16
|
+
chain — kinsoku adjustments silently vanished once a second
|
|
17
|
+
post-pass followed it.
|
|
18
|
+
|
|
19
|
+
## Remaining from TODO 13
|
|
20
|
+
|
|
21
|
+
Per-pair punctuation compression tables (full class-based
|
|
22
|
+
mojikumi).
|
|
@@ -22,12 +22,14 @@ 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, Contour, and Inverse modes
|
|
26
|
+
render (TODOs 130-131); JumpObject/NextColumn approximate as
|
|
27
|
+
the box; side-aware narrowing is not implemented.
|
|
27
28
|
|
|
28
29
|
### CJK
|
|
29
|
-
- **Mojikumi**: CJK/Latin auto script spacing
|
|
30
|
-
|
|
30
|
+
- **Mojikumi**: CJK/Latin auto script spacing (TODO 125) and
|
|
31
|
+
line-end punctuation compression (TODO 132) are applied; full
|
|
32
|
+
class-based compression tables are not.
|
|
31
33
|
- **Vertical mode**: Latin rotation is per glyph (not run-grouped);
|
|
32
34
|
ruby placement is beside-column; keep options do not apply.
|
|
33
35
|
|
|
@@ -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
|
|
@@ -14,7 +14,7 @@ module Idml
|
|
|
14
14
|
class TextWrapResolver
|
|
15
15
|
# A wrap contour: a PDF-coordinate rectangle the text avoids.
|
|
16
16
|
Contour = Struct.new(
|
|
17
|
-
:x, :y, :width, :height,
|
|
17
|
+
:x, :y, :width, :height, :inverse,
|
|
18
18
|
keyword_init: true
|
|
19
19
|
)
|
|
20
20
|
|
|
@@ -40,20 +40,45 @@ module Idml
|
|
|
40
40
|
# Returns the total horizontal overlap of all contours with a
|
|
41
41
|
# text line at the given y range within the given x range.
|
|
42
42
|
# Sum, not max — multiple overlapping contours compound.
|
|
43
|
-
# Returns 0.0 when there's no overlap.
|
|
43
|
+
# Returns 0.0 when there's no overlap. Inverse contours
|
|
44
|
+
# reduce by the frame width minus their own width (text may
|
|
45
|
+
# only flow inside).
|
|
44
46
|
def overlap_width(line_y, line_height, frame_x, frame_right)
|
|
45
47
|
@contours.sum do |contour|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
width = if contour.is_a?(WrapContour::Shape)
|
|
49
|
+
WrapContour.overlap_width(contour, line_y,
|
|
50
|
+
line_height, frame_x,
|
|
51
|
+
frame_right)
|
|
52
|
+
else
|
|
53
|
+
line_overlap(contour, line_y, line_height,
|
|
54
|
+
frame_x, frame_right)[:width]
|
|
55
|
+
end
|
|
56
|
+
next width unless contour.inverse
|
|
57
|
+
|
|
58
|
+
(frame_right - frame_x) - width
|
|
49
59
|
end
|
|
50
60
|
end
|
|
51
61
|
|
|
62
|
+
# The wrap shape for an item: a bounding-box rectangle
|
|
63
|
+
# (BoundingBoxTextWrap — the legacy "BoundingBox" spelling is
|
|
64
|
+
# also accepted from early synthetic fixtures) or a flattened
|
|
65
|
+
# PathGeometry polygon (Contour mode). JumpObject /
|
|
66
|
+
# NextColumn modes approximate as the bounding box (the
|
|
67
|
+
# per-run width reduction already pushes text past the
|
|
68
|
+
# object).
|
|
52
69
|
def self.contour_for(item, page_height)
|
|
53
70
|
pref = wrap_preference(item)
|
|
54
71
|
return nil unless pref
|
|
55
|
-
return nil unless bounding_box_mode?(pref)
|
|
56
72
|
|
|
73
|
+
if contour_mode?(pref)
|
|
74
|
+
WrapContour.shape(item, pref, page_height)
|
|
75
|
+
else
|
|
76
|
+
box_contour(item, page_height)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
private_class_method :contour_for
|
|
80
|
+
|
|
81
|
+
def self.box_contour(item, page_height)
|
|
57
82
|
bounds = item.geometric_bounds
|
|
58
83
|
return nil unless bounds
|
|
59
84
|
|
|
@@ -64,7 +89,7 @@ module Idml
|
|
|
64
89
|
width: rect[:width], height: rect[:height]
|
|
65
90
|
)
|
|
66
91
|
end
|
|
67
|
-
private_class_method :
|
|
92
|
+
private_class_method :box_contour
|
|
68
93
|
|
|
69
94
|
# Page item types that can declare TextWrapPreference per the
|
|
70
95
|
# Spread schema. Used to guard the attribute read — querying
|
|
@@ -95,10 +120,10 @@ module Idml
|
|
|
95
120
|
end
|
|
96
121
|
private_class_method :wrappable?
|
|
97
122
|
|
|
98
|
-
def self.
|
|
99
|
-
pref.text_wrap_mode == "
|
|
123
|
+
def self.contour_mode?(pref)
|
|
124
|
+
pref.text_wrap_mode == "Contour"
|
|
100
125
|
end
|
|
101
|
-
private_class_method :
|
|
126
|
+
private_class_method :contour_mode?
|
|
102
127
|
|
|
103
128
|
def line_overlap(contour, line_y, line_height, frame_x, frame_right)
|
|
104
129
|
return { width: 0.0 } unless y_overlap?(contour, line_y, line_height)
|
|
@@ -0,0 +1,176 @@
|
|
|
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. `inverse` flips the wrap region:
|
|
16
|
+
# text may only flow INSIDE the shape.
|
|
17
|
+
Shape = Struct.new(:polygons, :offset, :inverse,
|
|
18
|
+
keyword_init: true)
|
|
19
|
+
|
|
20
|
+
# Builds the wrap shape for an item's PathGeometry and wrap
|
|
21
|
+
# preference. Returns nil when the item has no geometry.
|
|
22
|
+
def self.shape(item, pref, page_height)
|
|
23
|
+
polygons = polygons_for(item, page_height)
|
|
24
|
+
return nil if polygons.empty?
|
|
25
|
+
|
|
26
|
+
Shape.new(polygons: polygons, offset: wrap_offset(pref),
|
|
27
|
+
inverse: pref.inverse)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.polygons_for(item, page_height)
|
|
31
|
+
transform = Geometry.parse_transform(item.item_transform)
|
|
32
|
+
paths = item.properties.flat_map do |props|
|
|
33
|
+
props.path_geometry.flat_map(&:geometry_path_type)
|
|
34
|
+
end
|
|
35
|
+
paths.filter_map do |path|
|
|
36
|
+
points = flatten_path(path, transform, page_height)
|
|
37
|
+
points.length >= 3 ? points : nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :polygons_for
|
|
41
|
+
|
|
42
|
+
# Flattens one subpath's Bézier segments into line segments
|
|
43
|
+
# in PDF space (ItemTransform applied, y flipped).
|
|
44
|
+
def self.flatten_path(path, transform, page_height)
|
|
45
|
+
anchors = path.points
|
|
46
|
+
return [] if anchors.length < 2
|
|
47
|
+
|
|
48
|
+
open = path.path_open == true
|
|
49
|
+
segments = open ? anchors.length - 1 : anchors.length
|
|
50
|
+
points = [pdf_point(anchors.first, transform, page_height)]
|
|
51
|
+
segments.times do |index|
|
|
52
|
+
from = anchors[index]
|
|
53
|
+
to = anchors[(index + 1) % anchors.length]
|
|
54
|
+
append_segment(points, from, to, transform, page_height)
|
|
55
|
+
end
|
|
56
|
+
points
|
|
57
|
+
end
|
|
58
|
+
private_class_method :flatten_path
|
|
59
|
+
|
|
60
|
+
def self.append_segment(points, from, to, transform, page_height)
|
|
61
|
+
c1 = pair_or_nil(from, :right_direction, transform, page_height)
|
|
62
|
+
c2 = pair_or_nil(to, :left_direction, transform, page_height)
|
|
63
|
+
start = points.last
|
|
64
|
+
if c1.nil? && c2.nil?
|
|
65
|
+
points << pdf_point(to, transform, page_height)
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
c1 ||= start
|
|
70
|
+
c2 ||= pdf_point(to, transform, page_height)
|
|
71
|
+
(1..FLATTEN_STEPS).each do |step|
|
|
72
|
+
t = step.to_f / FLATTEN_STEPS
|
|
73
|
+
points << bezier_point(start, c1, c2,
|
|
74
|
+
pdf_point(to, transform, page_height), t)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
private_class_method :append_segment
|
|
78
|
+
|
|
79
|
+
def self.bezier_point(p0, p1, p2, p3, t)
|
|
80
|
+
u = 1 - t
|
|
81
|
+
w0 = u * u * u
|
|
82
|
+
w1 = 3 * u * u * t
|
|
83
|
+
w2 = 3 * u * t * t
|
|
84
|
+
w3 = t * t * t
|
|
85
|
+
[
|
|
86
|
+
(w0 * p0[0]) + (w1 * p1[0]) + (w2 * p2[0]) + (w3 * p3[0]),
|
|
87
|
+
(w0 * p0[1]) + (w1 * p1[1]) + (w2 * p2[1]) + (w3 * p3[1]),
|
|
88
|
+
]
|
|
89
|
+
end
|
|
90
|
+
private_class_method :bezier_point
|
|
91
|
+
|
|
92
|
+
def self.pair_or_nil(point, direction_attr, transform, page_height)
|
|
93
|
+
raw = point.public_send(direction_attr)
|
|
94
|
+
raw = nil if raw && raw.strip.empty?
|
|
95
|
+
raw && pdf_pair(raw, transform, page_height)
|
|
96
|
+
end
|
|
97
|
+
private_class_method :pair_or_nil
|
|
98
|
+
|
|
99
|
+
def self.pdf_point(point, transform, page_height)
|
|
100
|
+
pdf_pair(point.anchor, transform, page_height)
|
|
101
|
+
end
|
|
102
|
+
private_class_method :pdf_point
|
|
103
|
+
|
|
104
|
+
def self.pdf_pair(raw, transform, page_height)
|
|
105
|
+
x, y = raw.to_s.split(/\s+/).map(&:to_f)
|
|
106
|
+
tx, ty = Geometry.apply_transform(transform, x, y)
|
|
107
|
+
[tx, page_height - ty]
|
|
108
|
+
end
|
|
109
|
+
private_class_method :pdf_pair
|
|
110
|
+
|
|
111
|
+
# The wrap offsets from Properties > TextWrapOffset
|
|
112
|
+
# ("top left bottom right"); zero when absent.
|
|
113
|
+
def self.wrap_offset(pref)
|
|
114
|
+
raw = pref.properties.first&.text_wrap_offset&.value
|
|
115
|
+
parts = raw.to_s.split(/\s+/).map(&:to_f)
|
|
116
|
+
return [0.0, 0.0] if parts.length < 4
|
|
117
|
+
|
|
118
|
+
[parts[1], parts[3]] # left, right
|
|
119
|
+
end
|
|
120
|
+
private_class_method :wrap_offset
|
|
121
|
+
|
|
122
|
+
# Total horizontal overlap of the text line's band with the
|
|
123
|
+
# shape, expanded by the offsets and clipped to the frame.
|
|
124
|
+
# The caller (TextWrapResolver) owns inverse flipping.
|
|
125
|
+
def self.overlap_width(shape, line_y, line_height, frame_x,
|
|
126
|
+
frame_right)
|
|
127
|
+
mid_y = line_y + (line_height / 2.0)
|
|
128
|
+
width = shape.polygons.sum do |polygon|
|
|
129
|
+
polygon_overlap(polygon, mid_y, shape.offset)
|
|
130
|
+
end
|
|
131
|
+
clip_width(width, frame_x, frame_right)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Even-odd intervals at mid_y expanded by [left, right]
|
|
135
|
+
# offsets, summed (separate subpaths sum; the common case is
|
|
136
|
+
# one polygon).
|
|
137
|
+
def self.polygon_overlap(polygon, mid_y, offset)
|
|
138
|
+
crossings = edge_crossings(ring(polygon), mid_y).sort
|
|
139
|
+
return 0.0 if crossings.length < 2
|
|
140
|
+
|
|
141
|
+
left, right = offset
|
|
142
|
+
crossings.each_slice(2).sum do |entry_x, exit_x|
|
|
143
|
+
(exit_x + right) - (entry_x - left)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
private_class_method :polygon_overlap
|
|
147
|
+
|
|
148
|
+
# Closes the polygon ring (each_cons alone misses the
|
|
149
|
+
# last→first edge).
|
|
150
|
+
def self.ring(polygon)
|
|
151
|
+
polygon + [polygon.first]
|
|
152
|
+
end
|
|
153
|
+
private_class_method :ring
|
|
154
|
+
|
|
155
|
+
def self.edge_crossings(points, mid_y)
|
|
156
|
+
points.each_cons(2).filter_map do |(x1, y1), (x2, y2)|
|
|
157
|
+
next unless crosses_midline?(y1, y2, mid_y)
|
|
158
|
+
|
|
159
|
+
t = (mid_y - y1) / (y2 - y1)
|
|
160
|
+
x1 + (t * (x2 - x1))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
private_class_method :edge_crossings
|
|
164
|
+
|
|
165
|
+
def self.crosses_midline?(y1, y2, mid_y)
|
|
166
|
+
(y1 <= mid_y && y2 > mid_y) || (y2 <= mid_y && y1 > mid_y)
|
|
167
|
+
end
|
|
168
|
+
private_class_method :crosses_midline?
|
|
169
|
+
|
|
170
|
+
def self.clip_width(width, frame_x, frame_right)
|
|
171
|
+
[width, frame_right - frame_x].min
|
|
172
|
+
end
|
|
173
|
+
private_class_method :clip_width
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
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"
|
|
@@ -39,6 +39,41 @@ module Idml
|
|
|
39
39
|
(0x61..0x7A).cover?(codepoint)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Mojikumi subset 2: line-end punctuation compression —
|
|
43
|
+
# full-width closing punctuation occupying a line's final
|
|
44
|
+
# position renders at half advance (行末約物半角詰め),
|
|
45
|
+
# tightening the line as InDesign's default mojikumi does.
|
|
46
|
+
TRAILING_COMPRESSION_CODEPOINTS = [
|
|
47
|
+
0x3001, # 、 ideographic comma
|
|
48
|
+
0x3002, # 。 ideographic full stop
|
|
49
|
+
0x3009, # 〉
|
|
50
|
+
0x300B, # 》
|
|
51
|
+
0x300D, # 」
|
|
52
|
+
0x300F, # 』
|
|
53
|
+
0x3011, # 】
|
|
54
|
+
0x3015, # 〕
|
|
55
|
+
0xFF09, # )fullwidth right paren
|
|
56
|
+
0xFF3D, # ]
|
|
57
|
+
0xFF5D, # }
|
|
58
|
+
0x30FC, # ー prolonged sound mark
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
def apply_line_end_compression(lines)
|
|
62
|
+
lines.each do |line|
|
|
63
|
+
last = line.glyphs.last
|
|
64
|
+
next unless last
|
|
65
|
+
next unless compressible?(last.codepoint)
|
|
66
|
+
|
|
67
|
+
last.width /= 2.0
|
|
68
|
+
line.width = line.glyphs.sum(&:width)
|
|
69
|
+
end
|
|
70
|
+
lines
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def compressible?(codepoint)
|
|
74
|
+
TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
|
|
75
|
+
end
|
|
76
|
+
|
|
42
77
|
CJK_RANGES = [
|
|
43
78
|
0x3000..0x303F, # CJK Symbols and Punctuation
|
|
44
79
|
0x3040..0x309F, # Hiragana
|
|
@@ -14,7 +14,8 @@ module Idml
|
|
|
14
14
|
lines = new(frame_width).break(glyphs)
|
|
15
15
|
return lines unless cjk_run?(glyphs)
|
|
16
16
|
|
|
17
|
-
CjkLayout.apply_kinsoku(lines)
|
|
17
|
+
lines = CjkLayout.apply_kinsoku(lines)
|
|
18
|
+
CjkLayout.apply_line_end_compression(lines)
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def self.cjk_run?(glyphs)
|
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose
|
|
@@ -189,6 +189,9 @@ 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
|
|
193
|
+
- TODO.pdf/131-inverse-wrap.md
|
|
194
|
+
- TODO.pdf/132-line-end-compression.md
|
|
192
195
|
- TODO.pdf/14-page-item-element-models.md
|
|
193
196
|
- TODO.pdf/15-wire-spread-children.md
|
|
194
197
|
- TODO.pdf/16-typed-model-pipeline.md
|
|
@@ -497,6 +500,7 @@ files:
|
|
|
497
500
|
- lib/idml/render/style_lookup.rb
|
|
498
501
|
- lib/idml/render/style_resolver.rb
|
|
499
502
|
- lib/idml/render/text_wrap_resolver.rb
|
|
503
|
+
- lib/idml/render/wrap_contour.rb
|
|
500
504
|
- lib/idml/text_engine.rb
|
|
501
505
|
- lib/idml/text_engine/cjk_layout.rb
|
|
502
506
|
- lib/idml/text_engine/justifier.rb
|