idml 0.10.9 → 0.10.10

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: c3ebdf83496770ecda7b37cc6f6cdc2388874785ccad23ac57aa396f76aef03b
4
- data.tar.gz: d7304a209e09d4de3ee6d9f7556d18053f838ee3fef9a4f787f06693aa42ee32
3
+ metadata.gz: 69238283188d95396073ce5caacf5662573ccaa6d913e1f76d056b24284449d7
4
+ data.tar.gz: 20639926a31480df14b3225e04a3eb04f3042bfb105aa9377a63bcb1a0cc5850
5
5
  SHA512:
6
- metadata.gz: bd4dc75a85966131506edf7f3e01df91127fbf26525bb02edcf783f0c07580380be0d8bd38562c11a477602731e2efff8de78061580b471f1389f3bb87a07119
7
- data.tar.gz: 74286965e709455a356789cc6f7e4c93f75d414a1132301ee8e347a85d114065bd1988921c180a522c646824d79f2049bdfacf7db609e9981b270d3a88b71fbb
6
+ metadata.gz: 474d4266f64894e0fde05e60e2791402854e20b0a4820950e165f8552fb6be7aa644eb7a5303f8f2fa0e86e6dd4df1673d6b53f6ae9a2fac304f36c4fa5cc5e0
7
+ data.tar.gz: 48ba09b818f5079b981cbed968a49779a69fc38c61e6500c973036f404f6ce1796d7238fea2a0532355b3d5a61130d1d741ac59b77fa823fb4b8e3a4fe0b8f5b
@@ -0,0 +1,45 @@
1
+ # TODO PDF 148: Codebase architecture improvements
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-29
4
+
5
+ ## Problem
6
+
7
+ Architecture audit findings:
8
+
9
+ 1. `TextFrameRenderer` was a 1520-line god class owning four
10
+ unrelated concerns: the horizontal shaping engine, the vertical
11
+ writing path, the no-metrics fallback, and everything else
12
+ (chains, endnotes, footnotes, wrap, keep options).
13
+ 2. Twelve render specs each declared their own near-identical
14
+ `*_FONT_CANDIDATES` list (~7 lines each), with two variants
15
+ drifting apart (some lists lacked the CJK-capable fonts —
16
+ CJK-dependent specs silently skipped on platforms that had
17
+ them).
18
+
19
+ ## Solution
20
+
21
+ - **VerticalEmit** (`renderers/vertical_emit.rb`, ~230 lines): the
22
+ whole vertical-writing family (vertical_render through
23
+ ruby_vertical_x), extracted as a class-methods module
24
+ `extend`ed into TextFrameRenderer — it shares the host's
25
+ constants, `layout_frame`, and `font_for_run` (MECE: the module
26
+ owns everything vertical; the host owns the horizontal engine).
27
+ Zero behavior change; all vertical/ruby specs pass untouched.
28
+ - **SimpleText** (`renderers/simple_text.rb`, ~90 lines): the
29
+ no-metrics fallback (simple_render, footnote stacking,
30
+ collect/build rich runs), same extend pattern.
31
+ - **Spec font candidates DRY'd**: `SPEC_FONT_CANDIDATES` +
32
+ `spec_font_path` in spec_helper; the twelve per-spec constants
33
+ deleted. Unifying on the CJK-capable superset un-skipped 20
34
+ previously-skipped examples on CJK-font platforms.
35
+
36
+ `TextFrameRenderer` is now 1235 lines of cohesive horizontal
37
+ engine + orchestration.
38
+
39
+ ## Files
40
+
41
+ - `lib/idml/render/renderers/vertical_emit.rb` (new)
42
+ - `lib/idml/render/renderers/simple_text.rb` (new)
43
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
44
+ - `lib/idml/render.rb`
45
+ - `spec/spec_helper.rb` + 12 render specs
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ module Renderers
6
+ # The no-metrics fallback path of TextFrameRenderer: when no
7
+ # FontMetrics is available (no shaper → no wrap), all runs
8
+ # emit as one `text_rich` block per frame and footnote text
9
+ # stacks at the frame bottom. Chain threading is best-effort
10
+ # here — the simple path doesn't wrap, so the first frame's
11
+ # render typically emits all content and clears the chain
12
+ # state.
13
+ #
14
+ # A class-methods module extended into TextFrameRenderer —
15
+ # it shares the host's font resolution (MECE: this module
16
+ # owns the rough path; the host owns the shaping engine).
17
+ module SimpleText
18
+ def simple_render(canvas, state, context, box)
19
+ runs = collect_runs(state)
20
+ return if runs.empty?
21
+
22
+ runs_for = build_rich_runs(runs, context)
23
+ first_size = runs.first.point_size ||
24
+ TextFrameRenderer::DEFAULT_SIZE
25
+ canvas.text_rich(
26
+ runs_for,
27
+ at: [box[:x], box[:y] + box[:height] - first_size],
28
+ )
29
+ emit_simple_footnotes(canvas, runs, context, box)
30
+ state.paragraphs = []
31
+ state.current_paragraph = nil
32
+ state.runs_remaining = []
33
+ end
34
+
35
+ # Footnote text for the rough (no-metrics) path: one line
36
+ # per footnote paragraph, stacked upward from the frame's
37
+ # bottom edge below a hairline separator. Paragraphs are
38
+ # already marker-prefixed at extraction.
39
+ def emit_simple_footnotes(canvas, runs, context, box)
40
+ paragraphs = runs.flat_map(&:footnote_paragraphs).compact
41
+ return if paragraphs.empty?
42
+
43
+ size = paragraphs.first.runs.first&.point_size ||
44
+ TextFrameRenderer::DEFAULT_SIZE
45
+ block_top = box[:y] + (paragraphs.length * size)
46
+ canvas.line_width = 0.5
47
+ canvas.stroke_color([:gray, 0.0])
48
+ canvas.move_to(box[:x], block_top + (size * 0.3))
49
+ canvas.line_to(box[:x] + (box[:width] * 0.25),
50
+ block_top + (size * 0.3))
51
+ canvas.stroke
52
+
53
+ paragraphs.reverse_each.with_index do |paragraph, index|
54
+ canvas.text_rich(
55
+ build_rich_runs(paragraph.runs, context),
56
+ at: [box[:x], box[:y] + ((index + 1) * size)],
57
+ )
58
+ end
59
+ end
60
+
61
+ def collect_runs(state)
62
+ runs = state.runs_remaining || []
63
+ if state.current_paragraph
64
+ runs + state.current_paragraph.runs
65
+ else
66
+ runs + state.paragraphs.flat_map(&:runs)
67
+ end
68
+ end
69
+
70
+ def build_rich_runs(runs, context)
71
+ runs.map do |run|
72
+ {
73
+ text: run.text,
74
+ font: font_for_run(run, context),
75
+ size: run.point_size || TextFrameRenderer::DEFAULT_SIZE,
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -27,6 +27,13 @@ module Idml
27
27
  DEFAULT_SIZE = 12.0
28
28
  RUBY_SIZE_FACTOR = 0.5
29
29
 
30
+ # Vertical-writing wing (MECE split; shares this class's
31
+ # constants, layout_frame, and font resolution).
32
+ extend VerticalEmit
33
+
34
+ # No-metrics fallback path (MECE split; rough output).
35
+ extend SimpleText
36
+
30
37
  def self.render(canvas, context)
31
38
  frame = context.item
32
39
  return unless frame.parent_story
@@ -221,231 +228,6 @@ module Idml
221
228
 
222
229
  # True when the story's StoryPreference declares vertical
223
230
  # writing (StoryOrientation="Vertical").
224
- def self.vertical_story?(story)
225
- story.inner&.story_preference&.story_orientation == "Vertical"
226
- end
227
- private_class_method :vertical_story?
228
-
229
- # Vertical writing path (StoryOrientation="Vertical", CJK):
230
- # paragraphs flow as upright glyph columns advancing
231
- # right-to-left; each run starts a fresh column (run-mixing
232
- # within a column is not modeled). Runs whose columns would
233
- # cross the frame's left inset overflow to the next frame in
234
- # the chain. Paragraph spacing, rules, and decorations are
235
- # not applied in vertical mode.
236
- def self.vertical_render(canvas, state, context, box, font)
237
- layout_frame = layout_frame(context.item, box)
238
- left_limit = layout_frame.x + (layout_frame.inset_left || 0)
239
- resume_vertical_paragraph(state)
240
- next_column = state.column_offset || 0
241
- pending = 0
242
-
243
- state.paragraphs.each do |paragraph|
244
- remaining_runs, next_column = vertical_paragraph(
245
- canvas, paragraph, context, layout_frame, font,
246
- left_limit, next_column
247
- )
248
- if remaining_runs.empty?
249
- pending += 1
250
- else
251
- state.current_paragraph = paragraph
252
- state.runs_remaining = remaining_runs
253
- state.column_offset = next_column
254
- break
255
- end
256
- end
257
-
258
- state.paragraphs = state.paragraphs[pending..]
259
- end
260
- private_class_method :vertical_render
261
-
262
- # Folds a chain-resumed paragraph (runs_remaining) back to
263
- # the head of the paragraph list so the vertical path sees a
264
- # uniform sequence. The consumed column offset persists in
265
- # the state so the next frame continues right where this
266
- # frame's columns ended.
267
- def self.resume_vertical_paragraph(state)
268
- return unless state.current_paragraph
269
-
270
- state.current_paragraph.runs = state.runs_remaining
271
- state.paragraphs.unshift(state.current_paragraph)
272
- state.current_paragraph = nil
273
- state.runs_remaining = []
274
- end
275
- private_class_method :resume_vertical_paragraph
276
-
277
- # Renders one paragraph's runs as vertical columns starting
278
- # at `start_column`. Returns [runs that did not fit (empty
279
- # when all placed), the next unused column index].
280
- def self.vertical_paragraph(canvas, paragraph, context,
281
- layout_frame, font, left_limit,
282
- start_column)
283
- next_column = start_column
284
- paragraph.runs.each_with_index do |run, index|
285
- size = run.point_size || DEFAULT_SIZE
286
- leading = TextEngine::VerticalLayout.leading_for(
287
- paragraph.auto_leading, size
288
- )
289
- glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
290
- size: size)
291
- grouped = tatechuyoko_group(run, glyphs, layout_frame,
292
- leading, size, next_column)
293
- positioned, next_column = grouped ||
294
- TextEngine::VerticalTextLayout.layout(
295
- glyphs: glyphs, frame: layout_frame, leading: leading,
296
- size: size, start_column: next_column
297
- )
298
- unless vertical_fits?(layout_frame, leading, next_column,
299
- left_limit)
300
- return [paragraph.runs[index..], start_column]
301
- end
302
-
303
- emit_vertical_run(canvas, positioned, grouped, run, context,
304
- size)
305
- emit_vertical_ruby(canvas, run, positioned, layout_frame,
306
- size, context)
307
- end
308
- [[], next_column]
309
- end
310
- private_class_method :vertical_paragraph
311
-
312
- # A tate-chu-yoko group for runs that declare it (and whose
313
- # glyphs fit horizontally in a column slot); nil otherwise.
314
- def self.tatechuyoko_group(run, glyphs, layout_frame, leading,
315
- size, start_column)
316
- return nil unless run.tatechuyoko
317
-
318
- TextEngine::VerticalTextLayout.tatechuyoko_group(
319
- glyphs: glyphs, frame: layout_frame, leading: leading,
320
- size: size, start_column: start_column
321
- )
322
- end
323
- private_class_method :tatechuyoko_group
324
-
325
- # Emits a vertical run's glyphs: rotated/normal vertical
326
- # handling for stacked columns, plain upright emission for
327
- # tate-chu-yoko groups.
328
- def self.emit_vertical_run(canvas, positioned, grouped, run,
329
- context, size)
330
- if grouped
331
- positioned.each do |glyph|
332
- emit_upright_glyph(canvas, glyph, run, context, size)
333
- end
334
- else
335
- emit_vertical_stack(canvas, positioned, run, context, size)
336
- end
337
- end
338
- private_class_method :emit_vertical_run
339
-
340
- # Vertical-stack emission: CJK glyphs stand upright per
341
- # glyph; consecutive non-CJK glyphs rotate as ONE group
342
- # (the whole Latin word sideways, as InDesign does). The
343
- # group's string renders as a single text op inside one
344
- # rotated graphics state — the font's advances land each
345
- # glyph exactly where per-glyph rotation placed it. A group
346
- # never spans columns (x reset) and shares the run's
347
- # styling, so one text op is lossless.
348
- def self.emit_vertical_stack(canvas, positioned, run, context,
349
- size)
350
- segment = []
351
- positioned.each do |glyph|
352
- if TextEngine::CjkLayout.cjk?(glyph.codepoint)
353
- flush_vertical_segment(canvas, segment, run, context, size)
354
- emit_upright_glyph(canvas, glyph, run, context, size)
355
- else
356
- if !segment.empty? && segment.last.x != glyph.x
357
- flush_vertical_segment(canvas, segment, run, context, size)
358
- end
359
- segment << glyph
360
- end
361
- end
362
- flush_vertical_segment(canvas, segment, run, context, size)
363
- end
364
- private_class_method :emit_vertical_stack
365
-
366
- def self.flush_vertical_segment(canvas, segment, run, context,
367
- size)
368
- return if segment.empty?
369
-
370
- text = segment.map { |g| [g.codepoint].pack("U") }.join
371
- first = segment.first
372
- canvas.save_graphics_state do
373
- # 90° clockwise: exact matrix (rotate would emit
374
- # cos/sin float noise like 6.1e-17).
375
- canvas.concat(0, -1, 1, 0, first.x, first.y)
376
- text_kwargs = CharacterStyle.text_kwargs(
377
- run,
378
- {
379
- at: [0, 0],
380
- font: font_for_run(run, context),
381
- size: size,
382
- },
383
- )
384
- canvas.text(text, **text_kwargs)
385
- end
386
- end
387
- private_class_method :flush_vertical_segment
388
-
389
- def self.emit_upright_glyph(canvas, positioned, run, context,
390
- size)
391
- text = [positioned.codepoint].pack("U")
392
- text_kwargs = CharacterStyle.text_kwargs(
393
- run,
394
- {
395
- at: [positioned.x, positioned.y],
396
- font: font_for_run(run, context),
397
- size: size,
398
- },
399
- )
400
- canvas.text(text, **text_kwargs)
401
- end
402
- private_class_method :emit_upright_glyph
403
-
404
- def self.vertical_fits?(layout_frame, leading, columns, left_limit)
405
- right = layout_frame.x + layout_frame.width -
406
- (layout_frame.inset_right || 0)
407
- (right - (columns * leading)) >= left_limit
408
- end
409
- private_class_method :vertical_fits?
410
-
411
- # Emits the run's ruby alongside its vertical glyphs:
412
- # stacked top-to-bottom beside the column — right side by
413
- # default, left for Below* RubyPosition values (the
414
- # horizontal above/below convention mirrored under
415
- # rotation).
416
- def self.emit_vertical_ruby(canvas, run, positioned, frame,
417
- base_size, context)
418
- ruby_text = run.ruby_string
419
- return if ruby_text.nil? || ruby_text.empty?
420
-
421
- ruby_size = run.ruby_font_size ||
422
- (base_size * RUBY_SIZE_FACTOR)
423
- x = ruby_vertical_x(positioned.first, run, base_size)
424
- y = frame.y + frame.height - (frame.inset_top || 0)
425
- ruby_text.each_codepoint do |codepoint|
426
- text_kwargs = CharacterStyle.text_kwargs(
427
- run,
428
- {
429
- at: [x, y - ruby_size],
430
- font: font_for_run(run, context),
431
- size: ruby_size,
432
- },
433
- )
434
- canvas.text([codepoint].pack("U"), **text_kwargs)
435
- y -= ruby_size
436
- end
437
- end
438
- private_class_method :emit_vertical_ruby
439
-
440
- def self.ruby_vertical_x(positioned, run, base_size)
441
- offset = base_size * 0.9
442
- if run.ruby_position.to_s.include?("Below")
443
- positioned.x - offset
444
- else
445
- positioned.x + offset
446
- end
447
- end
448
- private_class_method :ruby_vertical_x
449
231
 
