idml 0.10.8 → 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/145-apply-mojikumi-aki.md +37 -0
- data/TODO.pdf/146-next-column-wrap.md +25 -0
- data/TODO.pdf/147-side-aware-shapes.md +27 -0
- data/TODO.pdf/148-architecture-improvements.md +45 -0
- data/TODO.pdf/61-known-limitations.md +14 -14
- data/lib/idml/render/renderers/simple_text.rb +82 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +23 -293
- data/lib/idml/render/renderers/vertical_emit.rb +228 -0
- data/lib/idml/render/text_wrap_resolver.rb +49 -28
- data/lib/idml/render/wrap_contour.rb +13 -3
- data/lib/idml/render.rb +8 -0
- data/lib/idml/text_engine/cjk_layout.rb +79 -6
- data/lib/idml/version.rb +1 -1
- metadata +8 -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,37 @@
|
|
|
1
|
+
# TODO PDF 145: Apply mojikumi aki to layout
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-28
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Two gaps: (a) `apply_script_spacing` (TODO 125) was implemented
|
|
8
|
+
but never wired into the render pipeline — CJK/Latin auto spacing
|
|
9
|
+
never actually applied; (b) the named mojikumi sets modeled in
|
|
10
|
+
TODO 144 were parsed but unused.
|
|
11
|
+
|
|
12
|
+
## Solution
|
|
13
|
+
|
|
14
|
+
- `CjkLayout.apply_script_spacing` now takes the document's aki
|
|
15
|
+
overrides: when the designmap declares a `MojikumiTable`, each
|
|
16
|
+
adjacent glyph pair whose classes match an
|
|
17
|
+
`OverrideMojikumiAki` entry (Target/Side class ×
|
|
18
|
+
SideIsAfterTarget) gets that entry's Desired spacing (em ×
|
|
19
|
+
point size). Unlisted pairs keep the default eighth-em at
|
|
20
|
+
CJK/Latin script boundaries.
|
|
21
|
+
- New `CjkLayout.mojikumi_class` classifies the seven InDesign
|
|
22
|
+
mojikumi classes (ideograph, opening bracket, closing bracket,
|
|
23
|
+
comma/period, middle dot, digit — incl. fullwidth, Latin —
|
|
24
|
+
incl. fullwidth).
|
|
25
|
+
- `TextFrameRenderer#layout_run` applies the spacing between
|
|
26
|
+
shaping and line breaking, using
|
|
27
|
+
`mojikumi_aki_overrides(context)` (first named set from the
|
|
28
|
+
designmap; empty when absent).
|
|
29
|
+
|
|
30
|
+
The result: documents declaring custom mojikumi now render their
|
|
31
|
+
declared inter-class spacing.
|
|
32
|
+
|
|
33
|
+
## Files
|
|
34
|
+
|
|
35
|
+
- `lib/idml/text_engine/cjk_layout.rb`
|
|
36
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb`
|
|
37
|
+
- `spec/idml/text_engine/cjk_layout_spec.rb`
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# TODO PDF 146: NextColumn wrap behavior
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE (approximation) — implemented 2026-08-28
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
NextColumnTextWrap approximated as the bounding box (text narrowed
|
|
8
|
+
beside the object). InDesign moves the text to the next COLUMN.
|
|
9
|
+
|
|
10
|
+
## Solution
|
|
11
|
+
|
|
12
|
+
NextColumnTextWrap now joins JumpObjectTextWrap in the jump path:
|
|
13
|
+
the object blocks the full frame width and the run skips below
|
|
14
|
+
the object's bottom edge. In a single-column frame this matches
|
|
15
|
+
InDesign's visible outcome (the "next column" is below).
|
|
16
|
+
|
|
17
|
+
Remaining approximation (documented in TODO.pdf/61): in a
|
|
18
|
+
MULTI-column frame, true NextColumn jumps to the next column in
|
|
19
|
+
the same frame — that needs column-jump chain integration, which
|
|
20
|
+
stays a future enhancement.
|
|
21
|
+
|
|
22
|
+
## Files
|
|
23
|
+
|
|
24
|
+
- `lib/idml/render/text_wrap_resolver.rb`
|
|
25
|
+
- `spec/idml/render/text_wrap_resolver_spec.rb`
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# TODO PDF 147: Side-aware Contour shapes
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-28
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
TextWrapSide applied only to BoundingBoxTextWrap contours;
|
|
8
|
+
Contour-mode shapes narrowed by polygon overlap regardless of
|
|
9
|
+
side — text smeared over shapes at frame edges instead of
|
|
10
|
+
flowing around the chosen side.
|
|
11
|
+
|
|
12
|
+
## Solution
|
|
13
|
+
|
|
14
|
+
`WrapContour::Shape` carries `side`; `wrap_adjustment`'s shape
|
|
15
|
+
branch dispatches through the shared interval-based side logic
|
|
16
|
+
using the polygon's bounding box: LeftSide keeps text left of the
|
|
17
|
+
shape, RightSide shifts lines past its right edge, LargestArea
|
|
18
|
+
picks the roomier side; BothSides (and spine variants) keep the
|
|
19
|
+
exact polygon-overlap narrowing. Inverse shapes keep their
|
|
20
|
+
flip-to-inside semantics. The side dispatch is now one shared
|
|
21
|
+
`interval_side_adjustment` for boxes and shapes alike.
|
|
22
|
+
|
|
23
|
+
## Files
|
|
24
|
+
|
|
25
|
+
- `lib/idml/render/wrap_contour.rb`
|
|
26
|
+
- `lib/idml/render/text_wrap_resolver.rb`
|
|
27
|
+
- `spec/idml/render/text_wrap_resolver_spec.rb`
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TODO PDF 61: Known limitations and future work
|
|
2
2
|
|
|
3
|
-
## Status: DOCUMENTED — refreshed 2026-08-
|
|
3
|
+
## Status: DOCUMENTED — refreshed 2026-08-28
|
|
4
4
|
|
|
5
5
|
Previously listed limitations (font embedding, tagged PDF, per-run
|
|
6
6
|
styling, dead code) were resolved by TODOs 52/53/55/67/76 long ago;
|
|
@@ -25,18 +25,19 @@ this list reflects the current state.
|
|
|
25
25
|
- **StartParagraph** breaks act at frame/column granularity — no
|
|
26
26
|
odd/even page parity.
|
|
27
27
|
- **Text wrap**: BoundingBoxTextWrap, Contour, and Inverse modes
|
|
28
|
-
render (TODOs 130-131) with TextWrapSide side-awareness
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
render (TODOs 130-131) with TextWrapSide side-awareness for both
|
|
29
|
+
box contours and Contour shapes (TODOs 138/147); JumpObject and
|
|
30
|
+
NextColumn move text below the object (TODOs 143/146) — NextColumn
|
|
31
|
+
in a MULTI-column frame should jump to the next column instead
|
|
32
|
+
(chain integration, future); spine variants approximate as
|
|
33
|
+
BothSides.
|
|
33
34
|
|
|
34
35
|
### CJK
|
|
35
|
-
- **Mojikumi**: script spacing (TODO 125
|
|
36
|
-
(TODO 132),
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
- **Mojikumi**: script spacing (TODO 125, now actually wired),
|
|
37
|
+
line-end compression (TODO 132), class-based pair compression,
|
|
38
|
+
and named-set aki overrides (TODO 145: OverrideMojikumiAki
|
|
39
|
+
Desired applies per class pair) are applied; Minimum/Maximum
|
|
40
|
+
aki bounds and CompressionPriority are parsed but unused.
|
|
40
41
|
- **Vertical mode**: Latin runs rotate as one group per segment
|
|
41
42
|
(TODO 142); ruby placement is beside-column; keep options do
|
|
42
43
|
not apply.
|
|
@@ -59,8 +60,7 @@ this list reflects the current state.
|
|
|
59
60
|
## Future enhancements
|
|
60
61
|
|
|
61
62
|
- Hyphenation dictionary integration
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
- NextColumn wrap via column-jump chaining
|
|
63
|
+
- NextColumn wrap via column-jump chaining (multi-column frames)
|
|
64
|
+
- Minimum/Maximum mojikumi aki bounds + CompressionPriority
|
|
65
65
|
- ToBinding / RTL binding alignment
|
|
66
66
|
- Vertical-mode keep options
|
|
@@ -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"
|
|
@@ -1145,6 +927,9 @@ module Idml
|
|
|
1145
927
|
glyphs = TextEngine::Shaper.shape(
|
|
1146
928
|
text: run.text, font: font, size: size,
|
|
1147
929
|
)
|
|
930
|
+
glyphs = TextEngine::CjkLayout.apply_script_spacing(
|
|
931
|
+
glyphs, size, mojikumi_aki_overrides(context)
|
|
932
|
+
)
|
|
1148
933
|
lines = TextEngine::LineBreaker.break(
|
|
1149
934
|
glyphs: glyphs, frame_width: wrap_width,
|
|
1150
935
|
)
|
|
@@ -1169,6 +954,19 @@ module Idml
|
|
|
1169
954
|
end
|
|
1170
955
|
private_class_method :layout_run
|
|
1171
956
|
|
|
957
|
+
# Aki overrides from the document's first named mojikumi
|
|
958
|
+
# set, when the designmap declares one (TODO 145). Empty
|
|
959
|
+
# otherwise — apply_script_spacing then uses the default
|
|
960
|
+
# CJK/Latin script spacing.
|
|
961
|
+
def self.mojikumi_aki_overrides(context)
|
|
962
|
+
package = context.package
|
|
963
|
+
return [] unless package&.has_part?("designmap.xml")
|
|
964
|
+
|
|
965
|
+
table = package.designmap.mojikumi_table.first
|
|
966
|
+
table&.aki_overrides || []
|
|
967
|
+
end
|
|
968
|
+
private_class_method :mojikumi_aki_overrides
|
|
969
|
+
|
|
1172
970
|
# True when the paragraph should move wholly to the next
|
|
1173
971
|
# frame: a StartParagraph forced break, or KeepAllLines-
|
|
1174
972
|
# Together with insufficient remaining space (never for the
|
|
@@ -1430,74 +1228,6 @@ module Idml
|
|
|
1430
1228
|
line.glyphs.map { |g| [g.codepoint].pack("U") }.join
|
|
1431
1229
|
end
|
|
1432
1230
|
private_class_method :line_text
|
|
1433
|
-
|
|
1434
|
-
# Fallback when no metrics are available: emit all runs as
|
|
1435
|
-
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
1436
|
-
# Chain threading is best-effort here — the simple path
|
|
1437
|
-
# doesn't wrap, so the first frame's render typically emits
|
|
1438
|
-
# all content and clears the chain state.
|
|
1439
|
-
def self.simple_render(canvas, state, context, box)
|
|
1440
|
-
runs = collect_runs(state)
|
|
1441
|
-
return if runs.empty?
|
|
1442
|
-
|
|
1443
|
-
runs_for = build_rich_runs(runs, context)
|
|
1444
|
-
first_size = runs.first.point_size || DEFAULT_SIZE
|
|
1445
|
-
canvas.text_rich(
|
|
1446
|
-
runs_for,
|
|
1447
|
-
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
1448
|
-
)
|
|
1449
|
-
emit_simple_footnotes(canvas, runs, context, box)
|
|
1450
|
-
state.paragraphs = []
|
|
1451
|
-
state.current_paragraph = nil
|
|
1452
|
-
state.runs_remaining = []
|
|
1453
|
-
end
|
|
1454
|
-
private_class_method :simple_render
|
|
1455
|
-
|
|
1456
|
-
# Footnote text for the rough (no-metrics) path: one line
|
|
1457
|
-
# per footnote paragraph, stacked upward from the frame's
|
|
1458
|
-
# bottom edge below a hairline separator. Paragraphs are
|
|
1459
|
-
# already marker-prefixed at extraction.
|
|
1460
|
-
def self.emit_simple_footnotes(canvas, runs, context, box)
|
|
1461
|
-
paragraphs = runs.flat_map(&:footnote_paragraphs).compact
|
|
1462
|
-
return if paragraphs.empty?
|
|
1463
|
-
|
|
1464
|
-
size = paragraphs.first.runs.first&.point_size || DEFAULT_SIZE
|
|
1465
|
-
block_top = box[:y] + (paragraphs.length * size)
|
|
1466
|
-
canvas.line_width = 0.5
|
|
1467
|
-
canvas.stroke_color([:gray, 0.0])
|
|
1468
|
-
canvas.move_to(box[:x], block_top + (size * 0.3))
|
|
1469
|
-
canvas.line_to(box[:x] + (box[:width] * 0.25), block_top + (size * 0.3))
|
|
1470
|
-
canvas.stroke
|
|
1471
|
-
|
|
1472
|
-
paragraphs.reverse_each.with_index do |paragraph, index|
|
|
1473
|
-
canvas.text_rich(
|
|
1474
|
-
build_rich_runs(paragraph.runs, context),
|
|
1475
|
-
at: [box[:x], box[:y] + ((index + 1) * size)],
|
|
1476
|
-
)
|
|
1477
|
-
end
|
|
1478
|
-
end
|
|
1479
|
-
private_class_method :emit_simple_footnotes
|
|
1480
|
-
|
|
1481
|
-
def self.collect_runs(state)
|
|
1482
|
-
runs = state.runs_remaining || []
|
|
1483
|
-
if state.current_paragraph
|
|
1484
|
-
runs + state.current_paragraph.runs
|
|
1485
|
-
else
|
|
1486
|
-
runs + state.paragraphs.flat_map(&:runs)
|
|
1487
|
-
end
|
|
1488
|
-
end
|
|
1489
|
-
private_class_method :collect_runs
|
|
1490
|
-
|
|
1491
|
-
def self.build_rich_runs(runs, context)
|
|
1492
|
-
runs.map do |run|
|
|
1493
|
-
{
|
|
1494
|
-
text: run.text,
|
|
1495
|
-
font: font_for_run(run, context),
|
|
1496
|
-
size: run.point_size || DEFAULT_SIZE,
|
|
1497
|
-
}
|
|
1498
|
-
end
|
|
1499
|
-
end
|
|
1500
|
-
private_class_method :build_rich_runs
|
|
1501
1231
|
end
|
|
1502
1232
|
end
|
|
1503
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
|
|
@@ -63,10 +63,23 @@ module Idml
|
|
|
63
63
|
width = WrapContour.overlap_width(contour, line_y,
|
|
64
64
|
line_height, frame_x,
|
|
65
65
|
frame_right)
|
|
66
|
-
|
|
66
|
+
if contour.inverse
|
|
67
|
+
reduction = [reduction,
|
|
68
|
+
(frame_right - frame_x) - width].max
|
|
69
|
+
next
|
|
70
|
+
end
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
# Side-aware shapes (TODO 147): the side dispatch uses
|
|
73
|
+
# the polygon's bounding box; narrowing itself keeps the
|
|
74
|
+
# exact polygon overlap for BothSides.
|
|
75
|
+
bbox_left, _bottom, bbox_right, = WrapContour.bounding_box(
|
|
76
|
+
contour,
|
|
77
|
+
)
|
|
78
|
+
r, s = interval_side_adjustment(contour.side, bbox_left,
|
|
79
|
+
bbox_right, width,
|
|
80
|
+
frame_x, frame_right)
|
|
81
|
+
reduction = [reduction, r].max
|
|
82
|
+
shift = [shift, s].max
|
|
70
83
|
end
|
|
71
84
|
[reduction, shift]
|
|
72
85
|
end
|
|
@@ -86,12 +99,14 @@ module Idml
|
|
|
86
99
|
|
|
87
100
|
return [0.0, 0.0] if overlap.zero?
|
|
88
101
|
|
|
89
|
-
# JumpObject: text never flows beside the
|
|
90
|
-
# frame width is blocked and the renderer
|
|
91
|
-
# below the object's bottom edge.
|
|
102
|
+
# JumpObject / NextColumn: text never flows beside the
|
|
103
|
+
# object — the full frame width is blocked and the renderer
|
|
104
|
+
# skips the run below the object's bottom edge.
|
|
92
105
|
return [frame_right - frame_x, 0.0] if contour.jump
|
|
93
106
|
|
|
94
|
-
|
|
107
|
+
interval_side_adjustment(contour.side, contour.x,
|
|
108
|
+
contour.x + contour.width, overlap,
|
|
109
|
+
frame_x, frame_right)
|
|
95
110
|
end
|
|
96
111
|
private :box_adjustment
|
|
97
112
|
|
|
@@ -114,30 +129,35 @@ module Idml
|
|
|
114
129
|
end
|
|
115
130
|
private :box_contour?
|
|
116
131
|
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
# Side dispatch over an object's horizontal extent
|
|
133
|
+
# [obj_left, obj_right] (TODO 138/147 — shared by box
|
|
134
|
+
# contours and Contour shapes' bounding boxes): LeftSide
|
|
135
|
+
# keeps text left of the object (reduce to its left edge, no
|
|
136
|
+
# shift); RightSide moves text past its right edge (reduce +
|
|
137
|
+
# shift); LargestArea picks the roomier side; BothSides and
|
|
138
|
+
# the spine variants reduce by `overlap` only.
|
|
139
|
+
def interval_side_adjustment(side, obj_left, obj_right, overlap,
|
|
140
|
+
frame_x, frame_right)
|
|
141
|
+
case side
|
|
119
142
|
when "LeftSide"
|
|
120
|
-
[frame_right -
|
|
143
|
+
[frame_right - obj_left, 0.0]
|
|
121
144
|
when "RightSide"
|
|
122
|
-
shift =
|
|
145
|
+
shift = obj_right - frame_x
|
|
123
146
|
[shift, shift]
|
|
124
147
|
when "LargestArea"
|
|
125
|
-
|
|
148
|
+
left_free = obj_left - frame_x
|
|
149
|
+
right_free = frame_right - obj_right
|
|
150
|
+
if left_free >= right_free
|
|
151
|
+
[frame_right - obj_left, 0.0]
|
|
152
|
+
else
|
|
153
|
+
shift = obj_right - frame_x
|
|
154
|
+
[shift, shift]
|
|
155
|
+
end
|
|
126
156
|
else
|
|
127
157
|
[overlap, 0.0]
|
|
128
158
|
end
|
|
129
159
|
end
|
|
130
|
-
private :
|
|
131
|
-
|
|
132
|
-
def largest_side_adjustment(contour, frame_x, frame_right)
|
|
133
|
-
left_free = contour.x - frame_x
|
|
134
|
-
right_free = frame_right - (contour.x + contour.width)
|
|
135
|
-
return [frame_right - contour.x, 0.0] if left_free >= right_free
|
|
136
|
-
|
|
137
|
-
shift = contour.x + contour.width - frame_x
|
|
138
|
-
[shift, shift]
|
|
139
|
-
end
|
|
140
|
-
private :largest_side_adjustment
|
|
160
|
+
private :interval_side_adjustment
|
|
141
161
|
|
|
142
162
|
# Returns the total horizontal overlap of all contours with a
|
|
143
163
|
# text line at the given y range within the given x range.
|
|
@@ -164,10 +184,11 @@ module Idml
|
|
|
164
184
|
# The wrap shape for an item: a bounding-box rectangle
|
|
165
185
|
# (BoundingBoxTextWrap — the legacy "BoundingBox" spelling is
|
|
166
186
|
# also accepted from early synthetic fixtures) or a flattened
|
|
167
|
-
# PathGeometry polygon (Contour mode). JumpObject
|
|
168
|
-
# NextColumn
|
|
169
|
-
#
|
|
170
|
-
#
|
|
187
|
+
# PathGeometry polygon (Contour mode). JumpObject moves text
|
|
188
|
+
# below the object; NextColumn approximates as the same
|
|
189
|
+
# jump-below in a single-column frame (true column jumping
|
|
190
|
+
# needs chain integration, documented in TODO.pdf/61).
|
|
191
|
+
JUMP_MODES = %w[JumpObjectTextWrap NextColumnTextWrap].freeze
|
|
171
192
|
def self.contour_for(item, page_height)
|
|
172
193
|
pref = wrap_preference(item)
|
|
173
194
|
return nil unless pref
|
|
@@ -190,7 +211,7 @@ module Idml
|
|
|
190
211
|
x: rect[:x], y: rect[:y],
|
|
191
212
|
width: rect[:width], height: rect[:height],
|
|
192
213
|
side: pref&.text_wrap_side,
|
|
193
|
-
jump: pref&.text_wrap_mode
|
|
214
|
+
jump: JUMP_MODES.include?(pref&.text_wrap_mode)
|
|
194
215
|
)
|
|
195
216
|
end
|
|
196
217
|
private_class_method :box_contour
|
|
@@ -13,8 +13,9 @@ module Idml
|
|
|
13
13
|
|
|
14
14
|
# A wrap shape: flattened polygons (one per subpath) plus the
|
|
15
15
|
# TextWrapOffset bounds. `inverse` flips the wrap region:
|
|
16
|
-
# text may only flow INSIDE the shape.
|
|
17
|
-
|
|
16
|
+
# text may only flow INSIDE the shape. `side` carries
|
|
17
|
+
# TextWrapSide for side-aware narrowing (TODO 147).
|
|
18
|
+
Shape = Struct.new(:polygons, :offset, :inverse, :side,
|
|
18
19
|
keyword_init: true)
|
|
19
20
|
|
|
20
21
|
# Builds the wrap shape for an item's PathGeometry and wrap
|
|
@@ -24,7 +25,16 @@ module Idml
|
|
|
24
25
|
return nil if polygons.empty?
|
|
25
26
|
|
|
26
27
|
Shape.new(polygons: polygons, offset: wrap_offset(pref),
|
|
27
|
-
inverse: pref.inverse)
|
|
28
|
+
inverse: pref.inverse, side: pref.text_wrap_side)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The shape's bounding box: [left, bottom, right, top] in
|
|
32
|
+
# PDF coordinates, flattened across subpaths.
|
|
33
|
+
def self.bounding_box(shape)
|
|
34
|
+
points = shape.polygons.flatten(1)
|
|
35
|
+
xs = points.map(&:first)
|
|
36
|
+
ys = points.map(&:last)
|
|
37
|
+
[xs.min, ys.min, xs.max, ys.max]
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
def self.polygons_for(item, page_height)
|
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
|
+
)
|
|
@@ -10,22 +10,95 @@ module Idml
|
|
|
10
10
|
# Mojikumi subset: automatic inter-script spacing — an eighth
|
|
11
11
|
# em is inserted between adjacent CJK and ASCII alphanumeric
|
|
12
12
|
# glyphs (widenning the leading glyph of each pair), matching
|
|
13
|
-
# InDesign's default CJK/Latin auto spacing.
|
|
13
|
+
# InDesign's default CJK/Latin auto spacing. A named
|
|
14
|
+
# mojikumi set's OverrideMojikumiAki entries take precedence
|
|
15
|
+
# when given: the Desired value (em units) applies to each
|
|
16
|
+
# matching class pair (TODO 145).
|
|
14
17
|
SCRIPT_SPACING_EM = 0.125
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
# Mojikumi character classes (InDesign mojikumi table rows).
|
|
20
|
+
CLASS_IDEOGRAPH = 1
|
|
21
|
+
CLASS_OPENING = 2
|
|
22
|
+
CLASS_CLOSING = 3
|
|
23
|
+
CLASS_COMMA_PERIOD = 4
|
|
24
|
+
CLASS_MIDDLE_DOT = 5
|
|
25
|
+
CLASS_DIGIT = 6
|
|
26
|
+
CLASS_LATIN = 7
|
|
27
|
+
|
|
28
|
+
COMMA_PERIOD_CODEPOINTS = [
|
|
29
|
+
0x3001, # 、 ideographic comma
|
|
30
|
+
0x3002, # 。 ideographic full stop
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
CLOSING_CODEPOINTS = [
|
|
34
|
+
0x3009, # 〉
|
|
35
|
+
0x300B, # 》
|
|
36
|
+
0x300D, # 」
|
|
37
|
+
0x300F, # 』
|
|
38
|
+
0x3011, # 】
|
|
39
|
+
0x3015, # 〕
|
|
40
|
+
0xFF09, # )fullwidth right paren
|
|
41
|
+
0xFF3D, # ]
|
|
42
|
+
0xFF5D, # }
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
def apply_script_spacing(glyphs, size, aki_overrides = [])
|
|
18
46
|
(0...(glyphs.length - 1)).each do |index|
|
|
19
47
|
leading = glyphs[index]
|
|
20
48
|
following = glyphs[index + 1]
|
|
21
|
-
|
|
22
|
-
|
|
49
|
+
em = aki_em(leading.codepoint, following.codepoint,
|
|
50
|
+
aki_overrides)
|
|
51
|
+
next unless em&.positive?
|
|
23
52
|
|
|
24
|
-
leading.width +=
|
|
53
|
+
leading.width += em * size
|
|
25
54
|
end
|
|
26
55
|
glyphs
|
|
27
56
|
end
|
|
28
57
|
|
|
58
|
+
# Spacing (em units) between an adjacent pair: the named
|
|
59
|
+
# set's Desired aki for a matching class-pair override, else
|
|
60
|
+
# the default eighth-em at CJK/Latin script boundaries, else
|
|
61
|
+
# none.
|
|
62
|
+
def aki_em(left, right, aki_overrides)
|
|
63
|
+
override = aki_overrides.find do |entry|
|
|
64
|
+
entry.side_is_after_target &&
|
|
65
|
+
entry.target_mojikumi_class == mojikumi_class(left) &&
|
|
66
|
+
entry.side_mojikumi_class == mojikumi_class(right)
|
|
67
|
+
end
|
|
68
|
+
return override.desired if override&.desired
|
|
69
|
+
|
|
70
|
+
return SCRIPT_SPACING_EM if script_boundary?(left, right)
|
|
71
|
+
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The mojikumi class of a codepoint, or nil outside the
|
|
76
|
+
# CJK punctuation / script classes. Ideographs (the default
|
|
77
|
+
# CJK class) win only when no special class matches.
|
|
78
|
+
def mojikumi_class(codepoint)
|
|
79
|
+
return CLASS_OPENING if OPENING_CODEPOINTS.include?(codepoint)
|
|
80
|
+
return CLASS_COMMA_PERIOD if COMMA_PERIOD_CODEPOINTS.include?(codepoint)
|
|
81
|
+
return CLASS_CLOSING if CLOSING_CODEPOINTS.include?(codepoint)
|
|
82
|
+
return CLASS_MIDDLE_DOT if MIDDLE_CODEPOINTS.include?(codepoint)
|
|
83
|
+
return CLASS_DIGIT if digit_class?(codepoint)
|
|
84
|
+
return CLASS_LATIN if latin_class?(codepoint)
|
|
85
|
+
return CLASS_IDEOGRAPH if cjk?(codepoint)
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def digit_class?(codepoint)
|
|
91
|
+
(0x30..0x39).cover?(codepoint) ||
|
|
92
|
+
(0xFF10..0xFF19).cover?(codepoint)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def latin_class?(codepoint)
|
|
96
|
+
(0x41..0x5A).cover?(codepoint) ||
|
|
97
|
+
(0x61..0x7A).cover?(codepoint) ||
|
|
98
|
+
(0xFF21..0xFF3A).cover?(codepoint) ||
|
|
99
|
+
(0xFF41..0xFF5A).cover?(codepoint)
|
|
100
|
+
end
|
|
101
|
+
|
|
29
102
|
# True when exactly one of the pair is CJK and the other is
|
|
30
103
|
# an ASCII letter or digit.
|
|
31
104
|
def script_boundary?(left, right)
|
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
|
|
@@ -208,6 +208,10 @@ files:
|
|
|
208
208
|
- TODO.pdf/142-vertical-latin-run-groups.md
|
|
209
209
|
- TODO.pdf/143-jump-object-below.md
|
|
210
210
|
- TODO.pdf/144-mojikumi-table-model.md
|
|
211
|
+
- TODO.pdf/145-apply-mojikumi-aki.md
|
|
212
|
+
- TODO.pdf/146-next-column-wrap.md
|
|
213
|
+
- TODO.pdf/147-side-aware-shapes.md
|
|
214
|
+
- TODO.pdf/148-architecture-improvements.md
|
|
211
215
|
- TODO.pdf/15-wire-spread-children.md
|
|
212
216
|
- TODO.pdf/16-typed-model-pipeline.md
|
|
213
217
|
- TODO.pdf/17-shape-rendering.md
|
|
@@ -504,8 +508,10 @@ files:
|
|
|
504
508
|
- lib/idml/render/renderers/path_renderer.rb
|
|
505
509
|
- lib/idml/render/renderers/polygon_renderer.rb
|
|
506
510
|
- lib/idml/render/renderers/rectangle_renderer.rb
|
|
511
|
+
- lib/idml/render/renderers/simple_text.rb
|
|
507
512
|
- lib/idml/render/renderers/table_renderer.rb
|
|
508
513
|
- lib/idml/render/renderers/text_frame_renderer.rb
|
|
514
|
+
- lib/idml/render/renderers/vertical_emit.rb
|
|
509
515
|
- lib/idml/render/shape_paint.rb
|
|
510
516
|
- lib/idml/render/spread_renderer.rb
|
|
511
517
|
- lib/idml/render/story_chain_controller.rb
|