idml 0.7.1 → 0.7.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/TODO.pdf/103-drop-caps.md +12 -1
- data/TODO.pdf/108-multi-frame-story-flow.md +5 -1
- data/TODO.pdf/110-conditional-text.md +7 -1
- data/TODO.pdf/112-text-frame-vertical-justification.md +53 -0
- data/TODO.pdf/113-multi-column-text-frames.md +48 -0
- data/TODO.pdf/114-text-wrap-around-shapes.md +50 -0
- data/TODO.pdf/115-bullet-numbered-lists.md +46 -0
- data/lib/idml/elements/condition.rb +31 -0
- data/lib/idml/elements.rb +1 -0
- data/lib/idml/parts/designmap.rb +2 -0
- data/lib/idml/render/condition_filter.rb +57 -0
- data/lib/idml/render/drop_cap.rb +83 -0
- data/lib/idml/render/pipeline.rb +8 -4
- data/lib/idml/render/render_context.rb +2 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +288 -58
- data/lib/idml/render/spread_renderer.rb +5 -1
- data/lib/idml/render/story_chain_controller.rb +66 -0
- data/lib/idml/render/style_resolver.rb +21 -3
- data/lib/idml/render.rb +3 -0
- data/lib/idml/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66e6127aeed8e54926e01cacb67c81445565c1e43a558015fd9740e022c12372
|
|
4
|
+
data.tar.gz: 20b6dcdef5ee316ca394a2b062ed0dac2db9137e08e28f7649944623420e3938
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94180fd8ac2231198178f40f2051d4e1bd9c56d917cd271808fc87f7de66746739c4ecad65e23f6bbb57be539f061ff5ef9abfda56e71a2bb23ddc403ac3d9a5
|
|
7
|
+
data.tar.gz: 353fcc512f92ed689fc1e41cabf4fe9310bcb5ce0464be24b0be6a452c1559f860201c7132318bb3fd4be9c510eed63ebb98f056b927b9e781bffcfc2123f632
|
data/TODO.pdf/103-drop-caps.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# TODO PDF 103: Drop caps (DropCapLines, DropCapCharacters)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: PARTIAL — `Render::DropCap` helper computes drop-cap
|
|
4
|
+
geometry (font_size = base × lines, width via font_metrics.measure_text,
|
|
5
|
+
height = leading × lines). TextFrameRenderer.emit_drop_cap emits the
|
|
6
|
+
drop cap as an enlarged glyph at the paragraph's top-left when the
|
|
7
|
+
paragraph declares DropCapLines/DropCapCharacters > 0 and is the
|
|
8
|
+
chain's first paragraph.
|
|
9
|
+
|
|
10
|
+
Wrap-around text is NOT yet implemented — the drop cap renders as an
|
|
11
|
+
enlarged glyph; subsequent paragraph text flows normally and may
|
|
12
|
+
overlap the drop cap. Real wrap-around (indent lines 1..M to the
|
|
13
|
+
right of the drop cap) requires per-line wrap-width control in
|
|
14
|
+
VerticalLayout, which is a bigger refactor. Tracked as follow-up.
|
|
4
15
|
|
|
5
16
|
## Problem
|
|
6
17
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# TODO PDF 108: Multi-frame story flow (use StoryThreader)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — `Render::StoryChainController` threads paragraph/run
|
|
4
|
+
state across linked TextFrames. TextFrameRenderer no longer treats
|
|
5
|
+
chain non-heads as standalone; it picks up leftover state from the
|
|
6
|
+
controller, renders what fits, and stores the new leftover for the
|
|
7
|
+
next frame. Overflow text is no longer silently dropped.
|
|
4
8
|
|
|
5
9
|
## Problem
|
|
6
10
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# TODO PDF 110: Conditional text (AppliedConditions)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: DONE — `Elements::Condition` models the `<Condition>`
|
|
4
|
+
element from designmap.rnc. `Render::ConditionFilter` indexes
|
|
5
|
+
conditions by Self and answers `visible?(applied_conditions_string)`
|
|
6
|
+
per run. `StyleResolver.extract_paragraphs` takes optional
|
|
7
|
+
`condition_filter:` kwarg and drops PSRs/CSRs whose AppliedConditions
|
|
8
|
+
reference a hidden Condition. Pipeline constructs the filter from
|
|
9
|
+
`designmap.condition` and threads it through RenderContext.
|
|
4
10
|
|
|
5
11
|
## Problem
|
|
6
12
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# TODO PDF 112: TextFrame vertical justification (TopAlign / CenterAlign / BottomAlign / JustifyAlign)
|
|
2
|
+
|
|
3
|
+
## Status: DONE for TopAlign / CenterAlign / BottomAlign. JustifyAlign
|
|
4
|
+
defers to TopAlign (inter-paragraph space distribution not yet
|
|
5
|
+
implemented). Content height is approximated by treating each run
|
|
6
|
+
as one line — wrap may produce more lines, so slack is over-estimated;
|
|
7
|
+
we'd rather under-offset than overflow.
|
|
8
|
+
|
|
9
|
+
## Problem
|
|
10
|
+
|
|
11
|
+
`Elements::TextFramePreference` carries `VerticalJustification`
|
|
12
|
+
(attribute VerticalJustification on TextFramePreference_Object in
|
|
13
|
+
Styles.rnc). Possible values: TopAlign, CenterAlign, BottomAlign,
|
|
14
|
+
JustifyAlign.
|
|
15
|
+
|
|
16
|
+
Today TextFrameRenderer always starts text at the top of the frame
|
|
17
|
+
(`cursor_y = box[:y] + box[:height] - inset_top`). For
|
|
18
|
+
`VerticalJustification="CenterAlign"` documents, the entire text
|
|
19
|
+
block should be vertically centered within the frame's text area;
|
|
20
|
+
for `BottomAlign`, text sits at the bottom.
|
|
21
|
+
|
|
22
|
+
This is a common layout requirement — callouts, captions, footers,
|
|
23
|
+
and sidebars often use bottom or center vertical justification.
|
|
24
|
+
|
|
25
|
+
## What needs to happen
|
|
26
|
+
|
|
27
|
+
1. `TextFrameRenderer#engine_render` reads
|
|
28
|
+
`frame.text_frame_preference.vertical_justification`.
|
|
29
|
+
2. Compute the total content height (sum of all paragraph heights +
|
|
30
|
+
space_before/after).
|
|
31
|
+
3. If VerticalJustification is CenterAlign: top-offset =
|
|
32
|
+
(frame_text_area_height - content_height) / 2.
|
|
33
|
+
4. If BottomAlign: top-offset = (frame_text_area_height - content_height).
|
|
34
|
+
5. Adjust the initial `cursor_y` by top-offset before laying out
|
|
35
|
+
the first paragraph.
|
|
36
|
+
6. JustifyAlign: distribute extra vertical space between paragraphs
|
|
37
|
+
(more complex — defer).
|
|
38
|
+
|
|
39
|
+
## Acceptance criteria
|
|
40
|
+
|
|
41
|
+
- [ ] TextFrame with VerticalJustification="CenterAlign" centers
|
|
42
|
+
text vertically in the frame.
|
|
43
|
+
- [ ] TextFrame with VerticalJustification="BottomAlign" aligns
|
|
44
|
+
text to the bottom.
|
|
45
|
+
- [ ] TextFrame with VerticalJustification="TopAlign" (default)
|
|
46
|
+
renders as today.
|
|
47
|
+
- [ ] Spec coverage per value.
|
|
48
|
+
|
|
49
|
+
## Dependencies
|
|
50
|
+
|
|
51
|
+
- TODO 108 (multi-frame story flow) — content_height calculation
|
|
52
|
+
needs to consider all paragraphs that will render, which the
|
|
53
|
+
chain state already tracks.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# TODO PDF 113: Multi-column text frames (TextColumnCount, TextColumnGutter)
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-10 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
`Elements::TextFramePreference` declares:
|
|
8
|
+
- `TextColumnCount` — number of text columns in the frame (1-40)
|
|
9
|
+
- `TextColumnGutter` — gap between columns (in points)
|
|
10
|
+
- `TextColumnFixedWidth` — width of each column when uniform
|
|
11
|
+
- `UseFixedColumnWidth` — boolean
|
|
12
|
+
- `UseFlexibleColumnWidth` — boolean
|
|
13
|
+
- `TextColumnMaxWidth` — max column width when flexible
|
|
14
|
+
|
|
15
|
+
Real-world documents frequently use multi-column layouts
|
|
16
|
+
(magazines, newspapers, technical reports, brochures). Today the
|
|
17
|
+
renderer treats every text frame as a single column, ignoring
|
|
18
|
+
TextColumnCount > 1.
|
|
19
|
+
|
|
20
|
+
Effect: multi-column stories render as one wide column that over-
|
|
21
|
+
laps adjacent content. The PDF is unreadable for multi-column docs.
|
|
22
|
+
|
|
23
|
+
## What needs to happen
|
|
24
|
+
|
|
25
|
+
1. `TextFrameRenderer` reads `text_frame_preference.text_column_count`.
|
|
26
|
+
2. When > 1: split the frame's text area into N columns separated
|
|
27
|
+
by `text_column_gutter`.
|
|
28
|
+
3. Render text column-by-column: column 1 fills top-to-bottom,
|
|
29
|
+
overflow continues at top of column 2, etc.
|
|
30
|
+
4. StoryThreader chain state threads across columns WITHIN a frame
|
|
31
|
+
before crossing to the next frame.
|
|
32
|
+
5. Optional: support TextColumnFixedWidth vs flexible-width
|
|
33
|
+
distribution.
|
|
34
|
+
|
|
35
|
+
Architecture: this combines TODO 108 (chain flow) with column
|
|
36
|
+
awareness. Each column acts like a mini-frame in the chain.
|
|
37
|
+
|
|
38
|
+
## Acceptance criteria
|
|
39
|
+
|
|
40
|
+
- [ ] TextFrame with TextColumnCount=2 renders two side-by-side columns.
|
|
41
|
+
- [ ] Text overflows from column 1 → column 2 → next frame.
|
|
42
|
+
- [ ] TextColumnGutter honored as the gap between columns.
|
|
43
|
+
- [ ] Single-column frames render as today.
|
|
44
|
+
|
|
45
|
+
## Dependencies
|
|
46
|
+
|
|
47
|
+
- TODO 108 (multi-frame story flow) — column-to-column flow uses
|
|
48
|
+
the same chain state mechanism.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# TODO PDF 114: Text wrap around shapes (TextWrapPreference)
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-10 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
IDML page items (Rectangle, Polygon, GraphicLine, Group, Image, EPS)
|
|
8
|
+
can declare a `<TextWrapPreference>` child that controls how text
|
|
9
|
+
in neighboring TextFrames wraps around the item. Settings include:
|
|
10
|
+
|
|
11
|
+
- WrapMode: None / Jump / Next / Bounding Box / Shape / Drop Cap
|
|
12
|
+
- Contour: the wrap boundary (could differ from the visual shape)
|
|
13
|
+
- Inverse: text wraps INSIDE the contour (rare)
|
|
14
|
+
- OffsetTop/Left/Bottom/Right: additional margin around the contour
|
|
15
|
+
|
|
16
|
+
Real documents wrap text around images and callout boxes constantly.
|
|
17
|
+
Without text wrap support, overlapping items produce unreadable
|
|
18
|
+
output — text from one frame runs over a graphic in another.
|
|
19
|
+
|
|
20
|
+
Today TextFrameRenderer knows nothing about TextWrapPreference on
|
|
21
|
+
other page items. It renders into the frame's full geometric
|
|
22
|
+
rectangle regardless of overlapping wrap contours.
|
|
23
|
+
|
|
24
|
+
## What needs to happen
|
|
25
|
+
|
|
26
|
+
1. `Render::TextWrapResolver` reads TextWrapPreference from all
|
|
27
|
+
page items in the spread and indexes their contours by Self.
|
|
28
|
+
2. Per TextFrame: compute the intersection of the frame's text area
|
|
29
|
+
with all wrap contours in the spread.
|
|
30
|
+
3. TextFrameRenderer queries the resolver per text line; each line's
|
|
31
|
+
wrap width is reduced by the overlap on the left and right.
|
|
32
|
+
4. Lines that fall entirely inside a wrap region are skipped.
|
|
33
|
+
|
|
34
|
+
This is a substantial feature — wrap computation is non-trivial
|
|
35
|
+
(geometry intersection, contour following). Most documents use a
|
|
36
|
+
simple Bounding Box mode (rectangle subtract), which is tractable.
|
|
37
|
+
|
|
38
|
+
## Acceptance criteria
|
|
39
|
+
|
|
40
|
+
- [ ] TextWrapMode="BoundingBox" with an image inside the frame's
|
|
41
|
+
bounds reduces wrap width on affected lines.
|
|
42
|
+
- [ ] TextWrapMode="None" (default) renders as today.
|
|
43
|
+
- [ ] OffsetTop/Left/Bottom/Right honored (expands the contour by
|
|
44
|
+
the offsets).
|
|
45
|
+
|
|
46
|
+
## Dependencies
|
|
47
|
+
|
|
48
|
+
- TODO 108 (multi-frame story flow) — wrap-aware wrap width needs
|
|
49
|
+
to combine with chain-threaded line layout.
|
|
50
|
+
- TODO 113 (multi-column) — wrap interacts with column boundaries.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# TODO PDF 115: Bullet and numbered list rendering
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-10 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
`Elements::ParagraphStyleRange` carries a large set of bullets and
|
|
8
|
+
numbering attributes (BulletsAndNumberingListType, NumberingExpression,
|
|
9
|
+
BulletsTextAfter, NumberingLevel, NumberingContinue, NumberingStartAt,
|
|
10
|
+
BulletsAlignment, NumberingAlignment, BulletCharacterType,
|
|
11
|
+
BulletCharacterValue, RestartPolicy, LowerLevel, UpperLevel).
|
|
12
|
+
|
|
13
|
+
The renderer ignores all of these. Lists appear in the PDF as plain
|
|
14
|
+
paragraphs without bullet markers or numbering.
|
|
15
|
+
|
|
16
|
+
Real documents (technical specs, tutorials, procedures, contracts)
|
|
17
|
+
use lists extensively.
|
|
18
|
+
|
|
19
|
+
## What needs to happen
|
|
20
|
+
|
|
21
|
+
1. Read BulletsAndNumberingListType from PSR:
|
|
22
|
+
- "BulletList" → emit a bullet glyph at the paragraph's start
|
|
23
|
+
- "NumberedList" → emit a number/letter at the paragraph's start
|
|
24
|
+
2. Apply BulletsAlignment (left/center/right) within a hanging-indent
|
|
25
|
+
gutter.
|
|
26
|
+
3. Auto-increment numbering per NumberingLevel +
|
|
27
|
+
NumberingContinue + NumberingStartAt rules.
|
|
28
|
+
4. Honor RestartPolicy for sub-list restart behavior.
|
|
29
|
+
5. BulletCharacterValue picks the glyph (Unicode codepoint).
|
|
30
|
+
|
|
31
|
+
Architecture: list markers prepend the paragraph's first run; the
|
|
32
|
+
renderer needs an in-memory counter per numbering chain (similar
|
|
33
|
+
to StoryChainController).
|
|
34
|
+
|
|
35
|
+
## Acceptance criteria
|
|
36
|
+
|
|
37
|
+
- [ ] PSR with BulletsAndNumberingListType="BulletList" renders a
|
|
38
|
+
bullet glyph before the paragraph's first character.
|
|
39
|
+
- [ ] PSR with BulletsAndNumberingListType="NumberedList" renders
|
|
40
|
+
a sequential number, auto-incremented per chain.
|
|
41
|
+
- [ ] BulletsTextAfter (e.g., ". ", ") ") honored as the marker's
|
|
42
|
+
suffix.
|
|
43
|
+
|
|
44
|
+
## Dependencies
|
|
45
|
+
|
|
46
|
+
- None — independent feature work on PSR.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Elements
|
|
5
|
+
# `<Condition>` — a single condition declared in `designmap.xml`.
|
|
6
|
+
# Authors tag text runs with one or more conditions (the CSR's
|
|
7
|
+
# `AppliedConditions` attribute is a list of condition Self IDs).
|
|
8
|
+
# The condition's `Visible` flag controls whether tagged text
|
|
9
|
+
# appears in the rendered output.
|
|
10
|
+
#
|
|
11
|
+
# Schema: `Condition_Object` in
|
|
12
|
+
# `reference-docs/schemas/package/designmap.rnc`.
|
|
13
|
+
class Condition < Lutaml::Model::Serializable
|
|
14
|
+
attribute :self_attr, :string
|
|
15
|
+
attribute :name, :string
|
|
16
|
+
attribute :indicator_method, :string
|
|
17
|
+
attribute :underline_indicator_appearance, :string
|
|
18
|
+
attribute :visible, :boolean, default: true
|
|
19
|
+
|
|
20
|
+
xml do
|
|
21
|
+
root "Condition"
|
|
22
|
+
map_attribute "Self", to: :self_attr
|
|
23
|
+
map_attribute "Name", to: :name
|
|
24
|
+
map_attribute "IndicatorMethod", to: :indicator_method
|
|
25
|
+
map_attribute "UnderlineIndicatorAppearance",
|
|
26
|
+
to: :underline_indicator_appearance
|
|
27
|
+
map_attribute "Visible", to: :visible
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/idml/elements.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Idml
|
|
|
17
17
|
autoload :ButtonPreference, "idml/elements/pref_button_preference"
|
|
18
18
|
autoload :Bookmark, "idml/elements/bookmark"
|
|
19
19
|
autoload :Cell, "idml/elements/cell"
|
|
20
|
+
autoload :Condition, "idml/elements/condition"
|
|
20
21
|
autoload :CellStyle, "idml/elements/cell_style"
|
|
21
22
|
autoload :CellStyleGroup, "idml/elements/cell_style_group"
|
|
22
23
|
autoload :ChapterNumberPreference,
|
data/lib/idml/parts/designmap.rb
CHANGED
|
@@ -48,6 +48,7 @@ module Idml
|
|
|
48
48
|
|
|
49
49
|
attribute :layer, Idml::Elements::Layer, collection: true
|
|
50
50
|
attribute :bookmark, Idml::Elements::Bookmark, collection: true
|
|
51
|
+
attribute :condition, Idml::Elements::Condition, collection: true
|
|
51
52
|
attribute :hyperlink, Idml::Elements::Hyperlink, collection: true
|
|
52
53
|
attribute :hyperlink_page_destination,
|
|
53
54
|
Idml::Elements::HyperlinkPageDestination, collection: true
|
|
@@ -92,6 +93,7 @@ module Idml
|
|
|
92
93
|
map_attribute "ActiveProcess", to: :active_process
|
|
93
94
|
map_element "Layer", to: :layer
|
|
94
95
|
map_element "Bookmark", to: :bookmark
|
|
96
|
+
map_element "Condition", to: :condition
|
|
95
97
|
map_element "Hyperlink", to: :hyperlink
|
|
96
98
|
map_element "HyperlinkPageDestination",
|
|
97
99
|
to: :hyperlink_page_destination
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Resolves whether a styled run should render based on its
|
|
6
|
+
# AppliedConditions and the visibility flags declared on the
|
|
7
|
+
# corresponding `<Condition>` elements in `designmap.xml`.
|
|
8
|
+
#
|
|
9
|
+
# A run is visible when either:
|
|
10
|
+
# - It has no AppliedConditions (the common case), OR
|
|
11
|
+
# - Every condition it references is itself Visible.
|
|
12
|
+
#
|
|
13
|
+
# A run is hidden when ANY referenced condition is hidden. This
|
|
14
|
+
# matches InDesign's "hide any text the user has toggled off"
|
|
15
|
+
# semantics.
|
|
16
|
+
#
|
|
17
|
+
# Construction: `ConditionFilter.from_designmap(designmap)`
|
|
18
|
+
# reads the condition collection and indexes by Self. Use
|
|
19
|
+
# `filter.visible?(applied_conditions_string)` per run; pass nil
|
|
20
|
+
# or empty string for untagged runs.
|
|
21
|
+
class ConditionFilter
|
|
22
|
+
def self.from_designmap(designmap)
|
|
23
|
+
conditions = designmap ? designmap.condition : []
|
|
24
|
+
new(conditions)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(conditions)
|
|
28
|
+
@visibility_by_self = conditions.to_h do |cond|
|
|
29
|
+
[cond.self_attr, condition_visible?(cond)]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `applied_conditions` is the raw PSR/CSR string. IDML encodes
|
|
34
|
+
# it as a space-separated list of condition Self IDs. Returns
|
|
35
|
+
# true when the run should render.
|
|
36
|
+
def visible?(applied_conditions)
|
|
37
|
+
return true if applied_conditions.nil?
|
|
38
|
+
return true if applied_conditions.strip.empty?
|
|
39
|
+
|
|
40
|
+
applied_conditions.split.uniq.all? do |cond_self|
|
|
41
|
+
condition_visible?(cond_self)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A condition is visible when explicitly Visible="true" OR when
|
|
48
|
+
# the Visibility flag is unset (default true). Only an explicit
|
|
49
|
+
# Visible="false" hides it.
|
|
50
|
+
def condition_visible?(cond_or_self)
|
|
51
|
+
return @visibility_by_self.fetch(cond_or_self, true) if cond_or_self.is_a?(String)
|
|
52
|
+
|
|
53
|
+
cond_or_self.visible.nil? || cond_or_self.visible
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Computes drop-cap geometry for a paragraph. IDML's DropCapLines
|
|
6
|
+
# (M) and DropCapCharacters (N) declare that the first N
|
|
7
|
+
# characters of the paragraph should render at M-lines-tall and
|
|
8
|
+
# the next M text lines should wrap to the right of the drop cap.
|
|
9
|
+
#
|
|
10
|
+
# The helper returns a `DropCapLayout` struct with the drop cap
|
|
11
|
+
# text, font size, width (for wrap offset), and total height.
|
|
12
|
+
# Returns nil when the paragraph doesn't declare drop caps.
|
|
13
|
+
module DropCap
|
|
14
|
+
DropCapLayout = Struct.new(
|
|
15
|
+
:text, :width, :height, :font_size, :wrap_offset, :lines,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Returns a DropCapLayout for the paragraph, or nil when drop
|
|
20
|
+
# caps aren't declared. `font_metrics` measures the drop-cap
|
|
21
|
+
# text's natural width. `base_size` is the paragraph's normal
|
|
22
|
+
# font size (drop cap is enlarged relative to it).
|
|
23
|
+
def self.layout(paragraph, font_metrics:, base_size:, leading:)
|
|
24
|
+
return nil unless active?(paragraph)
|
|
25
|
+
return nil unless font_metrics
|
|
26
|
+
|
|
27
|
+
chars = paragraph.drop_cap_characters.to_i
|
|
28
|
+
lines = paragraph.drop_cap_lines.to_i
|
|
29
|
+
return nil if chars <= 0 || lines <= 0
|
|
30
|
+
|
|
31
|
+
build_layout(paragraph, font_metrics, base_size, leading, chars, lines)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when the paragraph declares an active drop cap
|
|
35
|
+
# (both DropCapLines and DropCapCharacters are positive integers).
|
|
36
|
+
def self.active?(paragraph)
|
|
37
|
+
return false unless paragraph
|
|
38
|
+
return false unless paragraph.drop_cap_lines.to_i.positive?
|
|
39
|
+
|
|
40
|
+
paragraph.drop_cap_characters.to_i.positive?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Pulls the first N characters from the paragraph's first run.
|
|
44
|
+
# If the first run is shorter than N, takes from subsequent runs
|
|
45
|
+
# until N chars are accumulated (rare — drop caps typically use
|
|
46
|
+
# 1-2 chars).
|
|
47
|
+
def self.extract_drop_cap_text(paragraph, n)
|
|
48
|
+
runs = paragraph.runs
|
|
49
|
+
return nil if runs.empty?
|
|
50
|
+
|
|
51
|
+
collected = +""
|
|
52
|
+
runs.each do |run|
|
|
53
|
+
needed = n - collected.length
|
|
54
|
+
break if needed <= 0
|
|
55
|
+
|
|
56
|
+
collected << run.text[0, needed]
|
|
57
|
+
break if collected.length >= n
|
|
58
|
+
end
|
|
59
|
+
collected
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.build_layout(paragraph, font_metrics, base_size, leading,
|
|
63
|
+
chars, lines)
|
|
64
|
+
text = extract_drop_cap_text(paragraph, chars)
|
|
65
|
+
return nil if text.nil? || text.empty?
|
|
66
|
+
|
|
67
|
+
font_size = base_size * lines
|
|
68
|
+
width = font_metrics.measure_text(text, font_size).to_f
|
|
69
|
+
return nil unless width&.positive?
|
|
70
|
+
|
|
71
|
+
DropCapLayout.new(
|
|
72
|
+
text: text,
|
|
73
|
+
width: width,
|
|
74
|
+
height: leading * lines,
|
|
75
|
+
font_size: font_size,
|
|
76
|
+
wrap_offset: width,
|
|
77
|
+
lines: lines,
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
private_class_method :build_layout
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/idml/render/pipeline.rb
CHANGED
|
@@ -38,6 +38,7 @@ module Idml
|
|
|
38
38
|
structure = StructureTracker.new(enabled: @tagged)
|
|
39
39
|
position_tracker = PositionTracker.new
|
|
40
40
|
layer_filter = LayerFilter.from_designmap(@package.designmap)
|
|
41
|
+
condition_filter = ConditionFilter.from_designmap(@package.designmap)
|
|
41
42
|
font_ref_resolver = FontReferenceResolver.build(@package)
|
|
42
43
|
font_setup = FontSetup.new(package: @package,
|
|
43
44
|
font_search_paths: @font_search_paths)
|
|
@@ -50,7 +51,8 @@ module Idml
|
|
|
50
51
|
page_index = render_spread(writer, spread, layer_filter,
|
|
51
52
|
font_ref_resolver, font_resource,
|
|
52
53
|
font_metrics, font_map, structure,
|
|
53
|
-
position_tracker,
|
|
54
|
+
position_tracker, condition_filter,
|
|
55
|
+
page_index)
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
structure.flush(writer)
|
|
@@ -90,14 +92,14 @@ module Idml
|
|
|
90
92
|
|
|
91
93
|
def render_spread(writer, spread, layer_filter, font_ref_resolver,
|
|
92
94
|
font_resource, font_metrics, font_map, structure,
|
|
93
|
-
position_tracker, page_offset)
|
|
95
|
+
position_tracker, condition_filter, page_offset)
|
|
94
96
|
pages = spread.spread.flat_map(&:page)
|
|
95
97
|
image_refs = ImageCollector.new(writer: writer,
|
|
96
98
|
base_dir: File.dirname(@package.path),
|
|
97
99
|
page_height: DEFAULT_HEIGHT).collect(spread)
|
|
98
100
|
renderer = build_renderer(layer_filter, font_ref_resolver,
|
|
99
101
|
font_resource, font_metrics, font_map,
|
|
100
|
-
structure, position_tracker)
|
|
102
|
+
structure, position_tracker, condition_filter)
|
|
101
103
|
current = page_offset
|
|
102
104
|
|
|
103
105
|
pages.each do |page|
|
|
@@ -126,7 +128,8 @@ module Idml
|
|
|
126
128
|
end
|
|
127
129
|
|
|
128
130
|
def build_renderer(layer_filter, font_ref_resolver, font_resource,
|
|
129
|
-
font_metrics, font_map, structure, position_tracker
|
|
131
|
+
font_metrics, font_map, structure, position_tracker,
|
|
132
|
+
condition_filter)
|
|
130
133
|
SpreadRenderer.new(
|
|
131
134
|
font_metrics: font_metrics,
|
|
132
135
|
font_ps_name: font_resource,
|
|
@@ -136,6 +139,7 @@ module Idml
|
|
|
136
139
|
font_ref_resolver: font_ref_resolver,
|
|
137
140
|
structure: structure,
|
|
138
141
|
position_tracker: position_tracker,
|
|
142
|
+
condition_filter: condition_filter,
|
|
139
143
|
)
|
|
140
144
|
end
|
|
141
145
|
|
|
@@ -30,7 +30,6 @@ module Idml
|
|
|
30
30
|
def self.render(canvas, context)
|
|
31
31
|
frame = context.item
|
|
32
32
|
return unless frame.parent_story
|
|
33
|
-
return unless chain_head?(frame)
|
|
34
33
|
|
|
35
34
|
story = context.package&.story_by_id(frame.parent_story)
|
|
36
35
|
return unless story
|
|
@@ -38,12 +37,53 @@ module Idml
|
|
|
38
37
|
box = frame_box(frame, context.page_height)
|
|
39
38
|
render_inline_tables(canvas, story, box, context)
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
return
|
|
40
|
+
state = initial_chain_state(frame, story, context)
|
|
41
|
+
return unless state
|
|
43
42
|
|
|
44
|
-
render_text(canvas,
|
|
43
|
+
render_text(canvas, state, context, box)
|
|
44
|
+
store_chain_state(frame, state, context)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Returns the StoryChainController::State to render this frame
|
|
48
|
+
# with. For chain heads (no predecessor), this is a fresh
|
|
49
|
+
# state built from extract_paragraphs. For chain non-heads,
|
|
50
|
+
# this is the leftover state from the previous frame in the
|
|
51
|
+
# chain (nil when there's nothing left to render).
|
|
52
|
+
def self.initial_chain_state(frame, story, context)
|
|
53
|
+
controller = context.chain_controller
|
|
54
|
+
return fresh_state(story, context) if chain_head?(frame)
|
|
55
|
+
return nil unless controller
|
|
56
|
+
|
|
57
|
+
controller.state_for(frame.parent_story)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :initial_chain_state
|
|
60
|
+
|
|
61
|
+
# Records the post-render state for the chain so the next
|
|
62
|
+
# frame in the chain picks up where this one left off. Only
|
|
63
|
+
# meaningful when a chain_controller is wired.
|
|
64
|
+
def self.store_chain_state(frame, state, context)
|
|
65
|
+
controller = context.chain_controller
|
|
66
|
+
return unless controller
|
|
67
|
+
|
|
68
|
+
controller.store_state(frame.parent_story, state)
|
|
69
|
+
end
|
|
70
|
+
private_class_method :store_chain_state
|
|
71
|
+
|
|
72
|
+
def self.fresh_state(story, context)
|
|
73
|
+
paragraphs = StyleResolver.extract_paragraphs(
|
|
74
|
+
story, condition_filter: context.condition_filter
|
|
75
|
+
)
|
|
76
|
+
return nil if paragraphs.empty?
|
|
77
|
+
|
|
78
|
+
StoryChainController::State.new(
|
|
79
|
+
paragraphs: paragraphs,
|
|
80
|
+
current_paragraph: nil,
|
|
81
|
+
runs_remaining: [],
|
|
82
|
+
char_cursor: 0,
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
private_class_method :fresh_state
|
|
86
|
+
|
|
47
87
|
# Discovers Tables inlined in the story (Story > PSR > CSR >
|
|
48
88
|
# Table — the real IDML structure) and renders each within
|
|
49
89
|
# the frame's bounds. Real IDML Tables have no own geometry,
|
|
@@ -70,12 +110,12 @@ module Idml
|
|
|
70
110
|
end
|
|
71
111
|
private_class_method :tables_in_story
|
|
72
112
|
|
|
73
|
-
def self.render_text(canvas,
|
|
113
|
+
def self.render_text(canvas, state, context, box)
|
|
74
114
|
font = context.font_metrics
|
|
75
115
|
if font
|
|
76
|
-
engine_render(canvas,
|
|
116
|
+
engine_render(canvas, state, context, box, font)
|
|
77
117
|
else
|
|
78
|
-
simple_render(canvas,
|
|
118
|
+
simple_render(canvas, state, context, box)
|
|
79
119
|
end
|
|
80
120
|
end
|
|
81
121
|
private_class_method :render_text
|
|
@@ -109,56 +149,251 @@ module Idml
|
|
|
109
149
|
end
|
|
110
150
|
private_class_method :layout_frame
|
|
111
151
|
|
|
112
|
-
# Walks paragraphs and emits one
|
|
113
|
-
# the Shaper → LineBreaker →
|
|
114
|
-
# pipeline. Cursor y descends
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
152
|
+
# Walks the chain state's paragraphs/runs and emits one
|
|
153
|
+
# `canvas.text` per line via the Shaper → LineBreaker →
|
|
154
|
+
# Justifier → VerticalLayout pipeline. Cursor y descends
|
|
155
|
+
# monotonically across paragraphs. When a paragraph partially
|
|
156
|
+
# fits, its remaining runs go back into the state for the
|
|
157
|
+
# next frame in the chain.
|
|
158
|
+
def self.engine_render(canvas, state, context, box, font)
|
|
118
159
|
layout_frame = layout_frame(context.item, box)
|
|
119
|
-
|
|
160
|
+
top_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
|
|
120
161
|
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
121
|
-
|
|
162
|
+
cursor_y = vertical_justify_top(top_y, bottom_limit, state, context)
|
|
163
|
+
char_cursor = state.char_cursor
|
|
122
164
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
cursor_y, char_cursor = render_paragraph(
|
|
127
|
-
canvas, paragraph, context, layout_frame, font,
|
|
165
|
+
if state.current_paragraph
|
|
166
|
+
cursor_y, char_cursor = resume_paragraph(
|
|
167
|
+
canvas, state, context, layout_frame, font,
|
|
128
168
|
cursor_y, bottom_limit, char_cursor
|
|
129
169
|
)
|
|
170
|
+
return if state.current_paragraph
|
|
130
171
|
end
|
|
172
|
+
|
|
173
|
+
consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
174
|
+
cursor_y, bottom_limit, char_cursor)
|
|
131
175
|
end
|
|
132
176
|
private_class_method :engine_render
|
|
133
177
|
|
|
134
|
-
|
|
135
|
-
|
|
178
|
+
# Computes the starting cursor_y for vertical justification.
|
|
179
|
+
# IDML's TextFramePreference.VerticalJustification can be:
|
|
180
|
+
# - TopAlign (default): start at frame top, no offset
|
|
181
|
+
# - CenterAlign: center the content block vertically
|
|
182
|
+
# - BottomAlign: align content to the frame bottom
|
|
183
|
+
# - JustifyAlign: distribute extra space between paragraphs
|
|
184
|
+
# (deferred — falls back to TopAlign behavior)
|
|
185
|
+
#
|
|
186
|
+
# Content height is estimated by walking paragraphs and
|
|
187
|
+
# summing leading × lines + space_before/after. Without
|
|
188
|
+
# shaping/wrapping we can't know exact line count, so we
|
|
189
|
+
# approximate by treating each run as one line.
|
|
190
|
+
def self.vertical_justify_top(top_y, bottom_limit, state, context)
|
|
191
|
+
justification = text_frame_vertical_justification(context)
|
|
192
|
+
return top_y unless %w[CenterAlign BottomAlign].include?(justification)
|
|
193
|
+
|
|
194
|
+
content_height = estimate_content_height(state)
|
|
195
|
+
available = top_y - bottom_limit
|
|
196
|
+
slack = [available - content_height, 0].max
|
|
197
|
+
return top_y if slack.zero?
|
|
198
|
+
|
|
199
|
+
case justification
|
|
200
|
+
when "CenterAlign" then top_y - (slack / 2)
|
|
201
|
+
when "BottomAlign" then top_y - slack
|
|
202
|
+
else top_y
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
private_class_method :vertical_justify_top
|
|
206
|
+
|
|
207
|
+
def self.text_frame_vertical_justification(context)
|
|
208
|
+
pref = context.item&.text_frame_preference&.first
|
|
209
|
+
pref&.vertical_justification
|
|
210
|
+
end
|
|
211
|
+
private_class_method :text_frame_vertical_justification
|
|
212
|
+
|
|
213
|
+
# Estimates total renderable content height for vertical
|
|
214
|
+
# justification. Walks paragraphs and runs, summing leading
|
|
215
|
+
# per run plus paragraph space_before/after. Approximation:
|
|
216
|
+
# treats each run as one line (wrap may produce more lines,
|
|
217
|
+
# which would over-estimate slack; acceptable for
|
|
218
|
+
# justification since we'd rather under-offset than overflow).
|
|
219
|
+
def self.estimate_content_height(state)
|
|
220
|
+
paragraphs = paragraphs_for_estimate(state)
|
|
221
|
+
return 0 if paragraphs.empty?
|
|
222
|
+
|
|
223
|
+
paragraphs.sum do |paragraph|
|
|
224
|
+
paragraph_height(paragraph)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
private_class_method :estimate_content_height
|
|
228
|
+
|
|
229
|
+
def self.paragraphs_for_estimate(state)
|
|
230
|
+
result = []
|
|
231
|
+
result << state.current_paragraph if state.current_paragraph
|
|
232
|
+
result + state.paragraphs
|
|
233
|
+
end
|
|
234
|
+
private_class_method :paragraphs_for_estimate
|
|
235
|
+
|
|
236
|
+
def self.paragraph_height(paragraph)
|
|
237
|
+
size = paragraph.runs.first&.point_size || DEFAULT_SIZE
|
|
238
|
+
leading = leading_for(paragraph, size)
|
|
239
|
+
line_count = [paragraph.runs.length, 1].max
|
|
240
|
+
(leading * line_count) + space_before_after(paragraph)
|
|
241
|
+
end
|
|
242
|
+
private_class_method :paragraph_height
|
|
243
|
+
|
|
244
|
+
def self.space_before_after(paragraph)
|
|
245
|
+
(paragraph.space_before || 0) + (paragraph.space_after || 0)
|
|
246
|
+
end
|
|
247
|
+
private_class_method :space_before_after
|
|
248
|
+
|
|
249
|
+
def self.resume_paragraph(canvas, state, context, layout_frame, font,
|
|
250
|
+
cursor_y, bottom_limit, char_cursor)
|
|
251
|
+
paragraph = state.current_paragraph
|
|
252
|
+
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
253
|
+
canvas, paragraph, state.runs_remaining, context,
|
|
254
|
+
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
255
|
+
first_paragraph: true
|
|
256
|
+
)
|
|
257
|
+
if remaining_runs.any?
|
|
258
|
+
state.runs_remaining = remaining_runs
|
|
259
|
+
else
|
|
260
|
+
state.current_paragraph = nil
|
|
261
|
+
state.runs_remaining = []
|
|
262
|
+
end
|
|
263
|
+
[cursor_y, char_cursor]
|
|
264
|
+
end
|
|
265
|
+
private_class_method :resume_paragraph
|
|
266
|
+
|
|
267
|
+
def self.consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
268
|
+
cursor_y, bottom_limit, char_cursor)
|
|
269
|
+
pending = 0
|
|
270
|
+
state.paragraphs.each do |paragraph|
|
|
271
|
+
break if cursor_y < bottom_limit
|
|
272
|
+
|
|
273
|
+
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
274
|
+
canvas, paragraph, paragraph.runs, context,
|
|
275
|
+
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
276
|
+
first_paragraph: pending.zero?
|
|
277
|
+
)
|
|
278
|
+
if remaining_runs.any?
|
|
279
|
+
state.current_paragraph = paragraph
|
|
280
|
+
state.runs_remaining = remaining_runs
|
|
281
|
+
break
|
|
282
|
+
end
|
|
283
|
+
pending += 1
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
state.paragraphs = state.paragraphs[pending..]
|
|
287
|
+
state.char_cursor = char_cursor
|
|
288
|
+
end
|
|
289
|
+
private_class_method :consume_paragraphs
|
|
290
|
+
|
|
291
|
+
# Renders a paragraph's runs (a subset when resuming mid-para).
|
|
292
|
+
# Returns [remaining_runs, cursor_y, char_cursor] where
|
|
293
|
+
# remaining_runs is the runs that didn't fit (empty when all
|
|
294
|
+
# done). Emits RuleAbove before the first run and RuleBelow
|
|
295
|
+
# after the last successfully-rendered run.
|
|
296
|
+
def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
|
|
297
|
+
layout_frame, font, cursor_y,
|
|
298
|
+
bottom_limit, char_cursor,
|
|
299
|
+
first_paragraph:)
|
|
136
300
|
wrap_width = TextEngine::VerticalLayout.wrap_width(
|
|
137
301
|
layout_frame, paragraph.right_indent || 0
|
|
138
302
|
)
|
|
139
303
|
para_attrs = paragraph_attrs(paragraph)
|
|
140
304
|
frame_left, frame_right = paragraph_frame_extents(layout_frame)
|
|
141
305
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
306
|
+
emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
|
|
307
|
+
frame_left, frame_right, first_paragraph)
|
|
308
|
+
emit_drop_cap(canvas, paragraph, context, frame_left, cursor_y,
|
|
309
|
+
first_paragraph)
|
|
310
|
+
|
|
311
|
+
rendered_count = 0
|
|
312
|
+
runs.each do |run|
|
|
313
|
+
break if cursor_y < bottom_limit
|
|
314
|
+
|
|
315
|
+
space_before = paragraph_space_before(para_attrs, first_paragraph,
|
|
316
|
+
rendered_count)
|
|
317
|
+
positioned, next_y = layout_run(
|
|
318
|
+
canvas, run, paragraph, context, layout_frame, font,
|
|
319
|
+
wrap_width, cursor_y, space_before,
|
|
320
|
+
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
321
|
+
char_cursor
|
|
322
|
+
)
|
|
323
|
+
char_cursor += positioned.length
|
|
324
|
+
cursor_y = next_y
|
|
325
|
+
rendered_count += 1
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
remaining_runs = runs[rendered_count..]
|
|
329
|
+
cursor_y = emit_paragraph_bottom_rule(canvas, paragraph, context,
|
|
330
|
+
cursor_y, frame_left,
|
|
331
|
+
frame_right, para_attrs,
|
|
332
|
+
remaining_runs)
|
|
333
|
+
[remaining_runs, cursor_y, char_cursor]
|
|
334
|
+
end
|
|
335
|
+
private_class_method :render_runs_for_paragraph
|
|
336
|
+
|
|
337
|
+
# Renders the drop cap for a paragraph (when declared and the
|
|
338
|
+
# first paragraph of the chain). Wrap-around text is not yet
|
|
339
|
+
# implemented — the drop cap renders as an enlarged glyph at
|
|
340
|
+
# the paragraph's top-left; subsequent text may overlap it.
|
|
341
|
+
# Marked as a follow-up in TODO 103.
|
|
342
|
+
def self.emit_drop_cap(canvas, paragraph, context, frame_left,
|
|
343
|
+
cursor_y, first_paragraph)
|
|
344
|
+
return nil unless first_paragraph
|
|
345
|
+
return nil unless DropCap.active?(paragraph)
|
|
346
|
+
|
|
347
|
+
base_size = paragraph.runs.first&.point_size || DEFAULT_SIZE
|
|
348
|
+
leading = leading_for(paragraph, base_size)
|
|
349
|
+
drop_cap = DropCap.layout(paragraph,
|
|
350
|
+
font_metrics: context.font_metrics,
|
|
351
|
+
base_size: base_size, leading: leading)
|
|
352
|
+
return nil unless drop_cap
|
|
353
|
+
|
|
354
|
+
drop_at_y = cursor_y - drop_cap.font_size + (base_size * 0.25)
|
|
355
|
+
canvas.text(
|
|
356
|
+
drop_cap.text,
|
|
357
|
+
at: [frame_left, drop_at_y],
|
|
358
|
+
font: font_for_run(paragraph.runs.first, context),
|
|
359
|
+
size: drop_cap.font_size,
|
|
360
|
+
)
|
|
361
|
+
end
|
|
362
|
+
private_class_method :emit_drop_cap
|
|
363
|
+
|
|
364
|
+
def self.emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
|
|
365
|
+
frame_left, frame_right,
|
|
366
|
+
first_paragraph)
|
|
367
|
+
return unless first_paragraph
|
|
368
|
+
|
|
145
369
|
ParagraphRules.emit_rule_above(canvas, paragraph, context,
|
|
146
370
|
cursor_y, frame_left, frame_right)
|
|
371
|
+
end
|
|
372
|
+
private_class_method :emit_paragraph_top_rule
|
|
147
373
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
374
|
+
def self.paragraph_space_before(para_attrs, first_paragraph,
|
|
375
|
+
rendered_count)
|
|
376
|
+
return 0 unless first_paragraph && rendered_count.zero?
|
|
377
|
+
|
|
378
|
+
para_attrs[:space_before]
|
|
379
|
+
end
|
|
380
|
+
private_class_method :paragraph_space_before
|
|
153
381
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
382
|
+
# Emits RuleBelow after a paragraph's last run. Returns the
|
|
383
|
+
# updated cursor_y (subtracting space_after when the paragraph
|
|
384
|
+
# completed).
|
|
385
|
+
def self.emit_paragraph_bottom_rule(canvas, paragraph, context,
|
|
386
|
+
cursor_y, frame_left,
|
|
387
|
+
frame_right, para_attrs,
|
|
388
|
+
remaining_runs)
|
|
389
|
+
return cursor_y unless remaining_runs.empty?
|
|
390
|
+
|
|
391
|
+
after_y = cursor_y - para_attrs[:space_after]
|
|
157
392
|
ParagraphRules.emit_rule_below(canvas, paragraph, context,
|
|
158
393
|
after_y, frame_left, frame_right)
|
|
159
|
-
|
|
394
|
+
after_y
|
|
160
395
|
end
|
|
161
|
-
private_class_method :
|
|
396
|
+
private_class_method :emit_paragraph_bottom_rule
|
|
162
397
|
|
|
163
398
|
def self.paragraph_frame_extents(layout_frame)
|
|
164
399
|
inset_left = layout_frame.inset_left || 0
|
|
@@ -178,27 +413,6 @@ module Idml
|
|
|
178
413
|
end
|
|
179
414
|
private_class_method :paragraph_attrs
|
|
180
415
|
|
|
181
|
-
def self.render_runs(canvas, paragraph, context, layout_frame, font,
|
|
182
|
-
wrap_width, cursor_y, bottom_limit, char_cursor,
|
|
183
|
-
para_attrs)
|
|
184
|
-
space_before = para_attrs[:space_before]
|
|
185
|
-
paragraph.runs.each do |run|
|
|
186
|
-
break if cursor_y < bottom_limit
|
|
187
|
-
|
|
188
|
-
positioned, next_y = layout_run(
|
|
189
|
-
canvas, run, paragraph, context, layout_frame, font,
|
|
190
|
-
wrap_width, cursor_y, space_before,
|
|
191
|
-
para_attrs[:first_line_indent], para_attrs[:left_indent],
|
|
192
|
-
char_cursor
|
|
193
|
-
)
|
|
194
|
-
char_cursor += positioned.length
|
|
195
|
-
cursor_y = next_y
|
|
196
|
-
space_before = 0
|
|
197
|
-
end
|
|
198
|
-
[cursor_y, char_cursor]
|
|
199
|
-
end
|
|
200
|
-
private_class_method :render_runs
|
|
201
|
-
|
|
202
416
|
# Shapes one run's text, wraps to the inset/indent-adjusted
|
|
203
417
|
# width, justifies per paragraph alignment, and asks
|
|
204
418
|
# VerticalLayout to position the lines. Emits each line via
|
|
@@ -321,8 +535,11 @@ module Idml
|
|
|
321
535
|
|
|
322
536
|
# Fallback when no metrics are available: emit all runs as
|
|
323
537
|
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
324
|
-
|
|
325
|
-
|
|
538
|
+
# Chain threading is best-effort here — the simple path
|
|
539
|
+
# doesn't wrap, so the first frame's render typically emits
|
|
540
|
+
# all content and clears the chain state.
|
|
541
|
+
def self.simple_render(canvas, state, context, box)
|
|
542
|
+
runs = collect_runs(state)
|
|
326
543
|
return if runs.empty?
|
|
327
544
|
|
|
328
545
|
runs_for = build_rich_runs(runs, context)
|
|
@@ -331,9 +548,22 @@ module Idml
|
|
|
331
548
|
runs_for,
|
|
332
549
|
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
333
550
|
)
|
|
551
|
+
state.paragraphs = []
|
|
552
|
+
state.current_paragraph = nil
|
|
553
|
+
state.runs_remaining = []
|
|
334
554
|
end
|
|
335
555
|
private_class_method :simple_render
|
|
336
556
|
|
|
557
|
+
def self.collect_runs(state)
|
|
558
|
+
runs = state.runs_remaining || []
|
|
559
|
+
if state.current_paragraph
|
|
560
|
+
runs + state.current_paragraph.runs
|
|
561
|
+
else
|
|
562
|
+
runs + state.paragraphs.flat_map(&:runs)
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
private_class_method :collect_runs
|
|
566
|
+
|
|
337
567
|
def self.build_rich_runs(runs, context)
|
|
338
568
|
runs.map do |run|
|
|
339
569
|
{
|
|
@@ -7,7 +7,8 @@ module Idml
|
|
|
7
7
|
def initialize(font_metrics: nil, font_ps_name: Render::DEFAULT_FONT,
|
|
8
8
|
package: nil, layer_filter: LayerFilter::EXCLUDE_NONE,
|
|
9
9
|
font_ref_resolver: nil, structure: nil,
|
|
10
|
-
position_tracker: nil, font_map: {}
|
|
10
|
+
position_tracker: nil, font_map: {},
|
|
11
|
+
condition_filter: nil)
|
|
11
12
|
@font_metrics = font_metrics
|
|
12
13
|
@font_ps_name = font_ps_name
|
|
13
14
|
@package = package
|
|
@@ -16,6 +17,7 @@ module Idml
|
|
|
16
17
|
@structure = structure
|
|
17
18
|
@position_tracker = position_tracker
|
|
18
19
|
@font_map = font_map
|
|
20
|
+
@condition_filter = condition_filter
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def render(canvas, spread, page_width:, page_height:, image_refs: [],
|
|
@@ -33,6 +35,8 @@ module Idml
|
|
|
33
35
|
structure: @structure,
|
|
34
36
|
page_index: page_index,
|
|
35
37
|
position_tracker: @position_tracker,
|
|
38
|
+
chain_controller: StoryChainController.new,
|
|
39
|
+
condition_filter: @condition_filter,
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
canvas.save_graphics_state do
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Threads story-rendering state across linked TextFrames in a
|
|
6
|
+
# chain. A story overflows from frame 1 → frame 2 → frame 3 etc.
|
|
7
|
+
# via PreviousTextFrame/NextTextFrame links; without chain
|
|
8
|
+
# awareness, the renderer would silently truncate overflow text.
|
|
9
|
+
#
|
|
10
|
+
# State is per story_id (the ParentStory attribute). State is
|
|
11
|
+
# populated when a chain head renders and consumed when chain
|
|
12
|
+
# non-head frames render.
|
|
13
|
+
#
|
|
14
|
+
# Threading granularity: paragraphs and runs. If a run partially
|
|
15
|
+
# fits in a frame, the next frame picks up where the previous
|
|
16
|
+
# left off (the partially-rendered lines stay in the previous
|
|
17
|
+
# frame; remaining lines come from re-wrapping the run).
|
|
18
|
+
class StoryChainController
|
|
19
|
+
# `paragraphs` : Array<Paragraph> not yet started
|
|
20
|
+
# `current_paragraph` : Paragraph in progress (nil if none)
|
|
21
|
+
# `runs_remaining` : Array<StyledRun> in current_paragraph not yet rendered
|
|
22
|
+
# `char_cursor` : global character cursor (for hyperlink tracking)
|
|
23
|
+
State = Struct.new(
|
|
24
|
+
:paragraphs,
|
|
25
|
+
:current_paragraph,
|
|
26
|
+
:runs_remaining,
|
|
27
|
+
:char_cursor,
|
|
28
|
+
keyword_init: true,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@states = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the chain state for a story, or nil if the story has
|
|
36
|
+
# no leftover content. Chain heads (no predecessor) get no
|
|
37
|
+
# state — they render from scratch.
|
|
38
|
+
def state_for(story_id)
|
|
39
|
+
@states[story_id]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Records the chain state for a story after a frame's render.
|
|
43
|
+
# Removes the state entirely when no content remains.
|
|
44
|
+
def store_state(story_id, state)
|
|
45
|
+
if state.nil? || empty_state?(state)
|
|
46
|
+
@states.delete(story_id)
|
|
47
|
+
else
|
|
48
|
+
@states[story_id] = state
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# True when a story has pending content from a previous frame.
|
|
53
|
+
def has_pending?(story_id)
|
|
54
|
+
@states.key?(story_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def empty_state?(state)
|
|
60
|
+
state.paragraphs.empty? &&
|
|
61
|
+
state.current_paragraph.nil? &&
|
|
62
|
+
state.runs_remaining&.empty?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -54,6 +54,8 @@ module Idml
|
|
|
54
54
|
:left_indent,
|
|
55
55
|
:right_indent,
|
|
56
56
|
:auto_leading,
|
|
57
|
+
:drop_cap_lines,
|
|
58
|
+
:drop_cap_characters,
|
|
57
59
|
# RuleAbove / RuleBelow — paragraph rules (horizontal lines
|
|
58
60
|
# above/below the paragraph block). Driven by PSR attributes.
|
|
59
61
|
:rule_above,
|
|
@@ -104,11 +106,13 @@ module Idml
|
|
|
104
106
|
# its runs and carries the paragraph-level attributes from the
|
|
105
107
|
# owning PSR. Empty paragraphs (no runs after filtering) are
|
|
106
108
|
# skipped.
|
|
107
|
-
def self.extract_paragraphs(story)
|
|
109
|
+
def self.extract_paragraphs(story, condition_filter: nil)
|
|
108
110
|
return [] unless story&.inner
|
|
109
111
|
|
|
110
112
|
story.inner.paragraph_style_range.filter_map do |psr|
|
|
111
|
-
|
|
113
|
+
next unless condition_visible?(psr, condition_filter)
|
|
114
|
+
|
|
115
|
+
runs = csr_runs(psr, condition_filter: condition_filter)
|
|
112
116
|
next if runs.empty?
|
|
113
117
|
|
|
114
118
|
Paragraph.new(
|
|
@@ -120,6 +124,8 @@ module Idml
|
|
|
120
124
|
left_indent: psr.left_indent,
|
|
121
125
|
right_indent: psr.right_indent,
|
|
122
126
|
auto_leading: psr.auto_leading,
|
|
127
|
+
drop_cap_lines: psr.drop_cap_lines,
|
|
128
|
+
drop_cap_characters: psr.drop_cap_characters,
|
|
123
129
|
rule_above: psr.rule_above,
|
|
124
130
|
rule_above_line_weight: psr.rule_above_line_weight,
|
|
125
131
|
rule_above_color: psr.stroke_color,
|
|
@@ -140,8 +146,10 @@ module Idml
|
|
|
140
146
|
end
|
|
141
147
|
end
|
|
142
148
|
|
|
143
|
-
def self.csr_runs(psr)
|
|
149
|
+
def self.csr_runs(psr, condition_filter: nil)
|
|
144
150
|
psr.character_style_range.filter_map do |csr|
|
|
151
|
+
next unless condition_visible?(csr, condition_filter)
|
|
152
|
+
|
|
145
153
|
text = csr.text_content
|
|
146
154
|
next if text.nil? || text.empty?
|
|
147
155
|
|
|
@@ -170,6 +178,16 @@ module Idml
|
|
|
170
178
|
end
|
|
171
179
|
private_class_method :csr_runs
|
|
172
180
|
|
|
181
|
+
# Drops the PSR/CSR when any of its AppliedConditions
|
|
182
|
+
# references a hidden Condition. Returns true when no filter
|
|
183
|
+
# is supplied (caller doesn't care about conditions).
|
|
184
|
+
def self.condition_visible?(psr_or_csr, condition_filter)
|
|
185
|
+
return true unless condition_filter
|
|
186
|
+
|
|
187
|
+
condition_filter.visible?(psr_or_csr.applied_conditions)
|
|
188
|
+
end
|
|
189
|
+
private_class_method :condition_visible?
|
|
190
|
+
|
|
173
191
|
def self.alignment_for(csr)
|
|
174
192
|
ALIGNMENT_MAP[csr.justification] || :left
|
|
175
193
|
end
|
data/lib/idml/render.rb
CHANGED
|
@@ -15,7 +15,10 @@ module Idml
|
|
|
15
15
|
autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
|
|
16
16
|
autoload :CharacterStyle, "#{__dir__}/render/character_style"
|
|
17
17
|
autoload :ParagraphRules, "#{__dir__}/render/paragraph_rules"
|
|
18
|
+
autoload :DropCap, "#{__dir__}/render/drop_cap"
|
|
19
|
+
autoload :StoryChainController, "#{__dir__}/render/story_chain_controller"
|
|
18
20
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
21
|
+
autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
|
|
19
22
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
20
23
|
autoload :ImageCollector, "#{__dir__}/render/image_collector"
|
|
21
24
|
autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
|
data/lib/idml/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: idml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose
|
|
@@ -168,6 +168,10 @@ files:
|
|
|
168
168
|
- TODO.pdf/11-font-embedding.md
|
|
169
169
|
- TODO.pdf/110-conditional-text.md
|
|
170
170
|
- TODO.pdf/111-footnote-support.md
|
|
171
|
+
- TODO.pdf/112-text-frame-vertical-justification.md
|
|
172
|
+
- TODO.pdf/113-multi-column-text-frames.md
|
|
173
|
+
- TODO.pdf/114-text-wrap-around-shapes.md
|
|
174
|
+
- TODO.pdf/115-bullet-numbered-lists.md
|
|
171
175
|
- TODO.pdf/12-idml-to-pdf-pipeline.md
|
|
172
176
|
- TODO.pdf/13-cjk-text-layout.md
|
|
173
177
|
- TODO.pdf/14-page-item-element-models.md
|
|
@@ -276,6 +280,7 @@ files:
|
|
|
276
280
|
- lib/idml/elements/character_style_group.rb
|
|
277
281
|
- lib/idml/elements/character_style_range.rb
|
|
278
282
|
- lib/idml/elements/color.rb
|
|
283
|
+
- lib/idml/elements/condition.rb
|
|
279
284
|
- lib/idml/elements/content.rb
|
|
280
285
|
- lib/idml/elements/content_transparency_setting.rb
|
|
281
286
|
- lib/idml/elements/contour_option.rb
|
|
@@ -428,6 +433,8 @@ files:
|
|
|
428
433
|
- lib/idml/render/character_style.rb
|
|
429
434
|
- lib/idml/render/color_helper.rb
|
|
430
435
|
- lib/idml/render/color_resolver.rb
|
|
436
|
+
- lib/idml/render/condition_filter.rb
|
|
437
|
+
- lib/idml/render/drop_cap.rb
|
|
431
438
|
- lib/idml/render/font_reference_resolver.rb
|
|
432
439
|
- lib/idml/render/font_setup.rb
|
|
433
440
|
- lib/idml/render/gradient_resolver.rb
|
|
@@ -454,6 +461,7 @@ files:
|
|
|
454
461
|
- lib/idml/render/renderers/table_renderer.rb
|
|
455
462
|
- lib/idml/render/renderers/text_frame_renderer.rb
|
|
456
463
|
- lib/idml/render/spread_renderer.rb
|
|
464
|
+
- lib/idml/render/story_chain_controller.rb
|
|
457
465
|
- lib/idml/render/story_threader.rb
|
|
458
466
|
- lib/idml/render/stroke_style.rb
|
|
459
467
|
- lib/idml/render/structure_mapper.rb
|