450
232
  def self.chain_head?(frame)
451
233
  frame.previous_text_frame.nil? || frame.previous_text_frame == "n"
@@ -1446,74 +1228,6 @@ module Idml
1446
1228
  line.glyphs.map { |g| [g.codepoint].pack("U") }.join
1447
1229
  end
1448
1230
  private_class_method :line_text
1449
-
1450
- # Fallback when no metrics are available: emit all runs as
1451
- # one `text_rich` block, letting pdfrb measure advance widths.
1452
- # Chain threading is best-effort here — the simple path
1453
- # doesn't wrap, so the first frame's render typically emits
1454
- # all content and clears the chain state.
1455
- def self.simple_render(canvas, state, context, box)
1456
- runs = collect_runs(state)
1457
- return if runs.empty?
1458
-
1459
- runs_for = build_rich_runs(runs, context)
1460
- first_size = runs.first.point_size || DEFAULT_SIZE
1461
- canvas.text_rich(
1462
- runs_for,
1463
- at: [box[:x], box[:y] + box[:height] - first_size],
1464
- )
1465
- emit_simple_footnotes(canvas, runs, context, box)
1466
- state.paragraphs = []
1467
- state.current_paragraph = nil
1468
- state.runs_remaining = []
1469
- end
1470
- private_class_method :simple_render
1471
-
1472
- # Footnote text for the rough (no-metrics) path: one line
1473
- # per footnote paragraph, stacked upward from the frame's
1474
- # bottom edge below a hairline separator. Paragraphs are
1475
- # already marker-prefixed at extraction.
1476
- def self.emit_simple_footnotes(canvas, runs, context, box)
1477
- paragraphs = runs.flat_map(&:footnote_paragraphs).compact
1478
- return if paragraphs.empty?
1479
-
1480
- size = paragraphs.first.runs.first&.point_size || DEFAULT_SIZE
1481
- block_top = box[:y] + (paragraphs.length * size)
1482
- canvas.line_width = 0.5
1483
- canvas.stroke_color([:gray, 0.0])
1484
- canvas.move_to(box[:x], block_top + (size * 0.3))
1485
- canvas.line_to(box[:x] + (box[:width] * 0.25), block_top + (size * 0.3))
1486
- canvas.stroke
1487
-
1488
- paragraphs.reverse_each.with_index do |paragraph, index|
1489
- canvas.text_rich(
1490
- build_rich_runs(paragraph.runs, context),
1491
- at: [box[:x], box[:y] + ((index + 1) * size)],
1492
- )
1493
- end
1494
- end
1495
- private_class_method :emit_simple_footnotes
1496
-
1497
- def self.collect_runs(state)
1498
- runs = state.runs_remaining || []
1499
- if state.current_paragraph
1500
- runs + state.current_paragraph.runs
1501
- else
1502
- runs + state.paragraphs.flat_map(&:runs)
1503
- end
1504
- end
1505
- private_class_method :collect_runs
1506
-
1507
- def self.build_rich_runs(runs, context)
1508
- runs.map do |run|
1509
- {
1510
- text: run.text,
1511
- font: font_for_run(run, context),
1512
- size: run.point_size || DEFAULT_SIZE,
1513
- }
1514
- end
1515
- end
1516
- private_class_method :build_rich_runs
1517
1231
  end
