idml 0.9.3 → 0.9.5

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: b5306c44be037a52666aa508b9cdb76978561403d72bc43896ea8575b3f61340
4
- data.tar.gz: 1d46f1c6de5f53f564f69ccfc41349d48d12b5f3522b7dfae1d01dc79f92ae00
3
+ metadata.gz: 955ca35f14b9460085c65187586817bf655085d3f814660e790c7050e1abbe5f
4
+ data.tar.gz: 1aa96f29ecc97594128d6f88a245e46383efc99f0ff4d9f6797947cd8fe361a9
5
5
  SHA512:
6
- metadata.gz: 4d6830d86be109139a52ee6c2d780f31c33488eee4090b6a64ca938c871fd78429d9da7dbd9b7911d2a814e6908c1a05d53b6083abbb6b74c9d04fec141fcf18
7
- data.tar.gz: '0886ff7d2aea4389f5bd0779151be35e6dd40473c2bec635ca4ff25f10321b196c09b171d104f405c4133ab699cfc008773a11533ad43fd870e755cd29b2e168'
6
+ metadata.gz: 638e2df6de7b160a8f7c64703309986f32644e29ada18f9c8f611b967e80ee4e9fdcf84cc7687ecd364b6aa964bcbb5831c4c279a841dd203b412f39d710fbc1
7
+ data.tar.gz: 9ef18bb1487ed2d6206c2c3aef6fe4ef699a12db584c2052557343144ae3f38d32cbcc275972114b2b46b1cc660c1cb24525befcd616462c33f9ab5ec52fd6e0
@@ -1,7 +1,7 @@
1
1
  # TODO PDF 117: Endnote support
2
2
 
3
- ## Status: OPEN (modeling COMPLETE 2026-08-18; rendering OPEN pending
4
- a real fixture)
3
+ ## Status: PARTIAL (modeling + reference markers COMPLETE
4
+ 2026-08-18/19; endnote TEXT rendering OPEN pending a real fixture)
5
5
 
6
6
  ## Problem
7
7
 
@@ -20,14 +20,19 @@ story flagged `IsEndnoteStory="true"`. Documents with endnotes
20
20
  collections). Round-trip + parse specs.
21
21
  - `StoryInner#is_endnote_story` already parsed, so endnote stories
22
22
  are identifiable.
23
+ - Reference markers render (2026-08-19): CSR-level `EndnoteRange`
24
+ elements (verified against CharacterStyleRange_Object in
25
+ Story.rnc) emit superscript marker runs numbered by a counter
26
+ SEPARATE from footnotes — endnoted text now shows visible numbered
27
+ references instead of dropping them. `StoryInner` also parses
28
+ `StoryPreference` (vertical writing support).
23
29
 
24
30
  ## Design for rendering (needs a real InDesign fixture to validate)
25
31
 
26
- 1. Marker: story-level `Endnote`/`EndnoteRange` pairs delimit the
27
- anchor range; marker position requires story text-offset
28
- bookkeeping the extraction layer doesn't currently track (unlike
29
- footnotes, which sit inside a CSR). A range-aware extraction is
30
- the prerequisite.
32
+ 1. ~~Marker~~ DONE 2026-08-19: CSR-level EndnoteRange emits a
33
+ superscript marker run at the CSR's position (same pattern as
34
+ footnotes). Story-level Endnote/EndnoteRange pairs (no CSR
35
+ anchor) remain unplaced.
31
36
  2. Endnote text: resolve `EndnoteTextRange` → the endnote story
32
37
  (IsEndnoteStory) → its paragraphs.
33
38
  3. Placement: per EndnoteOption Scope — end of story / end of
@@ -0,0 +1,34 @@
1
+ # TODO PDF 119: Justification word/letter spacing limits
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-19
4
+
5
+ ## Problem
6
+
7
+ `TextEngine::Justifier` stretched inter-word spaces without limit
8
+ under full justification — a line with one space could widen that
9
+ gap across the whole column. IDML paragraphs declare
10
+ MaximumWordSpacing / MaximumLetterSpacing (percentages,
11
+ InDesign defaults 133% word, 0% letter) that cap how justification
12
+ slack is distributed.
13
+
14
+ ## What was done
15
+
16
+ - `Justifier::SpacingLimits` (max_word_spacing, max_letter_spacing
17
+ percentages). `Justifier.justify` takes `limits:`.
18
+ - Distribution: spaces stretch up to the word-spacing cap
19
+ (capacity = Σ natural_space_width × (max−100)/100); any residual
20
+ slack goes to letter spacing — an even per-glyph addition capped
21
+ at max_letter_spacing% of the natural space width. With
22
+ InDesign's default letter cap of 0%, residual slack stays
23
+ unfilled and the line ends short, matching InDesign's behavior
24
+ when limits bind.
25
+ - `StyleResolver::Paragraph` carries maximum_word_spacing /
26
+ maximum_letter_spacing from the PSR; the frame renderer and the
27
+ footnote layout both pass the limits.
28
+
29
+ ## Known limitations
30
+
31
+ - Glyph scaling (Minimum/Maximum/DesiredGlyphScaling) is not
32
+ applied as a last resort.
33
+ - Minimum word/letter spacing (line compression) is unused — this
34
+ justifier only ever stretches (slack ≥ 0).
@@ -0,0 +1,26 @@
1
+ # TODO PDF 120: StartParagraph frame/column breaks
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-19
4
+
5
+ ## Problem
6
+
7
+ PSR `StartParagraph` (Anywhere / NextPage / NextColumn / NextFrame
8
+ / NextOddPage / NextEvenPage) was parsed but ignored — forced-break
9
+ paragraphs (chapter headings, magazine copy) rendered mid-frame.
10
+
11
+ ## What was done
12
+
13
+ - `StyleResolver::Paragraph` carries `start_paragraph` from the
14
+ PSR.
15
+ - `TextFrameRenderer.consume_paragraphs` stops filling the current
16
+ frame/column before a paragraph that requests a break (NextPage /
17
+ NextColumn / NextFrame / NextOddPage / NextEvenPage) — as long as
18
+ at least one paragraph was already placed there; the paragraph
19
+ and everything after it flow to the next frame via the existing
20
+ chain state. Multi-column frames break per column.
21
+
22
+ ## Known limitations
23
+
24
+ - All break flavors act at frame/column granularity (NextPage and
25
+ NextFrame are indistinguishable; odd/even page parity is not
26
+ modeled — the renderer has no page-parity notion at this layer).
@@ -0,0 +1,29 @@
1
+ # TODO PDF 121: Per-side cell edge strokes
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-19
4
+
5
+ ## Problem
6
+
7
+ `TableRenderer` stroked every cell as a uniform rectangle with the
8
+ canvas default line width. IDML cells declare per-side edge stroke
9
+ weights (TopEdgeStrokeWeight, LeftEdgeStrokeWeight,
10
+ BottomEdgeStrokeWeight, RightEdgeStrokeWeight — with the matching
11
+ stroke colors/tints), and the table declares fallback defaults
12
+ (DefaultRowStrokeWeight / DefaultColumnStrokeWeight).
13
+
14
+ ## What was done
15
+
16
+ - Cell_Object schema completion: `Elements::Cell` declares all 95
17
+ schema attributes (77 added — per-side edge strokes + diagonal
18
+ lines + cell style overrides), generated in schema order like the
19
+ Table completion (TODO 104).
20
+ - `TableRenderer.render_cell_border` draws each side separately
21
+ with the cell's edge stroke weight, falling back to the table's
22
+ default row/column stroke weight, and resolves stroke colors via
23
+ the color resolver (black fallback). Sides with zero weight are
24
+ skipped, so weightless cells no longer draw hairlines.
25
+
26
+ ## Known limitations
27
+
28
+ - Diagonal cell strokes (DiagonalLine*) are modeled but not drawn.
29
+ - Edge stroke types (dashes) fall back to solid.
@@ -1,11 +1,29 @@
1
1
  # TODO PDF 13: CJK text layout
