idml 0.9.4 → 0.9.6
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/117-endnote-support.md +12 -7
- data/TODO.pdf/122-vertical-latin-ruby.md +29 -0
- data/TODO.pdf/13-cjk-text-layout.md +24 -6
- data/lib/idml/elements/character_style_range.rb +3 -0
- data/lib/idml/elements/story_inner.rb +2 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +169 -3
- data/lib/idml/render/style_resolver.rb +25 -5
- data/lib/idml/text_engine/line_breaker.rb +16 -0
- data/lib/idml/text_engine/vertical_text_layout.rb +68 -0
- data/lib/idml/text_engine.rb +2 -0
- data/lib/idml/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76ef12540a7d7d1805189664f88fc0c5b400b8d1240fbc495239ddb35f64822c
|
|
4
|
+
data.tar.gz: 941893920b3e23a32c196de4286a64f10edb0e377bb716a5287ba110bc3052bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95da409c1987bac0a36ab73793c02e349d3060f313f3f4fc2670f22efffd89bd2d5f0bcfba4096dab595cf39a7843134ee0fc4f06f9e4136d477c65f44870586
|
|
7
|
+
data.tar.gz: a74c7d9a0a1ccd22e57308890a0f6b257e9ec2d3aab7d3d8a407c23b4b52f466cd2c39f86be48d4b89e912e26fcd3a582dbdf27455d8db49a405219ede022357
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# TODO PDF 117: Endnote support
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
a real fixture)
|
|
3
|
+
## Status: PARTIAL (modeling + reference markers COMPLETE
|
|
4
|
+
2026-08-18/19; endnote TEXT rendering OPEN pending a real fixture)
|
|
5
5
|
|
|
6
6
|
## Problem
|
|
7
7
|
|
|
@@ -20,14 +20,19 @@ story flagged `IsEndnoteStory="true"`. Documents with endnotes
|
|
|
20
20
|
collections). Round-trip + parse specs.
|
|
21
21
|
- `StoryInner#is_endnote_story` already parsed, so endnote stories
|
|
22
22
|
are identifiable.
|
|
23
|
+
- Reference markers render (2026-08-19): CSR-level `EndnoteRange`
|
|
24
|
+
elements (verified against CharacterStyleRange_Object in
|
|
25
|
+
Story.rnc) emit superscript marker runs numbered by a counter
|
|
26
|
+
SEPARATE from footnotes — endnoted text now shows visible numbered
|
|
27
|
+
references instead of dropping them. `StoryInner` also parses
|
|
28
|
+
`StoryPreference` (vertical writing support).
|
|
23
29
|
|
|
24
30
|
## Design for rendering (needs a real InDesign fixture to validate)
|
|
25
31
|
|
|
26
|
-
1. Marker:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
the prerequisite.
|
|
32
|
+
1. ~~Marker~~ DONE 2026-08-19: CSR-level EndnoteRange emits a
|
|
33
|
+
superscript marker run at the CSR's position (same pattern as
|
|
34
|
+
footnotes). Story-level Endnote/EndnoteRange pairs (no CSR
|
|
35
|
+
anchor) remain unplaced.
|
|
31
36
|
2. Endnote text: resolve `EndnoteTextRange` → the endnote story
|
|
32
37
|
(IsEndnoteStory) → its paragraphs.
|
|
33
38
|
3. Placement: per EndnoteOption Scope — end of story / end of
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# TODO PDF 122: Vertical mode completion — Latin rotation, vertical ruby
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-20
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
The vertical writing path (TODO 13, 2026-08-19) stacked every glyph
|
|
8
|
+
upright: Latin letters and digits rendered sideways-wrong (rotated
|
|
9
|
+
in appearance relative to convention), and ruby annotations were
|
|
10
|
+
not emitted at all in vertical mode.
|
|
11
|
+
|
|
12
|
+
## What was done
|
|
13
|
+
|
|
14
|
+
- Latin rotation: non-CJK glyphs in vertical text render rotated
|
|
15
|
+
90° clockwise (the vertical-writing convention for Latin runs),
|
|
16
|
+
via a graphics-state transform (q → translate → rotate −90° →
|
|
17
|
+
text at origin → Q). CJK glyphs stay upright.
|
|
18
|
+
- Vertical ruby: runs carrying RubyString emit the annotation
|
|
19
|
+
stacked vertically alongside the base glyphs — to the right of
|
|
20
|
+
the column by default, to the left for RubyPosition values
|
|
21
|
+
containing "Below" (mirroring the horizontal above/below
|
|
22
|
+
convention under rotation). RubyFontSize defaults to half the
|
|
23
|
+
base size.
|
|
24
|
+
|
|
25
|
+
## Known limitations
|
|
26
|
+
|
|
27
|
+
- Tate-chu-yoko (horizontal digit groups inside vertical text) and
|
|
28
|
+
mojikumi spacing classes remain documented stretch goals
|
|
29
|
+
(TODO 13).
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
# TODO PDF 13: CJK text layout
|
|
2
2
|
|
|
3
|
-
## Status: PARTIAL —
|
|
4
|
-
CJK glyph measurement,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
## Status: PARTIAL — vertical writing mode implemented 2026-08-19
|
|
4
|
+
(horizontal-mode CJK was complete 2026-08-17: glyph measurement,
|
|
5
|
+
kinsoku shori, ruby annotations).
|
|
6
|
+
|
|
7
|
+
## Vertical mode (2026-08-19)
|
|
8
|
+
|
|
9
|
+
- `StoryInner` now parses `StoryPreference` (StoryOrientation).
|
|
10
|
+
- `TextEngine::VerticalTextLayout` stacks glyphs UPRIGHT in
|
|
11
|
+
columns flowing right-to-left (CJK ideographs render correctly;
|
|
12
|
+
Latin glyphs are not rotated — documented approximation), reusing
|
|
13
|
+
LineBreaker (so kinsoku applies) against the column height.
|
|
14
|
+
- `TextFrameRenderer` takes the vertical path when the story
|
|
15
|
+
declares Vertical: paragraphs flow as glyph columns, runs start
|
|
16
|
+
fresh columns, overflow chains to the next frame. Paragraph
|
|
17
|
+
spacing/rules/decorations and ruby repositioning are not applied
|
|
18
|
+
in vertical mode.
|
|
19
|
+
- LineBreaker now breaks unspaced CJK runs PER CHARACTER at the
|
|
20
|
+
frame width (previously they overflowed as one long line) — this
|
|
21
|
+
also fixes horizontal CJK wrapping.
|
|
22
|
+
|
|
23
|
+
Remaining stretch goals: tate-chu-yoko, mojikumi, Latin rotation in
|
|
24
|
+
vertical mode, vertical ruby placement. See
|
|
25
|
+
`lib/idml/text_engine/cjk_layout.rb` and
|
|
26
|
+
`lib/idml/text_engine/vertical_text_layout.rb`.
|
|
9
27
|
|
|
10
28
|
## Progress 2026-08-17
|
|
11
29
|
|
|
@@ -302,6 +302,8 @@ module Idml
|
|
|
302
302
|
attribute :table, Idml::Elements::Table, collection: true
|
|
303
303
|
attribute :xml_element, Idml::Elements::XmlElement, collection: true
|
|
304
304
|
attribute :footnote, Idml::Elements::Footnote, collection: true
|
|
305
|
+
attribute :endnote_range, Idml::Elements::EndnoteRange,
|
|
306
|
+
collection: true
|
|
305
307
|
# Anchored page items embedded in the story flow. Their
|
|
306
308
|
# AnchoredObjectSetting (child of each item) declares how the
|
|
307
309
|
# item anchors to the surrounding text.
|
|
@@ -669,6 +671,7 @@ module Idml
|
|
|
669
671
|
map_element "Table", to: :table
|
|
670
672
|
map_element "XMLElement", to: :xml_element
|
|
671
673
|
map_element "Footnote", to: :footnote
|
|
674
|
+
map_element "EndnoteRange", to: :endnote_range
|
|
672
675
|
map_element "Rectangle", to: :rectangle
|
|
673
676
|
map_element "Oval", to: :oval
|
|
674
677
|
map_element "Polygon", to: :polygon
|
|
@@ -316,6 +316,7 @@ module Idml
|
|
|
316
316
|
attribute :endnote, Idml::Elements::Endnote, collection: true
|
|
317
317
|
attribute :endnote_range, Idml::Elements::EndnoteRange,
|
|
318
318
|
collection: true
|
|
319
|
+
attribute :story_preference, Idml::Elements::StoryPreference
|
|
319
320
|
|
|
320
321
|
xml do
|
|
321
322
|
root "Story"
|
|
@@ -683,6 +684,7 @@ module Idml
|
|
|
683
684
|
map_element "Footnote", to: :footnote
|
|
684
685
|
map_element "Endnote", to: :endnote
|
|
685
686
|
map_element "EndnoteRange", to: :endnote_range
|
|
687
|
+
map_element "StoryPreference", to: :story_preference
|
|
686
688
|
end
|
|
687
689
|
|
|
688
690
|
def text_content
|
|
@@ -41,7 +41,8 @@ module Idml
|
|
|
41
41
|
state = initial_chain_state(frame, story, context)
|
|
42
42
|
return unless state
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
vertical = vertical_story?(story)
|
|
45
|
+
render_text(canvas, state, context, box, vertical: vertical)
|
|
45
46
|
store_chain_state(frame, state, context)
|
|
46
47
|
end
|
|
47
48
|
|
|
@@ -146,9 +147,11 @@ module Idml
|
|
|
146
147
|
end
|
|
147
148
|
private_class_method :context_for_item
|
|
148
149
|
|
|
149
|
-
def self.render_text(canvas, state, context, box)
|
|
150
|
+
def self.render_text(canvas, state, context, box, vertical: false)
|
|
150
151
|
font = context.font_metrics
|
|
151
|
-
if font
|
|
152
|
+
if font && vertical
|
|
153
|
+
vertical_render(canvas, state, context, box, font)
|
|
154
|
+
elsif font
|
|
152
155
|
engine_render(canvas, state, context, box, font)
|
|
153
156
|
else
|
|
154
157
|
simple_render(canvas, state, context, box)
|
|
@@ -156,6 +159,169 @@ module Idml
|
|
|
156
159
|
end
|
|
157
160
|
private_class_method :render_text
|
|
158
161
|
|
|
162
|
+
# True when the story's StoryPreference declares vertical
|
|
163
|
+
# writing (StoryOrientation="Vertical").
|
|
164
|
+
def self.vertical_story?(story)
|
|
165
|
+
story.inner&.story_preference&.story_orientation == "Vertical"
|
|
166
|
+
end
|
|
167
|
+
private_class_method :vertical_story?
|
|
168
|
+
|
|
169
|
+
# Vertical writing path (StoryOrientation="Vertical", CJK):
|
|
170
|
+
# paragraphs flow as upright glyph columns advancing
|
|
171
|
+
# right-to-left; each run starts a fresh column (run-mixing
|
|
172
|
+
# within a column is not modeled). Runs whose columns would
|
|
173
|
+
# cross the frame's left inset overflow to the next frame in
|
|
174
|
+
# the chain. Paragraph spacing, rules, and decorations are
|
|
175
|
+
# not applied in vertical mode.
|
|
176
|
+
def self.vertical_render(canvas, state, context, box, font)
|
|
177
|
+
layout_frame = layout_frame(context.item, box)
|
|
178
|
+
left_limit = layout_frame.x + (layout_frame.inset_left || 0)
|
|
179
|
+
resume_vertical_paragraph(state)
|
|
180
|
+
pending = 0
|
|
181
|
+
|
|
182
|
+
state.paragraphs.each do |paragraph|
|
|
183
|
+
remaining_runs = vertical_paragraph(
|
|
184
|
+
canvas, paragraph, context, layout_frame, font, left_limit
|
|
185
|
+
)
|
|
186
|
+
if remaining_runs.empty?
|
|
187
|
+
pending += 1
|
|
188
|
+
else
|
|
189
|
+
state.current_paragraph = paragraph
|
|
190
|
+
state.runs_remaining = remaining_runs
|
|
191
|
+
break
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
state.paragraphs = state.paragraphs[pending..]
|
|
196
|
+
end
|
|
197
|
+
private_class_method :vertical_render
|
|
198
|
+
|
|
199
|
+
# Folds a chain-resumed paragraph (runs_remaining) back to
|
|
200
|
+
# the head of the paragraph list so the vertical path sees a
|
|
201
|
+
# uniform sequence.
|
|
202
|
+
def self.resume_vertical_paragraph(state)
|
|
203
|
+
return unless state.current_paragraph
|
|
204
|
+
|
|
205
|
+
state.current_paragraph.runs = state.runs_remaining
|
|
206
|
+
state.paragraphs.unshift(state.current_paragraph)
|
|
207
|
+
state.current_paragraph = nil
|
|
208
|
+
state.runs_remaining = []
|
|
209
|
+
end
|
|
210
|
+
private_class_method :resume_vertical_paragraph
|
|
211
|
+
|
|
212
|
+
# Renders one paragraph's runs as vertical columns. Returns
|
|
213
|
+
# the runs that did not fit (empty when all placed).
|
|
214
|
+
def self.vertical_paragraph(canvas, paragraph, context,
|
|
215
|
+
layout_frame, font, left_limit)
|
|
216
|
+
paragraph.runs.each_with_index do |run, index|
|
|
217
|
+
size = run.point_size || DEFAULT_SIZE
|
|
218
|
+
leading = TextEngine::VerticalLayout.leading_for(
|
|
219
|
+
paragraph.auto_leading, size
|
|
220
|
+
)
|
|
221
|
+
glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
|
|
222
|
+
size: size)
|
|
223
|
+
positioned, columns = TextEngine::VerticalTextLayout.layout(
|
|
224
|
+
glyphs: glyphs, frame: layout_frame, leading: leading,
|
|
225
|
+
size: size
|
|
226
|
+
)
|
|
227
|
+
unless vertical_fits?(layout_frame, leading, columns,
|
|
228
|
+
left_limit)
|
|
229
|
+
return paragraph.runs[index..]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
positioned.each do |glyph|
|
|
233
|
+
emit_vertical_glyph(canvas, glyph, run, context, size)
|
|
234
|
+
end
|
|
235
|
+
emit_vertical_ruby(canvas, run, positioned, layout_frame,
|
|
236
|
+
size, context)
|
|
237
|
+
end
|
|
238
|
+
[]
|
|
239
|
+
end
|
|
240
|
+
private_class_method :vertical_paragraph
|
|
241
|
+
|
|
242
|
+
def self.vertical_fits?(layout_frame, leading, columns, left_limit)
|
|
243
|
+
right = layout_frame.x + layout_frame.width -
|
|
244
|
+
(layout_frame.inset_right || 0)
|
|
245
|
+
(right - (columns * leading)) >= left_limit
|
|
246
|
+
end
|
|
247
|
+
private_class_method :vertical_fits?
|
|
248
|
+
|
|
249
|
+
# Emits one upright glyph at its vertical position with the
|
|
250
|
+
# run's character styling.
|
|
251
|
+
# Emits one glyph in vertical mode: CJK upright; Latin
|
|
252
|
+
# (non-CJK) rotated 90° clockwise per the vertical-writing
|
|
253
|
+
# convention, via a graphics-state transform.
|
|
254
|
+
def self.emit_vertical_glyph(canvas, positioned, run, context,
|
|
255
|
+
size)
|
|
256
|
+
text = [positioned.codepoint].pack("U")
|
|
257
|
+
if TextEngine::CjkLayout.cjk?(positioned.codepoint)
|
|
258
|
+
text_kwargs = CharacterStyle.text_kwargs(
|
|
259
|
+
run,
|
|
260
|
+
{
|
|
261
|
+
at: [positioned.x, positioned.y],
|
|
262
|
+
font: font_for_run(run, context),
|
|
263
|
+
size: size,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
canvas.text(text, **text_kwargs)
|
|
267
|
+
else
|
|
268
|
+
canvas.save_graphics_state do
|
|
269
|
+
# 90° clockwise: exact matrix (rotate would emit
|
|
270
|
+
# cos/sin float noise like 6.1e-17).
|
|
271
|
+
canvas.concat(0, -1, 1, 0, positioned.x, positioned.y)
|
|
272
|
+
text_kwargs = CharacterStyle.text_kwargs(
|
|
273
|
+
run,
|
|
274
|
+
{
|
|
275
|
+
at: [0, 0],
|
|
276
|
+
font: font_for_run(run, context),
|
|
277
|
+
size: size,
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
canvas.text(text, **text_kwargs)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
private_class_method :emit_vertical_glyph
|
|
285
|
+
|
|
286
|
+
# Emits the run's ruby alongside its vertical glyphs:
|
|
287
|
+
# stacked top-to-bottom beside the column — right side by
|
|
288
|
+
# default, left for Below* RubyPosition values (the
|
|
289
|
+
# horizontal above/below convention mirrored under
|
|
290
|
+
# rotation).
|
|
291
|
+
def self.emit_vertical_ruby(canvas, run, positioned, frame,
|
|
292
|
+
base_size, context)
|
|
293
|
+
ruby_text = run.ruby_string
|
|
294
|
+
return if ruby_text.nil? || ruby_text.empty?
|
|
295
|
+
|
|
296
|
+
ruby_size = run.ruby_font_size ||
|
|
297
|
+
(base_size * RUBY_SIZE_FACTOR)
|
|
298
|
+
x = ruby_vertical_x(positioned.first, run, base_size)
|
|
299
|
+
y = frame.y + frame.height - (frame.inset_top || 0)
|
|
300
|
+
ruby_text.each_codepoint do |codepoint|
|
|
301
|
+
text_kwargs = CharacterStyle.text_kwargs(
|
|
302
|
+
run,
|
|
303
|
+
{
|
|
304
|
+
at: [x, y - ruby_size],
|
|
305
|
+
font: font_for_run(run, context),
|
|
306
|
+
size: ruby_size,
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
canvas.text([codepoint].pack("U"), **text_kwargs)
|
|
310
|
+
y -= ruby_size
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
private_class_method :emit_vertical_ruby
|
|
314
|
+
|
|
315
|
+
def self.ruby_vertical_x(positioned, run, base_size)
|
|
316
|
+
offset = base_size * 0.9
|
|
317
|
+
if run.ruby_position.to_s.include?("Below")
|
|
318
|
+
positioned.x - offset
|
|
319
|
+
else
|
|
320
|
+
positioned.x + offset
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
private_class_method :ruby_vertical_x
|
|
324
|
+
|
|
159
325
|
def self.chain_head?(frame)
|
|
160
326
|
frame.previous_text_frame.nil? || frame.previous_text_frame == "n"
|
|
161
327
|
end
|
|
@@ -140,8 +140,9 @@ module Idml
|
|
|
140
140
|
return [] unless story&.inner
|
|
141
141
|
|
|
142
142
|
counter = Footnote.counter_for(nil)
|
|
143
|
+
endnotes = Footnote.counter_for(nil)
|
|
143
144
|
story.inner.paragraph_style_range.flat_map do |psr|
|
|
144
|
-
csr_runs(psr, footnote_counter: counter)
|
|
145
|
+
csr_runs(psr, footnote_counter: counter, endnote_counter: endnotes)
|
|
145
146
|
end
|
|
146
147
|
end
|
|
147
148
|
|
|
@@ -170,13 +171,15 @@ module Idml
|
|
|
170
171
|
style_lookup: nil,
|
|
171
172
|
footnote_option: nil)
|
|
172
173
|
counter = Footnote.counter_for(footnote_option)
|
|
174
|
+
endnotes = Footnote.counter_for(nil)
|
|
173
175
|
container.paragraph_style_range.filter_map do |psr|
|
|
174
176
|
next unless condition_visible?(psr, condition_filter)
|
|
175
177
|
|
|
176
178
|
runs = csr_runs(psr, condition_filter: condition_filter,
|
|
177
179
|
style_lookup: style_lookup,
|
|
178
180
|
footnote_counter: counter,
|
|
179
|
-
footnote_option: footnote_option
|
|
181
|
+
footnote_option: footnote_option,
|
|
182
|
+
endnote_counter: endnotes)
|
|
180
183
|
next if runs.empty?
|
|
181
184
|
|
|
182
185
|
paragraph = Paragraph.new(
|
|
@@ -304,15 +307,18 @@ module Idml
|
|
|
304
307
|
private_class_method :properties_color
|
|
305
308
|
|
|
306
309
|
def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
|
|
307
|
-
footnote_counter: nil, footnote_option: nil
|
|
310
|
+
footnote_counter: nil, footnote_option: nil,
|
|
311
|
+
endnote_counter: nil)
|
|
308
312
|
counter = footnote_counter || Footnote.counter_for(footnote_option)
|
|
313
|
+
endnotes = endnote_counter || Footnote.counter_for(nil)
|
|
309
314
|
psr.character_style_range.flat_map do |csr|
|
|
310
315
|
next [] unless condition_visible?(csr, condition_filter)
|
|
311
316
|
|
|
312
317
|
csr_content_runs(csr, condition_filter: condition_filter,
|
|
313
318
|
style_lookup: style_lookup,
|
|
314
319
|
footnote_counter: counter,
|
|
315
|
-
footnote_option: footnote_option
|
|
320
|
+
footnote_option: footnote_option,
|
|
321
|
+
endnote_counter: endnotes)
|
|
316
322
|
end
|
|
317
323
|
end
|
|
318
324
|
|
|
@@ -320,7 +326,8 @@ module Idml
|
|
|
320
326
|
# plus one superscript marker run per anchored Footnote. A CSR
|
|
321
327
|
# holding only a footnote still emits its marker.
|
|
322
328
|
def self.csr_content_runs(csr, condition_filter:, style_lookup:,
|
|
323
|
-
footnote_counter:, footnote_option
|
|
329
|
+
footnote_counter:, footnote_option:,
|
|
330
|
+
endnote_counter:)
|
|
324
331
|
text = csr.text_content
|
|
325
332
|
runs = []
|
|
326
333
|
unless text.nil? || text.empty?
|
|
@@ -336,8 +343,21 @@ module Idml
|
|
|
336
343
|
runs << Footnote.marker_run(number, runs.last, paragraphs,
|
|
337
344
|
footnote_option)
|
|
338
345
|
end
|
|
346
|
+
runs.concat(endnote_marker_runs(csr, endnote_counter, runs.last))
|
|
339
347
|
runs
|
|
340
348
|
end
|
|
349
|
+
|
|
350
|
+
# Endnote reference markers (CSR > EndnoteRange): superscript,
|
|
351
|
+
# numbered by a counter SEPARATE from footnotes. The endnote
|
|
352
|
+
# text lives in another story (IsEndnoteStory) and is not yet
|
|
353
|
+
# rendered — see TODO 117.
|
|
354
|
+
def self.endnote_marker_runs(csr, endnote_counter, base_run)
|
|
355
|
+
csr.endnote_range.map do |_range|
|
|
356
|
+
Footnote.marker_run(endnote_counter.next_number, base_run,
|
|
357
|
+
nil, nil)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
private_class_method :endnote_marker_runs
|
|
341
361
|
private_class_method :csr_content_runs
|
|
342
362
|
|
|
343
363
|
def self.text_run(csr, style_lookup:)
|
|
@@ -22,10 +22,22 @@ module Idml
|
|
|
22
22
|
end
|
|
23
23
|
private_class_method :cjk_run?
|
|
24
24
|
|
|
25
|
+
# CJK text wraps per character (no word boundaries): an
|
|
26
|
+
# overflow whose trailing glyph is CJK breaks before it
|
|
27
|
+
# instead of emitting an overlong line.
|
|
28
|
+
|
|
25
29
|
def initialize(frame_width)
|
|
26
30
|
@frame_width = frame_width
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
# CJK text wraps per character (no word boundaries): an
|
|
34
|
+
# overflow whose trailing glyph is CJK breaks before it
|
|
35
|
+
# instead of emitting an overlong line.
|
|
36
|
+
def cjk_break?(current)
|
|
37
|
+
current.length > 1 && CjkLayout.cjk?(current.last.codepoint)
|
|
38
|
+
end
|
|
39
|
+
private :cjk_break?
|
|
40
|
+
|
|
29
41
|
def break(glyphs)
|
|
30
42
|
lines = []
|
|
31
43
|
current = []
|
|
@@ -50,6 +62,10 @@ module Idml
|
|
|
50
62
|
current = current[(last_space_idx + 1)..]
|
|
51
63
|
current_width = current.sum(&:width)
|
|
52
64
|
last_space_idx = -1
|
|
65
|
+
elsif cjk_break?(current)
|
|
66
|
+
lines << Line.new(current[0..-2], current_width - glyph.width, 0)
|
|
67
|
+
current = [glyph]
|
|
68
|
+
current_width = glyph.width
|
|
53
69
|
else
|
|
54
70
|
lines << Line.new(current, current_width, 0)
|
|
55
71
|
current = []
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module TextEngine
|
|
5
|
+
# Vertical (top-to-bottom, right-to-left) glyph layout for CJK
|
|
6
|
+
# vertical writing (StoryOrientation="Vertical"). Glyphs stay
|
|
7
|
+
# UPRIGHT — CJK ideographs render correctly stacked; Latin
|
|
8
|
+
# glyphs are not rotated to the vertical axis (documented
|
|
9
|
+
# approximation). Line breaking (and therefore kinsoku shori)
|
|
10
|
+
# is reused from LineBreaker against the column height.
|
|
11
|
+
module VerticalTextLayout
|
|
12
|
+
# One glyph placed at its baseline in a vertical column.
|
|
13
|
+
PositionedGlyph = Struct.new(:codepoint, :x, :y, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Stacks `glyphs` into columns of at most the frame's text
|
|
16
|
+
# height; column 0 is the rightmost. Returns
|
|
17
|
+
# [positioned_glyphs, column_count].
|
|
18
|
+
def self.layout(glyphs:, frame:, leading:, size:)
|
|
19
|
+
lines = TextEngine::LineBreaker.break(
|
|
20
|
+
glyphs: glyphs, frame_width: column_height(frame),
|
|
21
|
+
)
|
|
22
|
+
positioned = []
|
|
23
|
+
lines.each_with_index do |line, column_index|
|
|
24
|
+
stack_column(positioned, line.glyphs, frame, leading, size,
|
|
25
|
+
column_index)
|
|
26
|
+
end
|
|
27
|
+
[positioned, lines.length]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The frame's usable vertical extent for one column of text.
|
|
31
|
+
def self.column_height(frame)
|
|
32
|
+
frame.height - (frame.inset_top || 0) - (frame.inset_bottom || 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Places one column's glyphs at a shared x, baselines walking
|
|
36
|
+
# down from the frame's text top by each glyph's advance.
|
|
37
|
+
def self.stack_column(positioned, glyphs, frame, leading, size,
|
|
38
|
+
column_index)
|
|
39
|
+
top_y = frame.y + frame.height - (frame.inset_top || 0)
|
|
40
|
+
x = column_x(frame, leading, size, column_index)
|
|
41
|
+
y = top_y
|
|
42
|
+
glyphs.each do |glyph|
|
|
43
|
+
y -= advance(glyph)
|
|
44
|
+
positioned << PositionedGlyph.new(codepoint: glyph.codepoint,
|
|
45
|
+
x: x, y: y)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_class_method :stack_column
|
|
49
|
+
|
|
50
|
+
# Vertical advance of one glyph — its measured width (the em
|
|
51
|
+
# for the common square-CJK case).
|
|
52
|
+
def self.advance(glyph)
|
|
53
|
+
glyph.width.positive? ? glyph.width : 0.0
|
|
54
|
+
end
|
|
55
|
+
private_class_method :advance
|
|
56
|
+
|
|
57
|
+
# The column's glyph left edge: columns advance right-to-left
|
|
58
|
+
# by `leading` from the right inset edge; each glyph (em =
|
|
59
|
+
# `size`) is centered in its column slot.
|
|
60
|
+
def self.column_x(frame, leading, size, column_index)
|
|
61
|
+
right = frame.x + frame.width - (frame.inset_right || 0)
|
|
62
|
+
slot_left = right - ((column_index + 1) * leading)
|
|
63
|
+
slot_left + ((leading - size) / 2.0)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :column_x
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/idml/text_engine.rb
CHANGED
|
@@ -8,6 +8,8 @@ module Idml
|
|
|
8
8
|
autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
|
|
9
9
|
autoload :Shaper, "#{__dir__}/text_engine/shaper"
|
|
10
10
|
autoload :Measurement, "#{__dir__}/text_engine/measurement"
|
|
11
|
+
autoload :VerticalTextLayout,
|
|
12
|
+
"#{__dir__}/text_engine/vertical_text_layout"
|
|
11
13
|
autoload :ShapedGlyph, "#{__dir__}/text_engine/shaper"
|
|
12
14
|
autoload :LineBreaker, "#{__dir__}/text_engine/line_breaker"
|
|
13
15
|
autoload :Line, "#{__dir__}/text_engine/line_breaker"
|
data/lib/idml/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: idml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.6
|
|
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-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- TODO.pdf/12-idml-to-pdf-pipeline.md
|
|
181
181
|
- TODO.pdf/120-start-paragraph-breaks.md
|
|
182
182
|
- TODO.pdf/121-cell-edge-strokes.md
|
|
183
|
+
- TODO.pdf/122-vertical-latin-ruby.md
|
|
183
184
|
- TODO.pdf/13-cjk-text-layout.md
|
|
184
185
|
- TODO.pdf/14-page-item-element-models.md
|
|
185
186
|
- TODO.pdf/15-wire-spread-children.md
|
|
@@ -497,6 +498,7 @@ files:
|
|
|
497
498
|
- lib/idml/text_engine/pdfrb_font_metrics.rb
|
|
498
499
|
- lib/idml/text_engine/shaper.rb
|
|
499
500
|
- lib/idml/text_engine/vertical_layout.rb
|
|
501
|
+
- lib/idml/text_engine/vertical_text_layout.rb
|
|
500
502
|
- lib/idml/validation.rb
|
|
501
503
|
- lib/idml/validation/result.rb
|
|
502
504
|
- lib/idml/validation/validator.rb
|