idml 0.8.9 → 0.9.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/101-paragraph-rules-borders.md +36 -39
- data/TODO.pdf/13-cjk-text-layout.md +20 -4
- data/lib/idml/elements/character_style_range.rb +2 -0
- data/lib/idml/elements/paragraph_style_range.rb +2 -0
- data/lib/idml/elements/properties.rb +16 -3
- data/lib/idml/elements/typed_value.rb +20 -0
- data/lib/idml/elements.rb +1 -0
- data/lib/idml/render/paragraph_rules.rb +125 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +69 -3
- data/lib/idml/render/style_resolver.rb +92 -2
- data/lib/idml/text_engine/line_breaker.rb +11 -1
- data/lib/idml/text_engine/measurement.rb +40 -0
- data/lib/idml/text_engine.rb +2 -1
- data/lib/idml/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a82b32708ab694d0d794c39f33d74de7b39a9bdc38b7a6315b99a2156dcef271
|
|
4
|
+
data.tar.gz: 7eee074d6ecf3a46798139fa6ed6fc36c975bcaf93059c52e68e597e7b1a87f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c561f3cd1a83d7688291a2ef3dc23d42616bce723b5ff82a77a641104fae0731247c4cbc50cae2d9d5d5aa979c31ac8e90ff0b38f107235793bdfd7fda4019a
|
|
7
|
+
data.tar.gz: e42f707ec01db8ce171244e95ba378420bdf44c4a195c1f2f285ac7d56e91777c4750f5d5f4b05dc6ac60fb2f59beebaa50755f6c1ee3bb2de13957a7b44edab
|
|
@@ -1,47 +1,44 @@
|
|
|
1
1
|
# TODO PDF 101: Paragraph borders and rules (RuleAbove, RuleBelow, ParagraphBorder)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
1. After laying out a paragraph, emit RuleAbove as a horizontal line
|
|
33
|
-
above the first line, with the rule's weight/tint/color/offset/indent.
|
|
34
|
-
2. Emit RuleBelow similarly below the last line.
|
|
35
|
-
3. Optionally emit ParagraphBorder as a rectangle around the paragraph.
|
|
36
|
-
4. Optionally emit ParagraphShading as a fill behind the paragraph.
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-17
|
|
4
|
+
|
|
5
|
+
## What was built
|
|
6
|
+
|
|
7
|
+
- RuleAbove / RuleBelow (earlier milestone): horizontal rules at
|
|
8
|
+
paragraph top/bottom with weight / tint / offset / indent /
|
|
9
|
+
TextWidth-ColumnWidth modes, via `Render::ParagraphRules`.
|
|
10
|
+
- Rule colors now resolve from `Properties > RuleAboveColor` /
|
|
11
|
+
`RuleBelowColor` (typed value elements) with StrokeColor fallback —
|
|
12
|
+
modeled via `Elements::TypedValue` + Properties mapping, PSR/CSR
|
|
13
|
+
now carry `properties` collections.
|
|
14
|
+
- ParagraphShading: fill rect behind the paragraph block. Color from
|
|
15
|
+
`Properties > ParagraphShadingColor`, tint scaling, ParagraphShading
|
|
16
|
+
width mode (ColumnWidth default / TextWidth), four outward offsets.
|
|
17
|
+
`TextEngine::Measurement` pre-measures the paragraph's height so
|
|
18
|
+
the rect is known before the lines are laid out (clamped to the
|
|
19
|
+
frame's bottom limit when the paragraph splits).
|
|
20
|
+
- ParagraphBorder: per-side strokes around the same block rect, each
|
|
21
|
+
side with its own line weight and the border offsets/tint/color
|
|
22
|
+
(Properties > ParagraphBorderColor). Shading and border share one
|
|
23
|
+
extent so they always agree.
|
|
24
|
+
|
|
25
|
+
## Known limitations
|
|
26
|
+
|
|
27
|
+
- Border corner radii and gap colors are parsed on the model but not
|
|
28
|
+
rendered; MergeConsecutiveParaBorders (merged rects across like-
|
|
29
|
+
bordered paragraphs) is not modeled.
|
|
30
|
+
- DisplayIfSplits: when a paragraph splits across frames, both
|
|
31
|
+
shading and border are drawn around the rendered portion only.
|
|
37
32
|
|
|
38
33
|
## Acceptance criteria
|
|
39
34
|
|
|
40
|
-
- [
|
|
41
|
-
|
|
42
|
-
- [
|
|
43
|
-
- [
|
|
35
|
+
- [x] PSR with RuleAbove=true and RuleAboveLineWeight=2 draws the
|
|
36
|
+
rule above the paragraph.
|
|
37
|
+
- [x] PSR with RuleAboveOffset=4 leaves a 4pt gap.
|
|
38
|
+
- [x] PSR with ParagraphShadingOn=true and tint fills the paragraph
|
|
39
|
+
rect.
|
|
40
|
+
- [x] ParagraphBorderOn draws per-side strokes.
|
|
44
41
|
|
|
45
42
|
## Dependencies
|
|
46
43
|
|
|
47
|
-
- TODO 94 (VerticalLayout knows the paragraph's rect).
|
|
44
|
+
- TODO 94 (VerticalLayout knows the paragraph's rect) — satisfied.
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# TODO PDF 13: CJK text layout
|
|
2
2
|
|
|
3
|
-
## Status: PARTIAL —
|
|
4
|
-
measurement and
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
## Status: PARTIAL — horizontal-mode CJK complete (2026-08-17):
|
|
4
|
+
CJK glyph measurement, kinsoku shori, and ruby annotations render.
|
|
5
|
+
Vertical writing mode, tate-chu-yoko, and mojikumi remain OPEN
|
|
6
|
+
stretch goals — they need a vertical (rotated) layout engine and
|
|
7
|
+
real vertical-mode fixtures to build against. See
|
|
8
|
+
`lib/idml/text_engine/cjk_layout.rb`.
|
|
9
|
+
|
|
10
|
+
## Progress 2026-08-17
|
|
11
|
+
|
|
12
|
+
- Kinsoku shori is now LIVE in the pipeline: LineBreaker.break
|
|
13
|
+
post-processes any run containing CJK glyphs through
|
|
14
|
+
CjkLayout.apply_kinsoku, so no rendered line starts with
|
|
15
|
+
、。,」etc. or ends with 「 etc. (previously the logic existed
|
|
16
|
+
but was never called).
|
|
17
|
+
- Ruby (phonetic annotations) render: CSR RubyString /
|
|
18
|
+
RubyFontSize / RubyPosition extract onto the run; the renderer
|
|
19
|
+
emits the annotation centered above the run's first line (half
|
|
20
|
+
the base size by default; Below* positions place it under).
|
|
21
|
+
Approximation: IDML attaches ruby to a character range; we
|
|
22
|
+
annotate the whole first line.
|
|
7
23
|
|
|
8
24
|
## Goal
|
|
9
25
|
|
|
@@ -298,6 +298,7 @@ module Idml
|
|
|
298
298
|
attribute :content, Idml::Elements::Content, collection: true
|
|
299
299
|
attribute :hyperlink_text_source, Idml::Elements::HyperlinkTextSource,
|
|
300
300
|
collection: true
|
|
301
|
+
attribute :properties, Idml::Elements::Properties, collection: true
|
|
301
302
|
attribute :table, Idml::Elements::Table, collection: true
|
|
302
303
|
attribute :xml_element, Idml::Elements::XmlElement, collection: true
|
|
303
304
|
attribute :footnote, Idml::Elements::Footnote, collection: true
|
|
@@ -664,6 +665,7 @@ module Idml
|
|
|
664
665
|
map_element "CharacterStyleRange", to: :character_style_range
|
|
665
666
|
map_element "Content", to: :content
|
|
666
667
|
map_element "HyperlinkTextSource", to: :hyperlink_text_source
|
|
668
|
+
map_element "Properties", to: :properties
|
|
667
669
|
map_element "Table", to: :table
|
|
668
670
|
map_element "XMLElement", to: :xml_element
|
|
669
671
|
map_element "Footnote", to: :footnote
|
|
@@ -298,6 +298,7 @@ module Idml
|
|
|
298
298
|
collection: true
|
|
299
299
|
attribute :content, Idml::Elements::Content, collection: true
|
|
300
300
|
attribute :xml_element, Idml::Elements::XmlElement, collection: true
|
|
301
|
+
attribute :properties, Idml::Elements::Properties, collection: true
|
|
301
302
|
|
|
302
303
|
xml do
|
|
303
304
|
root "ParagraphStyleRange"
|
|
@@ -651,6 +652,7 @@ module Idml
|
|
|
651
652
|
map_element "ParagraphStyleRange", to: :paragraph_style_range
|
|
652
653
|
map_element "Content", to: :content
|
|
653
654
|
map_element "XMLElement", to: :xml_element
|
|
655
|
+
map_element "Properties", to: :properties
|
|
654
656
|
end
|
|
655
657
|
|
|
656
658
|
def text_content
|
|
@@ -2,18 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Elements
|
|
5
|
-
# `<Properties>` — a common child element on page items
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# `<Properties>` — a common child element on page items and
|
|
6
|
+
# story style ranges. Carries optional PathBoundingBox,
|
|
7
|
+
# PathGeometry, Label, and the paragraph-decoration color value
|
|
8
|
+
# elements (RuleAboveColor, ParagraphShadingColor, …) used on
|
|
9
|
+
# PSR/CSR Properties. Only the modeled children are populated
|
|
10
|
+
# from XML; others are ignored.
|
|
8
11
|
class Properties < Lutaml::Model::Serializable
|
|
9
12
|
attribute :path_bounding_box, :string
|
|
10
13
|
attribute :path_geometry, Idml::Elements::PathGeometry,
|
|
11
14
|
collection: true
|
|
15
|
+
attribute :rule_above_color, Idml::Elements::TypedValue
|
|
16
|
+
attribute :rule_below_color, Idml::Elements::TypedValue
|
|
17
|
+
attribute :paragraph_shading_color, Idml::Elements::TypedValue
|
|
18
|
+
attribute :paragraph_border_color, Idml::Elements::TypedValue
|
|
19
|
+
attribute :paragraph_border_gap_color, Idml::Elements::TypedValue
|
|
12
20
|
|
|
13
21
|
xml do
|
|
14
22
|
root "Properties"
|
|
15
23
|
map_element "PathBoundingBox", to: :path_bounding_box
|
|
16
24
|
map_element "PathGeometry", to: :path_geometry
|
|
25
|
+
map_element "RuleAboveColor", to: :rule_above_color
|
|
26
|
+
map_element "RuleBelowColor", to: :rule_below_color
|
|
27
|
+
map_element "ParagraphShadingColor", to: :paragraph_shading_color
|
|
28
|
+
map_element "ParagraphBorderColor", to: :paragraph_border_color
|
|
29
|
+
map_element "ParagraphBorderGapColor", to: :paragraph_border_gap_color
|
|
17
30
|
end
|
|
18
31
|
|
|
19
32
|
def first_geometry
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Elements
|
|
5
|
+
# Typed value child element — IDML's `(object_type, xsd:string) |
|
|
6
|
+
# (string_type, xsd:string)` wrapper used inside Properties for
|
|
7
|
+
# values like RuleAboveColor and ParagraphShadingColor: a `type`
|
|
8
|
+
# attribute plus the value as text content.
|
|
9
|
+
class TypedValue < Lutaml::Model::Serializable
|
|
10
|
+
attribute :type, :string
|
|
11
|
+
attribute :value, :string
|
|
12
|
+
|
|
13
|
+
xml do
|
|
14
|
+
root "TypedValue"
|
|
15
|
+
map_attribute "type", to: :type
|
|
16
|
+
map_content to: :value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/idml/elements.rb
CHANGED
|
@@ -147,6 +147,7 @@ module Idml
|
|
|
147
147
|
autoload :TOCStyle, "idml/elements/toc_style"
|
|
148
148
|
autoload :TOCStyleEntry, "idml/elements/toc_style_entry"
|
|
149
149
|
autoload :Table, "idml/elements/table"
|
|
150
|
+
autoload :TypedValue, "idml/elements/typed_value"
|
|
150
151
|
autoload :TableCell, "idml/elements/table_cell"
|
|
151
152
|
autoload :TableRow, "idml/elements/table_row"
|
|
152
153
|
autoload :TableStyle, "idml/elements/table_style"
|
|
@@ -82,6 +82,131 @@ module Idml
|
|
|
82
82
|
end
|
|
83
83
|
private_class_method :emit_rule
|
|
84
84
|
|
|
85
|
+
# Emits the paragraph's shading: a fill rect behind the
|
|
86
|
+
# paragraph block (top_y..bottom_y within the column). The
|
|
87
|
+
# ParagraphShading* offsets expand the rect outward; tint
|
|
88
|
+
# scales the color. Color comes from Properties >
|
|
89
|
+
# ParagraphShadingColor (black when absent, per InDesign's
|
|
90
|
+
# default shading color).
|
|
91
|
+
def self.emit_shading(canvas, paragraph, context, top_y, bottom_y,
|
|
92
|
+
frame_left, frame_right)
|
|
93
|
+
return unless paragraph.paragraph_shading_on
|
|
94
|
+
return unless top_y > bottom_y
|
|
95
|
+
|
|
96
|
+
tint_value = decoration_tint(paragraph.paragraph_shading_tint)
|
|
97
|
+
return unless tint_value
|
|
98
|
+
|
|
99
|
+
color = resolve_color(paragraph.paragraph_shading_color,
|
|
100
|
+
tint_value, context)
|
|
101
|
+
return unless color
|
|
102
|
+
|
|
103
|
+
left, right, top, bottom = decoration_rect(
|
|
104
|
+
paragraph, top_y, bottom_y, frame_left, frame_right,
|
|
105
|
+
left_offset: paragraph.paragraph_shading_left_offset,
|
|
106
|
+
right_offset: paragraph.paragraph_shading_right_offset,
|
|
107
|
+
top_offset: paragraph.paragraph_shading_top_offset,
|
|
108
|
+
bottom_offset: paragraph.paragraph_shading_bottom_offset
|
|
109
|
+
)
|
|
110
|
+
canvas.fill_color(color)
|
|
111
|
+
canvas.rectangle(left, bottom, right - left, top - bottom)
|
|
112
|
+
canvas.fill
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Emits the paragraph's border: per-side strokes around the
|
|
116
|
+
# paragraph block, each side with its own line weight, drawn
|
|
117
|
+
# at the block rect expanded by the border offsets.
|
|
118
|
+
def self.emit_border(canvas, paragraph, context, top_y, bottom_y,
|
|
119
|
+
frame_left, frame_right)
|
|
120
|
+
return unless paragraph.paragraph_border_on
|
|
121
|
+
return unless top_y > bottom_y
|
|
122
|
+
|
|
123
|
+
tint_value = decoration_tint(paragraph.paragraph_border_tint)
|
|
124
|
+
return unless tint_value
|
|
125
|
+
|
|
126
|
+
color = resolve_color(paragraph.paragraph_border_color,
|
|
127
|
+
tint_value, context)
|
|
128
|
+
return unless color
|
|
129
|
+
|
|
130
|
+
left, right, top, bottom = decoration_rect(
|
|
131
|
+
paragraph, top_y, bottom_y, frame_left, frame_right,
|
|
132
|
+
left_offset: paragraph.paragraph_border_left_offset,
|
|
133
|
+
right_offset: paragraph.paragraph_border_right_offset,
|
|
134
|
+
top_offset: paragraph.paragraph_border_top_offset,
|
|
135
|
+
bottom_offset: paragraph.paragraph_border_bottom_offset
|
|
136
|
+
)
|
|
137
|
+
apply_rule_color(canvas, paragraph.paragraph_border_color,
|
|
138
|
+
tint_value, context)
|
|
139
|
+
stroke_border_sides(canvas, paragraph, left, right, top, bottom)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Tint value for a decoration, or nil when zero/absent.
|
|
143
|
+
def self.decoration_tint(tint)
|
|
144
|
+
value = (tint || DEFAULT_TINT).to_f
|
|
145
|
+
value.positive? ? value : nil
|
|
146
|
+
end
|
|
147
|
+
private_class_method :decoration_tint
|
|
148
|
+
|
|
149
|
+
# The decoration rect [left, right, top, bottom]: the column
|
|
150
|
+
# (or text) extents expanded outward by the given offsets.
|
|
151
|
+
def self.decoration_rect(paragraph, top_y, bottom_y, frame_left,
|
|
152
|
+
frame_right, left_offset:, right_offset:,
|
|
153
|
+
top_offset:, bottom_offset:)
|
|
154
|
+
left, right = decoration_extents(paragraph, frame_left, frame_right)
|
|
155
|
+
[left - (left_offset || 0).to_f,
|
|
156
|
+
right + (right_offset || 0).to_f,
|
|
157
|
+
top_y + (top_offset || 0).to_f,
|
|
158
|
+
bottom_y - (bottom_offset || 0).to_f]
|
|
159
|
+
end
|
|
160
|
+
private_class_method :decoration_rect
|
|
161
|
+
|
|
162
|
+
def self.stroke_border_sides(canvas, paragraph, left, right,
|
|
163
|
+
top, bottom)
|
|
164
|
+
stroke_horizontal(canvas, left, right, top,
|
|
165
|
+
paragraph.paragraph_border_top_line_weight)
|
|
166
|
+
stroke_horizontal(canvas, left, right, bottom,
|
|
167
|
+
paragraph.paragraph_border_bottom_line_weight)
|
|
168
|
+
stroke_vertical(canvas, bottom, top, left,
|
|
169
|
+
paragraph.paragraph_border_left_line_weight)
|
|
170
|
+
stroke_vertical(canvas, bottom, top, right,
|
|
171
|
+
paragraph.paragraph_border_right_line_weight)
|
|
172
|
+
end
|
|
173
|
+
private_class_method :stroke_border_sides
|
|
174
|
+
|
|
175
|
+
def self.stroke_horizontal(canvas, x1, x2, y, weight)
|
|
176
|
+
thickness = weight.to_f
|
|
177
|
+
return unless thickness.positive?
|
|
178
|
+
|
|
179
|
+
canvas.line_width = thickness
|
|
180
|
+
canvas.move_to(x1, y)
|
|
181
|
+
canvas.line_to(x2, y)
|
|
182
|
+
canvas.stroke
|
|
183
|
+
end
|
|
184
|
+
private_class_method :stroke_horizontal
|
|
185
|
+
|
|
186
|
+
def self.stroke_vertical(canvas, y1, y2, x, weight)
|
|
187
|
+
thickness = weight.to_f
|
|
188
|
+
return unless thickness.positive?
|
|
189
|
+
|
|
190
|
+
canvas.line_width = thickness
|
|
191
|
+
canvas.move_to(x, y1)
|
|
192
|
+
canvas.line_to(x, y2)
|
|
193
|
+
canvas.stroke
|
|
194
|
+
end
|
|
195
|
+
private_class_method :stroke_vertical
|
|
196
|
+
|
|
197
|
+
# Horizontal extents for shading/border: the column width, or
|
|
198
|
+
# the text width (indented by the paragraph's indents) when
|
|
199
|
+
# the shading width mode is "TextWidth".
|
|
200
|
+
def self.decoration_extents(paragraph, frame_left, frame_right)
|
|
201
|
+
if paragraph.paragraph_shading_width == "TextWidth"
|
|
202
|
+
[frame_left + (paragraph.left_indent || 0).to_f,
|
|
203
|
+
frame_right - (paragraph.right_indent || 0).to_f]
|
|
204
|
+
else
|
|
205
|
+
[frame_left, frame_right]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
private_class_method :decoration_extents
|
|
209
|
+
|
|
85
210
|
# Returns `[x1, x2]` for the rule. Width mode "Text" indents
|
|
86
211
|
# the rule by the paragraph's left/right indent (matches the
|
|
87
212
|
# text bounds); "Column" (default) spans the full column.
|
|
@@ -25,6 +25,7 @@ module Idml
|
|
|
25
25
|
# FontMetrics is available (no shaper → no wrap → rough output).
|
|
26
26
|
class TextFrameRenderer
|
|
27
27
|
DEFAULT_SIZE = 12.0
|
|
28
|
+
RUBY_SIZE_FACTOR = 0.5
|
|
28
29
|
|
|
29
30
|
def self.render(canvas, context)
|
|
30
31
|
frame = context.item
|
|
@@ -445,6 +446,12 @@ module Idml
|
|
|
445
446
|
|
|
446
447
|
emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
|
|
447
448
|
frame_left, frame_right, first_paragraph)
|
|
449
|
+
block_top, block_bottom = paragraph_block_extent(
|
|
450
|
+
paragraph, font, wrap_width, cursor_y, bottom_limit
|
|
451
|
+
)
|
|
452
|
+
ParagraphRules.emit_shading(canvas, paragraph, context,
|
|
453
|
+
block_top, block_bottom,
|
|
454
|
+
frame_left, frame_right)
|
|
448
455
|
drop_cap = emit_drop_cap(canvas, paragraph, context, frame_left,
|
|
449
456
|
cursor_y, first_paragraph)
|
|
450
457
|
|
|
@@ -463,10 +470,28 @@ module Idml
|
|
|
463
470
|
cursor_y, frame_left,
|
|
464
471
|
frame_right, para_attrs,
|
|
465
472
|
remaining_runs)
|
|
473
|
+
ParagraphRules.emit_border(canvas, paragraph, context,
|
|
474
|
+
block_top, block_bottom,
|
|
475
|
+
frame_left, frame_right)
|
|
466
476
|
[remaining_runs, cursor_y, char_cursor]
|
|
467
477
|
end
|
|
468
478
|
private_class_method :render_runs_for_paragraph
|
|
469
479
|
|
|
480
|
+
# The paragraph's block extent: top at the current cursor,
|
|
481
|
+
# bottom at the pre-measured height clamped to the frame's
|
|
482
|
+
# bottom limit. Shading and border both use this one rect so
|
|
483
|
+
# they agree even when the paragraph splits across frames
|
|
484
|
+
# (the split case shades only the rendered part).
|
|
485
|
+
def self.paragraph_block_extent(paragraph, font, wrap_width,
|
|
486
|
+
cursor_y, bottom_limit)
|
|
487
|
+
height = TextEngine::Measurement.paragraph_height(
|
|
488
|
+
paragraph, font, wrap_width
|
|
489
|
+
)
|
|
490
|
+
block_bottom = [cursor_y - height, bottom_limit].max
|
|
491
|
+
[cursor_y, block_bottom]
|
|
492
|
+
end
|
|
493
|
+
private_class_method :paragraph_block_extent
|
|
494
|
+
|
|
470
495
|
def self.iterate_paragraph_runs(canvas, paragraph, runs, context,
|
|
471
496
|
layout_frame, font, wrap_width,
|
|
472
497
|
cursor_y, bottom_limit, char_cursor,
|
|
@@ -714,15 +739,26 @@ module Idml
|
|
|
714
739
|
left_indent: left_indent,
|
|
715
740
|
)
|
|
716
741
|
|
|
717
|
-
positioned
|
|
742
|
+
emit_run_lines(canvas, positioned, run, context, size, font,
|
|
743
|
+
bottom_limit, char_cursor)
|
|
744
|
+
[positioned, next_y]
|
|
745
|
+
end
|
|
746
|
+
private_class_method :layout_run
|
|
747
|
+
|
|
748
|
+
# Emits the run's positioned lines (clipped at the bottom
|
|
749
|
+
# limit), recording positions and annotating the first line
|
|
750
|
+
# with the run's ruby when present.
|
|
751
|
+
def self.emit_run_lines(canvas, positioned, run, context, size,
|
|
752
|
+
font, bottom_limit, char_cursor)
|
|
753
|
+
positioned.each_with_index do |line, index|
|
|
718
754
|
break if line.y < bottom_limit
|
|
719
755
|
|
|
720
756
|
emit_line(canvas, line, run, context, size)
|
|
721
757
|
record_position(context, line, size, char_cursor)
|
|
758
|
+
emit_ruby(canvas, run, line, size, font, context) if index.zero?
|
|
722
759
|
end
|
|
723
|
-
[positioned, next_y]
|
|
724
760
|
end
|
|
725
|
-
private_class_method :
|
|
761
|
+
private_class_method :emit_run_lines
|
|
726
762
|
|
|
727
763
|
# Emits one positioned line: applies character-level styling
|
|
728
764
|
# (fill color + tint, capitalization, position, tracking,
|
|
@@ -755,6 +791,36 @@ module Idml
|
|
|
755
791
|
end
|
|
756
792
|
private_class_method :emit_line
|
|
757
793
|
|
|
794
|
+
# Emits the run's ruby (phonetic annotation) above the first
|
|
795
|
+
# line of the annotated run, centered over the line's width.
|
|
796
|
+
# RubyFontSize defaults to half the base size; RubyPosition
|
|
797
|
+
# values containing "Below" place it under the base text.
|
|
798
|
+
# Approximation: IDML attaches ruby to a character range;
|
|
799
|
+
# we annotate the whole first line.
|
|
800
|
+
def self.emit_ruby(canvas, run, line, base_size, font, context)
|
|
801
|
+
ruby_text = run.ruby_string
|
|
802
|
+
return if ruby_text.nil? || ruby_text.empty?
|
|
803
|
+
|
|
804
|
+
ruby_size = run.ruby_font_size || (base_size * RUBY_SIZE_FACTOR)
|
|
805
|
+
glyphs = TextEngine::Shaper.shape(text: ruby_text, font: font,
|
|
806
|
+
size: ruby_size)
|
|
807
|
+
ruby_width = glyphs.sum(&:width)
|
|
808
|
+
x = line.x + ((line.width - ruby_width) / 2)
|
|
809
|
+
y = ruby_y(run, line, base_size, ruby_size)
|
|
810
|
+
canvas.text(ruby_text, at: [x, y],
|
|
811
|
+
font: font_for_run(run, context), size: ruby_size)
|
|
812
|
+
end
|
|
813
|
+
private_class_method :emit_ruby
|
|
814
|
+
|
|
815
|
+
def self.ruby_y(run, line, base_size, ruby_size)
|
|
816
|
+
if run.ruby_position.to_s.include?("Below")
|
|
817
|
+
line.y - (base_size * 0.25) - ruby_size
|
|
818
|
+
else
|
|
819
|
+
line.y + (base_size * 0.45) + ruby_size
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
private_class_method :ruby_y
|
|
823
|
+
|
|
758
824
|
def self.leading_for(paragraph, size)
|
|
759
825
|
TextEngine::VerticalLayout.leading_for(paragraph.auto_leading, size)
|
|
760
826
|
end
|
|
@@ -43,6 +43,11 @@ module Idml
|
|
|
43
43
|
:baseline_shift,
|
|
44
44
|
:footnote_number,
|
|
45
45
|
:footnote_paragraphs,
|
|
46
|
+
# Ruby (phonetic annotation) — from the CSR's Ruby*
|
|
47
|
+
# attributes. Present only on runs with a RubyString.
|
|
48
|
+
:ruby_string,
|
|
49
|
+
:ruby_font_size,
|
|
50
|
+
:ruby_position,
|
|
46
51
|
keyword_init: true,
|
|
47
52
|
)
|
|
48
53
|
|
|
@@ -82,6 +87,28 @@ module Idml
|
|
|
82
87
|
:rule_below_left_indent,
|
|
83
88
|
:rule_below_right_indent,
|
|
84
89
|
:rule_below_width,
|
|
90
|
+
# ParagraphShading — fill rect behind the paragraph block.
|
|
91
|
+
# Color comes from Properties > ParagraphShadingColor.
|
|
92
|
+
:paragraph_shading_on,
|
|
93
|
+
:paragraph_shading_color,
|
|
94
|
+
:paragraph_shading_tint,
|
|
95
|
+
:paragraph_shading_width,
|
|
96
|
+
:paragraph_shading_left_offset,
|
|
97
|
+
:paragraph_shading_right_offset,
|
|
98
|
+
:paragraph_shading_top_offset,
|
|
99
|
+
:paragraph_shading_bottom_offset,
|
|
100
|
+
# ParagraphBorder — per-side strokes around the paragraph.
|
|
101
|
+
:paragraph_border_on,
|
|
102
|
+
:paragraph_border_color,
|
|
103
|
+
:paragraph_border_tint,
|
|
104
|
+
:paragraph_border_top_line_weight,
|
|
105
|
+
:paragraph_border_left_line_weight,
|
|
106
|
+
:paragraph_border_right_line_weight,
|
|
107
|
+
:paragraph_border_bottom_line_weight,
|
|
108
|
+
:paragraph_border_top_offset,
|
|
109
|
+
:paragraph_border_left_offset,
|
|
110
|
+
:paragraph_border_right_offset,
|
|
111
|
+
:paragraph_border_bottom_offset,
|
|
85
112
|
keyword_init: true,
|
|
86
113
|
)
|
|
87
114
|
|
|
@@ -171,7 +198,8 @@ module Idml
|
|
|
171
198
|
rule_above: resolve_attr(style_lookup, psr, :rule_above),
|
|
172
199
|
rule_above_line_weight: resolve_attr(style_lookup, psr,
|
|
173
200
|
:rule_above_line_weight),
|
|
174
|
-
rule_above_color:
|
|
201
|
+
rule_above_color: properties_color(psr, :rule_above_color) ||
|
|
202
|
+
resolve_attr(style_lookup, psr, :stroke_color),
|
|
175
203
|
rule_above_tint: resolve_attr(style_lookup, psr,
|
|
176
204
|
:rule_above_tint),
|
|
177
205
|
rule_above_offset: resolve_attr(style_lookup, psr,
|
|
@@ -185,7 +213,8 @@ module Idml
|
|
|
185
213
|
rule_below: resolve_attr(style_lookup, psr, :rule_below),
|
|
186
214
|
rule_below_line_weight: resolve_attr(style_lookup, psr,
|
|
187
215
|
:rule_below_line_weight),
|
|
188
|
-
rule_below_color:
|
|
216
|
+
rule_below_color: properties_color(psr, :rule_below_color) ||
|
|
217
|
+
resolve_attr(style_lookup, psr, :stroke_color),
|
|
189
218
|
rule_below_tint: resolve_attr(style_lookup, psr,
|
|
190
219
|
:rule_below_tint),
|
|
191
220
|
rule_below_offset: resolve_attr(style_lookup, psr,
|
|
@@ -196,12 +225,70 @@ module Idml
|
|
|
196
225
|
:rule_below_right_indent),
|
|
197
226
|
rule_below_width: resolve_attr(style_lookup, psr,
|
|
198
227
|
:rule_below_width),
|
|
228
|
+
paragraph_shading_on: resolve_attr(style_lookup, psr,
|
|
229
|
+
:paragraph_shading_on),
|
|
230
|
+
paragraph_shading_color: properties_color(psr,
|
|
231
|
+
:paragraph_shading_color),
|
|
232
|
+
paragraph_shading_tint: resolve_attr(style_lookup, psr,
|
|
233
|
+
:paragraph_shading_tint),
|
|
234
|
+
paragraph_shading_width: resolve_attr(style_lookup, psr,
|
|
235
|
+
:paragraph_shading_width),
|
|
236
|
+
paragraph_shading_left_offset: resolve_attr(
|
|
237
|
+
style_lookup, psr, :paragraph_shading_left_offset
|
|
238
|
+
),
|
|
239
|
+
paragraph_shading_right_offset: resolve_attr(
|
|
240
|
+
style_lookup, psr, :paragraph_shading_right_offset
|
|
241
|
+
),
|
|
242
|
+
paragraph_shading_top_offset: resolve_attr(
|
|
243
|
+
style_lookup, psr, :paragraph_shading_top_offset
|
|
244
|
+
),
|
|
245
|
+
paragraph_shading_bottom_offset: resolve_attr(
|
|
246
|
+
style_lookup, psr, :paragraph_shading_bottom_offset
|
|
247
|
+
),
|
|
248
|
+
paragraph_border_on: resolve_attr(style_lookup, psr,
|
|
249
|
+
:paragraph_border_on),
|
|
250
|
+
paragraph_border_color: properties_color(psr,
|
|
251
|
+
:paragraph_border_color),
|
|
252
|
+
paragraph_border_tint: resolve_attr(style_lookup, psr,
|
|
253
|
+
:paragraph_border_tint),
|
|
254
|
+
paragraph_border_top_line_weight: resolve_attr(
|
|
255
|
+
style_lookup, psr, :paragraph_border_top_line_weight
|
|
256
|
+
),
|
|
257
|
+
paragraph_border_left_line_weight: resolve_attr(
|
|
258
|
+
style_lookup, psr, :paragraph_border_left_line_weight
|
|
259
|
+
),
|
|
260
|
+
paragraph_border_right_line_weight: resolve_attr(
|
|
261
|
+
style_lookup, psr, :paragraph_border_right_line_weight
|
|
262
|
+
),
|
|
263
|
+
paragraph_border_bottom_line_weight: resolve_attr(
|
|
264
|
+
style_lookup, psr, :paragraph_border_bottom_line_weight
|
|
265
|
+
),
|
|
266
|
+
paragraph_border_top_offset: resolve_attr(
|
|
267
|
+
style_lookup, psr, :paragraph_border_top_offset
|
|
268
|
+
),
|
|
269
|
+
paragraph_border_left_offset: resolve_attr(
|
|
270
|
+
style_lookup, psr, :paragraph_border_left_offset
|
|
271
|
+
),
|
|
272
|
+
paragraph_border_right_offset: resolve_attr(
|
|
273
|
+
style_lookup, psr, :paragraph_border_right_offset
|
|
274
|
+
),
|
|
275
|
+
paragraph_border_bottom_offset: resolve_attr(
|
|
276
|
+
style_lookup, psr, :paragraph_border_bottom_offset
|
|
277
|
+
),
|
|
199
278
|
)
|
|
200
279
|
prepend_list_marker(paragraph)
|
|
201
280
|
paragraph
|
|
202
281
|
end
|
|
203
282
|
end
|
|
204
283
|
|
|
284
|
+
# Reads a color value element from the PSR's Properties (e.g.
|
|
285
|
+
# Properties > ParagraphShadingColor). Returns the color name
|
|
286
|
+
# string, or nil when the element is absent.
|
|
287
|
+
def self.properties_color(psr, element_attr)
|
|
288
|
+
psr.properties.first&.public_send(element_attr)&.value
|
|
289
|
+
end
|
|
290
|
+
private_class_method :properties_color
|
|
291
|
+
|
|
205
292
|
def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
|
|
206
293
|
footnote_counter: nil, footnote_option: nil)
|
|
207
294
|
counter = footnote_counter || Footnote.counter_for(footnote_option)
|
|
@@ -266,6 +353,9 @@ module Idml
|
|
|
266
353
|
:horizontal_scale),
|
|
267
354
|
vertical_scale: resolve_csr(style_lookup, csr, :vertical_scale),
|
|
268
355
|
baseline_shift: resolve_csr(style_lookup, csr, :baseline_shift),
|
|
356
|
+
ruby_string: resolve_csr(style_lookup, csr, :ruby_string),
|
|
357
|
+
ruby_font_size: resolve_csr(style_lookup, csr, :ruby_font_size),
|
|
358
|
+
ruby_position: resolve_csr(style_lookup, csr, :ruby_position),
|
|
269
359
|
)
|
|
270
360
|
end
|
|
271
361
|
private_class_method :text_run
|
|
@@ -7,10 +7,20 @@ module Idml
|
|
|
7
7
|
|
|
8
8
|
# Greedy word-wrap line breaker. Accumulates glyphs until
|
|
9
9
|
# exceeding the frame width, then breaks at the last space.
|
|
10
|
+
# CJK runs get a kinsoku shori post-pass (no line starts or
|
|
11
|
+
# ends with a forbidden character).
|
|
10
12
|
class LineBreaker
|
|
11
13
|
def self.break(glyphs:, frame_width:)
|
|
12
|
-
new(frame_width).break(glyphs)
|
|
14
|
+
lines = new(frame_width).break(glyphs)
|
|
15
|
+
return lines unless cjk_run?(glyphs)
|
|
16
|
+
|
|
17
|
+
CjkLayout.apply_kinsoku(lines)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.cjk_run?(glyphs)
|
|
21
|
+
glyphs.any? { |glyph| CjkLayout.cjk?(glyph.codepoint) }
|
|
13
22
|
end
|
|
23
|
+
private_class_method :cjk_run?
|
|
14
24
|
|
|
15
25
|
def initialize(frame_width)
|
|
16
26
|
@frame_width = frame_width
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module TextEngine
|
|
5
|
+
# Height measurement for paragraphs — shape + wrap each run and
|
|
6
|
+
# sum the line count × leading. Used where a paragraph's full
|
|
7
|
+
# extent must be known before its lines are laid out (paragraph
|
|
8
|
+
# shading/border rects).
|
|
9
|
+
module Measurement
|
|
10
|
+
DEFAULT_POINT_SIZE = 12.0
|
|
11
|
+
|
|
12
|
+
# Returns the paragraph's rendered height at `wrap_width`:
|
|
13
|
+
# every run's wrapped line count × leading, plus paragraph
|
|
14
|
+
# SpaceBefore / SpaceAfter.
|
|
15
|
+
def self.paragraph_height(paragraph, font, wrap_width)
|
|
16
|
+
lines_height = paragraph.runs.sum do |run|
|
|
17
|
+
size = run.point_size || DEFAULT_POINT_SIZE
|
|
18
|
+
lines = wrapped_lines(run, font, wrap_width, size)
|
|
19
|
+
lines.length *
|
|
20
|
+
TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
|
|
21
|
+
size)
|
|
22
|
+
end
|
|
23
|
+
lines_height + space_before_after(paragraph)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.wrapped_lines(run, font, wrap_width, size)
|
|
27
|
+
glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
|
|
28
|
+
size: size)
|
|
29
|
+
TextEngine::LineBreaker.break(glyphs: glyphs,
|
|
30
|
+
frame_width: wrap_width)
|
|
31
|
+
end
|
|
32
|
+
private_class_method :wrapped_lines
|
|
33
|
+
|
|
34
|
+
def self.space_before_after(paragraph)
|
|
35
|
+
(paragraph.space_before || 0) + (paragraph.space_after || 0)
|
|
36
|
+
end
|
|
37
|
+
private_class_method :space_before_after
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/idml/text_engine.rb
CHANGED
|
@@ -6,7 +6,8 @@ module Idml
|
|
|
6
6
|
# .ttf/.otf files. IDML-agnostic — works on any styled text.
|
|
7
7
|
module TextEngine
|
|
8
8
|
autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
|
|
9
|
-
autoload :Shaper,
|
|
9
|
+
autoload :Shaper, "#{__dir__}/text_engine/shaper"
|
|
10
|
+
autoload :Measurement, "#{__dir__}/text_engine/measurement"
|
|
10
11
|
autoload :ShapedGlyph, "#{__dir__}/text_engine/shaper"
|
|
11
12
|
autoload :LineBreaker, "#{__dir__}/text_engine/line_breaker"
|
|
12
13
|
autoload :Line, "#{__dir__}/text_engine/line_breaker"
|
data/lib/idml/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: idml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -407,6 +407,7 @@ files:
|
|
|
407
407
|
- lib/idml/elements/transform_attribute_option.rb
|
|
408
408
|
- lib/idml/elements/transparency_setting.rb
|
|
409
409
|
- lib/idml/elements/trap_preset.rb
|
|
410
|
+
- lib/idml/elements/typed_value.rb
|
|
410
411
|
- lib/idml/elements/xml_element.rb
|
|
411
412
|
- lib/idml/elements/xml_export_map.rb
|
|
412
413
|
- lib/idml/elements/xml_import_map.rb
|
|
@@ -484,6 +485,7 @@ files:
|
|
|
484
485
|
- lib/idml/text_engine/cjk_layout.rb
|
|
485
486
|
- lib/idml/text_engine/justifier.rb
|
|
486
487
|
- lib/idml/text_engine/line_breaker.rb
|
|
488
|
+
- lib/idml/text_engine/measurement.rb
|
|
487
489
|
- lib/idml/text_engine/pdfrb_font_metrics.rb
|
|
488
490
|
- lib/idml/text_engine/shaper.rb
|
|
489
491
|
- lib/idml/text_engine/vertical_layout.rb
|