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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d58f00746f6afb4233e161dfa9cdeb116d714eed0c238ec543a0f039b4167f0e
4
- data.tar.gz: 1a000e34996525fe34cdc441625c9eaa303f121c836ae3f3b07b73c12712abb1
3
+ metadata.gz: a82b32708ab694d0d794c39f33d74de7b39a9bdc38b7a6315b99a2156dcef271
4
+ data.tar.gz: 7eee074d6ecf3a46798139fa6ed6fc36c975bcaf93059c52e68e597e7b1a87f3
5
5
  SHA512:
6
- metadata.gz: 15001cbf1427dc278c701cd4980b837ff4b015f6c5657eeb5c88c0c8774933934e606439c40fa3c900368ef2959fc1170cd12841a9caadc0c025a199dd18cd68
7
- data.tar.gz: 41f45b1b2db245e1dd2d5ef40aa60990fc3c957930b3d16a633974d1f267149e8b2c5cf638b7b3549b4318bf8ea62f378fbb5d0f98ad32d4d50fbce0e3cb9505
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: PARTIALRuleAbove and RuleBelow implemented via new
4
- `Render::ParagraphRules` helper; TextFrameRenderer emits them at
5
- paragraph top/bottom. Rule color comes from PSR#stroke_color (IDML's
6
- PSR doesn't have a dedicated RuleAboveColor attribute). RuleAboveWidth
7
- "Text" vs "Column" honored. Tint scaling + line weight + offset honored.
8
-
9
- ParagraphBorder and ParagraphShading remain open — they need the
10
- paragraph's bounding rect, which the renderer doesn't currently
11
- expose to ParagraphRules. Will add when a real document fixture
12
- exercises them.
13
-
14
- ## Problem
15
-
16
- `ParagraphStyleRange` carries:
17
-
18
- - `RuleAbove`, `RuleAboveLineWeight`, `RuleAboveTint`, `RuleAboveOffset`,
19
- `RuleAboveLeftIndent`, `RuleAboveRightIndent`, `RuleAboveWidth`
20
- - `RuleBelow`, `RuleBelowLineWeight`, `RuleBelowTint`, `RuleBelowOffset`,
21
- `RuleBelowLeftIndent`, `RuleBelowRightIndent`, `RuleBelowWidth`
22
- - `ParagraphBorderOn`, `ParagraphBorderTopLineWeight`,
23
- `ParagraphBorderLeftLineWeight`, etc.
24
- - `ParagraphShadingOn`, `ParagraphShadingTint`, `ParagraphShadingColor`
25
-
26
- None of these are rendered today. Real-world documents use RuleAbove
27
- heavily for headings ("section divider line"), and ParagraphShading
28
- for callouts.
29
-
30
- ## What needs to happen
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
- - [ ] PSR with RuleAbove=true and RuleAboveLineWeight=2 draws a 2pt
41
- horizontal line above the paragraph.
42
- - [ ] PSR with RuleAboveOffset=4 leaves 4pt gap between rule and text.
43
- - [ ] PSR with ParagraphShadingOn=true and tint fills the paragraph rect.
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 — `Idml::TextEngine::CjkLayout` handles CJK glyph
4
- measurement and basic horizontal layout. Vertical writing mode,
5
- kinsoku shori, tate-chu-yoko, and ruby remain stretch goals as
6
- flagged in the original TODO. See `lib/idml/text_engine/cjk_layout.rb`.
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. Carries
6
- # optional PathBoundingBox, PathGeometry, and Label. Only the
7
- # modeled children are populated from XML; others are ignored.
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.each do |line|
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 :layout_run
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: resolve_attr(style_lookup, psr, :stroke_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: resolve_attr(style_lookup, psr, :stroke_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
@@ -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, "#{__dir__}/text_engine/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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.8.9"
4
+ VERSION = "0.9.1"
5
5
  end
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.8.9
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-17 00:00:00.000000000 Z
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