idml 0.9.9 → 0.10.0

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: fd4031c47fca99598c64ed757ac446d3e1f2d2870603eb31af338132dae01288
4
- data.tar.gz: 520a5ded7c74cfc50d0f893921486930c295b113f7e0d3c7af4dfb576186498c
3
+ metadata.gz: 13770ed8937eeed41a40bd223bb8caadc833da870858d691caa0e1806058c286
4
+ data.tar.gz: dec0fe8f24c1aaa8258ecf18506560b1e0793bd7d4ebe56bf361dc1a69ff736a
5
5
  SHA512:
6
- metadata.gz: bad5a41e54b9e173241e08ba9e7006e6f621230ff782e89e4821d6b26749ee11b30fe8a38dae5185ee1cb41e4497ec20f32267584852cac43c9a37e41f488990
7
- data.tar.gz: a62b908ae372636a3dec1722265ee44b24ab847510b348dd0029a3d04b388e41c6ad708f53c11ba21d7077817cfa831d700626ee73cc0380e46bfcb02e7ea58d
6
+ metadata.gz: '0394efac9722c854cd4e8c2771d0ec494aaa8d3b50ca6ea471bbe8f5f44daf62a1f1bd4d12031ee9d59b0d7b6c5f65f5378db661d5a110d056734958369a870e'
7
+ data.tar.gz: 07d3a9492ea5a41819f4fe0353176671307ad7263feedeb909bd08f8284eb79b84df0fef0057db1d089a3a02ff7d255459ea2e232e5c929d3d1ffccc70685e81
@@ -0,0 +1,21 @@
1
+ # TODO PDF 128: Text frame FirstBaselineOffset
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-23
4
+
5
+ ## What was done
6
+
7
+ `TextFrameRenderer` honors
8
+ `TextFramePreference.FirstBaselineOffset` when positioning the
9
+ first line's baseline: AscentOffset places it ascent-below the top
10
+ inset (typically higher than the default leading-based position);
11
+ FixedHeight uses MinimumFirstBaselineOffset. LeadingOffset /
12
+ CapHeight / XHeight / EmboxHeight approximate as the existing
13
+ leading-based default (cap/x-height metrics are not exposed by
14
+ PdfrbFontMetrics). Applied in both the single-column and
15
+ multi-column paths.
16
+
17
+ ## Known limitations
18
+
19
+ - CapHeight / XHeight offsets fall back to leading (font metrics
20
+ expose ascent only).
21
+ - Not applied in vertical writing mode.
@@ -0,0 +1,18 @@
1
+ # TODO PDF 129: Justification glyph scaling
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-23
4
+
5
+ ## What was done
6
+
7
+ `Justifier` distributes any residual slack remaining after the
8
+ word-spacing and letter-spacing caps as uniform glyph scaling,
9
+ capped at `MaximumGlyphScaling` (PSR → Paragraph → SpacingLimits).
10
+ InDesign's default cap (100%) disables scaling — only documents
11
+ that relax the cap get stretched glyphs, matching TODO 119's
12
+ layered distribution: words first, then letters, then glyphs.
13
+
14
+ ## Known limitations
15
+
16
+ - MinimumGlyphScaling (compression of overlong lines) is not
17
+ applied — the breaker never produces overlong justified lines
18
+ except forced unbreakable runs.
@@ -11,12 +11,15 @@ this list reflects the current state.
11
11
  ### Text layout
12
12
  - **Hyphenation** is not implemented (Hyphenation* attributes are
13
13
  parsed but unused); a hyphenation dictionary would be required.
14
- - **Justification**: word/letter spacing caps apply (TODO 119);
15
- glyph scaling (Min/Max/DesiredGlyphScaling) is not applied;
16
- ToBinding / RTL binding alignment maps to left.
17
- - **Keep options**: whole-paragraph KeepAllLinesTogether and
18
- KeepWithNext(first-line) are honored; partial keep windows
19
- (KeepFirstLines / KeepLastLines) are not.
14
+ - **Justification**: word/letter spacing caps apply (TODO 119) and
15
+ MaximumGlyphScaling stretches as the last resort (TODO 129);
16
+ MinimumGlyphScaling compression and ToBinding / RTL binding
17
+ alignment are not applied.
18
+ - **Keep options**: whole-paragraph KeepAllLinesTogether (TODO
19
+ 123) and KeepWithNext (TODO 127) are honored; partial keep
20
+ windows (KeepFirstLines / KeepLastLines) are not.
21
+ - **First baseline**: AscentOffset and FixedHeight are honored
22
+ (TODO 128); CapHeight / XHeight approximate as leading.
20
23
  - **StartParagraph** breaks act at frame/column granularity — no