1518
1232
  end
1519
1233
  end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ module Renderers
6
+ # Vertical writing path (StoryOrientation="Vertical", CJK):
7
+ # paragraphs flow as upright glyph columns advancing
8
+ # right-to-left; each run starts a fresh column (run-mixing
9
+ # within a column is not modeled). Runs whose columns would
10
+ # cross the frame's left inset overflow to the next frame in
11
+ # the chain. Paragraph spacing, rules, and decorations are
12
+ # not applied in vertical mode.
13
+ #
14
+ # A class-methods module extended into TextFrameRenderer —
15
+ # it shares the host's constants, font resolution, and
16
+ # layout_frame helper (MECE: this module owns everything
17
+ # vertical; the host owns the horizontal engine).
18
+ module VerticalEmit
19
+ def vertical_story?(story)
20
+ story.inner&.story_preference&.story_orientation == "Vertical"
21
+ end
22
+
23
+ def vertical_render(canvas, state, context, box, font)
24
+ layout_frame = layout_frame(context.item, box)
25
+ left_limit = layout_frame.x + (layout_frame.inset_left || 0)
26
+ resume_vertical_paragraph(state)
27
+ next_column = state.column_offset || 0
28
+ pending = 0
29
+
30
+ state.paragraphs.each do |paragraph|
31
+ remaining_runs, next_column = vertical_paragraph(
32
+ canvas, paragraph, context, layout_frame, font,
33
+ left_limit, next_column
34
+ )
35
+ if remaining_runs.empty?
36
+ pending += 1
37
+ else
38
+ state.current_paragraph = paragraph
39
+ state.runs_remaining = remaining_runs
40
+ state.column_offset = next_column
41
+ break
42
+ end
43
+ end
44
+
45
+ state.paragraphs = state.paragraphs[pending..]
46
+ end
47
+
48
+ # Folds a chain-resumed paragraph (runs_remaining) back to
49
+ # the head of the paragraph list so the vertical path sees a
50
+ # uniform sequence. The consumed column offset persists in
51
+ # the state so the next frame continues right where this
52
+ # frame's columns ended.
53
+ def resume_vertical_paragraph(state)
54
+ return unless state.current_paragraph
55
+
56
+ state.current_paragraph.runs = state.runs_remaining
57
+ state.paragraphs.unshift(state.current_paragraph)
58
+ state.current_paragraph = nil
59
+ state.runs_remaining = []
60
+ end
61
+
62
+ # Renders one paragraph's runs as vertical columns starting
63
+ # at `start_column`. Returns [runs that did not fit (empty
64
+ # when all placed), the next unused column index].
65
+ def vertical_paragraph(canvas, paragraph, context,
66
+ layout_frame, font, left_limit,
67
+ start_column)
68
+ next_column = start_column
69
+ paragraph.runs.each_with_index do |run, index|
70
+ size = run.point_size || TextFrameRenderer::DEFAULT_SIZE
71
+ leading = TextEngine::VerticalLayout.leading_for(
72
+ paragraph.auto_leading, size
73
+ )
74
+ glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
75
+ size: size)
76
+ grouped = tatechuyoko_group(run, glyphs, layout_frame,
77
+ leading, size, next_column)
78
+ positioned, next_column = grouped ||
79
+ TextEngine::VerticalTextLayout.layout(
80
+ glyphs: glyphs, frame: layout_frame, leading: leading,
81
+ size: size, start_column: next_column
82
+ )
83
+ unless vertical_fits?(layout_frame, leading, next_column,
84
+ left_limit)
85
+ return [paragraph.runs[index..], start_column]
86
+ end
87
+
88
+ emit_vertical_run(canvas, positioned, grouped, run, context,
89
+ size)
90
+ emit_vertical_ruby(canvas, run, positioned, layout_frame,
91
+ size, context)
92
+ end
93
+ [[], next_column]
94
+ end
95
+
96
+ # A tate-chu-yoko group for runs that declare it (and whose
97
+ # glyphs fit horizontally in a column slot); nil otherwise.
98
+ def tatechuyoko_group(run, glyphs, layout_frame, leading,
99
+ size, start_column)
100
+ return nil unless run.tatechuyoko
101
+
102
+ TextEngine::VerticalTextLayout.tatechuyoko_group(
103
+ glyphs: glyphs, frame: layout_frame, leading: leading,
104
+ size: size, start_column: start_column
105
+ )
106
+ end
107
+
108
+ # Emits a vertical run's glyphs: rotated/normal vertical
109
+ # handling for stacked columns, plain upright emission for
110
+ # tate-chu-yoko groups.
111
+ def emit_vertical_run(canvas, positioned, grouped, run,
112
+ context, size)
113
+ if grouped
114
+ positioned.each do |glyph|
115
+ emit_upright_glyph(canvas, glyph, run, context, size)
116
+ end
117
+ else
118
+ emit_vertical_stack(canvas, positioned, run, context, size)
119
+ end
120
+ end
121
+
122
+ # Vertical-stack emission: CJK glyphs stand upright per
123
+ # glyph; consecutive non-CJK glyphs rotate as ONE group
124
+ # (the whole Latin word sideways, as InDesign does). The
125
+ # group's string renders as a single text op inside one
126
+ # rotated graphics state — the font's advances land each
127
+ # glyph exactly where per-glyph rotation placed it. A group
128
+ # never spans columns (x reset) and shares the run's
129
+ # styling, so one text op is lossless.
130
+ def emit_vertical_stack(canvas, positioned, run, context,
131
+ size)
132
+ segment = []
133
+ positioned.each do |glyph|
134
+ if TextEngine::CjkLayout.cjk?(glyph.codepoint)
135
+ flush_vertical_segment(canvas, segment, run, context, size)
136
+ emit_upright_glyph(canvas, glyph, run, context, size)
137
+ else
138
+ if !segment.empty? && segment.last.x != glyph.x
139
+ flush_vertical_segment(canvas, segment, run, context, size)
140
+ end
141
+ segment << glyph
142
+ end
143
+ end
144
+ flush_vertical_segment(canvas, segment, run, context, size)
145
+ end
146
+
147
+ def flush_vertical_segment(canvas, segment, run, context,
148
+ size)
149
+ return if segment.empty?
150
+
151
+ text = segment.map { |g| [g.codepoint].pack("U") }.join
152
+ first = segment.first
153
+ canvas.save_graphics_state do
154
+ # 90° clockwise: exact matrix (rotate would emit
155
+ # cos/sin float noise like 6.1e-17).
156
+ canvas.concat(0, -1, 1, 0, first.x, first.y)
157
+ text_kwargs = CharacterStyle.text_kwargs(
158
+ run,
159
+ {
160
+ at: [0, 0],
161
+ font: font_for_run(run, context),
162
+ size: size,
163
+ },
164
+ )
165
+ canvas.text(text, **text_kwargs)
166
+ end
167
+ end
168
+
169
+ def emit_upright_glyph(canvas, positioned, run, context,
170
+ size)
171
+ text = [positioned.codepoint].pack("U")
172
+ text_kwargs = CharacterStyle.text_kwargs(
173
+ run,
174
+ {
175
+ at: [positioned.x, positioned.y],
176
+ font: font_for_run(run, context),
177
+ size: size,
178
+ },
179
+ )
180
+ canvas.text(text, **text_kwargs)
181
+ end
182
+
183
+ def vertical_fits?(layout_frame, leading, columns, left_limit)
184
+ right = layout_frame.x + layout_frame.width -
185
+ (layout_frame.inset_right || 0)
186
+ (right - (columns * leading)) >= left_limit
187
+ end
188
+
189
+ # Emits the run's ruby alongside its vertical glyphs:
190
+ # stacked top-to-bottom beside the column — right side by
191
+ # default, left for Below* RubyPosition values (the
192
+ # horizontal above/below convention mirrored under
193
+ # rotation).
194
+ def emit_vertical_ruby(canvas, run, positioned, frame,
195
+ base_size, context)
196
+ ruby_text = run.ruby_string
197
+ return if ruby_text.nil? || ruby_text.empty?
198
+
199
+ ruby_size = run.ruby_font_size ||
200
+ (base_size * TextFrameRenderer::RUBY_SIZE_FACTOR)
201
+ x = ruby_vertical_x(positioned.first, run, base_size)
202
+ y = frame.y + frame.height - (frame.inset_top || 0)
203
+ ruby_text.each_codepoint do |codepoint|
204
+ text_kwargs = CharacterStyle.text_kwargs(
205
+ run,
206
+ {
207
+ at: [x, y - ruby_size],
208
+ font: font_for_run(run, context),
209
+ size: ruby_size,
210
+ },
211
+ )
212
+ canvas.text([codepoint].pack("U"), **text_kwargs)
213
+ y -= ruby_size
214
+ end
215
+ end
216
+
217
+ def ruby_vertical_x(positioned, run, base_size)
218
+ offset = base_size * 0.9
219
+ if run.ruby_position.to_s.include?("Below")
220
+ positioned.x - offset
221
+ else
222
+ positioned.x + offset
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
data/lib/idml/render.rb CHANGED
@@ -92,3 +92,11 @@ Idml::Render::Renderers.autoload(
92
92
  :TableRenderer,
93
93
  "#{__dir__}/render/renderers/table_renderer",
94
94
  )
