idml 0.10.5 → 0.10.7

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: 92cf9efcb8cb1f39e0047561634dd472186e817399f86ec0ffb1052e6de31db9
4
- data.tar.gz: 1226c8e6cc234f21ed9f39ed79d371fb816bed45452eb28fba5cef62517b9dce
3
+ metadata.gz: 943028d5bf17ca306e3cce0d23c4b042ddaa9473295f2fa7f4af43009a7423a0
4
+ data.tar.gz: b9c3c1e5421ffa4de44396dbc94124434dda470c3f10fabd5ddd81146c3991c1
5
5
  SHA512:
6
- metadata.gz: b369f0215c2e82053016ffd629bd14894d3d790c4dd053737a8645a919dc7c8054648a882fd6a1494df335e26d252a89c61e4c85f58f7dd7f630f54ed0686107
7
- data.tar.gz: 79604135da4d6126775c9ae9ac044034180f89a034b1e56116c278b13a07a1b728839b4114cf0c317c8ff8af5b67150d1d62b111f02981796fea389767bbc758
6
+ metadata.gz: 86717ea81b1ede4576d3d41e7026cd2731ec40024ea887d33a03c4ff7771384cb0ae9fd04f5948a322d48986dae2946adcebf167a7c51ae3ce7890a28726e7ee
7
+ data.tar.gz: 12a2089f4600a04fe5cd1d74e409c0933dc6f691156ffca4eeb87fcb25fd5d89e2a1ec58261c8a087ea00b93cf42abee0bb54d64f72032dc3b6bbe919f6cc6b4
@@ -0,0 +1,28 @@
1
+ # TODO PDF 135: MinimumGlyphScaling justification compression
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-26
4
+
5
+ ## Problem
6
+
7
+ InDesign's justification dialog exposes MinimumGlyphScaling: overlong
8
+ justified lines may squeeze glyphs uniformly down to that percent.
9
+ The gem applied only MaximumGlyphScaling (stretching, TODO 129) —
10
+ overlong lines overflowed the frame instead of compressing.
11
+
12
+ ## Solution
13
+
14
+ `TextEngine::Justifier#justify_line` now handles negative slack:
15
+ `compress_glyphs` scales every glyph uniformly, capped at
16
+ `min_glyph_scaling` percent (default 100 disables it, matching
17
+ InDesign). The limit threads through `SpacingLimits#min_glyph_scaling`,
18
+ resolved from the PSR's `MinimumGlyphScaling` via `StyleResolver`
19
+ (`Paragraph#minimum_glyph_scaling`) and applied at both call sites
20
+ (frame renderer and footnote layout).
21
+
22
+ ## Files
23
+
24
+ - `lib/idml/text_engine/justifier.rb`
25
+ - `lib/idml/render/style_resolver.rb`
26
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
27
+ - `lib/idml/render/footnote.rb`
28
+ - `spec/idml/text_engine/text_engine_spec.rb`
@@ -0,0 +1,24 @@
1
+ # TODO PDF 136: Diagonal cell strokes drawn
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-26
4
+
5
+ ## Problem
6
+
7
+ The Table schema (TODO 104) modeled `TopLeftDiagonalLine`,
8
+ `TopRightDiagonalLine`, and the `DiagonalLineStroke*` attributes, but
9
+ the renderer never drew them — struck-through table cells (common in
10
+ forms) rendered as plain boxes.
11
+
12
+ ## Solution
13
+
14
+ `TableRenderer#render_cell_diagonals` draws after cell borders:
15
+ TopLeftDiagonalLine from top-left to bottom-right corner,
16
+ TopRightDiagonalLine from top-right to bottom-left. Weight defaults
17
+ to 1pt and color to black when the flags are set without explicit
18
+ stroke attributes (InDesign defaults); `DiagonalLineStrokeTint`
19
+ applies via `ColorHelper.apply_tint`.
20
+
21
+ ## Files
22
+
23
+ - `lib/idml/render/renderers/table_renderer.rb`
24
+ - `spec/idml/render/table_renderer_spec.rb`
@@ -0,0 +1,22 @@
1
+ # TODO PDF 137: Footnote text in the simple-render fallback
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-26
4
+
5
+ ## Problem
6
+
7
+ Without FontMetrics the text renderer falls back to `simple_render`
8
+ (one `text_rich` per frame). Footnote markers rendered but the
9
+ footnote text itself was dropped entirely.
10
+
11
+ ## Solution
12
+
13
+ `emit_simple_footnotes` emits after the body block: one `text_rich`
14
+ line per footnote paragraph, stacked upward from the frame's bottom
15
+ edge below a hairline separator rule (0.5pt, quarter-width — InDesign's
16
+ footnote rule proportions). Paragraphs carry their marker prefix from
17
+ extraction, so numbering reads correctly.
18
+
19
+ ## Files
20
+
21
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
22
+ - `spec/idml/render/simple_footnote_render_spec.rb`
@@ -0,0 +1,36 @@
1
+ # TODO PDF 138: Side-aware text wrap (TextWrapSide)
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-26
4
+
5
+ ## Problem
6
+
7
+ TextWrapSide was parsed but ignored: every wrap mode narrowed the run
8
+ width by the contour overlap, which smears text over objects that sit
9
+ at a frame edge (text starts at the frame's left edge regardless of
10
+ where the object is).
11
+
12
+ ## Solution
13
+
14
+ `TextWrapResolver#wrap_adjustment` returns
15
+ `[width_reduction, x_shift]` per line band:
16
+
17
+ - `LeftSide` — text stays left of the contour: reduce so the line
18
+ ends at the contour's left edge, no shift.
19
+ - `RightSide` — text flows right of the contour: shift lines past the
20
+ contour's right edge (`Line#x_offset += shift` after justification)
21
+ and reduce by the same amount.
22
+ - `LargestArea` — pick the roomier side of the two.
23
+ - `BothSides` (default) and the spine variants — narrow by the full
24
+ overlap; true two-segment lines and binding-side context remain
25
+ approximations.
26
+ - Inverse contours keep their flip-to-inside semantics.
27
+
28
+ Multiple contours combine by max (per-run approximation, as before).
29
+ Shape contours (Contour mode) narrow by polygon overlap without side
30
+ awareness.
31
+
32
+ ## Files
33
+
34
+ - `lib/idml/render/text_wrap_resolver.rb`
35
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
36
+ - `spec/idml/render/text_wrap_resolver_spec.rb`
@@ -0,0 +1,24 @@
1
+ # TODO PDF 139: Hyphen break opportunities in the LineBreaker
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ The greedy line breaker only treated spaces as break opportunities.
8
+ Long compound words ("state-of-the-art", "e-mail") overflowed the
9
+ frame as one unbreakable run instead of wrapping after a hyphen.
10
+
11
+ ## Solution
12
+
13
+ `LineBreaker#break_after?` treats hyphen-minus (U+002D), Unicode
14
+ hyphen (U+2010), non-breaking hyphen (U+2011), and soft hyphen
15
+ (U+00AD) as break-after points: the hyphen stays on the first line
16
+ and the remainder wraps. Soft hyphens break without adding a
17
+ visible hyphen. Dictionary-based hyphenation (Knuth-Liang) remains
18
+ out of scope — the Hyphenation* PSR attributes stay parsed but
19
+ unused.
20
+
21
+ ## Files
22
+
23
+ - `lib/idml/text_engine/line_breaker.rb`
24
+ - `spec/idml/text_engine/text_engine_spec.rb`
@@ -0,0 +1,29 @@
1
+ # TODO PDF 140: CapHeight / XHeight / EmboxHeight first baselines
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ FirstBaselineOffset honored AscentOffset and FixedHeight (TODO 128)
8
+ but approximated CapHeight, XHeight, and EmboxHeight as leading —
9
+ text sat noticeably lower than InDesign places it.
10
+
11
+ ## Solution
12
+
13
+ `TextFrameRenderer#baseline_target` now handles all five modes:
14
+
15
+ - **CapHeight** — the font's cap-height metric when the provider
16
+ fills it; pdfrb 0.7.1 never does, so the fallback is the standard
17
+ 0.72 em proportion (Arial and Helvetica both sit within 1%).
18
+ - **XHeight** — 0.52 em (no x-height metric exists in pdfrb).
19
+ - **EmboxHeight** — the em box itself: the first paragraph's point
20
+ size.
21
+
22
+ `PdfrbFontMetrics#cap_height` exposes the metric for when pdfrb
23
+ starts populating it.
24
+
25
+ ## Files
26
+
27
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
28
+ - `lib/idml/text_engine/pdfrb_font_metrics.rb`
29
+ - `spec/idml/render/first_baseline_spec.rb`
@@ -0,0 +1,22 @@
1
+ # TODO PDF 141: 100+ page performance regression guard
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ No test guarded against per-page complexity blowups — a regression
8
+ to O(document) work per page would only surface as slow user
9
+ renders.
10
+
11
+ ## Solution
12
+
13
+ `perf_hundred_pages_spec.rb` builds a synthetic 120-spread package
14
+ (one page + one text frame each, all sharing a wordy story) and
15
+ renders it end-to-end through `Idml::Render.render`. Asserts all
16
+ 120 PDF pages emit and the render completes under a generous 60s
17
+ ceiling — sized to trip only on pathological blowups, not machine
18
+ noise. Baseline on this branch: ~2s for 120 pages.
19
+
20
+ ## Files
21
+
22
+ - `spec/idml/render/perf_hundred_pages_spec.rb`
@@ -1,6 +1,6 @@
1
1
  # TODO PDF 61: Known limitations and future work
