idml 0.8.9 → 0.9.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.
@@ -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,
@@ -486,7 +511,8 @@ module Idml
486
511
  canvas, run, paragraph, context, layout_frame, font,
487
512
  run_wrap, cursor_y, space_before,
488
513
  para_attrs[:first_line_indent], para_attrs[:left_indent],
489
- char_cursor, bottom_limit
514
+ char_cursor, bottom_limit,
515
+ paragraph_last: run.equal?(runs.last)
490
516
  )
491
517
  char_cursor += positioned.length
492
518
  cursor_y = next_y
@@ -687,7 +713,7 @@ module Idml
687
713
  def self.layout_run(canvas, run, paragraph, context, layout_frame,
688
714
  font, wrap_width, cursor_y, space_before,
689
715
  first_line_indent, left_indent,
690
- char_cursor, bottom_limit)
716
+ char_cursor, bottom_limit, paragraph_last: false)
691
717
  size = run.point_size || DEFAULT_SIZE
692
718
  glyphs = TextEngine::Shaper.shape(
693
719
  text: run.text, font: font, size: size,
@@ -695,12 +721,7 @@ module Idml
695
721
  lines = TextEngine::LineBreaker.break(
696
722
  glyphs: glyphs, frame_width: wrap_width,
697
723
  )
698
- lines.each do |line|
699
- TextEngine::Justifier.justify(
700
- line: line, frame_width: wrap_width,
701
- alignment: paragraph.alignment || :left
702
- )
703
- end
724
+ justify_lines(lines, paragraph, wrap_width, paragraph_last)
704
725
  leading = leading_for(paragraph, size)
705
726
 
706
727
  positioned, next_y = TextEngine::VerticalLayout.layout_block(
@@ -714,15 +735,41 @@ module Idml
714
735
  left_indent: left_indent,
715
736
  )
716
737
 
717
- positioned.each do |line|
738
+ emit_run_lines(canvas, positioned, run, context, size, font,
739
+ bottom_limit, char_cursor)
740
+ [positioned, next_y]
741
+ end
742
+ private_class_method :layout_run
743
+
744
+ # Justifies the run's lines; only the final line of the
745
+ # paragraph's final run stays ragged under full
746
+ # justification.
747
+ def self.justify_lines(lines, paragraph, wrap_width,
748
+ paragraph_last)
749
+ lines.each_with_index do |line, index|
750
+ TextEngine::Justifier.justify(
751
+ line: line, frame_width: wrap_width,
752
+ alignment: paragraph.alignment || :left,
753
+ last_line: paragraph_last && index == lines.length - 1
754
+ )
755
+ end
756
+ end
757
+ private_class_method :justify_lines
758
+
759
+ # Emits the run's positioned lines (clipped at the bottom
760
+ # limit), recording positions and annotating the first line
761
+ # with the run's ruby when present.
762
+ def self.emit_run_lines(canvas, positioned, run, context, size,
763
+ font, bottom_limit, char_cursor)
764
+ positioned.each_with_index do |line, index|
718
765
  break if line.y < bottom_limit
719
766
 
720
767
  emit_line(canvas, line, run, context, size)
721
768
  record_position(context, line, size, char_cursor)
769
+ emit_ruby(canvas, run, line, size, font, context) if index.zero?
722
770
  end
723
- [positioned, next_y]
724
771
  end
725
- private_class_method :layout_run
772
+ private_class_method :emit_run_lines
726
773
 
727
774
  # Emits one positioned line: applies character-level styling
728
775
  # (fill color + tint, capitalization, position, tracking,
@@ -755,6 +802,36 @@ module Idml
755
802
  end
756
803
  private_class_method :emit_line
757
804
 
805
+ # Emits the run's ruby (phonetic annotation) above the first
806
+ # line of the annotated run, centered over the line's width.
807
+ # RubyFontSize defaults to half the base size; RubyPosition
808
+ # values containing "Below" place it under the base text.
809
+ # Approximation: IDML attaches ruby to a character range;
810
+ # we annotate the whole first line.
811
+ def self.emit_ruby(canvas, run, line, base_size, font, context)
812
+ ruby_text = run.ruby_string
813
+ return if ruby_text.nil? || ruby_text.empty?
814
+
815
+ ruby_size = run.ruby_font_size || (base_size * RUBY_SIZE_FACTOR)
816
+ glyphs = TextEngine::Shaper.shape(text: ruby_text, font: font,
817
+ size: ruby_size)
818
+ ruby_width = glyphs.sum(&:width)
819
+ x = line.x + ((line.width - ruby_width) / 2)
820
+ y = ruby_y(run, line, base_size, ruby_size)
821
+ canvas.text(ruby_text, at: [x, y],
822
+ font: font_for_run(run, context), size: ruby_size)
823
+ end
824
+ private_class_method :emit_ruby
825
+
826
+ def self.ruby_y(run, line, base_size, ruby_size)
827
+ if run.ruby_position.to_s.include?("Below")
828
+ line.y - (base_size * 0.25) - ruby_size
829
+ else
830
+ line.y + (base_size * 0.45) + ruby_size
831
+ end
832
+ end
833
+ private_class_method :ruby_y
834
+
758
835
  def self.leading_for(paragraph, size)
759
836
  TextEngine::VerticalLayout.leading_for(paragraph.auto_leading, size)
760
837
  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
@@ -4,19 +4,22 @@ module Idml
4
4
  module TextEngine
5
5
  # Distributes slack space within a line per the paragraph's
6
6
  # alignment. Adjusts x_offset and optionally scales inter-word
7
- # spaces for justified text.
7
+ # spaces for justified text. `last_line:` (the paragraph's final
8
+ # line) keeps ragged right under :justified, as InDesign does.
8
9
  class Justifier
9
- def self.justify(line:, frame_width:, alignment: :left)
10
- new(frame_width, alignment).justify(line)
10
+ def self.justify(line:, frame_width:, alignment: :left,
11
+ last_line: false)
12
+ new(frame_width, alignment, last_line).justify(line)
11
13
  end
12
14
 
13
- def initialize(frame_width, alignment)
15
+ def initialize(frame_width, alignment, last_line)
14
16
  @frame_width = frame_width
15
17
  @alignment = alignment
18
+ @last_line = last_line
16
19
  end
17
20
 
18
21
  def justify(line)
19
- case @alignment
22
+ case effective_alignment
20
23
  when :left, :start then line.x_offset = 0
21
24
  when :center, :middle
22
25
  line.x_offset = (@frame_width - line.width) / 2
@@ -29,6 +32,14 @@ module Idml
29
32
 
30
33
  private
31
34
 
35
+ # The paragraph's last line stays ragged under full
36
+ # justification.
37
+ def effective_alignment
38
+ return :left if @alignment == :justified && @last_line
39
+
40
+ @alignment
41
+ end
42
+
32
43
  def justify_line(line)
33
44
  spaces = line.glyphs.count(&:is_space)
34
45
  slack = @frame_width - line.width
@@ -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.2"
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.2
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
@@ -173,6 +173,8 @@ files:
173
173
  - TODO.pdf/114-text-wrap-around-shapes.md
174
174
  - TODO.pdf/115-bullet-numbered-lists.md
175
175
  - TODO.pdf/116-applied-style-resolution.md
176
+ - TODO.pdf/116-justification-last-line.md
177
+ - TODO.pdf/117-endnote-support.md
176
178
  - TODO.pdf/12-idml-to-pdf-pipeline.md
177
179
  - TODO.pdf/13-cjk-text-layout.md
178
180
  - TODO.pdf/14-page-item-element-models.md
@@ -291,6 +293,8 @@ files:
291
293
  - lib/idml/elements/document_object.rb
292
294
  - lib/idml/elements/dotted_stroke_style.rb
293
295
  - lib/idml/elements/drop_shadow_setting.rb
296
+ - lib/idml/elements/endnote.rb
297
+ - lib/idml/elements/endnote_range.rb
294
298
  - lib/idml/elements/feather_setting.rb
295
299
  - lib/idml/elements/fill_transparency_setting.rb
296
300
  - lib/idml/elements/flex_layout_attribute_option.rb
@@ -407,6 +411,7 @@ files:
407
411
  - lib/idml/elements/transform_attribute_option.rb
408
412
  - lib/idml/elements/transparency_setting.rb
409
413
  - lib/idml/elements/trap_preset.rb
414
+ - lib/idml/elements/typed_value.rb
410
415
  - lib/idml/elements/xml_element.rb
411
416
  - lib/idml/elements/xml_export_map.rb
412
417
  - lib/idml/elements/xml_import_map.rb
@@ -484,6 +489,7 @@ files:
484
489
  - lib/idml/text_engine/cjk_layout.rb
485
490
  - lib/idml/text_engine/justifier.rb
486
491
  - lib/idml/text_engine/line_breaker.rb
492
+ - lib/idml/text_engine/measurement.rb
487
493
  - lib/idml/text_engine/pdfrb_font_metrics.rb
488
494
  - lib/idml/text_engine/shaper.rb
489
495
  - lib/idml/text_engine/vertical_layout.rb