idml 0.10.5 → 0.10.6

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: f60b5f9020e44d5131e9f50e2ddfb673f9caca65bbdee8428b4670baaf96108f
4
+ data.tar.gz: 1f19b80511a14cea5fab0b76404be809a8f28e680deb91ee1b4caf9353a1b756
5
5
  SHA512:
6
- metadata.gz: b369f0215c2e82053016ffd629bd14894d3d790c4dd053737a8645a919dc7c8054648a882fd6a1494df335e26d252a89c61e4c85f58f7dd7f630f54ed0686107
7
- data.tar.gz: 79604135da4d6126775c9ae9ac044034180f89a034b1e56116c278b13a07a1b728839b4114cf0c317c8ff8af5b67150d1d62b111f02981796fea389767bbc758
6
+ metadata.gz: 9bea97bee388cae4ab403c3bea6cae233c3a378c569c0c678620bdb59a3ed5534ab9aa560866fad103d8a724351e396c72e9a533671fd44189719958f83d811e
7
+ data.tar.gz: 151917cdee9265410c788c85fb6f28d45d07ffc37cc3c4e3fc14b4578f2be57adee09393c284a53a883f1f1dbe105920ecd745a264f0311cc38c0c8f92d94a05
@@ -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`
@@ -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-26
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;
@@ -11,10 +11,10 @@ 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) and
15
- MaximumGlyphScaling stretches as the last resort (TODO 129);
16
- MinimumGlyphScaling compression and ToBinding / RTL binding
17
- alignment are not applied.
14
+ - **Justification**: word/letter spacing caps apply (TODO 119),
15
+ MaximumGlyphScaling stretches (TODO 129) and MinimumGlyphScaling
16
+ compresses overlong lines (TODO 135); ToBinding / RTL binding
17
+ alignment is not applied.
18
18
  - **Keep options**: KeepAllLinesTogether (TODO 123), KeepWithNext
19
19
  (TODO 127), and the KeepFirstLines / KeepLastLines windows
20
20
  (TODO 133, line-count approximation) are honored.
@@ -23,8 +23,10 @@ this list reflects the current state.
23
23
  - **StartParagraph** breaks act at frame/column granularity — no
24
24
  odd/even page parity.
25
25
  - **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.
26
+ render (TODOs 130-131) with TextWrapSide side-awareness (TODO 138:
27
+ LeftSide / RightSide / LargestArea pick the flow side; spine
28
+ variants approximate as BothSides); JumpObject/NextColumn
29
+ approximate as the box; Contour-mode shapes are not side-aware.
28
30
 
29
31
  ### CJK
30
32
  - **Mojikumi**: script spacing (TODO 125), line-end compression
@@ -34,24 +36,22 @@ this list reflects the current state.
34
36
  ruby placement is beside-column; keep options do not apply.
35
37
 
36
38
  ### 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).
39
+ - **Endnotes** (TODO 117): reference markers render and endnote TEXT
40
+ renders end-of-story with ordinal prefixes; multi-story linkage
41
+ (which endnotes belong to which main story) is not modeled.
40
42
  - **Footnotes** number per story; overflow balancing (NoSplitting)
41
- is not modeled; the simple-render fallback shows markers without
42
- footnote text.
43
+ is not modeled; the simple-render fallback shows markers with
44
+ footnote text stacked at the frame bottom (TODO 137).
43
45
  - **Anchored objects** render at stored geometry; AnchorType
44
46
  text-reflow is not simulated.
45
47
  - **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.
48
+ (TODO 134) and diagonal cell strokes draw (TODO 136);
49
+ row-spanning cells break at frame boundaries.
48
50
  - **Inline anchored objects / footnotes inside table cells** are
49
51
  not handled.
50
52
 
51
53
  ## Future enhancements
52
54
 
53
55
  - Hyphenation dictionary integration
54
- - Shape-mode text wrap contours
55
- - Frame-spanning tables with header repeats
56
- - Class-based mojikumi tables
56
+ - Shape-mode text wrap contours (side-aware)
57
57
  - Performance benchmark for 100+ page documents
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).
@@ -839,14 +839,17 @@ module Idml
839
839
  rendered_count)
840
840
  run_wrap = drop_cap_wrap_width(wrap_width, dc_width,
841
841
  dc_lines, rendered_count)
842
- run_wrap -= text_wrap_overlap(context, layout_frame, cursor_y,
843
- run)
842
+ wrap_reduction, wrap_shift = text_wrap_adjust(
843
+ context, layout_frame, cursor_y, run
844
+ )
845
+ run_wrap -= wrap_reduction
844
846
  positioned, next_y = layout_run(
845
847
  canvas, run, paragraph, context, layout_frame, font,
846
848
  run_wrap, cursor_y, space_before,
847
849
  para_attrs[:first_line_indent], para_attrs[:left_indent],
848
850
  char_cursor, bottom_limit,
849
- paragraph_last: run.equal?(runs.last)
851
+ paragraph_last: run.equal?(runs.last),
852
+ wrap_shift: wrap_shift
850
853
  )
851
854
  char_cursor += positioned.length
852
855
  cursor_y = next_y
@@ -905,26 +908,27 @@ module Idml
905
908
  end
906
909
  private_class_method :render_footnote_entries
907
910
 
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)
911
+ # Computes the text-wrap adjustment for a run at its current y
912
+ # position: [width_reduction, x_shift]. The reduction narrows
913
+ # the wrap width; the shift moves wrapped lines right past a
914
+ # contour the text must flow around (TextWrapSide RightSide /
915
+ # LargestArea). Uses the run's point_size as the line height
916
+ # approximation. Returns [0.0, 0.0] when no resolver is wired
917
+ # or no contours overlap. Per-run approximation: all lines in
918
+ # the run get the same reduced width and shift (correct when
919
+ # the run is fully inside or outside the contour; approximate
920
+ # when it spans the boundary).
921
+ def self.text_wrap_adjust(context, layout_frame, cursor_y, run)
916
922
  resolver = context.text_wrap_resolver
917
- return 0.0 unless resolver
923
+ return [0.0, 0.0] unless resolver
918
924
 
919
925
  size = run.point_size || DEFAULT_SIZE
920
926
  frame_x = layout_frame.x + (layout_frame.inset_left || 0)
921
927
  frame_right = layout_frame.x + layout_frame.width -
922
928
  (layout_frame.inset_right || 0)
923
- overlap = resolver.overlap_width(cursor_y, size,
924
- frame_x, frame_right)
925
- [overlap, 0.0].max
929
+ resolver.wrap_adjustment(cursor_y, size, frame_x, frame_right)
926
930
  end
927
- private_class_method :text_wrap_overlap
931
+ private_class_method :text_wrap_adjust
928
932
 
929
933
  # Strips drop cap characters from the first run so they don't
930
934
  # render twice (once as the enlarged drop cap, once as normal
@@ -1047,7 +1051,8 @@ module Idml
1047
1051
  def self.layout_run(canvas, run, paragraph, context, layout_frame,
1048
1052
  font, wrap_width, cursor_y, space_before,
1049
1053
  first_line_indent, left_indent,
1050
- char_cursor, bottom_limit, paragraph_last: false)
1054
+ char_cursor, bottom_limit, paragraph_last: false,
1055
+ wrap_shift: 0.0)
1051
1056
  size = run.point_size || DEFAULT_SIZE
1052
1057
  glyphs = TextEngine::Shaper.shape(
1053
1058
  text: run.text, font: font, size: size,
@@ -1056,6 +1061,7 @@ module Idml
1056
1061
  glyphs: glyphs, frame_width: wrap_width,
1057
1062
  )
1058
1063
  justify_lines(lines, paragraph, wrap_width, paragraph_last)
1064
+ lines.each { |line| line.x_offset += wrap_shift }
1059
1065
  leading = leading_for(paragraph, size)
1060
1066
 
1061
1067
  positioned, next_y = TextEngine::VerticalLayout.layout_block(
@@ -1208,6 +1214,7 @@ module Idml
1208
1214
  max_word_spacing: paragraph.maximum_word_spacing,
1209
1215
  max_letter_spacing: paragraph.maximum_letter_spacing,
1210
1216
  max_glyph_scaling: paragraph.maximum_glyph_scaling,
1217
+ min_glyph_scaling: paragraph.minimum_glyph_scaling,
1211
1218
  )
1212
1219
  lines.each_with_index do |line, index|
1213
1220
  TextEngine::Justifier.justify(
@@ -1351,12 +1358,38 @@ module Idml
1351
1358
  runs_for,
1352
1359
  at: [box[:x], box[:y] + box[:height] - first_size],
1353
1360
  )
1361
+ emit_simple_footnotes(canvas, runs, context, box)
1354
1362
  state.paragraphs = []
1355
1363
  state.current_paragraph = nil
1356
1364
  state.runs_remaining = []
1357
1365
  end
1358
1366
  private_class_method :simple_render
1359
1367
 
1368
+ # Footnote text for the rough (no-metrics) path: one line
1369
+ # per footnote paragraph, stacked upward from the frame's
1370
+ # bottom edge below a hairline separator. Paragraphs are
1371
+ # already marker-prefixed at extraction.
1372
+ def self.emit_simple_footnotes(canvas, runs, context, box)
1373
+ paragraphs = runs.flat_map(&:footnote_paragraphs).compact
1374
+ return if paragraphs.empty?
1375
+
1376
+ size = paragraphs.first.runs.first&.point_size || DEFAULT_SIZE
1377
+ block_top = box[:y] + (paragraphs.length * size)
1378
+ canvas.line_width = 0.5
1379
+ canvas.stroke_color([:gray, 0.0])
1380
+ canvas.move_to(box[:x], block_top + (size * 0.3))
1381
+ canvas.line_to(box[:x] + (box[:width] * 0.25), block_top + (size * 0.3))
1382
+ canvas.stroke
1383
+
1384
+ paragraphs.reverse_each.with_index do |paragraph, index|
1385
+ canvas.text_rich(
1386
+ build_rich_runs(paragraph.runs, context),
1387
+ at: [box[:x], box[:y] + ((index + 1) * size)],
1388
+ )
1389
+ end
1390
+ end
1391
+ private_class_method :emit_simple_footnotes
1392
+
1360
1393
  def self.collect_runs(state)
1361
1394
  runs = state.runs_remaining || []
1362
1395
  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
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.6"
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.10.5
4
+ version: 0.10.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -197,6 +197,10 @@ 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
200
204
  - TODO.pdf/14-page-item-element-models.md
201
205
  - TODO.pdf/15-wire-spread-children.md
202
206
  - TODO.pdf/16-typed-model-pipeline.md