idml 0.10.3 → 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: 79a78b04b80af7bc0018d4fde7500d164d8bc8b587a425c2fba89c5be19a7bb8
4
- data.tar.gz: 0b835730671e71723e043372530b1fbb6765029c50da2025315408dd2caf4fa7
3
+ metadata.gz: f60b5f9020e44d5131e9f50e2ddfb673f9caca65bbdee8428b4670baaf96108f
4
+ data.tar.gz: 1f19b80511a14cea5fab0b76404be809a8f28e680deb91ee1b4caf9353a1b756
5
5
  SHA512:
6
- metadata.gz: 3f79b3ee1dbe8a3d5cb01346f55935196e10d21ca3cd7d477132aaedafc200df0880179b568b8c071d5dbce62e77b2fe6275fdedb1a6d8fbbeb868f61b16a70f
7
- data.tar.gz: 5d556bd0f53b5817051dd5ae419fab76c4e91534e3ff0f1415ff5e22fc10949743f2e6fb5992d0c2a669ea5e8aa4e3e2c20ebac7ddfe8ae991495267cd5a2671
6
+ metadata.gz: 9bea97bee388cae4ab403c3bea6cae233c3a378c569c0c678620bdb59a3ed5534ab9aa560866fad103d8a724351e396c72e9a533671fd44189719958f83d811e
7
+ data.tar.gz: 151917cdee9265410c788c85fb6f28d45d07ffc37cc3c4e3fc14b4578f2be57adee09393c284a53a883f1f1dbe105920ecd745a264f0311cc38c0c8f92d94a05
data/Gemfile CHANGED
@@ -9,10 +9,12 @@ gem "lutaml-model", "~> 0.8.0"
9
9
  gem "pdfrb", "~> 0.7"
10
10
  gem "rake"
11
11
  gem "rspec"
12
- gem "rubocop"
13
- gem "rubocop-performance"
14
- gem "rubocop-rake"
15
- gem "rubocop-rspec"
12
+ # Pinned: new rubocop majors introduce cops that fail CI
13
+ # unexpectedly (1.90 did on 2026-08-25). Bump deliberately.
14
+ gem "rubocop", "1.90.0"
15
+ gem "rubocop-performance", "1.27.0"
16
+ gem "rubocop-rake", "0.7.1"
17
+ gem "rubocop-rspec", "3.10.2"
16
18
  gem "simplecov", require: false
17
19
  gem "thor"
18
20
  gem "yard"
@@ -1,7 +1,6 @@
1
1
  # TODO PDF 117: Endnote support
2
2
 
3
- ## Status: PARTIAL (modeling + reference markers COMPLETE
4
- 2026-08-18/19; endnote TEXT rendering OPEN pending a real fixture)
3
+ ## Status: COMPLETE implemented 2026-08-26
5
4
 
6
5
  ## Problem
7
6
 
@@ -27,7 +26,7 @@ story flagged `IsEndnoteStory="true"`. Documents with endnotes
27
26
  references instead of dropping them. `StoryInner` also parses
28
27
  `StoryPreference` (vertical writing support).
29
28
 
30
- ## Design for rendering (needs a real InDesign fixture to validate)
29
+ ## End-of-story text rendering (2026-08-26)
31
30
 
32
31
  1. ~~Marker~~ DONE 2026-08-19: CSR-level EndnoteRange emits a
33
32
  superscript marker run at the CSR's position (same pattern as
@@ -48,3 +47,22 @@ The marker-position machinery (text-range resolution) cannot be
48
47
  validated without a real fixture; blind implementation risks wrong
49
48
  anchor placement on production files. Schema modeling + round-trip
50
49
  is complete and verified.
50
+
51
+ ## Final implementation notes
52
+
53
+ When a main story carries EndnoteRange markers, the package's
54
+ endnote stories (IsEndnoteStory="true", package order) append
55
+ after the main flow via the chain state — end-of-story placement
56
+ per the design above. Each endnote story's first run is prefixed
57
+ with its ordinal ("1 …", "2 …") matching the body markers'
58
+ sequential numbering. Verified by an end-to-end synthetic-package
59
+ spec (3 text blocks with the marker, 1 without).
60
+
61
+ ## Known limitations
62
+
63
+ - Multi-story linkage is not modeled: ALL endnote stories follow
64
+ the marker-bearing story (correct for single-article documents;
65
+ the EndnoteTextRange chain remains the fixture-gated refinement
66
+ for multi-article layouts).
67
+ - EndnoteOption (Scope / StartAt / Prefix / Suffix) is not yet
68
+ modeled in Preferences; numbering is ordinal.
@@ -1,8 +1,9 @@
1
1
  # TODO PDF 13: CJK text layout
