idml 0.10.2 → 0.10.3

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: 79a78b04b80af7bc0018d4fde7500d164d8bc8b587a425c2fba89c5be19a7bb8
4
+ data.tar.gz: 0b835730671e71723e043372530b1fbb6765029c50da2025315408dd2caf4fa7
5
5
  SHA512:
6
- metadata.gz: b10228f8870d90e1a66f8fb9b621a26951db4a71e155a3f28165c22a3e9c44b53e7dbc17916ec4b5db0f49987403e4e645e5f0872a79cb0fe591881ff6f9caca
7
- data.tar.gz: d4904d3ef2a6d0b1c4dfcd497597042fcedee1b5c3c1643ff892ac867e7b4c3f0881419a906527e5f1705a64292c8c5c5e06ae8af774b8ce725f81c8bcf9ff92
6
+ metadata.gz: 3f79b3ee1dbe8a3d5cb01346f55935196e10d21ca3cd7d477132aaedafc200df0880179b568b8c071d5dbce62e77b2fe6275fdedb1a6d8fbbeb868f61b16a70f
7
+ data.tar.gz: 5d556bd0f53b5817051dd5ae419fab76c4e91534e3ff0f1415ff5e22fc10949743f2e6fb5992d0c2a669ea5e8aa4e3e2c20ebac7ddfe8ae991495267cd5a2671
@@ -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
@@ -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
 
@@ -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,15 @@ 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
+ tables = tables_in_story(story).map { |table| [table, 0] }
81
+ return nil if paragraphs.empty? && tables.empty?
81
82
 
82
83
  StoryChainController::State.new(
83
84
  paragraphs: paragraphs,
84
85
  current_paragraph: nil,
85
86
  runs_remaining: [],
86
87
  char_cursor: 0,
88
+ tables_remaining: tables,
87
89
  )
88
90
  end
89
91
  private_class_method :fresh_state
@@ -94,12 +96,20 @@ module Idml
94
96
  # so the containing TextFrame's box stands in. Synthetic
95
97
  # spread-level Tables dispatch separately via
96
98
  # 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)
99
+ # Renders the story's inline tables — fresh ones for a
100
+ # chain head, carried-over remainders for continuation
101
+ # frames — clipped to the frame bottom; unfinished tables
102
+ # stay in the chain state for the next frame (TODO 134).
103
+ def self.render_inline_tables(canvas, _story, box, context, state)
104
+ pairs = state ? state.tables_remaining.to_a : []
105
+ return if pairs.empty?
106
+
107
+ state.tables_remaining = pairs.filter_map do |table, start_row|
108
+ next_row = TableRenderer.render_in_box(
109
+ canvas, table, box, context,
110
+ start_row: start_row, bottom_limit: box[:y]
111
+ )
112
+ next_row ? [table, next_row] : nil
103
113
  end
104
114
  end
105
115
  private_class_method :render_inline_tables
@@ -1029,6 +1039,8 @@ module Idml
1029
1039
  cursor_y, bottom_limit)
1030
1040
 
1031
1041
  keep_all_lines_break?(paragraph, layout_frame, font,
1042
+ cursor_y, bottom_limit) ||
1043
+ keep_windows_break?(paragraph, layout_frame, font,
1032
1044
  cursor_y, bottom_limit)
1033
1045
  end
1034
1046
  private_class_method :paragraph_deferred?
@@ -1049,6 +1061,52 @@ module Idml
1049
1061
  end
1050
1062
  private_class_method :keep_all_lines_break?
1051
1063
 
1064
+ # Partial keep windows, approximated by measured line
1065
+ # counts: KeepFirstLines defers when fewer than N lines
1066
+ # fit; KeepLastLines defers when the overflow tail for the
1067
+ # next frame would strand fewer than N lines.
1068
+ def self.keep_windows_break?(paragraph, layout_frame, font,
1069
+ cursor_y, bottom_limit)
1070
+ return false unless paragraph.keep_first_lines ||
1071
+ paragraph.keep_last_lines
1072
+
1073
+ wrap_width = TextEngine::VerticalLayout.wrap_width(
1074
+ layout_frame, paragraph.right_indent || 0
1075
+ )
1076
+ height = TextEngine::Measurement.paragraph_height(
1077
+ paragraph, font, wrap_width
1078
+ )
1079
+ return false if (cursor_y - height) >= bottom_limit
1080
+
1081
+ leading = first_paragraph_leading_for(paragraph)
1082
+ lines_fit = [((cursor_y - bottom_limit) / leading).floor, 0].max
1083
+ first_window_break?(paragraph, lines_fit) ||
1084
+ last_window_break?(paragraph, lines_fit, height, leading)
1085
+ end
1086
+ private_class_method :keep_windows_break?
1087
+
1088
+ def self.first_window_break?(paragraph, lines_fit)
1089
+ paragraph.keep_first_lines &&
1090
+ lines_fit < paragraph.keep_first_lines
1091
+ end
1092
+ private_class_method :first_window_break?
1093
+
1094
+ def self.last_window_break?(paragraph, lines_fit, height, leading)
1095
+ return false unless paragraph.keep_last_lines
1096
+
1097
+ total_lines = [(height / leading).ceil, 1].max
1098
+ lines_fit.positive? &&
1099
+ (total_lines - lines_fit) < paragraph.keep_last_lines
1100
+ end
1101
+ private_class_method :last_window_break?
1102
+
1103
+ def self.first_paragraph_leading_for(paragraph)
1104
+ size = paragraph.runs.first&.point_size || DEFAULT_SIZE
1105
+ TextEngine::VerticalLayout.leading_for(paragraph.auto_leading,
1106
+ size)
1107
+ end
1108
+ private_class_method :first_paragraph_leading_for
1109
+
1052
1110
  # KeepWithNext: this paragraph defers when the next
1053
1111
  # paragraph is forced to the next frame, or when the next
1054
1112
  # 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/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.3"
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.3
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-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -192,6 +192,8 @@ files:
192
192
  - TODO.pdf/130-contour-text-wrap.md
193
193
  - TODO.pdf/131-inverse-wrap.md
194
194
  - TODO.pdf/132-line-end-compression.md
195
+ - TODO.pdf/133-keep-windows.md
196
+ - TODO.pdf/134-table-row-flow.md
195
197
  - TODO.pdf/14-page-item-element-models.md
196
198
  - TODO.pdf/15-wire-spread-children.md
197
199
  - TODO.pdf/16-typed-model-pipeline.md