2
2
 
3
- ## Status: PARTIAL — horizontal-mode CJK complete (2026-08-17):
4
- CJK glyph measurement, kinsoku shori, and ruby annotations render.
5
- Vertical writing mode, tate-chu-yoko, and mojikumi remain OPEN
6
- stretch goals — they need a vertical (rotated) layout engine and
7
- real vertical-mode fixtures to build against. See
8
- `lib/idml/text_engine/cjk_layout.rb`.
3
+ ## Status: PARTIAL — vertical writing mode implemented 2026-08-19
4
+ (horizontal-mode CJK was complete 2026-08-17: glyph measurement,
5
+ kinsoku shori, ruby annotations).
6
+
7
+ ## Vertical mode (2026-08-19)
8
+
9
+ - `StoryInner` now parses `StoryPreference` (StoryOrientation).
10
+ - `TextEngine::VerticalTextLayout` stacks glyphs UPRIGHT in
11
+ columns flowing right-to-left (CJK ideographs render correctly;
12
+ Latin glyphs are not rotated — documented approximation), reusing
13
+ LineBreaker (so kinsoku applies) against the column height.
14
+ - `TextFrameRenderer` takes the vertical path when the story
15
+ declares Vertical: paragraphs flow as glyph columns, runs start
16
+ fresh columns, overflow chains to the next frame. Paragraph
17
+ spacing/rules/decorations and ruby repositioning are not applied
18
+ in vertical mode.
19
+ - LineBreaker now breaks unspaced CJK runs PER CHARACTER at the
20
+ frame width (previously they overflowed as one long line) — this
21
+ also fixes horizontal CJK wrapping.
22
+
23
+ Remaining stretch goals: tate-chu-yoko, mojikumi, Latin rotation in
24
+ vertical mode, vertical ruby placement. See
25
+ `lib/idml/text_engine/cjk_layout.rb` and
26
+ `lib/idml/text_engine/vertical_text_layout.rb`.
9
27
 
10
28
  ## Progress 2026-08-17
11
29
 
@@ -38,6 +38,88 @@ module Idml
38
38
  attribute :paragraph_style_range, Idml::Elements::ParagraphStyleRange,
39
39
  collection: true
40
40
 
41
+ # The remaining Cell_Object attributes (per-side edge strokes,
42
+ # diagonal lines, cell style overrides, …) in schema order —
43
+ # generated by `scripts/rnc_to_lutaml.rb` from
44
+ # reference-docs/schemas/package/Stories/Story.rnc.
45
+ attribute :top_inset, :float
46
+ attribute :left_inset, :float
47
+ attribute :bottom_inset, :float
48
+ attribute :right_inset, :float
49
+ attribute :top_left_diagonal_line, :boolean
50
+ attribute :top_right_diagonal_line, :boolean
51
+ attribute :diagonal_line_in_front, :boolean
52
+ attribute :diagonal_line_stroke_weight, :float
53
+ attribute :diagonal_line_stroke_type, :string
54
+ attribute :diagonal_line_stroke_color, :string
55
+ attribute :diagonal_line_stroke_tint, :float
56
+ attribute :diagonal_line_stroke_overprint, :boolean
57
+ attribute :diagonal_line_stroke_gap_color, :string
58
+ attribute :diagonal_line_stroke_gap_tint, :float
59
+ attribute :diagonal_line_stroke_gap_overprint, :boolean
60
+ attribute :clip_content_to_cell, :boolean
61
+ attribute :first_baseline_offset, :string
62
+ attribute :minimum_first_baseline_offset, :float
63
+ attribute :left_edge_stroke_weight, :float
64
+ attribute :left_edge_stroke_type, :string
65
+ attribute :left_edge_stroke_color, :string
66
+ attribute :left_edge_stroke_tint, :float
67
+ attribute :left_edge_stroke_overprint, :boolean
68
+ attribute :left_edge_stroke_gap_color, :string
69
+ attribute :left_edge_stroke_gap_tint, :float
70
+ attribute :left_edge_stroke_gap_overprint, :boolean
71
+ attribute :top_edge_stroke_weight, :float
72
+ attribute :top_edge_stroke_type, :string
73
+ attribute :top_edge_stroke_color, :string
74
+ attribute :top_edge_stroke_tint, :float
75
+ attribute :top_edge_stroke_overprint, :boolean
76
+ attribute :top_edge_stroke_gap_color, :string
77
+ attribute :top_edge_stroke_gap_tint, :float
78
+ attribute :top_edge_stroke_gap_overprint, :boolean
79
+ attribute :right_edge_stroke_weight, :float
80
+ attribute :right_edge_stroke_type, :string
81
+ attribute :right_edge_stroke_color, :string
82
+ attribute :right_edge_stroke_tint, :float
83
+ attribute :right_edge_stroke_overprint, :boolean
84
+ attribute :right_edge_stroke_gap_color, :string
85
+ attribute :right_edge_stroke_gap_tint, :float
86
+ attribute :right_edge_stroke_gap_overprint, :boolean
87
+ attribute :bottom_edge_stroke_weight, :float
88
+ attribute :bottom_edge_stroke_type, :string
89
+ attribute :bottom_edge_stroke_color, :string
90
+ attribute :bottom_edge_stroke_tint, :float
91
+ attribute :bottom_edge_stroke_overprint, :boolean
92
+ attribute :bottom_edge_stroke_gap_color, :string
93
+ attribute :bottom_edge_stroke_gap_tint, :float
94
+ attribute :bottom_edge_stroke_gap_overprint, :boolean
95
+ attribute :inner_row_stroke_weight, :float
96
+ attribute :inner_row_stroke_type, :string
97
+ attribute :inner_row_stroke_color, :string
98
+ attribute :inner_row_stroke_tint, :float
99
+ attribute :inner_row_stroke_overprint, :boolean
100
+ attribute :inner_row_stroke_gap_color, :string
101
+ attribute :inner_row_stroke_gap_tint, :float
102
+ attribute :inner_row_stroke_gap_overprint, :boolean
103
+ attribute :inner_column_stroke_weight, :float
104
+ attribute :inner_column_stroke_type, :string
105
+ attribute :inner_column_stroke_color, :string
106
+ attribute :inner_column_stroke_tint, :float
107
+ attribute :inner_column_stroke_overprint, :boolean
108
+ attribute :inner_column_stroke_gap_color, :string
109
+ attribute :inner_column_stroke_gap_tint, :float
110
+ attribute :inner_column_stroke_gap_overprint, :boolean
111
+ attribute :top_edge_stroke_priority, :integer
112
+ attribute :left_edge_stroke_priority, :integer
113
+ attribute :bottom_edge_stroke_priority, :integer
114
+ attribute :right_edge_stroke_priority, :integer
115
+ attribute :graphic_left_inset, :float
116
+ attribute :graphic_top_inset, :float
117
+ attribute :graphic_right_inset, :float
118
+ attribute :graphic_bottom_inset, :float
119
+ attribute :clip_content_to_graphic_cell, :boolean
120
+ attribute :writing_direction, :boolean
121
+ attribute :applied_cell_style_priority, :integer
122
+
41
123
  xml do
