okab 0.1.0 → 0.2.0

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: 8ccd04c824a28ca26ec41d2610efdcd2912a51adc0a6d9cb49fe26a8aa858751
4
- data.tar.gz: 26a86a7f3b03e6406015ea712f8b2dac25909b947c22cc6d0d36d0edde7f58ff
3
+ metadata.gz: a450b67de73b065f7d9a15aefce5a2341640fe4e40f97ccf71d2e0e92cfd7b0e
4
+ data.tar.gz: 3f5cf2767ec1f0f33277917bb2579dec77365771b1c5d480013cd6ad59d2a61a
5
5
  SHA512:
6
- metadata.gz: 0e98d8261aff467b1e97f93242a73e6cacbc75d3a7d964e20da9f2fd1d900656342a31b72da32e715b593984e06c5ba08f9e5a4a70b4edddeb49b15c3cb269a8
7
- data.tar.gz: 81794209eebf9639ecd66023f0d42872d2573906a27de87a6a3d05f36e8e619522a8ac0fbf4d12677cf58aae5c80350b836548c3a49b7458aa4861c3465f2163
6
+ metadata.gz: 9ce197d51db85374f0cc879e8355a6a56d9feadcd70dcd434fa73116237e1ab45e8f22e4da247582189de2afd02b18216dcdbe6880efbd67b3db78e37638a5bb
7
+ data.tar.gz: dc25ea8a7ac3069facf207845cdc0eec481fe14a5cb2b2b368fe9eda730555b6f9789bab96102153b3cae6392c93333ac62b21b28ce9a8ddcaf5d07c7b7cbe34
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-09-24
4
+
5
+ - Add an optional Zaniah vector-document adapter that preserves PDF paths, searchable shaped glyphs, images, clipping, and opacity while rasterizing unsupported effects one command at a time.
6
+
3
7
  ## 0.1.0 — 2026-09-23
4
8
 
5
9
  - Initial release.
