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 +4 -4
- data/README.adoc +62 -2
- data/TODO.pdf/13-cjk-text-layout.md +2 -1
- data/TODO.pdf/131-inverse-wrap.md +19 -0
- data/TODO.pdf/132-line-end-compression.md +22 -0
- 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 +12 -10
- data/lib/idml/render/renderers/table_renderer.rb +98 -13
- data/lib/idml/render/renderers/text_frame_renderer.rb +67 -9
- data/lib/idml/render/story_chain_controller.rb +3 -0
- data/lib/idml/render/style_resolver.rb +8 -1
- data/lib/idml/render/text_wrap_resolver.rb +15 -9
- data/lib/idml/render/wrap_contour.rb +7 -3
- data/lib/idml/text_engine/cjk_layout.rb +35 -0
- data/lib/idml/text_engine/line_breaker.rb +2 -1
- data/lib/idml/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79a78b04b80af7bc0018d4fde7500d164d8bc8b587a425c2fba89c5be19a7bb8
|
|
4
|
+
data.tar.gz: 0b835730671e71723e043372530b1fbb6765029c50da2025315408dd2caf4fa7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f79b3ee1dbe8a3d5cb01346f55935196e10d21ca3cd7d477132aaedafc200df0880179b568b8c071d5dbce62e77b2fe6275fdedb1a6d8fbbeb868f61b16a70f
|
|
7
|
+
data.tar.gz: 5d556bd0f53b5817051dd5ae419fab76c4e91534e3ff0f1415ff5e22fc10949743f2e6fb5992d0c2a669ea5e8aa4e3e2c20ebac7ddfe8ae991495267cd5a2671
|
data/README.adoc
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
|
-
=
|
|
1
|
+
= idml
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
|
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**:
|
|
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
|
|
24
24
|
odd/even page parity.
|
|
25
|
-
- **Text wrap**: BoundingBoxTextWrap and
|
|
26
|
-
(
|
|
27
|
-
|
|
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
|
|
31
|
-
|
|
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**
|
|
45
|
-
|
|
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
|
-
|
|
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,15 @@ module Idml
|
|
|
77
77
|
style_lookup: context.style_lookup,
|
|
78
78
|
footnote_option: Footnote.option(context.package)
|
|
79
79
|
)
|
|
80
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
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.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-
|
|
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
|