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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e05a75aaa533dcd8f478d6b4ab635d15cf6c64ac85bebbd42fe3b136ed0c6783
4
- data.tar.gz: 3a478cdb4b23ac26ceb9914d5e3740286ef6fda60c80605a01dc6f2a38086b7a
3
+ metadata.gz: 69238283188d95396073ce5caacf5662573ccaa6d913e1f76d056b24284449d7
4
+ data.tar.gz: 20639926a31480df14b3225e04a3eb04f3042bfb105aa9377a63bcb1a0cc5850
5
5
  SHA512:
6
- metadata.gz: effb89c7a3869aa7c268027e4653ecc2072fc41402d53ff2ad8ca0405862b83076bd82637cc9f64a369cd3ceb2ca23b2e6e9ca7ba300de2fd9f073801c65c199
7
- data.tar.gz: 371206ab57061b8fa94733db6e489565eeb0cd588ffb6b0908a9a0c6e84fcec2d64745501dd8ec7b8147cb3044119c47d08f479e50cec26e939a35f5127ecf9e
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-27 (second refresh)
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 (TODO 138:
29
- LeftSide / RightSide / LargestArea pick the flow side; spine
30
- variants approximate as BothSides); JumpObject moves text below
31
- the object (TODO 143); NextColumn approximates as the box;
32
- Contour-mode shapes are not side-aware.
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), line-end compression
36
- (TODO 132), and class-based pair compression are applied; named
37
- mojikumi sets are modeled (TODO 144: MojikumiTable +
38
- OverrideMojikumiAki parse and round-trip) but not yet APPLIED to
39
- layout the built-in class-based aki rules stand in.
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
- - Shape-mode text wrap contours (side-aware)
63
- - Apply named mojikumi sets to CJK layout (consume TODO 144's model)
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
- next unless contour.inverse
66
+ if contour.inverse
67
+ reduction = [reduction,
68
+ (frame_right - frame_x) - width].max
69
+ next
70
+ end
67
71
 
68
- reduction = [reduction,
69
- (frame_right - frame_x) - width].max
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 object — the full
90
- # frame width is blocked and the renderer skips the run
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
- side_adjustment(contour, overlap, frame_x, frame_right)
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
- def side_adjustment(contour, overlap, frame_x, frame_right)
118
- case contour.side
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 - contour.x, 0.0]
143
+ [frame_right - obj_left, 0.0]
121
144
  when "RightSide"
122
- shift = contour.x + contour.width - frame_x
145
+ shift = obj_right - frame_x
123
146
  [shift, shift]
124
147
  when "LargestArea"
125
- largest_side_adjustment(contour, frame_x, frame_right)
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 :side_adjustment
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 modes approximate as the bounding box (the
169
- # per-run width reduction already pushes text past the
170
- # object).
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 == "JumpObjectTextWrap"
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
- Shape = Struct.new(:polygons, :offset, :inverse,
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
- def apply_script_spacing(glyphs, size)
17
- spacing = size * SCRIPT_SPACING_EM
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
- next unless script_boundary?(leading.codepoint,
22
- following.codepoint)
49
+ em = aki_em(leading.codepoint, following.codepoint,
50
+ aki_overrides)
51
+ next unless em&.positive?
23
52
 
24
- leading.width += spacing
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.10.8"
4
+ VERSION = "0.10.10"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.8
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-27 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -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