2
2
 
3
- ## Status: DOCUMENTED — refreshed 2026-08-20
3
+ ## Status: DOCUMENTED — refreshed 2026-08-27
4
4
 
5
5
  Previously listed limitations (font embedding, tagged PDF, per-run
6
6
  styling, dead code) were resolved by TODOs 52/53/55/67/76 long ago;
@@ -9,22 +9,26 @@ this list reflects the current state.
9
9
  ## Known limitations
10
10
 
11
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.
12
+ - **Hyphenation**: dictionary-based hyphenation is not implemented
13
+ (Hyphenation* attributes are parsed but unused); compound words
14
+ wrap after explicit hyphens (TODO 139).
15
+ - **Justification**: word/letter spacing caps apply (TODO 119),
16
+ MaximumGlyphScaling stretches (TODO 129) and MinimumGlyphScaling
17
+ compresses overlong lines (TODO 135); ToBinding / RTL binding
18
+ alignment is not applied.
18
19
  - **Keep options**: KeepAllLinesTogether (TODO 123), KeepWithNext
19
20
  (TODO 127), and the KeepFirstLines / KeepLastLines windows
20
21
  (TODO 133, line-count approximation) are honored.
21
- - **First baseline**: AscentOffset and FixedHeight are honored
22
- (TODO 128); CapHeight / XHeight approximate as leading.
22
+ - **First baseline**: all five modes honor their metrics (TODOs
23
+ 128/140); CapHeight / XHeight use standard font proportions
24
+ (0.72 / 0.52 em) until a metrics provider fills them.
23
25
  - **StartParagraph** breaks act at frame/column granularity — no
