idml 0.6.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46b15814f26a45f5df5c3170df66846ff831727ce3dcf4b7ebe1b1c0377d8c0e
4
- data.tar.gz: d4041eacffab33e437330116d3b1268577b6ad8afbc0f2ee4cc1e015c07ec3a4
3
+ metadata.gz: f43caa091c83d9d8288f22f3913d21760bf5a5dac986f76b72521bb19f5110e0
4
+ data.tar.gz: 4e33ef7a0c119936d16c0eecb687d153e04d9dc29d789511633fb6fff7f1a152
5
5
  SHA512:
6
- metadata.gz: 45e7dcb9c7bad255b0bc4fbe2e97e741c7a1b1ff52a6bf899ffdffdf838f310780c1e90832b54f9cbd5a0044b7b908f9c3791fd8274b4272fe1f40506b0ce793
7
- data.tar.gz: b928db59ed416b59431bb638e1d86630488247dff7a3aaa6361954029920d3cb053095fdba28d7d7a00ec386e1b7f6153c89a7478135ad44523a3cb000cba840
6
+ metadata.gz: 16b0f93a7c8993c5f98ad84c6e186ed61ebcaaabcdda63b26c9d1e4bfadd1c3986bef933d2a832d2b3463ab5b23324778a37e92e62cbeb8ce0876dbc26bebcbe
7
+ data.tar.gz: 9950c7afceda42f377e0d2bef226c4caa346c9d79ac4b3b61dcba99612a321c646eecd6b341be88fd7f8a46c695ecc2449d1bf4d900e4338ed1a4c9f76ea4d45
data/Gemfile CHANGED
@@ -6,7 +6,7 @@ gemspec
6
6
 
7
7
  gem "logger"
8
8
  gem "lutaml-model", "~> 0.8.0"
9
- gem "pdfrb", github: "claricle/pdfrb", ref: "629c72a"
9
+ gem "pdfrb", "~> 0.7"
10
10
  gem "rake"
11
11
  gem "rspec"
12
12
  gem "rubocop"
@@ -1,6 +1,16 @@
1
1
  # TODO PDF 100: Character-level styling (underline, strikethrough, caps, color)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEnew `Render::CharacterStyle` helper applies
4
+ per-run fill color, draws underline / strike-through rules, and
5
+ handles Capitalization (AllCaps/SmallCaps → uppercase) and Position
6
+ (Superscript/Subscript → shrunk font + baseline offset).
7
+ TextFrameRenderer.emit_line wraps canvas.text via CharacterStyle.
8
+ StyledRun extended with underline_offset/weight, strike_through_offset/weight
9
+ attrs from CSR.
10
+
11
+ Open follow-ups (lower priority): glyph horizontal/vertical scaling
12
+ (needs pdfrb text-matrix support), tracking (needs Shaper-level
13
+ width adjustment), text stroke outline.
4
14
 
5
15
  ## Problem
6
16
 
@@ -1,6 +1,15 @@
1
1
  # TODO PDF 101: Paragraph borders and rules (RuleAbove, RuleBelow, ParagraphBorder)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: PARTIALRuleAbove 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
 
@@ -1,6 +1,11 @@
1
1
  # TODO PDF 104: Schema-faithful Table model completion
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: PARTIALadded the rendering-relevant subset (column_count,
4
+ single_column_width, header/body/footer row counts, table_direction,
5
+ outer-border strokes). The remaining ~150 schema attributes (alternating
6
+ stroke rows, column rules, table-flavor bits) aren't needed for
7
+ rendering and would be pure model bloat. Add incrementally as use
8
+ cases demand.
4
9
 
5
10
  ## Problem
6
11
 
@@ -1,6 +1,9 @@
1
1
  # TODO PDF 105: Whole-package round-trip suite (byte-equivalent)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONE`spec/idml/round_trip_all_fixtures_spec.rb` already