42
124
  root "Cell"
43
125
  map_attribute "Self", to: :self_attr
@@ -58,6 +140,106 @@ module Idml
58
140
  map_attribute "ParagraphSpacingLimit", to: :paragraph_spacing_limit
59
141
  map_attribute "RotationAngle", to: :rotation_angle
60
142
  map_attribute "AppliedCellStyle", to: :applied_cell_style
143
+ map_attribute "TopInset", to: :top_inset
144
+ map_attribute "LeftInset", to: :left_inset
145
+ map_attribute "BottomInset", to: :bottom_inset
146
+ map_attribute "RightInset", to: :right_inset
147
+ map_attribute "TopLeftDiagonalLine", to: :top_left_diagonal_line
148
+ map_attribute "TopRightDiagonalLine", to: :top_right_diagonal_line
149
+ map_attribute "DiagonalLineInFront", to: :diagonal_line_in_front
150
+ map_attribute "DiagonalLineStrokeWeight",
151
+ to: :diagonal_line_stroke_weight
152
+ map_attribute "DiagonalLineStrokeType", to: :diagonal_line_stroke_type
153
+ map_attribute "DiagonalLineStrokeColor", to: :diagonal_line_stroke_color
154
+ map_attribute "DiagonalLineStrokeTint", to: :diagonal_line_stroke_tint
155
+ map_attribute "DiagonalLineStrokeOverprint",
156
+ to: :diagonal_line_stroke_overprint
157
+ map_attribute "DiagonalLineStrokeGapColor",
158
+ to: :diagonal_line_stroke_gap_color
159
+ map_attribute "DiagonalLineStrokeGapTint",
160
+ to: :diagonal_line_stroke_gap_tint
161
+ map_attribute "DiagonalLineStrokeGapOverprint",
162
+ to: :diagonal_line_stroke_gap_overprint
163
+ map_attribute "ClipContentToCell", to: :clip_content_to_cell
164
+ map_attribute "FirstBaselineOffset", to: :first_baseline_offset
165
+ map_attribute "MinimumFirstBaselineOffset",
166
+ to: :minimum_first_baseline_offset
167
+ map_attribute "LeftEdgeStrokeWeight", to: :left_edge_stroke_weight
168
+ map_attribute "LeftEdgeStrokeType", to: :left_edge_stroke_type
169
+ map_attribute "LeftEdgeStrokeColor", to: :left_edge_stroke_color
170
+ map_attribute "LeftEdgeStrokeTint", to: :left_edge_stroke_tint
171
+ map_attribute "LeftEdgeStrokeOverprint", to: :left_edge_stroke_overprint
172
+ map_attribute "LeftEdgeStrokeGapColor", to: :left_edge_stroke_gap_color
173
+ map_attribute "LeftEdgeStrokeGapTint", to: :left_edge_stroke_gap_tint
174
+ map_attribute "LeftEdgeStrokeGapOverprint",
175
+ to: :left_edge_stroke_gap_overprint
176
+ map_attribute "TopEdgeStrokeWeight", to: :top_edge_stroke_weight
177
+ map_attribute "TopEdgeStrokeType", to: :top_edge_stroke_type
178
+ map_attribute "TopEdgeStrokeColor", to: :top_edge_stroke_color
179
+ map_attribute "TopEdgeStrokeTint", to: :top_edge_stroke_tint
180
+ map_attribute "TopEdgeStrokeOverprint", to: :top_edge_stroke_overprint
181
+ map_attribute "TopEdgeStrokeGapColor", to: :top_edge_stroke_gap_color
182
+ map_attribute "TopEdgeStrokeGapTint", to: :top_edge_stroke_gap_tint
183
+ map_attribute "TopEdgeStrokeGapOverprint",
184
+ to: :top_edge_stroke_gap_overprint
185
+ map_attribute "RightEdgeStrokeWeight", to: :right_edge_stroke_weight
186
+ map_attribute "RightEdgeStrokeType", to: :right_edge_stroke_type
187
+ map_attribute "RightEdgeStrokeColor", to: :right_edge_stroke_color
188
+ map_attribute "RightEdgeStrokeTint", to: :right_edge_stroke_tint
189
+ map_attribute "RightEdgeStrokeOverprint",
190
+ to: :right_edge_stroke_overprint
191
+ map_attribute "RightEdgeStrokeGapColor",
192
+ to: :right_edge_stroke_gap_color
193
+ map_attribute "RightEdgeStrokeGapTint", to: :right_edge_stroke_gap_tint
194
+ map_attribute "RightEdgeStrokeGapOverprint",
195
+ to: :right_edge_stroke_gap_overprint
196
+ map_attribute "BottomEdgeStrokeWeight", to: :bottom_edge_stroke_weight
197
+ map_attribute "BottomEdgeStrokeType", to: :bottom_edge_stroke_type
198
+ map_attribute "BottomEdgeStrokeColor", to: :bottom_edge_stroke_color
199
+ map_attribute "BottomEdgeStrokeTint", to: :bottom_edge_stroke_tint
200
+ map_attribute "BottomEdgeStrokeOverprint",
201
+ to: :bottom_edge_stroke_overprint
202
+ map_attribute "BottomEdgeStrokeGapColor",
203
+ to: :bottom_edge_stroke_gap_color
204
+ map_attribute "BottomEdgeStrokeGapTint",
205
+ to: :bottom_edge_stroke_gap_tint
206
+ map_attribute "BottomEdgeStrokeGapOverprint",
207
+ to: :bottom_edge_stroke_gap_overprint
208
+ map_attribute "InnerRowStrokeWeight", to: :inner_row_stroke_weight
209
+ map_attribute "InnerRowStrokeType", to: :inner_row_stroke_type
210
+ map_attribute "InnerRowStrokeColor", to: :inner_row_stroke_color
211
+ map_attribute "InnerRowStrokeTint", to: :inner_row_stroke_tint
212
+ map_attribute "InnerRowStrokeOverprint", to: :inner_row_stroke_overprint
213
+ map_attribute "InnerRowStrokeGapColor", to: :inner_row_stroke_gap_color
214
+ map_attribute "InnerRowStrokeGapTint", to: :inner_row_stroke_gap_tint
215
+ map_attribute "InnerRowStrokeGapOverprint",
216
+ to: :inner_row_stroke_gap_overprint
217
+ map_attribute "InnerColumnStrokeWeight", to: :inner_column_stroke_weight
218
+ map_attribute "InnerColumnStrokeType", to: :inner_column_stroke_type
219
+ map_attribute "InnerColumnStrokeColor", to: :inner_column_stroke_color
220
+ map_attribute "InnerColumnStrokeTint", to: :inner_column_stroke_tint
221
+ map_attribute "InnerColumnStrokeOverprint",
222
+ to: :inner_column_stroke_overprint
223
+ map_attribute "InnerColumnStrokeGapColor",
224
+ to: :inner_column_stroke_gap_color
225
+ map_attribute "InnerColumnStrokeGapTint",
226
+ to: :inner_column_stroke_gap_tint
227
+ map_attribute "InnerColumnStrokeGapOverprint",
228
+ to: :inner_column_stroke_gap_overprint
229
+ map_attribute "TopEdgeStrokePriority", to: :top_edge_stroke_priority
230
+ map_attribute "LeftEdgeStrokePriority", to: :left_edge_stroke_priority
231
+ map_attribute "BottomEdgeStrokePriority",
232
+ to: :bottom_edge_stroke_priority
233
+ map_attribute "RightEdgeStrokePriority", to: :right_edge_stroke_priority
234
+ map_attribute "GraphicLeftInset", to: :graphic_left_inset
235
+ map_attribute "GraphicTopInset", to: :graphic_top_inset
236
+ map_attribute "GraphicRightInset", to: :graphic_right_inset
237
+ map_attribute "GraphicBottomInset", to: :graphic_bottom_inset
238
+ map_attribute "ClipContentToGraphicCell",
239
+ to: :clip_content_to_graphic_cell
240
+ map_attribute "WritingDirection", to: :writing_direction
241
+ map_attribute "AppliedCellStylePriority",
242
+ to: :applied_cell_style_priority
61
243
  map_element "ParagraphStyleRange", to: :paragraph_style_range
