idml 0.10.12 → 0.10.14
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/TODO.pdf/151-schema-conformance.md +48 -0
- data/TODO.pdf/152-rtl-alignment.md +27 -0
- data/TODO.pdf/153-vertical-keeps.md +31 -0
- data/TODO.pdf/61-known-limitations.md +9 -7
- data/lib/idml/elements/inset_spacing.rb +31 -0
- data/lib/idml/elements/properties.rb +2 -0
- data/lib/idml/elements/text_frame_preference.rb +4 -9
- data/lib/idml/elements.rb +1 -0
- data/lib/idml/render/footnote.rb +1 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +27 -6
- data/lib/idml/render/renderers/vertical_emit.rb +70 -2
- data/lib/idml/render/style_resolver.rb +6 -0
- data/lib/idml/schema/rnc.rb +88 -0
- data/lib/idml/schema.rb +12 -0
- data/lib/idml/text_engine/justifier.rb +16 -5
- data/lib/idml/version.rb +1 -1
- data/lib/idml.rb +1 -0
- data/scripts/rnc_to_lutaml.rb +13 -57
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00f5eb099cc78c69be52672e179bc0d2c10b102554bb74b28272b04d0ed904c6
|
|
4
|
+
data.tar.gz: c7936ca3c76fef90639d532cc70c0deb7423b2dcd9cd3d73ce6b88bff5a1257c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 354bb00de55fe629d84853a8e1dfdc2c8b821f708fc15584cf9ac509ba3489a56f92f48f71698cfcf9b28c9f3814b4c0366f3a88d40aa3e2e05b33b242e20a60
|
|
7
|
+
data.tar.gz: 52cc665c20cdf7c8c766593e38fd18b847f15da0ac3a09b1accfd5dbbe2b48614a7eadb5452b8f4b295bff1870c7a2acb1a48a0c03174f15916f681d62c4a16c
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# TODO PDF 151: Schema conformance for the Elements layer
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-31
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Architecture review round four: 147 element classes held hundreds
|
|
8
|
+
of wire attributes transcribed from the RNC, but only 11 classes
|
|
9
|
+
had attribute-set specs — the project's own drift defense
|
|
10
|
+
(CLAUDE.md convention 5) covered a tenth of the surface. Any
|
|
11
|
+
hand-edit that added, dropped, or misspelled a `map_attribute`
|
|
12
|
+
passed every test.
|
|
13
|
+
|
|
14
|
+
## Solution
|
|
15
|
+
|
|
16
|
+
- **`Idml::Schema::Rnc`** (`schema/rnc.rb`): the RNC parsing
|
|
17
|
+
extracted from the generator script into a lib adapter
|
|
18
|
+
(`element_attribute_map`, `element_definition`). Two consumers
|
|
19
|
+
over one seam — `scripts/rnc_to_lutaml.rb` (now presentation
|
|
20
|
+
only) and the conformance spec.
|
|
21
|
+
- **Schema-conformance spec** (`elements_schema_conformance_spec.rb`):
|
|
22
|
+
for every Elements class — no wire attribute outside the RNC
|
|
23
|
+
universe (catches typos and inventions), and every mapped
|
|
24
|
+
destination is a declared attribute (mapping/declaration
|
|
25
|
+
consistency). Legacy non-schema classes (TableCell) carry an
|
|
26
|
+
explicit allow-list. Full equality is documented as
|
|
27
|
+
unenforceable (deliberate subsets; typedef-composed attributes).
|
|
28
|
+
|
|
29
|
+
**Bug found by the probe and fixed**: `TextFramePreference`
|
|
30
|
+
carried four invented attributes (`InsetTop/Left/Bottom/Right`)
|
|
31
|
+
that appear in no schema, no spec, and no real file — while the
|
|
32
|
+
schema-faithful inset carrier (`Properties > InsetSpacing`, spec
|
|
33
|
+
examples 31/32) was unmodeled, so real documents' text frame
|
|
34
|
+
insets were silently ignored. New `Elements::InsetSpacing`
|
|
35
|
+
(single unit → all sides; list → [top, right, bottom, left]),
|
|
36
|
+
wired into `layout_frame`; the invented attributes removed.
|
|
37
|
+
|
|
38
|
+
## Files
|
|
39
|
+
|
|
40
|
+
- `lib/idml/schema.rb`, `lib/idml/schema/rnc.rb` (new)
|
|
41
|
+
- `scripts/rnc_to_lutaml.rb`
|
|
42
|
+
- `lib/idml/elements/inset_spacing.rb` (new)
|
|
43
|
+
- `lib/idml/elements/text_frame_preference.rb`,
|
|
44
|
+
`lib/idml/elements/properties.rb`, `lib/idml/elements.rb`
|
|
45
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb`,
|
|
46
|
+
`lib/idml.rb`
|
|
47
|
+
- `spec/idml/elements_schema_conformance_spec.rb` (new)
|
|
48
|
+
- `spec/idml/elements/inset_spacing_spec.rb` (new)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# TODO PDF 152: RTL paragraph alignment
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE — implemented 2026-08-31
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
ParagraphDirection (LeftToRightDirection /
|
|
8
|
+
RightToLeftDirection) was parsed but unused: RTL paragraphs
|
|
9
|
+
aligned left/right by LTR convention, so Arabic/Hebrew text sat
|
|
10
|
+
on the wrong edge.
|
|
11
|
+
|
|
12
|
+
## Solution
|
|
13
|
+
|
|
14
|
+
`StyleResolver::Paragraph` carries `paragraph_direction` (PSR or
|
|
15
|
+
paragraph style); `TextEngine::Justifier` gains a `direction:`
|
|
16
|
+
argument that mirrors the visual alignment under
|
|
17
|
+
RightToLeftDirection — left↔right, start↔end; center and
|
|
18
|
+
justified unchanged. Both justify call sites (frame renderer and
|
|
19
|
+
footnote layout) pass the paragraph's direction.
|
|
20
|
+
|
|
21
|
+
## Files
|
|
22
|
+
|
|
23
|
+
- `lib/idml/text_engine/justifier.rb`
|
|
24
|
+
- `lib/idml/render/style_resolver.rb`
|
|
25
|
+
- `lib/idml/render/renderers/text_frame_renderer.rb`
|
|
26
|
+
- `lib/idml/render/footnote.rb`
|
|
27
|
+
- `spec/idml/text_engine/text_engine_spec.rb`
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# TODO PDF 153: Keep options in vertical writing
|
|
2
|
+
|
|
3
|
+
## Status: COMPLETE (approximation) — implemented 2026-08-31
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Keep options (TODOs 123/127/133) applied only to the horizontal
|
|
8
|
+
engine; vertical paragraphs split across frames regardless of
|
|
9
|
+
KeepAllLinesTogether / KeepWithNext.
|
|
10
|
+
|
|
11
|
+
## Solution
|
|
12
|
+
|
|
13
|
+
`VerticalEmit.vertical_render` consults a keep check BEFORE
|
|
14
|
+
placing each paragraph (deferral after placement cannot un-emit):
|
|
15
|
+
|
|
16
|
+
- **KeepAllLinesTogether** defers the paragraph wholly to the
|
|
17
|
+
next frame when its column need exceeds the remaining columns.
|
|
18
|
+
- **KeepWithNext** also defers when the follower's forced break
|
|
19
|
+
or column need would strand it.
|
|
20
|
+
- Never defers the frame's first paragraph — the same progress
|
|
21
|
+
guarantee as KeepPolicy (shared: `KeepPolicy.paragraph_break?`).
|
|
22
|
+
|
|
23
|
+
Approximation: column need = runs count (one column per run),
|
|
24
|
+
which under-estimates runs spanning several columns — deferral
|
|
25
|
+
errs toward placing. KeepFirstLines/KeepLastLines windows remain
|
|
26
|
+
horizontal-only (they are line-window concepts).
|
|
27
|
+
|
|
28
|
+
## Files
|
|
29
|
+
|
|
30
|
+
- `lib/idml/render/renderers/vertical_emit.rb`
|
|
31
|
+
- `spec/idml/render/vertical_render_spec.rb`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TODO PDF 61: Known limitations and future work
|
|
2
2
|
|
|
3
|
-
## Status: DOCUMENTED — refreshed 2026-08-
|
|
3
|
+
## Status: DOCUMENTED — refreshed 2026-08-31
|
|
4
4
|
|
|
5
5
|
Previously listed limitations (font embedding, tagged PDF, per-run
|
|
6
6
|
styling, dead code) were resolved by TODOs 52/53/55/67/76 long ago;
|
|
@@ -14,8 +14,9 @@ this list reflects the current state.
|
|
|
14
14
|
wrap after explicit hyphens (TODO 139).
|
|
15
15
|
- **Justification**: word/letter spacing caps apply (TODO 119),
|
|
16
16
|
MaximumGlyphScaling stretches (TODO 129) and MinimumGlyphScaling
|
|
17
|
-
compresses overlong lines (TODO 135);
|
|
18
|
-
alignment
|
|
17
|
+
compresses overlong lines (TODO 135); RTL paragraphs mirror
|
|
18
|
+
left/right alignment (TODO 152); ToBinding / kashida-based
|
|
19
|
+
Arabic justification is not applied.
|
|
19
20
|
- **Keep options**: KeepAllLinesTogether (TODO 123), KeepWithNext
|
|
20
21
|
(TODO 127), and the KeepFirstLines / KeepLastLines windows
|
|
21
22
|
(TODO 133, line-count approximation) are honored.
|
|
@@ -39,8 +40,9 @@ this list reflects the current state.
|
|
|
39
40
|
Desired applies per class pair) are applied; Minimum/Maximum
|
|
40
41
|
aki bounds and CompressionPriority are parsed but unused.
|
|
41
42
|
- **Vertical mode**: Latin runs rotate as one group per segment
|
|
42
|
-
(TODO 142); ruby placement is beside-column;
|
|
43
|
-
|
|
43
|
+
(TODO 142); ruby placement is beside-column; KeepAllLines /
|
|
44
|
+
KeepWithNext apply with a column-count approximation (TODO 153);
|
|
45
|
+
keep windows are horizontal-only.
|
|
44
46
|
|
|
45
47
|
### Structural features
|
|
46
48
|
- **Endnotes** (TODO 117): reference markers render and endnote TEXT
|
|
@@ -62,5 +64,5 @@ this list reflects the current state.
|
|
|
62
64
|
- Hyphenation dictionary integration
|
|
63
65
|
- NextColumn wrap via column-jump chaining (multi-column frames)
|
|
64
66
|
- Minimum/Maximum mojikumi aki bounds + CompressionPriority
|
|
65
|
-
- ToBinding /
|
|
66
|
-
- Vertical
|
|
67
|
+
- ToBinding / kashida Arabic justification
|
|
68
|
+
- Vertical keep windows (KeepFirstLines / KeepLastLines)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Elements
|
|
5
|
+
# `<InsetSpacing>` inside TextFramePreference's Properties —
|
|
6
|
+
# the schema-faithful carrier of text frame insets (the
|
|
7
|
+
# spec's examples 31/32): a single `type="unit"` value applies
|
|
8
|
+
# to all sides; a `type="list"` of ListItems carries
|
|
9
|
+
# [top, right, bottom, left].
|
|
10
|
+
class InsetSpacing < Lutaml::Model::Serializable
|
|
11
|
+
attribute :type, :string
|
|
12
|
+
attribute :value, :string
|
|
13
|
+
attribute :list_item, TypedValue, collection: true
|
|
14
|
+
|
|
15
|
+
xml do
|
|
16
|
+
root "InsetSpacing"
|
|
17
|
+
map_attribute "type", to: :type
|
|
18
|
+
map_content to: :value
|
|
19
|
+
map_element "ListItem", to: :list_item
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# [top, right, bottom, left] insets; nil components when the
|
|
23
|
+
# document doesn't declare them.
|
|
24
|
+
def sides
|
|
25
|
+
return Array.new(4, value&.to_f) if list_item.empty?
|
|
26
|
+
|
|
27
|
+
(0...4).map { |i| list_item[i]&.value&.to_f }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -18,6 +18,7 @@ module Idml
|
|
|
18
18
|
attribute :paragraph_border_color, Idml::Elements::TypedValue
|
|
19
19
|
attribute :text_wrap_offset, Idml::Elements::TypedValue
|
|
20
20
|
attribute :paragraph_border_gap_color, Idml::Elements::TypedValue
|
|
21
|
+
attribute :inset_spacing, Idml::Elements::InsetSpacing
|
|
21
22
|
|
|
22
23
|
xml do
|
|
23
24
|
root "Properties"
|
|
@@ -29,6 +30,7 @@ module Idml
|
|
|
29
30
|
map_element "ParagraphBorderColor", to: :paragraph_border_color
|
|
30
31
|
map_element "TextWrapOffset", to: :text_wrap_offset
|
|
31
32
|
map_element "ParagraphBorderGapColor", to: :paragraph_border_gap_color
|
|
33
|
+
map_element "InsetSpacing", to: :inset_spacing
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def first_geometry
|
|
@@ -5,12 +5,10 @@ module Idml
|
|
|
5
5
|
# Typed model of `<TextFramePreference>`. Every
|
|
6
6
|
# attribute from `TextFramePreference_Object` in
|
|
7
7
|
# `reference-docs/schemas/package/Resources/Styles.rnc` is declared (33 attributes),
|
|
8
|
-
# generated by `scripts/rnc_to_lutaml.rb`.
|
|
8
|
+
# generated by `scripts/rnc_to_lutaml.rb`. Insets live in the
|
|
9
|
+
# Properties > InsetSpacing child (spec examples 31/32).
|
|
9
10
|
class TextFramePreference < Lutaml::Model::Serializable
|
|
10
|
-
attribute :
|
|
11
|
-
attribute :inset_left, :float
|
|
12
|
-
attribute :inset_bottom, :float
|
|
13
|
-
attribute :inset_right, :float
|
|
11
|
+
attribute :properties, Idml::Elements::Properties, collection: true
|
|
14
12
|
attribute :footnotes_enable_overrides, :boolean
|
|
15
13
|
attribute :footnotes_span_across_columns, :boolean
|
|
16
14
|
attribute :footnotes_minimum_spacing, :float
|
|
@@ -47,10 +45,7 @@ module Idml
|
|
|
47
45
|
|
|
48
46
|
xml do
|
|
49
47
|
root "TextFramePreference"
|
|
50
|
-
|
|
51
|
-
map_attribute "InsetLeft", to: :inset_left
|
|
52
|
-
map_attribute "InsetBottom", to: :inset_bottom
|
|
53
|
-
map_attribute "InsetRight", to: :inset_right
|
|
48
|
+
map_element "Properties", to: :properties
|
|
54
49
|
map_attribute "FootnotesEnableOverrides", to: :footnotes_enable_overrides
|
|
55
50
|
map_attribute "FootnotesSpanAcrossColumns", to: :footnotes_span_across_columns
|
|
56
51
|
map_attribute "FootnotesMinimumSpacing", to: :footnotes_minimum_spacing
|
data/lib/idml/elements.rb
CHANGED
|
@@ -90,6 +90,7 @@ module Idml
|
|
|
90
90
|
autoload :MasterSpreadObject, "idml/elements/master_spread_object"
|
|
91
91
|
autoload :MixedInk, "idml/elements/mixed_ink"
|
|
92
92
|
autoload :MixedInkGroup, "idml/elements/mixed_ink_group"
|
|
93
|
+
autoload :InsetSpacing, "idml/elements/inset_spacing"
|
|
93
94
|
autoload :MojikumiTable, "idml/elements/mojikumi_table"
|
|
94
95
|
autoload :MojikumiTableProperties, "idml/elements/mojikumi_table"
|
|
95
96
|
autoload :OverrideMojikumiAki, "idml/elements/mojikumi_table"
|
data/lib/idml/render/footnote.rb
CHANGED
|
@@ -138,6 +138,7 @@ module Idml
|
|
|
138
138
|
TextEngine::Justifier.justify(
|
|
139
139
|
line: line, frame_width: wrap_width,
|
|
140
140
|
alignment: paragraph.alignment || :left,
|
|
141
|
+
direction: paragraph.paragraph_direction,
|
|
141
142
|
last_line: last_line?(paragraph, run, index, lines),
|
|
142
143
|
limits: limits
|
|
143
144
|
)
|
|
@@ -244,20 +244,40 @@ module Idml
|
|
|
244
244
|
# back to 0 when the preference is absent or doesn't declare
|
|
245
245
|
# them.
|
|
246
246
|
def self.layout_frame(frame_item, box)
|
|
247
|
-
|
|
247
|
+
top, right, bottom, left = frame_insets(
|
|
248
|
+
frame_item.text_frame_preference&.first,
|
|
249
|
+
)
|
|
248
250
|
TextEngine::Frame.new(
|
|
249
251
|
x: box[:x],
|
|
250
252
|
y: box[:y],
|
|
251
253
|
width: box[:width],
|
|
252
254
|
height: box[:height],
|
|
253
|
-
inset_top:
|
|
254
|
-
inset_bottom:
|
|
255
|
-
inset_left:
|
|
256
|
-
inset_right:
|
|
255
|
+
inset_top: top,
|
|
256
|
+
inset_bottom: bottom,
|
|
257
|
+
inset_left: left,
|
|
258
|
+
inset_right: right,
|
|
257
259
|
)
|
|
258
260
|
end
|
|
259
261
|
private_class_method :layout_frame
|
|
260
262
|
|
|
263
|
+
# Insets from Properties > InsetSpacing (spec examples
|
|
264
|
+
# 31/32): one unit value applies to all sides; a list
|
|
265
|
+
# carries [top, right, bottom, left]. Missing sides are 0.
|
|
266
|
+
def self.frame_insets(pref)
|
|
267
|
+
props = pref&.properties
|
|
268
|
+
spacing = props.first&.inset_spacing if props
|
|
269
|
+
return Array.new(4, 0.0) unless spacing
|
|
270
|
+
|
|
271
|
+
side_or_zero(spacing)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def self.side_or_zero(spacing)
|
|
275
|
+
[0, 1, 2, 3].map do |index|
|
|
276
|
+
spacing.sides[index] || 0.0
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
private_class_method :frame_insets
|
|
280
|
+
|
|
261
281
|
# Walks the chain state's paragraphs/runs and emits one
|
|
262
282
|
# `canvas.text` per line via the Shaper → LineBreaker →
|
|
263
283
|
# Justifier → VerticalLayout pipeline. Cursor y descends
|
|
@@ -833,7 +853,8 @@ module Idml
|
|
|
833
853
|
line: line, frame_width: wrap_width,
|
|
834
854
|
alignment: paragraph.alignment || :left,
|
|
835
855
|
last_line: paragraph_last && index == lines.length - 1,
|
|
836
|
-
limits: limits
|
|
856
|
+
limits: limits,
|
|
857
|
+
direction: paragraph.paragraph_direction
|
|
837
858
|
)
|
|
838
859
|
end
|
|
839
860
|
end
|
|
@@ -24,10 +24,20 @@ module Idml
|
|
|
24
24
|
layout_frame = layout_frame(context.item, box)
|
|
25
25
|
left_limit = layout_frame.x + (layout_frame.inset_left || 0)
|
|
26
26
|
resume_vertical_paragraph(state)
|
|
27
|
-
|
|
27
|
+
entry_offset = state.column_offset || 0
|
|
28
|
+
next_column = entry_offset
|
|
28
29
|
pending = 0
|
|
29
30
|
|
|
30
|
-
state.paragraphs.
|
|
31
|
+
state.paragraphs.each_with_index do |paragraph, index|
|
|
32
|
+
if vertical_keep_deferred?(paragraph, state.paragraphs[index + 1],
|
|
33
|
+
layout_frame, font, left_limit,
|
|
34
|
+
next_column, pending, entry_offset)
|
|
35
|
+
state.current_paragraph = nil
|
|
36
|
+
state.runs_remaining = []
|
|
37
|
+
state.column_offset = 0
|
|
38
|
+
break
|
|
39
|
+
end
|
|
40
|
+
|
|
31
41
|
remaining_runs, next_column = vertical_paragraph(
|
|
32
42
|
canvas, paragraph, context, layout_frame, font,
|
|
33
43
|
left_limit, next_column
|
|
@@ -45,6 +55,64 @@ module Idml
|
|
|
45
55
|
state.paragraphs = state.paragraphs[pending..]
|
|
46
56
|
end
|
|
47
57
|
|
|
58
|
+
# Keep options in vertical mode (TODO 153, column-count
|
|
59
|
+
# approximation — one column per run under-estimates runs
|
|
60
|
+
# that span several columns, so deferral errs toward
|
|
61
|
+
# placing): KeepAllLinesTogether defers the paragraph
|
|
62
|
+
# wholly to the next frame; KeepWithNext also defers when
|
|
63
|
+
# the follower (or its forced break) would strand. Never
|
|
64
|
+
# defers the frame's first paragraph — the progress
|
|
65
|
+
# guarantee, shared with KeepPolicy.
|
|
66
|
+
def vertical_keep_deferred?(paragraph, next_paragraph,
|
|
67
|
+
layout_frame, _font, left_limit,
|
|
68
|
+
next_column, pending, entry_offset)
|
|
69
|
+
placed = pending.positive? || entry_offset.positive?
|
|
70
|
+
return false unless placed
|
|
71
|
+
return false unless keep_flagged?(paragraph)
|
|
72
|
+
|
|
73
|
+
needed = [paragraph.runs.length, 1].max
|
|
74
|
+
return true if paragraph.keep_all_lines_together &&
|
|
75
|
+
needed > vertical_room(layout_frame, paragraph, left_limit,
|
|
76
|
+
next_column)
|
|
77
|
+
|
|
78
|
+
with_next_deferred?(paragraph, next_paragraph, layout_frame,
|
|
79
|
+
left_limit, next_column, needed)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def keep_flagged?(paragraph)
|
|
83
|
+
paragraph.keep_all_lines_together ||
|
|
84
|
+
!paragraph.keep_with_next.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def with_next_deferred?(paragraph, next_paragraph, layout_frame,
|
|
88
|
+
left_limit, next_column, needed)
|
|
89
|
+
return false if paragraph.keep_with_next.nil?
|
|
90
|
+
|
|
91
|
+
follower_needed = [next_paragraph&.runs&.length || 1, 1].max
|
|
92
|
+
follower_break = KeepPolicy.paragraph_break?(next_paragraph)
|
|
93
|
+
room = vertical_room(layout_frame, paragraph, left_limit,
|
|
94
|
+
next_column)
|
|
95
|
+
follower_break || needed + follower_needed > room
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def vertical_room(layout_frame, paragraph, left_limit,
|
|
99
|
+
next_column)
|
|
100
|
+
leading = TextEngine::VerticalLayout.leading_for(
|
|
101
|
+
paragraph.auto_leading,
|
|
102
|
+
paragraph.runs.first&.point_size ||
|
|
103
|
+
TextFrameRenderer::DEFAULT_SIZE,
|
|
104
|
+
)
|
|
105
|
+
vertical_available_columns(layout_frame, leading, left_limit,
|
|
106
|
+
next_column)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def vertical_available_columns(layout_frame, leading, left_limit,
|
|
110
|
+
next_column)
|
|
111
|
+
right = layout_frame.x + layout_frame.width -
|
|
112
|
+
(layout_frame.inset_right || 0)
|
|
113
|
+
(((right - left_limit) / leading).floor - next_column).to_i
|
|
114
|
+
end
|
|
115
|
+
|
|
48
116
|
# Folds a chain-resumed paragraph (runs_remaining) back to
|
|
49
117
|
# the head of the paragraph list so the vertical path sees a
|
|
50
118
|
# uniform sequence. The consumed column offset persists in
|
|
@@ -70,6 +70,9 @@ module Idml
|
|
|
70
70
|
:maximum_letter_spacing,
|
|
71
71
|
:maximum_glyph_scaling,
|
|
72
72
|
:minimum_glyph_scaling,
|
|
73
|
+
# ParagraphDirection (LeftToRightDirection /
|
|
74
|
+
# RightToLeftDirection) — mirrors alignment under RTL.
|
|
75
|
+
:paragraph_direction,
|
|
73
76
|
# Forced paragraph break (StartParagraph: NextPage /
|
|
74
77
|
# NextColumn / NextFrame / NextOddPage / NextEvenPage).
|
|
75
78
|
:start_paragraph,
|
|
@@ -197,6 +200,9 @@ module Idml
|
|
|
197
200
|
paragraph = Paragraph.new(
|
|
198
201
|
runs: runs,
|
|
199
202
|
alignment: runs.first.alignment,
|
|
203
|
+
paragraph_direction: resolve_attr(
|
|
204
|
+
style_lookup, psr, :paragraph_direction
|
|
205
|
+
),
|
|
200
206
|
space_before: resolve_attr(style_lookup, psr, :space_before),
|
|
201
207
|
space_after: resolve_attr(style_lookup, psr, :space_after),
|
|
202
208
|
first_line_indent: resolve_attr(style_lookup, psr,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Schema
|
|
5
|
+
# Parses RelaxNG Compact element definitions into data:
|
|
6
|
+
# element name → wire attribute names. Handles the Adobe
|
|
7
|
+
# package schemas' shape (`NAME_Object = element Name { ... }`
|
|
8
|
+
# with balanced braces; attributes as `attribute X { type }`).
|
|
9
|
+
# Two consumers over this seam: the class generator script and
|
|
10
|
+
# the schema-conformance spec.
|
|
11
|
+
module Rnc
|
|
12
|
+
ELEMENT_DEF = /(\w+)\s*+=\s*+element\s*+([\w:]+)\s*+\{/
|
|
13
|
+
ATTRIBUTE_DEF = /attribute\s++(\w+)\s*+\{([^}]*)\}/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# { "Story" => [["Self", ...]], ... } for every element
|
|
18
|
+
# definition across the given RNC files. An element defined
|
|
19
|
+
# in several files keeps each definition as an alternative
|
|
20
|
+
# (array of attribute-name arrays) — a class conforms when
|
|
21
|
+
# its wire attributes equal ANY one alternative.
|
|
22
|
+
def element_attribute_map(paths)
|
|
23
|
+
definitions = Hash.new { |h, k| h[k] = [] }
|
|
24
|
+
each_element(paths) do |element_name, body|
|
|
25
|
+
definitions[element_name] << body_attributes(body)
|
|
26
|
+
end
|
|
27
|
+
definitions.to_h
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# [xml_element_name, attrs_with_types] for one named
|
|
31
|
+
# definition (`Story_Object`) in one file; nil when absent.
|
|
32
|
+
# attrs_with_types: [["Self", "xsd:string"], ...].
|
|
33
|
+
def element_definition(path, definition_name)
|
|
34
|
+
src = File.read(path)
|
|
35
|
+
prefix = /#{Regexp.escape(definition_name)}\s*=\s*element\s+/
|
|
36
|
+
pattern = /#{prefix}([\w:]+)\s*\{/
|
|
37
|
+
match = src.match(pattern)
|
|
38
|
+
return nil unless match
|
|
39
|
+
|
|
40
|
+
body = balanced_body(src, match.end(0))
|
|
41
|
+
return nil unless body
|
|
42
|
+
|
|
43
|
+
attrs_with_types = body.scan(ATTRIBUTE_DEF)
|
|
44
|
+
.map { |name, type| [name, type.strip] }
|
|
45
|
+
[match[1], attrs_with_types]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def body_attributes(body)
|
|
49
|
+
body.scan(ATTRIBUTE_DEF).map(&:first).uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def each_element(paths, &block)
|
|
53
|
+
paths.each do |path|
|
|
54
|
+
scan_file(path, &block)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def scan_file(path)
|
|
59
|
+
src = File.read(path)
|
|
60
|
+
src.scan(ELEMENT_DEF) do
|
|
61
|
+
match = Regexp.last_match
|
|
62
|
+
body = balanced_body(src, match.end(0))
|
|
63
|
+
next unless body
|
|
64
|
+
|
|
65
|
+
element_name = match[2].sub(/^idPkg:/, "")
|
|
66
|
+
yield(element_name, body)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The balanced-brace body starting just after `from` (which
|
|
71
|
+
# points past the opening brace); nil when unterminated.
|
|
72
|
+
def balanced_body(src, from)
|
|
73
|
+
depth = 1
|
|
74
|
+
i = from
|
|
75
|
+
while i < src.length && depth.positive?
|
|
76
|
+
case src[i]
|
|
77
|
+
when "{" then depth += 1
|
|
78
|
+
when "}" then depth -= 1
|
|
79
|
+
end
|
|
80
|
+
i += 1
|
|
81
|
+
end
|
|
82
|
+
return nil if depth.positive?
|
|
83
|
+
|
|
84
|
+
src[from...(i - 1)]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/idml/schema.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
# Namespace for adapters over the committed RelaxNG Compact
|
|
5
|
+
# schemas (`reference-docs/schemas/`). The RNC files are the
|
|
6
|
+
# authoritative interface of the Elements layer — this namespace
|
|
7
|
+
# turns them into data for the generator script and the
|
|
8
|
+
# conformance spec (two consumers, one seam).
|
|
9
|
+
module Schema
|
|
10
|
+
autoload :Rnc, "#{__dir__}/schema/rnc"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -21,13 +21,13 @@ module Idml
|
|
|
21
21
|
DEFAULT_MIN_GLYPH_SCALING = 100.0
|
|
22
22
|
|
|
23
23
|
def self.justify(line:, frame_width:, alignment: :left,
|
|
24
|
-
last_line: false, limits: nil)
|
|
25
|
-
new(frame_width, alignment, last_line, limits).justify(line)
|
|
24
|
+
last_line: false, limits: nil, direction: nil)
|
|
25
|
+
new(frame_width, alignment, last_line, limits, direction).justify(line)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def initialize(frame_width, alignment, last_line, limits)
|
|
28
|
+
def initialize(frame_width, alignment, last_line, limits, direction)
|
|
29
29
|
@frame_width = frame_width
|
|
30
|
-
@alignment = alignment
|
|
30
|
+
@alignment = mirrored_alignment(alignment, direction)
|
|
31
31
|
@last_line = last_line
|
|
32
32
|
@limits = limits
|
|
33
33
|
end
|
|
@@ -44,7 +44,18 @@ module Idml
|
|
|
44
44
|
line
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
RTL_ALIGNMENT_MIRROR = {
|
|
48
|
+
left: :right, right: :left,
|
|
49
|
+
start: :end, end: :start
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# RightToLeftDirection mirrors the visual alignment: what is
|
|
53
|
+
# "left" in an LTR paragraph sits right in an RTL one.
|
|
54
|
+
def mirrored_alignment(alignment, direction)
|
|
55
|
+
return alignment unless direction == "RightToLeftDirection"
|
|
56
|
+
|
|
57
|
+
RTL_ALIGNMENT_MIRROR[alignment] || alignment
|
|
58
|
+
end
|
|
48
59
|
|
|
49
60
|
# The paragraph's last line stays ragged under full
|
|
50
61
|
# justification.
|
data/lib/idml/version.rb
CHANGED
data/lib/idml.rb
CHANGED
|
@@ -18,6 +18,7 @@ module Idml
|
|
|
18
18
|
autoload :Validation, "#{__dir__}/idml/validation"
|
|
19
19
|
autoload :Composition, "#{__dir__}/idml/composition"
|
|
20
20
|
autoload :Geometry, "#{__dir__}/idml/geometry"
|
|
21
|
+
autoload :Schema, "#{__dir__}/idml/schema"
|
|
21
22
|
autoload :TextEngine, "#{__dir__}/idml/text_engine"
|
|
22
23
|
autoload :Render, "#{__dir__}/idml/render"
|
|
23
24
|
autoload :CLI, "#{__dir__}/idml/cli"
|
data/scripts/rnc_to_lutaml.rb
CHANGED
|
@@ -10,21 +10,11 @@
|
|
|
10
10
|
# Example:
|
|
11
11
|
# scripts/rnc_to_lutaml.rb reference-docs/schemas/package/Stories/Story.rnc Story_Object
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# Name { Type }?` line, and prints Ruby code suitable for pasting
|
|
16
|
-
# into a Lutaml::Model::Serializable subclass.
|
|
17
|
-
#
|
|
18
|
-
# Generated output:
|
|
19
|
-
# attribute :self_attr, :string
|
|
20
|
-
# attribute :first_line_indent, :float
|
|
21
|
-
# # ... etc.
|
|
22
|
-
# xml do
|
|
23
|
-
# map_attribute "Self", to: :self_attr
|
|
24
|
-
# map_attribute "FirstLineIndent", to: :first_line_indent
|
|
25
|
-
# end
|
|
13
|
+
# RNC parsing lives in Idml::Schema::Rnc (shared with the
|
|
14
|
+
# schema-conformance spec); this script owns presentation only.
|
|
26
15
|
|
|
27
|
-
|
|
16
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
17
|
+
require "idml"
|
|
28
18
|
|
|
29
19
|
RESERVED_RUBY_NAMES = %w[self class integer float string end do begin ensure
|
|
30
20
|
rescue retry next return break yield nil true false].freeze
|
|
@@ -56,59 +46,25 @@ def ruby_attr_name(xml_name)
|
|
|
56
46
|
base
|
|
57
47
|
end
|
|
58
48
|
|
|
59
|
-
def extract_element(rnc_path, element_name)
|
|
60
|
-
src = File.read(rnc_path)
|
|
61
|
-
pattern = /#{Regexp.escape(element_name)}\s*=\s*element\s+([\w:]+)\s*\{/
|
|
62
|
-
match = src.match(pattern)
|
|
63
|
-
abort "#{element_name} not found in #{rnc_path}" unless match
|
|
64
|
-
|
|
65
|
-
xml_element_name = match[1]
|
|
66
|
-
start_pos = match.end(0)
|
|
67
|
-
|
|
68
|
-
# Walk the brace structure to find the matching close.
|
|
69
|
-
depth = 1
|
|
70
|
-
i = start_pos
|
|
71
|
-
while i < src.length && depth.positive?
|
|
72
|
-
case src[i]
|
|
73
|
-
when "{" then depth += 1
|
|
74
|
-
when "}" then depth -= 1
|
|
75
|
-
end
|
|
76
|
-
i += 1
|
|
77
|
-
end
|
|
78
|
-
body = src[start_pos...(i - 1)]
|
|
79
|
-
|
|
80
|
-
[xml_element_name, body]
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def extract_attributes(body)
|
|
84
|
-
attrs = []
|
|
85
|
-
body.scan(/^\s*attribute\s+(\w+)\s*\{\s*([^}]+)\}/) do |name, type|
|
|
86
|
-
attrs << [name, type.strip]
|
|
87
|
-
end
|
|
88
|
-
attrs
|
|
89
|
-
end
|
|
90
|
-
|
|
91
49
|
rnc_path, element_name = ARGV
|
|
92
50
|
abort "usage: #{$PROGRAM_NAME} <rnc-file> <ElementName>" unless rnc_path && element_name
|
|
93
51
|
|
|
94
|
-
xml_root,
|
|
95
|
-
|
|
52
|
+
xml_root, attrs = Idml::Schema::Rnc.element_definition(rnc_path, element_name)
|
|
53
|
+
abort "#{element_name} not found in #{rnc_path}" unless xml_root
|
|
96
54
|
|
|
97
55
|
puts "# xml root: <#{xml_root}>"
|
|
98
56
|
puts "# source: #{rnc_path} (#{element_name})"
|
|
99
57
|
puts "# #{attrs.length} attributes"
|
|
100
58
|
puts
|
|
101
|
-
puts "attribute :_placeholder_, :string # remove me"
|
|
102
|
-
puts
|
|
103
|
-
puts "xml do"
|
|
104
|
-
puts " root \"#{xml_root.sub(/^idPkg:/, '')}\""
|
|
105
|
-
attrs.each do |name, _type| # rubocop:disable Style/HashEachMethods
|
|
106
|
-
puts %( map_attribute "#{name}", to: :#{ruby_attr_name(name)}")
|
|
107
|
-
end
|
|
108
|
-
puts "end"
|
|
109
|
-
puts
|
|
110
59
|
puts "# attribute declarations:"
|
|
111
60
|
attrs.each do |name, type|
|
|
112
61
|
lutaml = lutaml_type(type.split(/\s/).first)
|
|
113
62
|
puts "attribute :#{ruby_attr_name(name)}, :#{lutaml}"
|
|
114
63
|
end
|
|
64
|
+
puts
|
|
65
|
+
puts "xml do"
|
|
66
|
+
puts %( root "#{xml_root.sub(/^idPkg:/, '')}")
|
|
67
|
+
attrs.each do |name, _type| # rubocop:disable Style/HashEachMethods
|
|
68
|
+
puts %( map_attribute "#{name}", to: :#{ruby_attr_name(name)})
|
|
69
|
+
end
|
|
70
|
+
puts "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.
|
|
4
|
+
version: 0.10.14
|
|
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-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -215,6 +215,9 @@ files:
|
|
|
215
215
|
- TODO.pdf/149-architecture-round-two.md
|
|
216
216
|
- TODO.pdf/15-wire-spread-children.md
|
|
217
217
|
- TODO.pdf/150-architecture-round-three.md
|
|
218
|
+
- TODO.pdf/151-schema-conformance.md
|
|
219
|
+
- TODO.pdf/152-rtl-alignment.md
|
|
220
|
+
- TODO.pdf/153-vertical-keeps.md
|
|
218
221
|
- TODO.pdf/16-typed-model-pipeline.md
|
|
219
222
|
- TODO.pdf/17-shape-rendering.md
|
|
220
223
|
- TODO.pdf/18-text-rendering.md
|
|
@@ -352,6 +355,7 @@ files:
|
|
|
352
355
|
- lib/idml/elements/ink.rb
|
|
353
356
|
- lib/idml/elements/inner_glow_setting.rb
|
|
354
357
|
- lib/idml/elements/inner_shadow_setting.rb
|
|
358
|
+
- lib/idml/elements/inset_spacing.rb
|
|
355
359
|
- lib/idml/elements/layer.rb
|
|
356
360
|
- lib/idml/elements/link.rb
|
|
357
361
|
- lib/idml/elements/master_spread_object.rb
|
|
@@ -527,6 +531,8 @@ files:
|
|
|
527
531
|
- lib/idml/render/style_resolver.rb
|
|
528
532
|
- lib/idml/render/text_wrap_resolver.rb
|
|
529
533
|
- lib/idml/render/wrap_contour.rb
|
|
534
|
+
- lib/idml/schema.rb
|
|
535
|
+
- lib/idml/schema/rnc.rb
|
|
530
536
|
- lib/idml/text_engine.rb
|
|
531
537
|
- lib/idml/text_engine/cjk_layout.rb
|
|
532
538
|
- lib/idml/text_engine/justifier.rb
|