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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40262a683342f0a9308e33ab2cce4a8bcf6b803288d6a0bc3ece3d2b376c6761
4
- data.tar.gz: 78cb5495d1d9b72d5e850c6d25c8cd594622d6484476ea61a1ff239df41e9e99
3
+ metadata.gz: 92cf9efcb8cb1f39e0047561634dd472186e817399f86ec0ffb1052e6de31db9
4
+ data.tar.gz: 1226c8e6cc234f21ed9f39ed79d371fb816bed45452eb28fba5cef62517b9dce
5
5
  SHA512:
6
- metadata.gz: b10228f8870d90e1a66f8fb9b621a26951db4a71e155a3f28165c22a3e9c44b53e7dbc17916ec4b5db0f49987403e4e645e5f0872a79cb0fe591881ff6f9caca
7
- data.tar.gz: d4904d3ef2a6d0b1c4dfcd497597042fcedee1b5c3c1643ff892ac867e7b4c3f0881419a906527e5f1705a64292c8c5c5e06ae8af774b8ce725f81c8bcf9ff92
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
- gem "rubocop"
13
- gem "rubocop-performance"
14
- gem "rubocop-rake"
15
- gem "rubocop-rspec"
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: PARTIAL (modeling + reference markers COMPLETE
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
- ## Design for rendering (needs a real InDesign fixture to validate)
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: PARTIALvertical writing mode implemented 2026-08-19
4
- (horizontal-mode CJK was complete 2026-08-17: glyph measurement,
5
- kinsoku shori, ruby annotations).
3
+ ## Status: COMPLETEfinal 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). The remaining
26
- stretch goal is full class-based mojikumi (per-pair punctuation
27
- compression tables); line-end punctuation compression landed
28
- 2026-08-23 as TODO 132. See `lib/idml/text_engine/cjk_layout.rb` and
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**: whole-paragraph KeepAllLinesTogether (TODO
19
- 123) and KeepWithNext (TODO 127) are honored; partial keep
20
- windows (KeepFirstLines / KeepLastLines) are not.
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**: CJK/Latin auto script spacing (TODO 125) and
31
- line-end punctuation compression (TODO 132) are applied; full
32
- class-based compression tables are not.
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** do not span frames (no repeated header rows); diagonal
46
- cell strokes are modeled but not drawn.
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 below are functionally DONE (basic CJK in TODO 13 is
16
- the only PARTIAL vertical writing mode, kinsoku, ruby, and
17
- tate-chu-yoko remain stretch goals). Each file carries a
18
- `## Status:` header with a brief implementation summary.
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, manipulate, and round-trip Adobe IDML files."
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
- def self.render_in_box(canvas, table, box, context)
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
- render_schema_faithful(canvas, table, box, context)
57
- else
58
- render_legacy(canvas, table, box, context)
59
- end
60
- end
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
- layout.each_cell do |cell_x, cell_y, cell_w, cell_h, cell,
74
- col_idx, row_idx|
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, cell_h,
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 :render_schema_faithful
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
- render_inline_tables(canvas, story, box, context)
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
- return nil if paragraphs.empty?
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
- def self.render_inline_tables(canvas, story, box, context)
98
- tables = tables_in_story(story)
99
- return if tables.empty?
100
-
101
- tables.each do |table|
102
- TableRenderer.render_in_box(canvas, table, box, context)
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
@@ -15,6 +15,7 @@ module Idml
15
15
  return lines unless cjk_run?(glyphs)
16
16
 
17
17
  lines = CjkLayout.apply_kinsoku(lines)
18
+ CjkLayout.apply_pair_compression(lines)
18
19
  CjkLayout.apply_line_end_compression(lines)
19
20
  end
20
21
 
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.10.2"
4
+ VERSION = "0.10.5"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
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-23 00:00:00.000000000 Z
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, manipulate, and round-trip Adobe IDML files.
1155
+ summary: Parse, validate, compose, and render Adobe IDML files to PDF.
1151
1156
  test_files: []