4
+ runs byte-equivalent round-trip for every fixture .idml (28 examples,
5
+ all passing). `spec/idml/package_spec.rb:140` verifies the mimetype
6
+ entry is stored and first.
4
7
 
5
8
  ## Problem
6
9
 
@@ -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.
@@ -1,6 +1,10 @@
1
1
  # TODO PDF 94: TextFrameRenderer should delegate to VerticalLayout
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEVerticalLayout refactored to block-style API
4
+ (`layout_block` returns positioned lines + next cursor y);
5
+ TextFrameRenderer now uses StyleResolver.extract_paragraphs +
6
+ VerticalLayout.layout_block for proper vertical stacking across
7
+ paragraphs and runs.
4
8
 
5
9
  ## Problem
6
10
 
@@ -1,6 +1,7 @@
1
1
  # TODO PDF 95: Paragraph spacing and indents (SpaceBefore, SpaceAfter, FirstLineIndent)
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEParagraph struct carries these from PSR; renderer
4
+ passes them to VerticalLayout.layout_block.
4
5
 
5
6
  ## Problem
6
7
 
@@ -1,6 +1,8 @@
1
1
  # TODO PDF 96: TextFramePreference insets honored in layout
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONElayout_frame helper reads TextFramePreference
4
+ insets and passes them to the Frame struct; VerticalLayout honors
5
+ them in positioning and wrap_width.
4
6
 
5
7
  ## Problem
6
8
 
@@ -1,6 +1,9 @@
1
1
  # TODO PDF 97: Table cell rendering — fill, stroke, insets, vertical justification
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONEcell fill_color renders as background; per-cell
4
+ TextTopInset/Left/Bottom/Right honored (was hardcoded 4.0);
5
+ VerticalJustification positions text Top/Bottom/Center; per-cell
6
+ PSR walks typed runs with their own font/size.
4
7
 
5
8
  ## Problem
6
9
 
@@ -1,6 +1,9 @@
1
1
  # TODO PDF 98: RowSpan / ColumnSpan (merged cells) in TableRenderer
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONESchemaLayout reads cell.column_span and cell.row_span,
4
+ multiplies width/height by the span, tracks a coverage set, and skips
5
+ positions covered by a spanning cell. Spanning cell renders once at
6
+ its top-left corner.
4
7
 
5
8
  ## Problem
6
9
 
@@ -1,6 +1,10 @@
1
1
  # TODO PDF 99: Table column widths from ColumnAttributes
2
2
 
3
- ## Status: OPENgap identified in 2026-08-08 audit
3
+ ## Status: DONETable now models `column_count` and
4
+ `single_column_width`; SchemaLayout uses SingleColumnWidth when
5
+ declared, else falls back to even division. (IDML has no separate
6
+ ColumnAttributes element — column widths live on Table directly
7
+ when uniform.)
4
8
 
5
9
  ## Problem
6
10
 
data/idml.gemspec CHANGED
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency "bigdecimal"
32
32
  spec.add_dependency "logger"
33
33
  spec.add_dependency "lutaml-model", "~> 0.8.18"
34
- spec.add_dependency "pdfrb", "~> 0.6"
34
+ spec.add_dependency "pdfrb", "~> 0.7"
35
35
  spec.add_dependency "rubyzip"
36
36
  spec.add_dependency "thor"
37
37
 
@@ -2,10 +2,16 @@
2
2
 
3
3
  module Idml
4
4
  module Elements
5
- # `<Table>` — a table page item. Carries geometry attributes and a
6
- # collection of TableRow children. The table's width and height are
7
- # typically derived from PathPointArray anchors, but rendered here
8
- # using the ItemTransform + a bounding-box approximation.
5
+ # `<Table>` — a table page item. Carries geometry, body/header
6
+ # row counts, column layout, and border styling. Two child
7
+ # collections hold the table's content:
8
+ #
9
+ # - `row` — schema-faithful `<Row>` siblings (real IDML).
10
+ # - `cell` — schema-faithful `<Cell>` siblings (real IDML).
11
+ # - `table_row` — legacy nested `<TableRow><TableCell>` form
12
+ # (synthetic test fixture only; see TODO 84).
13
+ #
14
+ # The renderer auto-detects which layout is present.
9
15
  class Table < Lutaml::Model::Serializable
