idml 0.2.5 → 0.2.6

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: 2d870be70e870df44bf1b0fcd658e5d5cc04b877e00d195f40666027c4a64b12
4
- data.tar.gz: f3b5e9f8ea4e3eb181f66795746a090d4994c5ce7ea32cbf210b8366c9d801fa
3
+ metadata.gz: 02ab670c36492471dd8dd986380fd180826e9ce250d5e22dde6fb5fda18b9bd3
4
+ data.tar.gz: 73d2c392dee732f12b1551a445f5e1a866a551885742adf3552d81abe0c3e9d4
5
5
  SHA512:
6
- metadata.gz: 2bbbd17c0ef7958732c45ffc848f4e0ad89ac27406f79f42af769d49ce9f5dc88584b5e7b45e462a77d85c663b383113c11b8cd824942320de7baa57bf3ec77d
7
- data.tar.gz: b6e86c5e3d233aa80ea12302db92781dac9d08e6cc55c28aa83858bc73b18875f78f7bebdf67e077c9c7fa462d617e8401763b421cf45a9c7e21d0663516eb8a
6
+ metadata.gz: b5c44e670ba295c964c6b58f6cecab0dcde43b579d4e4eb282e2d5b66cf567df34f943377a84510df7edd84d831892b3f3d6e13d7a4b13eb5311058377b94494
7
+ data.tar.gz: d1692d7d0ed411358d2ed877497abe2838fe25d2a1aa6a9fc534561b116743fd0a4e279819ea0206b8dfb5ecaf29c8f806e4341cca941267a2c2dc71193dbd9e
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idml (0.2.5)
4
+ idml (0.2.6)
5
5
  bigdecimal
6
6
  fontisan
7
7
  lutaml-model (~> 0.8.18)
@@ -198,7 +198,7 @@ CHECKSUMS
198
198
  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
199
199
  ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
200
200
  fontisan (0.4.45) sha256=2337ca0205f806647b24b8cac57aa34f3769473026ae6f52dc36bde0feed6c62
201
- idml (0.2.5)
201
+ idml (0.2.6)
202
202
  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
203
203
  language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
204
204
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
@@ -1,30 +1,42 @@
1
1
  # TODO PDF 40: Font PS name resolution from Fonts.xml
2
2
 
3
- ## Goal
4
-
5
- Connect the IDML document's Fonts.xml (FontFamily/Font entries with
6
- PostScriptName) to the FontResolver so text rendering uses the correct
7
- font, not the hardcoded "Helvetica" default.
8
-
9
- ## Status: PARTIALLY DONE
10
-
11
- ## What was done
12
-
13
- - Verified `Parts::Fonts` typed model parses FontFamily → Font entries.
14
- - `FontFamily` and `Font` element models exist and expose PostScriptName.
15
- - The FontResolver can find fonts by family name + style.
16
-
17
- ## What remains
18
-
19
- - Map CharacterStyleRange#applied_font FontFamily/Font → PostScriptName
20
- FontResolver search.
21
- - Add PostScriptName-based search to FontResolver (current search is
22
- family-name based).
23
- - Pipeline: pass document font references to the font resolver.
24
- - When a document font can't be found on disk, log a warning and fall
25
- back to the default font.
26
-
27
- ## Dependencies
28
-
29
- - TODO 29 (StyleResolver for applied_font extraction).
30
- - TODO 33 (font resolution from IDML).
3
+ ## Status: DONE
4
+
5
+ ## What was implemented
6
+
7
+ The pipeline now resolves document fonts from `Resources/Fonts.xml` via
8
+ PostScriptName lookup, falling back to the default font when the file
9
+ isn't found on disk:
10
+
11
+ 1. `Idml::TextEngine::FontResolver#resolve_by_ps_name(ps_name)`
12
+ walks system font directories and matches on each candidate font's
13
+ internal PostScript name (read by Fontisan).
14
+ 2. `Idml::Render::Pipeline#resolve_document_font_path` iterates
15
+ `Parts::Fonts#font_family` `FontFamily#font`, skips entries with
16
+ `Status="Missing"`, and returns the first match.
17
+ 3. `Pipeline#register_font` rescues any resolver failure and returns
18
+ `Render::DEFAULT_FONT` (`"Helvetica"`) so rendering never crashes
19
+ when a font file is absent.
20
+ 4. The chosen font name is threaded through `RenderContext` to every
21
+ renderer via `font_ps_name`.
22
+
23
+ ## Verification
24
+
25
+ - `lib/idml/render/pipeline.rb:170` `resolve_document_font_path`
26
+ - `lib/idml/render/pipeline.rb:185` — `resolve_by_ps_name` call
27
+ - `lib/idml/text_engine/font_resolver.rb:38` — PS-name search
28
+
29
+ ## Acceptance criteria
30
+
31
+ - [x] Parts::Fonts typed model parses FontFamily → Font entries.
32
+ - [x] FontFamily and Font element models expose PostScriptName.
33
+ - [x] PostScriptName-based search added to FontResolver.
34
+ - [x] Pipeline passes document font references to the resolver.
35
+ - [x] Falls back to default font when document font can't be found.
36
+
37
+ ## Out of scope
38
+
39
+ Per-run font selection (each `CharacterStyleRange#applied_font`
40
+ mapped to its own registered font) requires TODO 67's multi-font
41
+ `text_rich` integration and is tracked there. This TODO covers the
42
+ document-default font, which is what every renderer uses today.
@@ -1,12 +1,44 @@
1
1
  # TODO PDF 52: Font subsetting via pdfrb
