idml 0.9.8 → 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: 29f97721ebdd63304b8c879eaa45764906dfa40b85363688aa9d860ccdd6f62c
4
- data.tar.gz: 439a3e3454be081957617ce5107ae735a47d56c4dc206104d3e483c5a20db669
3
+ metadata.gz: 13770ed8937eeed41a40bd223bb8caadc833da870858d691caa0e1806058c286
4
+ data.tar.gz: dec0fe8f24c1aaa8258ecf18506560b1e0793bd7d4ebe56bf361dc1a69ff736a
5
5
  SHA512:
6
- metadata.gz: 0c669bcf817d1e2b8ab74c116e3a0e472b8ca5529fdfeba4f0adb85d7f1ddb78da5d422c63907cbe68f4a7c381ce5862d17b674063070bce64e9664584159b97
7
- data.tar.gz: b427dc2a77703a71074fc541eb8eea36485dc78607dea9faa8f13d3ca69c70e013a749374ef8abca81057bdf5e7563f6717bea30e45ad967a5836ead3e325d6a
6
+ metadata.gz: '0394efac9722c854cd4e8c2771d0ec494aaa8d3b50ca6ea471bbe8f5f44daf62a1f1bd4d12031ee9d59b0d7b6c5f65f5378db661d5a110d056734958369a870e'
7
+ data.tar.gz: 07d3a9492ea5a41819f4fe0353176671307ad7263feedeb909bd08f8284eb79b84df0fef0057db1d089a3a02ff7d255459ea2e232e5c929d3d1ffccc70685e81
@@ -0,0 +1,20 @@
1
+ # TODO PDF 127: KeepWithNext
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## What was done
6
+
7
+ - `StyleResolver::Paragraph` carries `keep_with_next` from the PSR.
8
+ - A paragraph with KeepWithNext defers to the next frame when the
9
+ FOLLOWING paragraph is forced there (StartParagraph break) or
10
+ when the follower's first line would not fit after this
11
+ paragraph (pre-measured via TextEngine::Measurement plus the
12
+ follower's leading). Combined with TODO 123's KeepAllLinesTogether,
13
+ both keep flavors share one deferral predicate in
14
+ `consume_paragraphs`.
15
+
16
+ ## Known limitations
17
+
18
+ - The numeric strength of KeepWithNext (1–5, lines to keep) is
19
+ treated as boolean.
20
+ - Not applied in vertical writing mode.
@@ -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.
@@ -1,35 +1,54 @@
1
1
  # TODO PDF 61: Known limitations and future work
2
2
 
3
- ## Status: DOCUMENTED
3
+ ## Status: DOCUMENTED — refreshed 2026-08-20
4
4
 
5
- ## Known limitations
6
-
7
- 1. **Font embedding**: pdfrb 0.3.0 registers fonts by name but does
8
- not embed FontFile2 binary data in the PDF. Text renders correctly
9
- on systems with the font installed but may not render on other
10
- systems. Requires pdfrb font subsetting support (TODO 52).
5
+ Previously listed limitations (font embedding, tagged PDF, per-run
6
+ styling, dead code) were resolved by TODOs 52/53/55/67/76 long ago;
7
+ this list reflects the current state.
11
8
 
12
- 2. **Image fixture tests**: The fixture IDML references an external
13
- JPEG at a macOS-specific path. Tests that check image embedding
14
- are skipped on CI. A self-contained fixture with an embedded image
15
- would fix this.
16
-
17
- 3. **Text engine integration**: TextFrameRenderer uses the text engine
18
- for line breaking when FontMetrics is available (Helvetica as .ttf).
19
- On macOS, Helvetica is a .ttc collection, so the fallback simple
20
- renderer is used (no line breaking). Per-run styling (different
21
- fonts/sizes in one frame) is simplified to use the first run's style.
9
+ ## Known limitations
22
10
 
23
- 4. **Dead code**: Old modules (PdfWriter, FontEmbedder, Color, Path,
24
- Text) are marked DEPRECATED but cannot be deleted per the project's
25
- "never delete source files" policy. They are not used by any active
26
- code path.
11
+ ### Text layout
12
+ - **Hyphenation** is not implemented (Hyphenation* attributes are
13
+ parsed but unused); a hyphenation dictionary would be required.
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.
23
+ - **StartParagraph** breaks act at frame/column granularity — no
24
+ odd/even page parity.
25
+ - **Text wrap** (TODO 114): BoundingBox mode only; Shape contour
26
+ following and Inverse mode are not implemented.
27
+
28
+ ### CJK
29
+ - **Mojikumi**: CJK/Latin auto script spacing is applied (TODO
30
+ 125); full class-based punctuation compression tables are not.
31
+ - **Vertical mode**: Latin rotation is per glyph (not run-grouped);
32
+ ruby placement is beside-column; keep options do not apply.
33
+
34
+ ### Structural features
35
+ - **Endnotes** (TODO 117): reference markers render; endnote TEXT
36
+ rendering is gated on a real fixture (the EndnoteTextRange
37
+ reference chain is opaque without one).
38
+ - **Footnotes** number per story; overflow balancing (NoSplitting)
39
+ is not modeled; the simple-render fallback shows markers without
40
+ footnote text.
41
+ - **Anchored objects** render at stored geometry; AnchorType
42
+ text-reflow is not simulated.
43
+ - **Tables** do not span frames (no repeated header rows); diagonal
44
+ cell strokes are modeled but not drawn.
45
+ - **Inline anchored objects / footnotes inside table cells** are
46
+ not handled.
27
47
 
28
48
  ## Future enhancements
29
49
 
30
- - Font subsetting (TODO 52) — requires pdfrb enhancement
31
- - Tagged PDF / PDF/UA (TODO 53) — requires pdfrb StructTreeRoot
32
- - Dead code removal (TODO 51) — when policy allows
33
- - Self-contained test fixture with embedded image
34
- - Per-run text styling with cursor tracking
50
+ - Hyphenation dictionary integration
51
+ - Shape-mode text wrap contours
52
+ - Frame-spanning tables with header repeats
53
+ - Class-based mojikumi tables
35
54
  - Performance benchmark for 100+ page documents
@@ -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
@@ -629,11 +677,13 @@ module Idml
629
677
  cursor_y, bottom_limit, char_cursor,
630
678
  footnotes)
