idml 0.7.0 → 0.7.2
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/101-paragraph-rules-borders.md +10 -1
- data/TODO.pdf/108-multi-frame-story-flow.md +54 -0
- data/TODO.pdf/109-dry-color-tint.md +42 -0
- data/TODO.pdf/110-conditional-text.md +43 -0
- data/TODO.pdf/111-footnote-support.md +40 -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/character_style.rb +50 -2
- data/lib/idml/render/color_helper.rb +27 -3
- data/lib/idml/render/condition_filter.rb +57 -0
- data/lib/idml/render/paragraph_rules.rb +125 -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 +217 -56
- 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 +51 -3
- data/lib/idml/render.rb +3 -0
- data/lib/idml/version.rb +1 -1
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6cc7058e67830e2dc6ea523ea6ca054f702ce29b51989a19deb023e5a840bee
|
|
4
|
+
data.tar.gz: 434b5af5ea49b9f2b48728b100b612ff0cd784079abfd6d419dbeb16554b3ebb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a59cabfafa44138cb40ed8b6e493396c110ffe43728c0d0ea94d8ef4d8039efad635ba23660852b1040b58ce7141b6e301bf27ecb96599efd6d72069e6d81a95
|
|
7
|
+
data.tar.gz: d142ebcfc9eb6591827c72d3cd33303ee3a4c70637031b03622ea1355d453cac4aac1654f60ee7a3ee54a050755e88fb84c32c01df11df802c434442c0f9a4e2
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# TODO PDF 101: Paragraph borders and rules (RuleAbove, RuleBelow, ParagraphBorder)
|
|
2
2
|
|
|
3
|
-
## Status:
|
|
3
|
+
## Status: PARTIAL — RuleAbove and RuleBelow implemented via new
|
|
4
|
+
`Render::ParagraphRules` helper; TextFrameRenderer emits them at
|
|
5
|
+
paragraph top/bottom. Rule color comes from PSR#stroke_color (IDML's
|
|
6
|
+
PSR doesn't have a dedicated RuleAboveColor attribute). RuleAboveWidth
|
|
7
|
+
"Text" vs "Column" honored. Tint scaling + line weight + offset honored.
|
|
8
|
+
|
|
9
|
+
ParagraphBorder and ParagraphShading remain open — they need the
|
|
10
|
+
paragraph's bounding rect, which the renderer doesn't currently
|
|
11
|
+
expose to ParagraphRules. Will add when a real document fixture
|
|
12
|
+
exercises them.
|
|
4
13
|
|
|
5
14
|
## Problem
|
|
6
15
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# TODO PDF 108: Multi-frame story flow (use StoryThreader)
|
|
2
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Problem
|
|
10
|
+
|
|
11
|
+
`Render::StoryThreader` exists with specs (`spec/idml/render_layer_threader_spec.rb`).
|
|
12
|
+
It builds text-frame chains from PreviousTextFrame/NextTextFrame
|
|
13
|
+
links. The intent: a story flows across linked frames, with each
|
|
14
|
+
frame getting the lines that fit and overflow going to the next.
|
|
15
|
+
|
|
16
|
+
But `TextFrameRenderer` doesn't use it. The renderer treats each
|
|
17
|
+
frame independently:
|
|
18
|
+
- `chain_head?` filter skips non-head frames (correct — they'd
|
|
19
|
+
double-render)
|
|
20
|
+
- Each chain-head frame renders its entire story from the top
|
|
21
|
+
- Overflow that doesn't fit is silently dropped
|
|
22
|
+
|
|
23
|
+
For real documents with multi-page stories (books, magazines,
|
|
24
|
+
newspapers), this loses text. The PDF shows only what fits in the
|
|
25
|
+
first frame.
|
|
26
|
+
|
|
27
|
+
## What needs to happen
|
|
28
|
+
|
|
29
|
+
1. TextFrameRenderer accepts the chain (list of frames) instead
|
|
30
|
+
of one frame at a time.
|
|
31
|
+
2. For each chain:
|
|
32
|
+
- Lay out story text starting at frame 1's top
|
|
33
|
+
- When text overflows frame 1's bottom, continue at frame 2's top
|
|
34
|
+
- Repeat until either text ends or chain ends
|
|
35
|
+
3. The PageItemRenderer dispatch needs to know about chains, not
|
|
36
|
+
just per-item rendering.
|
|
37
|
+
|
|
38
|
+
Architecture: the chain context needs to live in RenderContext
|
|
39
|
+
(or a new RenderChainContext). Each frame's render call passes the
|
|
40
|
+
current y cursor + remaining paragraphs/runs/lines, and the next
|
|
41
|
+
frame's render call picks up where the previous left off.
|
|
42
|
+
|
|
43
|
+
## Acceptance criteria
|
|
44
|
+
|
|
45
|
+
- [ ] Story that overflows frame 1 continues in frame 2.
|
|
46
|
+
- [ ] Hyperlink rects resolve correctly across frames.
|
|
47
|
+
- [ ] Spec: 2 linked TextFrames, story of 50 lines, each frame fits
|
|
48
|
+
20 lines → frame 1 shows lines 1-20, frame 2 shows 21-40,
|
|
49
|
+
frame 3 (if present) shows 41-50.
|
|
50
|
+
|
|
51
|
+
## Dependencies
|
|
52
|
+
|
|
53
|
+
- None — independent feature work, but changes the renderer's
|
|
54
|
+
per-frame abstraction significantly.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# TODO PDF 109: DRY color tint application (ColorHelper vs CharacterStyle vs ParagraphRules)
|
|
2
|
+
|
|
3
|
+
## Status: DONE — `ColorHelper.apply_tint(color, tint)` is the single
|
|
4
|
+
canonical tint helper. CharacterStyle and ParagraphRules both delegate
|
|
5
|
+
to it. 7 new specs cover nil tint, tint >= 1.0, RGB/CMYK scaling,
|
|
6
|
+
tint 0.0, unknown models, nil input.
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
Color tint (scaling a color's components toward zero) is implemented
|
|
11
|
+
in three places with slightly different shapes:
|
|
12
|
+
|
|
13
|
+
1. `Render::CharacterStyle.apply_tint(color, tint)` — used for
|
|
14
|
+
text fill color.
|
|
15
|
+
2. `Render::ParagraphRules.scale_color(color, tint_value)` — used
|
|
16
|
+
for paragraph rule colors.
|
|
17
|
+
3. (Implicit) `Render::ColorResolver` resolves tints on its own
|
|
18
|
+
when reading ColorValue, before returning the color hash.
|
|
19
|
+
|
|
20
|
+
The first two are essentially the same logic with different method
|
|
21
|
+
names. The third means a `ColorResolver.resolve` result might or
|
|
22
|
+
might not have tint already applied — callers can't tell.
|
|
23
|
+
|
|
24
|
+
## What needs to happen
|
|
25
|
+
|
|
26
|
+
1. Pick one canonical tint helper. Either:
|
|
27
|
+
- Move to `ColorHelper.apply_tint(color, tint)`, or
|
|
28
|
+
- Create `Render::ColorTint.apply(color, tint)`.
|
|
29
|
+
2. CharacterStyle and ParagraphRules call the canonical helper.
|
|
30
|
+
3. ColorResolver documents whether it applies tint or returns raw
|
|
31
|
+
colors (today it appears to apply tint internally).
|
|
32
|
+
|
|
33
|
+
## Acceptance criteria
|
|
34
|
+
|
|
35
|
+
- [ ] Single tint helper in one place.
|
|
36
|
+
- [ ] CharacterStyle and ParagraphRules both delegate to it.
|
|
37
|
+
- [ ] ColorResolver's tint behavior is documented.
|
|
38
|
+
- [ ] Spec coverage for tint 0.0, 0.5, 1.0 on RGB and CMYK colors.
|
|
39
|
+
|
|
40
|
+
## Dependencies
|
|
41
|
+
|
|
42
|
+
- None — pure refactor.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# TODO PDF 110: Conditional text (AppliedConditions)
|
|
2
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Problem
|
|
12
|
+
|
|
13
|
+
PSR and CSR both carry an `AppliedConditions` attribute (a list of
|
|
14
|
+
condition Self IDs). In InDesign, conditions let authors mark text
|
|
15
|
+
as conditional (e.g., "show only in instructor edition", "draft
|
|
16
|
+
review comments"). Each `<Condition>` in Resources/Styles.xml
|
|
17
|
+
declares whether it's visible, its highlight color, etc.
|
|
18
|
+
|
|
19
|
+
Today the renderer ignores `AppliedConditions` — all conditional
|
|
20
|
+
text renders regardless of condition visibility. For documents that
|
|
21
|
+
use conditions heavily (textbooks, legal, regulatory), this means
|
|
22
|
+
the PDF always shows every variant instead of the chosen subset.
|
|
23
|
+
|
|
24
|
+
## What needs to happen
|
|
25
|
+
|
|
26
|
+
1. Verify `Elements::Condition` exists in the model (Resources/Styles.rnc).
|
|
27
|
+
2. Add `Render::ConditionFilter` that reads visible conditions from
|
|
28
|
+
Resources/Styles.xml.
|
|
29
|
+
3. StyleResolver filters PSRs/CSRs whose AppliedConditions reference
|
|
30
|
+
a hidden condition.
|
|
31
|
+
4. Spec: PSR with condition A (hidden) doesn't render; condition B
|
|
32
|
+
(visible) renders.
|
|
33
|
+
|
|
34
|
+
## Acceptance criteria
|
|
35
|
+
|
|
36
|
+
- [ ] Condition model from RNC.
|
|
37
|
+
- [ ] ConditionFilter.from_styles(graphic_or_styles).
|
|
38
|
+
- [ ] StyleResolver.extract_paragraphs takes optional ConditionFilter.
|
|
39
|
+
- [ ] Hidden-condition runs filtered out.
|
|
40
|
+
|
|
41
|
+
## Dependencies
|
|
42
|
+
|
|
43
|
+
- None — pure feature work on existing models.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# TODO PDF 111: Footnote support
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-09 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
IDML has full footnote support:
|
|
8
|
+
- `<Footnote>` elements in Stories (inline footnote markers + text)
|
|
9
|
+
- `<FootnoteOption>` on PSR (numbering rules, layout)
|
|
10
|
+
- `TextFramePreference.FootnotesEnableOverrides`,
|
|
11
|
+
`FootnotesSpanAcrossColumns`, `FootnotesMinimumSpacing`,
|
|
12
|
+
`FootnotesSpaceBetween` — frame-level footnote layout
|
|
13
|
+
- `<TextFrameFootnoteOptionsObject>` — per-frame footnote config
|
|
14
|
+
|
|
15
|
+
The renderer doesn't model footnote content or lay it out.
|
|
16
|
+
Documents with footnotes (academic, legal, technical) currently
|
|
17
|
+
lose footnote text in the PDF.
|
|
18
|
+
|
|
19
|
+
## What needs to happen
|
|
20
|
+
|
|
21
|
+
1. Model `<Footnote>` element (story-embedded footnote text +
|
|
22
|
+
reference marker).
|
|
23
|
+
2. Story parsing extracts footnotes alongside main text.
|
|
24
|
+
3. Footnote text renders at the bottom of the page that contains
|
|
25
|
+
the reference marker.
|
|
26
|
+
4. Footnote numbering rules from FootnoteOption honored.
|
|
27
|
+
5. Continuous footnotes flow across pages.
|
|
28
|
+
|
|
29
|
+
This is a large feature — defer until needed by a real fixture.
|
|
30
|
+
|
|
31
|
+
## Acceptance criteria
|
|
32
|
+
|
|
33
|
+
- [ ] Footnote text rendered at bottom of containing page.
|
|
34
|
+
- [ ] Footnote marker in body text links to footnote text.
|
|
35
|
+
- [ ] Numbering restarts per document section per FootnoteOption.
|
|
36
|
+
|
|
37
|
+
## Dependencies
|
|
38
|
+
|
|
39
|
+
- TODO 108 (multi-frame story flow) — footnotes need to know which
|
|
40
|
+
page a reference appears on.
|
|
@@ -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
|
|
@@ -20,7 +20,10 @@ module Idml
|
|
|
20
20
|
# Yields with the run's fill color applied, then draws underline
|
|
21
21
|
# and/or strike-through rules above/below the text baseline.
|
|
22
22
|
# `x`, `y`, `width`, `size` describe the line's geometry so the
|
|
23
|
-
# rules can be positioned correctly.
|
|
23
|
+
# rules can be positioned correctly. The block's emitted text
|
|
24
|
+
# gets the run's character-level adjustments baked in via
|
|
25
|
+
# `text_kwargs` (caller passes the canvas.text kwargs in, and
|
|
26
|
+
# CharacterStyle adds tracking/scale on top).
|
|
24
27
|
def self.apply(canvas, run, context, x:, y:, width:, size:)
|
|
25
28
|
apply_fill_color(canvas, run, context)
|
|
26
29
|
yield
|
|
@@ -28,6 +31,42 @@ module Idml
|
|
|
28
31
|
draw_strike_through(canvas, run, x, y, width, size)
|
|
29
32
|
end
|
|
30
33
|
|
|
34
|
+
# Returns the canvas.text kwargs augmented with the run's
|
|
35
|
+
# Tracking (PDF char_spacing). IDML Tracking is in points;
|
|
36
|
+
# pdfrb's char_spacing is also in text-space units, so the
|
|
37
|
+
# mapping is 1:1.
|
|
38
|
+
def self.text_kwargs(run, base_kwargs)
|
|
39
|
+
return base_kwargs unless run.tracking
|
|
40
|
+
|
|
41
|
+
base_kwargs.merge(char_spacing: run.tracking.to_f)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Yields within a transformed coordinate space when the run
|
|
45
|
+
# declares glyph scaling. IDML HorizontalScale/VerticalScale
|
|
46
|
+
# are percentages (100 = no scaling). When both are 100 / nil,
|
|
47
|
+
# yields without transformation (no graphics-state push).
|
|
48
|
+
def self.with_glyph_scaling(canvas, run, &)
|
|
49
|
+
sx = scale_factor(run.horizontal_scale)
|
|
50
|
+
sy = scale_factor(run.vertical_scale)
|
|
51
|
+
return yield if scaling_identity?(sx, sy)
|
|
52
|
+
|
|
53
|
+
canvas.scale(sx, sy, &)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.scaling_identity?(sx, sy)
|
|
57
|
+
(sx - 1.0).abs < FLOAT_EPSILON && (sy - 1.0).abs < FLOAT_EPSILON
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
FLOAT_EPSILON = 1e-9
|
|
61
|
+
private_constant :FLOAT_EPSILON
|
|
62
|
+
|
|
63
|
+
# Returns the run's baseline shift (in PDF units). Positive
|
|
64
|
+
# values shift up; negative shift down. Used to offset the
|
|
65
|
+
# text's `at: y` for CSR's BaselineShift.
|
|
66
|
+
def self.baseline_offset(run)
|
|
67
|
+
run.baseline_shift&.nonzero? ? run.baseline_shift.to_f : 0.0
|
|
68
|
+
end
|
|
69
|
+
|
|
31
70
|
# Transforms the text per the CSR's Capitalization attribute.
|
|
32
71
|
# AllCaps/SmallCaps → uppercase (true small-caps would require
|
|
33
72
|
# an OpenType feature; deferred). Other values pass through.
|
|
@@ -55,10 +94,19 @@ module Idml
|
|
|
55
94
|
color = context.color_resolver&.resolve(run.fill_color)
|
|
56
95
|
return unless color
|
|
57
96
|
|
|
58
|
-
|
|
97
|
+
tinted = ColorHelper.apply_tint(color, run.fill_tint)
|
|
98
|
+
canvas.fill_color(ColorHelper.to_canvas(tinted))
|
|
59
99
|
end
|
|
60
100
|
private_class_method :apply_fill_color
|
|
61
101
|
|
|
102
|
+
def self.scale_factor(declared)
|
|
103
|
+
return 1.0 unless declared
|
|
104
|
+
return 1.0 if declared <= 0
|
|
105
|
+
|
|
106
|
+
declared.to_f / 100.0
|
|
107
|
+
end
|
|
108
|
+
private_class_method :scale_factor
|
|
109
|
+
|
|
62
110
|
def self.draw_underline(canvas, run, x, y, width, size)
|
|
63
111
|
return unless run.underline
|
|
64
112
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string: true
|
|
2
2
|
|
|
3
3
|
module Idml
|
|
4
4
|
module Render
|
|
5
|
-
# Converts IDML color hashes to pdfrb Canvas color arrays
|
|
6
|
-
# Centralizes the mapping so all renderers
|
|
5
|
+
# Converts IDML color hashes to pdfrb Canvas color arrays and
|
|
6
|
+
# applies tint scaling. Centralizes the mapping so all renderers
|
|
7
|
+
# use a consistent format.
|
|
7
8
|
module ColorHelper
|
|
8
9
|
module_function
|
|
9
10
|
|
|
@@ -18,6 +19,29 @@ module Idml
|
|
|
18
19
|
when :cmyk then [:cmyk, color[:c], color[:m], color[:y], color[:k]]
|
|
19
20
|
end
|
|
20
21
|
end
|
|
22
|
+
|
|
23
|
+
# Scales a color's components by `tint` (1.0 = full strength,
|
|
24
|
+
# 0.5 = halved). Tint is the IDML mechanism for lightening a
|
|
25
|
+
# color without defining a new one. Returns the original color
|
|
26
|
+
# unchanged when tint is nil or >= 1.0. Single canonical
|
|
27
|
+
# implementation — CharacterStyle, ParagraphRules, and any
|
|
28
|
+
# future caller should all delegate here.
|
|
29
|
+
def apply_tint(color, tint)
|
|
30
|
+
return color unless color
|
|
31
|
+
return color unless tint
|
|
32
|
+
return color if tint >= 1.0
|
|
33
|
+
|
|
34
|
+
case color[:model]
|
|
35
|
+
when :rgb
|
|
36
|
+
{ model: :rgb, r: color[:r] * tint, g: color[:g] * tint,
|
|
37
|
+
b: color[:b] * tint }
|
|
38
|
+
when :cmyk
|
|
39
|
+
{ model: :cmyk, c: color[:c] * tint, m: color[:m] * tint,
|
|
40
|
+
y: color[:y] * tint, k: color[:k] * tint }
|
|
41
|
+
else
|
|
42
|
+
color
|
|
43
|
+
end
|
|
44
|
+
end
|
|
21
45
|
end
|
|
22
46
|
end
|
|
23
47
|
end
|
|
@@ -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,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Idml
|
|
4
|
+
module Render
|
|
5
|
+
# Emits paragraph-level rules (RuleAbove / RuleBelow) on a pdfrb
|
|
6
|
+
# canvas. Centralizes the IDML → PDF mapping for the paragraph
|
|
7
|
+
# rule attributes carried by `StyleResolver::Paragraph`.
|
|
8
|
+
#
|
|
9
|
+
# Each rule is a horizontal stroke above (RuleAbove) or below
|
|
10
|
+
# (RuleBelow) the paragraph block. The rule's weight, color,
|
|
11
|
+
# tint, offset, and indent come from the PSR's RuleAbove* /
|
|
12
|
+
# RuleBelow* attributes; defaults approximate InDesign behavior
|
|
13
|
+
# when an attribute is missing.
|
|
14
|
+
#
|
|
15
|
+
# Used by `TextFrameRenderer` so paragraph rules live in one
|
|
16
|
+
# place (DRY).
|
|
17
|
+
module ParagraphRules
|
|
18
|
+
DEFAULT_LINE_WEIGHT = 0.5
|
|
19
|
+
DEFAULT_TINT = 1.0
|
|
20
|
+
DEFAULT_OFFSET = 0.0
|
|
21
|
+
|
|
22
|
+
# Emits RuleAbove if `paragraph.rule_above` is true. The rule
|
|
23
|
+
# sits above the paragraph's first line. `top_y` is the y of
|
|
24
|
+
# the paragraph's top edge (before SpaceBefore is applied);
|
|
25
|
+
# `frame_left` / `frame_right` are the column's text bounds.
|
|
26
|
+
def self.emit_rule_above(canvas, paragraph, context, top_y,
|
|
27
|
+
frame_left, frame_right)
|
|
28
|
+
return unless paragraph.rule_above
|
|
29
|
+
|
|
30
|
+
emit_rule(canvas, paragraph, context, top_y, frame_left,
|
|
31
|
+
frame_right,
|
|
32
|
+
weight: paragraph.rule_above_line_weight,
|
|
33
|
+
color: paragraph.rule_above_color,
|
|
34
|
+
tint: paragraph.rule_above_tint,
|
|
35
|
+
offset: paragraph.rule_above_offset,
|
|
36
|
+
left_indent: paragraph.rule_above_left_indent,
|
|
37
|
+
right_indent: paragraph.rule_above_right_indent,
|
|
38
|
+
width_mode: paragraph.rule_above_width,
|
|
39
|
+
position: :above)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Emits RuleBelow if `paragraph.rule_below` is true. The rule
|
|
43
|
+
# sits below the paragraph's last line. `bottom_y` is the y of
|
|
44
|
+
# the paragraph's bottom edge (after SpaceAfter is applied).
|
|
45
|
+
def self.emit_rule_below(canvas, paragraph, context, bottom_y,
|
|
46
|
+
frame_left, frame_right)
|
|
47
|
+
return unless paragraph.rule_below
|
|
48
|
+
|
|
49
|
+
emit_rule(canvas, paragraph, context, bottom_y, frame_left,
|
|
50
|
+
frame_right,
|
|
51
|
+
weight: paragraph.rule_below_line_weight,
|
|
52
|
+
color: paragraph.rule_below_color,
|
|
53
|
+
tint: paragraph.rule_below_tint,
|
|
54
|
+
offset: paragraph.rule_below_offset,
|
|
55
|
+
left_indent: paragraph.rule_below_left_indent,
|
|
56
|
+
right_indent: paragraph.rule_below_right_indent,
|
|
57
|
+
width_mode: paragraph.rule_below_width,
|
|
58
|
+
position: :below)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.emit_rule(canvas, paragraph, context, anchor_y,
|
|
62
|
+
frame_left, frame_right,
|
|
63
|
+
weight:, color:, tint:, offset:,
|
|
64
|
+
left_indent:, right_indent:, width_mode:,
|
|
65
|
+
position:)
|
|
66
|
+
thickness = (weight || DEFAULT_LINE_WEIGHT).to_f
|
|
67
|
+
return unless thickness.positive?
|
|
68
|
+
|
|
69
|
+
tint_value = (tint || DEFAULT_TINT).to_f
|
|
70
|
+
return unless tint_value.positive?
|
|
71
|
+
|
|
72
|
+
offset_value = (offset || DEFAULT_OFFSET).to_f
|
|
73
|
+
x1, x2 = rule_extents(frame_left, frame_right, left_indent,
|
|
74
|
+
right_indent, width_mode, paragraph)
|
|
75
|
+
y = rule_y(anchor_y, offset_value, position)
|
|
76
|
+
|
|
77
|
+
apply_rule_color(canvas, color, tint_value, context)
|
|
78
|
+
canvas.line_width = thickness
|
|
79
|
+
canvas.move_to(x1, y)
|
|
80
|
+
canvas.line_to(x2, y)
|
|
81
|
+
canvas.stroke
|
|
82
|
+
end
|
|
83
|
+
private_class_method :emit_rule
|
|
84
|
+
|
|
85
|
+
# Returns `[x1, x2]` for the rule. Width mode "Text" indents
|
|
86
|
+
# the rule by the paragraph's left/right indent (matches the
|
|
87
|
+
# text bounds); "Column" (default) spans the full column.
|
|
88
|
+
def self.rule_extents(frame_left, frame_right, rule_left_indent,
|
|
89
|
+
rule_right_indent, width_mode, paragraph)
|
|
90
|
+
left = frame_left
|
|
91
|
+
right = frame_right
|
|
92
|
+
if width_mode == "Text"
|
|
93
|
+
left += (paragraph.left_indent || 0).to_f
|
|
94
|
+
right -= (paragraph.right_indent || 0).to_f
|
|
95
|
+
end
|
|
96
|
+
left += (rule_left_indent || 0).to_f
|
|
97
|
+
right -= (rule_right_indent || 0).to_f
|
|
98
|
+
[left, right]
|
|
99
|
+
end
|
|
100
|
+
private_class_method :rule_extents
|
|
101
|
+
|
|
102
|
+
def self.rule_y(anchor_y, offset, position)
|
|
103
|
+
position == :above ? anchor_y + offset : anchor_y - offset
|
|
104
|
+
end
|
|
105
|
+
private_class_method :rule_y
|
|
106
|
+
|
|
107
|
+
def self.apply_rule_color(canvas, color_name, tint_value, context)
|
|
108
|
+
color = resolve_color(color_name, tint_value, context)
|
|
109
|
+
canvas.stroke_color(color)
|
|
110
|
+
end
|
|
111
|
+
private_class_method :apply_rule_color
|
|
112
|
+
|
|
113
|
+
def self.resolve_color(color_name, tint_value, context)
|
|
114
|
+
return [:gray, 1.0 * tint_value] unless color_name
|
|
115
|
+
return [:gray, 1.0 * tint_value] if color_name == "Color/None"
|
|
116
|
+
|
|
117
|
+
color = context&.color_resolver&.resolve(color_name)
|
|
118
|
+
return [:gray, 1.0 * tint_value] unless color
|
|
119
|
+
|
|
120
|
+
ColorHelper.apply_tint(color, tint_value)
|
|
121
|
+
end
|
|
122
|
+
private_class_method :resolve_color
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
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,59 +149,97 @@ 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
|
cursor_y = box[:y] + box[:height] - (layout_frame.inset_top || 0)
|
|
120
161
|
bottom_limit = TextEngine::VerticalLayout.bottom_limit(layout_frame)
|
|
121
|
-
char_cursor =
|
|
122
|
-
|
|
123
|
-
paragraphs.each do |paragraph|
|
|
124
|
-
break if cursor_y < bottom_limit
|
|
162
|
+
char_cursor = state.char_cursor
|
|
125
163
|
|
|
126
|
-
|
|
127
|
-
|
|
164
|
+
if state.current_paragraph
|
|
165
|
+
cursor_y, char_cursor = resume_paragraph(
|
|
166
|
+
canvas, state, context, layout_frame, font,
|
|
128
167
|
cursor_y, bottom_limit, char_cursor
|
|
129
168
|
)
|
|
169
|
+
return if state.current_paragraph
|
|
130
170
|
end
|
|
171
|
+
|
|
172
|
+
consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
173
|
+
cursor_y, bottom_limit, char_cursor)
|
|
131
174
|
end
|
|
132
175
|
private_class_method :engine_render
|
|
133
176
|
|
|
134
|
-
def self.
|
|
135
|
-
|
|
177
|
+
def self.resume_paragraph(canvas, state, context, layout_frame, font,
|
|
178
|
+
cursor_y, bottom_limit, char_cursor)
|
|
179
|
+
paragraph = state.current_paragraph
|
|
180
|
+
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
181
|
+
canvas, paragraph, state.runs_remaining, context,
|
|
182
|
+
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
183
|
+
first_paragraph: true
|
|
184
|
+
)
|
|
185
|
+
if remaining_runs.any?
|
|
186
|
+
state.runs_remaining = remaining_runs
|
|
187
|
+
else
|
|
188
|
+
state.current_paragraph = nil
|
|
189
|
+
state.runs_remaining = []
|
|
190
|
+
end
|
|
191
|
+
[cursor_y, char_cursor]
|
|
192
|
+
end
|
|
193
|
+
private_class_method :resume_paragraph
|
|
194
|
+
|
|
195
|
+
def self.consume_paragraphs(canvas, state, context, layout_frame, font,
|
|
196
|
+
cursor_y, bottom_limit, char_cursor)
|
|
197
|
+
pending = 0
|
|
198
|
+
state.paragraphs.each do |paragraph|
|
|
199
|
+
break if cursor_y < bottom_limit
|
|
200
|
+
|
|
201
|
+
remaining_runs, cursor_y, char_cursor = render_runs_for_paragraph(
|
|
202
|
+
canvas, paragraph, paragraph.runs, context,
|
|
203
|
+
layout_frame, font, cursor_y, bottom_limit, char_cursor,
|
|
204
|
+
first_paragraph: pending.zero?
|
|
205
|
+
)
|
|
206
|
+
if remaining_runs.any?
|
|
207
|
+
state.current_paragraph = paragraph
|
|
208
|
+
state.runs_remaining = remaining_runs
|
|
209
|
+
break
|
|
210
|
+
end
|
|
211
|
+
pending += 1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
state.paragraphs = state.paragraphs[pending..]
|
|
215
|
+
state.char_cursor = char_cursor
|
|
216
|
+
end
|
|
217
|
+
private_class_method :consume_paragraphs
|
|
218
|
+
|
|
219
|
+
# Renders a paragraph's runs (a subset when resuming mid-para).
|
|
220
|
+
# Returns [remaining_runs, cursor_y, char_cursor] where
|
|
221
|
+
# remaining_runs is the runs that didn't fit (empty when all
|
|
222
|
+
# done). Emits RuleAbove before the first run and RuleBelow
|
|
223
|
+
# after the last successfully-rendered run.
|
|
224
|
+
def self.render_runs_for_paragraph(canvas, paragraph, runs, context,
|
|
225
|
+
layout_frame, font, cursor_y,
|
|
226
|
+
bottom_limit, char_cursor,
|
|
227
|
+
first_paragraph:)
|
|
136
228
|
wrap_width = TextEngine::VerticalLayout.wrap_width(
|
|
137
229
|
layout_frame, paragraph.right_indent || 0
|
|
138
230
|
)
|
|
139
231
|
para_attrs = paragraph_attrs(paragraph)
|
|
140
|
-
|
|
141
|
-
canvas, paragraph, context, layout_frame, font,
|
|
142
|
-
wrap_width, cursor_y, bottom_limit, char_cursor, para_attrs
|
|
143
|
-
)
|
|
144
|
-
[cursor_y - para_attrs[:space_after], char_cursor]
|
|
145
|
-
end
|
|
146
|
-
private_class_method :render_paragraph
|
|
232
|
+
frame_left, frame_right = paragraph_frame_extents(layout_frame)
|
|
147
233
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
space_before: paragraph.space_before || 0,
|
|
151
|
-
space_after: paragraph.space_after || 0,
|
|
152
|
-
first_line_indent: paragraph.first_line_indent || 0,
|
|
153
|
-
left_indent: paragraph.left_indent || 0,
|
|
154
|
-
}
|
|
155
|
-
end
|
|
156
|
-
private_class_method :paragraph_attrs
|
|
234
|
+
emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
|
|
235
|
+
frame_left, frame_right, first_paragraph)
|
|
157
236
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
para_attrs)
|
|
161
|
-
space_before = para_attrs[:space_before]
|
|
162
|
-
paragraph.runs.each do |run|
|
|
237
|
+
rendered_count = 0
|
|
238
|
+
runs.each do |run|
|
|
163
239
|
break if cursor_y < bottom_limit
|
|
164
240
|
|
|
241
|
+
space_before = paragraph_space_before(para_attrs, first_paragraph,
|
|
242
|
+
rendered_count)
|
|
165
243
|
positioned, next_y = layout_run(
|
|
166
244
|
canvas, run, paragraph, context, layout_frame, font,
|
|
167
245
|
wrap_width, cursor_y, space_before,
|
|
@@ -170,11 +248,69 @@ module Idml
|
|
|
170
248
|
)
|
|
171
249
|
char_cursor += positioned.length
|
|
172
250
|
cursor_y = next_y
|
|
173
|
-
|
|
251
|
+
rendered_count += 1
|
|
174
252
|
end
|
|
175
|
-
|
|
253
|
+
|
|
254
|
+
remaining_runs = runs[rendered_count..]
|
|
255
|
+
cursor_y = emit_paragraph_bottom_rule(canvas, paragraph, context,
|
|
256
|
+
cursor_y, frame_left,
|
|
257
|
+
frame_right, para_attrs,
|
|
258
|
+
remaining_runs)
|
|
259
|
+
[remaining_runs, cursor_y, char_cursor]
|
|
260
|
+
end
|
|
261
|
+
private_class_method :render_runs_for_paragraph
|
|
262
|
+
|
|
263
|
+
def self.emit_paragraph_top_rule(canvas, paragraph, context, cursor_y,
|
|
264
|
+
frame_left, frame_right,
|
|
265
|
+
first_paragraph)
|
|
266
|
+
return unless first_paragraph
|
|
267
|
+
|
|
268
|
+
ParagraphRules.emit_rule_above(canvas, paragraph, context,
|
|
269
|
+
cursor_y, frame_left, frame_right)
|
|
270
|
+
end
|
|
271
|
+
private_class_method :emit_paragraph_top_rule
|
|
272
|
+
|
|
273
|
+
def self.paragraph_space_before(para_attrs, first_paragraph,
|
|
274
|
+
rendered_count)
|
|
275
|
+
return 0 unless first_paragraph && rendered_count.zero?
|
|
276
|
+
|
|
277
|
+
para_attrs[:space_before]
|
|
278
|
+
end
|
|
279
|
+
private_class_method :paragraph_space_before
|
|
280
|
+
|
|
281
|
+
# Emits RuleBelow after a paragraph's last run. Returns the
|
|
282
|
+
# updated cursor_y (subtracting space_after when the paragraph
|
|
283
|
+
# completed).
|
|
284
|
+
def self.emit_paragraph_bottom_rule(canvas, paragraph, context,
|
|
285
|
+
cursor_y, frame_left,
|
|
286
|
+
frame_right, para_attrs,
|
|
287
|
+
remaining_runs)
|
|
288
|
+
return cursor_y unless remaining_runs.empty?
|
|
289
|
+
|
|
290
|
+
after_y = cursor_y - para_attrs[:space_after]
|
|
291
|
+
ParagraphRules.emit_rule_below(canvas, paragraph, context,
|
|
292
|
+
after_y, frame_left, frame_right)
|
|
293
|
+
after_y
|
|
294
|
+
end
|
|
295
|
+
private_class_method :emit_paragraph_bottom_rule
|
|
296
|
+
|
|
297
|
+
def self.paragraph_frame_extents(layout_frame)
|
|
298
|
+
inset_left = layout_frame.inset_left || 0
|
|
299
|
+
inset_right = layout_frame.inset_right || 0
|
|
300
|
+
[layout_frame.x + inset_left,
|
|
301
|
+
layout_frame.x + layout_frame.width - inset_right]
|
|
302
|
+
end
|
|
303
|
+
private_class_method :paragraph_frame_extents
|
|
304
|
+
|
|
305
|
+
def self.paragraph_attrs(paragraph)
|
|
306
|
+
{
|
|
307
|
+
space_before: paragraph.space_before || 0,
|
|
308
|
+
space_after: paragraph.space_after || 0,
|
|
309
|
+
first_line_indent: paragraph.first_line_indent || 0,
|
|
310
|
+
left_indent: paragraph.left_indent || 0,
|
|
311
|
+
}
|
|
176
312
|
end
|
|
177
|
-
private_class_method :
|
|
313
|
+
private_class_method :paragraph_attrs
|
|
178
314
|
|
|
179
315
|
# Shapes one run's text, wraps to the inset/indent-adjusted
|
|
180
316
|
# width, justifies per paragraph alignment, and asks
|
|
@@ -223,23 +359,32 @@ module Idml
|
|
|
223
359
|
private_class_method :layout_run
|
|
224
360
|
|
|
225
361
|
# Emits one positioned line: applies character-level styling
|
|
226
|
-
# (fill color, capitalization, position
|
|
227
|
-
#
|
|
362
|
+
# (fill color + tint, capitalization, position, tracking,
|
|
363
|
+
# glyph scaling, baseline shift) via CharacterStyle, then
|
|
364
|
+
# draws underline/strike-through rules after the text.
|
|
228
365
|
def self.emit_line(canvas, line, run, context, size)
|
|
229
366
|
text = CharacterStyle.transform_text(line_text(line),
|
|
230
367
|
run.capitalization)
|
|
231
|
-
scaled_size,
|
|
368
|
+
scaled_size, position_offset = CharacterStyle.position_scale(
|
|
232
369
|
run.position, size
|
|
233
370
|
)
|
|
371
|
+
baseline_offset = CharacterStyle.baseline_offset(run) +
|
|
372
|
+
position_offset
|
|
373
|
+
text_at = [line.x, line.y + baseline_offset]
|
|
374
|
+
text_kwargs = CharacterStyle.text_kwargs(
|
|
375
|
+
run,
|
|
376
|
+
{
|
|
377
|
+
at: text_at,
|
|
378
|
+
font: font_for_run(run, context),
|
|
379
|
+
size: scaled_size,
|
|
380
|
+
},
|
|
381
|
+
)
|
|
234
382
|
CharacterStyle.apply(canvas, run, context,
|
|
235
383
|
x: line.x, y: line.y + baseline_offset,
|
|
236
384
|
width: line.width, size: scaled_size) do
|
|
237
|
-
|
|
238
|
-
text,
|
|
239
|
-
|
|
240
|
-
font: font_for_run(run, context),
|
|
241
|
-
size: scaled_size,
|
|
242
|
-
)
|
|
385
|
+
CharacterStyle.with_glyph_scaling(canvas, run) do
|
|
386
|
+
canvas.text(text, **text_kwargs)
|
|
387
|
+
end
|
|
243
388
|
end
|
|
244
389
|
end
|
|
245
390
|
private_class_method :emit_line
|
|
@@ -289,8 +434,11 @@ module Idml
|
|
|
289
434
|
|
|
290
435
|
# Fallback when no metrics are available: emit all runs as
|
|
291
436
|
# one `text_rich` block, letting pdfrb measure advance widths.
|
|
292
|
-
|
|
293
|
-
|
|
437
|
+
# Chain threading is best-effort here — the simple path
|
|
438
|
+
# doesn't wrap, so the first frame's render typically emits
|
|
439
|
+
# all content and clears the chain state.
|
|
440
|
+
def self.simple_render(canvas, state, context, box)
|
|
441
|
+
runs = collect_runs(state)
|
|
294
442
|
return if runs.empty?
|
|
295
443
|
|
|
296
444
|
runs_for = build_rich_runs(runs, context)
|
|
@@ -299,9 +447,22 @@ module Idml
|
|
|
299
447
|
runs_for,
|
|
300
448
|
at: [box[:x], box[:y] + box[:height] - first_size],
|
|
301
449
|
)
|
|
450
|
+
state.paragraphs = []
|
|
451
|
+
state.current_paragraph = nil
|
|
452
|
+
state.runs_remaining = []
|
|
302
453
|
end
|
|
303
454
|
private_class_method :simple_render
|
|
304
455
|
|
|
456
|
+
def self.collect_runs(state)
|
|
457
|
+
runs = state.runs_remaining || []
|
|
458
|
+
if state.current_paragraph
|
|
459
|
+
runs + state.current_paragraph.runs
|
|
460
|
+
else
|
|
461
|
+
runs + state.paragraphs.flat_map(&:runs)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
private_class_method :collect_runs
|
|
465
|
+
|
|
305
466
|
def self.build_rich_runs(runs, context)
|
|
306
467
|
runs.map do |run|
|
|
307
468
|
{
|
|
@@ -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,24 @@ module Idml
|
|
|
54
54
|
:left_indent,
|
|
55
55
|
:right_indent,
|
|
56
56
|
:auto_leading,
|
|
57
|
+
# RuleAbove / RuleBelow — paragraph rules (horizontal lines
|
|
58
|
+
# above/below the paragraph block). Driven by PSR attributes.
|
|
59
|
+
:rule_above,
|
|
60
|
+
:rule_above_line_weight,
|
|
61
|
+
:rule_above_color,
|
|
62
|
+
:rule_above_tint,
|
|
63
|
+
:rule_above_offset,
|
|
64
|
+
:rule_above_left_indent,
|
|
65
|
+
:rule_above_right_indent,
|
|
66
|
+
:rule_above_width,
|
|
67
|
+
:rule_below,
|
|
68
|
+
:rule_below_line_weight,
|
|
69
|
+
:rule_below_color,
|
|
70
|
+
:rule_below_tint,
|
|
71
|
+
:rule_below_offset,
|
|
72
|
+
:rule_below_left_indent,
|
|
73
|
+
:rule_below_right_indent,
|
|
74
|
+
:rule_below_width,
|
|
57
75
|
keyword_init: true,
|
|
58
76
|
)
|
|
59
77
|
|
|
@@ -86,11 +104,13 @@ module Idml
|
|
|
86
104
|
# its runs and carries the paragraph-level attributes from the
|
|
87
105
|
# owning PSR. Empty paragraphs (no runs after filtering) are
|
|
88
106
|
# skipped.
|
|
89
|
-
def self.extract_paragraphs(story)
|
|
107
|
+
def self.extract_paragraphs(story, condition_filter: nil)
|
|
90
108
|
return [] unless story&.inner
|
|
91
109
|
|
|
92
110
|
story.inner.paragraph_style_range.filter_map do |psr|
|
|
93
|
-
|
|
111
|
+
next unless condition_visible?(psr, condition_filter)
|
|
112
|
+
|
|
113
|
+
runs = csr_runs(psr, condition_filter: condition_filter)
|
|
94
114
|
next if runs.empty?
|
|
95
115
|
|
|
96
116
|
Paragraph.new(
|
|
@@ -102,12 +122,30 @@ module Idml
|
|
|
102
122
|
left_indent: psr.left_indent,
|
|
103
123
|
right_indent: psr.right_indent,
|
|
104
124
|
auto_leading: psr.auto_leading,
|
|
125
|
+
rule_above: psr.rule_above,
|
|
126
|
+
rule_above_line_weight: psr.rule_above_line_weight,
|
|
127
|
+
rule_above_color: psr.stroke_color,
|
|
128
|
+
rule_above_tint: psr.rule_above_tint,
|
|
129
|
+
rule_above_offset: psr.rule_above_offset,
|
|
130
|
+
rule_above_left_indent: psr.rule_above_left_indent,
|
|
131
|
+
rule_above_right_indent: psr.rule_above_right_indent,
|
|
132
|
+
rule_above_width: psr.rule_above_width,
|
|
133
|
+
rule_below: psr.rule_below,
|
|
134
|
+
rule_below_line_weight: psr.rule_below_line_weight,
|
|
135
|
+
rule_below_color: psr.stroke_color,
|
|
136
|
+
rule_below_tint: psr.rule_below_tint,
|
|
137
|
+
rule_below_offset: psr.rule_below_offset,
|
|
138
|
+
rule_below_left_indent: psr.rule_below_left_indent,
|
|
139
|
+
rule_below_right_indent: psr.rule_below_right_indent,
|
|
140
|
+
rule_below_width: psr.rule_below_width,
|
|
105
141
|
)
|
|
106
142
|
end
|
|
107
143
|
end
|
|
108
144
|
|
|
109
|
-
def self.csr_runs(psr)
|
|
145
|
+
def self.csr_runs(psr, condition_filter: nil)
|
|
110
146
|
psr.character_style_range.filter_map do |csr|
|
|
147
|
+
next unless condition_visible?(csr, condition_filter)
|
|
148
|
+
|
|
111
149
|
text = csr.text_content
|
|
112
150
|
next if text.nil? || text.empty?
|
|
113
151
|
|
|
@@ -136,6 +174,16 @@ module Idml
|
|
|
136
174
|
end
|
|
137
175
|
private_class_method :csr_runs
|
|
138
176
|
|
|
177
|
+
# Drops the PSR/CSR when any of its AppliedConditions
|
|
178
|
+
# references a hidden Condition. Returns true when no filter
|
|
179
|
+
# is supplied (caller doesn't care about conditions).
|
|
180
|
+
def self.condition_visible?(psr_or_csr, condition_filter)
|
|
181
|
+
return true unless condition_filter
|
|
182
|
+
|
|
183
|
+
condition_filter.visible?(psr_or_csr.applied_conditions)
|
|
184
|
+
end
|
|
185
|
+
private_class_method :condition_visible?
|
|
186
|
+
|
|
139
187
|
def self.alignment_for(csr)
|
|
140
188
|
ALIGNMENT_MAP[csr.justification] || :left
|
|
141
189
|
end
|
data/lib/idml/render.rb
CHANGED
|
@@ -14,7 +14,10 @@ module Idml
|
|
|
14
14
|
autoload :Blending, "#{__dir__}/render/blending"
|
|
15
15
|
autoload :StrokeStyle, "#{__dir__}/render/stroke_style"
|
|
16
16
|
autoload :CharacterStyle, "#{__dir__}/render/character_style"
|
|
17
|
+
autoload :ParagraphRules, "#{__dir__}/render/paragraph_rules"
|
|
18
|
+
autoload :StoryChainController, "#{__dir__}/render/story_chain_controller"
|
|
17
19
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
20
|
+
autoload :ConditionFilter, "#{__dir__}/render/condition_filter"
|
|
18
21
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
19
22
|
autoload :ImageCollector, "#{__dir__}/render/image_collector"
|
|
20
23
|
autoload :StructureTracker, "#{__dir__}/render/structure_tracker"
|
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.7.
|
|
4
|
+
version: 0.7.2
|
|
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-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -163,7 +163,11 @@ files:
|
|
|
163
163
|
- TODO.pdf/105-whole-package-round-trip.md
|
|
164
164
|
- TODO.pdf/106-page-item-type-coverage.md
|
|
165
165
|
- TODO.pdf/107-image-rendering-unification.md
|
|
166
|
+
- TODO.pdf/108-multi-frame-story-flow.md
|
|
167
|
+
- TODO.pdf/109-dry-color-tint.md
|
|
166
168
|
- TODO.pdf/11-font-embedding.md
|
|
169
|
+
- TODO.pdf/110-conditional-text.md
|
|
170
|
+
- TODO.pdf/111-footnote-support.md
|
|
167
171
|
- TODO.pdf/12-idml-to-pdf-pipeline.md
|
|
168
172
|
- TODO.pdf/13-cjk-text-layout.md
|
|
169
173
|
- TODO.pdf/14-page-item-element-models.md
|
|
@@ -272,6 +276,7 @@ files:
|
|
|
272
276
|
- lib/idml/elements/character_style_group.rb
|
|
273
277
|
- lib/idml/elements/character_style_range.rb
|
|
274
278
|
- lib/idml/elements/color.rb
|
|
279
|
+
- lib/idml/elements/condition.rb
|
|
275
280
|
- lib/idml/elements/content.rb
|
|
276
281
|
- lib/idml/elements/content_transparency_setting.rb
|
|
277
282
|
- lib/idml/elements/contour_option.rb
|
|
@@ -424,6 +429,7 @@ files:
|
|
|
424
429
|
- lib/idml/render/character_style.rb
|
|
425
430
|
- lib/idml/render/color_helper.rb
|
|
426
431
|
- lib/idml/render/color_resolver.rb
|
|
432
|
+
- lib/idml/render/condition_filter.rb
|
|
427
433
|
- lib/idml/render/font_reference_resolver.rb
|
|
428
434
|
- lib/idml/render/font_setup.rb
|
|
429
435
|
- lib/idml/render/gradient_resolver.rb
|
|
@@ -435,6 +441,7 @@ files:
|
|
|
435
441
|
- lib/idml/render/layer_filter.rb
|
|
436
442
|
- lib/idml/render/metadata_builder.rb
|
|
437
443
|
- lib/idml/render/page_item_renderer.rb
|
|
444
|
+
- lib/idml/render/paragraph_rules.rb
|
|
438
445
|
- lib/idml/render/pdfa_packet.rb
|
|
439
446
|
- lib/idml/render/pdfrb_writer.rb
|
|
440
447
|
- lib/idml/render/pipeline.rb
|
|
@@ -449,6 +456,7 @@ files:
|
|
|
449
456
|
- lib/idml/render/renderers/table_renderer.rb
|
|
450
457
|
- lib/idml/render/renderers/text_frame_renderer.rb
|
|
451
458
|
- lib/idml/render/spread_renderer.rb
|
|
459
|
+
- lib/idml/render/story_chain_controller.rb
|
|
452
460
|
- lib/idml/render/story_threader.rb
|
|
453
461
|
- lib/idml/render/stroke_style.rb
|
|
454
462
|
- lib/idml/render/structure_mapper.rb
|