62
244
  end
63
245
 
@@ -302,6 +302,8 @@ module Idml
302
302
  attribute :table, Idml::Elements::Table, collection: true
303
303
  attribute :xml_element, Idml::Elements::XmlElement, collection: true
304
304
  attribute :footnote, Idml::Elements::Footnote, collection: true
305
+ attribute :endnote_range, Idml::Elements::EndnoteRange,
306
+ collection: true
305
307
  # Anchored page items embedded in the story flow. Their
306
308
  # AnchoredObjectSetting (child of each item) declares how the
307
309
  # item anchors to the surrounding text.
@@ -669,6 +671,7 @@ module Idml
669
671
  map_element "Table", to: :table
670
672
  map_element "XMLElement", to: :xml_element
671
673
  map_element "Footnote", to: :footnote
674
+ map_element "EndnoteRange", to: :endnote_range
672
675
  map_element "Rectangle", to: :rectangle
673
676
  map_element "Oval", to: :oval
674
677
  map_element "Polygon", to: :polygon
@@ -316,6 +316,7 @@ module Idml
316
316
  attribute :endnote, Idml::Elements::Endnote, collection: true
317
317
  attribute :endnote_range, Idml::Elements::EndnoteRange,
318
318
  collection: true
319
+ attribute :story_preference, Idml::Elements::StoryPreference
319
320
 
320
321
  xml do
321
322
  root "Story"
@@ -683,6 +684,7 @@ module Idml
683
684
  map_element "Footnote", to: :footnote
684
685
  map_element "Endnote", to: :endnote
685
686
  map_element "EndnoteRange", to: :endnote_range
687
+ map_element "StoryPreference", to: :story_preference
686
688
  end
687
689
 
688
690
  def text_content
@@ -128,11 +128,16 @@ module Idml
128
128
  size: size)
129
129
  lines = TextEngine::LineBreaker.break(glyphs: glyphs,
130
130
  frame_width: wrap_width)
131
+ limits = TextEngine::Justifier::SpacingLimits.new(
132
+ max_word_spacing: paragraph.maximum_word_spacing,
133
+ max_letter_spacing: paragraph.maximum_letter_spacing,
134
+ )
131
135
  lines.each_with_index do |line, index|
132
136
  TextEngine::Justifier.justify(
133
137
  line: line, frame_width: wrap_width,
134
138
  alignment: paragraph.alignment || :left,
135
- last_line: last_line?(paragraph, run, index, lines)
139
+ last_line: last_line?(paragraph, run, index, lines),
140
+ limits: limits
136
141
  )
137
142
  end
138
143
  leading = TextEngine::VerticalLayout.leading_for(
@@ -76,7 +76,8 @@ module Idml
76
76
  cell_x, cell_y, cell_w, cell_h, context)
77
77
  render_cell_background(canvas, cell, cell_x, cell_y, cell_w, cell_h,
78
78
  context)