21
24
  odd/even page parity.
22
25
  - **Text wrap** (TODO 114): BoundingBox mode only; Shape contour
@@ -131,6 +131,7 @@ module Idml
131
131
  limits = TextEngine::Justifier::SpacingLimits.new(
132
132
  max_word_spacing: paragraph.maximum_word_spacing,
133
133
  max_letter_spacing: paragraph.maximum_letter_spacing,
134
+ max_glyph_scaling: paragraph.maximum_glyph_scaling,
134
135
  )
135
136
  lines.each_with_index do |line, index|
136
137
  TextEngine::Justifier.justify(
@@ -433,6 +433,7 @@ module Idml
433
433
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
434
434
  footnotes = []
435
435
  cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
436
+ cursor_y -= first_baseline_offset(context, state, font)
436
437
  char_cursor = state.char_cursor
437
438
 
438
439
  if state.current_paragraph
@@ -473,7 +474,7 @@ module Idml
473
474
  top_y = col_frame.y + col_frame.height - (col_frame.inset_top || 0)
474
475
  bottom_limit = TextEngine::VerticalLayout.bottom_limit(col_frame)
475
476
  footnotes = []
476
- cursor_y = top_y
477
+ cursor_y = top_y - first_baseline_offset(context, state, font)
477
478
  char_cursor = state.char_cursor
478
479
 
479
480
  if state.current_paragraph
@@ -564,6 +565,53 @@ module Idml
564
565
  end
565
566
  private_class_method :vertical_justify_top
566
567
 
568
+ # FirstBaselineOffset: how far below the frame's top inset
569
+ # the first line's baseline sits. The layout's default is
570
+ # leading-based; AscentOffset shifts by (ascent − leading)
571
+ # and FixedHeight by (MinimumFirstBaselineOffset − leading).
572
+ # CapHeight / XHeight / EmboxHeight approximate as leading.
573
+ # Returns the extra shift to subtract from the cursor.
574
+ def self.first_baseline_offset(context, state, font)
575
+ pref = context.item&.text_frame_preference&.first
576
+ mode = pref&.first_baseline_offset
577
+ return 0.0 unless %w[AscentOffset FixedHeight].include?(mode)
578
+
579
+ baseline_target(pref, mode, state, font) -
580
+ first_paragraph_leading(state)
581
+ end
582
+ private_class_method :first_baseline_offset
583
+
584
+ # Distance from the top inset to the first baseline under
585
+ # the given mode.
586
+ def self.baseline_target(pref, mode, state, font)
587
+ if mode == "AscentOffset"
588
+ ascent_scaled(font) * first_paragraph_leading(state)
589
+ else
590
+ pref.minimum_first_baseline_offset ||
591
+ first_paragraph_leading(state)
592
+ end
593
+ end
594
+ private_class_method :baseline_target
595
+
596
+ def self.ascent_scaled(font)
597
+ upem = font.units_per_em.zero? ? 1 : font.units_per_em
598
+ font.ascent / upem
599
+ end
600
+ private_class_method :ascent_scaled
601
+
602
+ def self.first_paragraph_leading(state)
603
+ paragraph = state.current_paragraph || state.paragraphs.first
604
+ unless paragraph
605
+ return DEFAULT_SIZE *
606
+ TextEngine::VerticalLayout::DEFAULT_LEADING_FACTOR
607
+ end
608
+
609
+ size = paragraph.runs.first&.point_size || DEFAULT_SIZE
610
+ TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
611
+ size)
612
+ end
613
+ private_class_method :first_paragraph_leading
614
+
567
615
  def self.text_frame_vertical_justification(context)
568
616
  pref = context.item&.text_frame_preference&.first
569
617
  pref&.vertical_justification
@@ -1051,6 +1099,7 @@ module Idml
1051
1099
  limits = TextEngine::Justifier::SpacingLimits.new(
1052
1100
  max_word_spacing: paragraph.maximum_word_spacing,
1053
1101
  max_letter_spacing: paragraph.maximum_letter_spacing,
1102
+ max_glyph_scaling: paragraph.maximum_glyph_scaling,
1054
1103
  )