2
2
 
3
- ## Status: PARTIALvertical writing mode implemented 2026-08-19
4
- (horizontal-mode CJK was complete 2026-08-17: glyph measurement,
5
- kinsoku shori, ruby annotations).
3
+ ## Status: COMPLETEfinal mojikumi pair compression landed
4
+ 2026-08-26. Vertical writing (2026-08-19), kinsoku shori, ruby,
5
+ tate-chu-yoko, script spacing, and line-end compression were
6
+ already in.
6
7
 
7
8
  ## Vertical mode (2026-08-19)
8
9
 
@@ -22,10 +23,12 @@ kinsoku shori, ruby annotations).
22
23
 
23
24
  2026-08-20 completions: tate-chu-yoko (TODO 124), Latin rotation
24
25
  and vertical ruby (TODO 122), CJK/Latin automatic script spacing
25
- (TODO 125, a mojikumi subset wired into Shaper). The remaining
26
- stretch goal is full class-based mojikumi (per-pair punctuation
27
- compression tables); line-end punctuation compression landed
28
- 2026-08-23 as TODO 132. See `lib/idml/text_engine/cjk_layout.rb` and
26
+ (TODO 125, a mojikumi subset wired into Shaper). Final piece (2026-08-26): class-based pair compression — a
27
+ full-width closing/middle glyph followed by another full-width
28
+ punctuation compresses to half advance within the line (。」、
29
+ ー)… pairs), completing the three real mojikumi behaviors of
30
+ InDesign's default sets alongside script spacing (TODO 125) and
31
+ line-end compression (TODO 132). See `lib/idml/text_engine/cjk_layout.rb` and
29
32
  `lib/idml/text_engine/vertical_text_layout.rb`.
30
33
 
31
34
  ## Progress 2026-08-17