79
- render_cell_border(canvas, cell_x, cell_y, cell_w, cell_h)
79
+ render_cell_border(canvas, cell, table, cell_x, cell_y,
80
+ cell_w, cell_h, context)
80
81
  render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
81
82
  end
82
83
  end
@@ -113,12 +114,102 @@ module Idml
113
114
  end
114
115
  private_class_method :render_cell_background
115
116
 
116
- def self.render_cell_border(canvas, x, y, w, h)
117
- canvas.rectangle(x, y, w, h)
118
- canvas.stroke
117
+ # Cell borders: per-side strokes when the cell or table
118
+ # declare edge weights (legacy single rect otherwise, so
119
+ # undeclared tables keep their existing look). Top/bottom
120
+ # fall back to the table's default row stroke, left/right
121
+ # to the default column stroke; zero-weight sides draw
122
+ # nothing.
123
+ def self.render_cell_border(canvas, cell, table, x, y, w, h, context)
124
+ sides = cell_edge_weights(cell, table)
125
+ if sides.nil?
126
+ canvas.rectangle(x, y, w, h)
127
+ canvas.stroke
128
+ return
129
+ end
130
+
131
+ top, right, bottom, left = sides
132
+ stroke_cell_side(canvas, context,
133
+ cell.top_edge_stroke_color,
134
+ table.default_row_stroke_color, top) do
135
+ canvas.move_to(x, y + h)
136
+ canvas.line_to(x + w, y + h)
137
+ end
138
+ stroke_cell_side(canvas, context,
139
+ cell.bottom_edge_stroke_color,
140
+ table.default_row_stroke_color, bottom) do
141
+ canvas.move_to(x, y)
142
+ canvas.line_to(x + w, y)
143
+ end
144
+ stroke_cell_side(canvas, context,
145
+ cell.left_edge_stroke_color,
146
+ table.default_column_stroke_color, left) do
147
+ canvas.move_to(x, y)
148
+ canvas.line_to(x, y + h)
149
+ end
150
+ stroke_cell_side(canvas, context,
151
+ cell.right_edge_stroke_color,
152
+ table.default_column_stroke_color, right) do
153
+ canvas.move_to(x + w, y)
154
+ canvas.line_to(x + w, y + h)
155
+ end
119
156
  end
120
157
  private_class_method :render_cell_border
121
158
 
159
+ # [top, right, bottom, left] effective weights, or nil when
160
+ # neither cell edges nor table defaults declare any stroke
161
+ # (the legacy uniform-rect case).
162
+ def self.cell_edge_weights(cell, table)
163
+ return nil unless stroke_declared?(cell, table)
164
+
165
+ [
166
+ side_weight(cell.top_edge_stroke_weight,
167
+ table.default_row_stroke_weight),
168
+ side_weight(cell.right_edge_stroke_weight,
169
+ table.default_column_stroke_weight),
170
+ side_weight(cell.bottom_edge_stroke_weight,
171
+ table.default_row_stroke_weight),
172
+ side_weight(cell.left_edge_stroke_weight,
173
+ table.default_column_stroke_weight),
174
+ ]
175
+ end
176
+ private_class_method :cell_edge_weights
177
+
178
+ def self.stroke_declared?(cell, table)
179
+ [
180
+ cell.top_edge_stroke_weight, cell.right_edge_stroke_weight,
181
+ cell.bottom_edge_stroke_weight, cell.left_edge_stroke_weight,
182
+ table.default_row_stroke_weight,
183
+ table.default_column_stroke_weight
184
+ ].compact.any?
185
+ end
186
+ private_class_method :stroke_declared?
187
+
188
+ def self.side_weight(cell_weight, default_weight)
189
+ cell_weight || default_weight || 0
190
+ end
191
+ private_class_method :side_weight
192
+
193
+ def self.stroke_cell_side(canvas, context, cell_color,
194
+ table_color, weight)
195
+ thickness = weight.to_f
196
+ return unless thickness.positive?
197
+
198
+ apply_cell_stroke_color(canvas, context, cell_color, table_color)
199
+ canvas.line_width = thickness
200
+ yield
201
+ canvas.stroke
202
+ end
203
+ private_class_method :stroke_cell_side
204
+
205
+ def self.apply_cell_stroke_color(canvas, context, cell_color,
206
+ table_color)
207
+ name = cell_color || table_color
208
+ color = name && context.color_resolver&.resolve(name)
209
+ canvas.stroke_color(color ? ColorHelper.to_canvas(color) : [:gray, 1.0])
210
+ end
211
+ private_class_method :apply_cell_stroke_color
212
+
122
213
  def self.cell_fill_color(cell, context)
123
214
  return nil unless cell.fill_color
124
215
  return nil if cell.fill_color == "Color/None"
@@ -41,7 +41,8 @@ module Idml
41
41
  state = initial_chain_state(frame, story, context)
42
42
  return unless state
43
43
 
44
- render_text(canvas, state, context, box)
44
+ vertical = vertical_story?(story)
45
+ render_text(canvas, state, context, box, vertical: vertical)
45
46
  store_chain_state(frame, state, context)
46
47
  end
47
48
 
@@ -146,9 +147,11 @@ module Idml
146
147
  end
147
148
  private_class_method :context_for_item
148
149
 
149
- def self.render_text(canvas, state, context, box)
150
+ def self.render_text(canvas, state, context, box, vertical: false)
150
151
  font = context.font_metrics
151
- if font
152
+ if font && vertical
153
+ vertical_render(canvas, state, context, box, font)
154
+ elsif font
152
155
  engine_render(canvas, state, context, box, font)
153
156
  else
154
157
  simple_render(canvas, state, context, box)
@@ -156,6 +159,108 @@ module Idml
156
159
  end
157
160
  private_class_method :render_text
158
161
 
