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 +4 -4
- data/TODO.pdf/139-hyphen-break-opportunities.md +24 -0
- data/TODO.pdf/140-cap-xheight-first-baselines.md +29 -0
- data/TODO.pdf/141-perf-hundred-pages.md +22 -0
- data/TODO.pdf/142-vertical-latin-run-groups.md +27 -0
- data/TODO.pdf/143-jump-object-below.md +31 -0
- data/TODO.pdf/144-mojikumi-table-model.md +36 -0
- data/TODO.pdf/61-known-limitations.md +21 -12
- data/lib/idml/elements/mojikumi_table.rb +85 -0
- data/lib/idml/elements.rb +4 -0
- data/lib/idml/parts/designmap.rb +3 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +139 -51
- data/lib/idml/render/text_wrap_resolver.rb +27 -2
- data/lib/idml/text_engine/line_breaker.rb +24 -13
- data/lib/idml/text_engine/pdfrb_font_metrics.rb +6 -0
- data/lib/idml/version.rb +1 -1
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e05a75aaa533dcd8f478d6b4ab635d15cf6c64ac85bebbd42fe3b136ed0c6783
|
|
4
|
+
data.tar.gz: 3a478cdb4b23ac26ceb9914d5e3740286ef6fda60c80605a01dc6f2a38086b7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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
|
|
13
|
-
parsed but unused);
|
|
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**:
|
|
22
|
-
|
|
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
|
|
29
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
-
|
|
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"
|
data/lib/idml/parts/designmap.rb
CHANGED
|
@@ -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
|
|
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
|
|
633
|
-
#
|
|
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
|
|
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
|
-
|
|
648
|
-
|
|
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.
|
|
657
|
-
|
|
658
|
-
|
|
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 :
|
|
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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
62
|
-
line_glyphs = current[0..
|
|
63
|
-
lines << Line.new(line_glyphs,
|
|
64
|
-
current = current[(
|
|
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
|
-
|
|
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
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: idml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.10.
|
|
4
|
+
version: 0.10.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-
|
|
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
|