24
26
  odd/even page parity.
25
27
  - **Text wrap**: BoundingBoxTextWrap, Contour, and Inverse modes
26
- render (TODOs 130-131); JumpObject/NextColumn approximate as
27
- the box; side-aware narrowing is not implemented.
28
+ render (TODOs 130-131) with TextWrapSide side-awareness (TODO 138:
29
+ LeftSide / RightSide / LargestArea pick the flow side; spine
30
+ variants approximate as BothSides); JumpObject/NextColumn
31
+ approximate as the box; Contour-mode shapes are not side-aware.
28
32
 
29
33
  ### CJK
30
34
  - **Mojikumi**: script spacing (TODO 125), line-end compression
@@ -34,24 +38,24 @@ this list reflects the current state.
34
38
  ruby placement is beside-column; keep options do not apply.
35
39
 
36
40
  ### Structural features
37
- - **Endnotes** (TODO 117): reference markers render; endnote TEXT
38
- rendering is gated on a real fixture (the EndnoteTextRange
39
- reference chain is opaque without one).
41
+ - **Endnotes** (TODO 117): reference markers render and endnote TEXT
42
+ renders end-of-story with ordinal prefixes; multi-story linkage
43
+ (which endnotes belong to which main story) is not modeled.
40
44
  - **Footnotes** number per story; overflow balancing (NoSplitting)
41
- is not modeled; the simple-render fallback shows markers without
42
- footnote text.
45
+ is not modeled; the simple-render fallback shows markers with
46
+ footnote text stacked at the frame bottom (TODO 137).
43
47
  - **Anchored objects** render at stored geometry; AnchorType
44
48
  text-reflow is not simulated.
45
49
  - **Tables** flow across chained frames with repeated header rows
46
- (TODO 134); row-spanning cells break at frame boundaries and
47
- diagonal cell strokes are modeled but not drawn.
50
+ (TODO 134) and diagonal cell strokes draw (TODO 136);
51
+ row-spanning cells break at frame boundaries.
48
52
  - **Inline anchored objects / footnotes inside table cells** are
49
53
  not handled.
50
54
 
51
55
  ## Future enhancements
52
56
 
53
57
  - Hyphenation dictionary integration
54
- - Shape-mode text wrap contours
55
- - Frame-spanning tables with header repeats
56
- - Class-based mojikumi tables
57
- - Performance benchmark for 100+ page documents
58
+ - Shape-mode text wrap contours (side-aware)
59
+ - Named mojikumi sets (詳細 etc.)
60
+ - ToBinding / RTL binding alignment
61
+ - Vertical-mode keep options and run-grouped Latin rotation
data/TODO.pdf/README.md CHANGED
@@ -12,10 +12,12 @@ without InDesign Server. The pipeline has three layers:
12
12
  output together. IDML Package → typed model → render plan →
13
13
  pdfrb Document → PDF file.
14
14
 
15
- All entries are DONE except: TODO 117 (endnote TEXT rendering —
16
- gated on a real fixture to resolve the EndnoteTextRange reference
17
- chain) and TODO 13's final stretch goal (full class-based mojikumi
18
- tables; script spacing and line-end compression are in). Vertical
19
- writing, kinsoku, ruby, and tate-chu-yoko all landed (TODOs
20
- 122-125). TODO 61 is the live known-limitations index. Each file
21
- carries a `## Status:` header with a brief implementation summary.
15
+ All entries are DONE including TODO 117 (end-of-story endnote
16
+ TEXT rendering), TODO 13's class-based mojikumi pair compression,
17
+ and the final known-limitation sweep (TODOs 135–138:
18
+ MinimumGlyphScaling compression, diagonal cell strokes, footnote
19
+ text in the simple-render fallback, and side-aware TextWrapSide).
20
+ Vertical writing, kinsoku, ruby, and tate-chu-yoko all landed
21
+ (TODOs 122-125). TODO 61 is the live known-limitations index. Each
22
+ file carries a `## Status:` header with a brief implementation
23
+ summary.
@@ -132,6 +132,7 @@ module Idml
132
132
  max_word_spacing: paragraph.maximum_word_spacing,
133
133
  max_letter_spacing: paragraph.maximum_letter_spacing,
134
134
  max_glyph_scaling: paragraph.maximum_glyph_scaling,
135
+ min_glyph_scaling: paragraph.minimum_glyph_scaling,
135
136
  )
136
137
  lines.each_with_index do |line, index|
137
138
  TextEngine::Justifier.justify(
@@ -104,6 +104,8 @@ module Idml
104
104
  cell_h, context)
105
105
  render_cell_border(canvas, cell, table, cell_x, cell_y,
106
106
  cell_w, cell_h, context)