162
+ # True when the story's StoryPreference declares vertical
163
+ # writing (StoryOrientation="Vertical").
164
+ def self.vertical_story?(story)
165
+ story.inner&.story_preference&.story_orientation == "Vertical"
166
+ end
167
+ private_class_method :vertical_story?
168
+
169
+ # Vertical writing path (StoryOrientation="Vertical", CJK):
170
+ # paragraphs flow as upright glyph columns advancing
171
+ # right-to-left; each run starts a fresh column (run-mixing
172
+ # within a column is not modeled). Runs whose columns would
173
+ # cross the frame's left inset overflow to the next frame in
174
+ # the chain. Paragraph spacing, rules, and decorations are
175
+ # not applied in vertical mode.
176
+ def self.vertical_render(canvas, state, context, box, font)
177
+ layout_frame = layout_frame(context.item, box)
178
+ left_limit = layout_frame.x + (layout_frame.inset_left || 0)
179
+ resume_vertical_paragraph(state)
180
+ pending = 0
181
+
182
+ state.paragraphs.each do |paragraph|
183
+ remaining_runs = vertical_paragraph(
184
+ canvas, paragraph, context, layout_frame, font, left_limit
185
+ )
186
+ if remaining_runs.empty?
187
+ pending += 1
188
+ else
189
+ state.current_paragraph = paragraph
190
+ state.runs_remaining = remaining_runs
191
+ break
192
+ end
193
+ end
194
+
195
+ state.paragraphs = state.paragraphs[pending..]
196
+ end
197
+ private_class_method :vertical_render
198
+
199
+ # Folds a chain-resumed paragraph (runs_remaining) back to
200
+ # the head of the paragraph list so the vertical path sees a
201
+ # uniform sequence.
202
+ def self.resume_vertical_paragraph(state)
203
+ return unless state.current_paragraph
204
+
205
+ state.current_paragraph.runs = state.runs_remaining
206
+ state.paragraphs.unshift(state.current_paragraph)
207
+ state.current_paragraph = nil
208
+ state.runs_remaining = []
209
+ end
210
+ private_class_method :resume_vertical_paragraph
211
+
212
+ # Renders one paragraph's runs as vertical columns. Returns
213
+ # the runs that did not fit (empty when all placed).
214
+ def self.vertical_paragraph(canvas, paragraph, context,
215
+ layout_frame, font, left_limit)
216
+ paragraph.runs.each_with_index do |run, index|
217
+ size = run.point_size || DEFAULT_SIZE
218
+ leading = TextEngine::VerticalLayout.leading_for(
219
+ paragraph.auto_leading, size
220
+ )
221
+ glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
222
+ size: size)
223
+ positioned, columns = TextEngine::VerticalTextLayout.layout(
224
+ glyphs: glyphs, frame: layout_frame, leading: leading,
225
+ size: size
226
+ )
227
+ unless vertical_fits?(layout_frame, leading, columns,
228
+ left_limit)
229
+ return paragraph.runs[index..]
230
+ end
231
+
232
+ positioned.each do |glyph|
233
+ emit_vertical_glyph(canvas, glyph, run, context, size)
234
+ end
235
+ end
236
+ []
237
+ end
238
+ private_class_method :vertical_paragraph
239
+
240
+ def self.vertical_fits?(layout_frame, leading, columns, left_limit)
241
+ right = layout_frame.x + layout_frame.width -
242
+ (layout_frame.inset_right || 0)
243
+ (right - (columns * leading)) >= left_limit
244
+ end
245
+ private_class_method :vertical_fits?
246
+
247
+ # Emits one upright glyph at its vertical position with the
248
+ # run's character styling.
249
+ def self.emit_vertical_glyph(canvas, positioned, run, context,
250
+ size)
251
+ text = [positioned.codepoint].pack("U")
252
+ text_kwargs = CharacterStyle.text_kwargs(
253
+ run,
254
+ {
255
+ at: [positioned.x, positioned.y],
256
+ font: font_for_run(run, context),
257
+ size: size,
258
+ },
259
+ )
260
+ canvas.text(text, **text_kwargs)
261
+ end
262
+ private_class_method :emit_vertical_glyph
263
+
159
264
  def self.chain_head?(frame)
160
265
  frame.previous_text_frame.nil? || frame.previous_text_frame == "n"
161
266
  end
@@ -410,6 +515,7 @@ module Idml
410
515
  pending = 0
411
516
  state.paragraphs.each do |paragraph|
412
517
  break if cursor_y < bottom_limit
518
+ break if paragraph_break?(paragraph) && pending.positive?
413
519
 
414
520
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
415
521
  canvas, paragraph, paragraph.runs, context,
@@ -741,16 +847,30 @@ module Idml
741
847
  end
742
848
  private_class_method :layout_run
743
849
 
850
+ # True when the paragraph requests a forced break to the
851
+ # next frame/column (StartParagraph). All break flavors act
852
+ # at frame/column granularity.
853
+ def self.paragraph_break?(paragraph)
854
+ %w[NextPage NextColumn NextFrame NextOddPage NextEvenPage]
855
+ .include?(paragraph.start_paragraph)
856
+ end
857
+ private_class_method :paragraph_break?
858
+
744
859
  # Justifies the run's lines; only the final line of the
745
860
  # paragraph's final run stays ragged under full
746
861
  # justification.
747
862
  def self.justify_lines(lines, paragraph, wrap_width,
748
863
  paragraph_last)
864
+ limits = TextEngine::Justifier::SpacingLimits.new(
865
+ max_word_spacing: paragraph.maximum_word_spacing,
866
+ max_letter_spacing: paragraph.maximum_letter_spacing,
867
+ )
749
868
  lines.each_with_index do |line, index|
750
869
  TextEngine::Justifier.justify(
751
870
  line: line, frame_width: wrap_width,
752
871
  alignment: paragraph.alignment || :left,
753
- last_line: paragraph_last && index == lines.length - 1
872
+ last_line: paragraph_last && index == lines.length - 1,
873
+ limits: limits
754
874
  )
755
875
  end
756
876
  end
@@ -64,6 +64,12 @@ module Idml
64
64
  :right_indent,
65
65
  :auto_leading,
66
66
  :drop_cap_lines,
67
+ # Justification distribution caps (percent; from PSR).
68
+ :maximum_word_spacing,
69
+ :maximum_letter_spacing,
70
+ # Forced paragraph break (StartParagraph: NextPage /
71
+ # NextColumn / NextFrame / NextOddPage / NextEvenPage).
72
+ :start_paragraph,
67
73
  :bullets_and_numbering_list_type,
68
74
  :bullet_character_value,
69
75
  :bullets_text_after,
@@ -134,8 +140,9 @@ module Idml
134
140
  return [] unless story&.inner
135
141
 
136
142
  counter = Footnote.counter_for(nil)
143
+ endnotes = Footnote.counter_for(nil)
137
144
  story.inner.paragraph_style_range.flat_map do |psr|
138
- csr_runs(psr, footnote_counter: counter)
145
+ csr_runs(psr, footnote_counter: counter, endnote_counter: endnotes)
139
146
  end
140
147
  end
141
148
 
@@ -164,13 +171,15 @@ module Idml
164
171
  style_lookup: nil,
165
172
  footnote_option: nil)
166
173
  counter = Footnote.counter_for(footnote_option)
174
+ endnotes = Footnote.counter_for(nil)
167
175
  container.paragraph_style_range.filter_map do |psr|
168
176
  next unless condition_visible?(psr, condition_filter)
