idml 0.10.1 → 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: e7360c896ca2b95a5fa92dd030c71e3170b0f26039f5b48938f3ba038405bc46
4
- data.tar.gz: 1ce5fccd66f88f3dc5a25289e844cdfd99b391279f8bf8f659d74de9488c866e
3
+ metadata.gz: 79a78b04b80af7bc0018d4fde7500d164d8bc8b587a425c2fba89c5be19a7bb8
4
+ data.tar.gz: 0b835730671e71723e043372530b1fbb6765029c50da2025315408dd2caf4fa7
5
5
  SHA512:
6
- metadata.gz: e9aaaa8864f60931ebf50be726503f2b9db6313135262aa6d740d6fda4e06a57b03b8970aaaa87eddcd29a82e562cf0bd0ee56df227c2729782f276bca447e06
7
- data.tar.gz: b4a07f57002002cc027fb1459b9524107f1fc309a1d34cbb23575416054488589e355b4bc0a548ee59f6d69e50225dc2a8a021f4848007d9f04820b3925af778
6
+ metadata.gz: 3f79b3ee1dbe8a3d5cb01346f55935196e10d21ca3cd7d477132aaedafc200df0880179b568b8c071d5dbce62e77b2fe6275fdedb1a6d8fbbeb868f61b16a70f
7
+ data.tar.gz: 5d556bd0f53b5817051dd5ae419fab76c4e91534e3ff0f1415ff5e22fc10949743f2e6fb5992d0c2a669ea5e8aa4e3e2c20ebac7ddfe8ae991495267cd5a2671
data/README.adoc CHANGED
@@ -1,4 +1,64 @@
1
- = Ruby parser for Adobe IDML
1
+ = idml
2
2
 
3
- IDML is the InDesign Markup Language from Adobe used by InDesign.
3
+ A pure-Ruby toolkit for Adobe InDesign IDML files: parse, validate,
4
+ round-trip, compose, and render to PDF.
4
5
 
6
+ == What it does
7
+
8
+ * **Parse & round-trip** — every IDML part is a typed
9
+ `Lutaml::Model` class generated from Adobe's RelaxNG schemas
10
+ (`reference-docs/schemas/`): byte-faithful re-serialization with
11
+ `ZIP_STORED` mimetype ordering preserved.
12
+ * **Validate** — RelaxNG validation against the committed InDesign
13
+ 2026 (DOMVersion 21.5) schemas.
14
+ * **Compose** — `insert_idml`, `add_page_from_idml`, `prefix`,
15
+ `import_xml`/`export_xml` (ported from SimpleIDML).
16
+ * **Render to PDF** — a full pure-Ruby layout + paint pipeline
17
+ (pdfrb): multi-frame threaded stories, justification with
18
+ word/letter/glyph limits, footnotes (bottom-of-frame with
19
+ separator rules), anchored objects, tables (row/column banding,
20
+ per-side cell strokes, merged cells), Bézier shape contours,
21
+ gradients, text wrap (bounding-box, contour, and inverse modes),
22
+ paragraph shading/borders/rules, drop caps, bullets & numbering,
23
+ CJK layout (kinsoku shori, ruby, vertical writing with
24
+ tate-chu-yoko and rotated Latin, script spacing, line-end
25
+ punctuation compression), tagged PDF, hyperlinks, bookmarks,
26
+ PDF/A, and font subsetting.
27
+
28
+ == Install
29
+
30
+ ....
31
+ gem install idml
32
+ ....
33
+
34
+ `.indd` files are a proprietary binary format — export them to
35
+ `.idml` from InDesign first (File → Export), or use InDesign Server
36
+ for headless automation.
37
+
38
+ == Quick start
39
+
40
+ ....
41
+ require "idml"
42
+
43
+ # Round-trip a package
44
+ pkg = Idml::Package.new("doc.idml")
45
+ spec = pkg.spreads.first
46
+
47
+ # Render to PDF
48
+ Idml.render(package: pkg, to: "doc.pdf")
49
+ ....
50
+
51
+ == Requirements
52
+
53
+ * Ruby >= 3.3 (tested through 4.0 on macOS/Linux/Windows)
54
+
55
+ == Reference material
56
+
57
+ The `reference-docs/` tree carries Adobe's IDML specification, the
58
+ Plug-in SDK's idmltools (behavioral ground truth), and the
59
+ generated RelaxNG schemas — the attribute authority for every
60
+ model class.
61
+
62
+ == License
63
+
64
+ BSD-2-Clause.
@@ -24,7 +24,8 @@ kinsoku shori, ruby annotations).
24
24
  and vertical ruby (TODO 122), CJK/Latin automatic script spacing
25
25
  (TODO 125, a mojikumi subset wired into Shaper). The remaining