107
+ render_cell_diagonals(canvas, cell, cell_x, cell_y,
108
+ cell_w, cell_h, context)
107
109
  render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
108
110
  end
109
111
  end
@@ -220,6 +222,56 @@ module Idml
220
222
  end
221
223
  private_class_method :render_cell_border
222
224
 
225
+ # TopLeftDiagonalLine runs from the cell's top-left to its
226
+ # bottom-right corner; TopRightDiagonalLine from top-right
227
+ # to bottom-left. Stroke weight defaults to 1pt and color
228
+ # to black when the flags are set without explicit stroke
229
+ # attributes (InDesign defaults). DiagonalLineInFront is
230
+ # accepted; diagonals always paint after the cell fill.
231
+ def self.render_cell_diagonals(canvas, cell, x, y, w, h, context)
232
+ return unless cell.top_left_diagonal_line ||
233
+ cell.top_right_diagonal_line
234
+
235
+ weight = cell.diagonal_line_stroke_weight
236
+ thickness = weight.nil? ? 1.0 : weight.to_f
237
+ return unless thickness.positive?
238
+
239
+ canvas.line_width = thickness
240
+ canvas.stroke_color(diagonal_color(cell, context))
241
+ diagonals_for(cell, x, y, w, h).each do |from, to|
242
+ canvas.move_to(from[0], from[1])
243
+ canvas.line_to(to[0], to[1])
244
+ canvas.stroke
245
+ end
246
+ end
247
+ private_class_method :render_cell_diagonals
248
+
249
+ def self.diagonals_for(cell, x, y, w, h)
250
+ [].tap do |diagonals|
251
+ diagonals << [[x, y + h], [x + w, y]] if cell.top_left_diagonal_line
252
+ diagonals << [[x + w, y + h], [x, y]] if cell.top_right_diagonal_line
253
+ end
254
+ end
255
+ private_class_method :diagonals_for
256
+
257
+ def self.diagonal_color(cell, context)
258
+ color = diagonal_resolved_color(cell, context)
259
+ color ? ColorHelper.to_canvas(color) : [:gray, 0.0]
260
+ end
261
+ private_class_method :diagonal_color
262
+
263
+ def self.diagonal_resolved_color(cell, context)
264
+ name = cell.diagonal_line_stroke_color
265
+ return nil if name.nil? || name == "Color/None"
266
+
267
+ resolved = context.color_resolver&.resolve(name)
268
+ return nil unless resolved
269
+
270
+ tint = cell.diagonal_line_stroke_tint
271
+ tint ? ColorHelper.apply_tint(resolved, tint) : resolved
272
+ end
273
+ private_class_method :diagonal_resolved_color
274
+
223
275
  # [top, right, bottom, left] effective weights, or nil when
224
276
  # neither cell edges nor table defaults declare any stroke
225
277
  # (the legacy uniform-rect case).
@@ -629,12 +629,19 @@ module Idml
629
629
  # the first line's baseline sits. The layout's default is
630
630
  # leading-based; AscentOffset shifts by (ascent − leading)
631
631
  # and FixedHeight by (MinimumFirstBaselineOffset − leading).
632
- # CapHeight / XHeight / EmboxHeight approximate as leading.
633
- # Returns the extra shift to subtract from the cursor.
632
+ # CapHeight uses the font's cap-height metric (leading
633
+ # fallback when the font reports none); XHeight approximates
634
+ # as 70% of cap height; EmboxHeight uses the em box (the
635
+ # first paragraph's point size). Returns the extra shift to
636
+ # subtract from the cursor.
637
+ FIRST_BASELINE_MODES = %w[
638
+ AscentOffset FixedHeight CapHeight XHeight EmboxHeight
639
+ ].freeze
640
+
634
641
  def self.first_baseline_offset(context, state, font)
635
642
  pref = context.item&.text_frame_preference&.first
636
643
  mode = pref&.first_baseline_offset
637
- return 0.0 unless %w[AscentOffset FixedHeight].include?(mode)
644
+ return 0.0 unless FIRST_BASELINE_MODES.include?(mode)
638
645
 
639
646
  baseline_target(pref, mode, state, font) -
640
647
  first_paragraph_leading(state)
@@ -643,21 +650,52 @@ module Idml
643
650
 
644
651
  # Distance from the top inset to the first baseline under
645
652
  # the given mode.
653
+ # pdfrb 0.7.1 never fills the cap_height metric, so
654
+ # CapHeight / XHeight fall back to standard font
655
+ # proportions (cap ≈ 0.72 em, x ≈ 0.52 em — Arial and
656
+ # Helvetica both sit within 1% of these).
657
+ CAP_HEIGHT_RATIO = 0.72
658
+ X_HEIGHT_RATIO = 0.52
659
+
646
660
  def self.baseline_target(pref, mode, state, font)
647
- if mode == "AscentOffset"
648
- ascent_scaled(font) * first_paragraph_leading(state)
661
+ leading = first_paragraph_leading(state)
662
+ case mode
663
+ when "AscentOffset"
664
+ ratio_scaled(font.ascent, font) * leading
665
+ when "CapHeight"
666
+ cap_ratio(font) * leading
667
+ when "XHeight"
668
+ X_HEIGHT_RATIO * leading
669
+ when "EmboxHeight"
670
+ first_paragraph_size(state)
649
671
  else