169
177
 
170
178
  runs = csr_runs(psr, condition_filter: condition_filter,
171
179
  style_lookup: style_lookup,
172
180
  footnote_counter: counter,
173
- footnote_option: footnote_option)
181
+ footnote_option: footnote_option,
182
+ endnote_counter: endnotes)
174
183
  next if runs.empty?
175
184
 
176
185
  paragraph = Paragraph.new(
@@ -183,6 +192,14 @@ module Idml
183
192
  left_indent: resolve_attr(style_lookup, psr, :left_indent),
184
193
  right_indent: resolve_attr(style_lookup, psr, :right_indent),
185
194
  auto_leading: resolve_attr(style_lookup, psr, :auto_leading),
195
+ maximum_word_spacing: resolve_attr(
196
+ style_lookup, psr, :maximum_word_spacing
197
+ ),
198
+ maximum_letter_spacing: resolve_attr(
199
+ style_lookup, psr, :maximum_letter_spacing
200
+ ),
201
+ start_paragraph: resolve_attr(style_lookup, psr,
202
+ :start_paragraph),
186
203
  drop_cap_lines: resolve_attr(style_lookup, psr, :drop_cap_lines),
187
204
  drop_cap_characters: resolve_attr(style_lookup, psr,
188
205
  :drop_cap_characters),
@@ -290,15 +307,18 @@ module Idml
290
307
  private_class_method :properties_color
291
308
 
292
309
  def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
293
- footnote_counter: nil, footnote_option: nil)
310
+ footnote_counter: nil, footnote_option: nil,
311
+ endnote_counter: nil)
294
312
  counter = footnote_counter || Footnote.counter_for(footnote_option)
313
+ endnotes = endnote_counter || Footnote.counter_for(nil)
295
314
  psr.character_style_range.flat_map do |csr|
296
315
  next [] unless condition_visible?(csr, condition_filter)
297
316
 
298
317
  csr_content_runs(csr, condition_filter: condition_filter,
299
318
  style_lookup: style_lookup,
300
319
  footnote_counter: counter,
301
- footnote_option: footnote_option)
320
+ footnote_option: footnote_option,
321
+ endnote_counter: endnotes)
302
322
  end
303
323
  end
304
324
 
@@ -306,7 +326,8 @@ module Idml
306
326
  # plus one superscript marker run per anchored Footnote. A CSR
307
327
  # holding only a footnote still emits its marker.
308
328
  def self.csr_content_runs(csr, condition_filter:, style_lookup:,
309
- footnote_counter:, footnote_option:)
329
+ footnote_counter:, footnote_option:,
330
+ endnote_counter:)
310
331
  text = csr.text_content
311
332
  runs = []
312
333
  unless text.nil? || text.empty?
@@ -322,8 +343,21 @@ module Idml
322
343
  runs << Footnote.marker_run(number, runs.last, paragraphs,
323
344
  footnote_option)
324
345
  end
346
+ runs.concat(endnote_marker_runs(csr, endnote_counter, runs.last))
325
347
  runs
326
348
  end
349
+
350
+ # Endnote reference markers (CSR > EndnoteRange): superscript,
351
+ # numbered by a counter SEPARATE from footnotes. The endnote
352
+ # text lives in another story (IsEndnoteStory) and is not yet
353
+ # rendered — see TODO 117.
354
+ def self.endnote_marker_runs(csr, endnote_counter, base_run)
355
+ csr.endnote_range.map do |_range|
356
+ Footnote.marker_run(endnote_counter.next_number, base_run,
357
+ nil, nil)
358
+ end
359
+ end
360
+ private_class_method :endnote_marker_runs
327
361
  private_class_method :csr_content_runs
328
362
 
329
363
  def self.text_run(csr, style_lookup:)
@@ -7,15 +7,25 @@ module Idml
7
7
  # spaces for justified text. `last_line:` (the paragraph's final
8
8
  # line) keeps ragged right under :justified, as InDesign does.
9
9
  class Justifier
10
+ # Justification distribution caps, in percent (100 = natural).
11
+ # InDesign defaults: word spacing max 133%, letter spacing 0%.
12
+ SpacingLimits = Struct.new(
13
+ :max_word_spacing, :max_letter_spacing, keyword_init: true
14
+ )
15
+
16
+ DEFAULT_MAX_WORD_SPACING = 133.0
17
+ DEFAULT_MAX_LETTER_SPACING = 0.0
18
+
10
19
  def self.justify(line:, frame_width:, alignment: :left,
11
- last_line: false)
12
- new(frame_width, alignment, last_line).justify(line)
20
+ last_line: false, limits: nil)
21
+ new(frame_width, alignment, last_line, limits).justify(line)
13
22
  end
14
23
 
15
- def initialize(frame_width, alignment, last_line)
24
+ def initialize(frame_width, alignment, last_line, limits)
16
25
  @frame_width = frame_width
17
26
  @alignment = alignment
18
27
  @last_line = last_line
28
+ @limits = limits
19
29
  end
20
30
 
21
31
  def justify(line)
@@ -45,13 +55,42 @@ module Idml
45
55
  slack = @frame_width - line.width
46
56
  return if spaces.zero? || slack <= 0
47
57
 
48
- extra = slack / spaces
58
+ word_share = [slack, word_capacity(line, spaces)].min
59
+ extra = word_share / spaces
49
60
  line.glyphs.each do |glyph|
50
61
  next unless glyph.is_space
51
62
 
52
63
  glyph.width += extra
53
64
  end
65
+ residual = slack - word_share
66
+ distribute_letter_spacing(line, residual) if residual.positive?
67
+ end
68
+
69
+ # Total room the word spaces may grow within the max-word-
70
+ # spacing cap (percent of each space's natural width).
71
+ def word_capacity(line, _spaces)
72
+ max_pct = @limits&.max_word_spacing || DEFAULT_MAX_WORD_SPACING
73
+ grow = [(max_pct - 100) / 100.0, 0].max
74
+ line.glyphs.sum { |g| g.is_space ? g.width * grow : 0.0 } * 1.0
75
+ end
76
+ private :word_capacity
77
+
78
+ # Even per-glyph letter spacing for residual slack, capped at
79
+ # max_letter_spacing percent of the natural space width. With
80
+ # the default 0% cap nothing is added — the line ends short,
81
+ # as InDesign does when limits bind.
82
+ def distribute_letter_spacing(line, residual)
83
+ max_pct = @limits&.max_letter_spacing || DEFAULT_MAX_LETTER_SPACING
84
+ return unless max_pct.positive?
85
+
86
+ space_ref = line.glyphs.find(&:is_space)&.width || 0
87
+ return unless space_ref.positive?
88
+
89
+ cap = (max_pct / 100.0) * space_ref * line.glyphs.length
90
+ add = [residual, cap].min / line.glyphs.length
91
+ line.glyphs.each { |glyph| glyph.width += add }
54
92
  end
