idml 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 955ca35f14b9460085c65187586817bf655085d3f814660e790c7050e1abbe5f
4
- data.tar.gz: 1aa96f29ecc97594128d6f88a245e46383efc99f0ff4d9f6797947cd8fe361a9
3
+ metadata.gz: 0d49d9954838f3326eab4e65e2915eab9cd18a526ce7c4dd58669e6dacb8910e
4
+ data.tar.gz: 56713c3d26980c24c6baf8d080543cd2471aef5dea4ca83c33be82f73fe2e83e
5
5
  SHA512:
6
- metadata.gz: 638e2df6de7b160a8f7c64703309986f32644e29ada18f9c8f611b967e80ee4e9fdcf84cc7687ecd364b6aa964bcbb5831c4c279a841dd203b412f39d710fbc1
7
- data.tar.gz: 9ef18bb1487ed2d6206c2c3aef6fe4ef699a12db584c2052557343144ae3f38d32cbcc275972114b2b46b1cc660c1cb24525befcd616462c33f9ab5ec52fd6e0
6
+ metadata.gz: d52298b420e04ae5c4b8292e01d951eb23d03d4e01357f463111477e842aea9dd89f6e162a88ad05dd50fa8b721faf6522f891eb66e3405dd6207769f7a9bfe8
7
+ data.tar.gz: 1d7f89c274a8aac75868064826567901fbc0503ead631953fc7b24eaa282e0f1d8870d2e33f1dc6e81c834b4e3c00fc4b2b465b1a12cc349484b0e653628e63f
@@ -0,0 +1,29 @@
1
+ # TODO PDF 122: Vertical mode completion — Latin rotation, vertical ruby
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## Problem
6
+
7
+ The vertical writing path (TODO 13, 2026-08-19) stacked every glyph
8
+ upright: Latin letters and digits rendered sideways-wrong (rotated
9
+ in appearance relative to convention), and ruby annotations were
10
+ not emitted at all in vertical mode.
11
+
12
+ ## What was done
13
+
14
+ - Latin rotation: non-CJK glyphs in vertical text render rotated
15
+ 90° clockwise (the vertical-writing convention for Latin runs),
16
+ via a graphics-state transform (q → translate → rotate −90° →
17
+ text at origin → Q). CJK glyphs stay upright.
18
+ - Vertical ruby: runs carrying RubyString emit the annotation
19
+ stacked vertically alongside the base glyphs — to the right of
20
+ the column by default, to the left for RubyPosition values
21
+ containing "Below" (mirroring the horizontal above/below
22
+ convention under rotation). RubyFontSize defaults to half the
23
+ base size.
24
+
25
+ ## Known limitations
26
+
27
+ - Tate-chu-yoko (horizontal digit groups inside vertical text) and
28
+ mojikumi spacing classes remain documented stretch goals
29
+ (TODO 13).
@@ -0,0 +1,24 @@
1
+ # TODO PDF 123: Keep options (widow/orphan control)
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## What was done
6
+
7
+ - `StyleResolver::Paragraph` carries `keep_all_lines_together`
8
+ from the PSR.
9
+ - `TextFrameRenderer.consume_paragraphs` pre-measures the paragraph
10
+ (TextEngine::Measurement) and, when KeepAllLinesTogether is set,
11
+ the paragraph cannot fully fit the remaining frame space, and at
12
+ least one paragraph was already placed, pushes the whole
13
+ paragraph to the next frame via the chain state — no widowed
14
+ first lines. The first paragraph in a frame never pushes, so
15
+ progress is always made.
16
+
17
+ ## Known limitations
18
+
19
+ - KeepLinesTogether with per-side KeepFirstLines / KeepLastLines
20
+ counts (partial keep windows) is not modeled — only the
21
+ whole-paragraph KeepAllLinesTogether form.
22
+ - KeepWithNext (binding a paragraph to the next) is not modeled.
23
+ - Vertical writing mode does not apply keep options (column model
24
+ differs).
@@ -0,0 +1,23 @@
1
+ # TODO PDF 124: Tate-chu-yoko and vertical multi-run columns
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-20
4
+
5
+ ## What was done
6
+
7
+ - Multi-run column continuity (bug fix): every run in a vertical
8
+ frame used to restart at column 0, overlapping earlier runs.
9
+ `VerticalTextLayout.layout` takes `start_column`; the frame
10
+ renderer threads the next-unused column across runs and
11
+ paragraphs, and persists it in the chain state (`column_offset`)
12
+ so vertical text continues correctly into the next frame of a
13
+ chain.
14
+ - Tate-chu-yoko (`Tatechuyoko="true"` on the CSR — note the
15
+ schema's lowercase c): the run's glyphs render HORIZONTALLY,
16
+ sharing one baseline at the top of their column slot and centered
17
+ in it, consuming one column. Groups wider than the column height
18
+ fall back to stacked layout. StyledRun carries `tatechuyoko`.
19
+
20
+ ## Remaining from TODO 13
21
+
22
+ Mojikumi (punctuation spacing classes) is the last documented
23
+ stretch goal.
@@ -177,17 +177,20 @@ module Idml
177
177
  layout_frame = layout_frame(context.item, box)
178
178
  left_limit = layout_frame.x + (layout_frame.inset_left || 0)
179
179
  resume_vertical_paragraph(state)
180
+ next_column = state.column_offset || 0
180
181
  pending = 0
181
182
 
182
183
  state.paragraphs.each do |paragraph|
183
- remaining_runs = vertical_paragraph(
184
- canvas, paragraph, context, layout_frame, font, left_limit
184
+ remaining_runs, next_column = vertical_paragraph(
185
+ canvas, paragraph, context, layout_frame, font,
186
+ left_limit, next_column
185
187
  )
186
188
  if remaining_runs.empty?
187
189
  pending += 1
188
190
  else
189
191
  state.current_paragraph = paragraph
190
192
  state.runs_remaining = remaining_runs
193
+ state.column_offset = next_column
191
194
  break
192
195
  end
193
196
  end
@@ -198,7 +201,9 @@ module Idml
198
201
 
199
202
  # Folds a chain-resumed paragraph (runs_remaining) back to
200
203
  # the head of the paragraph list so the vertical path sees a
201
- # uniform sequence.
204
+ # uniform sequence. The consumed column offset persists in
205
+ # the state so the next frame continues right where this
206
+ # frame's columns ended.
202
207
  def self.resume_vertical_paragraph(state)
203
208
  return unless state.current_paragraph
204
209
 
@@ -209,10 +214,13 @@ module Idml
209
214
  end
210
215
  private_class_method :resume_vertical_paragraph
211
216
 
212
- # Renders one paragraph's runs as vertical columns. Returns
213
- # the runs that did not fit (empty when all placed).
217
+ # Renders one paragraph's runs as vertical columns starting
218
+ # at `start_column`. Returns [runs that did not fit (empty
219
+ # when all placed), the next unused column index].
214
220
  def self.vertical_paragraph(canvas, paragraph, context,
215
- layout_frame, font, left_limit)
221
+ layout_frame, font, left_limit,
222
+ start_column)
223
+ next_column = start_column
216
224
  paragraph.runs.each_with_index do |run, index|
217
225
  size = run.point_size || DEFAULT_SIZE
218
226
  leading = TextEngine::VerticalLayout.leading_for(
@@ -220,22 +228,71 @@ module Idml
220
228
  )
221
229
  glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
222
230
  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,
231
+ grouped = tatechuyoko_group(run, glyphs, layout_frame,
232
+ leading, size, next_column)
233
+ positioned, next_column = grouped ||
234
+ TextEngine::VerticalTextLayout.layout(
235
+ glyphs: glyphs, frame: layout_frame, leading: leading,
236
+ size: size, start_column: next_column
237
+ )
238
+ unless vertical_fits?(layout_frame, leading, next_column,
228
239
  left_limit)
229
- return paragraph.runs[index..]
240
+ return [paragraph.runs[index..], start_column]
230
241
  end
231
242
 
243
+ emit_vertical_run(canvas, positioned, grouped, run, context,
244
+ size)
245
+ emit_vertical_ruby(canvas, run, positioned, layout_frame,
246
+ size, context)
247
+ end
248
+ [[], next_column]
249
+ end
250
+ private_class_method :vertical_paragraph
251
+
252
+ # A tate-chu-yoko group for runs that declare it (and whose
253
+ # glyphs fit horizontally in a column slot); nil otherwise.
254
+ def self.tatechuyoko_group(run, glyphs, layout_frame, leading,
255
+ size, start_column)
256
+ return nil unless run.tatechuyoko
257
+
258
+ TextEngine::VerticalTextLayout.tatechuyoko_group(
259
+ glyphs: glyphs, frame: layout_frame, leading: leading,
260
+ size: size, start_column: start_column
261
+ )
262
+ end
263
+ private_class_method :tatechuyoko_group
264
+
265
+ # Emits a vertical run's glyphs: rotated/normal vertical
266
+ # handling for stacked columns, plain upright emission for
267
+ # tate-chu-yoko groups.
268
+ def self.emit_vertical_run(canvas, positioned, grouped, run,
269
+ context, size)
270
+ if grouped
271
+ positioned.each do |glyph|
272
+ emit_upright_glyph(canvas, glyph, run, context, size)
273
+ end
274
+ else
232
275
  positioned.each do |glyph|
233
276
  emit_vertical_glyph(canvas, glyph, run, context, size)
234
277
  end
235
278
  end
236
- []
237
279
  end
238
- private_class_method :vertical_paragraph
280
+ private_class_method :emit_vertical_run
281
+
282
+ def self.emit_upright_glyph(canvas, positioned, run, context,
283
+ size)
284
+ text = [positioned.codepoint].pack("U")
285
+ text_kwargs = CharacterStyle.text_kwargs(
286
+ run,
287
+ {
288
+ at: [positioned.x, positioned.y],
289
+ font: font_for_run(run, context),
290
+ size: size,
291
+ },
292
+ )
293
+ canvas.text(text, **text_kwargs)
294
+ end
295
+ private_class_method :emit_upright_glyph
239
296
 
240
297
  def self.vertical_fits?(layout_frame, leading, columns, left_limit)
241
298
  right = layout_frame.x + layout_frame.width -
@@ -246,21 +303,80 @@ module Idml
246
303
 
247
304
  # Emits one upright glyph at its vertical position with the
248
305
  # run's character styling.
306
+ # Emits one glyph in vertical mode: CJK upright; Latin
307
+ # (non-CJK) rotated 90° clockwise per the vertical-writing
308
+ # convention, via a graphics-state transform.
249
309
  def self.emit_vertical_glyph(canvas, positioned, run, context,
250
310
  size)
251
311
  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)
