idml 0.9.4 → 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: 41d28cdae1d32efe5815542e9f5f702affff8445c1c77b9e62e3c9229f974dca
4
- data.tar.gz: d9144aafa235ef8364cd373bc7f62742bc95ed1b6646f159f00e67f72a1706e2
3
+ metadata.gz: 955ca35f14b9460085c65187586817bf655085d3f814660e790c7050e1abbe5f
4
+ data.tar.gz: 1aa96f29ecc97594128d6f88a245e46383efc99f0ff4d9f6797947cd8fe361a9
5
5
  SHA512:
6
- metadata.gz: ca86d99a34bfb4cb1ab62580c3283dda29b21a060435f4dacce15e1069a49bf3009701d34c77700f5308b4c1fcd4e5d11788c89f6802398cb05a872516cae803
7
- data.tar.gz: 6b7033ff88e4e5ea4edc4fbdfe7ebdb0024fbcc024107a7f5e5c18e3c7196b6323dcc66ec55485dbc743351c6d15f7bfd3eff922531d2d1ce6d713c9efa99dc7
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
@@ -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
 
@@ -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
@@ -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
@@ -140,8 +140,9 @@ module Idml
140
140
  return [] unless story&.inner
141
141
 
142
142
  counter = Footnote.counter_for(nil)
143
+ endnotes = Footnote.counter_for(nil)
143
144
  story.inner.paragraph_style_range.flat_map do |psr|
144
- csr_runs(psr, footnote_counter: counter)
145
+ csr_runs(psr, footnote_counter: counter, endnote_counter: endnotes)
145
146
  end
146
147
  end
147
148
 
@@ -170,13 +171,15 @@ module Idml
170
171
  style_lookup: nil,
171
172
  footnote_option: nil)
172
173
  counter = Footnote.counter_for(footnote_option)
174
+ endnotes = Footnote.counter_for(nil)
173
175
  container.paragraph_style_range.filter_map do |psr|
174
176
  next unless condition_visible?(psr, condition_filter)
175
177
 
176
178
  runs = csr_runs(psr, condition_filter: condition_filter,
177
179
  style_lookup: style_lookup,
178
180
  footnote_counter: counter,
179
- footnote_option: footnote_option)
181
+ footnote_option: footnote_option,
182
+ endnote_counter: endnotes)
180
183
  next if runs.empty?
181
184
 
182
185
  paragraph = Paragraph.new(
@@ -304,15 +307,18 @@ module Idml
304
307
  private_class_method :properties_color
305
308
 
306
309
  def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
307
- footnote_counter: nil, footnote_option: nil)
310
+ footnote_counter: nil, footnote_option: nil,
311
+ endnote_counter: nil)
308
312
  counter = footnote_counter || Footnote.counter_for(footnote_option)
313
+ endnotes = endnote_counter || Footnote.counter_for(nil)
309
314
  psr.character_style_range.flat_map do |csr|
310
315
  next [] unless condition_visible?(csr, condition_filter)
311
316
 
312
317
  csr_content_runs(csr, condition_filter: condition_filter,
313
318
  style_lookup: style_lookup,
314
319
  footnote_counter: counter,
315
- footnote_option: footnote_option)
320
+ footnote_option: footnote_option,
321
+ endnote_counter: endnotes)
316
322
  end
317
323
  end
318
324
 
@@ -320,7 +326,8 @@ module Idml
320
326
  # plus one superscript marker run per anchored Footnote. A CSR
321
327
  # holding only a footnote still emits its marker.
322
328
  def self.csr_content_runs(csr, condition_filter:, style_lookup:,
323
- footnote_counter:, footnote_option:)
329
+ footnote_counter:, footnote_option:,
330
+ endnote_counter:)
324
331
  text = csr.text_content
325
332
  runs = []
326
333
  unless text.nil? || text.empty?
@@ -336,8 +343,21 @@ module Idml
336
343
  runs << Footnote.marker_run(number, runs.last, paragraphs,
337
344
  footnote_option)
338
345
  end
346
+ runs.concat(endnote_marker_runs(csr, endnote_counter, runs.last))
339
347
  runs
340
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
341
361
  private_class_method :csr_content_runs
342
362
 
343
363
  def self.text_run(csr, style_lookup:)
@@ -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.4"
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.4
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-19 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
@@ -497,6 +497,7 @@ files:
497
497
  - lib/idml/text_engine/pdfrb_font_metrics.rb
498
498
  - lib/idml/text_engine/shaper.rb
499
499
  - lib/idml/text_engine/vertical_layout.rb
500
+ - lib/idml/text_engine/vertical_text_layout.rb
500
501
  - lib/idml/validation.rb
501
502
  - lib/idml/validation/result.rb
502
503
  - lib/idml/validation/validator.rb