26
26
  stretch goal is full class-based mojikumi (per-pair punctuation
27
- compression tables). See `lib/idml/text_engine/cjk_layout.rb` and
27
+ compression tables); line-end punctuation compression landed
28
+ 2026-08-23 as TODO 132. See `lib/idml/text_engine/cjk_layout.rb` and
28
29
  `lib/idml/text_engine/vertical_text_layout.rb`.
29
30
 
30
31
  ## Progress 2026-08-17
@@ -0,0 +1,19 @@
1
+ # TODO PDF 131: Inverse text wrap
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-23
4
+
5
+ ## What was done
6
+
7
+ `TextWrapPreference Inverse="true"` flips the wrap region: text
8
+ may only flow INSIDE the wrap shape (the avoid-region becomes
9
+ everything outside it within the frame). Works for both the
10
+ bounding-box contour and the Contour-mode polygon shape (TODO
11
+ 130); the inversion lives in one place — TextWrapResolver's
12
+ overlap dispatch — and reduces by (frame width − inside width),
13
+ so bands outside the shape block text entirely.
14
+
15
+ ## Known limitations
16
+
17
+ - Side-aware narrowing is not implemented: the reduction shrinks
18
+ the wrap width from the right regardless of which side the
19
+ shape occupies.
@@ -0,0 +1,22 @@
1
+ # TODO PDF 132: Line-end punctuation compression (mojikumi subset 2)
2
+
3
+ ## Status: COMPLETE — implemented 2026-08-23
4
+
5
+ ## What was done
6
+
7
+ `CjkLayout.apply_line_end_compression`: a CJK line whose final
8
+ glyph is full-width closing punctuation (。、」』)]}ー etc.) renders
9
+ that glyph at half advance (行末約物半角詰め), tightening the line
10
+ as InDesign's default mojikumi does. Applied in LineBreaker's CJK
11
+ post-pass after kinsoku shori, so horizontal and vertical layout
12
+ both benefit.
13
+
14
+ Also fixed a latent bug the specs caught: kinsoku's return value
15
+ (new dup'd line array) was discarded when composing the post-pass
16
+ chain — kinsoku adjustments silently vanished once a second
17
+ post-pass followed it.
18
+
19
+ ## Remaining from TODO 13
20
+
21
+ Per-pair punctuation compression tables (full class-based
22
+ mojikumi).
@@ -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,20 +15,21 @@ 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
24
24
  odd/even page parity.
25
- - **Text wrap**: BoundingBoxTextWrap and Contour modes render
26
- (TODO 130); JumpObject/NextColumn approximate as the box;
27
- Inverse mode is not implemented.
25
+ - **Text wrap**: BoundingBoxTextWrap, Contour, and Inverse modes
26
+ render (TODOs 130-131); JumpObject/NextColumn approximate as
27
+ the box; side-aware narrowing is not implemented.
28
28
 
29
29
  ### CJK
30
- - **Mojikumi**: CJK/Latin auto script spacing is applied (TODO
31
- 125); full class-based punctuation compression tables are not.
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.
32
33
  - **Vertical mode**: Latin rotation is per glyph (not run-grouped);
33
34
  ruby placement is beside-column; keep options do not apply.
34
35
 
@@ -41,8 +42,9 @@ this list reflects the current state.
41
42
  footnote text.
42
43
  - **Anchored objects** render at stored geometry; AnchorType
43
44
  text-reflow is not simulated.
44
- - **Tables** do not span frames (no repeated header rows); diagonal
45
- 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.
46
48
  - **Inline anchored objects / footnotes inside table cells** are
47
49
  not handled.
48
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),
@@ -14,7 +14,7 @@ module Idml
14
14
  class TextWrapResolver
15
15
  # A wrap contour: a PDF-coordinate rectangle the text avoids.
16
16
  Contour = Struct.new(
17
- :x, :y, :width, :height,
17
+ :x, :y, :width, :height, :inverse,
18
18
  keyword_init: true
19
19
  )
20
20
 
@@ -40,16 +40,22 @@ module Idml
40
40
  # Returns the total horizontal overlap of all contours with a
41
41
  # text line at the given y range within the given x range.
42
42
  # Sum, not max — multiple overlapping contours compound.
43
- # Returns 0.0 when there's no overlap.
43
+ # Returns 0.0 when there's no overlap. Inverse contours
44
+ # reduce by the frame width minus their own width (text may
45
+ # only flow inside).
44
46
  def overlap_width(line_y, line_height, frame_x, frame_right)
45
47
  @contours.sum do |contour|
46
- if contour.is_a?(WrapContour::Shape)
47
- WrapContour.overlap_width(contour, line_y, line_height,
48
- frame_x, frame_right)
49
- else
50
- line_overlap(contour, line_y, line_height,
51
- frame_x, frame_right)[:width]
52
- end
48
+ width = if contour.is_a?(WrapContour::Shape)
49
+ WrapContour.overlap_width(contour, line_y,
50
+ line_height, frame_x,
51
+ frame_right)
52
+ else
53
+ line_overlap(contour, line_y, line_height,
54
+ frame_x, frame_right)[:width]
55
+ end
56
+ next width unless contour.inverse
57
+
58
+ (frame_right - frame_x) - width
53
59
  end
