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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +86 -22
- data/docs/zaniah-vector.md +26 -0
- data/lib/okab/document.rb +1 -1
- data/lib/okab/font.rb +16 -0
- data/lib/okab/page.rb +47 -2
- data/lib/okab/version.rb +1 -1
- data/lib/okab/zaniah_vector.rb +291 -0
- data/sig/okab.rbs +10 -2
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a450b67de73b065f7d9a15aefce5a2341640fe4e40f97ccf71d2e0e92cfd7b0e
|
|
4
|
+
data.tar.gz: 3f5cf2767ec1f0f33277917bb2579dec77365771b1c5d480013cd6ad59d2a61a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
8
|
-
- Text
|
|
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
|
-
-
|
|
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
|
-
```
|
|
17
|
-
gem
|
|
41
|
+
```sh
|
|
42
|
+
gem install okab
|
|
18
43
|
```
|
|
19
44
|
|
|
20
|
-
Okab requires Ruby 3.2 or
|
|
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
|
-
|
|
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"
|
|
29
|
-
page = document.page(width: 595, height: 842) # points
|
|
30
|
-
page.text("
|
|
31
|
-
page.text_block("A searchable report with embedded fonts.",
|
|
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
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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
|
@@ -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
|
|
48
|
-
def
|
|
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.
|
|
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:
|
|
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.
|
|
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: []
|