650
- pref.minimum_first_baseline_offset ||
651
- first_paragraph_leading(state)
672
+ pref.minimum_first_baseline_offset || leading
652
673
  end
653
674
  end
654
675
  private_class_method :baseline_target
655
676
 
656
- def self.ascent_scaled(font)
657
- upem = font.units_per_em.zero? ? 1 : font.units_per_em
658
- font.ascent / upem
677
+ def self.cap_ratio(font)
678
+ cap = font.cap_height
679
+ return CAP_HEIGHT_RATIO unless cap
680
+
681
+ ratio_scaled(cap, font)
682
+ end
683
+ private_class_method :cap_ratio
684
+
685
+ def self.ratio_scaled(metric, font)
686
+ upem = font.units_per_em
687
+ return 1.0 if upem.zero?
688
+
689
+ metric / upem
690
+ end
691
+ private_class_method :ratio_scaled
692
+
693
+ def self.first_paragraph_size(state)
694
+ paragraph = state.current_paragraph || state.paragraphs.first
695
+ first_run = paragraph&.runs&.first
696
+ first_run&.point_size || DEFAULT_SIZE
659
697
  end
660
- private_class_method :ascent_scaled
698
+ private_class_method :first_paragraph_size
661
699
 
662
700
  def self.first_paragraph_leading(state)
663
701
  paragraph = state.current_paragraph || state.paragraphs.first
@@ -839,14 +877,17 @@ module Idml
839
877
  rendered_count)
840
878
  run_wrap = drop_cap_wrap_width(wrap_width, dc_width,
841
879
  dc_lines, rendered_count)
842
- run_wrap -= text_wrap_overlap(context, layout_frame, cursor_y,
843
- run)
880
+ wrap_reduction, wrap_shift = text_wrap_adjust(
881
+ context, layout_frame, cursor_y, run
882
+ )
883
+ run_wrap -= wrap_reduction
844
884
  positioned, next_y = layout_run(
845
885
  canvas, run, paragraph, context, layout_frame, font,
846
886
  run_wrap, cursor_y, space_before,
847
887
  para_attrs[:first_line_indent], para_attrs[:left_indent],
848
888
  char_cursor, bottom_limit,
849
- paragraph_last: run.equal?(runs.last)
889
+ paragraph_last: run.equal?(runs.last),
890
+ wrap_shift: wrap_shift
850
891
  )
851
892
  char_cursor += positioned.length
852
893
  cursor_y = next_y
@@ -905,26 +946,27 @@ module Idml
905
946
  end
906
947
  private_class_method :render_footnote_entries
907
948
 
908
- # Computes the text-wrap overlap for a run at its current y
909
- # position. Uses the run's point_size as the line height
910
- # approximation. Returns 0.0 when no resolver is wired or no
911
- # contours overlap. Per-run approximation: all lines in the
912
- # run get the same reduced width (correct when the run is
913
- # fully inside or outside the contour; approximate when it
914
- # spans the boundary).
915
- def self.text_wrap_overlap(context, layout_frame, cursor_y, run)
949
+ # Computes the text-wrap adjustment for a run at its current y
950
+ # position: [width_reduction, x_shift]. The reduction narrows
951
+ # the wrap width; the shift moves wrapped lines right past a
952
+ # contour the text must flow around (TextWrapSide RightSide /
953
+ # LargestArea). Uses the run's point_size as the line height
954
+ # approximation. Returns [0.0, 0.0] when no resolver is wired
955
+ # or no contours overlap. Per-run approximation: all lines in
956
+ # the run get the same reduced width and shift (correct when
957
+ # the run is fully inside or outside the contour; approximate
958
+ # when it spans the boundary).
959
+ def self.text_wrap_adjust(context, layout_frame, cursor_y, run)
916
960
  resolver = context.text_wrap_resolver
917
- return 0.0 unless resolver
961
+ return [0.0, 0.0] unless resolver
918
962
 
919
963
  size = run.point_size || DEFAULT_SIZE
920
964
  frame_x = layout_frame.x + (layout_frame.inset_left || 0)
921
965
  frame_right = layout_frame.x + layout_frame.width -
922
966
  (layout_frame.inset_right || 0)
923
- overlap = resolver.overlap_width(cursor_y, size,
924
- frame_x, frame_right)
925
- [overlap, 0.0].max
967
+ resolver.wrap_adjustment(cursor_y, size, frame_x, frame_right)
926
968
  end
927
- private_class_method :text_wrap_overlap
969
+ private_class_method :text_wrap_adjust
928
970
 
929
971
  # Strips drop cap characters from the first run so they don't
930
972
  # render twice (once as the enlarged drop cap, once as normal
@@ -1047,7 +1089,8 @@ module Idml
1047
1089
  def self.layout_run(canvas, run, paragraph, context, layout_frame,
1048
1090
  font, wrap_width, cursor_y, space_before,
1049
1091
  first_line_indent, left_indent,
1050
- char_cursor, bottom_limit, paragraph_last: false)
1092
+ char_cursor, bottom_limit, paragraph_last: false,
1093
+ wrap_shift: 0.0)
1051
1094
  size = run.point_size || DEFAULT_SIZE
1052
1095
  glyphs = TextEngine::Shaper.shape(
1053
1096
  text: run.text, font: font, size: size,
@@ -1056,6 +1099,7 @@ module Idml
1056
1099
  glyphs: glyphs, frame_width: wrap_width,
1057
1100
  )
