idml 0.10.7 → 0.10.9

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: 943028d5bf17ca306e3cce0d23c4b042ddaa9473295f2fa7f4af43009a7423a0
4
- data.tar.gz: b9c3c1e5421ffa4de44396dbc94124434dda470c3f10fabd5ddd81146c3991c1
3
+ metadata.gz: c3ebdf83496770ecda7b37cc6f6cdc2388874785ccad23ac57aa396f76aef03b
4
+ data.tar.gz: d7304a209e09d4de3ee6d9f7556d18053f838ee3fef9a4f787f06693aa42ee32
5
5
  SHA512:
6
- metadata.gz: 86717ea81b1ede4576d3d41e7026cd2731ec40024ea887d33a03c4ff7771384cb0ae9fd04f5948a322d48986dae2946adcebf167a7c51ae3ce7890a28726e7ee
7
- data.tar.gz: 12a2089f4600a04fe5cd1d74e409c0933dc6f691156ffca4eeb87fcb25fd5d89e2a1ec58261c8a087ea00b93cf42abee0bb54d64f72032dc3b6bbe919f6cc6b4
6
+ metadata.gz: bd4dc75a85966131506edf7f3e01df91127fbf26525bb02edcf783f0c07580380be0d8bd38562c11a477602731e2efff8de78061580b471f1389f3bb87a07119
7
+ data.tar.gz: 74286965e709455a356789cc6f7e4c93f75d414a1132301ee8e347a85d114065bd1988921c180a522c646824d79f2049bdfacf7db609e9981b270d3a88b71fbb
@@ -0,0 +1,27 @@
1
+ # TODO PDF 142: Run-grouped Latin rotation in vertical writing
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ In vertical (StoryOrientation="Vertical") text, every non-CJK glyph
8
+ rotated 90° individually — each Latin letter got its own rotated
9
+ graphics state and stacked one cell apart. InDesign rotates a whole
10
+ Latin run as one block (the word reads sideways as a unit).
11
+
12
+ ## Solution
13
+
14
+ `TextFrameRenderer#emit_vertical_stack` partitions a vertical run's
15
+ glyphs: CJK singletons stay upright; consecutive non-CJK glyphs
16
+ (groups never span columns, x-reset on column change) render as ONE
17
+ text op inside a single rotated graphics state. The font's natural
18
+ advances land each glyph exactly where per-glyph rotation placed
19
+ it, so the change is lossless for position — but a whole word now
20
+ rotates as a unit and emits one op instead of N. The per-glyph
21
+ `emit_vertical_glyph` path is gone (dead after grouping covered it).
22
+
23
+ ## Files
24
+
25
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
26
+ - `spec/idml/render/vertical_render_spec.rb`
27
+ - `spec/idml/render/vertical_multirun_spec.rb`
@@ -0,0 +1,31 @@
1
+ # TODO PDF 143: JumpObject wrap — text below the object
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ JumpObjectTextWrap approximated as the bounding box: text narrowed
8
+ beside the object. InDesign's JumpObject means text NEVER flows
9
+ beside the object — it continues below it.
10
+
11
+ ## Solution
12
+
13
+ - `TextWrapResolver::Contour` gains a `jump` flag, set from
14
+ `TextWrapMode="JumpObjectTextWrap"`.
15
+ - A jump contour blocks the FULL frame width in
16
+ `wrap_adjustment`.
17
+ - `TextFrameRenderer#skip_below_jump_object`: when the run's band
18
+ is fully blocked (wrap width below the point size), the cursor
19
+ skips to `jump_contour_bottom` — the blocking object's bottom
20
+ edge — and the wrap re-measures there once. Text resumes below
21
+ the object, never beside it.
22
+
23
+ NextColumn still approximates as the box (it needs column-jump
24
+ chaining, documented in TODO.pdf/61).
25
+
26
+ ## Files
27
+
28
+ - `lib/idml/render/text_wrap_resolver.rb`
29
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
30
+ - `spec/idml/render/text_wrap_resolver_spec.rb`
31
+ - `spec/idml/render/jump_object_render_spec.rb` (end-to-end)
@@ -0,0 +1,36 @@
1
+ # TODO PDF 144: Named mojikumi sets modeled
2
+
3
+ ## Status: COMPLETE (modeling) — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ Designmap-level `<MojikumiTable>` elements (named mojikumi sets:
8
+ 詳細 etc. with per-class-pair aki overrides) were ignored by the
9
+ model — a document declaring custom spacing lost that information
10
+ on round-trip.
11
+
12
+ ## Solution
13
+
14
+ New elements per `designmap.rnc` / `datatype.rnc`:
15
+
16
+ - `Elements::MojikumiTable` — Self / Name / BasedOnMojikumiSet +
17
+ `#aki_overrides` (flattened document-order overrides).
18
+ - `Elements::MojikumiTableProperties` — OverrideMojikumiAkiList.
19
+ - `Elements::OverrideMojikumiAkiList` — the entries.
20
+ - `Elements::OverrideMojikumiAki` — all 8 typedef attributes
21
+ (Target/SideMojikumiClass, SideIsAfterTarget, Minimum/Desired/
22
+ Maximum, CompressionPriority, AkiDoesNotFloat).
23
+
24
+ `Parts::Designmap` maps the children; round-trip verified.
25
+
26
+ **Not yet applied**: rendering still uses the built-in class-based
27
+ aki rules (TODOs 125/132/13); consuming BasedOnMojikumiSet and the
28
+ override entries in `CjkLayout` is the follow-up (TODO.pdf/61).
29
+
30
+ ## Files
31
+
32
+ - `lib/idml/elements/mojikumi_table.rb`
33
+ - `lib/idml/parts/designmap.rb`
34
+ - `lib/idml/elements.rb`
35
+ - `spec/idml/elements/mojikumi_table_spec.rb`
36
+ - `spec/idml/parts/designmap_spec.rb`
@@ -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`
@@ -1,6 +1,6 @@
1
1
  # TODO PDF 61: Known limitations and future work
2
2
 
3
- ## Status: DOCUMENTED — refreshed 2026-08-27
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,17 +25,22 @@ 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/NextColumn
31
- approximate as the box; 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.
32
34
 
33
35
  ### CJK
34
- - **Mojikumi**: script spacing (TODO 125), line-end compression
35
- (TODO 132), and class-based pair compression (2026-08-26) are
36
- applied; named mojikumi sets (詳細 etc.) are not modeled.
37
- - **Vertical mode**: Latin rotation is per glyph (not run-grouped);
38
- ruby placement is beside-column; keep options do not apply.
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.
41
+ - **Vertical mode**: Latin runs rotate as one group per segment
42
+ (TODO 142); ruby placement is beside-column; keep options do
43
+ not apply.
39
44
 
40
45
  ### Structural features
41
46
  - **Endnotes** (TODO 117): reference markers render and endnote TEXT
@@ -55,7 +60,7 @@ this list reflects the current state.
55
60
  ## Future enhancements
56
61
 
57
62
  - Hyphenation dictionary integration
58
- - Shape-mode text wrap contours (side-aware)
59
- - Named mojikumi sets (詳細 etc.)
63
+ - NextColumn wrap via column-jump chaining (multi-column frames)
64
+ - Minimum/Maximum mojikumi aki bounds + CompressionPriority
60
65
  - ToBinding / RTL binding alignment
61
- - Vertical-mode keep options and run-grouped Latin rotation
66
+ - Vertical-mode keep options
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Elements
5
+ # One aki override (per `OverrideMojikumiAkiType_TypeDef`):
6
+ # spacing between the Target and Side character classes, as
7
+ # minimum / desired / maximum.
8
+ class OverrideMojikumiAki < Lutaml::Model::Serializable
9
+ attribute :target_mojikumi_class, :integer
10
+ attribute :side_mojikumi_class, :integer
11
+ attribute :side_is_after_target, :boolean
12
+ attribute :minimum, :float
13
+ attribute :desired, :float
14
+ attribute :maximum, :float
15
+ attribute :compression_priority, :integer
16
+ attribute :aki_does_not_float, :boolean
17
+
18
+ xml do
19
+ root "OverrideMojikumiAkiType"
20
+ map_attribute "TargetMojikumiClass", to: :target_mojikumi_class
21
+ map_attribute "SideMojikumiClass", to: :side_mojikumi_class
22
+ map_attribute "SideIsAfterTarget", to: :side_is_after_target
23
+ map_attribute "Minimum", to: :minimum
24
+ map_attribute "Desired", to: :desired
25
+ map_attribute "Maximum", to: :maximum
26
+ map_attribute "CompressionPriority", to: :compression_priority
27
+ map_attribute "AkiDoesNotFloat", to: :aki_does_not_float
28
+ end
29
+ end
30
+
31
+ # `<OverrideMojikumiAkiList>` — the per-class-pair override
32
+ # entries of a named mojikumi set.
33
+ class OverrideMojikumiAkiList < Lutaml::Model::Serializable
34
+ attribute :override_mojikumi_aki, OverrideMojikumiAki,
35
+ collection: true
36
+
37
+ xml do
38
+ root "OverrideMojikumiAkiList"
39
+ map_element "OverrideMojikumiAkiType",
40
+ to: :override_mojikumi_aki
41
+ end
42
+ end
43
+
44
+ # `<Properties>` inside a MojikumiTable — carries the aki
45
+ # override list.
46
+ class MojikumiTableProperties < Lutaml::Model::Serializable
47
+ attribute :override_mojikumi_aki_list, OverrideMojikumiAkiList,
48
+ collection: true
49
+
50
+ xml do
51
+ root "Properties"
52
+ map_element "OverrideMojikumiAkiList",
53
+ to: :override_mojikumi_aki_list
54
+ end
55
+ end
56
+
57
+ # Typed model of `<MojikumiTable>` (TODO 144) — a named
58
+ # mojikumi set declared at the designmap level. Named sets are
59
+ # modeled (parsed and round-tripped) but not yet APPLIED: the
60
+ # renderer keeps using the built-in class-based aki rules (see
61
+ # TODO.pdf/61).
62
+ class MojikumiTable < Lutaml::Model::Serializable
63
+ attribute :self_attr, :string
64
+ attribute :name, :string
65
+ attribute :based_on_mojikumi_set, :string
66
+ attribute :properties, MojikumiTableProperties, collection: true
67
+
68
+ xml do
69
+ root "MojikumiTable"
70
+ map_attribute "Self", to: :self_attr
71
+ map_attribute "Name", to: :name
72
+ map_attribute "BasedOnMojikumiSet", to: :based_on_mojikumi_set
73
+ map_element "Properties", to: :properties
74
+ end
75
+
76
+ # Aki overrides in document order, flattened across the
77
+ # Properties > OverrideMojikumiAkiList chain.
78
+ def aki_overrides
79
+ properties.flat_map do |prop|
80
+ prop.override_mojikumi_aki_list.flat_map(&:override_mojikumi_aki)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
data/lib/idml/elements.rb CHANGED
@@ -90,6 +90,10 @@ module Idml
90
90
  autoload :MasterSpreadObject, "idml/elements/master_spread_object"
91
91
  autoload :MixedInk, "idml/elements/mixed_ink"
92
92
  autoload :MixedInkGroup, "idml/elements/mixed_ink_group"
93
+ autoload :MojikumiTable, "idml/elements/mojikumi_table"
94
+ autoload :MojikumiTableProperties, "idml/elements/mojikumi_table"
95
+ autoload :OverrideMojikumiAki, "idml/elements/mojikumi_table"
96
+ autoload :OverrideMojikumiAkiList, "idml/elements/mojikumi_table"
93
97
  autoload :MojikumiUiPreference, "idml/elements/pref_mojikumi_ui_preference"
94
98
  autoload :ObjectExportOption, "idml/elements/object_export_option"
95
99
  autoload :ObjectStyle, "idml/elements/object_style"
@@ -54,6 +54,8 @@ module Idml
54
54
  Idml::Elements::HyperlinkPageDestination, collection: true
55
55
  attribute :hyperlink_url_destination,
56
56
  Idml::Elements::HyperlinkURLDestination, collection: true
57
+ attribute :mojikumi_table, Idml::Elements::MojikumiTable,
58
+ collection: true
57
59
 
58
60
  xml do
59
61
  root "Document"
@@ -99,6 +101,7 @@ module Idml
99
101
  to: :hyperlink_page_destination
100
102
  map_element "HyperlinkURLDestination",
101
103
  to: :hyperlink_url_destination
104
+ map_element "MojikumiTable", to: :mojikumi_table
102
105
  end
103
106
  end
104
107
  end
@@ -332,13 +332,60 @@ module Idml
332
332
  emit_upright_glyph(canvas, glyph, run, context, size)
333
333
  end
334
334
  else
335
- positioned.each do |glyph|
336
- emit_vertical_glyph(canvas, glyph, run, context, size)
337
- end
335
+ emit_vertical_stack(canvas, positioned, run, context, size)
338
336
  end
339
337
  end
340
338
  private_class_method :emit_vertical_run
341
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
+
342
389
  def self.emit_upright_glyph(canvas, positioned, run, context,
343
390
  size)
344
391
  text = [positioned.codepoint].pack("U")
@@ -361,43 +408,6 @@ module Idml
361
408
  end
362
409
  private_class_method :vertical_fits?
363
410
 
364
- # Emits one upright glyph at its vertical position with the
365
- # run's character styling.
366
- # Emits one glyph in vertical mode: CJK upright; Latin
367
- # (non-CJK) rotated 90° clockwise per the vertical-writing
368
- # convention, via a graphics-state transform.
369
- def self.emit_vertical_glyph(canvas, positioned, run, context,
370
- size)
371
- text = [positioned.codepoint].pack("U")
372
- if TextEngine::CjkLayout.cjk?(positioned.codepoint)
373
- text_kwargs = CharacterStyle.text_kwargs(
374
- run,
375
- {
376
- at: [positioned.x, positioned.y],
377
- font: font_for_run(run, context),
378
- size: size,
379
- },
380
- )
381
- canvas.text(text, **text_kwargs)
382
- else
383
- canvas.save_graphics_state do
384
- # 90° clockwise: exact matrix (rotate would emit
385
- # cos/sin float noise like 6.1e-17).
386
- canvas.concat(0, -1, 1, 0, positioned.x, positioned.y)
387
- text_kwargs = CharacterStyle.text_kwargs(
388
- run,
389
- {
390
- at: [0, 0],
391
- font: font_for_run(run, context),
392
- size: size,
393
- },
394
- )
395
- canvas.text(text, **text_kwargs)
396
- end
397
- end
398
- end
399
- private_class_method :emit_vertical_glyph
400
-
401
411
  # Emits the run's ruby alongside its vertical glyphs:
402
412
  # stacked top-to-bottom beside the column — right side by
403
413
  # default, left for Below* RubyPosition values (the
@@ -881,6 +891,10 @@ module Idml
881
891
  context, layout_frame, cursor_y, run
882
892
  )
883
893
  run_wrap -= wrap_reduction
894
+ cursor_y, run_wrap, wrap_shift = skip_below_jump_object(
895
+ context, layout_frame, cursor_y, run, run_wrap, wrap_shift,
896
+ wrap_reduction, bottom_limit
897
+ )
884
898
  positioned, next_y = layout_run(
885
899
  canvas, run, paragraph, context, layout_frame, font,
886
900
  run_wrap, cursor_y, space_before,
@@ -946,6 +960,42 @@ module Idml
946
960
  end
947
961
  private_class_method :render_footnote_entries
948
962
 
963
+ # JumpObjectTextWrap blocks the full column width: when the
964
+ # run's band is fully blocked, move the cursor below the
965
+ # object's bottom edge and re-measure once. InDesign's
966
+ # semantics — text flows under the object, never beside it.
967
+ # Returns the (possibly skipped) cursor and re-measured
968
+ # wrap geometry.
969
+ def self.skip_below_jump_object(context, layout_frame, cursor_y,
970
+ run, run_wrap, wrap_shift,
971
+ wrap_reduction, bottom_limit)
972
+ min_width = run.point_size || DEFAULT_SIZE
973
+ return [cursor_y, run_wrap, wrap_shift] if run_wrap >= min_width
974
+
975
+ skip_y = text_wrap_jump_below(context, layout_frame, cursor_y, run)
976
+ return [cursor_y, run_wrap, wrap_shift] unless skip_y
977
+ return [cursor_y, run_wrap, wrap_shift] if skip_y >= cursor_y
978
+ return [cursor_y, run_wrap, wrap_shift] if skip_y <= bottom_limit
979
+
980
+ reduction, shift = text_wrap_adjust(context, layout_frame,
981
+ skip_y, run)
982
+ [skip_y, [run_wrap + wrap_reduction - reduction, 0].max, shift]
983
+ end
984
+ private_class_method :skip_below_jump_object
985
+
986
+ def self.text_wrap_jump_below(context, layout_frame, cursor_y, run)
987
+ resolver = context.text_wrap_resolver
988
+ return nil unless resolver
989
+
990
+ size = run.point_size || DEFAULT_SIZE
991
+ frame_x = layout_frame.x + (layout_frame.inset_left || 0)
992
+ frame_right = layout_frame.x + layout_frame.width -
993
+ (layout_frame.inset_right || 0)
994
+ resolver.jump_contour_bottom(cursor_y, size, frame_x,
995
+ frame_right)
996
+ end
997
+ private_class_method :text_wrap_jump_below
998
+
949
999
  # Computes the text-wrap adjustment for a run at its current y
950
1000
  # position: [width_reduction, x_shift]. The reduction narrows
951
1001
  # the wrap width; the shift moves wrapped lines right past a
@@ -1095,6 +1145,9 @@ module Idml
1095
1145
  glyphs = TextEngine::Shaper.shape(
1096
1146
  text: run.text, font: font, size: size,
1097
1147
  )
1148
+ glyphs = TextEngine::CjkLayout.apply_script_spacing(
1149
+ glyphs, size, mojikumi_aki_overrides(context)
1150
+ )
1098
1151
  lines = TextEngine::LineBreaker.break(
1099
1152
  glyphs: glyphs, frame_width: wrap_width,
1100
1153
  )
@@ -1119,6 +1172,19 @@ module Idml
1119
1172
  end
1120
1173
  private_class_method :layout_run
1121
1174
 
1175
+ # Aki overrides from the document's first named mojikumi
1176
+ # set, when the designmap declares one (TODO 145). Empty
1177
+ # otherwise — apply_script_spacing then uses the default
1178
+ # CJK/Latin script spacing.
1179
+ def self.mojikumi_aki_overrides(context)
1180
+ package = context.package
1181
+ return [] unless package&.has_part?("designmap.xml")
1182
+
1183
+ table = package.designmap.mojikumi_table.first
1184
+ table&.aki_overrides || []
1185
+ end
1186
+ private_class_method :mojikumi_aki_overrides
1187
+
1122
1188
  # True when the paragraph should move wholly to the next
1123
1189
  # frame: a StartParagraph forced break, or KeepAllLines-
1124
1190
  # Together with insufficient remaining space (never for the
@@ -18,7 +18,7 @@ module Idml
18
18
  class TextWrapResolver
19
19
  # A wrap contour: a PDF-coordinate rectangle the text avoids.
20
20
  Contour = Struct.new(
21
- :x, :y, :width, :height, :inverse, :side,
21
+ :x, :y, :width, :height, :inverse, :side, :jump,
22
22
  keyword_init: true
23
23
  )
24
24
 
@@ -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,34 +99,65 @@ module Idml
86
99
 
87
100
  return [0.0, 0.0] if overlap.zero?
88
101
 
89
- side_adjustment(contour, overlap, frame_x, frame_right)
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.
105
+ return [frame_right - frame_x, 0.0] if contour.jump
106
+
107
+ interval_side_adjustment(contour.side, contour.x,
108
+ contour.x + contour.width, overlap,
109
+ frame_x, frame_right)
90
110
  end
91
111
  private :box_adjustment
92
112
 
93
- def side_adjustment(contour, overlap, frame_x, frame_right)
94
- case contour.side
113
+ # Bottom edge of the lowest jump contour overlapping the given
114
+ # band — where text resumes below a JumpObject. nil when no
115
+ # jump contour blocks the band.
116
+ def jump_contour_bottom(line_y, line_height, frame_x, frame_right)
117
+ bottoms = @contours.filter_map do |contour|
118
+ next unless box_contour?(contour) && contour.jump
119
+ next unless y_overlap?(contour, line_y, line_height) &&
120
+ x_overlap?(contour, frame_x, frame_right)
121
+
122
+ contour.y
123
+ end
124
+ bottoms.min
125
+ end
126
+
127
+ def box_contour?(contour)
128
+ !contour.is_a?(WrapContour::Shape)
129
+ end
130
+ private :box_contour?
131
+
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
95
142
  when "LeftSide"
96
- [frame_right - contour.x, 0.0]
143
+ [frame_right - obj_left, 0.0]
97
144
  when "RightSide"
98
- shift = contour.x + contour.width - frame_x
145
+ shift = obj_right - frame_x
99
146
  [shift, shift]
100
147
  when "LargestArea"
101
- 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
102
156
  else
103
157
  [overlap, 0.0]
104
158
  end
105
159
  end
106
- private :side_adjustment
107
-
108
- def largest_side_adjustment(contour, frame_x, frame_right)
109
- left_free = contour.x - frame_x
110
- right_free = frame_right - (contour.x + contour.width)
111
- return [frame_right - contour.x, 0.0] if left_free >= right_free
112
-
113
- shift = contour.x + contour.width - frame_x
114
- [shift, shift]
115
- end
116
- private :largest_side_adjustment
160
+ private :interval_side_adjustment
117
161
 
118
162
  # Returns the total horizontal overlap of all contours with a
119
163
  # text line at the given y range within the given x range.
@@ -140,10 +184,11 @@ module Idml
140
184
  # The wrap shape for an item: a bounding-box rectangle
141
185
  # (BoundingBoxTextWrap — the legacy "BoundingBox" spelling is
142
186
  # also accepted from early synthetic fixtures) or a flattened
143
- # PathGeometry polygon (Contour mode). JumpObject /
144
- # NextColumn modes approximate as the bounding box (the
145
- # per-run width reduction already pushes text past the
146
- # 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
147
192
  def self.contour_for(item, page_height)
148
193
  pref = wrap_preference(item)
149
194
  return nil unless pref
@@ -165,7 +210,8 @@ module Idml
165
210
  Contour.new(
166
211
  x: rect[:x], y: rect[:y],
167
212
  width: rect[:width], height: rect[:height],
168
- side: pref&.text_wrap_side
213
+ side: pref&.text_wrap_side,
214
+ jump: JUMP_MODES.include?(pref&.text_wrap_mode)
169
215
  )
170
216
  end
171
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)
@@ -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.7"
4
+ VERSION = "0.10.9"
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.7
4
+ version: 0.10.9
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-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -205,6 +205,12 @@ files:
205
205
  - TODO.pdf/14-page-item-element-models.md
206
206
  - TODO.pdf/140-cap-xheight-first-baselines.md
207
207
  - TODO.pdf/141-perf-hundred-pages.md
208
+ - TODO.pdf/142-vertical-latin-run-groups.md
209
+ - TODO.pdf/143-jump-object-below.md
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
208
214
  - TODO.pdf/15-wire-spread-children.md
209
215
  - TODO.pdf/16-typed-model-pipeline.md
210
216
  - TODO.pdf/17-shape-rendering.md
@@ -348,6 +354,7 @@ files:
348
354
  - lib/idml/elements/master_spread_object.rb
349
355
  - lib/idml/elements/mixed_ink.rb
350
356
  - lib/idml/elements/mixed_ink_group.rb
357
+ - lib/idml/elements/mojikumi_table.rb
351
358
  - lib/idml/elements/object_export_option.rb
352
359
  - lib/idml/elements/object_style.rb
353
360
  - lib/idml/elements/object_style_content_effects_category_settings.rb