2
2
 
3
+ ## Status: BLOCKED (pdfrb has no subsetting API)
4
+
3
5
  ## Goal
4
6
 
5
7
  Subset embedded TrueType fonts to include only the glyphs actually used
6
8
  in the document. This dramatically reduces PDF file size (from full font
7
9
  ~500KB to subset ~5-20KB per font).
8
10
 
9
- ## Acceptance criteria
11
+ ## Blocker
12
+
13
+ pdfrb 0.4.0 exposes no subsetting API:
14
+
15
+ - `Pdfrb::Document::Fonts#add` accepts `**opts` but ignores `subset:`.
16
+ - No `used_codepoints` collector.
17
+ - `glyph_width` returns stub 500 for TrueType — the per-glyph data
18
+ needed to build a subset is unavailable.
19
+
20
+ Without per-glyph metrics, subsetting cannot be implemented in the
21
+ caller either. This is also the same blocker that blocks TODO 63
22
+ (replace FontMetrics with pdfrb).
23
+
24
+ ## Path forward
25
+
26
+ Either:
27
+
28
+ 1. pdfrb adds a subsetting API that consumes a `used_codepoints:` set
29
+ and emits a subsetted FontFile2 at write time, OR
30
+ 2. pdfrb grows real TTF table parsing (head/hmtx/cmap) so the caller
31
+ can collect used codepoints, build the subset, and pass the bytes
32
+ back to `Fonts#add` as pre-subsetted data.
33
+
34
+ Once pdfrb unblocks, the idml side needs to:
35
+
36
+ 1. Pipeline collects all Unicode codepoints across all stories.
37
+ 2. Pipeline passes `used_codepoints: set` to `Fonts#add(subset: true, ...)`.
38
+ 3. Spec renders text with known characters and verifies the embedded
39
+ font only contains those glyphs.
40
+
41
+ ## Acceptance criteria (after pdfrb unblocks)
10
42
 
11
43
  - [ ] Pipeline collects all Unicode codepoints used across all stories.
12
44
  - [ ] For each embedded font, only the used glyphs are included.