312
+ if TextEngine::CjkLayout.cjk?(positioned.codepoint)
313
+ text_kwargs = CharacterStyle.text_kwargs(
314
+ run,
315
+ {
316
+ at: [positioned.x, positioned.y],
317
+ font: font_for_run(run, context),
318
+ size: size,
319
+ },
320
+ )
321
+ canvas.text(text, **text_kwargs)
322
+ else
323
+ canvas.save_graphics_state do
324
+ # 90° clockwise: exact matrix (rotate would emit
325
+ # cos/sin float noise like 6.1e-17).
326
+ canvas.concat(0, -1, 1, 0, positioned.x, positioned.y)
327
+ text_kwargs = CharacterStyle.text_kwargs(
328
+ run,
329
+ {
330
+ at: [0, 0],
331
+ font: font_for_run(run, context),
332
+ size: size,
333
+ },
334
+ )
335
+ canvas.text(text, **text_kwargs)
336
+ end
337
+ end
261
338
  end
262
339
  private_class_method :emit_vertical_glyph
263
340
 
341
+ # Emits the run's ruby alongside its vertical glyphs:
342
+ # stacked top-to-bottom beside the column — right side by
343
+ # default, left for Below* RubyPosition values (the
344
+ # horizontal above/below convention mirrored under
345
+ # rotation).
346
+ def self.emit_vertical_ruby(canvas, run, positioned, frame,
347
+ base_size, context)
348
+ ruby_text = run.ruby_string
349
+ return if ruby_text.nil? || ruby_text.empty?
350
+
351
+ ruby_size = run.ruby_font_size ||
352
+ (base_size * RUBY_SIZE_FACTOR)
353
+ x = ruby_vertical_x(positioned.first, run, base_size)
354
+ y = frame.y + frame.height - (frame.inset_top || 0)
355
+ ruby_text.each_codepoint do |codepoint|
356
+ text_kwargs = CharacterStyle.text_kwargs(
357
+ run,
358
+ {
359
+ at: [x, y - ruby_size],
360
+ font: font_for_run(run, context),
361
+ size: ruby_size,
362
+ },
363
+ )
364
+ canvas.text([codepoint].pack("U"), **text_kwargs)
365
+ y -= ruby_size
366
+ end
367
+ end
368
+ private_class_method :emit_vertical_ruby
369
+
370
+ def self.ruby_vertical_x(positioned, run, base_size)
371
+ offset = base_size * 0.9
372
+ if run.ruby_position.to_s.include?("Below")
373
+ positioned.x - offset
374
+ else
375
+ positioned.x + offset
376
+ end
377
+ end
378
+ private_class_method :ruby_vertical_x
379
+
264
380
  def self.chain_head?(frame)
