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 +4 -4
- data/TODO.pdf/148-architecture-improvements.md +45 -0
- data/lib/idml/render/renderers/simple_text.rb +82 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +7 -293
- data/lib/idml/render/renderers/vertical_emit.rb +228 -0
- data/lib/idml/render.rb +8 -0
- data/lib/idml/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69238283188d95396073ce5caacf5662573ccaa6d913e1f76d056b24284449d7
|
|
4
|
+
data.tar.gz: 20639926a31480df14b3225e04a3eb04f3042bfb105aa9377a63bcb1a0cc5850
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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.
|
|
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-
|
|
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
|