631
679
  pending = 0
632
- state.paragraphs.each do |paragraph|
680
+ state.paragraphs.each_with_index do |paragraph, index|
633
681
  break if cursor_y < bottom_limit
634
- break if paragraph_deferred?(paragraph, layout_frame, font,
635
- cursor_y, bottom_limit,
636
- pending.positive?)
682
+
683
+ next_paragraph = state.paragraphs[index + 1]
684
+ break if paragraph_deferred?(paragraph, next_paragraph,
685
+ layout_frame, font, cursor_y,
686
+ bottom_limit, pending.positive?)
637
687
 
638
688
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
639
689
  canvas, paragraph, paragraph.runs, context,
@@ -969,11 +1019,25 @@ module Idml
969
1019
  # frame: a StartParagraph forced break, or KeepAllLines-
970
1020
  # Together with insufficient remaining space (never for the
971
1021
  # frame's first paragraph, so progress is always made).
972
- def self.paragraph_deferred?(paragraph, layout_frame, font,
973
- cursor_y, bottom_limit, placed_any)
1022
+ def self.paragraph_deferred?(paragraph, next_paragraph,
1023
+ layout_frame, font, cursor_y,
1024
+ bottom_limit, placed_any)
974
1025
  return true if paragraph_break?(paragraph) && placed_any
975
- return false unless paragraph.keep_all_lines_together
976
1026
  return false unless placed_any
1027
+ return true if keep_with_next_break?(paragraph, next_paragraph,
1028
+ layout_frame, font,
1029
+ cursor_y, bottom_limit)
1030
+
1031
+ keep_all_lines_break?(paragraph, layout_frame, font,
1032
+ cursor_y, bottom_limit)
1033
+ end
1034
+ private_class_method :paragraph_deferred?
1035
+
1036
+ # KeepAllLinesTogether: defer when the paragraph cannot
1037
+ # fully fit the remaining space.
1038
+ def self.keep_all_lines_break?(paragraph, layout_frame, font,
1039
+ cursor_y, bottom_limit)
1040
+ return false unless paragraph.keep_all_lines_together
977
1041
 
978
1042
  wrap_width = TextEngine::VerticalLayout.wrap_width(
979
1043
  layout_frame, paragraph.right_indent || 0
@@ -983,7 +1047,40 @@ module Idml
983
1047
  )
984
1048
  (cursor_y - height) < bottom_limit
985
1049
  end