265
381
  frame.previous_text_frame.nil? || frame.previous_text_frame == "n"
266
382
  end
@@ -515,7 +631,9 @@ module Idml
515
631
  pending = 0
516
632
  state.paragraphs.each do |paragraph|
517
633
  break if cursor_y < bottom_limit
518
- break if paragraph_break?(paragraph) && pending.positive?
634
+ break if paragraph_deferred?(paragraph, layout_frame, font,
635
+ cursor_y, bottom_limit,
636
+ pending.positive?)
519
637
 
520
638
  remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
521
639
  canvas, paragraph, paragraph.runs, context,
@@ -847,6 +965,26 @@ module Idml
847
965
  end
848
966
  private_class_method :layout_run
849
967
 
968
+ # True when the paragraph should move wholly to the next
969
+ # frame: a StartParagraph forced break, or KeepAllLines-
970
+ # Together with insufficient remaining space (never for the
971
+ # frame's first paragraph, so progress is always made).
972
+ def self.paragraph_deferred?(paragraph, layout_frame, font,
973
+ cursor_y, bottom_limit, placed_any)
974
+ return true if paragraph_break?(paragraph) && placed_any
975
+ return false unless paragraph.keep_all_lines_together
976
+ return false unless placed_any
977
+
978
+ wrap_width = TextEngine::VerticalLayout.wrap_width(
979
+ layout_frame, paragraph.right_indent || 0
980
+ )
981
+ height = TextEngine::Measurement.paragraph_height(
982
+ paragraph, font, wrap_width
983
+ )
984
+ (cursor_y - height) < bottom_limit
985
+ end
986
+ private_class_method :paragraph_deferred?
987
+
850
988
  # True when the paragraph requests a forced break to the