data/README.md CHANGED
@@ -1,46 +1,110 @@
1
- # Okab
2
-
3
- Okab (ζ Aquilae) takes its name from Arabic *ʿuqāb*, “eagle”. It is a small Ruby library for creating searchable PDF 1.7 documents with embedded TrueType and supported CFF1-outline fonts.
1
+ <h1 align="center">Okab</h1>
2
+
3
+ <p align="center">
4
+ <strong>Generate searchable PDFs with embedded TrueType and CFF1 fonts in pure Ruby</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/okab"><img src="https://img.shields.io/gem/v/okab.svg" alt="Gem version"></a>
9
+ <a href="https://rubygems.org/gems/okab"><img src="https://img.shields.io/gem/dt/okab.svg" alt="Gem downloads"></a>
10
+ <a href="https://github.com/noxdea/okab/actions/workflows/main.yml"><img src="https://github.com/noxdea/okab/actions/workflows/main.yml/badge.svg" alt="CI"></a>
11
+ <img src="https://img.shields.io/badge/Ruby-%3E%3D%203.2-cc342d.svg" alt="Ruby 3.2 or newer">
12
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="#features">Features</a> ·
17
+ <a href="#installation">Installation</a> ·
18
+ <a href="#quick-start">Quick start</a> ·
19
+ <a href="#limits">Limits</a> ·
20
+ <a href="#development">Development</a>
21
+ </p>
22
+
23
+ ---
24
+
25
+ Okab is a small Ruby library for generating PDF 1.7 documents. It embeds font
26
+ subsets through [Alhena](https://github.com/noxdea/alhena) and writes `ToUnicode`
27
+ mappings so text remains searchable and copyable. The same document inputs
28
+ produce stable output without timestamps. Its name comes from Arabic *ʿuqāb*,
29
+ “eagle” (ζ Aquilae).
4
30
 
5
31
  ## Features
6
32
 
7
- - TrueType and static name-keyed CFF1 font subsetting through [Alhena](https://github.com/noxdea/alhena), with CID fonts and `ToUnicode` mappings for text search and copy
8
- - Text, wrapped text, paths, clipping, transforms, opacity, links, and outlines
33
+ - TrueType and supported static CFF1 font embedding, including Japanese text when the font contains the glyphs
34
+ - Text and wrapped text, vector paths, clipping, transforms, and opacity
9
35
  - PNG images with alpha and JPEG images embedded without re-encoding
10
- - Stable output for the same document inputs; timestamps are omitted
11
-
12
- PDF reading/editing, encryption, signatures, forms, and PDF/A are out of scope. CFF2, variable CFF1, and already CID-keyed CFF fonts are not supported; CFF embedding requires `subset: true`. PNG input is limited to non-interlaced 8-bit images; JPEG input is limited to 8-bit grayscale or RGB baseline, extended-sequential, or progressive images, and Exif orientation is not applied.
36
+ - Optional Zaniah vector-document bridge with searchable glyph runs
37
+ - Page links, document outlines, and deterministic PDF output
13
38
 
14
39
  ## Installation
15
40
 
16
- ```ruby
17
- gem "okab"
41
+ ```sh
42
+ gem install okab
18
43
  ```
19
44
 
20
- Okab requires Ruby 3.2 or later and Alhena 0.3.0 or later.
45
+ Okab requires Ruby 3.2 or newer and Alhena 0.3.x. With Bundler, add `gem "okab"`
46
+ to your Gemfile.
47
+
48
+ ## Quick start
21
49
 
22
- ## Usage
50
+ Supply a TrueType font containing the text you want to render:
23
51
 
24
52
  ```ruby
25
53
  require "okab"
26
54
 
27
55
  font = Okab::Font.load("/path/to/font.ttf")
28
- document = Okab::Document.new(title: "Quarterly report", author: "Yudai Takada")
29
- page = document.page(width: 595, height: 842) # points
30
- page.text("四半期報告", x: 48, y: 790, font: font, size: 24)
31
- page.text_block("A searchable report with embedded fonts.", x: 48, y: 750,
32
- width: 360, font: font, size: 12, line_height: 18)
33
- page.rect(48, 700, 120, 28).fill([0.2, 0.4, 0.8])
34
- page.link([48, 700, 120, 28], uri: "https://example.com")
56
+ document = Okab::Document.new(title: "Quarterly report")
57
+ page = document.page(width: 595, height: 842) # PDF points
58
+ page.text("Quarterly report", x: 48, y: 790, font: font, size: 24)
59
+ page.text_block("A searchable report with embedded fonts.",
60
+ x: 48, y: 750, width: 360, font: font, size: 12, line_height: 18)
35
61
  document.outline("Report", page: page)
36
62
  document.write("report.pdf")
37
63
  ```
38
64
 
39
- Coordinates use PDF points, with the origin at the lower-left corner. Text must be UTF-8. Supply a font that contains the characters you want to render.
65
+ Coordinates are in PDF points, with the origin at the lower-left corner. Text
66
+ must be valid UTF-8. Use a font with Japanese glyphs to render Japanese text.
67
+
68
+ ### Zaniah vector documents
69
+
70
+ Install Zaniah separately, then load the optional bridge explicitly:
71
+
72
+ ```ruby
73
+ require "okab/zaniah_vector"
74
+
75
+ vector = Zaniah::Vector.record(width: 595, height: 842) { slide_element }
76
+ pdf = Okab::Document.new
77
+ page = pdf.page(width: vector.width, height: vector.height)
78
+ Okab::ZaniahVector.draw(page, vector)
79
+ pdf.write("slide.pdf")
80
+ ```
81
+
82
+ The bridge converts Zaniah's top-left coordinates to PDF's lower-left
83
+ coordinates. Solid quads and paths remain PDF vector operations, shaped glyph
84
+ IDs are embedded with `ToUnicode` mappings, and images remain PDF images.
85
+ Unsupported paint effects such as multi-stop gradients and shadows are
86
+ rasterized individually, never as a full-page screenshot. See
87
+ [the adapter notes](docs/zaniah-vector.md) for the mapping and limits.
88
+
89
+ ## Limits
90
+
91
+ Okab generates PDFs; it does not read or edit them. Encryption, signatures,
92
+ forms, and PDF/A are out of scope.
93
+
94
+ - CFF2, variable CFF1, and already CID-keyed CFF fonts are unsupported. CFF embedding requires `subset: true`.
95
+ - PNG input must be non-interlaced and 8-bit.
96
+ - JPEG input must be 8-bit grayscale or RGB, using baseline, extended-sequential, or progressive encoding. Exif orientation is not applied.
40
97
 
41
98
  ## Development
42
99
 
43
- Run `bundle exec rake` and `bundle exec rbs -I sig validate`. To exercise real Japanese extraction locally, set `OKAB_TEST_FONT` to a TrueType font containing Japanese characters and install Poppler's `pdftotext` utility:
100
+ ```sh
101
+ bundle install
102
+ bundle exec rake
103
+ bundle exec rbs -I sig -r alhena validate
104
+ ```
105
+
106
+ To test real Japanese text extraction, set `OKAB_TEST_FONT` to a TrueType font
107
+ with Japanese glyphs and install Poppler's `pdftotext` utility:
44
108
 
45
109
  ```sh
46
110
  OKAB_TEST_FONT=/path/to/japanese-font.ttf bundle exec rake
@@ -48,4 +112,4 @@ OKAB_TEST_FONT=/path/to/japanese-font.ttf bundle exec rake
48
112
 
49
113
  ## License
50
114
 
51
- MIT
115
+ Okab is released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,26 @@
1
+ # Zaniah vector-document adapter
2
+
3
+ `require "okab/zaniah_vector"` loads the optional integration. Call
4
+ `Okab::ZaniahVector.draw(page, vector_document)` with an `Okab::Page` whose
5
+ dimensions match the `Zaniah::Vector::Document`. The method returns the page.
6
+ The core `require "okab"` path does not load or depend on Zaniah.
7
+
8
+ | Zaniah command | PDF representation |
9
+ |---|---|
10
+ | Solid `Quad`, `Path`, plain `Underline` | PDF fill/stroke path with the source color, fill rule, line cap/join, and opacity |
11
+ | `GlyphRun` | Embedded `Okab::Font` CID glyphs at their shaped positions, with source clusters in `ToUnicode` |
12
+ | `Image`, `Raster` | Image XObject (including alpha and source cropping); raster tint is applied before embedding |
13
+ | Clip, transform, opacity | PDF graphics-state clip, transform, and `ExtGState` |
14
+ | Gradient `Quad`, `Shadow`, wavy underline, unsupported dashed-border combination | Local image XObject of that command only |
15
+
16
+ This is a drawing adapter, not a layout engine. Persisted or printed text stays
17
+ searchable when Zaniah records a `GlyphRun`, including multi-character clusters.
18
+ The original font must be embeddable by Okab: static TrueType and supported
19
+ static CFF1 are supported; CFF2 and variable CFF1 are not. An invalid or
20
+ unknown vector command raises an error instead of silently dropping content.
21
+ The PDF page uses points at 72 per inch; choose matching Zaniah document
22
+ dimensions when recording. The adapter does not automatically scale pages.
23
+
24
+ To test the optional integration locally, make Zaniah available on Ruby's load
25
+ path and run `bundle exec rspec spec/zaniah_vector_spec.rb`. The image comparison
26
+ test uses `pdftoppm` when installed; the text extraction test uses `pdftotext`.
data/lib/okab/document.rb CHANGED
@@ -33,7 +33,7 @@ module Okab
33
33
  font = Font.new(font) if font.is_a?(Alhena::Font)
34
34
  raise ArgumentError, "font must be an Okab::Font or Alhena::Font" unless font.is_a?(Font)
35
35
 
36
- key = [font.object_id, !!subset]
36
+ key = [font.face.object_id, !!subset]
37
37
  @fonts[key] ||= EmbeddedFont.new(self, font, subset: subset)
38
38
  end
39
39
 
data/lib/okab/font.rb CHANGED
@@ -58,6 +58,22 @@ module Okab
58
58
  cids.pack("n*")
59
59
  end
60
60
 
61
+ # Preserve glyph IDs produced by an external shaper instead of shaping the
62
+ # source text a second time when a vector display list is embedded.
63
+ def encode_glyph(glyph, unicode:)
64
+ raise ArgumentError, "invalid glyph ID" unless glyph.is_a?(Integer) && glyph.between?(0, face.glyph_count - 1)
65
+ raise ArgumentError, "glyph text must be valid UTF-8" unless unicode.is_a?(String) && unicode.encoding == Encoding::UTF_8 && unicode.valid_encoding?
66
+ key = [unicode, glyph]
67
+ cid = @cid_by_character[key]
68
+ unless cid
69
+ cid = @characters.length
70
+ raise ArgumentError, "a PDF font cannot encode more than 65,535 characters" if cid > 65_535
71
+ @cid_by_character[key] = cid
72
+ @characters[cid] = [unicode.empty? ? "\uFFFD" : unicode, glyph]
73
+ end
74
+ [cid].pack("n")
75
+ end
76
+
61
77
  def glyph_ids
62
78
  @characters.values.map(&:last).uniq
63
79
  end
data/lib/okab/page.rb CHANGED
@@ -57,6 +57,15 @@ module Okab
57
57
  self
58
58
  end
59
59
 
60
+ def glyph(glyph_id, x:, y:, font:, size:, unicode:, color: [0, 0, 0])
61
+ size = finite(size, "font size")
62
+ raise ArgumentError, "font size must be positive" unless size.positive?
63
+ embedded = @document.embed_font(font)
64
+ encoded = embedded.encode_glyph(glyph_id, unicode: unicode)
65
+ @operations << [:text, finite(x, "x"), finite(y, "y"), embedded, encoded, size, rgb(color), 0.0, false, false]
66
+ self
67
+ end
68
+
60
69
  def text_block(string, x:, y:, width:, font:, size:, line_height:, align: :left, color: [0, 0, 0], tracking: 0)
61
70
  validate_text(string)
62
71
  x, y = finite(x, "x"), finite(y, "y")
@@ -90,8 +99,28 @@ module Okab
90
99
  raw(path.commands.join("\n"))
91
100
  end
92
101
 
93
- def fill(color) = raw("#{rgb(color).join(' ')} rg\nf")
94
- def stroke(color, width: 1) = raw("#{rgb(color).join(' ')} RG\n#{positive(width, 'line width')} w\nS")
102
+ def path(outline)
103
+ raise ArgumentError, "path must not be empty" unless outline.is_a?(Path) && !outline.commands.empty?
104
+ raw(outline.commands.join("\n"))
105
+ end
106
+
107
+ def fill(color, rule: :nonzero)
108
+ raise ArgumentError, "fill rule must be nonzero or evenodd" unless %i[nonzero evenodd].include?(rule)
109
+ raw("#{rgb(color).join(' ')} rg\n#{rule == :evenodd ? 'f*' : 'f'}")
110
+ end
111
+
112
+ def stroke(color, width: 1, cap: :butt, join: :miter, miter: 10, dash: nil)
113
+ raise ArgumentError, "line cap must be butt, round, or square" unless %i[butt round square].include?(cap)
114
+ raise ArgumentError, "line join must be miter, round, or bevel" unless %i[miter round bevel].include?(join)
115
+ style = "#{rgb(color).join(' ')} RG\n#{positive(width, 'line width')} w\n#{ {butt: 0, round: 1, square: 2}.fetch(cap) } J\n#{ {miter: 0, round: 1, bevel: 2}.fetch(join) } j\n#{positive(miter, 'miter limit')} M"
116
+ if dash
117
+ raise ArgumentError, "dash must contain positive lengths" unless dash.is_a?(Array) && !dash.empty? && dash.all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? }
118
+ style << "\n[#{dash.map { |value| PDF::Encoding.number(value) }.join(' ')}] 0 d"
119
+ else
120
+ style << "\n[] 0 d"
121
+ end
122
+ raw("#{style}\nS")
123
+ end
95
124
  def fill_and_stroke(color, width: 1) = raw("#{rgb(color).join(' ')} rg\n#{rgb(color).join(' ')} RG\n#{positive(width, 'line width')} w\nB")
96
125
 
97
126
  def clip
@@ -104,6 +133,22 @@ module Okab
104
133
  self
105
134
  end
106
135
 
136
+ def with_clip(path, rule: :nonzero)
137
+ raise ArgumentError, "clip requires a block" unless block_given?
138
+ raise ArgumentError, "clip path must not be empty" unless path.is_a?(Path) && !path.commands.empty?
139
+ raise ArgumentError, "clip rule must be nonzero or evenodd" unless %i[nonzero evenodd].include?(rule)
140
+ start = @operations.length
141
+ @operations << [:raw, "q\n#{path.commands.join("\n")}\n#{rule == :evenodd ? 'W*' : 'W'} n"]
142
+ begin
143
+ yield self
144
+ rescue StandardError
145
+ @operations.slice!(start..)
146
+ raise
147
+ end
148
+ @operations << [:raw, "Q"]
149
+ self
150
+ end
151
+
107
152
  def transform(a, b, c, d, e, f)
108
153
  raise ArgumentError, "transform requires a block" unless block_given?
109
154
  values = [a, b, c, d, e, f].map { |value| finite(value, 'transform') }
data/lib/okab/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Okab
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../okab"
4
+ require "zaniah/vector"
5
+
6
+ module Okab
7
+ # Optional bridge: each display-list item remains a PDF path, glyph, or image.
8
+ # Only effects without an equivalent PDF page operation are rasterized locally.
9
+ class ZaniahVector
10
+ KAPPA = 0.5522847498307936
11
+
12
+ def self.draw(page, vector_document) = new(page, vector_document).draw
13
+
14
+ def initialize(page, vector_document)
15
+ unless page.is_a?(Page) && vector_document.is_a?(::Zaniah::Vector::Document)
16
+ raise ArgumentError, "expected an Okab::Page and Zaniah::Vector::Document"
17
+ end
18
+ unless page.width == vector_document.width && page.height == vector_document.height
19
+ raise ArgumentError, "PDF page and vector document dimensions must match"
20
+ end
21
+ @page, @vector, @fonts = page, vector_document, {}.compare_by_identity
22
+ end
23
+
24
+ def draw
25
+ @vector.commands.each do |command|
26
+ clipped(command) do
27
+ transformed(command) { draw_command(command) }
28
+ end
29
+ end
30
+ @page
31
+ end
32
+
33
+ private
34
+
35
+ def clipped(command)
36
+ return yield unless command.clip
37
+ clip = command.clip
38
+ path = Path.new.rect(clip.x, @page.height - clip.bottom, clip.width, clip.height)
39
+ @page.with_clip(path) { yield }
40
+ end
41
+
42
+ def transformed(command)
43
+ transform = command.transform
44
+ return yield unless transform && transform != ::Zaniah::Transform.identity
45
+ a, b, c, d, tx, ty = transform.to_a
46
+ height = @page.height
47
+ @page.transform(a, -b, -c, d, c * height + tx, height * (1 - d) - ty) { yield }
48
+ end
49
+
50
+ def draw_command(command)
51
+ case command
52
+ when ::Zaniah::Vector::Quad then draw_quad(command)
53
+ when ::Zaniah::Vector::Path then draw_path(command)
54
+ when ::Zaniah::Vector::GlyphRun then draw_glyph_run(command)
55
+ when ::Zaniah::Vector::Image, ::Zaniah::Vector::Raster then draw_image(command)
56
+ when ::Zaniah::Vector::Underline then draw_underline(command)
57
+ when ::Zaniah::Vector::Shadow then draw_shadow(command)
58
+ else raise ArgumentError, "unsupported vector command #{command.class}"
59
+ end
60
+ end
61
+
62
+ def draw_quad(command)
63
+ bounds = command.bounds
64
+ return if bounds.width <= 0 || bounds.height <= 0
65
+ unless command.fill.is_a?(::Zaniah::Color)
66
+ return draw_raster_image(raster_quad(command), bounds, command.opacity)
67
+ end
68
+ widths, border_color = command.border
69
+ if command.border_style == :dashed && widths.uniq.length != 1
70
+ return draw_raster_image(raster_quad(command), bounds, command.opacity)
71
+ end
72
+ outer = rounded_rect(bounds, command.radii)
73
+ paint_fill(outer, command.fill, command.opacity)
74
+ return unless widths.any?(&:positive?) && border_color.a.positive?
75
+ if command.border_style == :dashed
76
+ inset = widths.first / 2.0
77
+ center = rounded_rect(inset_bounds(bounds, [inset] * 4), command.radii.map { |radius| [radius - inset, 0].max })
78
+ paint_stroke(center, border_color, widths.first, command.opacity, dash: [widths.first * 3, widths.first * 2])
79
+ else
80
+ inner = inset_bounds(bounds, widths)
81
+ if inner.width.positive? && inner.height.positive?
82
+ radii = command.radii.each_with_index.map do |radius, index|
83
+ adjacent = [[widths[0], widths[3]], [widths[0], widths[1]],
84
+ [widths[2], widths[1]], [widths[2], widths[3]]].fetch(index)
85
+ [radius - adjacent.max, 0].max
86
+ end
87
+ ring = Path.new
88
+ ring.commands.concat(outer.commands).concat(rounded_rect(inner, radii).commands)
89
+ paint_fill(ring, border_color, command.opacity, rule: :evenodd)
90
+ else
91
+ paint_fill(outer, border_color, command.opacity)
92
+ end
93
+ end
94
+ end
95
+
96
+ def draw_path(command)
97
+ path = outline_path(command.outline)
98
+ paint_fill(path, command.fill, command.opacity, rule: command.fill_rule) if command.fill
99
+ if command.stroke && command.stroke_width.positive?
100
+ paint_stroke(path, command.stroke, command.stroke_width, command.opacity,
101
+ cap: command.stroke_cap, join: command.stroke_join, miter: command.stroke_miter)
102
+ end
103
+ end
104
+
105
+ def draw_glyph_run(command)
106
+ font = (@fonts[command.font] ||= Font.new(command.font))
107
+ color = command.color
108
+ with_alpha(color.a * command.opacity) do
109
+ command.glyphs.zip(command.clusters).each do |(glyph, x, y), cluster|
110
+ unicode = command.text.byteslice(cluster[0]...cluster[1])
111
+ unicode = "\uFFFD" unless unicode&.valid_encoding? && !unicode.empty?
112
+ @page.glyph(glyph, x: x, y: @page.height - y, font: font,
113
+ size: command.size, unicode: unicode, color: rgb(color))
114
+ end
115
+ end
116
+ end
117
+
118
+ def draw_image(command)
119
+ image = pixel_image(command)
120
+ draw_raster_image(image, command.bounds, command.opacity)
121
+ end
122
+
123
+ def draw_underline(command)
124
+ if command.wave
125
+ bounds = ::Zaniah::Bounds.new(command.x, command.y - command.thickness,
126
+ command.width, command.thickness * 3)
127
+ scene = ::Zaniah::Scene.new
128
+ scene.underline(0, command.thickness, command.width, color: command.color,
129
+ thickness: command.thickness, wave: true)
130
+ draw_raster_image(raster_scene(scene, bounds), bounds, command.opacity)
131
+ else
132
+ bounds = ::Zaniah::Bounds.new(command.x, command.y, command.width, command.thickness)
133
+ paint_fill(rounded_rect(bounds, [0] * 4), command.color, command.opacity)
134
+ end
135
+ end
136
+
137
+ def draw_shadow(command)
138
+ bounds = command.bounds
139
+ margin = (command.blur * 3 + command.spread.abs + 2).ceil
140
+ local = ::Zaniah::Bounds.new(bounds.x - margin, bounds.y - margin,
141
+ bounds.width + margin * 2, bounds.height + margin * 2)
142
+ scene = ::Zaniah::Scene.new
143
+ scene.shadow(margin, margin, bounds.width, bounds.height, color: command.color,
144
+ blur: command.blur, spread: command.spread, radius: command.radii, inset: command.inset)
145
+ draw_raster_image(raster_scene(scene, local), local, command.opacity)
146
+ end
147
+
148
+ def raster_quad(command)
149
+ bounds = command.bounds
150
+ scene = ::Zaniah::Scene.new
151
+ scene.quad(0, 0, bounds.width, bounds.height, color: command.fill,
152
+ radius: command.radii, border_width: command.border.first,
153
+ border_color: command.border.last, border_style: command.border_style)
154
+ raster_scene(scene, ::Zaniah::Bounds.new(0, 0, bounds.width, bounds.height))
155
+ end
156
+
157
+ def raster_scene(scene, bounds)
158
+ width, height = [bounds.width.ceil, 1].max, [bounds.height.ceil, 1].max
159
+ rgba_image(::Zaniah::GPU::Software.new(width, height).render(scene, clear: "#0000"), width, height)
160
+ end
161
+
162
+ def draw_raster_image(image, bounds, opacity)
163
+ with_alpha(opacity) do
164
+ @page.image(image, x: bounds.x, y: @page.height - bounds.bottom,
165
+ width: bounds.width, height: bounds.height)
166
+ end
167
+ end
168
+
169
+ def pixel_image(command)
170
+ width, height = command.pixel_width, command.pixel_height
171
+ source = command.source || ::Zaniah::Bounds.new(0, 0, width, height)
172
+ tint = command.is_a?(::Zaniah::Vector::Raster) ? command.color : ::Zaniah::Color.new(1, 1, 1, 1)
173
+ if [source.x, source.y, source.width, source.height].all? { |value| value.is_a?(Integer) } &&
174
+ source.x >= 0 && source.y >= 0 && source.right <= width && source.bottom <= height
175
+ rgba = +"".b
176
+ channels = command.format == :r8 ? 1 : 4
177
+ source.height.times do |row|
178
+ offset = ((source.y + row) * width + source.x) * channels
179
+ slice = command.pixels.byteslice(offset, source.width * channels)
180
+ if channels == 1
181
+ slice.each_byte do |coverage|
182
+ rgba << [(tint.r * 255).round, (tint.g * 255).round, (tint.b * 255).round,
183
+ (coverage * tint.a).round].pack("C4")
184
+ end
185
+ else
186
+ slice.bytes.each_slice(4) do |red, green, blue, alpha|
187
+ rgba << [(red * tint.r).round, (green * tint.g).round,
188
+ (blue * tint.b).round, (alpha * tint.a).round].pack("C4")
189
+ end
190
+ end
191
+ end
192
+ rgba_image(rgba, source.width, source.height)
193
+ else
194
+ texture = ::Zaniah::GPU::Texture.new(width, height, format: command.format, data: command.pixels)
195
+ scene = ::Zaniah::Scene.new
196
+ scene.sprite(0, 0, command.bounds.width, command.bounds.height,
197
+ texture: texture, source: source, color: tint)
198
+ raster_scene(scene, ::Zaniah::Bounds.new(0, 0, command.bounds.width, command.bounds.height))
199
+ end
200
+ end
201
+
202
+ def rgba_image(pixels, width, height)
203
+ rgb, alpha = +"".b, +"".b
204
+ pixels.bytes.each_slice(4) do |red, green, blue, opacity|
205
+ rgb << [red, green, blue].pack("C3")
206
+ alpha << opacity.chr
207
+ end
208
+ Image.new(width: width, height: height, color_space: :rgb,
209
+ data: rgb, alpha: alpha.each_byte.all? { |value| value == 255 } ? nil : alpha)
210
+ end
211
+
212
+ def outline_path(outline)
213
+ path = Path.new
214
+ current = start = [0.0, 0.0]
215
+ outline.each do |operation, *values|
216
+ case operation
217
+ when :move_to
218
+ current = start = values
219
+ path.move_to(values[0], @page.height - values[1])
220
+ when :line_to
221
+ current = values
222
+ path.line_to(values[0], @page.height - values[1])
223
+ when :quad_to
224
+ cx, cy, x, y = values
225
+ x1 = current[0] + (cx - current[0]) * 2 / 3.0
226
+ y1 = current[1] + (cy - current[1]) * 2 / 3.0
227
+ x2 = x + (cx - x) * 2 / 3.0
228
+ y2 = y + (cy - y) * 2 / 3.0
229
+ path.curve_to(x1, @page.height - y1, x2, @page.height - y2, x, @page.height - y)
230
+ current = [x, y]
231
+ when :cubic_to
232
+ x1, y1, x2, y2, x, y = values
233
+ path.curve_to(x1, @page.height - y1, x2, @page.height - y2, x, @page.height - y)
234
+ current = [x, y]
235
+ when :close
236
+ path.close
237
+ current = start
238
+ else raise ArgumentError, "unsupported outline command #{operation}"
239
+ end
240
+ end
241
+ path
242
+ end
243
+
244
+ def rounded_rect(bounds, radii)
245
+ x, y, width, height = bounds.x, bounds.y, bounds.width, bounds.height
246
+ tl, tr, br, bl = radii.map { |radius| radius.clamp(0, [width, height].min / 2.0) }
247
+ path = Path.new
248
+ point = ->(px, py) { [px, @page.height - py] }
249
+ path.move_to(*point.call(x + tl, y))
250
+ path.line_to(*point.call(x + width - tr, y))
251
+ path.curve_to(*point.call(x + width - tr + KAPPA * tr, y),
252
+ *point.call(x + width, y + tr - KAPPA * tr), *point.call(x + width, y + tr)) if tr.positive?
253
+ path.line_to(*point.call(x + width, y + height - br))
254
+ path.curve_to(*point.call(x + width, y + height - br + KAPPA * br),
255
+ *point.call(x + width - br + KAPPA * br, y + height), *point.call(x + width - br, y + height)) if br.positive?
256
+ path.line_to(*point.call(x + bl, y + height))
257
+ path.curve_to(*point.call(x + bl - KAPPA * bl, y + height),
258
+ *point.call(x, y + height - bl + KAPPA * bl), *point.call(x, y + height - bl)) if bl.positive?
259
+ path.line_to(*point.call(x, y + tl))
260
+ path.curve_to(*point.call(x, y + tl - KAPPA * tl),
261
+ *point.call(x + tl - KAPPA * tl, y), *point.call(x + tl, y)) if tl.positive?
262
+ path.close
263
+ end
264
+
265
+ def inset_bounds(bounds, widths)
266
+ top, right, bottom, left = widths
267
+ ::Zaniah::Bounds.new(bounds.x + left, bounds.y + top,
268
+ bounds.width - left - right, bounds.height - top - bottom)
269
+ end
270
+
271
+ def paint_fill(path, color, opacity, rule: :nonzero)
272
+ return unless color.a.positive?
273
+ with_alpha(color.a * opacity) { @page.path(path).fill(rgb(color), rule: rule.to_sym) }
274
+ end
275
+
276
+ def paint_stroke(path, color, width, opacity, cap: :butt, join: :miter, miter: 10, dash: nil)
277
+ return unless color.a.positive?
278
+ with_alpha(color.a * opacity) do
279
+ @page.path(path).stroke(rgb(color), width: width, cap: cap || :butt,
280
+ join: join || :miter, miter: miter || 10, dash: dash)
281
+ end
282
+ end
283
+
284
+ def with_alpha(alpha)
285
+ return if alpha <= 0
286
+ alpha < 1 ? @page.opacity(alpha) { yield } : yield
287
+ end
288
+
289
+ def rgb(color) = [color.r, color.g, color.b]
290
+ end
291
+ end
data/sig/okab.rbs CHANGED
@@ -37,6 +37,7 @@ module Okab
37
37
  attr_reader document: Document
38
38
  def initialize: (Document document, width: Numeric, height: Numeric) -> void
39
39
  def text: (String string, x: Numeric, y: Numeric, font: Font | EmbeddedFont | Alhena::Font, size: Numeric, ?color: Array[Numeric], ?tracking: Numeric, ?bold: bool, ?italic: bool) -> self
40
+ def glyph: (Integer glyph_id, x: Numeric, y: Numeric, font: Font | Alhena::Font, size: Numeric, unicode: String, ?color: Array[Numeric]) -> self
40
41
  def text_block: (String string, x: Numeric, y: Numeric, width: Numeric, font: Font | EmbeddedFont | Alhena::Font, size: Numeric, line_height: Numeric, ?align: Symbol, ?color: Array[Numeric], ?tracking: Numeric) -> self
41
42
  def move_to: (Numeric x, Numeric y) -> self
42
43
  def line_to: (Numeric x, Numeric y) -> self
@@ -44,10 +45,12 @@ module Okab
44
45
  def close: () -> self
45
46
  def rect: (Numeric x, Numeric y, Numeric width, Numeric height) -> self
46
47
  def ellipse: (Numeric x, Numeric y, Numeric width, Numeric height) -> self
47
- def fill: (Array[Numeric] color) -> self
48
- def stroke: (Array[Numeric] color, ?width: Numeric) -> self
48
+ def path: (Path outline) -> self
49
+ def fill: (Array[Numeric] color, ?rule: :nonzero | :evenodd) -> self
50
+ def stroke: (Array[Numeric] color, ?width: Numeric, ?cap: :butt | :round | :square, ?join: :miter | :round | :bevel, ?miter: Numeric, ?dash: Array[Numeric]?) -> self
49
51
  def fill_and_stroke: (Array[Numeric] color, ?width: Numeric) -> self
50
52
  def clip: () { (Path) -> void } -> self
53
+ def with_clip: (Path path, ?rule: :nonzero | :evenodd) { (Page) -> void } -> self
51
54
  def transform: (Numeric a, Numeric b, Numeric c, Numeric d, Numeric e, Numeric f) { (Page) -> void } -> self
52
55
  def opacity: (Numeric value) { (Page) -> void } -> self
53
56
  def image: (String | Image data, x: Numeric, y: Numeric, width: Numeric, height: Numeric, ?format: Symbol) -> self
@@ -71,6 +74,7 @@ module Okab
71
74
  def face: () -> Alhena::Font
72
75
  def measure: (String text, size: Numeric) -> Float
73
76
  def encode: (String string) -> String
77
+ def encode_glyph: (Integer glyph, unicode: String) -> String
74
78
  def glyph_ids: () -> Array[Integer]
75
79
  def subset_font: () -> [String, Alhena::Font, Hash[Integer, Integer], bool]
76
80
  def characters: () -> Hash[Integer, [String, Integer]]
@@ -88,4 +92,8 @@ module Okab
88
92
  def self.decode: (String bytes, ?format: Symbol) -> Image
89
93
  def initialize: (width: Integer, height: Integer, color_space: Symbol, data: String, ?alpha: String?, ?filter: Symbol?, ?decode_parms: String?) -> void
90
94
  end
95
+
96
+ class ZaniahVector
97
+ def self.draw: (Page page, untyped vector_document) -> Page
98
+ end
91
99
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: alhena
@@ -55,6 +56,7 @@ files:
55
56
  - LICENSE.txt
56
57
  - README.md
57
58
  - Rakefile
59
+ - docs/zaniah-vector.md
58
60
  - lib/okab.rb
59
61
  - lib/okab/document.rb
60
62
  - lib/okab/font.rb
@@ -62,6 +64,7 @@ files:
62
64
  - lib/okab/page.rb
63
65
  - lib/okab/pdf/writer.rb
64
66
  - lib/okab/version.rb
67
+ - lib/okab/zaniah_vector.rb
65
68
  - sig/okab.rbs
66
69
  homepage: https://github.com/noxdea/okab
67
70
  licenses:
@@ -71,6 +74,7 @@ metadata:
71
74
  source_code_uri: https://github.com/noxdea/okab/tree/main
72
75
  changelog_uri: https://github.com/noxdea/okab/blob/main/CHANGELOG.md
73
76
  rubygems_mfa_required: 'true'
77
+ post_install_message:
74
78
  rdoc_options: []
75
79
  require_paths:
76
80
  - lib
@@ -85,7 +89,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
89
  - !ruby/object:Gem::Version
86
90
  version: '0'
87
91
  requirements: []
88
- rubygems_version: 4.0.16
92
+ rubygems_version: 3.4.19
93
+ signing_key:
89
94
  specification_version: 4
90
95
  summary: Generate searchable, font-embedded PDF documents
91
96
  test_files: []