idml 0.7.0 → 0.7.1
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 +50 -0
- data/TODO.pdf/109-dry-color-tint.md +42 -0
- data/TODO.pdf/110-conditional-text.md +37 -0
- data/TODO.pdf/111-footnote-support.md +40 -0
- data/lib/idml/render/character_style.rb +50 -2
- data/lib/idml/render/color_helper.rb +27 -3
- data/lib/idml/render/paragraph_rules.rb +125 -0
- data/lib/idml/render/renderers/text_frame_renderer.rb +42 -10
- data/lib/idml/render/style_resolver.rb +34 -0
- data/lib/idml/render.rb +1 -0
- data/lib/idml/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f43caa091c83d9d8288f22f3913d21760bf5a5dac986f76b72521bb19f5110e0
|
|
4
|
+
data.tar.gz: 4e33ef7a0c119936d16c0eecb687d153e04d9dc29d789511633fb6fff7f1a152
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16b0f93a7c8993c5f98ad84c6e186ed61ebcaaabcdda63b26c9d1e4bfadd1c3986bef933d2a832d2b3463ab5b23324778a37e92e62cbeb8ce0876dbc26bebcbe
|
|
7
|
+
data.tar.gz: 9950c7afceda42f377e0d2bef226c4caa346c9d79ac4b3b61dcba99612a321c646eecd6b341be88fd7f8a46c695ecc2449d1bf4d900e4338ed1a4c9f76ea4d45
|
|
@@ -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,50 @@
|
|
|
1
|
+
# TODO PDF 108: Multi-frame story flow (use StoryThreader)
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-09 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
`Render::StoryThreader` exists with specs (`spec/idml/render_layer_threader_spec.rb`).
|
|
8
|
+
It builds text-frame chains from PreviousTextFrame/NextTextFrame
|
|
9
|
+
links. The intent: a story flows across linked frames, with each
|
|
10
|
+
frame getting the lines that fit and overflow going to the next.
|
|
11
|
+
|
|
12
|
+
But `TextFrameRenderer` doesn't use it. The renderer treats each
|
|
13
|
+
frame independently:
|
|
14
|
+
- `chain_head?` filter skips non-head frames (correct — they'd
|
|
15
|
+
double-render)
|
|
16
|
+
- Each chain-head frame renders its entire story from the top
|
|
17
|
+
- Overflow that doesn't fit is silently dropped
|
|
18
|
+
|
|
19
|
+
For real documents with multi-page stories (books, magazines,
|
|
20
|
+
newspapers), this loses text. The PDF shows only what fits in the
|
|
21
|
+
first frame.
|
|
22
|
+
|
|
23
|
+
## What needs to happen
|
|
24
|
+
|
|
25
|
+
1. TextFrameRenderer accepts the chain (list of frames) instead
|
|
26
|
+
of one frame at a time.
|
|
27
|
+
2. For each chain:
|
|
28
|
+
- Lay out story text starting at frame 1's top
|
|
29
|
+
- When text overflows frame 1's bottom, continue at frame 2's top
|
|
30
|
+
- Repeat until either text ends or chain ends
|
|
31
|
+
3. The PageItemRenderer dispatch needs to know about chains, not
|
|
32
|
+
just per-item rendering.
|
|
33
|
+
|
|
34
|
+
Architecture: the chain context needs to live in RenderContext
|
|
35
|
+
(or a new RenderChainContext). Each frame's render call passes the
|
|
36
|
+
current y cursor + remaining paragraphs/runs/lines, and the next
|
|
37
|
+
frame's render call picks up where the previous left off.
|
|
38
|
+
|
|
39
|
+
## Acceptance criteria
|
|
40
|
+
|
|
41
|
+
- [ ] Story that overflows frame 1 continues in frame 2.
|
|
42
|
+
- [ ] Hyperlink rects resolve correctly across frames.
|
|
43
|
+
- [ ] Spec: 2 linked TextFrames, story of 50 lines, each frame fits
|
|
44
|
+
20 lines → frame 1 shows lines 1-20, frame 2 shows 21-40,
|
|
45
|
+
frame 3 (if present) shows 41-50.
|
|
46
|
+
|
|
47
|
+
## Dependencies
|
|
48
|
+
|
|
49
|
+
- None — independent feature work, but changes the renderer's
|
|
50
|
+
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,37 @@
|
|
|
1
|
+
# TODO PDF 110: Conditional text (AppliedConditions)
|
|
2
|
+
|
|
3
|
+
## Status: OPEN — gap identified in 2026-08-09 audit
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
PSR and CSR both carry an `AppliedConditions` attribute (a list of
|
|
8
|
+
condition Self IDs). In InDesign, conditions let authors mark text
|
|
9
|
+
as conditional (e.g., "show only in instructor edition", "draft
|
|
10
|
+
review comments"). Each `<Condition>` in Resources/Styles.xml
|
|
11
|
+
declares whether it's visible, its highlight color, etc.
|
|
12
|
+
|
|
13
|
+
Today the renderer ignores `AppliedConditions` — all conditional
|
|
14
|
+
text renders regardless of condition visibility. For documents that
|
|
15
|
+
use conditions heavily (textbooks, legal, regulatory), this means
|
|
16
|
+
the PDF always shows every variant instead of the chosen subset.
|
|
17
|
+
|
|
18
|
+
## What needs to happen
|
|
19
|
+
|
|
20
|
+
1. Verify `Elements::Condition` exists in the model (Resources/Styles.rnc).
|
|
21
|
+
2. Add `Render::ConditionFilter` that reads visible conditions from
|
|
22
|
+
Resources/Styles.xml.
|
|
23
|
+
3. StyleResolver filters PSRs/CSRs whose AppliedConditions reference
|
|
24
|
+
a hidden condition.
|
|
25
|
+
4. Spec: PSR with condition A (hidden) doesn't render; condition B
|
|
26
|
+
(visible) renders.
|
|
27
|
+
|
|
28
|
+
## Acceptance criteria
|
|
29
|
+
|
|
30
|
+
- [ ] Condition model from RNC.
|
|
31
|
+
- [ ] ConditionFilter.from_styles(graphic_or_styles).
|
|
32
|
+
- [ ] StyleResolver.extract_paragraphs takes optional ConditionFilter.
|
|
33
|
+
- [ ] Hidden-condition runs filtered out.
|
|
34
|
+
|
|
35
|
+
## Dependencies
|
|
36
|
+
|
|
37
|
+
- 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.
|
|
@@ -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,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
|
|
@@ -137,14 +137,37 @@ module Idml
|
|
|
137
137
|
layout_frame, paragraph.right_indent || 0
|
|
138
138
|
)
|
|
139
139
|
para_attrs = paragraph_attrs(paragraph)
|
|
140
|
+
frame_left, frame_right = paragraph_frame_extents(layout_frame)
|
|
141
|
+
|
|
142
|
+
# RuleAbove sits at the paragraph's top edge (before
|
|
143
|
+
# space_before is subtracted), so emit it first using the
|
|
144
|
+
# incoming cursor_y as the anchor.
|
|
145
|
+
ParagraphRules.emit_rule_above(canvas, paragraph, context,
|
|
146
|
+
cursor_y, frame_left, frame_right)
|
|
147
|
+
|
|
140
148
|
cursor_y, char_cursor = render_runs(
|
|
141
149
|
canvas, paragraph, context, layout_frame, font,
|
|
142
150
|
wrap_width, cursor_y, bottom_limit, char_cursor, para_attrs
|
|
143
151
|
)
|
|
144
|
-
|
|
152
|
+
after_y = cursor_y - para_attrs[:space_after]
|
|
153
|
+
|
|
154
|
+
# RuleBelow sits at the paragraph's bottom edge (after the
|
|
155
|
+
# last line + space_after), so emit it last using after_y
|
|
156
|
+
# as the anchor.
|
|
157
|
+
ParagraphRules.emit_rule_below(canvas, paragraph, context,
|
|
158
|
+
after_y, frame_left, frame_right)
|
|
159
|
+
[after_y, char_cursor]
|
|
145
160
|
end
|
|
146
161
|
private_class_method :render_paragraph
|
|
147
162
|
|
|
163
|
+
def self.paragraph_frame_extents(layout_frame)
|
|
164
|
+
inset_left = layout_frame.inset_left || 0
|
|
165
|
+
inset_right = layout_frame.inset_right || 0
|
|
166
|
+
[layout_frame.x + inset_left,
|
|
167
|
+
layout_frame.x + layout_frame.width - inset_right]
|
|
168
|
+
end
|
|
169
|
+
private_class_method :paragraph_frame_extents
|
|
170
|
+
|
|
148
171
|
def self.paragraph_attrs(paragraph)
|
|
149
172
|
{
|
|
150
173
|
space_before: paragraph.space_before || 0,
|
|
@@ -223,23 +246,32 @@ module Idml
|
|
|
223
246
|
private_class_method :layout_run
|
|
224
247
|
|
|
225
248
|
# Emits one positioned line: applies character-level styling
|
|
226
|
-
# (fill color, capitalization, position
|
|
227
|
-
#
|
|
249
|
+
# (fill color + tint, capitalization, position, tracking,
|
|
250
|
+
# glyph scaling, baseline shift) via CharacterStyle, then
|
|
251
|
+
# draws underline/strike-through rules after the text.
|
|
228
252
|
def self.emit_line(canvas, line, run, context, size)
|
|
229
253
|
text = CharacterStyle.transform_text(line_text(line),
|
|
230
254
|
run.capitalization)
|
|
231
|
-
scaled_size,
|
|
255
|
+
scaled_size, position_offset = CharacterStyle.position_scale(
|
|
232
256
|
run.position, size
|
|
233
257
|
)
|
|
258
|
+
baseline_offset = CharacterStyle.baseline_offset(run) +
|
|
259
|
+
position_offset
|
|
260
|
+
text_at = [line.x, line.y + baseline_offset]
|
|
261
|
+
text_kwargs = CharacterStyle.text_kwargs(
|
|
262
|
+
run,
|
|
263
|
+
{
|
|
264
|
+
at: text_at,
|
|
265
|
+
font: font_for_run(run, context),
|
|
266
|
+
size: scaled_size,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
234
269
|
CharacterStyle.apply(canvas, run, context,
|
|
235
270
|
x: line.x, y: line.y + baseline_offset,
|
|
236
271
|
width: line.width, size: scaled_size) do
|
|
237
|
-
|
|
238
|
-
text,
|
|
239
|
-
|
|
240
|
-
font: font_for_run(run, context),
|
|
241
|
-
size: scaled_size,
|
|
242
|
-
)
|
|
272
|
+
CharacterStyle.with_glyph_scaling(canvas, run) do
|
|
273
|
+
canvas.text(text, **text_kwargs)
|
|
274
|
+
end
|
|
243
275
|
end
|
|
244
276
|
end
|
|
245
277
|
private_class_method :emit_line
|
|
@@ -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
|
|
|
@@ -102,6 +120,22 @@ module Idml
|
|
|
102
120
|
left_indent: psr.left_indent,
|
|
103
121
|
right_indent: psr.right_indent,
|
|
104
122
|
auto_leading: psr.auto_leading,
|
|
123
|
+
rule_above: psr.rule_above,
|
|
124
|
+
rule_above_line_weight: psr.rule_above_line_weight,
|
|
125
|
+
rule_above_color: psr.stroke_color,
|
|
126
|
+
rule_above_tint: psr.rule_above_tint,
|
|
127
|
+
rule_above_offset: psr.rule_above_offset,
|
|
128
|
+
rule_above_left_indent: psr.rule_above_left_indent,
|
|
129
|
+
rule_above_right_indent: psr.rule_above_right_indent,
|
|
130
|
+
rule_above_width: psr.rule_above_width,
|
|
131
|
+
rule_below: psr.rule_below,
|
|
132
|
+
rule_below_line_weight: psr.rule_below_line_weight,
|
|
133
|
+
rule_below_color: psr.stroke_color,
|
|
134
|
+
rule_below_tint: psr.rule_below_tint,
|
|
135
|
+
rule_below_offset: psr.rule_below_offset,
|
|
136
|
+
rule_below_left_indent: psr.rule_below_left_indent,
|
|
137
|
+
rule_below_right_indent: psr.rule_below_right_indent,
|
|
138
|
+
rule_below_width: psr.rule_below_width,
|
|
105
139
|
)
|
|
106
140
|
end
|
|
107
141
|
end
|
data/lib/idml/render.rb
CHANGED
|
@@ -14,6 +14,7 @@ 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"
|
|
17
18
|
autoload :LayerFilter, "#{__dir__}/render/layer_filter"
|
|
18
19
|
autoload :Placement, "#{__dir__}/render/placement"
|
|
19
20
|
autoload :ImageCollector, "#{__dir__}/render/image_collector"
|
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.1
|
|
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
|
|
@@ -435,6 +439,7 @@ files:
|
|
|
435
439
|
- lib/idml/render/layer_filter.rb
|
|
436
440
|
- lib/idml/render/metadata_builder.rb
|
|
437
441
|
- lib/idml/render/page_item_renderer.rb
|
|
442
|
+
- lib/idml/render/paragraph_rules.rb
|
|
438
443
|
- lib/idml/render/pdfa_packet.rb
|
|
439
444
|
- lib/idml/render/pdfrb_writer.rb
|
|
440
445
|
- lib/idml/render/pipeline.rb
|