idml 0.10.2 → 0.10.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/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/133-keep-windows.md +22 -0
- data/TODO.pdf/134-table-row-flow.md +45 -0
- data/TODO.pdf/61-known-limitations.md +9 -8
- data/TODO.pdf/README.md +7 -4
- data/idml.gemspec +8 -1
- data/lib/idml/render/renderers/table_renderer.rb +98 -13
- data/lib/idml/render/renderers/text_frame_renderer.rb +117 -9
- data/lib/idml/render/story_chain_controller.rb +3 -0
- data/lib/idml/render/style_resolver.rb +8 -1
- data/lib/idml/render.rb +1 -2
- data/lib/idml/text_engine/cjk_layout.rb +62 -0
- data/lib/idml/text_engine/line_breaker.rb +1 -0
- data/lib/idml/version.rb +1 -1
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 92cf9efcb8cb1f39e0047561634dd472186e817399f86ec0ffb1052e6de31db9
|
|
4
|
+
data.tar.gz: 1226c8e6cc234f21ed9f39ed79d371fb816bed45452eb28fba5cef62517b9dce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b369f0215c2e82053016ffd629bd14894d3d790c4dd053737a8645a919dc7c8054648a882fd6a1494df335e26d252a89c61e4c85f58f7dd7f630f54ed0686107
|
|
7
|
+
data.tar.gz: 79604135da4d6126775c9ae9ac044034180f89a034b1e56116c278b13a07a1b728839b4114cf0c317c8ff8af5b67150d1d62b111f02981796fea389767bbc758
|
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,22 @@
|
|
|
1
|
+
# TODO PDF 133: Partial keep windows (KeepFirstLines / KeepLastLines)
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-24
|
|
4
|
+
|
|
5
|
+
## What was done
|
|
6
|
+
|
|
7
|
+
- `StyleResolver::Paragraph` carries `keep_first_lines` /
|
|
8
|
+
`keep_last_lines` from the PSR.
|
|
9
|
+
- `consume_paragraphs` defers the whole paragraph when it cannot
|
|
10
|
+
fully fit AND either window binds, approximated by measured line
|
|
11
|
+
counts (TextEngine::Measurement height ÷ leading):
|
|
12
|
+
- KeepFirstLines=N defers when fewer than N lines fit the
|
|
13
|
+
remaining space.
|
|
14
|
+
- KeepLastLines=N defers when the overflow tail for the next
|
|
15
|
+
frame would strand fewer than N lines.
|
|
16
|
+
|
|
17
|
+
## Known limitations
|
|
18
|
+
|
|
19
|
+
- Line counts are estimates (pre-measured shape/wrap); runs whose
|
|
20
|
+
mid-paragraph wrap differs from the estimate may defer one line
|
|
21
|
+
early or late.
|
|
22
|
+
- Not applied in vertical writing mode.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# TODO PDF 134: Table row flow across chained frames (design)
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-24
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Tables render within a single frame box (TableRenderer#
|
|
8
|
+
render_in_box). A table taller than its frame overflows invisibly;
|
|
9
|
+
continuation frames do not receive the remaining rows, and header
|
|
10
|
+
rows never repeat (HeaderRowCount).
|
|
11
|
+
|
|
12
|
+
## Design
|
|
13
|
+
|
|
14
|
+
1. `TableRenderer.render_schema_faithful` gains `start_row:` and a
|
|
15
|
+
frame-bottom clip: rows render while the row's bottom edge
|
|
16
|
+
stays above the limit; the first row crossing it stops the walk
|
|
17
|
+
and the renderer returns `next_start_row` (nil when the table
|
|
18
|
+
completed).
|
|
19
|
+
2. Header repeat: for continuation calls (start_row > 0) with
|
|
20
|
+
HeaderRowCount > 0, re-emit header rows at the top of the
|
|
21
|
+
continuation area and reduce its available height accordingly.
|
|
22
|
+
3. State threading: `StoryChainController::State` gains
|
|
23
|
+
`tables_remaining` — an array of [table, start_row] pairs.
|
|
24
|
+
`render_inline_tables` consumes the chain head's story tables
|
|
25
|
+
plus any carried-over remainders, appending unfinished pairs
|
|
26
|
+
back for the next frame.
|
|
27
|
+
4. Footer rows (FooterRowCount) stay with the table's final chunk.
|
|
28
|
+
|
|
29
|
+
## Implementation notes (2026-08-24)
|
|
30
|
+
|
|
31
|
+
Delivered per the design above: `render_in_box` takes `start_row:`
|
|
32
|
+
and `bottom_limit:`, renders whole rows while their bottom edge
|
|
33
|
+
stays above the limit, re-emits HeaderRowCount header rows on
|
|
34
|
+
continuations, and returns the next unrendered row (nil when
|
|
35
|
+
complete). `StoryChainController::State` carries `tables_remaining`
|
|
36
|
+
([table, start_row] pairs); `render_inline_tables` renders fresh
|
|
37
|
+
story tables on chain heads plus carried remainders, storing
|
|
38
|
+
unfinished tables back for the next frame. `fresh_state` now keeps
|
|
39
|
+
table-only stories alive (previously a story with no paragraphs
|
|
40
|
+
produced a nil state and its tables never rendered).
|
|
41
|
+
|
|
42
|
+
Known limitations: row-spanning cells break at frame boundaries
|
|
43
|
+
(a span cell crossing the limit renders at its start frame only);
|
|
44
|
+
footer rows render with their chunk (no forced keep-with-end);
|
|
45
|
+
multi-column frames flow tables across the full box width.
|
|
@@ -15,9 +15,9 @@ this list reflects the current state.
|
|
|
15
15
|
MaximumGlyphScaling stretches as the last resort (TODO 129);
|
|
16
16
|
MinimumGlyphScaling compression and ToBinding / RTL binding
|
|
17
17
|
alignment are not applied.
|
|
18
|
-
- **Keep options**:
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
- **Keep options**: KeepAllLinesTogether (TODO 123), KeepWithNext
|
|
19
|
+
(TODO 127), and the KeepFirstLines / KeepLastLines windows
|
|
20
|
+
(TODO 133, line-count approximation) are honored.
|
|
21
21
|
- **First baseline**: AscentOffset and FixedHeight are honored
|
|
22
22
|
(TODO 128); CapHeight / XHeight approximate as leading.
|
|
23
23
|
- **StartParagraph** breaks act at frame/column granularity — no
|
|
@@ -27,9 +27,9 @@ this list reflects the current state.
|
|
|
27
27
|
the box; side-aware narrowing is not implemented.
|
|
28
28
|
|
|
29
29
|
### CJK
|
|
30
|
-
- **Mojikumi**:
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
- **Mojikumi**: script spacing (TODO 125), line-end compression
|
|
31
|
+
(TODO 132), and class-based pair compression (2026-08-26) are
|
|
32
|
+
applied; named mojikumi sets (詳細 etc.) are not modeled.
|
|
33
33
|
- **Vertical mode**: Latin rotation is per glyph (not run-grouped);
|
|
34
34
|
ruby placement is beside-column; keep options do not apply.
|
|
35
35
|
|
|
@@ -42,8 +42,9 @@ this list reflects the current state.
|
|
|
42
42
|
footnote text.
|
|
43
43
|
- **Anchored objects** render at stored geometry; AnchorType
|
|
44
44
|
text-reflow is not simulated.
|
|
45
|
-
- **Tables**
|
|
46
|
-
|
|
45
|
+
- **Tables** flow across chained frames with repeated header rows
|
|
46
|
+
(TODO 134); row-spanning cells break at frame boundaries and
|
|
47
|
+
diagonal cell strokes are modeled but not drawn.
|
|
47
48
|
- **Inline anchored objects / footnotes inside table cells** are
|
|
48
49
|
not handled.
|
|
49
50
|
|
data/TODO.pdf/README.md
CHANGED
|
@@ -12,7 +12,10 @@ 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 except: TODO 117 (endnote TEXT rendering —
|
|
16
|
+
gated on a real fixture to resolve the EndnoteTextRange reference
|
|
17
|
+
chain) and TODO 13's final stretch goal (full class-based mojikumi
|
|
18
|
+
tables; script spacing and line-end compression are in). Vertical
|
|
19
|
+
writing, kinsoku, ruby, and tate-chu-yoko all landed (TODOs
|
|
20
|
+
122-125). TODO 61 is the live known-limitations index. Each file
|
|
21
|
+
carries a `## Status:` header with a brief implementation 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")
|
|
@@ -48,16 +48,30 @@ module Idml
|
|
|
48
48
|
# render Tables inlined in a story — real IDML Tables
|
|
49
49
|
# have no own geometry, so the containing TextFrame's
|
|
50
50
|
# bounds stand in for the Table's bounds.
|
|
51
|
-
|
|
51
|
+
# Renders the table within `box`. When `bottom_limit` is
|
|
52
|
+
# given, rows stop at the limit and the next unrendered row
|
|
53
|
+
# index is returned (nil when the table completed) — the
|
|
54
|
+
# caller threads it into the next frame of the chain via
|
|
55
|
+
# `start_row`, which also re-emits HeaderRowCount header
|
|
56
|
+
# rows above the continuation.
|
|
57
|
+
def self.render_in_box(canvas, table, box, context, start_row: 0,
|
|
58
|
+
bottom_limit: nil)
|
|
52
59
|
return if table.visible == false
|
|
60
|
+
return nil if schema_faithful?(table) &&
|
|
61
|
+
start_row >= table.row.length
|
|
53
62
|
|
|
63
|
+
next_row = nil
|
|
54
64
|
canvas.save_graphics_state do
|
|
55
|
-
if schema_faithful?(table)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
next_row = if schema_faithful?(table)
|
|
66
|
+
render_schema_faithful(canvas, table, box, context,
|
|
67
|
+
start_row: start_row,
|
|
68
|
+
bottom_limit: bottom_limit)
|
|
69
|
+
else
|
|
70
|
+
render_legacy(canvas, table, box, context)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
next_row
|
|
61
75
|
end
|
|
62
76
|
|
|
63
77
|
def self.schema_faithful?(table)
|
|
@@ -68,20 +82,70 @@ module Idml
|
|
|
68
82
|
# Real IDML: Table > {Cell, Row} siblings. Cell Name is
|
|
69
83
|
# "col:row". Row count from `row` collection; column count
|
|
70
84
|
# derived from `column_count` attribute or max col + 1.
|
|
71
|
-
def self.render_schema_faithful(canvas, table, box, context
|
|
85
|
+
def self.render_schema_faithful(canvas, table, box, context,
|
|
86
|
+
start_row: 0, bottom_limit: nil)
|
|
72
87
|
layout = SchemaLayout.new(table: table, box: box)
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
rendered, next_row = visible_row_range(
|
|
89
|
+
layout, table, start_row, bottom_limit
|
|
90
|
+
)
|
|
91
|
+
rendered.each do |row_idx|
|
|
92
|
+
render_row(canvas, table, layout, box, row_idx, context)
|
|
93
|
+
end
|
|
94
|
+
next_row
|
|
95
|
+
end
|
|
96
|
+
private_class_method :render_schema_faithful
|
|
97
|
+
|
|
98
|
+
def self.render_row(canvas, table, layout, _box, row_idx, context)
|
|
99
|
+
layout.each_cell_in_row(row_idx) do |cell_x, cell_y, cell_w,
|
|
100
|
+
cell_h, cell, col_idx|
|
|
75
101
|
render_band_background(canvas, cell, layout, col_idx, row_idx,
|
|
76
102
|
cell_x, cell_y, cell_w, cell_h, context)
|
|
77
|
-
render_cell_background(canvas, cell, cell_x, cell_y, cell_w,
|
|
78
|
-
context)
|
|
103
|
+
render_cell_background(canvas, cell, cell_x, cell_y, cell_w,
|
|
104
|
+
cell_h, context)
|
|
79
105
|
render_cell_border(canvas, cell, table, cell_x, cell_y,
|
|
80
106
|
cell_w, cell_h, context)
|
|
81
107
|
render_cell_text(canvas, cell, cell_x, cell_y, cell_h, context)
|
|
82
108
|
end
|
|
83
109
|
end
|
|
84
|
-
private_class_method :
|
|
110
|
+
private_class_method :render_row
|
|
111
|
+
|
|
112
|
+
# [rows_to_render, next_unrendered_row]: header rows
|
|
113
|
+
# re-emit on continuations (start_row > 0) with
|
|
114
|
+
# HeaderRowCount; body rows run from start_row until the
|
|
115
|
+
# frame bottom limit clips one.
|
|
116
|
+
def self.visible_row_range(layout, table, start_row, bottom_limit)
|
|
117
|
+
rows = continuation_header_rows(table, start_row) +
|
|
118
|
+
body_rows_within(layout, table, start_row, bottom_limit)
|
|
119
|
+
[rows, next_unrendered(layout, table, start_row, bottom_limit)]
|
|
120
|
+
end
|
|
121
|
+
private_class_method :visible_row_range
|
|
122
|
+
|
|
123
|
+
def self.continuation_header_rows(table, start_row)
|
|
124
|
+
return [] if start_row.zero?
|
|
125
|
+
|
|
126
|
+
(table.header_row_count || 0).times.to_a
|
|
127
|
+
end
|
|
128
|
+
private_class_method :continuation_header_rows
|
|
129
|
+
|
|
130
|
+
def self.body_rows_within(layout, table, start_row, bottom_limit)
|
|
131
|
+
body_rows = (start_row...table.row.length).to_a
|
|
132
|
+
return body_rows if bottom_limit.nil?
|
|
133
|
+
|
|
134
|
+
body_rows.take_while do |row_idx|
|
|
135
|
+
layout.row_bottom_y(row_idx) >= bottom_limit
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
private_class_method :body_rows_within
|
|
139
|
+
|
|
140
|
+
def self.next_unrendered(layout, table, start_row, bottom_limit)
|
|
141
|
+
last = start_row + body_rows_within(
|
|
142
|
+
layout, table, start_row, bottom_limit
|
|
143
|
+
).length - 1
|
|
144
|
+
return nil if last >= table.row.length - 1
|
|
145
|
+
|
|
146
|
+
last + 1
|
|
147
|
+
end
|
|
148
|
+
private_class_method :next_unrendered
|
|
85
149
|
|
|
86
150
|
# Table-level alternating band fill behind a cell. An
|
|
87
151
|
# explicit Cell FillColor wins; Color/None bands and zero
|
|
@@ -384,6 +448,27 @@ module Idml
|
|
|
384
448
|
table.row
|
|
385
449
|
end
|
|
386
450
|
|
|
451
|
+
# Cells whose START row is `row` (cells spanning from an
|
|
452
|
+
# earlier row render there, not here).
|
|
453
|
+
def each_cell_in_row(row)
|
|
454
|
+
col_count.times do |col_idx|
|
|
455
|
+
cell = cell_at(col_idx, row)
|
|
456
|
+
next unless cell
|
|
457
|
+
next unless cell.col_row.nil? || cell.col_row.last == row
|
|
458
|
+
|
|
459
|
+
col_span = cell.column_span || 1
|
|
460
|
+
row_span = cell.row_span || 1
|
|
461
|
+
yield cell_x(col_idx), cell_y(row, col_span, row_span),
|
|
462
|
+
cell_w(col_idx, col_span), cell_h(row, row_span),
|
|
463
|
+
cell, col_idx
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# The bottom y of a row's band (PDF coords).
|
|
468
|
+
def row_bottom_y(row)
|
|
469
|
+
box[:y] + total_height - cumulative_height(row + 1)
|
|
470
|
+
end
|
|
471
|
+
|
|
387
472
|
# Table-level alternating band fill for a cell position:
|
|
388
473
|
# [color_name, tint] or nil. Row banding by default; column
|
|
389
474
|
# banding when ColumnFillsPriority is true or rows are
|
|
@@ -35,10 +35,10 @@ module Idml
|
|
|
35
35
|
return unless story
|
|
36
36
|
|
|
37
37
|
box = frame_box(frame, context.page_height)
|
|
38
|
-
|
|
38
|
+
state = initial_chain_state(frame, story, context)
|
|
39
|
+
render_inline_tables(canvas, story, box, context, state)
|
|
39
40
|
render_anchored_objects(canvas, story, context)
|
|
40
41
|
|
|
41
|
-
state = initial_chain_state(frame, story, context)
|
|
42
42
|
return unless state
|
|
43
43
|
|
|
44
44
|
vertical = vertical_story?(story)
|
|
@@ -77,13 +77,16 @@ module Idml
|
|
|
77
77
|
style_lookup: context.style_lookup,
|
|
78
78
|
footnote_option: Footnote.option(context.package)
|
|
79
79
|
)
|
|
80
|
-
|
|
80
|
+
paragraphs.concat(endnote_paragraphs(story, context))
|
|
81
|
+
tables = tables_in_story(story).map { |table| [table, 0] }
|
|
82
|
+
return nil if paragraphs.empty? && tables.empty?
|
|
81
83
|
|
|
82
84
|
StoryChainController::State.new(
|
|
83
85
|
paragraphs: paragraphs,
|
|
84
86
|
current_paragraph: nil,
|
|
85
87
|
runs_remaining: [],
|
|
86
88
|
char_cursor: 0,
|
|
89
|
+
tables_remaining: tables,
|
|
87
90
|
)
|
|
88
91
|
end
|
|
89
92
|
private_class_method :fresh_state
|
|
@@ -94,16 +97,73 @@ module Idml
|
|
|
94
97
|
# so the containing TextFrame's box stands in. Synthetic
|
|
95
98
|
# spread-level Tables dispatch separately via
|
|
96
99
|
# PageItemRenderer.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
# Renders the story's inline tables — fresh ones for a
|
|
101
|
+
# chain head, carried-over remainders for continuation
|
|
102
|
+
# frames — clipped to the frame bottom; unfinished tables
|
|
103
|
+
# stay in the chain state for the next frame (TODO 134).
|
|
104
|
+
def self.render_inline_tables(canvas, _story, box, context, state)
|
|
105
|
+
pairs = state ? state.tables_remaining.to_a : []
|
|
106
|
+
return if pairs.empty?
|
|
107
|
+
|
|
108
|
+
state.tables_remaining = pairs.filter_map do |table, start_row|
|
|
109
|
+
next_row = TableRenderer.render_in_box(
|
|
110
|
+
canvas, table, box, context,
|
|
111
|
+
start_row: start_row, bottom_limit: box[:y]
|
|
112
|
+
)
|
|
113
|
+
next_row ? [table, next_row] : nil
|
|
103
114
|
end
|
|
104
115
|
end
|
|
105
116
|
private_class_method :render_inline_tables
|
|
106
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
|
+
|
|
107
167
|
def self.tables_in_story(story)
|
|
108
168
|
story_csrs(story).flat_map(&:table).compact
|
|
109
169
|
end
|
|
@@ -1029,6 +1089,8 @@ module Idml
|
|
|
1029
1089
|
cursor_y, bottom_limit)
|
|
1030
1090
|
|
|
1031
1091
|
keep_all_lines_break?(paragraph, layout_frame, font,
|
|
1092
|
+
cursor_y, bottom_limit) ||
|
|
1093
|
+
keep_windows_break?(paragraph, layout_frame, font,
|
|
1032
1094
|
cursor_y, bottom_limit)
|
|
1033
1095
|
end
|
|
1034
1096
|
private_class_method :paragraph_deferred?
|
|
@@ -1049,6 +1111,52 @@ module Idml
|
|
|
1049
1111
|
end
|
|
1050
1112
|
private_class_method :keep_all_lines_break?
|
|
1051
1113
|
|
|
1114
|
+
# Partial keep windows, approximated by measured line
|
|
1115
|
+
# counts: KeepFirstLines defers when fewer than N lines
|
|
1116
|
+
# fit; KeepLastLines defers when the overflow tail for the
|
|
1117
|
+
# next frame would strand fewer than N lines.
|
|
1118
|
+
def self.keep_windows_break?(paragraph, layout_frame, font,
|
|
1119
|
+
cursor_y, bottom_limit)
|
|
1120
|
+
return false unless paragraph.keep_first_lines ||
|
|
1121
|
+
paragraph.keep_last_lines
|
|
1122
|
+
|
|
1123
|
+
wrap_width = TextEngine::VerticalLayout.wrap_width(
|
|
1124
|
+
layout_frame, paragraph.right_indent || 0
|
|
1125
|
+
)
|
|
1126
|
+
height = TextEngine::Measurement.paragraph_height(
|
|
1127
|
+
paragraph, font, wrap_width
|
|
1128
|
+
)
|
|
1129
|
+
return false if (cursor_y - height) >= bottom_limit
|
|
1130
|
+
|
|
1131
|
+
leading = first_paragraph_leading_for(paragraph)
|
|
1132
|
+
lines_fit = [((cursor_y - bottom_limit) / leading).floor, 0].max
|
|
1133
|
+
first_window_break?(paragraph, lines_fit) ||
|
|
1134
|
+
last_window_break?(paragraph, lines_fit, height, leading)
|
|
1135
|
+
end
|
|
1136
|
+
private_class_method :keep_windows_break?
|
|
1137
|
+
|
|
1138
|
+
def self.first_window_break?(paragraph, lines_fit)
|
|
1139
|
+
paragraph.keep_first_lines &&
|
|
1140
|
+
lines_fit < paragraph.keep_first_lines
|
|
1141
|
+
end
|
|
1142
|
+
private_class_method :first_window_break?
|
|
1143
|
+
|
|
1144
|
+
def self.last_window_break?(paragraph, lines_fit, height, leading)
|
|
1145
|
+
return false unless paragraph.keep_last_lines
|
|
1146
|
+
|
|
1147
|
+
total_lines = [(height / leading).ceil, 1].max
|
|
1148
|
+
lines_fit.positive? &&
|
|
1149
|
+
(total_lines - lines_fit) < paragraph.keep_last_lines
|
|
1150
|
+
end
|
|
1151
|
+
private_class_method :last_window_break?
|
|
1152
|
+
|
|
1153
|
+
def self.first_paragraph_leading_for(paragraph)
|
|
1154
|
+
size = paragraph.runs.first&.point_size || DEFAULT_SIZE
|
|
1155
|
+
TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
|
|
1156
|
+
size)
|
|
1157
|
+
end
|
|
1158
|
+
private_class_method :first_paragraph_leading_for
|
|
1159
|
+
|
|
1052
1160
|
# KeepWithNext: this paragraph defers when the next
|
|
1053
1161
|
# paragraph is forced to the next frame, or when the next
|
|
1054
1162
|
# paragraph's first line would not fit after this one.
|
|
@@ -28,6 +28,9 @@ module Idml
|
|
|
28
28
|
# Vertical-writing path: columns already consumed when the
|
|
29
29
|
# paragraph chain continues into the next frame.
|
|
30
30
|
:column_offset,
|
|
31
|
+
# Frame-spanning tables: [table, start_row] pairs still to
|
|
32
|
+
# render (TODO 134).
|
|
33
|
+
:tables_remaining,
|
|
31
34
|
keyword_init: true,
|
|
32
35
|
)
|
|
33
36
|
|
|
@@ -75,9 +75,12 @@ module Idml
|
|
|
75
75
|
# Widow/orphan control: KeepAllLinesTogether pushes the whole
|
|
76
76
|
# paragraph to the next frame when it cannot fully fit;
|
|
77
77
|
# KeepWithNext binds this paragraph to the next paragraph's
|
|
78
|
-
# first line
|
|
78
|
+
# first line; KeepFirstLines / KeepLastLines are partial
|
|
79
|
+
# keep windows (approximated via measured line counts).
|
|
79
80
|
:keep_all_lines_together,
|
|
80
81
|
:keep_with_next,
|
|
82
|
+
:keep_first_lines,
|
|
83
|
+
:keep_last_lines,
|
|
81
84
|
:bullets_and_numbering_list_type,
|
|
82
85
|
:bullet_character_value,
|
|
83
86
|
:bullets_text_after,
|
|
@@ -216,6 +219,10 @@ module Idml
|
|
|
216
219
|
),
|
|
217
220
|
keep_with_next: resolve_attr(style_lookup, psr,
|
|
218
221
|
:keep_with_next),
|
|
222
|
+
keep_first_lines: resolve_attr(style_lookup, psr,
|
|
223
|
+
:keep_first_lines),
|
|
224
|
+
keep_last_lines: resolve_attr(style_lookup, psr,
|
|
225
|
+
:keep_last_lines),
|
|
219
226
|
drop_cap_lines: resolve_attr(style_lookup, psr, :drop_cap_lines),
|
|
220
227
|
drop_cap_characters: resolve_attr(style_lookup, psr,
|
|
221
228
|
:drop_cap_characters),
|
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
|
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.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-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:
|
|
@@ -192,6 +195,8 @@ files:
|
|
|
192
195
|
- TODO.pdf/130-contour-text-wrap.md
|
|
193
196
|
- TODO.pdf/131-inverse-wrap.md
|
|
194
197
|
- TODO.pdf/132-line-end-compression.md
|
|
198
|
+
- TODO.pdf/133-keep-windows.md
|
|
199
|
+
- TODO.pdf/134-table-row-flow.md
|
|
195
200
|
- TODO.pdf/14-page-item-element-models.md
|
|
196
201
|
- TODO.pdf/15-wire-spread-children.md
|
|
197
202
|
- TODO.pdf/16-typed-model-pipeline.md
|
|
@@ -1147,5 +1152,5 @@ requirements: []
|
|
|
1147
1152
|
rubygems_version: 3.5.22
|
|
1148
1153
|
signing_key:
|
|
1149
1154
|
specification_version: 4
|
|
1150
|
-
summary: Parse,
|
|
1155
|
+
summary: Parse, validate, compose, and render Adobe IDML files to PDF.
|
|
1151
1156
|
test_files: []
|