1055
1104
  lines.each_with_index do |line, index|
1056
1105
  TextEngine::Justifier.justify(
@@ -68,6 +68,7 @@ module Idml
68
68
  # Justification distribution caps (percent; from PSR).
69
69
  :maximum_word_spacing,
70
70
  :maximum_letter_spacing,
71
+ :maximum_glyph_scaling,
71
72
  # Forced paragraph break (StartParagraph: NextPage /
72
73
  # NextColumn / NextFrame / NextOddPage / NextEvenPage).
73
74
  :start_paragraph,
@@ -205,6 +206,9 @@ module Idml
205
206
  maximum_letter_spacing: resolve_attr(
206
207
  style_lookup, psr, :maximum_letter_spacing
207
208
  ),
209
+ maximum_glyph_scaling: resolve_attr(
210
+ style_lookup, psr, :maximum_glyph_scaling
211
+ ),
208
212
  start_paragraph: resolve_attr(style_lookup, psr,
209
213
  :start_paragraph),
210
214
  keep_all_lines_together: resolve_attr(
@@ -10,11 +10,13 @@ module Idml
10
10
  # Justification distribution caps, in percent (100 = natural).
11
11
  # InDesign defaults: word spacing max 133%, letter spacing 0%.
12
12
  SpacingLimits = Struct.new(
13
- :max_word_spacing, :max_letter_spacing, keyword_init: true
13
+ :max_word_spacing, :max_letter_spacing, :max_glyph_scaling,
14
+ keyword_init: true
14
15
  )
15
16
 
16
17
  DEFAULT_MAX_WORD_SPACING = 133.0
17
18
  DEFAULT_MAX_LETTER_SPACING = 0.0
19
+ DEFAULT_MAX_GLYPH_SCALING = 100.0
18
20
 
19
21
  def self.justify(line:, frame_width:, alignment: :left,
20
22
  last_line: false, limits: nil)
@@ -63,7 +65,12 @@ module Idml
63
65
  glyph.width += extra
64
66
  end
65
67
  residual = slack - word_share
66
- distribute_letter_spacing(line, residual) if residual.positive?
68
+ return unless residual.positive?
69
+
70
+ before = line.glyphs.sum(&:width)
71
+ distribute_letter_spacing(line, residual)
72
+ residual -= (line.glyphs.sum(&:width) - before)
73
+ distribute_glyph_scaling(line, residual) if residual.positive?
67
74
  end
68
75
 
69
76
  # Total room the word spaces may grow within the max-word-
@@ -91,6 +98,23 @@ module Idml
91
98
  line.glyphs.each { |glyph| glyph.width += add }
92
99
  end
93
100
  private :distribute_letter_spacing
101
+
102
+ # Last resort: uniform glyph scaling for any remaining slack,
103
+ # capped at max_glyph_scaling percent. InDesign's default cap
104
+ # (100%) disables it; only documents that relax the cap get
105
+ # stretched glyphs.
106
+ def distribute_glyph_scaling(line, residual)
107
+ max_pct = @limits&.max_glyph_scaling || DEFAULT_MAX_GLYPH_SCALING
108
+ grow = (max_pct - 100) / 100.0
109
+ return unless grow.positive?
110
+
111
+ total = line.glyphs.sum(&:width)
112
+ return unless total.positive?
113
+
114
+ factor = [residual / total, grow].min
115
+ line.glyphs.each { |glyph| glyph.width *= (1 + factor) }
116
+ end
117
+ private :distribute_glyph_scaling
94
118
  end
95
119
  end
96
120
  end
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.9.9"
4
+ VERSION = "0.10.0"
5
5
  end
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.9.9
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -186,6 +186,8 @@ files:
186
186
  - TODO.pdf/125-script-spacing.md
187
187
  - TODO.pdf/126-part-cache.md
188
188
  - TODO.pdf/127-keep-with-next.md
189
+ - TODO.pdf/128-first-baseline-offset.md
190
+ - TODO.pdf/129-justification-glyph-scaling.md
189
191
  - TODO.pdf/13-cjk-text-layout.md
190
192
  - TODO.pdf/14-page-item-element-models.md
191
193
  - TODO.pdf/15-wire-spread-children.md