93
+ private :distribute_letter_spacing
55
94
  end
56
95
  end
57
96
  end
@@ -22,10 +22,22 @@ module Idml
22
22
  end
23
23
  private_class_method :cjk_run?
24
24
 
25
+ # CJK text wraps per character (no word boundaries): an
26
+ # overflow whose trailing glyph is CJK breaks before it
27
+ # instead of emitting an overlong line.
28
+
25
29
  def initialize(frame_width)
26
30
  @frame_width = frame_width
27
31
  end
28
32
 
33
+ # CJK text wraps per character (no word boundaries): an
34
+ # overflow whose trailing glyph is CJK breaks before it
35
+ # instead of emitting an overlong line.
36
+ def cjk_break?(current)
37
+ current.length > 1 && CjkLayout.cjk?(current.last.codepoint)
38
+ end
39
+ private :cjk_break?
40
+
29
41
  def break(glyphs)
30
42
  lines = []
31
43
  current = []
@@ -50,6 +62,10 @@ module Idml
50
62
  current = current[(last_space_idx + 1)..]
51
63
  current_width = current.sum(&:width)
52
64
  last_space_idx = -1
65
+ elsif cjk_break?(current)
66
+ lines << Line.new(current[0..-2], current_width - glyph.width, 0)
67
+ current = [glyph]
68
+ current_width = glyph.width
53
69
  else
54
70
  lines << Line.new(current, current_width, 0)
55
71
  current = []
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module TextEngine
5
+ # Vertical (top-to-bottom, right-to-left) glyph layout for CJK
6
+ # vertical writing (StoryOrientation="Vertical"). Glyphs stay
7
+ # UPRIGHT — CJK ideographs render correctly stacked; Latin
8
+ # glyphs are not rotated to the vertical axis (documented
9
+ # approximation). Line breaking (and therefore kinsoku shori)
10
+ # is reused from LineBreaker against the column height.
11
+ module VerticalTextLayout
12
+ # One glyph placed at its baseline in a vertical column.
13
+ PositionedGlyph = Struct.new(:codepoint, :x, :y, keyword_init: true)
14
+
15
+ # Stacks `glyphs` into columns of at most the frame's text
16
+ # height; column 0 is the rightmost. Returns
17
+ # [positioned_glyphs, column_count].
18
+ def self.layout(glyphs:, frame:, leading:, size:)
19
+ lines = TextEngine::LineBreaker.break(
20
+ glyphs: glyphs, frame_width: column_height(frame),
21
+ )
22
+ positioned = []
23
+ lines.each_with_index do |line, column_index|
24
+ stack_column(positioned, line.glyphs, frame, leading, size,
25
+ column_index)
26
+ end
27
+ [positioned, lines.length]
28
+ end
29
+
30
+ # The frame's usable vertical extent for one column of text.
31
+ def self.column_height(frame)
32
+ frame.height - (frame.inset_top || 0) - (frame.inset_bottom || 0)
33
+ end
34
+
35
+ # Places one column's glyphs at a shared x, baselines walking
36
+ # down from the frame's text top by each glyph's advance.
37
+ def self.stack_column(positioned, glyphs, frame, leading, size,
38
+ column_index)
39
+ top_y = frame.y + frame.height - (frame.inset_top || 0)
40
+ x = column_x(frame, leading, size, column_index)
41
+ y = top_y
42
+ glyphs.each do |glyph|
43
+ y -= advance(glyph)
44
+ positioned << PositionedGlyph.new(codepoint: glyph.codepoint,
45
+ x: x, y: y)
46
+ end
47
+ end
48
+ private_class_method :stack_column
49
+
50
+ # Vertical advance of one glyph — its measured width (the em
51
+ # for the common square-CJK case).
52
+ def self.advance(glyph)
53
+ glyph.width.positive? ? glyph.width : 0.0
54
+ end
55
+ private_class_method :advance
56
+
57
+ # The column's glyph left edge: columns advance right-to-left
58
+ # by `leading` from the right inset edge; each glyph (em =
59
+ # `size`) is centered in its column slot.
60
+ def self.column_x(frame, leading, size, column_index)
61
+ right = frame.x + frame.width - (frame.inset_right || 0)
62
+ slot_left = right - ((column_index + 1) * leading)
63
+ slot_left + ((leading - size) / 2.0)
64
+ end
65
+ private_class_method :column_x
66
+ end
67
+ end
68
+ end
@@ -8,6 +8,8 @@ module Idml
8
8
  autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
9
9
  autoload :Shaper, "#{__dir__}/text_engine/shaper"
10
10
  autoload :Measurement, "#{__dir__}/text_engine/measurement"
11
+ autoload :VerticalTextLayout,
12
+ "#{__dir__}/text_engine/vertical_text_layout"
11
13
  autoload :ShapedGlyph, "#{__dir__}/text_engine/shaper"
12
14
  autoload :LineBreaker, "#{__dir__}/text_engine/line_breaker"
13
15
  autoload :Line, "#{__dir__}/text_engine/line_breaker"
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.9.3"
4
+ VERSION = "0.9.5"
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.9.3
4
+ version: 0.9.5
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-18 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -176,7 +176,10 @@ files:
176
176
  - TODO.pdf/116-justification-last-line.md
177
177
  - TODO.pdf/117-endnote-support.md
178
178
  - TODO.pdf/118-table-band-fills.md
179
+ - TODO.pdf/119-justification-spacing-limits.md
179
180
  - TODO.pdf/12-idml-to-pdf-pipeline.md
181
+ - TODO.pdf/120-start-paragraph-breaks.md
182
+ - TODO.pdf/121-cell-edge-strokes.md
180
183
  - TODO.pdf/13-cjk-text-layout.md
181
184
  - TODO.pdf/14-page-item-element-models.md
182
185
  - TODO.pdf/15-wire-spread-children.md
@@ -494,6 +497,7 @@ files:
494
497
  - lib/idml/text_engine/pdfrb_font_metrics.rb
495
498
  - lib/idml/text_engine/shaper.rb
496
499
  - lib/idml/text_engine/vertical_layout.rb
500
+ - lib/idml/text_engine/vertical_text_layout.rb
497
501
  - lib/idml/validation.rb
498
502
  - lib/idml/validation/result.rb
499
503
  - lib/idml/validation/validator.rb