54
60
  end
55
61
 
@@ -12,8 +12,10 @@ module Idml
12
12
  FLATTEN_STEPS = 8
13
13
 
14
14
  # A wrap shape: flattened polygons (one per subpath) plus the
15
- # TextWrapOffset bounds.
16
- Shape = Struct.new(:polygons, :offset, keyword_init: true)
15
+ # TextWrapOffset bounds. `inverse` flips the wrap region:
16
+ # text may only flow INSIDE the shape.
17
+ Shape = Struct.new(:polygons, :offset, :inverse,
18
+ keyword_init: true)
17
19
 
18
20
  # Builds the wrap shape for an item's PathGeometry and wrap
19
21
  # preference. Returns nil when the item has no geometry.
@@ -21,7 +23,8 @@ module Idml
21
23
  polygons = polygons_for(item, page_height)
22
24
  return nil if polygons.empty?
23
25
 
24
- Shape.new(polygons: polygons, offset: wrap_offset(pref))
26
+ Shape.new(polygons: polygons, offset: wrap_offset(pref),
27
+ inverse: pref.inverse)
25
28
  end
26
29
 
27
30
  def self.polygons_for(item, page_height)
@@ -118,6 +121,7 @@ module Idml
118
121
 
119
122
  # Total horizontal overlap of the text line's band with the
120
123
  # shape, expanded by the offsets and clipped to the frame.
124
+ # The caller (TextWrapResolver) owns inverse flipping.
121
125
  def self.overlap_width(shape, line_y, line_height, frame_x,
122
126
  frame_right)
123
127
  mid_y = line_y + (line_height / 2.0)
@@ -39,6 +39,41 @@ module Idml
39
39
  (0x61..0x7A).cover?(codepoint)
40
40
  end
41
41
 
42
+ # Mojikumi subset 2: line-end punctuation compression —
43
+ # full-width closing punctuation occupying a line's final
44
+ # position renders at half advance (行末約物半角詰め),
45
+ # tightening the line as InDesign's default mojikumi does.
46
+ TRAILING_COMPRESSION_CODEPOINTS = [
47
+ 0x3001, # 、 ideographic comma
48
+ 0x3002, # 。 ideographic full stop
49
+ 0x3009, # 〉
50
+ 0x300B, # 》
51
+ 0x300D, # 」
52
+ 0x300F, # 』
53
+ 0x3011, # 】
54
+ 0x3015, # 〕
55
+ 0xFF09, # )fullwidth right paren
56
+ 0xFF3D, # ]
57
+ 0xFF5D, # }
58
+ 0x30FC, # ー prolonged sound mark
59
+ ].freeze
60
+
61
+ def apply_line_end_compression(lines)
62
+ lines.each do |line|
63
+ last = line.glyphs.last
64
+ next unless last
65
+ next unless compressible?(last.codepoint)
66
+
67
+ last.width /= 2.0
68
+ line.width = line.glyphs.sum(&:width)
69
+ end
70
+ lines
71
+ end
72
+
73
+ def compressible?(codepoint)
74
+ TRAILING_COMPRESSION_CODEPOINTS.include?(codepoint)
75
+ end
76
+
42
77
  CJK_RANGES = [
43
78
  0x3000..0x303F, # CJK Symbols and Punctuation
44
79
  0x3040..0x309F, # Hiragana
@@ -14,7 +14,8 @@ module Idml
14
14
  lines = new(frame_width).break(glyphs)
15
15
  return lines unless cjk_run?(glyphs)
16
16
 
17
- CjkLayout.apply_kinsoku(lines)
17
+ lines = CjkLayout.apply_kinsoku(lines)
18
+ CjkLayout.apply_line_end_compression(lines)
18
19
  end
19
20
 
20
21
  def self.cjk_run?(glyphs)
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.1"
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.1
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
@@ -190,6 +190,10 @@ files:
190
190
  - TODO.pdf/129-justification-glyph-scaling.md
191
191
  - TODO.pdf/13-cjk-text-layout.md
192
192
  - TODO.pdf/130-contour-text-wrap.md
193
+ - TODO.pdf/131-inverse-wrap.md
194
+ - TODO.pdf/132-line-end-compression.md
195
+ - TODO.pdf/133-keep-windows.md
196
+ - TODO.pdf/134-table-row-flow.md
193
197
  - TODO.pdf/14-page-item-element-models.md
194
198
  - TODO.pdf/15-wire-spread-children.md
195
199
  - TODO.pdf/16-typed-model-pipeline.md