986
- private_class_method :paragraph_deferred?
1050
+ private_class_method :keep_all_lines_break?
1051
+
1052
+ # KeepWithNext: this paragraph defers when the next
1053
+ # paragraph is forced to the next frame, or when the next
1054
+ # paragraph's first line would not fit after this one.
1055
+ def self.keep_with_next_break?(paragraph, next_paragraph,
1056
+ layout_frame, font, cursor_y,
1057
+ bottom_limit)
1058
+ return false unless keepable_pair?(paragraph, next_paragraph)
1059
+ return true if paragraph_break?(next_paragraph)
1060
+
1061
+ wrap_width = TextEngine::VerticalLayout.wrap_width(
1062
+ layout_frame, paragraph.right_indent || 0
1063
+ )
1064
+ height = TextEngine::Measurement.paragraph_height(
1065
+ paragraph, font, wrap_width
1066
+ )
1067
+ (cursor_y - height - follower_leading(next_paragraph)) <
1068
+ bottom_limit
1069
+ end
1070
+ private_class_method :keep_with_next_break?
1071
+
1072
+ def self.keepable_pair?(paragraph, next_paragraph)
1073
+ !paragraph.keep_with_next.nil? && !next_paragraph.nil?
1074
+ end
1075
+ private_class_method :keepable_pair?
1076
+
1077
+ def self.follower_leading(next_paragraph)
1078
+ size = next_paragraph.runs.first&.point_size || DEFAULT_SIZE
1079
+ TextEngine::VerticalLayout.leading_for(
1080
+ next_paragraph.auto_leading, size
1081
+ )
1082
+ end
1083
+ private_class_method :follower_leading
987
1084
 
988
1085
  # True when the paragraph requests a forced break to the
989
1086
  # next frame/column (StartParagraph). All break flavors act
@@ -1002,6 +1099,7 @@ module Idml
1002
1099
  limits = TextEngine::Justifier::SpacingLimits.new(
1003
1100
  max_word_spacing: paragraph.maximum_word_spacing,
1004
1101
  max_letter_spacing: paragraph.maximum_letter_spacing,
1102
+ max_glyph_scaling: paragraph.maximum_glyph_scaling,
1005
1103
  )
1006
1104
  lines.each_with_index do |line, index|
1007
1105
  TextEngine::Justifier.justify(
@@ -68,12 +68,16 @@ 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,
74
75
  # Widow/orphan control: KeepAllLinesTogether pushes the whole
75
- # paragraph to the next frame when it cannot fully fit.
76
+ # paragraph to the next frame when it cannot fully fit;
77
+ # KeepWithNext binds this paragraph to the next paragraph's
78
+ # first line.
76
79
  :keep_all_lines_together,
80
+ :keep_with_next,
77
81
  :bullets_and_numbering_list_type,
78
82
  :bullet_character_value,
79
83
  :bullets_text_after,
@@ -202,11 +206,16 @@ module Idml
202
206
  maximum_letter_spacing: resolve_attr(
203
207
  style_lookup, psr, :maximum_letter_spacing
204
208
  ),
209
+ maximum_glyph_scaling: resolve_attr(
210
+ style_lookup, psr, :maximum_glyph_scaling
211
+ ),
205
212
  start_paragraph: resolve_attr(style_lookup, psr,
206
213
  :start_paragraph),
207
214
  keep_all_lines_together: resolve_attr(
208
215
  style_lookup, psr, :keep_all_lines_together
209
216
  ),
217
+ keep_with_next: resolve_attr(style_lookup, psr,
218
+ :keep_with_next),
210
219
  drop_cap_lines: resolve_attr(style_lookup, psr, :drop_cap_lines),
211
220
  drop_cap_characters: resolve_attr(style_lookup, psr,
212
221
  :drop_cap_characters),
@@ -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.8"
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.8
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -185,6 +185,9 @@ files:
185
185
  - TODO.pdf/124-tatechuyoko-vertical.md
186
186
  - TODO.pdf/125-script-spacing.md
187
187
  - TODO.pdf/126-part-cache.md
188
+ - TODO.pdf/127-keep-with-next.md
189
+ - TODO.pdf/128-first-baseline-offset.md
190
+ - TODO.pdf/129-justification-glyph-scaling.md
188
191
  - TODO.pdf/13-cjk-text-layout.md
189
192
  - TODO.pdf/14-page-item-element-models.md
190
193
  - TODO.pdf/15-wire-spread-children.md