10
16
  attribute :self_attr, :string
11
17
  attribute :name, :string
@@ -15,6 +21,28 @@ module Idml
15
21
  attribute :fill_color, :string
16
22
  attribute :stroke_color, :string
17
23
  attribute :stroke_weight, :float
24
+
25
+ # Schema-faithful layout attributes (from Table_Object in
26
+ # Stories/Story.rnc). The renderer consults these to compute
27
+ # column widths and to clip body/header/footer regions.
28
+ attribute :header_row_count, :integer
29
+ attribute :footer_row_count, :integer
30
+ attribute :body_row_count, :integer
31
+ attribute :column_count, :integer
32
+ attribute :single_column_width, :float
33
+ attribute :table_direction, :string
34
+
35
+ # Outer-border strokes (TopBorderStrokeWeight, etc.). Useful
36
+ # when the renderer draws the table's bounding rectangle.
37
+ attribute :top_border_stroke_weight, :float
38
+ attribute :top_border_stroke_color, :string
39
+ attribute :left_border_stroke_weight, :float
40
+ attribute :left_border_stroke_color, :string
41
+ attribute :bottom_border_stroke_weight, :float
42
+ attribute :bottom_border_stroke_color, :string
43
+ attribute :right_border_stroke_weight, :float
44
+ attribute :right_border_stroke_color, :string
45
+
18
46
  attribute :table_row, Idml::Elements::TableRow, collection: true
19
47
  attribute :cell, Idml::Elements::Cell, collection: true
20
48
  attribute :row, Idml::Elements::Row, collection: true
@@ -30,6 +58,22 @@ module Idml
30
58
  map_attribute "FillColor", to: :fill_color
31
59
  map_attribute "StrokeColor", to: :stroke_color
32
60
  map_attribute "StrokeWeight", to: :stroke_weight
61
+ map_attribute "HeaderRowCount", to: :header_row_count
62
+ map_attribute "FooterRowCount", to: :footer_row_count
63
+ map_attribute "BodyRowCount", to: :body_row_count
64
+ map_attribute "ColumnCount", to: :column_count
65
+ map_attribute "SingleColumnWidth", to: :single_column_width
66
+ map_attribute "TableDirection", to: :table_direction
67
+ map_attribute "TopBorderStrokeWeight", to: :top_border_stroke_weight
68
+ map_attribute "TopBorderStrokeColor", to: :top_border_stroke_color
69
+ map_attribute "LeftBorderStrokeWeight", to: :left_border_stroke_weight
70
+ map_attribute "LeftBorderStrokeColor", to: :left_border_stroke_color
71
+ map_attribute "BottomBorderStrokeWeight",
72
+ to: :bottom_border_stroke_weight
73
+ map_attribute "BottomBorderStrokeColor",
74
+ to: :bottom_border_stroke_color
75
+ map_attribute "RightBorderStrokeWeight", to: :right_border_stroke_weight
76
+ map_attribute "RightBorderStrokeColor", to: :right_border_stroke_color
33
77
  map_element "TableRow", to: :table_row
34
78
  map_element "Cell", to: :cell