1058
1101
  justify_lines(lines, paragraph, wrap_width, paragraph_last)
1102
+ lines.each { |line| line.x_offset += wrap_shift }
1059
1103
  leading = leading_for(paragraph, size)
1060
1104
 
1061
1105
  positioned, next_y = TextEngine::VerticalLayout.layout_block(
@@ -1208,6 +1252,7 @@ module Idml
1208
1252
  max_word_spacing: paragraph.maximum_word_spacing,
1209
1253
  max_letter_spacing: paragraph.maximum_letter_spacing,
1210
1254
  max_glyph_scaling: paragraph.maximum_glyph_scaling,
1255
+ min_glyph_scaling: paragraph.minimum_glyph_scaling,
1211
1256
  )
1212
1257
  lines.each_with_index do |line, index|
1213
1258
  TextEngine::Justifier.justify(
@@ -1351,12 +1396,38 @@ module Idml
1351
1396
  runs_for,
1352
1397
  at: [box[:x], box[:y] + box[:height] - first_size],
1353
1398
  )
1399
+ emit_simple_footnotes(canvas, runs, context, box)
1354
1400
  state.paragraphs = []
1355
1401
  state.current_paragraph = nil
1356
1402
  state.runs_remaining = []
1357
1403
  end
1358
1404
  private_class_method :simple_render
1359
1405
 
1406
+ # Footnote text for the rough (no-metrics) path: one line
1407
+ # per footnote paragraph, stacked upward from the frame's
1408
+ # bottom edge below a hairline separator. Paragraphs are
1409
+ # already marker-prefixed at extraction.
1410
+ def self.emit_simple_footnotes(canvas, runs, context, box)
1411
+ paragraphs = runs.flat_map(&:footnote_paragraphs).compact
1412
+ return if paragraphs.empty?
1413
+
1414
+ size = paragraphs.first.runs.first&.point_size || DEFAULT_SIZE
1415
+ block_top = box[:y] + (paragraphs.length * size)
1416
+ canvas.line_width = 0.5
1417
+ canvas.stroke_color([:gray, 0.0])
1418
+ canvas.move_to(box[:x], block_top + (size * 0.3))
1419
+ canvas.line_to(box[:x] + (box[:width] * 0.25), block_top + (size * 0.3))
1420
+ canvas.stroke
1421
+
1422
+ paragraphs.reverse_each.with_index do |paragraph, index|
1423
+ canvas.text_rich(
1424
+ build_rich_runs(paragraph.runs, context),
1425
+ at: [box[:x], box[:y] + ((index + 1) * size)],
1426
+ )
1427
+ end
1428
+ end
1429
+ private_class_method :emit_simple_footnotes
1430
+
1360
1431
  def self.collect_runs(state)
1361
1432
  runs = state.runs_remaining || []
1362
1433
  if state.current_paragraph
@@ -69,6 +69,7 @@ module Idml
69
69
  :maximum_word_spacing,
70
70
  :maximum_letter_spacing,
71
71
  :maximum_glyph_scaling,
72
+ :minimum_glyph_scaling,
72
73
  # Forced paragraph break (StartParagraph: NextPage /
73
74
  # NextColumn / NextFrame / NextOddPage / NextEvenPage).
74
75
  :start_paragraph,
@@ -212,6 +213,9 @@ module Idml
212
213
  maximum_glyph_scaling: resolve_attr(
213
214
  style_lookup, psr, :maximum_glyph_scaling
214
215
  ),
216
+ minimum_glyph_scaling: resolve_attr(
217
+ style_lookup, psr, :minimum_glyph_scaling
218
+ ),
215
219
  start_paragraph: resolve_attr(style_lookup, psr,
216
220
  :start_paragraph),
