idml 0.10.6 → 0.10.8

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: f60b5f9020e44d5131e9f50e2ddfb673f9caca65bbdee8428b4670baaf96108f
4
- data.tar.gz: 1f19b80511a14cea5fab0b76404be809a8f28e680deb91ee1b4caf9353a1b756
3
+ metadata.gz: e05a75aaa533dcd8f478d6b4ab635d15cf6c64ac85bebbd42fe3b136ed0c6783
4
+ data.tar.gz: 3a478cdb4b23ac26ceb9914d5e3740286ef6fda60c80605a01dc6f2a38086b7a
5
5
  SHA512:
6
- metadata.gz: 9bea97bee388cae4ab403c3bea6cae233c3a378c569c0c678620bdb59a3ed5534ab9aa560866fad103d8a724351e396c72e9a533671fd44189719958f83d811e
7
- data.tar.gz: 151917cdee9265410c788c85fb6f28d45d07ffc37cc3c4e3fc14b4578f2be57adee09393c284a53a883f1f1dbe105920ecd745a264f0311cc38c0c8f92d94a05
6
+ metadata.gz: effb89c7a3869aa7c268027e4653ecc2072fc41402d53ff2ad8ca0405862b83076bd82637cc9f64a369cd3ceb2ca23b2e6e9ca7ba300de2fd9f073801c65c199
7
+ data.tar.gz: 371206ab57061b8fa94733db6e489565eeb0cd588ffb6b0908a9a0c6e84fcec2d64745501dd8ec7b8147cb3044119c47d08f479e50cec26e939a35f5127ecf9e
@@ -0,0 +1,24 @@
1
+ # TODO PDF 139: Hyphen break opportunities in the LineBreaker
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ The greedy line breaker only treated spaces as break opportunities.
8
+ Long compound words ("state-of-the-art", "e-mail") overflowed the
9
+ frame as one unbreakable run instead of wrapping after a hyphen.
10
+
11
+ ## Solution
12
+
13
+ `LineBreaker#break_after?` treats hyphen-minus (U+002D), Unicode
14
+ hyphen (U+2010), non-breaking hyphen (U+2011), and soft hyphen
15
+ (U+00AD) as break-after points: the hyphen stays on the first line
16
+ and the remainder wraps. Soft hyphens break without adding a
17
+ visible hyphen. Dictionary-based hyphenation (Knuth-Liang) remains
18
+ out of scope — the Hyphenation* PSR attributes stay parsed but
19
+ unused.
20
+
21
+ ## Files
22
+
23
+ - `lib/idml/text_engine/line_breaker.rb`
24
+ - `spec/idml/text_engine/text_engine_spec.rb`
@@ -0,0 +1,29 @@
1
+ # TODO PDF 140: CapHeight / XHeight / EmboxHeight first baselines
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ FirstBaselineOffset honored AscentOffset and FixedHeight (TODO 128)
8
+ but approximated CapHeight, XHeight, and EmboxHeight as leading —
9
+ text sat noticeably lower than InDesign places it.
10
+
11
+ ## Solution
12
+
13
+ `TextFrameRenderer#baseline_target` now handles all five modes:
14
+
15
+ - **CapHeight** — the font's cap-height metric when the provider
16
+ fills it; pdfrb 0.7.1 never does, so the fallback is the standard
17
+ 0.72 em proportion (Arial and Helvetica both sit within 1%).
18
+ - **XHeight** — 0.52 em (no x-height metric exists in pdfrb).
19
+ - **EmboxHeight** — the em box itself: the first paragraph's point
20
+ size.
21
+
22
+ `PdfrbFontMetrics#cap_height` exposes the metric for when pdfrb
23
+ starts populating it.
24
+
25
+ ## Files
26
+
27
+ - `lib/idml/render/renderers/text_frame_renderer.rb`
28
+ - `lib/idml/text_engine/pdfrb_font_metrics.rb`
29
+ - `spec/idml/render/first_baseline_spec.rb`
@@ -0,0 +1,22 @@
1
+ # TODO PDF 141: 100+ page performance regression guard
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-27
4
+
5
+ ## Problem
6
+
7
+ No test guarded against per-page complexity blowups — a regression
8
+ to O(document) work per page would only surface as slow user
9
+ renders.
10
+
11
+ ## Solution
12
+
13
+ `perf_hundred_pages_spec.rb` builds a synthetic 120-spread package
14
+ (one page + one text frame each, all sharing a wordy story) and
15
+ renders it end-to-end through `Idml::Render.render`. Asserts all
16
+ 120 PDF pages emit and the render completes under a generous 60s
17
+ ceiling — sized to trip only on pathological blowups, not machine
18
+ noise. Baseline on this branch: ~2s for 120 pages.
19
+
20
+ ## Files
21
+
22
+ - `spec/idml/render/perf_hundred_pages_spec.rb`
@@ -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`
@@ -1,6 +1,6 @@
1
1
  # TODO PDF 61: Known limitations and future work
2
2
 
3
- ## Status: DOCUMENTED — refreshed 2026-08-26
3
+ ## Status: DOCUMENTED — refreshed 2026-08-27 (second refresh)
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;
@@ -9,8 +9,9 @@ this list reflects the current state.
9
9
  ## Known limitations
10
10
 
11
11
  ### Text layout
12
- - **Hyphenation** is not implemented (Hyphenation* attributes are
13
- parsed but unused); a hyphenation dictionary would be required.
12
+ - **Hyphenation**: dictionary-based hyphenation is not implemented
13
+ (Hyphenation* attributes are parsed but unused); compound words
14
+ wrap after explicit hyphens (TODO 139).
14
15
  - **Justification**: word/letter spacing caps apply (TODO 119),
15
16
  MaximumGlyphScaling stretches (TODO 129) and MinimumGlyphScaling
16
17
  compresses overlong lines (TODO 135); ToBinding / RTL binding
@@ -18,22 +19,27 @@ this list reflects the current state.
18
19
  - **Keep options**: KeepAllLinesTogether (TODO 123), KeepWithNext
19
20
  (TODO 127), and the KeepFirstLines / KeepLastLines windows
20
21
  (TODO 133, line-count approximation) are honored.
21
- - **First baseline**: AscentOffset and FixedHeight are honored
22
- (TODO 128); CapHeight / XHeight approximate as leading.
22
+ - **First baseline**: all five modes honor their metrics (TODOs
23
+ 128/140); CapHeight / XHeight use standard font proportions
24
+ (0.72 / 0.52 em) until a metrics provider fills them.
23
25
  - **StartParagraph** breaks act at frame/column granularity — no
24
26
  odd/even page parity.
25
27
  - **Text wrap**: BoundingBoxTextWrap, Contour, and Inverse modes
26
28
  render (TODOs 130-131) with TextWrapSide side-awareness (TODO 138:
27
29
  LeftSide / RightSide / LargestArea pick the flow side; spine
28
- variants approximate as BothSides); JumpObject/NextColumn
29
- approximate as the box; Contour-mode shapes are not side-aware.
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.
30
33
 
31
34
  ### CJK
32
35
  - **Mojikumi**: script spacing (TODO 125), line-end compression
33
- (TODO 132), and class-based pair compression (2026-08-26) are
34
- applied; named mojikumi sets (詳細 etc.) are not modeled.
35
- - **Vertical mode**: Latin rotation is per glyph (not run-grouped);
36
- ruby placement is beside-column; keep options do not apply.
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.
40
+ - **Vertical mode**: Latin runs rotate as one group per segment
41
+ (TODO 142); ruby placement is beside-column; keep options do
42
+ not apply.
37
43
 
38
44
  ### Structural features
39
45
  - **Endnotes** (TODO 117): reference markers render and endnote TEXT
@@ -54,4 +60,7 @@ this list reflects the current state.
54
60
 
55
61
  - Hyphenation dictionary integration
56
62
  - Shape-mode text wrap contours (side-aware)
57
- - Performance benchmark for 100+ page documents
63
+ - Apply named mojikumi sets to CJK layout (consume TODO 144's model)
64
+ - NextColumn wrap via column-jump chaining
65
+ - ToBinding / RTL binding alignment
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
@@ -629,12 +639,19 @@ module Idml
629
639
  # the first line's baseline sits. The layout's default is
630
640
  # leading-based; AscentOffset shifts by (ascent − leading)
631
641
  # and FixedHeight by (MinimumFirstBaselineOffset − leading).
632
- # CapHeight / XHeight / EmboxHeight approximate as leading.
633
- # Returns the extra shift to subtract from the cursor.
642
+ # CapHeight uses the font's cap-height metric (leading
643
+ # fallback when the font reports none); XHeight approximates
644
+ # as 70% of cap height; EmboxHeight uses the em box (the
645
+ # first paragraph's point size). Returns the extra shift to
646
+ # subtract from the cursor.
647
+ FIRST_BASELINE_MODES = %w[
648
+ AscentOffset FixedHeight CapHeight XHeight EmboxHeight
649
+ ].freeze
650
+
634
651
  def self.first_baseline_offset(context, state, font)
635
652
  pref = context.item&.text_frame_preference&.first
636
653
  mode = pref&.first_baseline_offset
637
- return 0.0 unless %w[AscentOffset FixedHeight].include?(mode)
654
+ return 0.0 unless FIRST_BASELINE_MODES.include?(mode)
638
655
 
639
656
  baseline_target(pref, mode, state, font) -
640
657
  first_paragraph_leading(state)
@@ -643,21 +660,52 @@ module Idml
643
660
 
644
661
  # Distance from the top inset to the first baseline under
645
662
  # the given mode.
663
+ # pdfrb 0.7.1 never fills the cap_height metric, so
664
+ # CapHeight / XHeight fall back to standard font
665
+ # proportions (cap ≈ 0.72 em, x ≈ 0.52 em — Arial and
666
+ # Helvetica both sit within 1% of these).
667
+ CAP_HEIGHT_RATIO = 0.72
668
+ X_HEIGHT_RATIO = 0.52
669
+
646
670
  def self.baseline_target(pref, mode, state, font)
647
- if mode == "AscentOffset"
648
- ascent_scaled(font) * first_paragraph_leading(state)
671
+ leading = first_paragraph_leading(state)
672
+ case mode
673
+ when "AscentOffset"
674
+ ratio_scaled(font.ascent, font) * leading
675
+ when "CapHeight"
676
+ cap_ratio(font) * leading
677
+ when "XHeight"
678
+ X_HEIGHT_RATIO * leading
679
+ when "EmboxHeight"
680
+ first_paragraph_size(state)
649
681
  else
650
- pref.minimum_first_baseline_offset ||
651
- first_paragraph_leading(state)
682
+ pref.minimum_first_baseline_offset || leading
652
683
  end
653
684
  end
654
685
  private_class_method :baseline_target
655
686
 
656
- def self.ascent_scaled(font)
657
- upem = font.units_per_em.zero? ? 1 : font.units_per_em
658
- font.ascent / upem
687
+ def self.cap_ratio(font)
688
+ cap = font.cap_height
689
+ return CAP_HEIGHT_RATIO unless cap
690
+
691
+ ratio_scaled(cap, font)
692
+ end
693
+ private_class_method :cap_ratio
694
+
695
+ def self.ratio_scaled(metric, font)
696
+ upem = font.units_per_em
697
+ return 1.0 if upem.zero?
698
+
699
+ metric / upem
659
700
  end
660
- private_class_method :ascent_scaled
701
+ private_class_method :ratio_scaled
702
+
703
+ def self.first_paragraph_size(state)
704
+ paragraph = state.current_paragraph || state.paragraphs.first
705
+ first_run = paragraph&.runs&.first
706
+ first_run&.point_size || DEFAULT_SIZE
707
+ end
708
+ private_class_method :first_paragraph_size
661
709
 
662
710
  def self.first_paragraph_leading(state)
663
711
  paragraph = state.current_paragraph || state.paragraphs.first
@@ -843,6 +891,10 @@ module Idml
843
891
  context, layout_frame, cursor_y, run
844
892
  )
845
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
+ )
846
898
  positioned, next_y = layout_run(
847
899
  canvas, run, paragraph, context, layout_frame, font,
848
900
  run_wrap, cursor_y, space_before,
@@ -908,6 +960,42 @@ module Idml
908
960
  end
909
961
  private_class_method :render_footnote_entries
910
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
+
911
999
  # Computes the text-wrap adjustment for a run at its current y
912
1000
  # position: [width_reduction, x_shift]. The reduction narrows
913
1001
  # the wrap width; the shift moves wrapped lines right past a
@@ -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
 
@@ -86,10 +86,34 @@ module Idml
86
86
 
87
87
  return [0.0, 0.0] if overlap.zero?
88
88
 
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.
92
+ return [frame_right - frame_x, 0.0] if contour.jump
93
+
89
94
  side_adjustment(contour, overlap, frame_x, frame_right)
90
95
  end
91
96
  private :box_adjustment
92
97
 
98
+ # Bottom edge of the lowest jump contour overlapping the given
99
+ # band — where text resumes below a JumpObject. nil when no
100
+ # jump contour blocks the band.
101
+ def jump_contour_bottom(line_y, line_height, frame_x, frame_right)
102
+ bottoms = @contours.filter_map do |contour|
103
+ next unless box_contour?(contour) && contour.jump
104
+ next unless y_overlap?(contour, line_y, line_height) &&
105
+ x_overlap?(contour, frame_x, frame_right)
106
+
107
+ contour.y
108
+ end
109
+ bottoms.min
110
+ end
111
+
112
+ def box_contour?(contour)
113
+ !contour.is_a?(WrapContour::Shape)
114
+ end
115
+ private :box_contour?
116
+
93
117
  def side_adjustment(contour, overlap, frame_x, frame_right)
94
118
  case contour.side
95
119
  when "LeftSide"
@@ -165,7 +189,8 @@ module Idml
165
189
  Contour.new(
166
190
  x: rect[:x], y: rect[:y],
167
191
  width: rect[:width], height: rect[:height],
168
- side: pref&.text_wrap_side
192
+ side: pref&.text_wrap_side,
193
+ jump: pref&.text_wrap_mode == "JumpObjectTextWrap"
169
194
  )
170
195
  end
171
196
  private_class_method :box_contour
@@ -6,10 +6,16 @@ module Idml
6
6
  Line = Struct.new(:glyphs, :width, :x_offset)
7
7
 
8
8
  # Greedy word-wrap line breaker. Accumulates glyphs until
9
- # exceeding the frame width, then breaks at the last space.
10
- # CJK runs get a kinsoku shori post-pass (no line starts or
11
- # ends with a forbidden character).
9
+ # exceeding the frame width, then breaks at the last break
10
+ # opportunity: a space, or a hyphen (compound words wrap after
11
+ # the hyphen, which stays on the first line). CJK runs get a
12
+ # kinsoku shori post-pass (no line starts or ends with a
13
+ # forbidden character).
12
14
  class LineBreaker
15
+ # Break-after opportunities besides spaces: hyphen-minus,
16
+ # the Unicode hyphens, and the soft hyphen (which breaks
17
+ # without adding a visible hyphen).
18
+ HYPHEN_CODEPOINTS = [0x2D, 0x2010, 0x2011, 0x00AD].freeze
13
19
  def self.break(glyphs:, frame_width:)
14
20
  lines = new(frame_width).break(glyphs)
15
21
  return lines unless cjk_run?(glyphs)
@@ -44,26 +50,26 @@ module Idml
44
50
  lines = []
45
51
  current = []
46
52
  current_width = 0
47
- last_space_idx = -1
48
- width_at_space = 0
53
+ break_idx = -1
54
+ width_at_break = 0
49
55
 
50
56
  glyphs.each_with_index do |glyph, _idx|
51
57
  current << glyph
52
58
  current_width += glyph.width
53
59
 
54
- if glyph.is_space
55
- last_space_idx = current.length - 1
56
- width_at_space = current_width
60
+ if break_after?(glyph)
61
+ break_idx = current.length - 1
62
+ width_at_break = current_width
57
63
  end
58
64
 
59
65
  next unless current_width > @frame_width && current.length > 1
60
66
 
61
- if last_space_idx >= 0
62
- line_glyphs = current[0..last_space_idx]
63
- lines << Line.new(line_glyphs, width_at_space, 0)
64
- current = current[(last_space_idx + 1)..]
67
+ if break_idx >= 0
68
+ line_glyphs = current[0..break_idx]
69
+ lines << Line.new(line_glyphs, width_at_break, 0)
70
+ current = current[(break_idx + 1)..]
65
71
  current_width = current.sum(&:width)
66
- last_space_idx = -1
72
+ break_idx = -1
67
73
  elsif cjk_break?(current)
68
74
  lines << Line.new(current[0..-2], current_width - glyph.width, 0)
69
75
  current = [glyph]
@@ -78,6 +84,11 @@ module Idml
78
84
  lines << Line.new(current, current_width, 0) if current.any?
79
85
  lines
80
86
  end
87
+
88
+ def break_after?(glyph)
89
+ glyph.is_space || HYPHEN_CODEPOINTS.include?(glyph.codepoint)
90
+ end
91
+ private :break_after?
81
92
  end
82
93
  end
83
94
  end
@@ -37,6 +37,12 @@ module Idml
37
37
  0
38
38
  end
39
39
 
40
+ # Cap height in font units; nil when the font tables don't
41
+ # report one (some pdfrb-loaded fonts).
42
+ def cap_height
43
+ metrics_data[:cap_height]
44
+ end
45
+
40
46
  # Returns raw advance width in font units (not scaled by size).
41
47
  def glyph_width(codepoint)
42
48
  cp = codepoint.is_a?(String) ? codepoint.each_codepoint.first : codepoint
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.6"
4
+ VERSION = "0.10.8"
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.6
4
+ version: 0.10.8
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-26 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -201,7 +201,13 @@ files:
201
201
  - TODO.pdf/136-diagonal-cell-strokes.md
202
202
  - TODO.pdf/137-simple-render-footnote-text.md
203
203
  - TODO.pdf/138-text-wrap-sides.md
204
+ - TODO.pdf/139-hyphen-break-opportunities.md
204
205
  - TODO.pdf/14-page-item-element-models.md
206
+ - TODO.pdf/140-cap-xheight-first-baselines.md
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
205
211
  - TODO.pdf/15-wire-spread-children.md
206
212
  - TODO.pdf/16-typed-model-pipeline.md
207
213
  - TODO.pdf/17-shape-rendering.md
@@ -345,6 +351,7 @@ files:
345
351
  - lib/idml/elements/master_spread_object.rb
346
352
  - lib/idml/elements/mixed_ink.rb
347
353
  - lib/idml/elements/mixed_ink_group.rb
354
+ - lib/idml/elements/mojikumi_table.rb
348
355
  - lib/idml/elements/object_export_option.rb
349
356
  - lib/idml/elements/object_style.rb
350
357
  - lib/idml/elements/object_style_content_effects_category_settings.rb