851
989
  # next frame/column (StartParagraph). All break flavors act
852
990
  # at frame/column granularity.
@@ -25,6 +25,9 @@ module Idml
25
25
  :current_paragraph,
26
26
  :runs_remaining,
27
27
  :char_cursor,
28
+ # Vertical-writing path: columns already consumed when the
29
+ # paragraph chain continues into the next frame.
30
+ :column_offset,
28
31
  keyword_init: true,
29
32
  )
30
33
 
@@ -48,6 +48,7 @@ module Idml
48
48
  :ruby_string,
49
49
  :ruby_font_size,
50
50
  :ruby_position,
51
+ :tatechuyoko,
51
52
  keyword_init: true,
52
53
  )
53
54
 
@@ -70,6 +71,9 @@ module Idml
70
71
  # Forced paragraph break (StartParagraph: NextPage /
71
72
  # NextColumn / NextFrame / NextOddPage / NextEvenPage).
72
73
  :start_paragraph,
74
+ # Widow/orphan control: KeepAllLinesTogether pushes the whole
75
+ # paragraph to the next frame when it cannot fully fit.
76
+ :keep_all_lines_together,
73
77
  :bullets_and_numbering_list_type,
74
78
  :bullet_character_value,
75
79
  :bullets_text_after,
@@ -200,6 +204,9 @@ module Idml
200
204
  ),
201
205
  start_paragraph: resolve_attr(style_lookup, psr,
202
206
  :start_paragraph),