217
221
  keep_all_lines_together: resolve_attr(
@@ -9,12 +9,16 @@ module Idml
9
9
  # text line to compute the effective wrap width — lines that
10
10
  # overlap a contour get their width reduced by the overlap.
11
11
  #
12
- # Supported: BoundingBox mode (rectangle intersection).
13
- # Unsupported: Shape mode (contour following), Inverse mode.
12
+ # Supported: BoundingBox mode (rectangle intersection) with
13
+ # TextWrapSide awareness (LeftSide / RightSide / LargestArea
14
+ # pick the side text flows on; BothSides and the spine variants
15
+ # narrow by the full overlap). Shape contours (Contour mode)
16
+ # narrow by polygon overlap without side awareness.
17
+ # Unsupported: Inverse mode's side behavior.
14
18
  class TextWrapResolver
15
19
  # A wrap contour: a PDF-coordinate rectangle the text avoids.
16
20
  Contour = Struct.new(
17
- :x, :y, :width, :height, :inverse,
21
+ :x, :y, :width, :height, :inverse, :side,
18
22
  keyword_init: true
19
23
  )
20
24
 
@@ -37,6 +41,80 @@ module Idml
37
41
 
38
42
  attr_reader :contours
39
43
 
44
+ # Side-aware adjustment for a text line: returns
45
+ # [width_reduction, x_shift]. LeftSide keeps text left of the
46
+ # contour (reduce, no shift); RightSide moves text past the
47
+ # contour's right edge (reduce + shift); LargestArea picks the
48
+ # roomier side; BothSides and the spine variants (which need
49
+ # binding-side context) reduce by the full overlap. Multiple
50
+ # contours combine by max — the per-run approximation.
51
+ def wrap_adjustment(line_y, line_height, frame_x, frame_right)
52
+ reduction = 0.0
53
+ shift = 0.0
54
+ @contours.each do |contour|
55
+ unless contour.is_a?(WrapContour::Shape)
56
+ r, s = box_adjustment(contour, line_y, line_height,
57
+ frame_x, frame_right)
58
+ reduction = [reduction, r].max
59
+ shift = [shift, s].max
60
+ next
61
+ end
62
+
63
+ width = WrapContour.overlap_width(contour, line_y,
64
+ line_height, frame_x,
65
+ frame_right)
66
+ next unless contour.inverse
67
+
68
+ reduction = [reduction,
69
+ (frame_right - frame_x) - width].max
70
+ end
71
+ [reduction, shift]
72
+ end
73
+
74
+ def box_adjustment(contour, line_y, line_height, frame_x,
75
+ frame_right)
76
+ overlap = if y_overlap?(contour, line_y, line_height) &&
77
+ x_overlap?(contour, frame_x, frame_right)
78
+ x_overlap_width(contour, frame_x, frame_right)
79
+ else
80
+ 0.0
81
+ end
82
+ # Inverse contours: text flows only inside, so the reduction
83
+ # is the frame width minus the covered part (the full width
84
+ # when the line misses the object entirely).
85
+ return [(frame_right - frame_x) - overlap, 0.0] if contour.inverse
86
+
87
+ return [0.0, 0.0] if overlap.zero?
88
+
89
+ side_adjustment(contour, overlap, frame_x, frame_right)
90
+ end
91
+ private :box_adjustment
92
+
93
+ def side_adjustment(contour, overlap, frame_x, frame_right)
94
+ case contour.side
95
+ when "LeftSide"
96
+ [frame_right - contour.x, 0.0]
97
+ when "RightSide"
98
+ shift = contour.x + contour.width - frame_x
99
+ [shift, shift]
100
+ when "LargestArea"
101
+ largest_side_adjustment(contour, frame_x, frame_right)
102
+ else
103
+ [overlap, 0.0]
104
+ end
105
+ end
106
+ private :side_adjustment
107
+
108
+ def largest_side_adjustment(contour, frame_x, frame_right)
109
+ left_free = contour.x - frame_x
110
+ right_free = frame_right - (contour.x + contour.width)
111
+ return [frame_right - contour.x, 0.0] if left_free >= right_free
112
+
113
+ shift = contour.x + contour.width - frame_x
114
+ [shift, shift]
115
+ end
116
+ private :largest_side_adjustment
117
+
40
118
  # Returns the total horizontal overlap of all contours with a
41
119
  # text line at the given y range within the given x range.
42
120
  # Sum, not max — multiple overlapping contours compound.
@@ -73,12 +151,12 @@ module Idml
73
151
  if contour_mode?(pref)
74
152
  WrapContour.shape(item, pref, page_height)
75
153
  else
76
- box_contour(item, page_height)
154
+ box_contour(item, page_height, pref)
77
155
  end
78
156
  end
79
157
  private_class_method :contour_for
80
158
 
81
- def self.box_contour(item, page_height)
159
+ def self.box_contour(item, page_height, pref = nil)
82
160
  bounds = item.geometric_bounds
83
161
  return nil unless bounds
84
162
 
@@ -86,7 +164,8 @@ module Idml
86
164
  page_height)
87
165
  Contour.new(
88
166
  x: rect[:x], y: rect[:y],
89
- width: rect[:width], height: rect[:height]
167
+ width: rect[:width], height: rect[:height],
168
+ side: pref&.text_wrap_side
90
169
  )
91
170
  end
92
171
  private_class_method :box_contour
@@ -11,12 +11,14 @@ module Idml
11
11
  # InDesign defaults: word spacing max 133%, letter spacing 0%.
12
12
  SpacingLimits = Struct.new(
13
13
  :max_word_spacing, :max_letter_spacing, :max_glyph_scaling,
14
+ :min_glyph_scaling,
14
15
  keyword_init: true
15
16
  )
16
17
 
17
18
  DEFAULT_MAX_WORD_SPACING = 133.0
18
19
  DEFAULT_MAX_LETTER_SPACING = 0.0
19
20
  DEFAULT_MAX_GLYPH_SCALING = 100.0
21
+ DEFAULT_MIN_GLYPH_SCALING = 100.0
20
22
 
21
23
  def self.justify(line:, frame_width:, alignment: :left,
22
24
  last_line: false, limits: nil)
@@ -55,6 +57,8 @@ module Idml
55
57
  def justify_line(line)
56
58
  spaces = line.glyphs.count(&:is_space)
57
59
  slack = @frame_width - line.width
60
+ return compress_glyphs(line, slack) if slack.negative?
61
+
58
62
  return if spaces.zero? || slack <= 0
59
63
 
60
64
  word_share = [slack, word_capacity(line, spaces)].min
@@ -99,6 +103,23 @@ module Idml
99
103
  end
100
104
  private :distribute_letter_spacing
101
105
 