@@ -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,35 +23,35 @@ 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
- - **Mojikumi**: CJK/Latin auto script spacing (TODO 125) and
31
- line-end punctuation compression (TODO 132) are applied; full
32
- class-based compression tables are not.
32
+ - **Mojikumi**: script spacing (TODO 125), line-end compression
33
+ (TODO 132), and class-based pair compression (2026-08-26) are
34
+ applied; named mojikumi sets (詳細 etc.) are not modeled.
33
35
  - **Vertical mode**: Latin rotation is per glyph (not run-grouped);
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,7 +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 below are functionally DONE (basic CJK in TODO 13 is
16
- the only PARTIAL vertical writing mode, kinsoku, ruby, and
17
- tate-chu-yoko remain stretch goals). Each file carries a
18
- `## 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.
data/idml.gemspec CHANGED
@@ -12,7 +12,14 @@ Gem::Specification.new do |spec|
12
12
  spec.authors = ["Ribose"]
13
13
  spec.email = ["open.source@ribose.com"]
14
14
 
15
- spec.summary = "Parse, manipulate, and round-trip Adobe IDML files."
15
+ spec.summary = "Parse, validate, compose, and render Adobe IDML files to PDF."
16
+ spec.description = "Pure-Ruby toolkit for Adobe InDesign IDML: " \
17
+ "schema-faithful models with byte-faithful " \
18
+ "round-trip, RelaxNG validation, composition " \
19
+ "operations, and a full " \
20
+ "IDML-to-PDF rendering pipeline (text layout, " \
21
+ "tables, CJK " \
22
+ "vertical writing, text wrap, footnotes, PDF/A)."
16
23
  spec.homepage = "https://github.com/metanorma/idml"
17
24
  spec.license = "BSD-2-Clause"
18
25
  spec.required_ruby_version = Gem::Requirement.new(">= 3.3.0")
@@ -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).
@@ -77,6 +77,7 @@ module Idml
77
77
  style_lookup: context.style_lookup,
78
78
  footnote_option: Footnote.option(context.package)
79
79
  )
80
+ paragraphs.concat(endnote_paragraphs(story, context))
80
81
  tables = tables_in_story(story).map { |table| [table, 0] }
81
82
  return nil if paragraphs.empty? && tables.empty?
82
83
 
@@ -114,6 +115,55 @@ module Idml
114
115
  end
115
116
  private_class_method :render_inline_tables
116
117
 
118
+ # End-of-story endnote text (TODO 117): when the main
119
+ # story carries endnote markers, the package's endnote
120
+ # stories (IsEndnoteStory="true", in designmap StoryList
121
+ # order) append after the main flow, each prefixed with
122
+ # its ordinal so it reads as numbered. Multi-story linkage
123
+ # (which endnotes belong to which main story) is not
124
+ # modeled — all endnote stories follow the marker-bearing
125
+ # story, which matches single-article documents.
126
+ def self.endnote_paragraphs(story, context)
127
+ return [] unless endnotes_marked?(story, context)
128
+
129
+ number = 0
130
+ context.package.stories.filter_map do |endnote_story|
131
+ next unless endnote_story.inner&.is_endnote_story
132
+
133
+ number += 1
134
+ paragraphs = StyleResolver.extract_paragraphs(
135
+ endnote_story,
136
+ condition_filter: context.condition_filter,
137
+ style_lookup: context.style_lookup,
138
+ footnote_option: Footnote.option(context.package),
139
+ )
140
+ prefix_endnote_number(paragraphs, number)
141
+ paragraphs
142
+ end.flatten
143
+ end
144
+ private_class_method :endnote_paragraphs
145
+
146
+ def self.endnotes_marked?(story, context)
147
+ return false unless context.package
148
+ return false unless story_csrs(story).any? do |csr|
149
+ !csr.endnote_range.empty?
150
+ end
151
+
152
+ context.package.stories.any? do |candidate|
153
+ candidate.inner&.is_endnote_story
154
+ end
155
+ end
156
+ private_class_method :endnotes_marked?
157
+
158
+ def self.prefix_endnote_number(paragraphs, number)
159
+ return if paragraphs.empty?
160
+ return if paragraphs.first.runs.empty?
161
+
162
+ first_run = paragraphs.first.runs.first
163
+ first_run.text = "#{number} #{first_run.text}"
164
+ end
165
+ private_class_method :prefix_endnote_number
166
+
117
167
  def self.tables_in_story(story)
118
168
  story_csrs(story).flat_map(&:table).compact
119
169
  end
@@ -789,14 +839,17 @@ module Idml
789
839
  rendered_count)
790
840
  run_wrap = drop_cap_wrap_width(wrap_width, dc_width,
791
841
  dc_lines, rendered_count)
792
- run_wrap -= text_wrap_overlap(context, layout_frame, cursor_y,
793
- run)
842
+ wrap_reduction, wrap_shift = text_wrap_adjust(
843
+ context, layout_frame, cursor_y, run
844
+ )
845
+ run_wrap -= wrap_reduction
794
846
  positioned, next_y = layout_run(
795
847
  canvas, run, paragraph, context, layout_frame, font,
796
848
  run_wrap, cursor_y, space_before,
797
849
  para_attrs[:first_line_indent], para_attrs[:left_indent],
798
850
  char_cursor, bottom_limit,
799
- paragraph_last: run.equal?(runs.last)
851
+ paragraph_last: run.equal?(runs.last),
852
+ wrap_shift: wrap_shift
800
853
  )
801
854
  char_cursor += positioned.length
802
855
  cursor_y = next_y
@@ -855,26 +908,27 @@ module Idml
855
908
  end
856
909
  private_class_method :render_footnote_entries
857
910
 
858
- # Computes the text-wrap overlap for a run at its current y
859
- # position. Uses the run's point_size as the line height
860
- # approximation. Returns 0.0 when no resolver is wired or no
861
- # contours overlap. Per-run approximation: all lines in the
862
- # run get the same reduced width (correct when the run is
863
- # fully inside or outside the contour; approximate when it
864
- # spans the boundary).
865
- 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)
866
922
  resolver = context.text_wrap_resolver
867
- return 0.0 unless resolver
923
+ return [0.0, 0.0] unless resolver
868
924
 
869
925
  size = run.point_size || DEFAULT_SIZE
870
926
  frame_x = layout_frame.x + (layout_frame.inset_left || 0)
871
927
  frame_right = layout_frame.x + layout_frame.width -
872
928
  (layout_frame.inset_right || 0)
873
- overlap = resolver.overlap_width(cursor_y, size,
874
- frame_x, frame_right)
875
- [overlap, 0.0].max
929
+ resolver.wrap_adjustment(cursor_y, size, frame_x, frame_right)
876
930
  end
877
- private_class_method :text_wrap_overlap
931
+ private_class_method :text_wrap_adjust
878
932
 
879
933
  # Strips drop cap characters from the first run so they don't
880
934
  # render twice (once as the enlarged drop cap, once as normal
@@ -997,7 +1051,8 @@ module Idml
997
1051
  def self.layout_run(canvas, run, paragraph, context, layout_frame,
998
1052
  font, wrap_width, cursor_y, space_before,
999
1053
  first_line_indent, left_indent,
1000
- char_cursor, bottom_limit, paragraph_last: false)
1054
+ char_cursor, bottom_limit, paragraph_last: false,
1055
+ wrap_shift: 0.0)
1001
1056
  size = run.point_size || DEFAULT_SIZE
1002
1057
  glyphs = TextEngine::Shaper.shape(
1003
1058
  text: run.text, font: font, size: size,
@@ -1006,6 +1061,7 @@ module Idml
1006
1061
  glyphs: glyphs, frame_width: wrap_width,
1007
1062
  )
1008
1063
  justify_lines(lines, paragraph, wrap_width, paragraph_last)
1064
+ lines.each { |line| line.x_offset += wrap_shift }
1009
1065
  leading = leading_for(paragraph, size)
1010
1066
 
1011
1067
  positioned, next_y = TextEngine::VerticalLayout.layout_block(
@@ -1158,6 +1214,7 @@ module Idml
1158
1214
  max_word_spacing: paragraph.maximum_word_spacing,
1159
1215
  max_letter_spacing: paragraph.maximum_letter_spacing,
1160
1216
  max_glyph_scaling: paragraph.maximum_glyph_scaling,
1217
+ min_glyph_scaling: paragraph.minimum_glyph_scaling,
1161
1218
  )
1162
1219
  lines.each_with_index do |line, index|
1163
1220
  TextEngine::Justifier.justify(
@@ -1301,12 +1358,38 @@ module Idml
1301
1358
  runs_for,
1302
1359
  at: [box[:x], box[:y] + box[:height] - first_size],
1303
1360
  )
1361
+ emit_simple_footnotes(canvas, runs, context, box)
1304
1362
  state.paragraphs = []
1305
1363
  state.current_paragraph = nil
1306
1364
  state.runs_remaining = []
1307
1365
  end
1308
1366
  private_class_method :simple_render
1309
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
+
1310
1393
  def self.collect_runs(state)
1311
1394
  runs = state.runs_remaining || []
1312
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
data/lib/idml/render.rb CHANGED
@@ -50,14 +50,13 @@ module Idml
50
50
 
51
51
  # Render an IDML package to a PDF file. All options except
52
52
  # `package:` and `to:` are keyword-only and optional.
53
- # rubocop:disable Metrics/ParameterLists
53
+ # rubocop:disable-next Metrics/ParameterLists
54
54
  def self.render(package:, to:, font_search_paths: nil, compliance: nil,
55
55
  tagged: false, subset_fonts: true, compress: false)
56
56
  Pipeline.new(package, to, font_search_paths,
57
57
  compliance: compliance, tagged: tagged,
58
58
  subset_fonts: subset_fonts, compress: compress).call
59
59
  end
60
- # rubocop:enable Metrics/ParameterLists
61
60
  end
62
61
  end
63
62
 
@@ -74,6 +74,68 @@ module Idml
74
74
  TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
75
75
  end
76
76
 
77
+ # Class-based mojikumi pair compression: a full-width
78
+ # closing/middle glyph followed by another full-width
79
+ # punctuation glyph compresses to half advance within the
80
+ # line (。」、ー) … pairs), matching InDesign's default
81
+ # mojikumi behavior for consecutive 約物.
82
+ OPENING_CODEPOINTS = [
83
+ 0x300C, # 「
84
+ 0x300E, # 『
85
+ 0x3008, # 〈
86
+ 0x300A, # 《
87
+ 0x3010, # 【
88
+ 0x3014, # 〔
89
+ 0xFF08, # (fullwidth left paren
90
+ 0xFF3B, # [
91
+ 0xFF5B, # {
92
+ ].freeze
93
+
94
+ MIDDLE_CODEPOINTS = [
95
+ 0x30FB, # ・ fullwidth middle dot
96
+ 0x30FC, # ー prolonged sound mark
97
+ 0x2025, # ‥ two-dot leader
98
+ 0x2026, # … ellipsis
99
+ ].freeze
100
+
101
+ def apply_pair_compression(lines)
102
+ lines.each do |line|
103
+ glyphs = line.glyphs
104
+ (0...(glyphs.length - 1)).each do |index|
105
+ leading = glyphs[index]
106
+ following = glyphs[index + 1]
107
+ next unless pair_compressible?(leading, following)
108
+
109
+ leading.width /= 2.0
110
+ end
111
+ line.width = glyphs.sum(&:width)
112
+ end
113
+ lines
114
+ end
115
+
116
+ # Closing/middle glyph followed by full-width punctuation
117
+ # compresses the leading glyph to half advance.
118
+ def pair_compressible?(leading, following)
119
+ return false unless fullwidth_punct?(following.codepoint)
120
+
121
+ closing?(leading.codepoint) ||
122
+ (middle?(leading.codepoint) &&
123
+ !OPENING_CODEPOINTS.include?(following.codepoint))
124
+ end
125
+
126
+ def closing?(codepoint)
127
+ TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
128
+ end
129
+
130
+ def middle?(codepoint)
131
+ MIDDLE_CODEPOINTS.include?(codepoint)
132
+ end
133
+
134
+ def fullwidth_punct?(codepoint)
135
+ closing?(codepoint) || middle?(codepoint) ||
136
+ OPENING_CODEPOINTS.include?(codepoint)
137
+ end
138
+
77
139
  CJK_RANGES = [
78
140
  0x3000..0x303F, # CJK Symbols and Punctuation
79
141
  0x3040..0x309F, # Hiragana
@@ -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
@@ -15,6 +15,7 @@ module Idml
15
15
  return lines unless cjk_run?(glyphs)
16
16
 
17
17
  lines = CjkLayout.apply_kinsoku(lines)
18
+ CjkLayout.apply_pair_compression(lines)
18
19
  CjkLayout.apply_line_end_compression(lines)
19
20
  end
20
21
 
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.3"
4
+ VERSION = "0.10.6"
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.3
4
+ version: 0.10.6
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-24 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -94,7 +94,10 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
- description:
97
+ description: 'Pure-Ruby toolkit for Adobe InDesign IDML: schema-faithful models with
98
+ byte-faithful round-trip, RelaxNG validation, composition operations, and a full
99
+ IDML-to-PDF rendering pipeline (text layout, tables, CJK vertical writing, text
100
+ wrap, footnotes, PDF/A).'
98
101
  email:
99
102
  - open.source@ribose.com
100
103
  executables:
@@ -194,6 +197,10 @@ files:
194
197
  - TODO.pdf/132-line-end-compression.md
195
198
  - TODO.pdf/133-keep-windows.md
196
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
197
204
  - TODO.pdf/14-page-item-element-models.md
198
205
  - TODO.pdf/15-wire-spread-children.md
199
206
  - TODO.pdf/16-typed-model-pipeline.md
@@ -1149,5 +1156,5 @@ requirements: []
1149
1156
  rubygems_version: 3.5.22
1150
1157
  signing_key:
1151
1158
  specification_version: 4
1152
- summary: Parse, manipulate, and round-trip Adobe IDML files.
1159
+ summary: Parse, validate, compose, and render Adobe IDML files to PDF.
1153
1160
  test_files: []