207
+ keep_all_lines_together: resolve_attr(
208
+ style_lookup, psr, :keep_all_lines_together
209
+ ),
203
210
  drop_cap_lines: resolve_attr(style_lookup, psr, :drop_cap_lines),
204
211
  drop_cap_characters: resolve_attr(style_lookup, psr,
205
212
  :drop_cap_characters),
@@ -390,6 +397,7 @@ module Idml
390
397
  ruby_string: resolve_csr(style_lookup, csr, :ruby_string),
391
398
  ruby_font_size: resolve_csr(style_lookup, csr, :ruby_font_size),
392
399
  ruby_position: resolve_csr(style_lookup, csr, :ruby_position),
400
+ tatechuyoko: resolve_csr(style_lookup, csr, :tatechuyoko),
393
401
  )
394
402
  end
395
403
  private_class_method :text_run
@@ -13,18 +13,19 @@ module Idml
13
13
  PositionedGlyph = Struct.new(:codepoint, :x, :y, keyword_init: true)
14
14
 
15
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:)
16
+ # height; column 0 is the rightmost. `start_column` places
17
+ # the first column after already-used columns (multi-run
18
+ # frames). Returns [positioned_glyphs, next_column_index].
19
+ def self.layout(glyphs:, frame:, leading:, size:, start_column: 0)
19
20
  lines = TextEngine::LineBreaker.break(
20
21
  glyphs: glyphs, frame_width: column_height(frame),
21
22
  )
22
23
  positioned = []
23
- lines.each_with_index do |line, column_index|
24
+ lines.each_with_index do |line, index|
24
25
  stack_column(positioned, line.glyphs, frame, leading, size,
25
- column_index)
26
+ index + start_column)
26
27
  end
27
- [positioned, lines.length]
28
+ [positioned, start_column + lines.length]
28
29
  end
29
30
 
30
31
  # The frame's usable vertical extent for one column of text.
@@ -47,6 +48,32 @@ module Idml
47
48
  end
48
49
  private_class_method :stack_column
49
50
 
51
+ # Positions a tate-chu-yoko group (e.g. the digits "12" kept
52
+ # horizontal inside vertical text): glyphs share one baseline
53
+ # at the top of the given column slot and advance left-to-
54
+ # right, centered in the slot. Returns
55
+ # [positioned_glyphs, next_column_index]; nil when the group
56
+ # is wider than the column height (caller falls back to
57
+ # stacked layout).
58
+ def self.tatechuyoko_group(glyphs:, frame:, leading:, size:,
59
+ start_column:)
60
+ total = glyphs.sum(&:width)
61
+ return nil if total > column_height(frame)
62
+
63
+ right = frame.x + frame.width - (frame.inset_right || 0)
64
+ slot_center = right - ((start_column + 0.5) * leading)
65
+ x = slot_center - (total / 2.0)
66
+ baseline_y = frame.y + frame.height - (frame.inset_top || 0) - size
67
+ positioned = glyphs.map do |glyph|
68
+ glyph_position = PositionedGlyph.new(
69
+ codepoint: glyph.codepoint, x: x, y: baseline_y,
70
+ )
71
+ x += glyph.width
72
+ glyph_position
73
+ end
74
+ [positioned, start_column + 1]
75
+ end
76
+
50
77
  # Vertical advance of one glyph — its measured width (the em
51
78
  # for the common square-CJK case).
52
79
  def self.advance(glyph)
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.5"
4
+ VERSION = "0.9.7"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.5
4
+ version: 0.9.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-20 00:00:00.000000000 Z
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -180,6 +180,9 @@ files:
180
180
  - TODO.pdf/12-idml-to-pdf-pipeline.md
181
181
  - TODO.pdf/120-start-paragraph-breaks.md
182
182
  - TODO.pdf/121-cell-edge-strokes.md
183
+ - TODO.pdf/122-vertical-latin-ruby.md
184
+ - TODO.pdf/123-keep-options.md
185
+ - TODO.pdf/124-tatechuyoko-vertical.md
183
186
  - TODO.pdf/13-cjk-text-layout.md
184
187
  - TODO.pdf/14-page-item-element-models.md
185
188
  - TODO.pdf/15-wire-spread-children.md