106
+ # Overlong justified line: compress all glyphs uniformly,
107
+ # capped at min_glyph_scaling percent. InDesign's default cap
108
+ # (100%) disables it; only documents that lower the cap get
109
+ # squeezed glyphs.
110
+ def compress_glyphs(line, slack)
111
+ min_pct = @limits&.min_glyph_scaling || DEFAULT_MIN_GLYPH_SCALING
112
+ shrink = (100 - min_pct) / 100.0
113
+ return unless shrink.positive?
114
+
115
+ total = line.glyphs.sum(&:width)
116
+ return unless total.positive?
117
+
118
+ factor = [-slack / total, shrink].min
119
+ line.glyphs.each { |glyph| glyph.width *= (1 - factor) }
120
+ end
121
+ private :compress_glyphs
122
+
102
123
  # Last resort: uniform glyph scaling for any remaining slack,
103
124
  # capped at max_glyph_scaling percent. InDesign's default cap
104
125
  # (100%) disables it; only documents that relax the cap get
@@ -6,10 +6,16 @@ module Idml
6
6
  Line = Struct.new(:glyphs, :width, :x_offset)
7
7
 
8
8
  # Greedy word-wrap line breaker. Accumulates glyphs until
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).
9
+ # exceeding the frame width, then breaks at the last break
10
+ # opportunity: a space, or a hyphen (compound words wrap after
11
+ # the hyphen, which stays on the first line). CJK runs get a
12
+ # kinsoku shori post-pass (no line starts or ends with a
13
+ # forbidden character).
12
14
  class LineBreaker
15
+ # Break-after opportunities besides spaces: hyphen-minus,
16
+ # the Unicode hyphens, and the soft hyphen (which breaks
17
+ # without adding a visible hyphen).
18
+ HYPHEN_CODEPOINTS = [0x2D, 0x2010, 0x2011, 0x00AD].freeze
13
19
  def self.break(glyphs:, frame_width:)
14
20
  lines = new(frame_width).break(glyphs)
15
21
  return lines unless cjk_run?(glyphs)
@@ -44,26 +50,26 @@ module Idml
44
50
  lines = []
45
51
  current = []
46
52
  current_width = 0
47
- last_space_idx = -1
48
- width_at_space = 0
53
+ break_idx = -1
54
+ width_at_break = 0
49
55
 
50
56
  glyphs.each_with_index do |glyph, _idx|
51
57
  current << glyph
52
58
  current_width += glyph.width
53
59
 
54
- if glyph.is_space
55
- last_space_idx = current.length - 1
56
- width_at_space = current_width
60
+ if break_after?(glyph)
61
+ break_idx = current.length - 1
62
+ width_at_break = current_width
57
63
  end
58
64
 
59
65
  next unless current_width > @frame_width && current.length > 1
60
66
 
61
- if last_space_idx >= 0
62
- line_glyphs = current[0..last_space_idx]
63
- lines << Line.new(line_glyphs, width_at_space, 0)
64
- current = current[(last_space_idx + 1)..]
67
+ if break_idx >= 0
68
+ line_glyphs = current[0..break_idx]
69
+ lines << Line.new(line_glyphs, width_at_break, 0)
70
+ current = current[(break_idx + 1)..]
65
71
  current_width = current.sum(&:width)
66
- last_space_idx = -1
72
+ break_idx = -1
67
73
  elsif cjk_break?(current)
68
74
  lines << Line.new(current[0..-2], current_width - glyph.width, 0)
69
75
  current = [glyph]
@@ -78,6 +84,11 @@ module Idml
78
84
  lines << Line.new(current, current_width, 0) if current.any?
79
85
  lines
80
86
  end
87
+
88
+ def break_after?(glyph)
89
+ glyph.is_space || HYPHEN_CODEPOINTS.include?(glyph.codepoint)
90
+ end
91
+ private :break_after?
81
92
  end
82
93
  end
83
94
  end
@@ -37,6 +37,12 @@ module Idml
37
37
  0
38
38
  end
39
39
 
40
+ # Cap height in font units; nil when the font tables don't
41
+ # report one (some pdfrb-loaded fonts).
42
+ def cap_height
43
+ metrics_data[:cap_height]
44
+ end
45
+
40
46
  # Returns raw advance width in font units (not scaled by size).
41
47
  def glyph_width(codepoint)
42
48
  cp = codepoint.is_a?(String) ? codepoint.each_codepoint.first : codepoint
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.10.5"
4
+ VERSION = "0.10.7"
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.10.5
4
+ version: 0.10.7
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-26 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -197,7 +197,14 @@ files:
197
197
  - TODO.pdf/132-line-end-compression.md
198
198
  - TODO.pdf/133-keep-windows.md
199
199
  - TODO.pdf/134-table-row-flow.md
200
+ - TODO.pdf/135-minimum-glyph-scaling.md
201
+ - TODO.pdf/136-diagonal-cell-strokes.md
202
+ - TODO.pdf/137-simple-render-footnote-text.md
203
+ - TODO.pdf/138-text-wrap-sides.md
204
+ - TODO.pdf/139-hyphen-break-opportunities.md
200
205
  - TODO.pdf/14-page-item-element-models.md
206
+ - TODO.pdf/140-cap-xheight-first-baselines.md
207
+ - TODO.pdf/141-perf-hundred-pages.md
201
208
  - TODO.pdf/15-wire-spread-children.md
202
209
  - TODO.pdf/16-typed-model-pipeline.md
203
210
  - TODO.pdf/17-shape-rendering.md