95
+ Idml::Render::Renderers.autoload(
96
+ :VerticalEmit,
97
+ "#{__dir__}/render/renderers/vertical_emit",
98
+ )
99
+ Idml::Render::Renderers.autoload(
100
+ :SimpleText,
101
+ "#{__dir__}/render/renderers/simple_text",
102
+ )
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.9"
4
+ VERSION = "0.10.10"
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.9
4
+ version: 0.10.10
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-28 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -211,6 +211,7 @@ files:
211
211
  - TODO.pdf/145-apply-mojikumi-aki.md
212
212
  - TODO.pdf/146-next-column-wrap.md
213
213
  - TODO.pdf/147-side-aware-shapes.md
214
+ - TODO.pdf/148-architecture-improvements.md
214
215
  - TODO.pdf/15-wire-spread-children.md
215
216
  - TODO.pdf/16-typed-model-pipeline.md
216
217
  - TODO.pdf/17-shape-rendering.md
@@ -507,8 +508,10 @@ files:
507
508
  - lib/idml/render/renderers/path_renderer.rb
508
509
  - lib/idml/render/renderers/polygon_renderer.rb
509
510
  - lib/idml/render/renderers/rectangle_renderer.rb
511
+ - lib/idml/render/renderers/simple_text.rb
510
512
  - lib/idml/render/renderers/table_renderer.rb
511
513
  - lib/idml/render/renderers/text_frame_renderer.rb
514
+ - lib/idml/render/renderers/vertical_emit.rb
512
515
  - lib/idml/render/shape_paint.rb
513
516
  - lib/idml/render/spread_renderer.rb
514
517
  - lib/idml/render/story_chain_controller.rb