idml 0.10.3 → 0.10.6
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/Gemfile +6 -4
- data/TODO.pdf/117-endnote-support.md +21 -3
- data/TODO.pdf/13-cjk-text-layout.md +10 -7
- data/TODO.pdf/135-minimum-glyph-scaling.md +28 -0
- data/TODO.pdf/136-diagonal-cell-strokes.md +24 -0
- data/TODO.pdf/137-simple-render-footnote-text.md +22 -0
- data/TODO.pdf/138-text-wrap-sides.md +36 -0
- data/TODO.pdf/61-known-limitations.md +20 -20
- data/TODO.pdf/README.md +9 -4
- data/idml.gemspec +8 -1
- data/lib/idml/render/footnote.rb +1 -0
- data/lib/idml/render/renderers/table_renderer.rb +52 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +100 -17
- data/lib/idml/render/style_resolver.rb +4 -0
- data/lib/idml/render/text_wrap_resolver.rb +85 -6
- data/lib/idml/render.rb +1 -2
- data/lib/idml/text_engine/cjk_layout.rb +62 -0
- data/lib/idml/text_engine/justifier.rb +21 -0
- data/lib/idml/text_engine/line_breaker.rb +1 -0
- data/lib/idml/version.rb +1 -1
- metadata +11 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f60b5f9020e44d5131e9f50e2ddfb673f9caca65bbdee8428b4670baaf96108f
|
|
4
|
+
data.tar.gz: 1f19b80511a14cea5fab0b76404be809a8f28e680deb91ee1b4caf9353a1b756
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9bea97bee388cae4ab403c3bea6cae233c3a378c569c0c678620bdb59a3ed5534ab9aa560866fad103d8a724351e396c72e9a533671fd44189719958f83d811e
|
|
7
|
+
data.tar.gz: 151917cdee9265410c788c85fb6f28d45d07ffc37cc3c4e3fc14b4578f2be57adee09393c284a53a883f1f1dbe105920ecd745a264f0311cc38c0c8f92d94a05
|
data/Gemfile
CHANGED
|
@@ -9,10 +9,12 @@ gem "lutaml-model", "~> 0.8.0"
|
|
|
9
9
|
gem "pdfrb", "~> 0.7"
|
|
10
10
|
gem "rake"
|
|
11
11
|
gem "rspec"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
gem "rubocop
|
|
15
|
-
gem "rubocop-
|
|
12
|
+
# Pinned: new rubocop majors introduce cops that fail CI
|
|
13
|
+
# unexpectedly (1.90 did on 2026-08-25). Bump deliberately.
|
|
14
|
+
gem "rubocop", "1.90.0"
|
|
15
|
+
gem "rubocop-performance", "1.27.0"
|
|
16
|
+
gem "rubocop-rake", "0.7.1"
|
|
17
|
+
gem "rubocop-rspec", "3.10.2"
|
|
16
18
|
gem "simplecov", require: false
|
|
17
19
|
gem "thor"
|
|
18
20
|
gem "yard"
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# TODO PDF 117: Endnote support
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
2026-08-18/19; endnote TEXT rendering OPEN pending a real fixture)
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-26
|
|
5
4
|
|
|
6
5
|
## Problem
|
|
7
6
|
|
|
@@ -27,7 +26,7 @@ story flagged `IsEndnoteStory="true"`. Documents with endnotes
|
|
|
27
26
|
references instead of dropping them. `StoryInner` also parses
|
|
28
27
|
`StoryPreference` (vertical writing support).
|
|
29
28
|
|
|
30
|
-
##
|
|
29
|
+
## End-of-story text rendering (2026-08-26)
|
|
31
30
|
|
|
32
31
|
1. ~~Marker~~ DONE 2026-08-19: CSR-level EndnoteRange emits a
|
|
33
32
|
superscript marker run at the CSR's position (same pattern as
|
|
@@ -48,3 +47,22 @@ The marker-position machinery (text-range resolution) cannot be
|
|
|
48
47
|
validated without a real fixture; blind implementation risks wrong
|
|
49
48
|
anchor placement on production files. Schema modeling + round-trip
|
|
50
49
|
is complete and verified.
|
|
50
|
+
|
|
51
|
+
## Final implementation notes
|
|
52
|
+
|
|
53
|
+
When a main story carries EndnoteRange markers, the package's
|
|
54
|
+
endnote stories (IsEndnoteStory="true", package order) append
|
|
55
|
+
after the main flow via the chain state — end-of-story placement
|
|
56
|
+
per the design above. Each endnote story's first run is prefixed
|
|
57
|
+
with its ordinal ("1 …", "2 …") matching the body markers'
|
|
58
|
+
sequential numbering. Verified by an end-to-end synthetic-package
|
|
59
|
+
spec (3 text blocks with the marker, 1 without).
|
|
60
|
+
|
|
61
|
+
## Known limitations
|
|
62
|
+
|
|
63
|
+
- Multi-story linkage is not modeled: ALL endnote stories follow
|
|
64
|
+
the marker-bearing story (correct for single-article documents;
|
|
65
|
+
the EndnoteTextRange chain remains the fixture-gated refinement
|
|
66
|
+
for multi-article layouts).
|
|
67
|
+
- EndnoteOption (Scope / StartAt / Prefix / Suffix) is not yet
|
|
68
|
+
modeled in Preferences; numbering is ordinal.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# TODO PDF 13: CJK text layout
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
## Status: COMPLETE — final mojikumi pair compression landed
|
|
4
|
+
2026-08-26. Vertical writing (2026-08-19), kinsoku shori, ruby,
|
|
5
|
+
tate-chu-yoko, script spacing, and line-end compression were
|
|
6
|
+
already in.
|
|
6
7
|
|
|
7
8
|
## Vertical mode (2026-08-19)
|
|
8
9
|
|
|
@@ -22,10 +23,12 @@ kinsoku shori, ruby annotations).
|
|
|
22
23
|
|
|
23
24
|
2026-08-20 completions: tate-chu-yoko (TODO 124), Latin rotation
|
|
24
25
|
and vertical ruby (TODO 122), CJK/Latin automatic script spacing
|
|
25
|
-
(TODO 125, a mojikumi subset wired into Shaper).
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
(TODO 125, a mojikumi subset wired into Shaper). Final piece (2026-08-26): class-based pair compression — a
|
|
27
|
+
full-width closing/middle glyph followed by another full-width
|
|
28
|
+
punctuation compresses to half advance within the line (。」、
|
|
29
|
+
ー)… pairs), completing the three real mojikumi behaviors of
|
|
30
|
+
InDesign's default sets alongside script spacing (TODO 125) and
|
|
31
|
+
line-end compression (TODO 132). See `lib/idml/text_engine/cjk_layout.rb` and
|
|
29
32
|
`lib/idml/text_engine/vertical_text_layout.rb`.
|
|
30
33
|
|
|
31
34
|
## Progress 2026-08-17
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# TODO PDF 135: MinimumGlyphScaling justification compression
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-26
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
InDesign's justification dialog exposes MinimumGlyphScaling: overlong
|
|
8
|
+
justified lines may squeeze glyphs uniformly down to that percent.
|
|
9
|
+
The gem applied only MaximumGlyphScaling (stretching, TODO 129) —
|
|
10
|
+
overlong lines overflowed the frame instead of compressing.
|
|
11
|
+
|
|
12
|
+
## Solution
|
|
13
|
+
|
|
14
|
+
`TextEngine::Justifier#justify_line` now handles negative slack:
|
|
15
|
+
`compress_glyphs` scales every glyph uniformly, capped at
|
|
16
|
+
`min_glyph_scaling` percent (default 100 disables it, matching
|
|
17
|
+
InDesign). The limit threads through `SpacingLimits#min_glyph_scaling`,
|
|
18
|
+
resolved from the PSR's `MinimumGlyphScaling` via `StyleResolver`
|
|
19
|
+
(`Paragraph#minimum_glyph_scaling`) and applied at both call sites
|
|
20
|
+
(frame renderer and footnote layout).
|
|
21
|
+
|
|
22
|
+
## Files
|
|
23
|
+
|
|
24
|
+
- `lib/idml/text_engine/justifier.rb`
|
|
25
|
+
- `lib/idml/render/style_resolver.rb`
|
|
26
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb`
|
|
27
|
+
- `lib/idml/render/footnote.rb`
|
|
28
|
+
- `spec/idml/text_engine/text_engine_spec.rb`
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# TODO PDF 136: Diagonal cell strokes drawn
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-26
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
The Table schema (TODO 104) modeled `TopLeftDiagonalLine`,
|
|
8
|
+
`TopRightDiagonalLine`, and the `DiagonalLineStroke*` attributes, but
|
|
9
|
+
the renderer never drew them — struck-through table cells (common in
|
|
10
|
+
forms) rendered as plain boxes.
|
|
11
|
+
|
|
12
|
+
## Solution
|
|
13
|
+
|
|
14
|
+
`TableRenderer#render_cell_diagonals` draws after cell borders:
|
|
15
|
+
TopLeftDiagonalLine from top-left to bottom-right corner,
|
|
16
|
+
TopRightDiagonalLine from top-right to bottom-left. Weight defaults
|
|
17
|
+
to 1pt and color to black when the flags are set without explicit
|
|
18
|
+
stroke attributes (InDesign defaults); `DiagonalLineStrokeTint`
|
|
19
|
+
applies via `ColorHelper.apply_tint`.
|
|
20
|
+
|
|
21
|
+
## Files
|
|
22
|
+
|
|
23
|
+
- `lib/idml/render/renderers/table_renderer.rb`
|
|
24
|
+
- `spec/idml/render/table_renderer_spec.rb`
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# TODO PDF 137: Footnote text in the simple-render fallback
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-26
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Without FontMetrics the text renderer falls back to `simple_render`
|
|
8
|
+
(one `text_rich` per frame). Footnote markers rendered but the
|
|
9
|
+
footnote text itself was dropped entirely.
|
|
10
|
+
|
|
11
|
+
## Solution
|
|
12
|
+
|
|
13
|
+
`emit_simple_footnotes` emits after the body block: one `text_rich`
|
|
14
|
+
line per footnote paragraph, stacked upward from the frame's bottom
|
|
15
|
+
edge below a hairline separator rule (0.5pt, quarter-width — InDesign's
|
|
16
|
+
footnote rule proportions). Paragraphs carry their marker prefix from
|
|
17
|
+
extraction, so numbering reads correctly.
|
|
18
|
+
|
|
19
|
+
## Files
|
|
20
|
+
|
|
21
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb`
|
|
22
|
+
- `spec/idml/render/simple_footnote_render_spec.rb`
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# TODO PDF 138: Side-aware text wrap (TextWrapSide)
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-26
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
TextWrapSide was parsed but ignored: every wrap mode narrowed the run
|
|
8
|
+
width by the contour overlap, which smears text over objects that sit
|
|
9
|
+
at a frame edge (text starts at the frame's left edge regardless of
|
|
10
|
+
where the object is).
|
|
11
|
+
|
|
12
|
+
## Solution
|
|
13
|
+
|
|
14
|
+
`TextWrapResolver#wrap_adjustment` returns
|
|
15
|
+
`[width_reduction, x_shift]` per line band:
|
|
16
|
+
|
|
17
|
+
- `LeftSide` — text stays left of the contour: reduce so the line
|
|
18
|
+
ends at the contour's left edge, no shift.
|
|
19
|
+
- `RightSide` — text flows right of the contour: shift lines past the
|
|
20
|
+
contour's right edge (`Line#x_offset += shift` after justification)
|
|
21
|
+
and reduce by the same amount.
|
|
22
|
+
- `LargestArea` — pick the roomier side of the two.
|
|
23
|
+
- `BothSides` (default) and the spine variants — narrow by the full
|
|
24
|
+
overlap; true two-segment lines and binding-side context remain
|
|
25
|
+
approximations.
|
|
26
|
+
- Inverse contours keep their flip-to-inside semantics.
|
|
27
|
+
|
|
28
|
+
Multiple contours combine by max (per-run approximation, as before).
|
|
29
|
+
Shape contours (Contour mode) narrow by polygon overlap without side
|
|
30
|
+
awareness.
|
|
31
|
+
|
|
32
|
+
## Files
|
|
33
|
+
|
|
34
|
+
- `lib/idml/render/text_wrap_resolver.rb`
|
|
35
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb`
|
|
36
|
+
- `spec/idml/render/text_wrap_resolver_spec.rb`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TODO PDF 61: Known limitations and future work
|
|
2
2
|
|
|
3
|
-
## Status: DOCUMENTED — refreshed 2026-08-
|
|
3
|
+
## Status: DOCUMENTED — refreshed 2026-08-26
|
|
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;
|
|
@@ -11,10 +11,10 @@ this list reflects the current state.
|
|
|
11
11
|
### Text layout
|
|
12
12
|
- **Hyphenation** is not implemented (Hyphenation* attributes are
|
|
13
13
|
parsed but unused); a hyphenation dictionary would be required.
|
|
14
|
-
- **Justification**: word/letter spacing caps apply (TODO 119)
|
|
15
|
-
MaximumGlyphScaling stretches
|
|
16
|
-
|
|
17
|
-
alignment
|
|
14
|
+
- **Justification**: word/letter spacing caps apply (TODO 119),
|
|
15
|
+
MaximumGlyphScaling stretches (TODO 129) and MinimumGlyphScaling
|
|
16
|
+
compresses overlong lines (TODO 135); ToBinding / RTL binding
|
|
17
|
+
alignment is not applied.
|
|
18
18
|
- **Keep options**: KeepAllLinesTogether (TODO 123), KeepWithNext
|
|
19
19
|
(TODO 127), and the KeepFirstLines / KeepLastLines windows
|
|
20
20
|
(TODO 133, line-count approximation) are honored.
|
|
@@ -23,35 +23,35 @@ this list reflects the current state.
|
|
|
23
23
|
- **StartParagraph** breaks act at frame/column granularity — no
|
|
24
24
|
odd/even page parity.
|
|
25
25
|
- **Text wrap**: BoundingBoxTextWrap, Contour, and Inverse modes
|
|
26
|
-
render (TODOs 130-131)
|
|
27
|
-
|
|
26
|
+
render (TODOs 130-131) with TextWrapSide side-awareness (TODO 138:
|
|
27
|
+
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.
|
|
28
30
|
|
|
29
31
|
### CJK
|
|
30
|
-
- **Mojikumi**:
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
- **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.
|
|
33
35
|
- **Vertical mode**: Latin rotation is per glyph (not run-grouped);
|
|
34
36
|
ruby placement is beside-column; keep options do not apply.
|
|
35
37
|
|
|
36
38
|
### Structural features
|
|
37
|
-
- **Endnotes** (TODO 117): reference markers render
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
- **Endnotes** (TODO 117): reference markers render and endnote TEXT
|
|
40
|
+
renders end-of-story with ordinal prefixes; multi-story linkage
|
|
41
|
+
(which endnotes belong to which main story) is not modeled.
|
|
40
42
|
- **Footnotes** number per story; overflow balancing (NoSplitting)
|
|
41
|
-
is not modeled; the simple-render fallback shows markers
|
|
42
|
-
footnote text.
|
|
43
|
+
is not modeled; the simple-render fallback shows markers with
|
|
44
|
+
footnote text stacked at the frame bottom (TODO 137).
|
|
43
45
|
- **Anchored objects** render at stored geometry; AnchorType
|
|
44
46
|
text-reflow is not simulated.
|
|
45
47
|
- **Tables** flow across chained frames with repeated header rows
|
|
46
|
-
(TODO 134)
|
|
47
|
-
|
|
48
|
+
(TODO 134) and diagonal cell strokes draw (TODO 136);
|
|
49
|
+
row-spanning cells break at frame boundaries.
|
|
48
50
|
- **Inline anchored objects / footnotes inside table cells** are
|
|
49
51
|
not handled.
|
|
50
52
|
|
|
51
53
|
## Future enhancements
|
|
52
54
|
|
|
53
55
|
- Hyphenation dictionary integration
|
|
54
|
-
- Shape-mode text wrap contours
|
|
55
|
-
- Frame-spanning tables with header repeats
|
|
56
|
-
- Class-based mojikumi tables
|
|
56
|
+
- Shape-mode text wrap contours (side-aware)
|
|
57
57
|
- Performance benchmark for 100+ page documents
|
data/TODO.pdf/README.md
CHANGED
|
@@ -12,7 +12,12 @@ without InDesign Server. The pipeline has three layers:
|
|
|
12
12
|
output together. IDML Package → typed model → render plan →
|
|
13
13
|
pdfrb Document → PDF file.
|
|
14
14
|
|
|
15
|
-
All entries
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
All entries are DONE — including TODO 117 (end-of-story endnote
|
|
16
|
+
TEXT rendering), TODO 13's class-based mojikumi pair compression,
|
|
17
|
+
and the final known-limitation sweep (TODOs 135–138:
|
|
18
|
+
MinimumGlyphScaling compression, diagonal cell strokes, footnote
|
|
19
|
+
text in the simple-render fallback, and side-aware TextWrapSide).
|
|
20
|
+
Vertical writing, kinsoku, ruby, and tate-chu-yoko all landed
|
|
21
|
+
(TODOs 122-125). TODO 61 is the live known-limitations index. Each
|
|
22
|
+
file carries a `## Status:` header with a brief implementation
|
|
23
|
+
summary.
|
data/idml.gemspec
CHANGED
|
@@ -12,7 +12,14 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.authors = ["Ribose"]
|
|
13
13
|
spec.email = ["open.source@ribose.com"]
|
|
14
14
|
|
|
15
|
-
spec.summary = "Parse,
|
|
15
|
+
spec.summary = "Parse, validate, compose, and render Adobe IDML files to PDF."
|
|
16
|
+
spec.description = "Pure-Ruby toolkit for Adobe InDesign IDML: " \
|
|
17
|
+
"schema-faithful models with byte-faithful " \
|
|
18
|
+
"round-trip, RelaxNG validation, composition " \
|
|
19
|
+
"operations, and a full " \
|
|
20
|
+
"IDML-to-PDF rendering pipeline (text layout, " \
|
|
21
|
+
"tables, CJK " \
|
|
22
|
+
"vertical writing, text wrap, footnotes, PDF/A)."
|
|
16
23
|
spec.homepage = "https://github.com/metanorma/idml"
|
|
17
24
|
spec.license = "BSD-2-Clause"
|
|
18
25
|
spec.required_ruby_version = Gem::Requirement.new(">= 3.3.0")
|
data/lib/idml/render/footnote.rb
CHANGED
|
@@ -132,6 +132,7 @@ module Idml
|
|
|
132
132
|
max_word_spacing: paragraph.maximum_word_spacing,
|
|
133
133
|
max_letter_spacing: paragraph.maximum_letter_spacing,
|
|
134
134
|
max_glyph_scaling: paragraph.maximum_glyph_scaling,
|
|
135
|
+
min_glyph_scaling: paragraph.minimum_glyph_scaling,
|
|
135
136
|
)
|
|
136
137
|
lines.each_with_index do |line, index|
|
|
137
138
|
TextEngine::Justifier.justify(
|
|
@@ -104,6 +104,8 @@ module Idml
|
|
|
104
104
|
cell_h, context)
|
|
105
105
|
render_cell_border(canvas, cell, table, cell_x, cell_y,
|
|
106
106
|
cell_w, cell_h, context)
|
|
107
|
+
render_cell_diagonals(canvas, cell, cell_x, cell_y,
|
|
108
|
+
cell_w, cell_h, context)
|
|
107
109
|
render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
|
|
108
110
|
end
|
|
109
111
|
end
|
|
@@ -220,6 +222,56 @@ module Idml
|
|
|
220
222
|
end
|
|
221
223
|
private_class_method :render_cell_border
|
|
222
224
|
|
|
225
|
+
# TopLeftDiagonalLine runs from the cell's top-left to its
|
|
226
|
+
# bottom-right corner; TopRightDiagonalLine from top-right
|
|
227
|
+
# to bottom-left. Stroke weight defaults to 1pt and color
|
|
228
|
+
# to black when the flags are set without explicit stroke
|
|
229
|
+
# attributes (InDesign defaults). DiagonalLineInFront is
|
|
230
|
+
# accepted; diagonals always paint after the cell fill.
|
|
231
|
+
def self.render_cell_diagonals(canvas, cell, x, y, w, h, context)
|
|
232
|
+
return unless cell.top_left_diagonal_line ||
|
|
233
|
+
cell.top_right_diagonal_line
|
|
234
|
+
|
|
235
|
+
weight = cell.diagonal_line_stroke_weight
|
|
236
|
+
thickness = weight.nil? ? 1.0 : weight.to_f
|
|
237
|
+
return unless thickness.positive?
|
|
238
|
+
|
|
239
|
+
canvas.line_width = thickness
|
|
240
|
+
canvas.stroke_color(diagonal_color(cell, context))
|
|
241
|
+
diagonals_for(cell, x, y, w, h).each do |from, to|
|
|
242
|
+
canvas.move_to(from[0], from[1])
|
|
243
|
+
canvas.line_to(to[0], to[1])
|
|
244
|
+
canvas.stroke
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
private_class_method :render_cell_diagonals
|
|
248
|
+
|
|
249
|
+
def self.diagonals_for(cell, x, y, w, h)
|
|
250
|
+
[].tap do |diagonals|
|
|
251
|
+
diagonals << [[x, y + h], [x + w, y]] if cell.top_left_diagonal_line
|
|
252
|
+
diagonals << [[x + w, y + h], [x, y]] if cell.top_right_diagonal_line
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
private_class_method :diagonals_for
|
|
256
|
+
|
|
257
|
+
def self.diagonal_color(cell, context)
|
|
258
|
+
color = diagonal_resolved_color(cell, context)
|
|
259
|
+
color ? ColorHelper.to_canvas(color) : [:gray, 0.0]
|
|
260
|
+
end
|
|
261
|
+
private_class_method :diagonal_color
|
|
262
|
+
|
|
263
|
+
def self.diagonal_resolved_color(cell, context)
|
|
264
|
+
name = cell.diagonal_line_stroke_color
|
|
265
|
+
return nil if name.nil? || name == "Color/None"
|
|
266
|
+
|
|
267
|
+
resolved = context.color_resolver&.resolve(name)
|
|
268
|
+
return nil unless resolved
|
|
269
|
+
|
|
270
|
+
tint = cell.diagonal_line_stroke_tint
|
|
271
|
+
tint ? ColorHelper.apply_tint(resolved, tint) : resolved
|
|
272
|
+
end
|
|
273
|
+
private_class_method :diagonal_resolved_color
|
|
274
|
+
|
|
223
275
|
# [top, right, bottom, left] effective weights, or nil when
|
|
224
276
|
# neither cell edges nor table defaults declare any stroke
|
|
225
277
|
# (the legacy uniform-rect case).
|
|
@@ -77,6 +77,7 @@ module Idml
|
|
|
77
77
|
style_lookup: context.style_lookup,
|
|
78
78
|
footnote_option: Footnote.option(context.package)
|
|
79
79
|
)
|
|
80
|
+
paragraphs.concat(endnote_paragraphs(story, context))
|
|
80
81
|
tables = tables_in_story(story).map { |table| [table, 0] }
|
|
81
82
|
return nil if paragraphs.empty? && tables.empty?
|
|
82
83
|
|
|
@@ -114,6 +115,55 @@ module Idml
|
|
|
114
115
|
end
|
|
115
116
|
private_class_method :render_inline_tables
|
|
116
117
|
|
|
118
|
+
# End-of-story endnote text (TODO 117): when the main
|
|
119
|
+
# story carries endnote markers, the package's endnote
|
|
120
|
+
# stories (IsEndnoteStory="true", in designmap StoryList
|
|
121
|
+
# order) append after the main flow, each prefixed with
|
|
122
|
+
# its ordinal so it reads as numbered. Multi-story linkage
|
|
123
|
+
# (which endnotes belong to which main story) is not
|
|
124
|
+
# modeled — all endnote stories follow the marker-bearing
|
|
125
|
+
# story, which matches single-article documents.
|
|
126
|
+
def self.endnote_paragraphs(story, context)
|
|
127
|
+
return [] unless endnotes_marked?(story, context)
|
|
128
|
+
|
|
129
|
+
number = 0
|
|
130
|
+
context.package.stories.filter_map do |endnote_story|
|
|
131
|
+
next unless endnote_story.inner&.is_endnote_story
|
|
132
|
+
|
|
133
|
+
number += 1
|
|
134
|
+
paragraphs = StyleResolver.extract_paragraphs(
|
|
135
|
+
endnote_story,
|
|
136
|
+
condition_filter: context.condition_filter,
|
|
137
|
+
style_lookup: context.style_lookup,
|
|
138
|
+
footnote_option: Footnote.option(context.package),
|
|
139
|
+
)
|
|
140
|
+
prefix_endnote_number(paragraphs, number)
|
|
141
|
+
paragraphs
|
|
142
|
+
end.flatten
|
|
143
|
+
end
|
|
144
|
+
private_class_method :endnote_paragraphs
|
|
145
|
+
|
|
146
|
+
def self.endnotes_marked?(story, context)
|
|
147
|
+
return false unless context.package
|
|
148
|
+
return false unless story_csrs(story).any? do |csr|
|
|
149
|
+
!csr.endnote_range.empty?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
context.package.stories.any? do |candidate|
|
|
153
|
+
candidate.inner&.is_endnote_story
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
private_class_method :endnotes_marked?
|
|
157
|
+
|
|
158
|
+
def self.prefix_endnote_number(paragraphs, number)
|
|
159
|
+
return if paragraphs.empty?
|
|
160
|
+
return if paragraphs.first.runs.empty?
|
|
161
|
+
|
|
162
|
+
first_run = paragraphs.first.runs.first
|
|
163
|
+
first_run.text = "#{number} #{first_run.text}"
|
|
164
|
+
end
|
|
165
|
+
private_class_method :prefix_endnote_number
|
|
166
|
+
|
|
117
167
|
def self.tables_in_story(story)
|
|
118
168
|
story_csrs(story).flat_map(&:table).compact
|
|
119
169
|
end
|
|
@@ -789,14 +839,17 @@ module Idml
|
|
|
789
839
|
rendered_count)
|
|
790
840
|
run_wrap = drop_cap_wrap_width(wrap_width, dc_width,
|
|
791
841
|
dc_lines, rendered_count)
|
|
792
|
-
|
|
793
|
-
|
|
842
|
+
wrap_reduction, wrap_shift = text_wrap_adjust(
|
|
843
|
+
context, layout_frame, cursor_y, run
|
|
844
|
+
)
|
|
845
|
+
run_wrap -= wrap_reduction
|
|
794
846
|
positioned, next_y = layout_run(
|
|
795
847
|
canvas, run, paragraph, context, layout_frame, font,
|
|
796
848
|
run_wrap, cursor_y, space_before,
|
|
797
849
|
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
798
850
|
char_cursor, bottom_limit,
|
|
799
|
-
paragraph_last: run.equal?(runs.last)
|
|
851
|
+
paragraph_last: run.equal?(runs.last),
|
|
852
|
+
wrap_shift: wrap_shift
|
|
800
853
|
)
|
|
801
854
|
char_cursor += positioned.length
|
|
802
855
|
cursor_y = next_y
|
|
@@ -855,26 +908,27 @@ module Idml
|
|
|
855
908
|
end
|
|
856
909
|
private_class_method :render_footnote_entries
|
|
857
910
|
|
|
858
|
-
# Computes the text-wrap
|
|
859
|
-
# position
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
#
|
|
863
|
-
#
|
|
864
|
-
#
|
|
865
|
-
|
|
911
|
+
# Computes the text-wrap adjustment for a run at its current y
|
|
912
|
+
# position: [width_reduction, x_shift]. The reduction narrows
|
|
913
|
+
# the wrap width; the shift moves wrapped lines right past a
|
|
914
|
+
# contour the text must flow around (TextWrapSide RightSide /
|
|
915
|
+
# LargestArea). Uses the run's point_size as the line height
|
|
916
|
+
# approximation. Returns [0.0, 0.0] when no resolver is wired
|
|
917
|
+
# or no contours overlap. Per-run approximation: all lines in
|
|
918
|
+
# the run get the same reduced width and shift (correct when
|
|
919
|
+
# the run is fully inside or outside the contour; approximate
|
|
920
|
+
# when it spans the boundary).
|
|
921
|
+
def self.text_wrap_adjust(context, layout_frame, cursor_y, run)
|
|
866
922
|
resolver = context.text_wrap_resolver
|
|
867
|
-
return 0.0 unless resolver
|
|
923
|
+
return [0.0, 0.0] unless resolver
|
|
868
924
|
|
|
869
925
|
size = run.point_size || DEFAULT_SIZE
|
|
870
926
|
frame_x = layout_frame.x + (layout_frame.inset_left || 0)
|
|
871
927
|
frame_right = layout_frame.x + layout_frame.width -
|
|
872
928
|
(layout_frame.inset_right || 0)
|
|
873
|
-
|
|
874
|
-
frame_x, frame_right)
|
|
875
|
-
[overlap, 0.0].max
|
|
929
|
+
resolver.wrap_adjustment(cursor_y, size, frame_x, frame_right)
|
|
876
930
|
end
|
|
877
|
-
private_class_method :
|
|
931
|
+
private_class_method :text_wrap_adjust
|
|
878
932
|
|
|
879
933
|
# Strips drop cap characters from the first run so they don't
|
|
880
934
|
# render twice (once as the enlarged drop cap, once as normal
|
|
@@ -997,7 +1051,8 @@ module Idml
|
|
|
997
1051
|
def self.layout_run(canvas, run, paragraph, context, layout_frame,
|
|
998
1052
|
font, wrap_width, cursor_y, space_before,
|
|
999
1053
|
first_line_indent, left_indent,
|
|
1000
|
-
char_cursor, bottom_limit, paragraph_last: false
|
|
1054
|
+
char_cursor, bottom_limit, paragraph_last: false,
|
|
1055
|
+
wrap_shift: 0.0)
|
|
1001
1056
|
size = run.point_size || DEFAULT_SIZE
|
|
1002
1057
|
glyphs = TextEngine::Shaper.shape(
|
|
1003
1058
|
text: run.text, font: font, size: size,
|
|
@@ -1006,6 +1061,7 @@ module Idml
|
|
|
1006
1061
|
glyphs: glyphs, frame_width: wrap_width,
|
|
1007
1062
|
)
|
|
1008
1063
|
justify_lines(lines, paragraph, wrap_width, paragraph_last)
|
|
1064
|
+
lines.each { |line| line.x_offset += wrap_shift }
|
|
1009
1065
|
leading = leading_for(paragraph, size)
|
|
1010
1066
|
|
|
1011
1067
|
positioned, next_y = TextEngine::VerticalLayout.layout_block(
|
|
@@ -1158,6 +1214,7 @@ module Idml
|
|
|
1158
1214
|
max_word_spacing: paragraph.maximum_word_spacing,
|
|
1159
1215
|
max_letter_spacing: paragraph.maximum_letter_spacing,
|
|
1160
1216
|
max_glyph_scaling: paragraph.maximum_glyph_scaling,
|
|
1217
|
+
min_glyph_scaling: paragraph.minimum_glyph_scaling,
|
|
1161
1218
|
)
|
|
1162
1219
|
lines.each_with_index do |line, index|
|
|
1163
1220
|
TextEngine::Justifier.justify(
|
|
@@ -1301,12 +1358,38 @@ module Idml
|
|
|
1301
1358
|
runs_for,
|
|
1302
1359
|
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
1303
1360
|
)
|
|
1361
|
+
emit_simple_footnotes(canvas, runs, context, box)
|
|
1304
1362
|
state.paragraphs = []
|
|
1305
1363
|
state.current_paragraph = nil
|
|
1306
1364
|
state.runs_remaining = []
|
|
1307
1365
|
end
|
|
1308
1366
|
private_class_method :simple_render
|
|
1309
1367
|
|
|
1368
|
+
# Footnote text for the rough (no-metrics) path: one line
|
|
1369
|
+
# per footnote paragraph, stacked upward from the frame's
|
|
1370
|
+
# bottom edge below a hairline separator. Paragraphs are
|
|
1371
|
+
# already marker-prefixed at extraction.
|
|
1372
|
+
def self.emit_simple_footnotes(canvas, runs, context, box)
|
|
1373
|
+
paragraphs = runs.flat_map(&:footnote_paragraphs).compact
|
|
1374
|
+
return if paragraphs.empty?
|
|
1375
|
+
|
|
1376
|
+
size = paragraphs.first.runs.first&.point_size || DEFAULT_SIZE
|
|
1377
|
+
block_top = box[:y] + (paragraphs.length * size)
|
|
1378
|
+
canvas.line_width = 0.5
|
|
1379
|
+
canvas.stroke_color([:gray, 0.0])
|
|
1380
|
+
canvas.move_to(box[:x], block_top + (size * 0.3))
|
|
1381
|
+
canvas.line_to(box[:x] + (box[:width] * 0.25), block_top + (size * 0.3))
|
|
1382
|
+
canvas.stroke
|
|
1383
|
+
|
|
1384
|
+
paragraphs.reverse_each.with_index do |paragraph, index|
|
|
1385
|
+
canvas.text_rich(
|
|
1386
|
+
build_rich_runs(paragraph.runs, context),
|
|
1387
|
+
at: [box[:x], box[:y] + ((index + 1) * size)],
|
|
1388
|
+
)
|
|
1389
|
+
end
|
|
1390
|
+
end
|
|
1391
|
+
private_class_method :emit_simple_footnotes
|
|
1392
|
+
|
|
1310
1393
|
def self.collect_runs(state)
|
|
1311
1394
|
runs = state.runs_remaining || []
|
|
1312
1395
|
if state.current_paragraph
|
|
@@ -69,6 +69,7 @@ module Idml
|
|
|
69
69
|
:maximum_word_spacing,
|
|
70
70
|
:maximum_letter_spacing,
|
|
71
71
|
:maximum_glyph_scaling,
|
|
72
|
+
:minimum_glyph_scaling,
|
|
72
73
|
# Forced paragraph break (StartParagraph: NextPage /
|
|
73
74
|
# NextColumn / NextFrame / NextOddPage / NextEvenPage).
|
|
74
75
|
:start_paragraph,
|
|
@@ -212,6 +213,9 @@ module Idml
|
|
|
212
213
|
maximum_glyph_scaling: resolve_attr(
|
|
213
214
|
style_lookup, psr, :maximum_glyph_scaling
|
|
214
215
|
),
|
|
216
|
+
minimum_glyph_scaling: resolve_attr(
|
|
217
|
+
style_lookup, psr, :minimum_glyph_scaling
|
|
218
|
+
),
|
|
215
219
|
start_paragraph: resolve_attr(style_lookup, psr,
|
|
216
220
|
:start_paragraph),
|
|
217
221
|
keep_all_lines_together: resolve_attr(
|
|
@@ -9,12 +9,16 @@ module Idml
|
|
|
9
9
|
# text line to compute the effective wrap width — lines that
|
|
10
10
|
# overlap a contour get their width reduced by the overlap.
|
|
11
11
|
#
|
|
12
|
-
# Supported: BoundingBox mode (rectangle intersection)
|
|
13
|
-
#
|
|
12
|
+
# Supported: BoundingBox mode (rectangle intersection) with
|
|
13
|
+
# TextWrapSide awareness (LeftSide / RightSide / LargestArea
|
|
14
|
+
# pick the side text flows on; BothSides and the spine variants
|
|
15
|
+
# narrow by the full overlap). Shape contours (Contour mode)
|
|
16
|
+
# narrow by polygon overlap without side awareness.
|
|
17
|
+
# Unsupported: Inverse mode's side behavior.
|
|
14
18
|
class TextWrapResolver
|
|
15
19
|
# A wrap contour: a PDF-coordinate rectangle the text avoids.
|
|
16
20
|
Contour = Struct.new(
|
|
17
|
-
:x, :y, :width, :height, :inverse,
|
|
21
|
+
:x, :y, :width, :height, :inverse, :side,
|
|
18
22
|
keyword_init: true
|
|
19
23
|
)
|
|
20
24
|
|
|
@@ -37,6 +41,80 @@ module Idml
|
|
|
37
41
|
|
|
38
42
|
attr_reader :contours
|
|
39
43
|
|
|
44
|
+
# Side-aware adjustment for a text line: returns
|
|
45
|
+
# [width_reduction, x_shift]. LeftSide keeps text left of the
|
|
46
|
+
# contour (reduce, no shift); RightSide moves text past the
|
|
47
|
+
# contour's right edge (reduce + shift); LargestArea picks the
|
|
48
|
+
# roomier side; BothSides and the spine variants (which need
|
|
49
|
+
# binding-side context) reduce by the full overlap. Multiple
|
|
50
|
+
# contours combine by max — the per-run approximation.
|
|
51
|
+
def wrap_adjustment(line_y, line_height, frame_x, frame_right)
|
|
52
|
+
reduction = 0.0
|
|
53
|
+
shift = 0.0
|
|
54
|
+
@contours.each do |contour|
|
|
55
|
+
unless contour.is_a?(WrapContour::Shape)
|
|
56
|
+
r, s = box_adjustment(contour, line_y, line_height,
|
|
57
|
+
frame_x, frame_right)
|
|
58
|
+
reduction = [reduction, r].max
|
|
59
|
+
shift = [shift, s].max
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
width = WrapContour.overlap_width(contour, line_y,
|
|
64
|
+
line_height, frame_x,
|
|
65
|
+
frame_right)
|
|
66
|
+
next unless contour.inverse
|
|
67
|
+
|
|
68
|
+
reduction = [reduction,
|
|
69
|
+
(frame_right - frame_x) - width].max
|
|
70
|
+
end
|
|
71
|
+
[reduction, shift]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def box_adjustment(contour, line_y, line_height, frame_x,
|
|
75
|
+
frame_right)
|
|
76
|
+
overlap = if y_overlap?(contour, line_y, line_height) &&
|
|
77
|
+
x_overlap?(contour, frame_x, frame_right)
|
|
78
|
+
x_overlap_width(contour, frame_x, frame_right)
|
|
79
|
+
else
|
|
80
|
+
0.0
|
|
81
|
+
end
|
|
82
|
+
# Inverse contours: text flows only inside, so the reduction
|
|
83
|
+
# is the frame width minus the covered part (the full width
|
|
84
|
+
# when the line misses the object entirely).
|
|
85
|
+
return [(frame_right - frame_x) - overlap, 0.0] if contour.inverse
|
|
86
|
+
|
|
87
|
+
return [0.0, 0.0] if overlap.zero?
|
|
88
|
+
|
|
89
|
+
side_adjustment(contour, overlap, frame_x, frame_right)
|
|
90
|
+
end
|
|
91
|
+
private :box_adjustment
|
|
92
|
+
|
|
93
|
+
def side_adjustment(contour, overlap, frame_x, frame_right)
|
|
94
|
+
case contour.side
|
|
95
|
+
when "LeftSide"
|
|
96
|
+
[frame_right - contour.x, 0.0]
|
|
97
|
+
when "RightSide"
|
|
98
|
+
shift = contour.x + contour.width - frame_x
|
|
99
|
+
[shift, shift]
|
|
100
|
+
when "LargestArea"
|
|
101
|
+
largest_side_adjustment(contour, frame_x, frame_right)
|
|
102
|
+
else
|
|
103
|
+
[overlap, 0.0]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
private :side_adjustment
|
|
107
|
+
|
|
108
|
+
def largest_side_adjustment(contour, frame_x, frame_right)
|
|
109
|
+
left_free = contour.x - frame_x
|
|
110
|
+
right_free = frame_right - (contour.x + contour.width)
|
|
111
|
+
return [frame_right - contour.x, 0.0] if left_free >= right_free
|
|
112
|
+
|
|
113
|
+
shift = contour.x + contour.width - frame_x
|
|
114
|
+
[shift, shift]
|
|
115
|
+
end
|
|
116
|
+
private :largest_side_adjustment
|
|
117
|
+
|
|
40
118
|
# Returns the total horizontal overlap of all contours with a
|
|
41
119
|
# text line at the given y range within the given x range.
|
|
42
120
|
# Sum, not max — multiple overlapping contours compound.
|
|
@@ -73,12 +151,12 @@ module Idml
|
|
|
73
151
|
if contour_mode?(pref)
|
|
74
152
|
WrapContour.shape(item, pref, page_height)
|
|
75
153
|
else
|
|
76
|
-
box_contour(item, page_height)
|
|
154
|
+
box_contour(item, page_height, pref)
|
|
77
155
|
end
|
|
78
156
|
end
|
|
79
157
|
private_class_method :contour_for
|
|
80
158
|
|
|
81
|
-
def self.box_contour(item, page_height)
|
|
159
|
+
def self.box_contour(item, page_height, pref = nil)
|
|
82
160
|
bounds = item.geometric_bounds
|
|
83
161
|
return nil unless bounds
|
|
84
162
|
|
|
@@ -86,7 +164,8 @@ module Idml
|
|
|
86
164
|
page_height)
|
|
87
165
|
Contour.new(
|
|
88
166
|
x: rect[:x], y: rect[:y],
|
|
89
|
-
width: rect[:width], height: rect[:height]
|
|
167
|
+
width: rect[:width], height: rect[:height],
|
|
168
|
+
side: pref&.text_wrap_side
|
|
90
169
|
)
|
|
91
170
|
end
|
|
92
171
|
private_class_method :box_contour
|
data/lib/idml/render.rb
CHANGED
|
@@ -50,14 +50,13 @@ module Idml
|
|
|
50
50
|
|
|
51
51
|
# Render an IDML package to a PDF file. All options except
|
|
52
52
|
# `package:` and `to:` are keyword-only and optional.
|
|
53
|
-
# rubocop:disable Metrics/ParameterLists
|
|
53
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
54
54
|
def self.render(package:, to:, font_search_paths: nil, compliance: nil,
|
|
55
55
|
tagged: false, subset_fonts: true, compress: false)
|
|
56
56
|
Pipeline.new(package, to, font_search_paths,
|
|
57
57
|
compliance: compliance, tagged: tagged,
|
|
58
58
|
subset_fonts: subset_fonts, compress: compress).call
|
|
59
59
|
end
|
|
60
|
-
# rubocop:enable Metrics/ParameterLists
|
|
61
60
|
end
|
|
62
61
|
end
|
|
63
62
|
|
|
@@ -74,6 +74,68 @@ module Idml
|
|
|
74
74
|
TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# Class-based mojikumi pair compression: a full-width
|
|
78
|
+
# closing/middle glyph followed by another full-width
|
|
79
|
+
# punctuation glyph compresses to half advance within the
|
|
80
|
+
# line (。」、ー) … pairs), matching InDesign's default
|
|
81
|
+
# mojikumi behavior for consecutive 約物.
|
|
82
|
+
OPENING_CODEPOINTS = [
|
|
83
|
+
0x300C, # 「
|
|
84
|
+
0x300E, # 『
|
|
85
|
+
0x3008, # 〈
|
|
86
|
+
0x300A, # 《
|
|
87
|
+
0x3010, # 【
|
|
88
|
+
0x3014, # 〔
|
|
89
|
+
0xFF08, # (fullwidth left paren
|
|
90
|
+
0xFF3B, # [
|
|
91
|
+
0xFF5B, # {
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
94
|
+
MIDDLE_CODEPOINTS = [
|
|
95
|
+
0x30FB, # ・ fullwidth middle dot
|
|
96
|
+
0x30FC, # ー prolonged sound mark
|
|
97
|
+
0x2025, # ‥ two-dot leader
|
|
98
|
+
0x2026, # … ellipsis
|
|
99
|
+
].freeze
|
|
100
|
+
|
|
101
|
+
def apply_pair_compression(lines)
|
|
102
|
+
lines.each do |line|
|
|
103
|
+
glyphs = line.glyphs
|
|
104
|
+
(0...(glyphs.length - 1)).each do |index|
|
|
105
|
+
leading = glyphs[index]
|
|
106
|
+
following = glyphs[index + 1]
|
|
107
|
+
next unless pair_compressible?(leading, following)
|
|
108
|
+
|
|
109
|
+
leading.width /= 2.0
|
|
110
|
+
end
|
|
111
|
+
line.width = glyphs.sum(&:width)
|
|
112
|
+
end
|
|
113
|
+
lines
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Closing/middle glyph followed by full-width punctuation
|
|
117
|
+
# compresses the leading glyph to half advance.
|
|
118
|
+
def pair_compressible?(leading, following)
|
|
119
|
+
return false unless fullwidth_punct?(following.codepoint)
|
|
120
|
+
|
|
121
|
+
closing?(leading.codepoint) ||
|
|
122
|
+
(middle?(leading.codepoint) &&
|
|
123
|
+
!OPENING_CODEPOINTS.include?(following.codepoint))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def closing?(codepoint)
|
|
127
|
+
TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def middle?(codepoint)
|
|
131
|
+
MIDDLE_CODEPOINTS.include?(codepoint)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def fullwidth_punct?(codepoint)
|
|
135
|
+
closing?(codepoint) || middle?(codepoint) ||
|
|
136
|
+
OPENING_CODEPOINTS.include?(codepoint)
|
|
137
|
+
end
|
|
138
|
+
|
|
77
139
|
CJK_RANGES = [
|
|
78
140
|
0x3000..0x303F, # CJK Symbols and Punctuation
|
|
79
141
|
0x3040..0x309F, # Hiragana
|
|
@@ -11,12 +11,14 @@ module Idml
|
|
|
11
11
|
# InDesign defaults: word spacing max 133%, letter spacing 0%.
|
|
12
12
|
SpacingLimits = Struct.new(
|
|
13
13
|
:max_word_spacing, :max_letter_spacing, :max_glyph_scaling,
|
|
14
|
+
:min_glyph_scaling,
|
|
14
15
|
keyword_init: true
|
|
15
16
|
)
|
|
16
17
|
|
|
17
18
|
DEFAULT_MAX_WORD_SPACING = 133.0
|
|
18
19
|
DEFAULT_MAX_LETTER_SPACING = 0.0
|
|
19
20
|
DEFAULT_MAX_GLYPH_SCALING = 100.0
|
|
21
|
+
DEFAULT_MIN_GLYPH_SCALING = 100.0
|
|
20
22
|
|
|
21
23
|
def self.justify(line:, frame_width:, alignment: :left,
|
|
22
24
|
last_line: false, limits: nil)
|
|
@@ -55,6 +57,8 @@ module Idml
|
|
|
55
57
|
def justify_line(line)
|
|
56
58
|
spaces = line.glyphs.count(&:is_space)
|
|
57
59
|
slack = @frame_width - line.width
|
|
60
|
+
return compress_glyphs(line, slack) if slack.negative?
|
|
61
|
+
|
|
58
62
|
return if spaces.zero? || slack <= 0
|
|
59
63
|
|
|
60
64
|
word_share = [slack, word_capacity(line, spaces)].min
|
|
@@ -99,6 +103,23 @@ module Idml
|
|
|
99
103
|
end
|
|
100
104
|
private :distribute_letter_spacing
|
|
101
105
|
|
|
106
|
+
# Overlong justified line: compress all glyphs uniformly,
|
|
107
|
+
# capped at min_glyph_scaling percent. InDesign's default cap
|
|
108
|
+
# (100%) disables it; only documents that lower the cap get
|
|
109
|
+
# squeezed glyphs.
|
|
110
|
+
def compress_glyphs(line, slack)
|
|
111
|
+
min_pct = @limits&.min_glyph_scaling || DEFAULT_MIN_GLYPH_SCALING
|
|
112
|
+
shrink = (100 - min_pct) / 100.0
|
|
113
|
+
return unless shrink.positive?
|
|
114
|
+
|
|
115
|
+
total = line.glyphs.sum(&:width)
|
|
116
|
+
return unless total.positive?
|
|
117
|
+
|
|
118
|
+
factor = [-slack / total, shrink].min
|
|
119
|
+
line.glyphs.each { |glyph| glyph.width *= (1 - factor) }
|
|
120
|
+
end
|
|
121
|
+
private :compress_glyphs
|
|
122
|
+
|
|
102
123
|
# Last resort: uniform glyph scaling for any remaining slack,
|
|
103
124
|
# capped at max_glyph_scaling percent. InDesign's default cap
|
|
104
125
|
# (100%) disables it; only documents that relax the cap get
|
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.6
|
|
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-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -94,7 +94,10 @@ dependencies:
|
|
|
94
94
|
- - ">="
|
|
95
95
|
- !ruby/object:Gem::Version
|
|
96
96
|
version: '0'
|
|
97
|
-
description:
|
|
97
|
+
description: 'Pure-Ruby toolkit for Adobe InDesign IDML: schema-faithful models with
|
|
98
|
+
byte-faithful round-trip, RelaxNG validation, composition operations, and a full
|
|
99
|
+
IDML-to-PDF rendering pipeline (text layout, tables, CJK vertical writing, text
|
|
100
|
+
wrap, footnotes, PDF/A).'
|
|
98
101
|
email:
|
|
99
102
|
- open.source@ribose.com
|
|
100
103
|
executables:
|
|
@@ -194,6 +197,10 @@ files:
|
|
|
194
197
|
- TODO.pdf/132-line-end-compression.md
|
|
195
198
|
- TODO.pdf/133-keep-windows.md
|
|
196
199
|
- TODO.pdf/134-table-row-flow.md
|
|
200
|
+
- TODO.pdf/135-minimum-glyph-scaling.md
|
|
201
|
+
- TODO.pdf/136-diagonal-cell-strokes.md
|
|
202
|
+
- TODO.pdf/137-simple-render-footnote-text.md
|
|
203
|
+
- TODO.pdf/138-text-wrap-sides.md
|
|
197
204
|
- TODO.pdf/14-page-item-element-models.md
|
|
198
205
|
- TODO.pdf/15-wire-spread-children.md
|
|
199
206
|
- TODO.pdf/16-typed-model-pipeline.md
|
|
@@ -1149,5 +1156,5 @@ requirements: []
|
|
|
1149
1156
|
rubygems_version: 3.5.22
|
|
1150
1157
|
signing_key:
|
|
1151
1158
|
specification_version: 4
|
|
1152
|
-
summary: Parse,
|
|
1159
|
+
summary: Parse, validate, compose, and render Adobe IDML files to PDF.
|
|
1153
1160
|
test_files: []
|