idml 0.9.3 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/TODO.pdf/117-endnote-support.md +12 -7
- data/TODO.pdf/119-justification-spacing-limits.md +34 -0
- data/TODO.pdf/120-start-paragraph-breaks.md +26 -0
- data/TODO.pdf/121-cell-edge-strokes.md +29 -0
- data/TODO.pdf/13-cjk-text-layout.md +24 -6
- data/lib/idml/elements/cell.rb +182 -0
- data/lib/idml/elements/character_style_range.rb +3 -0
- data/lib/idml/elements/story_inner.rb +2 -0
- data/lib/idml/render/footnote.rb +6 -1
- data/lib/idml/render/renderers/table_renderer.rb +95 -4
- data/lib/idml/render/renderers/text_frame_renderer.rb +124 -4
- data/lib/idml/render/style_resolver.rb +39 -5
- data/lib/idml/text_engine/justifier.rb +43 -4
- data/lib/idml/text_engine/line_breaker.rb +16 -0
- data/lib/idml/text_engine/vertical_text_layout.rb +68 -0
- data/lib/idml/text_engine.rb +2 -0
- data/lib/idml/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 955ca35f14b9460085c65187586817bf655085d3f814660e790c7050e1abbe5f
|
|
4
|
+
data.tar.gz: 1aa96f29ecc97594128d6f88a245e46383efc99f0ff4d9f6797947cd8fe361a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 638e2df6de7b160a8f7c64703309986f32644e29ada18f9c8f611b967e80ee4e9fdcf84cc7687ecd364b6aa964bcbb5831c4c279a841dd203b412f39d710fbc1
|
|
7
|
+
data.tar.gz: 9ef18bb1487ed2d6206c2c3aef6fe4ef699a12db584c2052557343144ae3f38d32cbcc275972114b2b46b1cc660c1cb24525befcd616462c33f9ab5ec52fd6e0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# TODO PDF 117: Endnote support
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
a real fixture)
|
|
3
|
+
## Status: PARTIAL (modeling + reference markers COMPLETE
|
|
4
|
+
2026-08-18/19; endnote TEXT rendering OPEN pending a real fixture)
|
|
5
5
|
|
|
6
6
|
## Problem
|
|
7
7
|
|
|
@@ -20,14 +20,19 @@ story flagged `IsEndnoteStory="true"`. Documents with endnotes
|
|
|
20
20
|
collections). Round-trip + parse specs.
|
|
21
21
|
- `StoryInner#is_endnote_story` already parsed, so endnote stories
|
|
22
22
|
are identifiable.
|
|
23
|
+
- Reference markers render (2026-08-19): CSR-level `EndnoteRange`
|
|
24
|
+
elements (verified against CharacterStyleRange_Object in
|
|
25
|
+
Story.rnc) emit superscript marker runs numbered by a counter
|
|
26
|
+
SEPARATE from footnotes — endnoted text now shows visible numbered
|
|
27
|
+
references instead of dropping them. `StoryInner` also parses
|
|
28
|
+
`StoryPreference` (vertical writing support).
|
|
23
29
|
|
|
24
30
|
## Design for rendering (needs a real InDesign fixture to validate)
|
|
25
31
|
|
|
26
|
-
1. Marker:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
the prerequisite.
|
|
32
|
+
1. ~~Marker~~ DONE 2026-08-19: CSR-level EndnoteRange emits a
|
|
33
|
+
superscript marker run at the CSR's position (same pattern as
|
|
34
|
+
footnotes). Story-level Endnote/EndnoteRange pairs (no CSR
|
|
35
|
+
anchor) remain unplaced.
|
|
31
36
|
2. Endnote text: resolve `EndnoteTextRange` → the endnote story
|
|
32
37
|
(IsEndnoteStory) → its paragraphs.
|
|
33
38
|
3. Placement: per EndnoteOption Scope — end of story / end of
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# TODO PDF 119: Justification word/letter spacing limits
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-19
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
`TextEngine::Justifier` stretched inter-word spaces without limit
|
|
8
|
+
under full justification — a line with one space could widen that
|
|
9
|
+
gap across the whole column. IDML paragraphs declare
|
|
10
|
+
MaximumWordSpacing / MaximumLetterSpacing (percentages,
|
|
11
|
+
InDesign defaults 133% word, 0% letter) that cap how justification
|
|
12
|
+
slack is distributed.
|
|
13
|
+
|
|
14
|
+
## What was done
|
|
15
|
+
|
|
16
|
+
- `Justifier::SpacingLimits` (max_word_spacing, max_letter_spacing
|
|
17
|
+
percentages). `Justifier.justify` takes `limits:`.
|
|
18
|
+
- Distribution: spaces stretch up to the word-spacing cap
|
|
19
|
+
(capacity = Σ natural_space_width × (max−100)/100); any residual
|
|
20
|
+
slack goes to letter spacing — an even per-glyph addition capped
|
|
21
|
+
at max_letter_spacing% of the natural space width. With
|
|
22
|
+
InDesign's default letter cap of 0%, residual slack stays
|
|
23
|
+
unfilled and the line ends short, matching InDesign's behavior
|
|
24
|
+
when limits bind.
|
|
25
|
+
- `StyleResolver::Paragraph` carries maximum_word_spacing /
|
|
26
|
+
maximum_letter_spacing from the PSR; the frame renderer and the
|
|
27
|
+
footnote layout both pass the limits.
|
|
28
|
+
|
|
29
|
+
## Known limitations
|
|
30
|
+
|
|
31
|
+
- Glyph scaling (Minimum/Maximum/DesiredGlyphScaling) is not
|
|
32
|
+
applied as a last resort.
|
|
33
|
+
- Minimum word/letter spacing (line compression) is unused — this
|
|
34
|
+
justifier only ever stretches (slack ≥ 0).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# TODO PDF 120: StartParagraph frame/column breaks
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-19
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
PSR `StartParagraph` (Anywhere / NextPage / NextColumn / NextFrame
|
|
8
|
+
/ NextOddPage / NextEvenPage) was parsed but ignored — forced-break
|
|
9
|
+
paragraphs (chapter headings, magazine copy) rendered mid-frame.
|
|
10
|
+
|
|
11
|
+
## What was done
|
|
12
|
+
|
|
13
|
+
- `StyleResolver::Paragraph` carries `start_paragraph` from the
|
|
14
|
+
PSR.
|
|
15
|
+
- `TextFrameRenderer.consume_paragraphs` stops filling the current
|
|
16
|
+
frame/column before a paragraph that requests a break (NextPage /
|
|
17
|
+
NextColumn / NextFrame / NextOddPage / NextEvenPage) — as long as
|
|
18
|
+
at least one paragraph was already placed there; the paragraph
|
|
19
|
+
and everything after it flow to the next frame via the existing
|
|
20
|
+
chain state. Multi-column frames break per column.
|
|
21
|
+
|
|
22
|
+
## Known limitations
|
|
23
|
+
|
|
24
|
+
- All break flavors act at frame/column granularity (NextPage and
|
|
25
|
+
NextFrame are indistinguishable; odd/even page parity is not
|
|
26
|
+
modeled — the renderer has no page-parity notion at this layer).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# TODO PDF 121: Per-side cell edge strokes
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-19
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
`TableRenderer` stroked every cell as a uniform rectangle with the
|
|
8
|
+
canvas default line width. IDML cells declare per-side edge stroke
|
|
9
|
+
weights (TopEdgeStrokeWeight, LeftEdgeStrokeWeight,
|
|
10
|
+
BottomEdgeStrokeWeight, RightEdgeStrokeWeight — with the matching
|
|
11
|
+
stroke colors/tints), and the table declares fallback defaults
|
|
12
|
+
(DefaultRowStrokeWeight / DefaultColumnStrokeWeight).
|
|
13
|
+
|
|
14
|
+
## What was done
|
|
15
|
+
|
|
16
|
+
- Cell_Object schema completion: `Elements::Cell` declares all 95
|
|
17
|
+
schema attributes (77 added — per-side edge strokes + diagonal
|
|
18
|
+
lines + cell style overrides), generated in schema order like the
|
|
19
|
+
Table completion (TODO 104).
|
|
20
|
+
- `TableRenderer.render_cell_border` draws each side separately
|
|
21
|
+
with the cell's edge stroke weight, falling back to the table's
|
|
22
|
+
default row/column stroke weight, and resolves stroke colors via
|
|
23
|
+
the color resolver (black fallback). Sides with zero weight are
|
|
24
|
+
skipped, so weightless cells no longer draw hairlines.
|
|
25
|
+
|
|
26
|
+
## Known limitations
|
|
27
|
+
|
|
28
|
+
- Diagonal cell strokes (DiagonalLine*) are modeled but not drawn.
|
|
29
|
+
- Edge stroke types (dashes) fall back to solid.
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
# TODO PDF 13: CJK text layout
|
|
2
2
|
|
|
3
|
-
## Status: PARTIAL —
|
|
4
|
-
CJK glyph measurement,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
## Status: PARTIAL — vertical writing mode implemented 2026-08-19
|
|
4
|
+
(horizontal-mode CJK was complete 2026-08-17: glyph measurement,
|
|
5
|
+
kinsoku shori, ruby annotations).
|
|
6
|
+
|
|
7
|
+
## Vertical mode (2026-08-19)
|
|
8
|
+
|
|
9
|
+
- `StoryInner` now parses `StoryPreference` (StoryOrientation).
|
|
10
|
+
- `TextEngine::VerticalTextLayout` stacks glyphs UPRIGHT in
|
|
11
|
+
columns flowing right-to-left (CJK ideographs render correctly;
|
|
12
|
+
Latin glyphs are not rotated — documented approximation), reusing
|
|
13
|
+
LineBreaker (so kinsoku applies) against the column height.
|
|
14
|
+
- `TextFrameRenderer` takes the vertical path when the story
|
|
15
|
+
declares Vertical: paragraphs flow as glyph columns, runs start
|
|
16
|
+
fresh columns, overflow chains to the next frame. Paragraph
|
|
17
|
+
spacing/rules/decorations and ruby repositioning are not applied
|
|
18
|
+
in vertical mode.
|
|
19
|
+
- LineBreaker now breaks unspaced CJK runs PER CHARACTER at the
|
|
20
|
+
frame width (previously they overflowed as one long line) — this
|
|
21
|
+
also fixes horizontal CJK wrapping.
|
|
22
|
+
|
|
23
|
+
Remaining stretch goals: tate-chu-yoko, mojikumi, Latin rotation in
|
|
24
|
+
vertical mode, vertical ruby placement. See
|
|
25
|
+
`lib/idml/text_engine/cjk_layout.rb` and
|
|
26
|
+
`lib/idml/text_engine/vertical_text_layout.rb`.
|
|
9
27
|
|
|
10
28
|
## Progress 2026-08-17
|
|
11
29
|
|
data/lib/idml/elements/cell.rb
CHANGED
|
@@ -38,6 +38,88 @@ module Idml
|
|
|
38
38
|
attribute :paragraph_style_range, Idml::Elements::ParagraphStyleRange,
|
|
39
39
|
collection: true
|
|
40
40
|
|
|
41
|
+
# The remaining Cell_Object attributes (per-side edge strokes,
|
|
42
|
+
# diagonal lines, cell style overrides, …) in schema order —
|
|
43
|
+
# generated by `scripts/rnc_to_lutaml.rb` from
|
|
44
|
+
# reference-docs/schemas/package/Stories/Story.rnc.
|
|
45
|
+
attribute :top_inset, :float
|
|
46
|
+
attribute :left_inset, :float
|
|
47
|
+
attribute :bottom_inset, :float
|
|
48
|
+
attribute :right_inset, :float
|
|
49
|
+
attribute :top_left_diagonal_line, :boolean
|
|
50
|
+
attribute :top_right_diagonal_line, :boolean
|
|
51
|
+
attribute :diagonal_line_in_front, :boolean
|
|
52
|
+
attribute :diagonal_line_stroke_weight, :float
|
|
53
|
+
attribute :diagonal_line_stroke_type, :string
|
|
54
|
+
attribute :diagonal_line_stroke_color, :string
|
|
55
|
+
attribute :diagonal_line_stroke_tint, :float
|
|
56
|
+
attribute :diagonal_line_stroke_overprint, :boolean
|
|
57
|
+
attribute :diagonal_line_stroke_gap_color, :string
|
|
58
|
+
attribute :diagonal_line_stroke_gap_tint, :float
|
|
59
|
+
attribute :diagonal_line_stroke_gap_overprint, :boolean
|
|
60
|
+
attribute :clip_content_to_cell, :boolean
|
|
61
|
+
attribute :first_baseline_offset, :string
|
|
62
|
+
attribute :minimum_first_baseline_offset, :float
|
|
63
|
+
attribute :left_edge_stroke_weight, :float
|
|
64
|
+
attribute :left_edge_stroke_type, :string
|
|
65
|
+
attribute :left_edge_stroke_color, :string
|
|
66
|
+
attribute :left_edge_stroke_tint, :float
|
|
67
|
+
attribute :left_edge_stroke_overprint, :boolean
|
|
68
|
+
attribute :left_edge_stroke_gap_color, :string
|
|
69
|
+
attribute :left_edge_stroke_gap_tint, :float
|
|
70
|
+
attribute :left_edge_stroke_gap_overprint, :boolean
|
|
71
|
+
attribute :top_edge_stroke_weight, :float
|
|
72
|
+
attribute :top_edge_stroke_type, :string
|
|
73
|
+
attribute :top_edge_stroke_color, :string
|
|
74
|
+
attribute :top_edge_stroke_tint, :float
|
|
75
|
+
attribute :top_edge_stroke_overprint, :boolean
|
|
76
|
+
attribute :top_edge_stroke_gap_color, :string
|
|
77
|
+
attribute :top_edge_stroke_gap_tint, :float
|
|
78
|
+
attribute :top_edge_stroke_gap_overprint, :boolean
|
|
79
|
+
attribute :right_edge_stroke_weight, :float
|
|
80
|
+
attribute :right_edge_stroke_type, :string
|
|
81
|
+
attribute :right_edge_stroke_color, :string
|
|
82
|
+
attribute :right_edge_stroke_tint, :float
|
|
83
|
+
attribute :right_edge_stroke_overprint, :boolean
|
|
84
|
+
attribute :right_edge_stroke_gap_color, :string
|
|
85
|
+
attribute :right_edge_stroke_gap_tint, :float
|
|
86
|
+
attribute :right_edge_stroke_gap_overprint, :boolean
|
|
87
|
+
attribute :bottom_edge_stroke_weight, :float
|
|
88
|
+
attribute :bottom_edge_stroke_type, :string
|
|
89
|
+
attribute :bottom_edge_stroke_color, :string
|
|
90
|
+
attribute :bottom_edge_stroke_tint, :float
|
|
91
|
+
attribute :bottom_edge_stroke_overprint, :boolean
|
|
92
|
+
attribute :bottom_edge_stroke_gap_color, :string
|
|
93
|
+
attribute :bottom_edge_stroke_gap_tint, :float
|
|
94
|
+
attribute :bottom_edge_stroke_gap_overprint, :boolean
|
|
95
|
+
attribute :inner_row_stroke_weight, :float
|
|
96
|
+
attribute :inner_row_stroke_type, :string
|
|
97
|
+
attribute :inner_row_stroke_color, :string
|
|
98
|
+
attribute :inner_row_stroke_tint, :float
|
|
99
|
+
attribute :inner_row_stroke_overprint, :boolean
|
|
100
|
+
attribute :inner_row_stroke_gap_color, :string
|
|
101
|
+
attribute :inner_row_stroke_gap_tint, :float
|
|
102
|
+
attribute :inner_row_stroke_gap_overprint, :boolean
|
|
103
|
+
attribute :inner_column_stroke_weight, :float
|
|
104
|
+
attribute :inner_column_stroke_type, :string
|
|
105
|
+
attribute :inner_column_stroke_color, :string
|
|
106
|
+
attribute :inner_column_stroke_tint, :float
|
|
107
|
+
attribute :inner_column_stroke_overprint, :boolean
|
|
108
|
+
attribute :inner_column_stroke_gap_color, :string
|
|
109
|
+
attribute :inner_column_stroke_gap_tint, :float
|
|
110
|
+
attribute :inner_column_stroke_gap_overprint, :boolean
|
|
111
|
+
attribute :top_edge_stroke_priority, :integer
|
|
112
|
+
attribute :left_edge_stroke_priority, :integer
|
|
113
|
+
attribute :bottom_edge_stroke_priority, :integer
|
|
114
|
+
attribute :right_edge_stroke_priority, :integer
|
|
115
|
+
attribute :graphic_left_inset, :float
|
|
116
|
+
attribute :graphic_top_inset, :float
|
|
117
|
+
attribute :graphic_right_inset, :float
|
|
118
|
+
attribute :graphic_bottom_inset, :float
|
|
119
|
+
attribute :clip_content_to_graphic_cell, :boolean
|
|
120
|
+
attribute :writing_direction, :boolean
|
|
121
|
+
attribute :applied_cell_style_priority, :integer
|
|
122
|
+
|
|
41
123
|
xml do
|
|
42
124
|
root "Cell"
|
|
43
125
|
map_attribute "Self", to: :self_attr
|
|
@@ -58,6 +140,106 @@ module Idml
|
|
|
58
140
|
map_attribute "ParagraphSpacingLimit", to: :paragraph_spacing_limit
|
|
59
141
|
map_attribute "RotationAngle", to: :rotation_angle
|
|
60
142
|
map_attribute "AppliedCellStyle", to: :applied_cell_style
|
|
143
|
+
map_attribute "TopInset", to: :top_inset
|
|
144
|
+
map_attribute "LeftInset", to: :left_inset
|
|
145
|
+
map_attribute "BottomInset", to: :bottom_inset
|
|
146
|
+
map_attribute "RightInset", to: :right_inset
|
|
147
|
+
map_attribute "TopLeftDiagonalLine", to: :top_left_diagonal_line
|
|
148
|
+
map_attribute "TopRightDiagonalLine", to: :top_right_diagonal_line
|
|
149
|
+
map_attribute "DiagonalLineInFront", to: :diagonal_line_in_front
|
|
150
|
+
map_attribute "DiagonalLineStrokeWeight",
|
|
151
|
+
to: :diagonal_line_stroke_weight
|
|
152
|
+
map_attribute "DiagonalLineStrokeType", to: :diagonal_line_stroke_type
|
|
153
|
+
map_attribute "DiagonalLineStrokeColor", to: :diagonal_line_stroke_color
|
|
154
|
+
map_attribute "DiagonalLineStrokeTint", to: :diagonal_line_stroke_tint
|
|
155
|
+
map_attribute "DiagonalLineStrokeOverprint",
|
|
156
|
+
to: :diagonal_line_stroke_overprint
|
|
157
|
+
map_attribute "DiagonalLineStrokeGapColor",
|
|
158
|
+
to: :diagonal_line_stroke_gap_color
|
|
159
|
+
map_attribute "DiagonalLineStrokeGapTint",
|
|
160
|
+
to: :diagonal_line_stroke_gap_tint
|
|
161
|
+
map_attribute "DiagonalLineStrokeGapOverprint",
|
|
162
|
+
to: :diagonal_line_stroke_gap_overprint
|
|
163
|
+
map_attribute "ClipContentToCell", to: :clip_content_to_cell
|
|
164
|
+
map_attribute "FirstBaselineOffset", to: :first_baseline_offset
|
|
165
|
+
map_attribute "MinimumFirstBaselineOffset",
|
|
166
|
+
to: :minimum_first_baseline_offset
|
|
167
|
+
map_attribute "LeftEdgeStrokeWeight", to: :left_edge_stroke_weight
|
|
168
|
+
map_attribute "LeftEdgeStrokeType", to: :left_edge_stroke_type
|
|
169
|
+
map_attribute "LeftEdgeStrokeColor", to: :left_edge_stroke_color
|
|
170
|
+
map_attribute "LeftEdgeStrokeTint", to: :left_edge_stroke_tint
|
|
171
|
+
map_attribute "LeftEdgeStrokeOverprint", to: :left_edge_stroke_overprint
|
|
172
|
+
map_attribute "LeftEdgeStrokeGapColor", to: :left_edge_stroke_gap_color
|
|
173
|
+
map_attribute "LeftEdgeStrokeGapTint", to: :left_edge_stroke_gap_tint
|
|
174
|
+
map_attribute "LeftEdgeStrokeGapOverprint",
|
|
175
|
+
to: :left_edge_stroke_gap_overprint
|
|
176
|
+
map_attribute "TopEdgeStrokeWeight", to: :top_edge_stroke_weight
|
|
177
|
+
map_attribute "TopEdgeStrokeType", to: :top_edge_stroke_type
|
|
178
|
+
map_attribute "TopEdgeStrokeColor", to: :top_edge_stroke_color
|
|
179
|
+
map_attribute "TopEdgeStrokeTint", to: :top_edge_stroke_tint
|
|
180
|
+
map_attribute "TopEdgeStrokeOverprint", to: :top_edge_stroke_overprint
|
|
181
|
+
map_attribute "TopEdgeStrokeGapColor", to: :top_edge_stroke_gap_color
|
|
182
|
+
map_attribute "TopEdgeStrokeGapTint", to: :top_edge_stroke_gap_tint
|
|
183
|
+
map_attribute "TopEdgeStrokeGapOverprint",
|
|
184
|
+
to: :top_edge_stroke_gap_overprint
|
|
185
|
+
map_attribute "RightEdgeStrokeWeight", to: :right_edge_stroke_weight
|
|
186
|
+
map_attribute "RightEdgeStrokeType", to: :right_edge_stroke_type
|
|
187
|
+
map_attribute "RightEdgeStrokeColor", to: :right_edge_stroke_color
|
|
188
|
+
map_attribute "RightEdgeStrokeTint", to: :right_edge_stroke_tint
|
|
189
|
+
map_attribute "RightEdgeStrokeOverprint",
|
|
190
|
+
to: :right_edge_stroke_overprint
|
|
191
|
+
map_attribute "RightEdgeStrokeGapColor",
|
|
192
|
+
to: :right_edge_stroke_gap_color
|
|
193
|
+
map_attribute "RightEdgeStrokeGapTint", to: :right_edge_stroke_gap_tint
|
|
194
|
+
map_attribute "RightEdgeStrokeGapOverprint",
|
|
195
|
+
to: :right_edge_stroke_gap_overprint
|
|
196
|
+
map_attribute "BottomEdgeStrokeWeight", to: :bottom_edge_stroke_weight
|
|
197
|
+
map_attribute "BottomEdgeStrokeType", to: :bottom_edge_stroke_type
|
|
198
|
+
map_attribute "BottomEdgeStrokeColor", to: :bottom_edge_stroke_color
|
|
199
|
+
map_attribute "BottomEdgeStrokeTint", to: :bottom_edge_stroke_tint
|
|
200
|
+
map_attribute "BottomEdgeStrokeOverprint",
|
|
201
|
+
to: :bottom_edge_stroke_overprint
|
|
202
|
+
map_attribute "BottomEdgeStrokeGapColor",
|
|
203
|
+
to: :bottom_edge_stroke_gap_color
|
|
204
|
+
map_attribute "BottomEdgeStrokeGapTint",
|
|
205
|
+
to: :bottom_edge_stroke_gap_tint
|
|
206
|
+
map_attribute "BottomEdgeStrokeGapOverprint",
|
|
207
|
+
to: :bottom_edge_stroke_gap_overprint
|
|
208
|
+
map_attribute "InnerRowStrokeWeight", to: :inner_row_stroke_weight
|
|
209
|
+
map_attribute "InnerRowStrokeType", to: :inner_row_stroke_type
|
|
210
|
+
map_attribute "InnerRowStrokeColor", to: :inner_row_stroke_color
|
|
211
|
+
map_attribute "InnerRowStrokeTint", to: :inner_row_stroke_tint
|
|
212
|
+
map_attribute "InnerRowStrokeOverprint", to: :inner_row_stroke_overprint
|
|
213
|
+
map_attribute "InnerRowStrokeGapColor", to: :inner_row_stroke_gap_color
|
|
214
|
+
map_attribute "InnerRowStrokeGapTint", to: :inner_row_stroke_gap_tint
|
|
215
|
+
map_attribute "InnerRowStrokeGapOverprint",
|
|
216
|
+
to: :inner_row_stroke_gap_overprint
|
|
217
|
+
map_attribute "InnerColumnStrokeWeight", to: :inner_column_stroke_weight
|
|
218
|
+
map_attribute "InnerColumnStrokeType", to: :inner_column_stroke_type
|
|
219
|
+
map_attribute "InnerColumnStrokeColor", to: :inner_column_stroke_color
|
|
220
|
+
map_attribute "InnerColumnStrokeTint", to: :inner_column_stroke_tint
|
|
221
|
+
map_attribute "InnerColumnStrokeOverprint",
|
|
222
|
+
to: :inner_column_stroke_overprint
|
|
223
|
+
map_attribute "InnerColumnStrokeGapColor",
|
|
224
|
+
to: :inner_column_stroke_gap_color
|
|
225
|
+
map_attribute "InnerColumnStrokeGapTint",
|
|
226
|
+
to: :inner_column_stroke_gap_tint
|
|
227
|
+
map_attribute "InnerColumnStrokeGapOverprint",
|
|
228
|
+
to: :inner_column_stroke_gap_overprint
|
|
229
|
+
map_attribute "TopEdgeStrokePriority", to: :top_edge_stroke_priority
|
|
230
|
+
map_attribute "LeftEdgeStrokePriority", to: :left_edge_stroke_priority
|
|
231
|
+
map_attribute "BottomEdgeStrokePriority",
|
|
232
|
+
to: :bottom_edge_stroke_priority
|
|
233
|
+
map_attribute "RightEdgeStrokePriority", to: :right_edge_stroke_priority
|
|
234
|
+
map_attribute "GraphicLeftInset", to: :graphic_left_inset
|
|
235
|
+
map_attribute "GraphicTopInset", to: :graphic_top_inset
|
|
236
|
+
map_attribute "GraphicRightInset", to: :graphic_right_inset
|
|
237
|
+
map_attribute "GraphicBottomInset", to: :graphic_bottom_inset
|
|
238
|
+
map_attribute "ClipContentToGraphicCell",
|
|
239
|
+
to: :clip_content_to_graphic_cell
|
|
240
|
+
map_attribute "WritingDirection", to: :writing_direction
|
|
241
|
+
map_attribute "AppliedCellStylePriority",
|
|
242
|
+
to: :applied_cell_style_priority
|
|
61
243
|
map_element "ParagraphStyleRange", to: :paragraph_style_range
|
|
62
244
|
end
|
|
63
245
|
|
|
@@ -302,6 +302,8 @@ module Idml
|
|
|
302
302
|
attribute :table, Idml::Elements::Table, collection: true
|
|
303
303
|
attribute :xml_element, Idml::Elements::XmlElement, collection: true
|
|
304
304
|
attribute :footnote, Idml::Elements::Footnote, collection: true
|
|
305
|
+
attribute :endnote_range, Idml::Elements::EndnoteRange,
|
|
306
|
+
collection: true
|
|
305
307
|
# Anchored page items embedded in the story flow. Their
|
|
306
308
|
# AnchoredObjectSetting (child of each item) declares how the
|
|
307
309
|
# item anchors to the surrounding text.
|
|
@@ -669,6 +671,7 @@ module Idml
|
|
|
669
671
|
map_element "Table", to: :table
|
|
670
672
|
map_element "XMLElement", to: :xml_element
|
|
671
673
|
map_element "Footnote", to: :footnote
|
|
674
|
+
map_element "EndnoteRange", to: :endnote_range
|
|
672
675
|
map_element "Rectangle", to: :rectangle
|
|
673
676
|
map_element "Oval", to: :oval
|
|
674
677
|
map_element "Polygon", to: :polygon
|
|
@@ -316,6 +316,7 @@ module Idml
|
|
|
316
316
|
attribute :endnote, Idml::Elements::Endnote, collection: true
|
|
317
317
|
attribute :endnote_range, Idml::Elements::EndnoteRange,
|
|
318
318
|
collection: true
|
|
319
|
+
attribute :story_preference, Idml::Elements::StoryPreference
|
|
319
320
|
|
|
320
321
|
xml do
|
|
321
322
|
root "Story"
|
|
@@ -683,6 +684,7 @@ module Idml
|
|
|
683
684
|
map_element "Footnote", to: :footnote
|
|
684
685
|
map_element "Endnote", to: :endnote
|
|
685
686
|
map_element "EndnoteRange", to: :endnote_range
|
|
687
|
+
map_element "StoryPreference", to: :story_preference
|
|
686
688
|
end
|
|
687
689
|
|
|
688
690
|
def text_content
|
data/lib/idml/render/footnote.rb
CHANGED
|
@@ -128,11 +128,16 @@ module Idml
|
|
|
128
128
|
size: size)
|
|
129
129
|
lines = TextEngine::LineBreaker.break(glyphs: glyphs,
|
|
130
130
|
frame_width: wrap_width)
|
|
131
|
+
limits = TextEngine::Justifier::SpacingLimits.new(
|
|
132
|
+
max_word_spacing: paragraph.maximum_word_spacing,
|
|
133
|
+
max_letter_spacing: paragraph.maximum_letter_spacing,
|
|
134
|
+
)
|
|
131
135
|
lines.each_with_index do |line, index|
|
|
132
136
|
TextEngine::Justifier.justify(
|
|
133
137
|
line: line, frame_width: wrap_width,
|
|
134
138
|
alignment: paragraph.alignment || :left,
|
|
135
|
-
last_line: last_line?(paragraph, run, index, lines)
|
|
139
|
+
last_line: last_line?(paragraph, run, index, lines),
|
|
140
|
+
limits: limits
|
|
136
141
|
)
|
|
137
142
|
end
|
|
138
143
|
leading = TextEngine::VerticalLayout.leading_for(
|
|
@@ -76,7 +76,8 @@ module Idml
|
|
|
76
76
|
cell_x, cell_y, cell_w, cell_h, context)
|
|
77
77
|
render_cell_background(canvas, cell, cell_x, cell_y, cell_w, cell_h,
|
|
78
78
|
context)
|
|
79
|
-
render_cell_border(canvas,
|
|
79
|
+
render_cell_border(canvas, cell, table, cell_x, cell_y,
|
|
80
|
+
cell_w, cell_h, context)
|
|
80
81
|
render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
|
|
81
82
|
end
|
|
82
83
|
end
|
|
@@ -113,12 +114,102 @@ module Idml
|
|
|
113
114
|
end
|
|
114
115
|
private_class_method :render_cell_background
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
# Cell borders: per-side strokes when the cell or table
|
|
118
|
+
# declare edge weights (legacy single rect otherwise, so
|
|
119
|
+
# undeclared tables keep their existing look). Top/bottom
|
|
120
|
+
# fall back to the table's default row stroke, left/right
|
|
121
|
+
# to the default column stroke; zero-weight sides draw
|
|
122
|
+
# nothing.
|
|
123
|
+
def self.render_cell_border(canvas, cell, table, x, y, w, h, context)
|
|
124
|
+
sides = cell_edge_weights(cell, table)
|
|
125
|
+
if sides.nil?
|
|
126
|
+
canvas.rectangle(x, y, w, h)
|
|
127
|
+
canvas.stroke
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
top, right, bottom, left = sides
|
|
132
|
+
stroke_cell_side(canvas, context,
|
|
133
|
+
cell.top_edge_stroke_color,
|
|
134
|
+
table.default_row_stroke_color, top) do
|
|
135
|
+
canvas.move_to(x, y + h)
|
|
136
|
+
canvas.line_to(x + w, y + h)
|
|
137
|
+
end
|
|
138
|
+
stroke_cell_side(canvas, context,
|
|
139
|
+
cell.bottom_edge_stroke_color,
|
|
140
|
+
table.default_row_stroke_color, bottom) do
|
|
141
|
+
canvas.move_to(x, y)
|
|
142
|
+
canvas.line_to(x + w, y)
|
|
143
|
+
end
|
|
144
|
+
stroke_cell_side(canvas, context,
|
|
145
|
+
cell.left_edge_stroke_color,
|
|
146
|
+
table.default_column_stroke_color, left) do
|
|
147
|
+
canvas.move_to(x, y)
|
|
148
|
+
canvas.line_to(x, y + h)
|
|
149
|
+
end
|
|
150
|
+
stroke_cell_side(canvas, context,
|
|
151
|
+
cell.right_edge_stroke_color,
|
|
152
|
+
table.default_column_stroke_color, right) do
|
|
153
|
+
canvas.move_to(x + w, y)
|
|
154
|
+
canvas.line_to(x + w, y + h)
|
|
155
|
+
end
|
|
119
156
|
end
|
|
120
157
|
private_class_method :render_cell_border
|
|
121
158
|
|
|
159
|
+
# [top, right, bottom, left] effective weights, or nil when
|
|
160
|
+
# neither cell edges nor table defaults declare any stroke
|
|
161
|
+
# (the legacy uniform-rect case).
|
|
162
|
+
def self.cell_edge_weights(cell, table)
|
|
163
|
+
return nil unless stroke_declared?(cell, table)
|
|
164
|
+
|
|
165
|
+
[
|
|
166
|
+
side_weight(cell.top_edge_stroke_weight,
|
|
167
|
+
table.default_row_stroke_weight),
|
|
168
|
+
side_weight(cell.right_edge_stroke_weight,
|
|
169
|
+
table.default_column_stroke_weight),
|
|
170
|
+
side_weight(cell.bottom_edge_stroke_weight,
|
|
171
|
+
table.default_row_stroke_weight),
|
|
172
|
+
side_weight(cell.left_edge_stroke_weight,
|
|
173
|
+
table.default_column_stroke_weight),
|
|
174
|
+
]
|
|
175
|
+
end
|
|
176
|
+
private_class_method :cell_edge_weights
|
|
177
|
+
|
|
178
|
+
def self.stroke_declared?(cell, table)
|
|
179
|
+
[
|
|
180
|
+
cell.top_edge_stroke_weight, cell.right_edge_stroke_weight,
|
|
181
|
+
cell.bottom_edge_stroke_weight, cell.left_edge_stroke_weight,
|
|
182
|
+
table.default_row_stroke_weight,
|
|
183
|
+
table.default_column_stroke_weight
|
|
184
|
+
].compact.any?
|
|
185
|
+
end
|
|
186
|
+
private_class_method :stroke_declared?
|
|
187
|
+
|
|
188
|
+
def self.side_weight(cell_weight, default_weight)
|
|
189
|
+
cell_weight || default_weight || 0
|
|
190
|
+
end
|
|
191
|
+
private_class_method :side_weight
|
|
192
|
+
|
|
193
|
+
def self.stroke_cell_side(canvas, context, cell_color,
|
|
194
|
+
table_color, weight)
|
|
195
|
+
thickness = weight.to_f
|
|
196
|
+
return unless thickness.positive?
|
|
197
|
+
|
|
198
|
+
apply_cell_stroke_color(canvas, context, cell_color, table_color)
|
|
199
|
+
canvas.line_width = thickness
|
|
200
|
+
yield
|
|
201
|
+
canvas.stroke
|
|
202
|
+
end
|
|
203
|
+
private_class_method :stroke_cell_side
|
|
204
|
+
|
|
205
|
+
def self.apply_cell_stroke_color(canvas, context, cell_color,
|
|
206
|
+
table_color)
|
|
207
|
+
name = cell_color || table_color
|
|
208
|
+
color = name && context.color_resolver&.resolve(name)
|
|
209
|
+
canvas.stroke_color(color ? ColorHelper.to_canvas(color) : [:gray, 1.0])
|
|
210
|
+
end
|
|
211
|
+
private_class_method :apply_cell_stroke_color
|
|
212
|
+
|
|
122
213
|
def self.cell_fill_color(cell, context)
|
|
123
214
|
return nil unless cell.fill_color
|
|
124
215
|
return nil if cell.fill_color == "Color/None"
|
|
@@ -41,7 +41,8 @@ module Idml
|
|
|
41
41
|
state = initial_chain_state(frame, story, context)
|
|
42
42
|
return unless state
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
vertical = vertical_story?(story)
|
|
45
|
+
render_text(canvas, state, context, box, vertical: vertical)
|
|
45
46
|
store_chain_state(frame, state, context)
|
|
46
47
|
end
|
|
47
48
|
|
|
@@ -146,9 +147,11 @@ module Idml
|
|
|
146
147
|
end
|
|
147
148
|
private_class_method :context_for_item
|
|
148
149
|
|
|
149
|
-
def self.render_text(canvas, state, context, box)
|
|
150
|
+
def self.render_text(canvas, state, context, box, vertical: false)
|
|
150
151
|
font = context.font_metrics
|
|
151
|
-
if font
|
|
152
|
+
if font && vertical
|
|
153
|
+
vertical_render(canvas, state, context, box, font)
|
|
154
|
+
elsif font
|
|
152
155
|
engine_render(canvas, state, context, box, font)
|
|
153
156
|
else
|
|
154
157
|
simple_render(canvas, state, context, box)
|
|
@@ -156,6 +159,108 @@ module Idml
|
|
|
156
159
|
end
|
|
157
160
|
private_class_method :render_text
|
|
158
161
|
|
|
162
|
+
# True when the story's StoryPreference declares vertical
|
|
163
|
+
# writing (StoryOrientation="Vertical").
|
|
164
|
+
def self.vertical_story?(story)
|
|
165
|
+
story.inner&.story_preference&.story_orientation == "Vertical"
|
|
166
|
+
end
|
|
167
|
+
private_class_method :vertical_story?
|
|
168
|
+
|
|
169
|
+
# Vertical writing path (StoryOrientation="Vertical", CJK):
|
|
170
|
+
# paragraphs flow as upright glyph columns advancing
|
|
171
|
+
# right-to-left; each run starts a fresh column (run-mixing
|
|
172
|
+
# within a column is not modeled). Runs whose columns would
|
|
173
|
+
# cross the frame's left inset overflow to the next frame in
|
|
174
|
+
# the chain. Paragraph spacing, rules, and decorations are
|
|
175
|
+
# not applied in vertical mode.
|
|
176
|
+
def self.vertical_render(canvas, state, context, box, font)
|
|
177
|
+
layout_frame = layout_frame(context.item, box)
|
|
178
|
+
left_limit = layout_frame.x + (layout_frame.inset_left || 0)
|
|
179
|
+
resume_vertical_paragraph(state)
|
|
180
|
+
pending = 0
|
|
181
|
+
|
|
182
|
+
state.paragraphs.each do |paragraph|
|
|
183
|
+
remaining_runs = vertical_paragraph(
|
|
184
|
+
canvas, paragraph, context, layout_frame, font, left_limit
|
|
185
|
+
)
|
|
186
|
+
if remaining_runs.empty?
|
|
187
|
+
pending += 1
|
|
188
|
+
else
|
|
189
|
+
state.current_paragraph = paragraph
|
|
190
|
+
state.runs_remaining = remaining_runs
|
|
191
|
+
break
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
state.paragraphs = state.paragraphs[pending..]
|
|
196
|
+
end
|
|
197
|
+
private_class_method :vertical_render
|
|
198
|
+
|
|
199
|
+
# Folds a chain-resumed paragraph (runs_remaining) back to
|
|
200
|
+
# the head of the paragraph list so the vertical path sees a
|
|
201
|
+
# uniform sequence.
|
|
202
|
+
def self.resume_vertical_paragraph(state)
|
|
203
|
+
return unless state.current_paragraph
|
|
204
|
+
|
|
205
|
+
state.current_paragraph.runs = state.runs_remaining
|
|
206
|
+
state.paragraphs.unshift(state.current_paragraph)
|
|
207
|
+
state.current_paragraph = nil
|
|
208
|
+
state.runs_remaining = []
|
|
209
|
+
end
|
|
210
|
+
private_class_method :resume_vertical_paragraph
|
|
211
|
+
|
|
212
|
+
# Renders one paragraph's runs as vertical columns. Returns
|
|
213
|
+
# the runs that did not fit (empty when all placed).
|
|
214
|
+
def self.vertical_paragraph(canvas, paragraph, context,
|
|
215
|
+
layout_frame, font, left_limit)
|
|
216
|
+
paragraph.runs.each_with_index do |run, index|
|
|
217
|
+
size = run.point_size || DEFAULT_SIZE
|
|
218
|
+
leading = TextEngine::VerticalLayout.leading_for(
|
|
219
|
+
paragraph.auto_leading, size
|
|
220
|
+
)
|
|
221
|
+
glyphs = TextEngine::Shaper.shape(text: run.text, font: font,
|
|
222
|
+
size: size)
|
|
223
|
+
positioned, columns = TextEngine::VerticalTextLayout.layout(
|
|
224
|
+
glyphs: glyphs, frame: layout_frame, leading: leading,
|
|
225
|
+
size: size
|
|
226
|
+
)
|
|
227
|
+
unless vertical_fits?(layout_frame, leading, columns,
|
|
228
|
+
left_limit)
|
|
229
|
+
return paragraph.runs[index..]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
positioned.each do |glyph|
|
|
233
|
+
emit_vertical_glyph(canvas, glyph, run, context, size)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
[]
|
|
237
|
+
end
|
|
238
|
+
private_class_method :vertical_paragraph
|
|
239
|
+
|
|
240
|
+
def self.vertical_fits?(layout_frame, leading, columns, left_limit)
|
|
241
|
+
right = layout_frame.x + layout_frame.width -
|
|
242
|
+
(layout_frame.inset_right || 0)
|
|
243
|
+
(right - (columns * leading)) >= left_limit
|
|
244
|
+
end
|
|
245
|
+
private_class_method :vertical_fits?
|
|
246
|
+
|
|
247
|
+
# Emits one upright glyph at its vertical position with the
|
|
248
|
+
# run's character styling.
|
|
249
|
+
def self.emit_vertical_glyph(canvas, positioned, run, context,
|
|
250
|
+
size)
|
|
251
|
+
text = [positioned.codepoint].pack("U")
|
|
252
|
+
text_kwargs = CharacterStyle.text_kwargs(
|
|
253
|
+
run,
|
|
254
|
+
{
|
|
255
|
+
at: [positioned.x, positioned.y],
|
|
256
|
+
font: font_for_run(run, context),
|
|
257
|
+
size: size,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
canvas.text(text, **text_kwargs)
|
|
261
|
+
end
|
|
262
|
+
private_class_method :emit_vertical_glyph
|
|
263
|
+
|
|
159
264
|
def self.chain_head?(frame)
|
|
160
265
|
frame.previous_text_frame.nil? || frame.previous_text_frame == "n"
|
|
161
266
|
end
|
|
@@ -410,6 +515,7 @@ module Idml
|
|
|
410
515
|
pending = 0
|
|
411
516
|
state.paragraphs.each do |paragraph|
|
|
412
517
|
break if cursor_y < bottom_limit
|
|
518
|
+
break if paragraph_break?(paragraph) && pending.positive?
|
|
413
519
|
|
|
414
520
|
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
415
521
|
canvas, paragraph, paragraph.runs, context,
|
|
@@ -741,16 +847,30 @@ module Idml
|
|
|
741
847
|
end
|
|
742
848
|
private_class_method :layout_run
|
|
743
849
|
|
|
850
|
+
# True when the paragraph requests a forced break to the
|
|
851
|
+
# next frame/column (StartParagraph). All break flavors act
|
|
852
|
+
# at frame/column granularity.
|
|
853
|
+
def self.paragraph_break?(paragraph)
|
|
854
|
+
%w[NextPage NextColumn NextFrame NextOddPage NextEvenPage]
|
|
855
|
+
.include?(paragraph.start_paragraph)
|
|
856
|
+
end
|
|
857
|
+
private_class_method :paragraph_break?
|
|
858
|
+
|
|
744
859
|
# Justifies the run's lines; only the final line of the
|
|
745
860
|
# paragraph's final run stays ragged under full
|
|
746
861
|
# justification.
|
|
747
862
|
def self.justify_lines(lines, paragraph, wrap_width,
|
|
748
863
|
paragraph_last)
|
|
864
|
+
limits = TextEngine::Justifier::SpacingLimits.new(
|
|
865
|
+
max_word_spacing: paragraph.maximum_word_spacing,
|
|
866
|
+
max_letter_spacing: paragraph.maximum_letter_spacing,
|
|
867
|
+
)
|
|
749
868
|
lines.each_with_index do |line, index|
|
|
750
869
|
TextEngine::Justifier.justify(
|
|
751
870
|
line: line, frame_width: wrap_width,
|
|
752
871
|
alignment: paragraph.alignment || :left,
|
|
753
|
-
last_line: paragraph_last && index == lines.length - 1
|
|
872
|
+
last_line: paragraph_last && index == lines.length - 1,
|
|
873
|
+
limits: limits
|
|
754
874
|
)
|
|
755
875
|
end
|
|
756
876
|
end
|
|
@@ -64,6 +64,12 @@ module Idml
|
|
|
64
64
|
:right_indent,
|
|
65
65
|
:auto_leading,
|
|
66
66
|
:drop_cap_lines,
|
|
67
|
+
# Justification distribution caps (percent; from PSR).
|
|
68
|
+
:maximum_word_spacing,
|
|
69
|
+
:maximum_letter_spacing,
|
|
70
|
+
# Forced paragraph break (StartParagraph: NextPage /
|
|
71
|
+
# NextColumn / NextFrame / NextOddPage / NextEvenPage).
|
|
72
|
+
:start_paragraph,
|
|
67
73
|
:bullets_and_numbering_list_type,
|
|
68
74
|
:bullet_character_value,
|
|
69
75
|
:bullets_text_after,
|
|
@@ -134,8 +140,9 @@ module Idml
|
|
|
134
140
|
return [] unless story&.inner
|
|
135
141
|
|
|
136
142
|
counter = Footnote.counter_for(nil)
|
|
143
|
+
endnotes = Footnote.counter_for(nil)
|
|
137
144
|
story.inner.paragraph_style_range.flat_map do |psr|
|
|
138
|
-
csr_runs(psr, footnote_counter: counter)
|
|
145
|
+
csr_runs(psr, footnote_counter: counter, endnote_counter: endnotes)
|
|
139
146
|
end
|
|
140
147
|
end
|
|
141
148
|
|
|
@@ -164,13 +171,15 @@ module Idml
|
|
|
164
171
|
style_lookup: nil,
|
|
165
172
|
footnote_option: nil)
|
|
166
173
|
counter = Footnote.counter_for(footnote_option)
|
|
174
|
+
endnotes = Footnote.counter_for(nil)
|
|
167
175
|
container.paragraph_style_range.filter_map do |psr|
|
|
168
176
|
next unless condition_visible?(psr, condition_filter)
|
|
169
177
|
|
|
170
178
|
runs = csr_runs(psr, condition_filter: condition_filter,
|
|
171
179
|
style_lookup: style_lookup,
|
|
172
180
|
footnote_counter: counter,
|
|
173
|
-
footnote_option: footnote_option
|
|
181
|
+
footnote_option: footnote_option,
|
|
182
|
+
endnote_counter: endnotes)
|
|
174
183
|
next if runs.empty?
|
|
175
184
|
|
|
176
185
|
paragraph = Paragraph.new(
|
|
@@ -183,6 +192,14 @@ module Idml
|
|
|
183
192
|
left_indent: resolve_attr(style_lookup, psr, :left_indent),
|
|
184
193
|
right_indent: resolve_attr(style_lookup, psr, :right_indent),
|
|
185
194
|
auto_leading: resolve_attr(style_lookup, psr, :auto_leading),
|
|
195
|
+
maximum_word_spacing: resolve_attr(
|
|
196
|
+
style_lookup, psr, :maximum_word_spacing
|
|
197
|
+
),
|
|
198
|
+
maximum_letter_spacing: resolve_attr(
|
|
199
|
+
style_lookup, psr, :maximum_letter_spacing
|
|
200
|
+
),
|
|
201
|
+
start_paragraph: resolve_attr(style_lookup, psr,
|
|
202
|
+
:start_paragraph),
|
|
186
203
|
drop_cap_lines: resolve_attr(style_lookup, psr, :drop_cap_lines),
|
|
187
204
|
drop_cap_characters: resolve_attr(style_lookup, psr,
|
|
188
205
|
:drop_cap_characters),
|
|
@@ -290,15 +307,18 @@ module Idml
|
|
|
290
307
|
private_class_method :properties_color
|
|
291
308
|
|
|
292
309
|
def self.csr_runs(psr, condition_filter: nil, style_lookup: nil,
|
|
293
|
-
footnote_counter: nil, footnote_option: nil
|
|
310
|
+
footnote_counter: nil, footnote_option: nil,
|
|
311
|
+
endnote_counter: nil)
|
|
294
312
|
counter = footnote_counter || Footnote.counter_for(footnote_option)
|
|
313
|
+
endnotes = endnote_counter || Footnote.counter_for(nil)
|
|
295
314
|
psr.character_style_range.flat_map do |csr|
|
|
296
315
|
next [] unless condition_visible?(csr, condition_filter)
|
|
297
316
|
|
|
298
317
|
csr_content_runs(csr, condition_filter: condition_filter,
|
|
299
318
|
style_lookup: style_lookup,
|
|
300
319
|
footnote_counter: counter,
|
|
301
|
-
footnote_option: footnote_option
|
|
320
|
+
footnote_option: footnote_option,
|
|
321
|
+
endnote_counter: endnotes)
|
|
302
322
|
end
|
|
303
323
|
end
|
|
304
324
|
|
|
@@ -306,7 +326,8 @@ module Idml
|
|
|
306
326
|
# plus one superscript marker run per anchored Footnote. A CSR
|
|
307
327
|
# holding only a footnote still emits its marker.
|
|
308
328
|
def self.csr_content_runs(csr, condition_filter:, style_lookup:,
|
|
309
|
-
footnote_counter:, footnote_option
|
|
329
|
+
footnote_counter:, footnote_option:,
|
|
330
|
+
endnote_counter:)
|
|
310
331
|
text = csr.text_content
|
|
311
332
|
runs = []
|
|
312
333
|
unless text.nil? || text.empty?
|
|
@@ -322,8 +343,21 @@ module Idml
|
|
|
322
343
|
runs << Footnote.marker_run(number, runs.last, paragraphs,
|
|
323
344
|
footnote_option)
|
|
324
345
|
end
|
|
346
|
+
runs.concat(endnote_marker_runs(csr, endnote_counter, runs.last))
|
|
325
347
|
runs
|
|
326
348
|
end
|
|
349
|
+
|
|
350
|
+
# Endnote reference markers (CSR > EndnoteRange): superscript,
|
|
351
|
+
# numbered by a counter SEPARATE from footnotes. The endnote
|
|
352
|
+
# text lives in another story (IsEndnoteStory) and is not yet
|
|
353
|
+
# rendered — see TODO 117.
|
|
354
|
+
def self.endnote_marker_runs(csr, endnote_counter, base_run)
|
|
355
|
+
csr.endnote_range.map do |_range|
|
|
356
|
+
Footnote.marker_run(endnote_counter.next_number, base_run,
|
|
357
|
+
nil, nil)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
private_class_method :endnote_marker_runs
|
|
327
361
|
private_class_method :csr_content_runs
|
|
328
362
|
|
|
329
363
|
def self.text_run(csr, style_lookup:)
|
|
@@ -7,15 +7,25 @@ module Idml
|
|
|
7
7
|
# spaces for justified text. `last_line:` (the paragraph's final
|
|
8
8
|
# line) keeps ragged right under :justified, as InDesign does.
|
|
9
9
|
class Justifier
|
|
10
|
+
# Justification distribution caps, in percent (100 = natural).
|
|
11
|
+
# InDesign defaults: word spacing max 133%, letter spacing 0%.
|
|
12
|
+
SpacingLimits = Struct.new(
|
|
13
|
+
:max_word_spacing, :max_letter_spacing, keyword_init: true
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT_MAX_WORD_SPACING = 133.0
|
|
17
|
+
DEFAULT_MAX_LETTER_SPACING = 0.0
|
|
18
|
+
|
|
10
19
|
def self.justify(line:, frame_width:, alignment: :left,
|
|
11
|
-
last_line: false)
|
|
12
|
-
new(frame_width, alignment, last_line).justify(line)
|
|
20
|
+
last_line: false, limits: nil)
|
|
21
|
+
new(frame_width, alignment, last_line, limits).justify(line)
|
|
13
22
|
end
|
|
14
23
|
|
|
15
|
-
def initialize(frame_width, alignment, last_line)
|
|
24
|
+
def initialize(frame_width, alignment, last_line, limits)
|
|
16
25
|
@frame_width = frame_width
|
|
17
26
|
@alignment = alignment
|
|
18
27
|
@last_line = last_line
|
|
28
|
+
@limits = limits
|
|
19
29
|
end
|
|
20
30
|
|
|
21
31
|
def justify(line)
|
|
@@ -45,13 +55,42 @@ module Idml
|
|
|
45
55
|
slack = @frame_width - line.width
|
|
46
56
|
return if spaces.zero? || slack <= 0
|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
word_share = [slack, word_capacity(line, spaces)].min
|
|
59
|
+
extra = word_share / spaces
|
|
49
60
|
line.glyphs.each do |glyph|
|
|
50
61
|
next unless glyph.is_space
|
|
51
62
|
|
|
52
63
|
glyph.width += extra
|
|
53
64
|
end
|
|
65
|
+
residual = slack - word_share
|
|
66
|
+
distribute_letter_spacing(line, residual) if residual.positive?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Total room the word spaces may grow within the max-word-
|
|
70
|
+
# spacing cap (percent of each space's natural width).
|
|
71
|
+
def word_capacity(line, _spaces)
|
|
72
|
+
max_pct = @limits&.max_word_spacing || DEFAULT_MAX_WORD_SPACING
|
|
73
|
+
grow = [(max_pct - 100) / 100.0, 0].max
|
|
74
|
+
line.glyphs.sum { |g| g.is_space ? g.width * grow : 0.0 } * 1.0
|
|
75
|
+
end
|
|
76
|
+
private :word_capacity
|
|
77
|
+
|
|
78
|
+
# Even per-glyph letter spacing for residual slack, capped at
|
|
79
|
+
# max_letter_spacing percent of the natural space width. With
|
|
80
|
+
# the default 0% cap nothing is added — the line ends short,
|
|
81
|
+
# as InDesign does when limits bind.
|
|
82
|
+
def distribute_letter_spacing(line, residual)
|
|
83
|
+
max_pct = @limits&.max_letter_spacing || DEFAULT_MAX_LETTER_SPACING
|
|
84
|
+
return unless max_pct.positive?
|
|
85
|
+
|
|
86
|
+
space_ref = line.glyphs.find(&:is_space)&.width || 0
|
|
87
|
+
return unless space_ref.positive?
|
|
88
|
+
|
|
89
|
+
cap = (max_pct / 100.0) * space_ref * line.glyphs.length
|
|
90
|
+
add = [residual, cap].min / line.glyphs.length
|
|
91
|
+
line.glyphs.each { |glyph| glyph.width += add }
|
|
54
92
|
end
|
|
93
|
+
private :distribute_letter_spacing
|
|
55
94
|
end
|
|
56
95
|
end
|
|
57
96
|
end
|
|
@@ -22,10 +22,22 @@ module Idml
|
|
|
22
22
|
end
|
|
23
23
|
private_class_method :cjk_run?
|
|
24
24
|
|
|
25
|
+
# CJK text wraps per character (no word boundaries): an
|
|
26
|
+
# overflow whose trailing glyph is CJK breaks before it
|
|
27
|
+
# instead of emitting an overlong line.
|
|
28
|
+
|
|
25
29
|
def initialize(frame_width)
|
|
26
30
|
@frame_width = frame_width
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
# CJK text wraps per character (no word boundaries): an
|
|
34
|
+
# overflow whose trailing glyph is CJK breaks before it
|
|
35
|
+
# instead of emitting an overlong line.
|
|
36
|
+
def cjk_break?(current)
|
|
37
|
+
current.length > 1 && CjkLayout.cjk?(current.last.codepoint)
|
|
38
|
+
end
|
|
39
|
+
private :cjk_break?
|
|
40
|
+
|
|
29
41
|
def break(glyphs)
|
|
30
42
|
lines = []
|
|
31
43
|
current = []
|
|
@@ -50,6 +62,10 @@ module Idml
|
|
|
50
62
|
current = current[(last_space_idx + 1)..]
|
|
51
63
|
current_width = current.sum(&:width)
|
|
52
64
|
last_space_idx = -1
|
|
65
|
+
elsif cjk_break?(current)
|
|
66
|
+
lines << Line.new(current[0..-2], current_width - glyph.width, 0)
|
|
67
|
+
current = [glyph]
|
|
68
|
+
current_width = glyph.width
|
|
53
69
|
else
|
|
54
70
|
lines << Line.new(current, current_width, 0)
|
|
55
71
|
current = []
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module TextEngine
|
|
5
|
+
# Vertical (top-to-bottom, right-to-left) glyph layout for CJK
|
|
6
|
+
# vertical writing (StoryOrientation="Vertical"). Glyphs stay
|
|
7
|
+
# UPRIGHT — CJK ideographs render correctly stacked; Latin
|
|
8
|
+
# glyphs are not rotated to the vertical axis (documented
|
|
9
|
+
# approximation). Line breaking (and therefore kinsoku shori)
|
|
10
|
+
# is reused from LineBreaker against the column height.
|
|
11
|
+
module VerticalTextLayout
|
|
12
|
+
# One glyph placed at its baseline in a vertical column.
|
|
13
|
+
PositionedGlyph = Struct.new(:codepoint, :x, :y, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Stacks `glyphs` into columns of at most the frame's text
|
|
16
|
+
# height; column 0 is the rightmost. Returns
|
|
17
|
+
# [positioned_glyphs, column_count].
|
|
18
|
+
def self.layout(glyphs:, frame:, leading:, size:)
|
|
19
|
+
lines = TextEngine::LineBreaker.break(
|
|
20
|
+
glyphs: glyphs, frame_width: column_height(frame),
|
|
21
|
+
)
|
|
22
|
+
positioned = []
|
|
23
|
+
lines.each_with_index do |line, column_index|
|
|
24
|
+
stack_column(positioned, line.glyphs, frame, leading, size,
|
|
25
|
+
column_index)
|
|
26
|
+
end
|
|
27
|
+
[positioned, lines.length]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The frame's usable vertical extent for one column of text.
|
|
31
|
+
def self.column_height(frame)
|
|
32
|
+
frame.height - (frame.inset_top || 0) - (frame.inset_bottom || 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Places one column's glyphs at a shared x, baselines walking
|
|
36
|
+
# down from the frame's text top by each glyph's advance.
|
|
37
|
+
def self.stack_column(positioned, glyphs, frame, leading, size,
|
|
38
|
+
column_index)
|
|
39
|
+
top_y = frame.y + frame.height - (frame.inset_top || 0)
|
|
40
|
+
x = column_x(frame, leading, size, column_index)
|
|
41
|
+
y = top_y
|
|
42
|
+
glyphs.each do |glyph|
|
|
43
|
+
y -= advance(glyph)
|
|
44
|
+
positioned << PositionedGlyph.new(codepoint: glyph.codepoint,
|
|
45
|
+
x: x, y: y)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_class_method :stack_column
|
|
49
|
+
|
|
50
|
+
# Vertical advance of one glyph — its measured width (the em
|
|
51
|
+
# for the common square-CJK case).
|
|
52
|
+
def self.advance(glyph)
|
|
53
|
+
glyph.width.positive? ? glyph.width : 0.0
|
|
54
|
+
end
|
|
55
|
+
private_class_method :advance
|
|
56
|
+
|
|
57
|
+
# The column's glyph left edge: columns advance right-to-left
|
|
58
|
+
# by `leading` from the right inset edge; each glyph (em =
|
|
59
|
+
# `size`) is centered in its column slot.
|
|
60
|
+
def self.column_x(frame, leading, size, column_index)
|
|
61
|
+
right = frame.x + frame.width - (frame.inset_right || 0)
|
|
62
|
+
slot_left = right - ((column_index + 1) * leading)
|
|
63
|
+
slot_left + ((leading - size) / 2.0)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :column_x
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/idml/text_engine.rb
CHANGED
|
@@ -8,6 +8,8 @@ module Idml
|
|
|
8
8
|
autoload :PdfrbFontMetrics, "#{__dir__}/text_engine/pdfrb_font_metrics"
|
|
9
9
|
autoload :Shaper, "#{__dir__}/text_engine/shaper"
|
|
10
10
|
autoload :Measurement, "#{__dir__}/text_engine/measurement"
|
|
11
|
+
autoload :VerticalTextLayout,
|
|
12
|
+
"#{__dir__}/text_engine/vertical_text_layout"
|
|
11
13
|
autoload :ShapedGlyph, "#{__dir__}/text_engine/shaper"
|
|
12
14
|
autoload :LineBreaker, "#{__dir__}/text_engine/line_breaker"
|
|
13
15
|
autoload :Line, "#{__dir__}/text_engine/line_breaker"
|
data/lib/idml/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: idml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.5
|
|
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-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -176,7 +176,10 @@ files:
|
|
|
176
176
|
- TODO.pdf/116-justification-last-line.md
|
|
177
177
|
- TODO.pdf/117-endnote-support.md
|
|
178
178
|
- TODO.pdf/118-table-band-fills.md
|
|
179
|
+
- TODO.pdf/119-justification-spacing-limits.md
|
|
179
180
|
- TODO.pdf/12-idml-to-pdf-pipeline.md
|
|
181
|
+
- TODO.pdf/120-start-paragraph-breaks.md
|
|
182
|
+
- TODO.pdf/121-cell-edge-strokes.md
|
|
180
183
|
- TODO.pdf/13-cjk-text-layout.md
|
|
181
184
|
- TODO.pdf/14-page-item-element-models.md
|
|
182
185
|
- TODO.pdf/15-wire-spread-children.md
|
|
@@ -494,6 +497,7 @@ files:
|
|
|
494
497
|
- lib/idml/text_engine/pdfrb_font_metrics.rb
|
|
495
498
|
- lib/idml/text_engine/shaper.rb
|
|
496
499
|
- lib/idml/text_engine/vertical_layout.rb
|
|
500
|
+
- lib/idml/text_engine/vertical_text_layout.rb
|
|
497
501
|
- lib/idml/validation.rb
|
|
498
502
|
- lib/idml/validation/result.rb
|
|
499
503
|
- lib/idml/validation/validator.rb
|