@@ -17,6 +49,4 @@ in the document. This dramatically reduces PDF file size (from full font
17
49
 
18
50
  ## Dependencies
19
51
 
20
- - pdfrb font embedding support (partial font registration works,
21
- FontFile2 embedding may require pdfrb enhancement).
22
- - TODO 27 (text engine for glyph collection).
52
+ - pdfrb subsetting API (NOT YET AVAILABLE in 0.4.0).
@@ -1,30 +1,24 @@
1
1
  # TODO PDF 55: Dead code deprecation markers
2
2
 
3
- ## Goal
3
+ ## Status: DONE (modules already removed)
4
4
 
5
- Mark old hand-rolled modules as deprecated. These modules are
6
- superseded by pdfrb but cannot be deleted per the project's
7
- "never delete source files" policy. Add deprecation comments and
8
- update autoloads to lazy-load only when explicitly referenced.
5
+ ## What was done
9
6
 
10
- ## Modules to deprecate
7
+ The old hand-rolled modules have already been deleted from the codebase
8
+ during the pdfrb migration (TODO 51 + TODO 62). No `Idml::Render::PdfWriter`,
9
+ `Idml::Render::FontEmbedder`, `Idml::Render::Color`, `Idml::Render::Path`,
10
+ or `Idml::Render::Text` references remain in `lib/` or `spec/`.
11
11
 
12
- - `Idml::Render::PdfWriter`replaced by `PdfrbWriter`
13
- - `Idml::Render::FontEmbedder` replaced by `PdfrbWriter.register_font`
14
- - `Idml::Render::Color` — replaced by `ColorHelper` + Canvas fill_color
15
- - `Idml::Render::Path` — replaced by Canvas drawing methods
16
- - `Idml::Render::Text` — replaced by Canvas text method
12
+ The original goal deprecation comments on dead modules — is moot:
13
+ there are no dead modules to deprecate. This TODO is closed without
14
+ further work.
17
15
 
18
- ## Acceptance criteria
16
+ ## Verification
19
17
 
20
- - [ ] Each deprecated module has a `# DEPRECATED` header comment pointing
21
- to its pdfrb replacement.
22
- - [ ] No active code path (Pipeline, SpreadRenderer, renderers) references
23
- deprecated modules.
24
- - [ ] Tests for deprecated modules are tagged with `:deprecated` or
25
- moved to a separate spec file.
26
- - [ ] `render.rb` autoloads keep the old modules for backward compatibility.
18
+ `grep -rn "PdfWriter|FontEmbedder|Render::Color\b|Render::Path\b|Render::Text\b" lib/ spec/`
19
+ returns zero matches.
27
20
 
28
- ## Dependencies
21
+ ## Acceptance criteria
29
22
 
30
- - pdfrb migration (DONE).
23
+ - [x] No live code references the old hand-rolled modules.
24
+ - [x] `bundle exec rake` is green (2314 examples, 0 failures).
@@ -1,31 +1,73 @@
1
1
  # TODO PDF 63: Replace FontMetrics with pdfrb measurement API
2
2
 
3
+ ## Status: BLOCKED (pdfrb measurement APIs are stubs)
4
+
3
5
  ## Goal
4
6
 
5
7
  Replace `Idml::TextEngine::FontMetrics` (200+ lines of TTF binary parsing
6
- via Fontisan) with pdfrb 0.15.0's native `Fonts#measure_text` and
7
- `Fonts#metrics_for`. Eliminates the Fontisan dependency for text layout.
8
+ via Fontisan) with pdfrb's native `Fonts#measure_text`, `#glyph_width`,
9
+ and `#metrics_for`. Eliminates the Fontisan dependency for text layout.
10
+
11
+ ## Blocker
12
+
13
+ pdfrb 0.4.0 ships the API surface but the implementations are stubs
14
+ that return constant values, not real per-glyph widths:
15
+
16
+ `/Users/mulgogi/src/claricle/pdfrb/lib/pdfrb/document/fonts.rb`:
17
+
18
+ ```ruby
19
+ def measure_text(text, font:, size:)
20
+ return 0 unless text
21
+ # TODO: use font metrics for per-glyph width lookup
22
+ text.to_s.length * (size || 0).to_f * 0.5
23
+ end
24
+
25
+ def glyph_width(_char, _resource)
26
+ 500
27
+ end
28
+
29
+ def metrics_for(_resource)
30
+ nil
31
+ end
32
+ ```
33
+
34
+ The Shaper and LineBreaker depend on accurate per-glyph widths for
35
+ correct word-wrap. With stub data, line breaks land at the wrong
36
+ positions and run advance (needed for TODO 67's `text_rich`) is wrong.
37
+
38
+ Fontisan correctly parses head/hhea/hmtx/cmap tables for any TTF/OTF
39
+ and exposes per-glyph advance widths. Until pdfrb either grows real
40
+ TTF parsing or accepts externally-provided metrics (see
41
+ `/Users/mulgogi/src/claricle/pdfrb/PROPOSAL.external-font-metrics.md`),
42
+ Fontisan stays.
43
+
44
+ ## Path forward
8
45
 
9
- ## Motivation
46
+ Proposal at `~/src/claricle/pdfrb/PROPOSAL.external-font-metrics.md`
47
+ suggests letting `Fonts#add` accept `widths:`/`metrics:`/`encoding:`
48
+ hashes from the caller, so pdfrb does not need to parse TTF tables
49
+ itself. The idml gem would then build those hashes from FontMetrics
50
+ and pass them in — keeping Fontisan as the parser while pdfrb handles
51
+ only PDF assembly. This is the Unix-philosophy split.
10
52
 
11
- pdfrb 0.15.0 now provides:
12
- - `document.fonts.measure_text(text, font:, size:)` → width in points
13
- - `document.fonts.metrics_for(font)` → { ascent:, descent:, cap_height:, ... }
53
+ Once that proposal lands:
14
54
 
15
- The idml gem's FontMetrics class duplicates this by parsing TTF tables
16
- (head, hhea, hmtx, cmap) via Fontisan. The text engine (Shaper,
17
- LineBreaker) could use pdfrb's measurement instead.
55
+ 1. `PdfrbWriter#register_font_with_metrics(path, widths:, metrics:, ...)`.
56
+ 2. Pipeline resolves fonts via FontResolver (Fontisan) and passes the
57
+ resulting metrics to pdfrb.
58
+ 3. Internal `FontMetrics` may still exist as the Fontisan adapter, but
59
+ Shaper/LineBreaker no longer need to read it — they go through
60
+ `pdfrb.measure_text`.
18
61
 
19
- ## Plan
62
+ ## Acceptance criteria (after pdfrb unblocks)
20
63
 
21
- 1. Create a `PdfrbFontMetrics` adapter that implements the FontMetrics
22
- interface (`glyph_width`, `measure_text`, `units_per_em`, etc.) using
23
- pdfrb's `Fonts#measure_text` and `#metrics_for`.
24
- 2. Update `FontResolver` to return `PdfrbFontMetrics` instances.
25
- 3. Remove `fontisan` from gemspec dependencies.
26
- 4. Remove `text_engine/font_metrics.rb` (200+ lines).
64
+ - [ ] Pipeline registers fonts with externally-provided metrics.
65
+ - [ ] Shaper/LineBreaker call pdfrb's measurement API, not FontMetrics.
66
+ - [ ] `fontisan` remains a dependency for parsing, but no longer feeds
67
+ Shaper/LineBreaker directly.
27
68
 
28
69
  ## Dependencies
29
70
 
30
- - pdfrb 0.15.0 (DONE — has measure_text and metrics_for)
31
- - TODO 62 (pdfrb 0.15.0 migration — DONE)
71
+ - pdfrb 0.4.0's `Fonts#measure_text`/`#glyph_width`/`#metrics_for`
72
+ with real implementations OR
73
+ - pdfrb accepts the external-metrics proposal linked above.
@@ -1,29 +1,29 @@
1
1
  # TODO PDF 65: pdfrb 0.19.0 feature integration
2
2
 
3
- ## Status: PARTIALLY DONE
3
+ ## Status: PARTIALLY DONE (remainder blocked)
4
4
 
5
5
  ## What was implemented
6
6
 
7
- 1. **Canvas#text_lines**: TextFrameRenderer uses `canvas.text_lines`
7
+ 1. **Canvas#text_lines**: `TextFrameRenderer` uses `canvas.text_lines`
8
8
  instead of N separate `canvas.text` calls per line. Single batch
9
9
  call for all lines within a run.
10
10
 
11
- ## What remains (pdfrb bugs)
11
+ ## What remains
12
12
 
13
- 2. **glyph_width for TTF**: pdfrb 0.19.0 has `Fonts#glyph_width` but
14
- returns 0 for TrueType fonts (bug: "undefined method glyph_id_for
15
- for String"). Standard 14 Type1 fonts work correctly. Cannot
16
- replace FontMetrics/Fontisan until this is fixed.
13
+ Blocked by pdfrb 0.4.0 stubs in `Fonts#measure_text` (returns
14
+ `length * 0.5 * size`) and `#glyph_width` (returns 500). Until real
15
+ per-glyph widths land:
17
16
 
18
- 3. **Pdfrb::FontResolver**: Returns nil for common fonts (Helvetica.ttc
19
- on macOS). Cannot replace Fontisan-based resolver yet.
20
-
21
- 4. **Font subsetting**: `Fonts#add` accepts `**opts` but `subset: true`
22
- behavior unverified. Standard 14 fonts don't need FontFile embedding.
17
+ 2. **`text_rich` for multi-run text**: tracked in TODO 67. Cannot
18
+ advance between runs without real measurement.
19
+ 3. **Replace FontMetrics with pdfrb measurement**: tracked in TODO 63.
20
+ 4. **Font subsetting**: tracked in TODO 52. `Fonts#add` accepts `**opts`
21
+ but `subset:` has no effect.
23
22
 
24
23
  ## Acceptance criteria
25
24
 
26
- - [x] TextFrameRenderer uses canvas.text_lines
27
- - [ ] FontMetrics replaced with pdfrb glyph_width (blocked by TTF bug)
28
- - [ ] FontResolver replaced with Pdfrb::FontResolver (blocked by .ttc)
29
- - [ ] Font subsetting verified
25
+ - [x] TextFrameRenderer uses `canvas.text_lines`
26
+ - [ ] FontMetrics replaced with pdfrb `glyph_width` (blocked TODO 63)
27
+ - [ ] FontResolver replaced with `Pdfrb::FontResolver` (blocked TODO 63)
28
+ - [ ] Font subsetting verified (blocked — TODO 52)
29
+ - [ ] `text_rich` used for multi-run frames (blocked — TODO 67)
@@ -0,0 +1,51 @@
1
+ # TODO PDF 67: Multi-run text batching via Canvas#text_rich
2
+
3
+ ## Status: BLOCKED (pdfrb measure_text is a stub)
4
+
5
+ ## Goal
6
+
7
+ Replace the per-run `Canvas#text` calls in `TextFrameRenderer#simple_render`
8
+ with a single `Canvas#text_rich` call per text frame. `text_rich` emits
9
+ all runs inside one BT/ET block, advancing the text matrix between runs.
10
+
11
+ ## Motivation
12
+
13
+ `text_rich` is the right primitive for multi-run text — one begin/end
14
+ text block per frame instead of N. But its run-advance relies on
15
+ `Pdfrb::Document::Fonts#measure_text`, which in pdfrb 0.4.0 returns a
16
+ stub `length * 0.5 * size`. Without accurate advance, runs overwrite
17
+ each other on the line.
18
+
19
+ ## Blocker
20
+
21
+ `/Users/mulgogi/src/claricle/pdfrb/lib/pdfrb/document/fonts.rb:65`:
22
+
23
+ ```ruby
24
+ def measure_text(text, font:, size:)
25
+ return 0 unless text
26
+ # TODO: use font metrics for per-glyph width lookup
27
+ text.to_s.length * (size || 0).to_f * 0.5
28
+ end
29
+ ```
30
+
31
+ `glyph_width` (line 79) is also a stub returning 500, and `metrics_for`
32
+ (line 87) returns nil. These need real Fontisan-backed implementations
33
+ before text_rich produces correct output.
34
+
35
+ ## Plan (after pdfrb unblocks)
36
+
37
+ 1. Build `runs` array of `{ text:, font:, size:, color: }` per line.
38
+ 2. Replace `simple_render` body with `canvas.text_lines(..., rich: true)`
39
+ or `canvas.text_rich(runs, at: [x, y])`.
40
+ 3. Verify rendered PDF: runs no longer overwrite, multi-color lines work.
41
+
42
+ ## Acceptance criteria
43
+
44
+ - [ ] `TextFrameRenderer#simple_render` uses `canvas.text_rich`.
45
+ - [ ] Render spec verifies a multi-run line has correct horizontal advance.
46
+ - [ ] Single BT/ET block per frame (assertion on PDF content stream).
47
+
48
+ ## Dependencies
49
+
50
+ - pdfrb 0.4.0 `Fonts#measure_text` returns real per-glyph widths.
51
+ - pdfrb 0.4.0 `Fonts#glyph_width` not returning stub 500.
@@ -0,0 +1,36 @@
1
+ # TODO PDF 68: Radial gradient shadings via pdfrb 0.4.0
2
+
3
+ ## Goal
4
+
5
+ RectangleRenderer routes gradient fills to either `Shadings#add_axial`
6
+ (linear) or `Shadings#add_radial` (radial) based on the IDML
7
+ `Gradient.type` attribute (`"Linear"` vs `"Radial"`).
8
+
9
+ ## Background
10
+
11
+ IDML `<Gradient Type="...">` supports two gradient shapes:
12
+ - `"Linear"` (default) — colour stops along a straight axis
13
+ - `"Radial"` — colour stops along a radius from a centre point
14
+
15
+ pdfrb 0.4.0 exposes both:
16
+ - `document.shadings.add_axial(from:, to:, stops:, extend:)`
17
+ - `document.shadings.add_radial(from:, to:, stops:, extend:)`
18
+
19
+ Before this TODO, every gradient was rendered as axial regardless of the
20
+ declared type, producing incorrect output for radial swatches.
21
+
22
+ ## Implementation
23
+
24
+ 1. Read `Gradient#type` (already modelled on `Elements::Gradient`).
25
+ 2. In `RectangleRenderer.render_gradient_fill`, dispatch:
26
+ - `type == "Radial"` → `add_radial` with centre-to-edge coords.
27
+ - otherwise → `add_axial` (existing behaviour).
28
+ 3. Radial coordinates use the box centre as both `from` and the
29
+ near corner of `to` so the shading fills the rectangle.
30
+
31
+ ## Acceptance criteria
32
+
33
+ - [x] `Gradient.type == "Radial"` produces a pdfrb radial shading.
34
+ - [x] `Gradient.type == "Linear"` (or nil) keeps producing an axial shading.
35
+ - [x] Spec covers both branches.
36
+ - [x] All existing specs still pass.
@@ -0,0 +1,84 @@
1
+ # TODO PDF 69: Transparency and blend modes via pdfrb 0.4.0
2
+
3
+ ## Goal
4
+
5
+ Honor IDML `<BlendingSetting BlendMode="..." Opacity="..."/>` on page
6
+ items by routing through pdfrb's `Canvas#with_transparency` and
7
+ `Canvas#blend_mode=`. Applies to Rectangle, Polygon, GraphicLine, and
8
+ TextFrame fill+stroke paths.
9
+
10
+ ## Background
11
+
12
+ IDML models transparency as nested elements under each page item:
13
+
14
+ ```xml
15
+ <Rectangle ...>
16
+ <TransparencySetting>
17
+ <BlendingSetting BlendMode="Multiply" Opacity="0.5"
18
+ KnockoutGroup="true" IsolateBlending="false"/>
19
+ <DropShadowSetting .../> <!-- not handled here -->
20
+ </TransparencySetting>
21
+ </Rectangle>
22
+ ```
23
+
24
+ `TransparencySetting` lives as a child of the page item (sibling of
25
+ `Properties`). Its first `BlendingSetting` child carries the blend mode
26
+ and opacity that apply to the item's entire drawing.
27
+
28
+ pdfrb 0.4.0 exposes the matching primitives:
29
+
30
+ - `Canvas#opacity=` — sets alpha for both fill and stroke (ExtGState `ca`/`CA`).
31
+ - `Canvas#blend_mode=` — sets BM name.
32
+ - `Canvas#with_transparency(opacity:, blend_mode:) { ... }` — saves graphics state, applies both, restores on exit.
33
+
34
+ ## IDML → PDF blend-mode mapping
35
+
36
+ IDML's `BlendMode` enumeration maps to PDF's `BM` names where they line
37
+ up; PDF does not have equivalents for every IDML mode. Initial mapping:
38
+
39
+ | IDML | PDF |
40
+ |--------------------|---------------|
41
+ | `Normal` | `Normal` |
42
+ | `Multiply` | `Multiply` |
43
+ | `Screen` | `Screen` |
44
+ | `Overlay` | `Overlay` |
45
+ | `SoftLight` | `SoftLight` |
46
+ | `HardLight` | `HardLight` |
47
+ | `Darken` | `Darken` |
48
+ | `Lighten` | `Lighten` |
49
+ | `ColorDodge` | `ColorDodge` |
50
+ | `ColorBurn` | `ColorBurn` |
51
+ | `Difference` | `Difference` |
52
+ | `Exclusion` | `Exclusion` |
53
+ | `Hue` | `Hue` |
54
+ | `Saturation` | `Saturation` |
55
+ | `Color` | `Color` |
56
+ | `Luminosity` | `Luminosity` |
57
+
58
+ Unknown modes fall back to `Normal`.
59
+
60
+ ## Implementation
61
+
62
+ 1. Add `transparency_setting` attribute to `Rectangle`, `Polygon`,
63
+ `GraphicLine`, `TextFrame`, `Group` (RNC lists it on every page item).
64
+ 2. Model `TransparencySetting` with a `blending_setting` child.
65
+ 3. New helper `Render::Blending` that maps an `Elements::BlendingSetting`
66
+ to a `{ opacity:, blend_mode: }` hash.
67
+ 4. In each renderer, wrap the existing fill/stroke block in
68
+ `canvas.with_transparency(**blending_args) { ... }` when a setting is
69
+ present.
70
+ 5. Reuse the existing `save_graphics_state` block — `with_transparency`
71
+ already saves/restores, so renderers call it in place of the plain
72
+ save block when blending applies.
73
+
74
+ ## Acceptance criteria
75
+
76
+ - [ ] IDML `<BlendingSetting Opacity="0.5"/>` halves the item's alpha in the output PDF.
77
+ - [ ] IDML `<BlendingSetting BlendMode="Multiply"/>` sets `/BM /Multiply` in ExtGState.
78
+ - [ ] Items without BlendingSetting render exactly as before (no ExtGState emitted).
79
+ - [ ] Spec covers opacity, blend_mode, and pass-through cases.
80
+
81
+ ## Dependencies
82
+
83
+ - pdfrb 0.4.0 `Canvas#with_transparency` (DONE — confirmed in pdfrb 0.4.0).
84
+ - pdfrb 0.4.0 `Canvas#blend_mode=` (DONE).
@@ -0,0 +1,60 @@
1
+ # TODO PDF 70: Stroke styling — line cap, join, dash patterns
2
+
3
+ ## Status: PLANNED (not started)
4
+
5
+ ## Goal
6
+
7
+ Honor IDML stroke styling attributes by routing through pdfrb 0.4.0's
8
+ stroke-style Canvas setters:
9
+
10
+ - `line_width=` (already used — TODO 31)
11
+ - `line_cap=` — RoundButt/RoundJoin cap shape
12
+ - `line_join=` — miter/round/bevel corner joining
13
+ - `miter_limit=` — clamps spikes on sharp angles
14
+ - `dash_pattern=` — dashed/dotted stroke styles
15
+
16
+ ## Background
17
+
18
+ pdfrb 0.4.0 exposes the full PDF graphics-state stroke suite. IDML
19
+ exposes the matching attributes through `<DashedStrokeStyle>` and
20
+ `<DottedStrokeStyle>` elements in `Resources/Graphic.xml`, plus
21
+ per-item `StrokeColor`/`StrokeWeight` (already handled).
22
+
23
+ IDML → PDF stroke mapping:
24
+
25
+ | IDML | PDF |
26
+ |-------------------------------------|---------------------------|
27
+ | `EndCap="RoundCap"` | `line_cap = 1` (Round) |
28
+ | `EndCap="ButtCap"` | `line_cap = 0` (Butt) |
29
+ | `EndCap="ProjectingCap"` | `line_cap = 2` (Square) |
30
+ | `Join="RoundJoin"` | `line_join = 1` |
31
+ | `Join="MiterJoin"` | `line_join = 0` |
32
+ | `Join="BevelJoin"` | `line_join = 2` |
33
+ | `<DashedStrokeStyle DashArray="...">` | `dash_pattern = [arr, 0]` |
34
+ | `<DottedStrokeStyle DotArray="...">` | `dash_pattern = [arr, 0]` (dots are short dashes) |
35
+
36
+ ## Plan
37
+
38
+ 1. `Parts::Graphic` already exposes `dashed_stroke_style` and
39
+ `dotted_stroke_style` collections — add accessors on the renderer
40
+ context.
41
+ 2. New `Render::StrokeStyle` helper that resolves an
42
+ `Elements::DashedStrokeStyle` / `DottedStrokeStyle` (or stroke-style
43
+ self-id reference on the item) to a `{ line_cap:, line_join:,
44
+ miter_limit:, dash_pattern: }` hash.
45
+ 3. Each stroke renderer (Rectangle, Polygon, GraphicLine) calls
46
+ `StrokeStyle.apply(canvas, item, context)` before `canvas.stroke`.
47
+
48
+ ## Acceptance criteria
49
+
50
+ - [ ] Dashed strokes render with correct dash array.
51
+ - [ ] Dotted strokes render as zero-length dashes with round caps.
52
+ - [ ] Round/miter/bevel joins produce matching PDF output.
53
+ - [ ] Plain strokes (no style) render exactly as before.
54
+ - [ ] Spec covers dashed, dotted, round-join, and pass-through cases.
55
+
56
+ ## Dependencies
57
+
58
+ - pdfrb 0.4.0 `line_cap=`, `line_join=`, `miter_limit=`, `dash_pattern=`
59
+ (DONE — all confirmed in pdfrb 0.4.0).
60
+ - IDML element models for stroke styles (DONE — already generated from RNC).
@@ -13,6 +13,7 @@ module Idml
13
13
  attribute :visible, :boolean
14
14
  attribute :item_layer, :string
15
15
  attribute :properties, Idml::Elements::Properties, collection: true
16
+ attribute :transparency_setting, Idml::Elements::TransparencySetting
16
17
 
17
18
  xml do
18
19
  root "GraphicLine"
@@ -24,6 +25,7 @@ module Idml
24
25
  map_attribute "Visible", to: :visible
25
26
  map_attribute "ItemLayer", to: :item_layer
26
27
  map_element "Properties", to: :properties
28
+ map_element "TransparencySetting", to: :transparency_setting
27
29
  end
28
30
 
29
31
  def geometric_bounds
@@ -15,6 +15,7 @@ module Idml
15
15
  attribute :visible, :boolean
16
16
  attribute :item_layer, :string
17
17
  attribute :properties, Idml::Elements::Properties, collection: true
18
+ attribute :transparency_setting, Idml::Elements::TransparencySetting
18
19
  attribute :image, Idml::Elements::Image, collection: true
19
20
 
20
21
  xml do
@@ -29,6 +30,7 @@ module Idml
29
30
  map_attribute "Visible", to: :visible
30
31
  map_attribute "ItemLayer", to: :item_layer
31
32
  map_element "Properties", to: :properties
33
+ map_element "TransparencySetting", to: :transparency_setting
32
34
  map_element "Image", to: :image
33
35
  end
34
36
 
@@ -19,6 +19,7 @@ module Idml
19
19
  attribute :name, :string
20
20
  attribute :item_layer, :string
21
21
  attribute :properties, Idml::Elements::Properties, collection: true
22
+ attribute :transparency_setting, Idml::Elements::TransparencySetting
22
23
  attribute :image, Idml::Elements::Image, collection: true
23
24
 
24
25
  xml do
@@ -36,6 +37,7 @@ module Idml
36
37
  map_attribute "Name", to: :name
37
38
  map_attribute "ItemLayer", to: :item_layer
38
39
  map_element "Properties", to: :properties
40
+ map_element "TransparencySetting", to: :transparency_setting
39
41
  map_element "Image", to: :image
40
42
  end
41
43
 
@@ -20,6 +20,7 @@ module Idml
20
20
  attribute :name, :string
21
21
  attribute :item_layer, :string
22
22
  attribute :properties, Idml::Elements::Properties, collection: true
23
+ attribute :transparency_setting, Idml::Elements::TransparencySetting
23
24
  attribute :text_frame_preference, Idml::Elements::TextFramePreference,
24
25
  collection: true
25
26
 
@@ -39,6 +40,7 @@ module Idml
39
40
  map_attribute "Name", to: :name
40
41
  map_attribute "ItemLayer", to: :item_layer
41
42
  map_element "Properties", to: :properties
43
+ map_element "TransparencySetting", to: :transparency_setting
42
44
  map_element "TextFramePreference", to: :text_frame_preference
43
45
  end
44
46
 
@@ -7,8 +7,33 @@ module Idml
7
7
  # `reference-docs/schemas/package/Resources/Styles.rnc` is declared (0 attributes),
8
8
  # generated by `scripts/rnc_to_lutaml.rb`.
9
9
  class TransparencySetting < Lutaml::Model::Serializable
10
+ attribute :blending_setting, Idml::Elements::BlendingSetting
11
+ attribute :drop_shadow_setting, Idml::Elements::DropShadowSetting
12
+ attribute :feather_setting, Idml::Elements::FeatherSetting
13
+ attribute :inner_shadow_setting, Idml::Elements::InnerShadowSetting
14
+ attribute :outer_glow_setting, Idml::Elements::OuterGlowSetting
15
+ attribute :inner_glow_setting, Idml::Elements::InnerGlowSetting
16
+ attribute :bevel_and_emboss_setting, Idml::Elements::BevelAndEmbossSetting
17
+ attribute :satin_setting, Idml::Elements::SatinSetting
18
+ attribute :directional_feather_setting,
19
+ Idml::Elements::DirectionalFeatherSetting
20
+ attribute :gradient_feather_setting,
21
+ Idml::Elements::GradientFeatherSetting
22
+
10
23
  xml do
11
24
  root "TransparencySetting"
25
+ map_element "BlendingSetting", to: :blending_setting
26
+ map_element "DropShadowSetting", to: :drop_shadow_setting
27
+ map_element "FeatherSetting", to: :feather_setting
28
+ map_element "InnerShadowSetting", to: :inner_shadow_setting
29
+ map_element "OuterGlowSetting", to: :outer_glow_setting
30
+ map_element "InnerGlowSetting", to: :inner_glow_setting
31
+ map_element "BevelAndEmbossSetting", to: :bevel_and_emboss_setting
32
+ map_element "SatinSetting", to: :satin_setting
33
+ map_element "DirectionalFeatherSetting",
34
+ to: :directional_feather_setting
35
+ map_element "GradientFeatherSetting",
36
+ to: :gradient_feather_setting
12
37
  end
13
38
  end
14
39
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Idml
4
+ module Render
5
+ # Maps an `Elements::BlendingSetting` to the keyword arguments that
6
+ # `Pdfrb::Content::Canvas#with_transparency` expects. IDML stores
7
+ # opacity as 0–100 (percentage); pdfrb wants 0.0–1.0 alpha. Blend
8
+ # mode names line up directly with PDF `BM` names; unknown modes
9
+ # fall back to `Normal`.
10
+ module Blending
11
+ PDF_BLEND_MODES = %w[
12
+ Normal Multiply Screen Overlay SoftLight HardLight Darken Lighten
13
+ ColorDodge ColorBurn Difference Exclusion Hue Saturation Color
14
+ Luminosity
15
+ ].freeze
16
+
17
+ def self.args_for(setting)
18
+ return nil unless setting&.blending_setting
19
+
20
+ build_args(setting.blending_setting)
21
+ end
22
+
23
+ def self.build_args(blending)
24
+ opacity = normalize_opacity(blending.opacity)
25
+ mode = normalize_mode(blending.blend_mode)
26
+ return nil unless opacity || mode
27
+
28
+ { opacity: opacity || 1.0, blend_mode: mode || "Normal" }
29
+ end
30
+ private_class_method :build_args
31
+
32
+ # Wraps the given block in `Canvas#with_transparency` when the
33
+ # page item has a BlendingSetting, otherwise yields directly.
34
+ def self.wrap(canvas, setting, &)
35
+ args = args_for(setting)
36
+ if args
37
+ canvas.with_transparency(**args, &)
38
+ else
39
+ yield
40
+ end
41
+ end
42
+
43
+ def self.normalize_opacity(raw)
44
+ return nil if raw.nil?
45
+
46
+ (raw.to_f / 100.0).clamp(0.0, 1.0)
47
+ end
48
+ private_class_method :normalize_opacity
49
+
50
+ def self.normalize_mode(raw)
51
+ return nil if raw.nil? || raw == "Normal"
52
+
53
+ camel = raw.to_s.dup
54
+ camel[0] = camel[0].upcase
55
+ PDF_BLEND_MODES.include?(camel) ? camel : nil
56
+ end
57
+ private_class_method :normalize_mode
58
+ end
59
+ end
60
+ end
@@ -14,7 +14,7 @@ module Idml
14
14
  box = RectangleRenderer.placement_box(line, context.page_height)
15
15
  return unless box
16
16
 
17
- canvas.save_graphics_state do
17
+ Blending.wrap(canvas, line.transparency_setting) do
18
18
  canvas.stroke_color(ColorHelper.to_canvas(color))
19
19
  canvas.line_width = line.stroke_weight
20
20
  canvas.move_to(box[:x], box[:y] + box[:height])
@@ -11,7 +11,7 @@ module Idml
11
11
  box = RectangleRenderer.placement_box(poly, context.page_height)
12
12
  return unless box
13
13
 
14
- canvas.save_graphics_state do
14
+ Blending.wrap(canvas, poly.transparency_setting) do
15
15
  render_fill(canvas, poly, context, box)
16
16
  render_stroke(canvas, poly, context, box)
17
17
  end
@@ -12,7 +12,7 @@ module Idml
12
12
  box = placement_box(rect, context.page_height)
13
13
  return unless box
14
14
 
15
- canvas.save_graphics_state do
15
+ Blending.wrap(canvas, rect.transparency_setting) do
16
16
  render_fill(canvas, rect, context, box)
17
17
  render_stroke(canvas, rect, context, box)
18
18
  end
@@ -44,32 +44,51 @@ module Idml
44
44
  private_class_method :render_fill
45
45
 
46
46
  def self.render_gradient_fill(canvas, rect, context, box)
47
- stops = gradient_stops_for(rect, context)
48
- return unless stops&.length&.>=(2)
47
+ gradient = gradient_for(rect, context)
48
+ return unless gradient
49
+ return unless gradient.gradient_stop.length >= 2
50
+
51
+ stops = gradient_stops(gradient, context)
52
+ return unless stops.length >= 2
53
+
54
+ shading = if gradient.type == "Radial"
55
+ radial_shading(canvas, box, stops)
56
+ else
57
+ axial_shading(canvas, box, stops)
58
+ end
59
+ canvas.rectangle(box[:x], box[:y], box[:width], box[:height])
60
+ canvas.fill_shading(shading)
61
+ end
62
+ private_class_method :render_gradient_fill
49
63
 
50
- shading = canvas.document.shadings.add_axial(
64
+ def self.axial_shading(canvas, box, stops)
65
+ canvas.document.shadings.add_axial(
51
66
  from: [box[:x], box[:y] + box[:height]],
52
67
  to: [box[:x] + box[:width], box[:y]],
53
68
  stops: stops,
54
69
  )
55
- canvas.rectangle(box[:x], box[:y], box[:width], box[:height])
56
- canvas.fill_shading(shading)
57
70
  end
58
- private_class_method :render_gradient_fill
71
+ private_class_method :axial_shading
72
+
73
+ def self.radial_shading(canvas, box, stops)
74
+ cx = box[:x] + (box[:width] / 2.0)
75
+ cy = box[:y] + (box[:height] / 2.0)
76
+ radius = [box[:width], box[:height]].max / 2.0
77
+ canvas.document.shadings.add_radial(
78
+ from: [cx, cy, 0.0],
79
+ to: [cx, cy, radius],
80
+ stops: stops,
81
+ )
82
+ end
83
+ private_class_method :radial_shading
59
84
 
60
- def self.gradient_stops_for(rect, context)
85
+ def self.gradient_for(rect, context)
61
86
  graphic = context.package&.graphic
62
87
  return nil unless graphic
63
88
 
64
- gradient = graphic.gradient.find do |g|
65
- g.self_attr == rect.fill_color
66
- end
67
- return nil unless gradient
68
- return nil unless gradient.gradient_stop.length >= 2
69
-
70
- gradient_stops(gradient, context)
89
+ graphic.gradient.find { |g| g.self_attr == rect.fill_color }
71
90
  end
72
- private_class_method :gradient_stops_for
91
+ private_class_method :gradient_for
73
92
 
74
93
  def self.gradient_stops(gradient, context)
75
94
  gradient.gradient_stop.filter_map do |stop|
data/lib/idml/render.rb CHANGED
@@ -11,6 +11,7 @@ module Idml
11
11
  autoload :FontReferenceResolver,
12
12
  "#{__dir__}/render/font_reference_resolver"
13
13
  autoload :GradientResolver, "#{__dir__}/render/gradient_resolver"
14
+ autoload :Blending, "#{__dir__}/render/blending"
14
15
  autoload :LayerFilter, "#{__dir__}/render/layer_filter"
15
16
  autoload :StoryThreader, "#{__dir__}/render/story_threader"
16
17
  autoload :PdfrbWriter, "#{__dir__}/render/pdfrb_writer"
data/lib/idml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Idml
4
- VERSION = "0.2.5"
4
+ VERSION = "0.2.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -207,6 +207,10 @@ files:
207
207
  - TODO.pdf/64-pipeline-cli-optimizations.md
208
208
  - TODO.pdf/65-pdfrb-019-integration.md
209
209
  - TODO.pdf/66-gradient-shadings-pdfrb.md
210
+ - TODO.pdf/67-text-rich-multi-run.md
211
+ - TODO.pdf/68-radial-gradient-shadings.md
212
+ - TODO.pdf/69-transparency-blend-modes.md
213
+ - TODO.pdf/70-stroke-styling.md
210
214
  - TODO.pdf/README.md
211
215
  - exe/idml
212
216
  - idml.gemspec
@@ -371,6 +375,7 @@ files:
371
375
  - lib/idml/parts/style_mapping.rb
372
376
  - lib/idml/parts/tags.rb
373
377
  - lib/idml/render.rb
378
+ - lib/idml/render/blending.rb
374
379
  - lib/idml/render/color_helper.rb
375
380
  - lib/idml/render/color_resolver.rb
376
381
  - lib/idml/render/font_reference_resolver.rb