35
79
  map_element "Row", to: :row
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Applies character-level styling from a `StyleResolver::StyledRun`
6
+ # around a text-emitting block. Centralizes the IDML → PDF mapping
7
+ # for run-level visual properties (text color, underline rule,
8
+ # strike-through rule, capitalization, super/subscript position).
9
+ #
10
+ # Used by `TextFrameRenderer` so character-level effects live in
11
+ # one place (DRY). The block typically calls `canvas.text` (or
12
+ # `canvas.text_rich`); this helper sets the surrounding graphics
13
+ # state and emits the rule lines after the block returns.
14
+ module CharacterStyle
15
+ UNDERLINE_THICKNESS_FACTOR = 0.05
16
+ UNDERLINE_DROP_FACTOR = 0.10
17
+ STRIKE_THICKNESS_FACTOR = 0.05
18
+ STRIKE_RISE_FACTOR = 0.25
19
+
20
+ # Yields with the run's fill color applied, then draws underline
21
+ # and/or strike-through rules above/below the text baseline.
22
+ # `x`, `y`, `width`, `size` describe the line's geometry so the
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).
27
+ def self.apply(canvas, run, context, x:, y:, width:, size:)
28
+ apply_fill_color(canvas, run, context)
29
+ yield
30
+ draw_underline(canvas, run, x, y, width, size)
31
+ draw_strike_through(canvas, run, x, y, width, size)
32
+ end
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
+
70
+ # Transforms the text per the CSR's Capitalization attribute.
71
+ # AllCaps/SmallCaps → uppercase (true small-caps would require
72
+ # an OpenType feature; deferred). Other values pass through.
73
+ def self.transform_text(text, capitalization)
74
+ return text unless %w[AllCaps SmallCaps].include?(capitalization)
75
+
76
+ text.upcase
77
+ end
78
+
79
+ # Returns `[font_size, baseline_offset]` for the CSR's Position
80
+ # attribute. Superscript/subscript shrink the font and shift
81
+ # the baseline. Normal / nil returns the size unchanged.
82
+ def self.position_scale(position, font_size)
83
+ case position
84
+ when "Superscript" then [font_size * 0.583, font_size * 0.333]
85
+ when "Subscript" then [font_size * 0.583, -font_size * 0.0833]
86
+ else [font_size, 0.0]
87
+ end
88
+ end
89
+
90
+ def self.apply_fill_color(canvas, run, context)
91
+ return unless run.fill_color
92
+ return if run.fill_color == "Color/None"
93
+
94
+ color = context.color_resolver&.resolve(run.fill_color)
95
+ return unless color
96
+
97
+ tinted = ColorHelper.apply_tint(color, run.fill_tint)
98
+ canvas.fill_color(ColorHelper.to_canvas(tinted))
99
+ end
100
+ private_class_method :apply_fill_color
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
+
110
+ def self.draw_underline(canvas, run, x, y, width, size)
111
+ return unless run.underline
112
+
113
+ thickness = rule_thickness(run.underline_weight, size,
114
+ UNDERLINE_THICKNESS_FACTOR)
115
+ drop = rule_offset(run.underline_offset, size,
116
+ UNDERLINE_DROP_FACTOR)
117
+ canvas.rectangle(x, y - drop, width, thickness)
118
+ canvas.fill
119
+ end
120
+ private_class_method :draw_underline
121
+
122
+ def self.draw_strike_through(canvas, run, x, y, width, size)
123
+ return unless run.strike_thru
124
+
125
+ thickness = rule_thickness(run.strike_through_weight, size,
126
+ STRIKE_THICKNESS_FACTOR)
127
+ rise = rule_offset(run.strike_through_offset, size,
128
+ STRIKE_RISE_FACTOR)
129
+ canvas.rectangle(x, y + rise - thickness, width, thickness)
130
+ canvas.fill
131
+ end
132
+ private_class_method :draw_strike_through
133
+
134
+ def self.rule_thickness(declared, size, factor)
135
+ declared&.positive? ? declared : (size * factor)
136
+ end
137
+ private_class_method :rule_thickness
138
+
139
+ def self.rule_offset(declared, size, factor)
140
+ return size * factor unless declared
141
+ return size * factor if declared.zero?
142
+
143
+ declared
144
+ end
145
+ private_class_method :rule_offset
146
+ end
147
+ end
148
+ end
@@ -1,9 +1,10 @@
1
- # frozen_string_literal: true
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 use a consistent format.
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